AssetManager.API/AssetManager.Infrastructure/Services/MarketDataService.cs
niannian zheng 2a6512ff48 feat(市场数据): 添加Yahoo财经服务并设为优先数据源
- 新增YahooMarketService实现股票实时价格和历史数据获取
- 更新MarketDataService优先使用Yahoo服务,腾讯财经降级为第二选择
- 添加YahooQuotesApi依赖并更新相关NuGet包版本
- 补充Yahoo服务测试用例
2026-03-17 12:06:47 +08:00

262 lines
9.5 KiB
C#
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using AssetManager.Data;
using AssetManager.Data.Repositories;
using AssetManager.Models.DTOs;
using Microsoft.Extensions.Logging;
namespace AssetManager.Infrastructure.Services;
/// <summary>
/// 市场数据服务实现组合模式Yahoo财经优先腾讯财经降级Tiingo最终降级OKX处理加密货币
/// </summary>
public class MarketDataService : IMarketDataService
{
private readonly ILogger<MarketDataService> _logger;
private readonly ITencentMarketService _tencentService;
private readonly IYahooMarketService _yahooService;
private readonly ITiingoMarketService _tiingoService;
private readonly IOkxMarketService _okxService;
private readonly IMarketDataRepository _marketDataRepo;
public MarketDataService(
ILogger<MarketDataService> logger,
ITencentMarketService tencentService,
IYahooMarketService yahooService,
ITiingoMarketService tiingoService,
IOkxMarketService okxService,
IMarketDataRepository marketDataRepo)
{
_logger = logger;
_tencentService = tencentService;
_yahooService = yahooService;
_tiingoService = tiingoService;
_okxService = okxService;
_marketDataRepo = marketDataRepo;
}
/// <summary>
/// 获取实时价格(自动根据资产类型路由到对应数据源)
/// </summary>
public async Task<MarketPriceResponse> GetPriceAsync(string symbol, string assetType)
{
_logger.LogInformation("获取实时价格: {Symbol}, 资产类型: {AssetType}", symbol, assetType);
// 先查缓存
var cached = await _marketDataRepo.GetPriceCacheAsync(symbol, assetType);
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))
{
response = await _okxService.GetCryptoPriceAsync(symbol);
source = "OKX";
}
else
{
// 股票优先Yahoo财经失败降级腾讯财经最后降级 Tiingo
try
{
response = await _yahooService.GetStockPriceAsync(symbol);
source = "Yahoo";
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Yahoo财经获取失败降级使用 腾讯: {Symbol}", symbol);
try
{
response = await _tencentService.GetStockPriceAsync(symbol);
source = "Tencent";
}
catch (Exception tencentEx)
{
_logger.LogWarning(tencentEx, "腾讯财经获取失败,降级使用 Tiingo: {Symbol}", symbol);
response = await _tiingoService.GetStockPriceAsync(symbol);
source = "Tiingo";
}
}
}
// 写入缓存
await SavePriceCacheAsync(symbol, assetType, response, source);
return response;
}
/// <summary>
/// 获取历史数据(自动根据资产类型路由到对应数据源)
/// </summary>
public async Task<List<MarketDataResponse>> GetHistoricalDataAsync(string symbol, string assetType, string timeframe, int limit)
{
_logger.LogInformation("获取历史数据: {Symbol}, 资产类型: {AssetType}, 周期: {Timeframe}, 数量: {Limit}",
symbol, assetType, timeframe, limit);
// 先查缓存
var cachedKlines = await _marketDataRepo.GetKlineCacheAsync(symbol, assetType, timeframe, limit);
// 缓存足够,直接返回
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))
{
response = await _okxService.GetCryptoHistoricalDataAsync(symbol, timeframe, limit);
source = "OKX";
}
else
{
// 股票优先Yahoo财经失败降级腾讯财经最后降级 Tiingo
try
{
response = await _yahooService.GetStockHistoricalDataAsync(symbol, timeframe, limit);
if (response.Count > 0)
{
source = "Yahoo";
}
else
{
throw new Exception("Yahoo财经返回空数据");
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Yahoo财经历史数据获取失败降级使用 腾讯: {Symbol}", symbol);
try
{
response = await _tencentService.GetStockHistoricalDataAsync(symbol, timeframe, limit);
if (response.Count > 0)
{
source = "Tencent";
}
else
{
throw new Exception("腾讯财经返回空数据");
}
}
catch (Exception tencentEx)
{
_logger.LogWarning(tencentEx, "腾讯财经历史数据获取失败,降级使用 Tiingo: {Symbol}", symbol);
response = await _tiingoService.GetStockHistoricalDataAsync(symbol, timeframe, limit);
source = "Tiingo";
}
}
}
// 批量写入缓存
await SaveKlineCacheAsync(symbol, assetType, timeframe, response, source);
return response;
}
/// <summary>
/// 获取股票实时价格
/// </summary>
public async Task<MarketPriceResponse> GetStockPriceAsync(string symbol)
{
return await GetPriceAsync(symbol, "Stock");
}
/// <summary>
/// 获取加密货币实时价格
/// </summary>
public async Task<MarketPriceResponse> GetCryptoPriceAsync(string symbol)
{
return await GetPriceAsync(symbol, "Crypto");
}
/// <summary>
/// 获取股票历史数据
/// </summary>
public async Task<List<MarketDataResponse>> GetStockHistoricalDataAsync(string symbol, string timeframe, int limit)
{
return await GetHistoricalDataAsync(symbol, "Stock", timeframe, limit);
}
/// <summary>
/// 获取加密货币历史数据
/// </summary>
public async Task<List<MarketDataResponse>> GetCryptoHistoricalDataAsync(string symbol, string timeframe, int limit)
{
return await GetHistoricalDataAsync(symbol, "Crypto", timeframe, limit);
}
// ===== 私有辅助方法 =====
private async Task SavePriceCacheAsync(string symbol, string assetType, MarketPriceResponse response, string source)
{
var cacheEntity = new MarketPriceCache
{
Symbol = symbol.ToUpper(),
AssetType = assetType.ToUpper(),
Price = Math.Round(response.Price, 8),
PreviousClose = response.PreviousClose > 0 ? Math.Round(response.PreviousClose, 8) : null,
Source = source,
FetchedAt = DateTime.Now,
ExpiredAt = GetCacheExpirationTime(assetType)
};
await _marketDataRepo.SavePriceCacheAsync(cacheEntity);
_logger.LogDebug("缓存写入: {Symbol} {AssetType}, 过期时间: {ExpiredAt}", symbol, assetType, cacheEntity.ExpiredAt);
}
private async Task SaveKlineCacheAsync(string symbol, string assetType, string timeframe, List<MarketDataResponse> data, string source)
{
var cacheEntities = data.Select(k => new MarketKlineCache
{
Symbol = symbol.ToUpper(),
AssetType = assetType.ToUpper(),
Timeframe = timeframe.ToUpper(),
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 _marketDataRepo.SaveKlineCacheBatchAsync(cacheEntities);
_logger.LogDebug("历史K线缓存写入: {Symbol} {AssetType} {Timeframe}, 数量: {Count}", symbol, assetType, timeframe, cacheEntities.Count);
}
private DateTime GetCacheExpirationTime(string assetType)
{
return assetType.ToLower() switch
{
"crypto" => DateTime.Now.AddMinutes(1),
_ => DateTime.Now.AddMinutes(5)
};
}
}