实现行情缓存系统:实时价格+历史K线双层缓存,减少API调用提升响应速度
This commit is contained in:
parent
fb5faeee81
commit
949fa8e85b
@ -19,7 +19,9 @@ public class DatabaseService
|
|||||||
typeof(Portfolio),
|
typeof(Portfolio),
|
||||||
typeof(Position),
|
typeof(Position),
|
||||||
typeof(Transaction),
|
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.Net.Http.Json;
|
||||||
using System.Text.Json;
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using AssetManager.Data;
|
||||||
using AssetManager.Models.DTOs;
|
using AssetManager.Models.DTOs;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
@ -12,6 +14,7 @@ public class MarketDataService : IMarketDataService
|
|||||||
{
|
{
|
||||||
private readonly ILogger<MarketDataService> _logger;
|
private readonly ILogger<MarketDataService> _logger;
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly DatabaseService _databaseService;
|
||||||
private readonly string _tiingoApiKey;
|
private readonly string _tiingoApiKey;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -19,10 +22,15 @@ public class MarketDataService : IMarketDataService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="logger">日志记录器</param>
|
/// <param name="logger">日志记录器</param>
|
||||||
/// <param name="httpClientFactory">HTTP 客户端工厂</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;
|
_logger = logger;
|
||||||
_httpClient = httpClientFactory.CreateClient();
|
_httpClient = httpClientFactory.CreateClient();
|
||||||
|
_databaseService = databaseService;
|
||||||
// 完全从环境变量读取 Tiingo API Key
|
// 完全从环境变量读取 Tiingo API Key
|
||||||
_tiingoApiKey = Environment.GetEnvironmentVariable("Tiingo__ApiKey")
|
_tiingoApiKey = Environment.GetEnvironmentVariable("Tiingo__ApiKey")
|
||||||
?? Environment.GetEnvironmentVariable("TIINGO_API_KEY")
|
?? Environment.GetEnvironmentVariable("TIINGO_API_KEY")
|
||||||
@ -246,15 +254,59 @@ public class MarketDataService : IMarketDataService
|
|||||||
{
|
{
|
||||||
_logger.LogInformation("获取实时价格: {Symbol}, 资产类型: {AssetType}", symbol, assetType);
|
_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))
|
if (assetType.Equals("Crypto", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return await GetCryptoPriceAsync(symbol);
|
response = await GetCryptoPriceAsync(symbol);
|
||||||
|
source = "OKX";
|
||||||
}
|
}
|
||||||
else
|
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>
|
/// <summary>
|
||||||
@ -265,15 +317,76 @@ public class MarketDataService : IMarketDataService
|
|||||||
_logger.LogInformation("获取历史数据: {Symbol}, 资产类型: {AssetType}, 周期: {Timeframe}, 数量: {Limit}",
|
_logger.LogInformation("获取历史数据: {Symbol}, 资产类型: {AssetType}, 周期: {Timeframe}, 数量: {Limit}",
|
||||||
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))
|
if (assetType.Equals("Crypto", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return await GetCryptoHistoricalDataAsync(symbol, timeframe, limit);
|
response = await GetCryptoHistoricalDataAsync(symbol, timeframe, limit);
|
||||||
|
source = "OKX";
|
||||||
}
|
}
|
||||||
else
|
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>
|
/// <summary>
|
||||||
@ -293,6 +406,35 @@ public class MarketDataService : IMarketDataService
|
|||||||
_ => endDate.AddDays(-limit)
|
_ => 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 响应模型
|
// Tiingo 响应模型
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user