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 响应模型