实现行情缓存系统:实时价格+历史K线双层缓存,减少API调用提升响应速度
This commit is contained in:
parent
fb5faeee81
commit
949fa8e85b
@ -19,7 +19,9 @@ public class DatabaseService
|
||||
typeof(Portfolio),
|
||||
typeof(Position),
|
||||
typeof(Transaction),
|
||||
typeof(TiingoTicker)
|
||||
typeof(TiingoTicker),
|
||||
typeof(MarketPriceCache),
|
||||
typeof(MarketKlineCache)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
82
AssetManager.Data/MarketKlineCache.cs
Normal file
82
AssetManager.Data/MarketKlineCache.cs
Normal 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; }
|
||||
}
|
||||
58
AssetManager.Data/MarketPriceCache.cs
Normal file
58
AssetManager.Data/MarketPriceCache.cs
Normal 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; }
|
||||
}
|
||||
@ -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 响应模型
|
||||
|
||||
Loading…
Reference in New Issue
Block a user