AssetManager.API/AssetManager.Infrastructure/Services/MarketDataService.cs

503 lines
18 KiB
C#
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 System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using AssetManager.Data;
using AssetManager.Models.DTOs;
using Microsoft.Extensions.Logging;
namespace AssetManager.Infrastructure.Services;
/// <summary>
/// 市场数据服务实现(基于 Tiingo
/// </summary>
public class MarketDataService : IMarketDataService
{
private readonly ILogger<MarketDataService> _logger;
private readonly HttpClient _httpClient;
private readonly DatabaseService _databaseService;
private readonly string _tiingoApiKey;
/// <summary>
/// 构造函数
/// </summary>
/// <param name="logger">日志记录器</param>
/// <param name="httpClientFactory">HTTP 客户端工厂</param>
/// <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")
?? "bd00fee76d3012b047473078904001b33322cb46";
_httpClient.DefaultRequestHeaders.Add("Authorization", $"Token {_tiingoApiKey}");
}
/// <summary>
/// 获取股票实时价格
/// </summary>
/// <param name="symbol">股票代码</param>
/// <returns>股票价格信息</returns>
public async Task<MarketPriceResponse> GetStockPriceAsync(string symbol)
{
try
{
_logger.LogInformation($"Requesting stock price for symbol: {symbol}");
// Tiingo 日线最新价格端点取最近1条
var url = $"https://api.tiingo.com/tiingo/daily/{symbol}/prices?token={_tiingoApiKey}";
var response = await _httpClient.GetFromJsonAsync<List<TiingoDailyResponse>>(url);
if (response == null || response.Count == 0)
{
throw new Exception($"No data found for {symbol}");
}
var latest = response[^1];
decimal? prevClose = response.Count >= 2 ? response[^2].close : null;
return new MarketPriceResponse
{
Symbol = symbol,
Price = latest.close ?? 0,
PreviousClose = prevClose ?? 0,
Timestamp = latest.date ?? DateTime.UtcNow,
AssetType = "Stock"
};
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error getting stock price for {symbol}");
throw;
}
}
/// <summary>
/// 获取加密货币实时价格
/// </summary>
/// <param name="symbol">加密货币代码(如 BTC-USDT</param>
/// <returns>加密货币价格信息</returns>
public async Task<MarketPriceResponse> GetCryptoPriceAsync(string symbol)
{
try
{
_logger.LogInformation($"Requesting crypto price for symbol: {symbol}");
// OKX 实时 ticker 接口symbol 格式如 BTC-USDT
var url = $"https://www.okx.com/api/v5/market/ticker?instId={symbol}";
var response = await _httpClient.GetFromJsonAsync<OkxTickerResponse>(url);
if (response == null || response.code != "0" || response.data == null || response.data.Count == 0)
{
throw new Exception($"No data found for {symbol}, code: {response?.code}, msg: {response?.msg}");
}
var latest = response.data[0];
return new MarketPriceResponse
{
Symbol = symbol,
Price = decimal.TryParse(latest.last, out var price) ? price : 0,
PreviousClose = decimal.TryParse(latest.sodUtc0, out var prevClose) ? prevClose : 0, // UTC0 开盘价作为昨日收盘价
Timestamp = DateTime.UtcNow,
AssetType = "Crypto"
};
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error getting crypto price for {symbol}");
throw;
}
}
/// <summary>
/// 获取股票历史数据
/// </summary>
/// <param name="symbol">股票代码</param>
/// <param name="timeframe">时间周期</param>
/// <param name="limit">数据点数量</param>
/// <returns>历史数据列表</returns>
public async Task<List<MarketDataResponse>> GetStockHistoricalDataAsync(string symbol, string timeframe, int limit)
{
try
{
_logger.LogInformation($"Requesting stock historical data for symbol: {symbol}, timeframe: {timeframe}, limit: {limit}");
var endDate = DateTime.UtcNow;
var startDate = CalculateStartDate(endDate, timeframe, limit);
// Tiingo 历史数据端点(和示例一致)
var url = $"https://api.tiingo.com/tiingo/daily/{symbol}/prices?startDate={startDate:yyyy-MM-dd}&endDate={endDate:yyyy-MM-dd}&token={_tiingoApiKey}";
var response = await _httpClient.GetFromJsonAsync<List<TiingoDailyResponse>>(url);
if (response == null)
{
return new List<MarketDataResponse>();
}
// 取最近 limit 条
var result = response
.OrderByDescending(x => x.date)
.Take(limit)
.OrderBy(x => x.date)
.Select(x => new MarketDataResponse
{
Symbol = symbol,
Open = x.open ?? 0,
High = x.high ?? 0,
Low = x.low ?? 0,
Close = x.close ?? 0,
Volume = x.volume ?? 0,
Timestamp = x.date ?? DateTime.UtcNow,
AssetType = "Stock"
})
.ToList();
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error getting stock historical data for {symbol}");
throw;
}
}
/// <summary>
/// 获取加密货币历史数据
/// </summary>
/// <param name="symbol">加密货币代码(如 BTC-USDT</param>
/// <param name="timeframe">时间周期</param>
/// <param name="limit">数据点数量</param>
/// <returns>历史数据列表</returns>
public async Task<List<MarketDataResponse>> GetCryptoHistoricalDataAsync(string symbol, string timeframe, int limit)
{
try
{
_logger.LogInformation($"Requesting crypto historical data for symbol: {symbol}, timeframe: {timeframe}, limit: {limit}");
// 转换 timeframe 为 OKX 支持的粒度
var bar = ConvertToOkxBar(timeframe);
// OKX K线接口
var url = $"https://www.okx.com/api/v5/market/candles?instId={symbol}&bar={bar}&limit={limit}";
var response = await _httpClient.GetFromJsonAsync<OkxCandlesResponse>(url);
if (response == null || response.code != "0" || response.data == null)
{
throw new Exception($"No data found for {symbol}, code: {response?.code}, msg: {response?.msg}");
}
var result = new List<MarketDataResponse>();
foreach (var item in response.data)
{
if (item.Length < 6) continue;
if (long.TryParse(item[0], out var ts) &&
decimal.TryParse(item[1], out var open) &&
decimal.TryParse(item[2], out var high) &&
decimal.TryParse(item[3], out var low) &&
decimal.TryParse(item[4], out var close) &&
decimal.TryParse(item[5], out var volume))
{
result.Add(new MarketDataResponse
{
Symbol = symbol,
Open = open,
High = high,
Low = low,
Close = close,
Volume = volume,
Timestamp = DateTimeOffset.FromUnixTimeMilliseconds(ts).UtcDateTime,
AssetType = "Crypto"
});
}
}
// 按时间升序排列
result = result.OrderBy(x => x.Timestamp).ToList();
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error getting crypto historical data for {symbol}");
throw;
}
}
/// <summary>
/// 转换 timeframe 为 OKX 支持的 bar 格式
/// </summary>
private string ConvertToOkxBar(string timeframe)
{
return timeframe.ToLower() switch
{
"1min" => "1m",
"5min" => "5m",
"15min" => "15m",
"1h" => "1H",
"4h" => "4H",
"1d" => "1D",
"1w" => "1W",
"1m" => "1M",
_ => "1D"
};
}
/// <summary>
/// 获取实时价格(自动根据资产类型路由到对应数据源)
/// </summary>
public async Task<MarketPriceResponse> GetPriceAsync(string symbol, string 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))
{
response = await GetCryptoPriceAsync(symbol);
source = "OKX";
}
else
{
// 默认走股票数据源
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>
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 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))
{
response = await GetCryptoHistoricalDataAsync(symbol, timeframe, limit);
source = "OKX";
}
else
{
// 默认走股票数据源
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>
private DateTime CalculateStartDate(DateTime endDate, string timeframe, int limit)
{
return timeframe.ToLower() switch
{
"1min" => endDate.AddMinutes(-limit),
"5min" => endDate.AddMinutes(-limit * 5),
"15min" => endDate.AddMinutes(-limit * 15),
"1h" => endDate.AddHours(-limit),
"1d" => endDate.AddDays(-limit),
"1w" => endDate.AddDays(-limit * 7),
"1m" => endDate.AddMonths(-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)
{
if (assetType.Equals("crypto", StringComparison.OrdinalIgnoreCase))
{
return DateTime.Now.AddMinutes(1); // 加密货币7*24小时交易缓存1分钟
}
// 美股交易时段9:30-16:00 美国东部时间 = 14:30-21:00 UTC夏令时/15:30-22:00 UTC冬令时
// 简化处理UTC时间14:00-22:00视为美股交易时段
var nowUtc = DateTime.UtcNow;
if (nowUtc.Hour >= 14 && nowUtc.Hour < 22 && nowUtc.DayOfWeek is not DayOfWeek.Saturday and not DayOfWeek.Sunday)
{
return DateTime.Now.AddMinutes(15); // 交易时段缓存15分钟
}
return DateTime.Now.AddHours(24); // 非交易时段(盘前/盘后/周末缓存24小时节省API调用
}
}
// Tiingo 响应模型
internal class TiingoPriceResponse
{
public decimal? tngoLast { get; set; }
public decimal? close { get; set; }
public decimal? prevClose { get; set; }
public DateTime? date { get; set; }
}
internal class TiingoDailyResponse
{
public decimal? open { get; set; }
public decimal? high { get; set; }
public decimal? low { get; set; }
public decimal? close { get; set; }
public decimal? volume { get; set; }
public DateTime? date { get; set; }
}
internal class TiingoCryptoPriceResponse
{
public List<TiingoCryptoBar>? priceData { get; set; }
}
internal class TiingoCryptoBar
{
public decimal? open { get; set; }
public decimal? high { get; set; }
public decimal? low { get; set; }
public decimal? close { get; set; }
public decimal? volume { get; set; }
public DateTime? date { get; set; }
}
// OKX 响应模型
internal class OkxTickerResponse
{
public string code { get; set; }
public string msg { get; set; }
public List<OkxTickerData> data { get; set; }
}
internal class OkxTickerData
{
public string instId { get; set; }
public string last { get; set; }
public string sodUtc0 { get; set; } // UTC0 开盘价,作为昨日收盘价
}
internal class OkxCandlesResponse
{
public string code { get; set; }
public string msg { get; set; }
public List<string[]> data { get; set; }
}