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;
///
/// 市场数据服务实现(基于 Tiingo)
///
public class MarketDataService : IMarketDataService
{
private readonly ILogger _logger;
private readonly HttpClient _httpClient;
private readonly DatabaseService _databaseService;
private readonly string _tiingoApiKey;
///
/// 构造函数
///
/// 日志记录器
/// HTTP 客户端工厂
/// 数据库服务
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")
?? "bd00fee76d3012b047473078904001b33322cb46";
_httpClient.DefaultRequestHeaders.Add("Authorization", $"Token {_tiingoApiKey}");
}
///
/// 获取股票实时价格
///
/// 股票代码
/// 股票价格信息
public async Task 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>(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;
}
}
///
/// 获取加密货币实时价格
///
/// 加密货币代码(如 BTC-USDT)
/// 加密货币价格信息
public async Task 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(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;
}
}
///
/// 获取股票历史数据
///
/// 股票代码
/// 时间周期
/// 数据点数量
/// 历史数据列表
public async Task> 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>(url);
if (response == null)
{
return new List();
}
// 取最近 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;
}
}
///
/// 获取加密货币历史数据
///
/// 加密货币代码(如 BTC-USDT)
/// 时间周期
/// 数据点数量
/// 历史数据列表
public async Task> 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(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();
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;
}
}
///
/// 转换 timeframe 为 OKX 支持的 bar 格式
///
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"
};
}
///
/// 获取实时价格(自动根据资产类型路由到对应数据源)
///
public async Task 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()
.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;
}
///
/// 获取历史数据(自动根据资产类型路由到对应数据源)
///
public async Task> 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()
.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))
{
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;
}
///
/// 计算开始日期
///
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)
};
}
///
/// 生成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 响应模型
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? 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 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 data { get; set; }
}