feat: 替换市场数据源为 Tiingo(移除 Alpaca)

This commit is contained in:
虾球 2026-03-06 08:31:55 +00:00
parent b5499ef7fe
commit 14b51e636a
2 changed files with 129 additions and 75 deletions

View File

@ -6,8 +6,8 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Alpaca.Markets" Version="7.2.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
</ItemGroup>
<PropertyGroup>

View File

@ -1,30 +1,30 @@
using Alpaca.Markets;
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 IAlpacaDataClient _dataClient;
private readonly HttpClient _httpClient;
private readonly string _tiingoApiKey;
/// <summary>
/// 构造函数
/// </summary>
/// <param name="logger">日志记录器</param>
public MarketDataService(ILogger<MarketDataService> logger)
/// <param name="httpClientFactory">HTTP 客户端工厂</param>
public MarketDataService(ILogger<MarketDataService> logger, IHttpClientFactory httpClientFactory)
{
_logger = logger;
// 初始化 Alpaca 客户端 (7.2 版本)
var secretKey = new SecretKey("YOUR_API_KEY", "YOUR_SECRET_KEY");
// 使用 Paper Trading 环境进行测试,生产环境请使用 Environments.Live
_dataClient = Environments.Paper.GetAlpacaDataClient(secretKey);
_httpClient = httpClientFactory.CreateClient();
// TODO: 从配置读取 Tiingo API Key
_tiingoApiKey = Environment.GetEnvironmentVariable("TIINGO_API_KEY") ?? "YOUR_TIINGO_API_KEY";
_httpClient.DefaultRequestHeaders.Add("Authorization", $"Token {_tiingoApiKey}");
}
/// <summary>
@ -38,14 +38,22 @@ public class MarketDataService : IMarketDataService
{
_logger.LogInformation($"Requesting stock price for symbol: {symbol}");
var request = new LatestMarketDataRequest(symbol);
var latestTrade = await _dataClient.GetLatestTradeAsync(request);
// Tiingo 最新价格端点
var url = $"https://api.tiingo.com/iex/{symbol}?token={_tiingoApiKey}";
var response = await _httpClient.GetFromJsonAsync<List<TiingoPriceResponse>>(url);
if (response == null || response.Count == 0)
{
throw new Exception($"No data found for {symbol}");
}
var latest = response[0];
return new MarketPriceResponse
{
Symbol = symbol,
Price = latestTrade.Price,
Timestamp = latestTrade.TimestampUtc,
Price = latest.tngoLast ?? latest.close ?? 0,
PreviousClose = latest.prevClose ?? 0,
Timestamp = latest.date ?? DateTime.UtcNow,
AssetType = "Stock"
};
}
@ -59,7 +67,7 @@ public class MarketDataService : IMarketDataService
/// <summary>
/// 获取加密货币实时价格
/// </summary>
/// <param name="symbol">加密货币代码</param>
/// <param name="symbol">加密货币代码(如 BTCUSD</param>
/// <returns>加密货币价格信息</returns>
public async Task<MarketPriceResponse> GetCryptoPriceAsync(string symbol)
{
@ -67,14 +75,22 @@ public class MarketDataService : IMarketDataService
{
_logger.LogInformation($"Requesting crypto price for symbol: {symbol}");
var request = new LatestMarketDataRequest(symbol);
var latestTrade = await _dataClient.GetLatestTradeAsync(request);
// 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 = latestTrade.Price,
Timestamp = latestTrade.TimestampUtc,
Price = latest.close ?? 0,
PreviousClose = 0, // Tiingo crypto 端点没有 prevClose暂时用 0
Timestamp = latest.date ?? DateTime.UtcNow,
AssetType = "Crypto"
};
}
@ -98,32 +114,37 @@ public class MarketDataService : IMarketDataService
{
_logger.LogInformation($"Requesting stock historical data for symbol: {symbol}, timeframe: {timeframe}, limit: {limit}");
var barTimeFrame = GetBarTimeFrame(timeframe);
var endDate = DateTime.UtcNow;
var startDate = CalculateStartDate(endDate, timeframe, limit);
var resampleFreq = GetTiingoResampleFreq(timeframe);
var request = new HistoricalBarsRequest(symbol, startDate, endDate, barTimeFrame);
var barsPage = await _dataClient.GetHistoricalBarsAsync(request);
// Tiingo 历史数据端点
var url = $"https://api.tiingo.com/tiingo/daily/{symbol}/prices?startDate={startDate:yyyy-MM-dd}&endDate={endDate:yyyy-MM-dd}&resampleFreq={resampleFreq}&token={_tiingoApiKey}";
var response = await _httpClient.GetFromJsonAsync<List<TiingoDailyResponse>>(url);
var result = new List<MarketDataResponse>();
foreach (var kvp in barsPage.Items)
if (response == null)
{
foreach (var bar in kvp.Value)
{
result.Add(new MarketDataResponse
{
Symbol = symbol,
Open = bar.Open,
High = bar.High,
Low = bar.Low,
Close = bar.Close,
Volume = bar.Volume,
Timestamp = bar.TimeUtc,
AssetType = "Stock"
});
}
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)
@ -146,32 +167,37 @@ public class MarketDataService : IMarketDataService
{
_logger.LogInformation($"Requesting crypto historical data for symbol: {symbol}, timeframe: {timeframe}, limit: {limit}");
var barTimeFrame = GetBarTimeFrame(timeframe);
var endDate = DateTime.UtcNow;
var startDate = CalculateStartDate(endDate, timeframe, limit);
var resampleFreq = GetTiingoResampleFreq(timeframe);
var request = new HistoricalBarsRequest(symbol, startDate, endDate, barTimeFrame);
var barsPage = await _dataClient.GetHistoricalBarsAsync(request);
// Tiingo 加密货币历史数据端点
var url = $"https://api.tiingo.com/tiingo/crypto/prices?tickers={symbol}&startDate={startDate:yyyy-MM-dd}&endDate={endDate:yyyy-MM-dd}&resampleFreq={resampleFreq}&token={_tiingoApiKey}";
var response = await _httpClient.GetFromJsonAsync<List<TiingoCryptoPriceResponse>>(url);
var result = new List<MarketDataResponse>();
foreach (var kvp in barsPage.Items)
if (response == null || response.Count == 0 || response[0].priceData == null)
{
foreach (var bar in kvp.Value)
{
result.Add(new MarketDataResponse
{
Symbol = symbol,
Open = bar.Open,
High = bar.High,
Low = bar.Low,
Close = bar.Close,
Volume = bar.Volume,
Timestamp = bar.TimeUtc,
AssetType = "Crypto"
});
}
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)
@ -182,32 +208,26 @@ public class MarketDataService : IMarketDataService
}
/// <summary>
/// 转换时间周期
/// 转换为 Tiingo 的 resampleFreq
/// </summary>
/// <param name="timeframe">时间周期字符串</param>
/// <returns>BarTimeFrame 对象</returns>
private BarTimeFrame GetBarTimeFrame(string timeframe)
private string GetTiingoResampleFreq(string timeframe)
{
return timeframe.ToLower() switch
{
"1min" => BarTimeFrame.Minute,
"5min" => BarTimeFrame.Minute,
"15min" => BarTimeFrame.Minute,
"1h" => BarTimeFrame.Hour,
"1d" => BarTimeFrame.Day,
"1w" => BarTimeFrame.Week,
"1m" => BarTimeFrame.Month,
_ => BarTimeFrame.Day
"1min" => "1min",
"5min" => "5min",
"15min" => "15min",
"1h" => "1hour",
"1d" => "daily",
"1w" => "weekly",
"1m" => "monthly",
_ => "daily"
};
}
/// <summary>
/// 计算开始日期
/// </summary>
/// <param name="endDate">结束日期</param>
/// <param name="timeframe">时间周期</param>
/// <param name="limit">数据点数量</param>
/// <returns>开始日期</returns>
private DateTime CalculateStartDate(DateTime endDate, string timeframe, int limit)
{
return timeframe.ToLower() switch
@ -222,4 +242,38 @@ public class MarketDataService : IMarketDataService
_ => 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; }
}