353 lines
12 KiB
C#
353 lines
12 KiB
C#
using System.Net.Http.Json;
|
||
using System.Text.Json;
|
||
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 string _tiingoApiKey;
|
||
|
||
/// <summary>
|
||
/// 构造函数
|
||
/// </summary>
|
||
/// <param name="logger">日志记录器</param>
|
||
/// <param name="httpClientFactory">HTTP 客户端工厂</param>
|
||
public MarketDataService(ILogger<MarketDataService> logger, IHttpClientFactory httpClientFactory)
|
||
{
|
||
_logger = logger;
|
||
_httpClient = httpClientFactory.CreateClient();
|
||
// 完全从环境变量读取 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);
|
||
|
||
if (assetType.Equals("Crypto", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return await GetCryptoPriceAsync(symbol);
|
||
}
|
||
else
|
||
{
|
||
// 默认走股票数据源
|
||
return await GetStockPriceAsync(symbol);
|
||
}
|
||
}
|
||
|
||
/// <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);
|
||
|
||
if (assetType.Equals("Crypto", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return await GetCryptoHistoricalDataAsync(symbol, timeframe, limit);
|
||
}
|
||
else
|
||
{
|
||
// 默认走股票数据源
|
||
return await GetStockHistoricalDataAsync(symbol, timeframe, limit);
|
||
}
|
||
}
|
||
|
||
/// <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)
|
||
};
|
||
}
|
||
}
|
||
|
||
// 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; }
|
||
}
|