diff --git a/AssetManager.Data/DatabaseService.cs b/AssetManager.Data/DatabaseService.cs index 0c45506..c88a531 100644 --- a/AssetManager.Data/DatabaseService.cs +++ b/AssetManager.Data/DatabaseService.cs @@ -19,7 +19,9 @@ public class DatabaseService typeof(Portfolio), typeof(Position), typeof(Transaction), - typeof(TiingoTicker) + typeof(TiingoTicker), + typeof(MarketPriceCache), + typeof(MarketKlineCache) ); } diff --git a/AssetManager.Data/MarketKlineCache.cs b/AssetManager.Data/MarketKlineCache.cs new file mode 100644 index 0000000..cd75e72 --- /dev/null +++ b/AssetManager.Data/MarketKlineCache.cs @@ -0,0 +1,82 @@ +using SqlSugar; + +namespace AssetManager.Data; + +/// +/// 历史K线缓存表(永久存储) +/// +[SugarTable("market_kline_cache")] +public class MarketKlineCache +{ + /// + /// 主键:md5(symbol + asset_type + timeframe + timestamp) + /// + [SugarColumn(IsPrimaryKey = true, Length = 64)] + public string Id { get; set; } = string.Empty; + + /// + /// 标的代码(如 AAPL、BTC-USDT) + /// + [SugarColumn(Length = 32, IsNullable = false)] + public string Symbol { get; set; } = string.Empty; + + /// + /// 资产类型:Stock/Crypto + /// + [SugarColumn(Length = 16, IsNullable = false, DefaultValue = "Stock")] + public string AssetType { get; set; } = "Stock"; + + /// + /// 时间周期:1min/5min/1h/1d/1w等 + /// + [SugarColumn(Length = 16, IsNullable = false)] + public string Timeframe { get; set; } = string.Empty; + + /// + /// K线时间 + /// + [SugarColumn(IsNullable = false)] + public DateTime Timestamp { get; set; } + + /// + /// 开盘价 + /// + [SugarColumn(DecimalDigits = 8, IsNullable = false)] + public decimal Open { get; set; } + + /// + /// 最高价 + /// + [SugarColumn(DecimalDigits = 8, IsNullable = false)] + public decimal High { get; set; } + + /// + /// 最低价 + /// + [SugarColumn(DecimalDigits = 8, IsNullable = false)] + public decimal Low { get; set; } + + /// + /// 收盘价 + /// + [SugarColumn(DecimalDigits = 8, IsNullable = false)] + public decimal Close { get; set; } + + /// + /// 成交量 + /// + [SugarColumn(DecimalDigits = 8, IsNullable = true)] + public decimal? Volume { get; set; } + + /// + /// 数据源:Tiingo/OKX/Mock + /// + [SugarColumn(Length = 32, IsNullable = false)] + public string Source { get; set; } = string.Empty; + + /// + /// 获取时间 + /// + [SugarColumn(IsNullable = false)] + public DateTime FetchedAt { get; set; } +} diff --git a/AssetManager.Data/MarketPriceCache.cs b/AssetManager.Data/MarketPriceCache.cs new file mode 100644 index 0000000..a264d99 --- /dev/null +++ b/AssetManager.Data/MarketPriceCache.cs @@ -0,0 +1,58 @@ +using SqlSugar; + +namespace AssetManager.Data; + +/// +/// 实时价格缓存表 +/// +[SugarTable("market_price_cache")] +public class MarketPriceCache +{ + /// + /// 主键:md5(symbol + asset_type) + /// + [SugarColumn(IsPrimaryKey = true, Length = 32)] + public string Id { get; set; } = string.Empty; + + /// + /// 标的代码(如 AAPL、BTC-USDT) + /// + [SugarColumn(Length = 32, IsNullable = false)] + public string Symbol { get; set; } = string.Empty; + + /// + /// 资产类型:Stock/Crypto + /// + [SugarColumn(Length = 16, IsNullable = false, DefaultValue = "Stock")] + public string AssetType { get; set; } = "Stock"; + + /// + /// 最新价格 + /// + [SugarColumn(DecimalDigits = 8, IsNullable = false)] + public decimal Price { get; set; } + + /// + /// 前收盘价(计算当日涨跌幅用) + /// + [SugarColumn(DecimalDigits = 8, IsNullable = true)] + public decimal? PreviousClose { get; set; } + + /// + /// 数据源:Tiingo/OKX/Mock + /// + [SugarColumn(Length = 32, IsNullable = false)] + public string Source { get; set; } = string.Empty; + + /// + /// 数据获取时间 + /// + [SugarColumn(IsNullable = false)] + public DateTime FetchedAt { get; set; } + + /// + /// 过期时间 + /// + [SugarColumn(IsNullable = false)] + public DateTime ExpiredAt { get; set; } +} diff --git a/AssetManager.Infrastructure/Services/MarketDataService.cs b/AssetManager.Infrastructure/Services/MarketDataService.cs index fd11dc4..0311547 100644 --- a/AssetManager.Infrastructure/Services/MarketDataService.cs +++ b/AssetManager.Infrastructure/Services/MarketDataService.cs @@ -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 _logger; private readonly HttpClient _httpClient; + private readonly DatabaseService _databaseService; private readonly string _tiingoApiKey; /// @@ -19,10 +22,15 @@ public class MarketDataService : IMarketDataService /// /// 日志记录器 /// HTTP 客户端工厂 - public MarketDataService(ILogger logger, IHttpClientFactory httpClientFactory) + /// 数据库服务 + public MarketDataService( + ILogger 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() + .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; } /// @@ -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() + .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 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; } /// @@ -293,6 +406,35 @@ public class MarketDataService : IMarketDataService _ => endDate.AddDays(-limit) }; } + + /// + /// 生成MD5哈希(用于缓存键) + /// + 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(); + } + + /// + /// 获取缓存过期时间 + /// + 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 响应模型