547 lines
19 KiB
C#
547 lines
19 KiB
C#
using System.Net.Http.Json;
|
||
using System.Security.Cryptography;
|
||
using System.Text;
|
||
using System.Text.RegularExpressions;
|
||
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} (腾讯财经接口)");
|
||
|
||
// 注册GBK编码支持
|
||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||
|
||
// 腾讯财经美股接口:前缀us,大写代码
|
||
var url = $"http://qt.gtimg.cn/q=us{symbol.ToUpper()}";
|
||
var responseBytes = await _httpClient.GetByteArrayAsync(url);
|
||
var response = Encoding.GetEncoding("GBK").GetString(responseBytes);
|
||
|
||
if (string.IsNullOrEmpty(response) || !response.Contains("~"))
|
||
{
|
||
throw new Exception($"腾讯财经接口返回无效数据,标的: {symbol}");
|
||
}
|
||
|
||
// 解析返回数据
|
||
var parts = response.Split('"')[1].Split('~');
|
||
if (parts.Length < 36)
|
||
{
|
||
throw new Exception($"腾讯财经返回字段不足,标的: {symbol}");
|
||
}
|
||
|
||
// 提取字段:[3]=最新价 [4]=昨收价
|
||
if (!decimal.TryParse(parts[3], out var currentPrice) || currentPrice <= 0)
|
||
{
|
||
throw new Exception($"解析最新价失败,标的: {symbol},返回值: {parts[3]}");
|
||
}
|
||
|
||
if (!decimal.TryParse(parts[4], out var prevClose) || prevClose <= 0)
|
||
{
|
||
prevClose = currentPrice; // 解析失败用当前价当昨收
|
||
}
|
||
|
||
_logger.LogDebug("腾讯财经接口返回 {Symbol}:最新价 {CurrentPrice},昨收价 {PrevClose},涨跌额 {Change}",
|
||
symbol, currentPrice, prevClose, currentPrice - prevClose);
|
||
|
||
return new MarketPriceResponse
|
||
{
|
||
Symbol = symbol,
|
||
Price = currentPrice,
|
||
PreviousClose = prevClose,
|
||
Timestamp = DateTime.UtcNow,
|
||
AssetType = "Stock"
|
||
};
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, $"腾讯财经接口获取价格失败,标的: {symbol},降级使用Tiingo接口");
|
||
// 降级使用Tiingo接口
|
||
return await GetStockPriceFromTiingoAsync(symbol);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 从Tiingo获取股票价格(降级用)
|
||
/// </summary>
|
||
private async Task<MarketPriceResponse> GetStockPriceFromTiingoAsync(string symbol)
|
||
{
|
||
_logger.LogInformation($"Requesting stock price for symbol: {symbol} (Tiingo接口)");
|
||
|
||
// 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"
|
||
};
|
||
}
|
||
|
||
/// <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";
|
||
}
|
||
|
||
// 写入缓存,保留8位小数
|
||
var cacheEntity = new MarketPriceCache
|
||
{
|
||
Id = cacheKey,
|
||
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 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)
|
||
{
|
||
return assetType.ToLower() switch
|
||
{
|
||
"crypto" => DateTime.Now.AddMinutes(1), // 加密货币缓存1分钟
|
||
_ => DateTime.Now.AddMinutes(5) // 股票固定缓存5分钟
|
||
};
|
||
}
|
||
}
|
||
|
||
// 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; }
|
||
}
|