using System.Net.Http.Json;
using AssetManager.Models.DTOs;
using Microsoft.Extensions.Logging;
namespace AssetManager.Infrastructure.Services;
///
/// Tiingo 市场数据服务接口
///
public interface ITiingoMarketService
{
///
/// 获取股票实时价格
///
Task GetStockPriceAsync(string symbol);
///
/// 获取股票历史数据
///
Task> GetStockHistoricalDataAsync(string symbol, string timeframe, int limit);
}
///
/// Tiingo 市场数据服务实现
///
public class TiingoMarketService : ITiingoMarketService
{
private readonly ILogger _logger;
private readonly HttpClient _httpClient;
private readonly string _apiKey;
public TiingoMarketService(ILogger 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 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>(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> 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>();
if (data == null)
{
return new List();
}
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();
}
///
/// 带重试的 HTTP GET 请求(处理 429 限流)
///
private async Task 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; }
}