P0 - 安全修复: - 移除硬编码 API Key,启动时校验必填环境变量 P1 - 高优先级: - Entity 拆分:Position.cs, Transaction.cs 独立文件 - Controller Facade 封装:IPortfolioFacade 减少依赖注入 P2 - 中优先级: - Repository 抽象:IPortfolioRepository, IMarketDataRepository - MarketDataService 拆分:组合模式整合 Tencent/Tiingo/OKX P3 - 低优先级: - DTO 命名规范:统一 PascalCase - 单元测试框架:xUnit + Moq + FluentAssertions
165 lines
5.7 KiB
C#
165 lines
5.7 KiB
C#
using System.Net.Http.Json;
|
||
using AssetManager.Models.DTOs;
|
||
using Microsoft.Extensions.Logging;
|
||
|
||
namespace AssetManager.Infrastructure.Services;
|
||
|
||
/// <summary>
|
||
/// Tiingo 市场数据服务接口
|
||
/// </summary>
|
||
public interface ITiingoMarketService
|
||
{
|
||
/// <summary>
|
||
/// 获取股票实时价格
|
||
/// </summary>
|
||
Task<MarketPriceResponse> GetStockPriceAsync(string symbol);
|
||
|
||
/// <summary>
|
||
/// 获取股票历史数据
|
||
/// </summary>
|
||
Task<List<MarketDataResponse>> GetStockHistoricalDataAsync(string symbol, string timeframe, int limit);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Tiingo 市场数据服务实现
|
||
/// </summary>
|
||
public class TiingoMarketService : ITiingoMarketService
|
||
{
|
||
private readonly ILogger<TiingoMarketService> _logger;
|
||
private readonly HttpClient _httpClient;
|
||
private readonly string _apiKey;
|
||
|
||
public TiingoMarketService(ILogger<TiingoMarketService> logger, IHttpClientFactory httpClientFactory)
|
||
{
|
||
_logger = logger;
|
||
_httpClient = httpClientFactory.CreateClient();
|
||
|
||
// 从环境变量读取 Tiingo API Key(必填)
|
||
_apiKey = Environment.GetEnvironmentVariable("Tiingo__ApiKey")
|
||
?? Environment.GetEnvironmentVariable("TIINGO_API_KEY")
|
||
?? throw new InvalidOperationException("Tiingo__ApiKey 环境变量未配置");
|
||
|
||
_httpClient.DefaultRequestHeaders.Add("Authorization", $"Token {_apiKey}");
|
||
}
|
||
|
||
public async Task<MarketPriceResponse> GetStockPriceAsync(string symbol)
|
||
{
|
||
_logger.LogInformation("Tiingo 获取股票价格: {Symbol}", symbol);
|
||
|
||
// Tiingo 日线最新价格端点(取最近1条)
|
||
var url = $"https://api.tiingo.com/tiingo/daily/{symbol}/prices?token={_apiKey}";
|
||
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"
|
||
};
|
||
}
|
||
|
||
public async Task<List<MarketDataResponse>> GetStockHistoricalDataAsync(string symbol, string timeframe, int limit)
|
||
{
|
||
_logger.LogInformation("Tiingo 获取历史数据: {Symbol}", symbol);
|
||
|
||
var endDate = DateTime.UtcNow;
|
||
var startDate = CalculateStartDate(endDate, timeframe, limit);
|
||
|
||
var url = $"https://api.tiingo.com/tiingo/daily/{symbol}/prices?startDate={startDate:yyyy-MM-dd}&endDate={endDate:yyyy-MM-dd}&token={_apiKey}";
|
||
|
||
var response = await GetWithRetryAsync(url);
|
||
var data = await response.Content.ReadFromJsonAsync<List<TiingoDailyResponse>>();
|
||
|
||
if (data == null)
|
||
{
|
||
return new List<MarketDataResponse>();
|
||
}
|
||
|
||
return data
|
||
.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();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 带重试的 HTTP GET 请求(处理 429 限流)
|
||
/// </summary>
|
||
private async Task<HttpResponseMessage> GetWithRetryAsync(string url, int maxRetries = 3)
|
||
{
|
||
for (int i = 0; i < maxRetries; i++)
|
||
{
|
||
try
|
||
{
|
||
var response = await _httpClient.GetAsync(url);
|
||
|
||
if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
|
||
{
|
||
var retryAfter = response.Headers.RetryAfter?.Delta?.TotalSeconds ?? 2 * (i + 1);
|
||
_logger.LogWarning("Tiingo API 限流,等待 {Seconds} 秒后重试 (第 {Attempt}/{Max} 次)",
|
||
retryAfter, i + 1, maxRetries);
|
||
await Task.Delay((int)(retryAfter * 1000));
|
||
continue;
|
||
}
|
||
|
||
response.EnsureSuccessStatusCode();
|
||
return response;
|
||
}
|
||
catch (HttpRequestException ex) when (i < maxRetries - 1)
|
||
{
|
||
_logger.LogWarning(ex, "HTTP 请求失败,重试中 (第 {Attempt}/{Max} 次)", i + 1, maxRetries);
|
||
await Task.Delay(1000 * (i + 1));
|
||
}
|
||
}
|
||
|
||
throw new HttpRequestException($"请求失败,已重试 {maxRetries} 次");
|
||
}
|
||
|
||
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 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; }
|
||
}
|