fix: 行情获取失败时不写入错误数据,添加 429 限流重试机制

1. GetHistoricalPriceAsync 返回 decimal? 而非 decimal
   - 价格获取失败时返回 null 而非 0
2. BackfillNavHistoryInternalAsync 检查价格有效性
   - 任何持仓价格获取失败时跳过该日期
   - 不写入 totalValue=0 的错误数据
3. MarketDataService 添加 GetWithRetryAsync 方法
   - 处理 429 Too Many Requests 限流
   - 最多重试 3 次,指数退避
This commit is contained in:
OpenClaw Agent 2026-03-15 10:02:52 +00:00
parent 6a757f56da
commit da86aa43e6
2 changed files with 70 additions and 7 deletions

View File

@ -167,6 +167,40 @@ public class MarketDataService : IMarketDataService
}
}
/// <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)
{
// 429 限流,等待后重试
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} 次");
}
/// <summary>
/// 获取股票历史数据
/// </summary>
@ -185,15 +219,18 @@ public class MarketDataService : IMarketDataService
// 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);
// 使用带重试的请求
var response = await GetWithRetryAsync(url);
var data = await response.Content.ReadFromJsonAsync<List<TiingoDailyResponse>>();
if (response == null)
if (data == null)
{
return new List<MarketDataResponse>();
}
// 取最近 limit 条
var result = response
var result = data
.OrderByDescending(x => x.date)
.Take(limit)
.OrderBy(x => x.date)

View File

@ -373,15 +373,34 @@ public class PortfolioNavService : IPortfolioNavService
// 计算当日市值
decimal totalValue = 0;
bool hasValidPrice = true;
List<string> failedSymbols = new List<string>();
foreach (var (stockCode, (shares, cost, currency, assetType)) in holdings)
{
decimal price = await GetHistoricalPriceAsync(stockCode, assetType ?? "Stock", date);
var priceResult = await GetHistoricalPriceAsync(stockCode, assetType ?? "Stock", date);
if (priceResult == null || priceResult <= 0)
{
hasValidPrice = false;
failedSymbols.Add(stockCode);
continue;
}
decimal price = priceResult.Value;
decimal positionValue = shares * price;
decimal positionValueInTarget = await _exchangeRateService.ConvertAmountAsync(
positionValue, currency ?? targetCurrency, targetCurrency);
totalValue += positionValueInTarget;
}
// 如果有任何持仓价格获取失败,跳过该日期,不写入错误数据
if (!hasValidPrice)
{
_logger.LogWarning("跳过日期 {Date},以下持仓价格获取失败: {Symbols}",
date.ToString("yyyy-MM-dd"), string.Join(", ", failedSymbols));
continue;
}
// 计算净值
decimal nav = cumulativeCost > 0 ? totalValue / cumulativeCost : 1.0m;
decimal cumulativeReturn = cumulativeCost > 0 ? (totalValue - cumulativeCost) / cumulativeCost * 100 : 0;
@ -452,7 +471,7 @@ public class PortfolioNavService : IPortfolioNavService
/// <summary>
/// 获取历史价格
/// </summary>
private async Task<decimal> GetHistoricalPriceAsync(string symbol, string assetType, DateTime date)
private async Task<decimal?> GetHistoricalPriceAsync(string symbol, string assetType, DateTime date)
{
try
{
@ -478,12 +497,19 @@ public class PortfolioNavService : IPortfolioNavService
// 最后尝试获取实时价格
var currentPrice = await _marketDataService.GetPriceAsync(symbol, assetType);
return currentPrice.Price;
if (currentPrice != null && currentPrice.Price > 0)
{
return currentPrice.Price;
}
// 无法获取有效价格
_logger.LogWarning("无法获取有效价格: {Symbol}, {Date}", symbol, date);
return null;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "获取历史价格失败: {Symbol}, {Date}", symbol, date);
return 0;
return null;
}
}