262 lines
9.1 KiB
C#
262 lines
9.1 KiB
C#
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();
|
||
// TODO: 从配置读取 Tiingo API Key
|
||
_tiingoApiKey = 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">加密货币代码(如 BTCUSD)</param>
|
||
/// <returns>加密货币价格信息</returns>
|
||
public async Task<MarketPriceResponse> GetCryptoPriceAsync(string symbol)
|
||
{
|
||
try
|
||
{
|
||
_logger.LogInformation($"Requesting crypto price for symbol: {symbol}");
|
||
|
||
// Tiingo 加密货币最新价格端点
|
||
var url = $"https://api.tiingo.com/tiingo/crypto/prices?tickers={symbol}&token={_tiingoApiKey}";
|
||
var response = await _httpClient.GetFromJsonAsync<List<TiingoCryptoPriceResponse>>(url);
|
||
|
||
if (response == null || response.Count == 0 || response[0].priceData == null || response[0].priceData.Count == 0)
|
||
{
|
||
throw new Exception($"No data found for {symbol}");
|
||
}
|
||
|
||
var latest = response[0].priceData[0];
|
||
return new MarketPriceResponse
|
||
{
|
||
Symbol = symbol,
|
||
Price = latest.close ?? 0,
|
||
PreviousClose = 0, // Tiingo crypto 端点没有 prevClose,暂时用 0
|
||
Timestamp = latest.date ?? 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">加密货币代码</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}");
|
||
|
||
var endDate = DateTime.UtcNow;
|
||
var startDate = CalculateStartDate(endDate, timeframe, limit);
|
||
|
||
// Tiingo 加密货币历史数据端点(不带 resampleFreq)
|
||
var url = $"https://api.tiingo.com/tiingo/crypto/prices?tickers={symbol}&startDate={startDate:yyyy-MM-dd}&endDate={endDate:yyyy-MM-dd}&token={_tiingoApiKey}";
|
||
var response = await _httpClient.GetFromJsonAsync<List<TiingoCryptoPriceResponse>>(url);
|
||
|
||
if (response == null || response.Count == 0 || response[0].priceData == null)
|
||
{
|
||
return new List<MarketDataResponse>();
|
||
}
|
||
|
||
// 取最近 limit 条
|
||
var result = response[0].priceData!
|
||
.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 = "Crypto"
|
||
})
|
||
.ToList();
|
||
|
||
return result;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, $"Error getting crypto historical data for {symbol}");
|
||
throw;
|
||
}
|
||
}
|
||
|
||
/// <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; }
|
||
}
|