实现行情缓存系统:实时价格+历史K线双层缓存,减少API调用提升响应速度

This commit is contained in:
claw_bot 2026-03-10 13:21:54 +00:00
parent fb5faeee81
commit 949fa8e85b
4 changed files with 291 additions and 7 deletions

View File

@ -19,7 +19,9 @@ public class DatabaseService
typeof(Portfolio),
typeof(Position),
typeof(Transaction),
typeof(TiingoTicker)
typeof(TiingoTicker),
typeof(MarketPriceCache),
typeof(MarketKlineCache)
);
}

View File

@ -0,0 +1,82 @@
using SqlSugar;
namespace AssetManager.Data;
/// <summary>
/// 历史K线缓存表永久存储
/// </summary>
[SugarTable("market_kline_cache")]
public class MarketKlineCache
{
/// <summary>
/// 主键md5(symbol + asset_type + timeframe + timestamp)
/// </summary>
[SugarColumn(IsPrimaryKey = true, Length = 64)]
public string Id { get; set; } = string.Empty;
/// <summary>
/// 标的代码(如 AAPL、BTC-USDT
/// </summary>
[SugarColumn(Length = 32, IsNullable = false)]
public string Symbol { get; set; } = string.Empty;
/// <summary>
/// 资产类型Stock/Crypto
/// </summary>
[SugarColumn(Length = 16, IsNullable = false, DefaultValue = "Stock")]
public string AssetType { get; set; } = "Stock";
/// <summary>
/// 时间周期1min/5min/1h/1d/1w等
/// </summary>
[SugarColumn(Length = 16, IsNullable = false)]
public string Timeframe { get; set; } = string.Empty;
/// <summary>
/// K线时间
/// </summary>
[SugarColumn(IsNullable = false)]
public DateTime Timestamp { get; set; }
/// <summary>
/// 开盘价
/// </summary>
[SugarColumn(DecimalDigits = 8, IsNullable = false)]
public decimal Open { get; set; }
/// <summary>
/// 最高价
/// </summary>
[SugarColumn(DecimalDigits = 8, IsNullable = false)]
public decimal High { get; set; }
/// <summary>
/// 最低价
/// </summary>
[SugarColumn(DecimalDigits = 8, IsNullable = false)]
public decimal Low { get; set; }
/// <summary>
/// 收盘价
/// </summary>
[SugarColumn(DecimalDigits = 8, IsNullable = false)]
public decimal Close { get; set; }
/// <summary>
/// 成交量
/// </summary>
[SugarColumn(DecimalDigits = 8, IsNullable = true)]
public decimal? Volume { get; set; }
/// <summary>
/// 数据源Tiingo/OKX/Mock
/// </summary>
[SugarColumn(Length = 32, IsNullable = false)]
public string Source { get; set; } = string.Empty;
/// <summary>
/// 获取时间
/// </summary>
[SugarColumn(IsNullable = false)]
public DateTime FetchedAt { get; set; }
}

View File

@ -0,0 +1,58 @@
using SqlSugar;
namespace AssetManager.Data;
/// <summary>
/// 实时价格缓存表
/// </summary>
[SugarTable("market_price_cache")]
public class MarketPriceCache
{
/// <summary>
/// 主键md5(symbol + asset_type)
/// </summary>
[SugarColumn(IsPrimaryKey = true, Length = 32)]
public string Id { get; set; } = string.Empty;
/// <summary>
/// 标的代码(如 AAPL、BTC-USDT
/// </summary>
[SugarColumn(Length = 32, IsNullable = false)]
public string Symbol { get; set; } = string.Empty;
/// <summary>
/// 资产类型Stock/Crypto
/// </summary>
[SugarColumn(Length = 16, IsNullable = false, DefaultValue = "Stock")]
public string AssetType { get; set; } = "Stock";
/// <summary>
/// 最新价格
/// </summary>
[SugarColumn(DecimalDigits = 8, IsNullable = false)]
public decimal Price { get; set; }
/// <summary>
/// 前收盘价(计算当日涨跌幅用)
/// </summary>
[SugarColumn(DecimalDigits = 8, IsNullable = true)]
public decimal? PreviousClose { get; set; }
/// <summary>
/// 数据源Tiingo/OKX/Mock
/// </summary>
[SugarColumn(Length = 32, IsNullable = false)]
public string Source { get; set; } = string.Empty;
/// <summary>
/// 数据获取时间
/// </summary>
[SugarColumn(IsNullable = false)]
public DateTime FetchedAt { get; set; }
/// <summary>
/// 过期时间
/// </summary>
[SugarColumn(IsNullable = false)]
public DateTime ExpiredAt { get; set; }
}

View File

@ -1,5 +1,7 @@
using System.Net.Http.Json;
using System.Text.Json;
using System.Security.Cryptography;
using System.Text;
using AssetManager.Data;
using AssetManager.Models.DTOs;
using Microsoft.Extensions.Logging;
@ -12,6 +14,7 @@ public class MarketDataService : IMarketDataService
{
private readonly ILogger<MarketDataService> _logger;
private readonly HttpClient _httpClient;
private readonly DatabaseService _databaseService;
private readonly string _tiingoApiKey;
/// <summary>
@ -19,10 +22,15 @@ public class MarketDataService : IMarketDataService
/// </summary>
/// <param name="logger">日志记录器</param>
/// <param name="httpClientFactory">HTTP 客户端工厂</param>
public MarketDataService(ILogger<MarketDataService> logger, IHttpClientFactory httpClientFactory)
/// <param name="databaseService">数据库服务</param>
public MarketDataService(
ILogger<MarketDataService> logger,
IHttpClientFactory httpClientFactory,
DatabaseService databaseService)
{
_logger = logger;
_httpClient = httpClientFactory.CreateClient();
_databaseService = databaseService;
// 完全从环境变量读取 Tiingo API Key
_tiingoApiKey = Environment.GetEnvironmentVariable("Tiingo__ApiKey")
?? Environment.GetEnvironmentVariable("TIINGO_API_KEY")
@ -246,15 +254,59 @@ public class MarketDataService : IMarketDataService
{
_logger.LogInformation("获取实时价格: {Symbol}, 资产类型: {AssetType}", symbol, assetType);
var db = _databaseService.GetDb();
var cacheKey = GenerateMd5Hash($"{symbol.ToUpper()}_{assetType.ToUpper()}");
// 先查缓存
var cached = await db.Queryable<MarketPriceCache>()
.FirstAsync(p => p.Id == cacheKey && p.ExpiredAt > DateTime.Now);
if (cached != null)
{
_logger.LogDebug("缓存命中: {Symbol} {AssetType}, 价格: {Price}", symbol, assetType, cached.Price);
return new MarketPriceResponse
{
Symbol = cached.Symbol,
Price = cached.Price,
PreviousClose = cached.PreviousClose ?? 0,
Timestamp = cached.FetchedAt,
AssetType = cached.AssetType
};
}
// 缓存未命中调用API
MarketPriceResponse response;
string source;
if (assetType.Equals("Crypto", StringComparison.OrdinalIgnoreCase))
{
return await GetCryptoPriceAsync(symbol);
response = await GetCryptoPriceAsync(symbol);
source = "OKX";
}
else
{
// 默认走股票数据源
return await GetStockPriceAsync(symbol);
response = await GetStockPriceAsync(symbol);
source = "Tiingo";
}
// 写入缓存
var cacheEntity = new MarketPriceCache
{
Id = cacheKey,
Symbol = symbol.ToUpper(),
AssetType = assetType.ToUpper(),
Price = response.Price,
PreviousClose = response.PreviousClose,
Source = source,
FetchedAt = DateTime.Now,
ExpiredAt = GetCacheExpirationTime(assetType)
};
// 存在则更新,不存在则插入
await db.Storageable(cacheEntity).ExecuteCommandAsync();
_logger.LogDebug("缓存写入: {Symbol} {AssetType}, 过期时间: {ExpiredAt}", symbol, assetType, cacheEntity.ExpiredAt);
return response;
}
/// <summary>
@ -265,15 +317,76 @@ public class MarketDataService : IMarketDataService
_logger.LogInformation("获取历史数据: {Symbol}, 资产类型: {AssetType}, 周期: {Timeframe}, 数量: {Limit}",
symbol, assetType, timeframe, limit);
var db = _databaseService.GetDb();
var symbolUpper = symbol.ToUpper();
var assetTypeUpper = assetType.ToUpper();
var timeframeUpper = timeframe.ToUpper();
// 先查缓存
var cachedKlines = await db.Queryable<MarketKlineCache>()
.Where(k => k.Symbol == symbolUpper
&& k.AssetType == assetTypeUpper
&& k.Timeframe == timeframeUpper)
.OrderByDescending(k => k.Timestamp)
.Take(limit)
.ToListAsync();
// 缓存足够,直接返回
if (cachedKlines.Count >= limit)
{
_logger.LogDebug("历史K线缓存命中: {Symbol} {AssetType} {Timeframe}, 数量: {Count}", symbol, assetType, timeframe, cachedKlines.Count);
return cachedKlines.Select(k => new MarketDataResponse
{
Symbol = k.Symbol,
Open = k.Open,
High = k.High,
Low = k.Low,
Close = k.Close,
Volume = k.Volume ?? 0,
Timestamp = k.Timestamp,
AssetType = k.AssetType
}).OrderBy(k => k.Timestamp).ToList();
}
// 缓存不足调用API补全
List<MarketDataResponse> response;
string source;
if (assetType.Equals("Crypto", StringComparison.OrdinalIgnoreCase))
{
return await GetCryptoHistoricalDataAsync(symbol, timeframe, limit);
response = await GetCryptoHistoricalDataAsync(symbol, timeframe, limit);
source = "OKX";
}
else
{
// 默认走股票数据源
return await GetStockHistoricalDataAsync(symbol, timeframe, limit);
response = await GetStockHistoricalDataAsync(symbol, timeframe, limit);
source = "Tiingo";
}
// 批量写入缓存(去重)
var cacheEntities = response.Select(k => new MarketKlineCache
{
Id = GenerateMd5Hash($"{symbolUpper}_{assetTypeUpper}_{timeframeUpper}_{k.Timestamp:yyyyMMddHHmmss}"),
Symbol = symbolUpper,
AssetType = assetTypeUpper,
Timeframe = timeframeUpper,
Timestamp = k.Timestamp,
Open = k.Open,
High = k.High,
Low = k.Low,
Close = k.Close,
Volume = k.Volume,
Source = source,
FetchedAt = DateTime.Now
}).ToList();
// 批量插入,存在则忽略
await db.Storageable(cacheEntities)
.WhereColumns(it => new { it.Id })
.ExecuteCommandAsync();
_logger.LogDebug("历史K线缓存写入: {Symbol} {AssetType} {Timeframe}, 数量: {Count}", symbol, assetType, timeframe, cacheEntities.Count);
return response;
}
/// <summary>
@ -293,6 +406,35 @@ public class MarketDataService : IMarketDataService
_ => endDate.AddDays(-limit)
};
}
/// <summary>
/// 生成MD5哈希用于缓存键
/// </summary>
private string GenerateMd5Hash(string input)
{
using var md5 = MD5.Create();
var inputBytes = Encoding.UTF8.GetBytes(input);
var hashBytes = md5.ComputeHash(inputBytes);
var sb = new StringBuilder();
foreach (var b in hashBytes)
{
sb.Append(b.ToString("x2"));
}
return sb.ToString();
}
/// <summary>
/// 获取缓存过期时间
/// </summary>
private DateTime GetCacheExpirationTime(string assetType)
{
return assetType.ToLower() switch
{
"stock" => DateTime.Now.AddMinutes(15), // 股票缓存15分钟
"crypto" => DateTime.Now.AddMinutes(1), // 加密货币缓存1分钟
_ => DateTime.Now.AddMinutes(15)
};
}
}
// Tiingo 响应模型