feat: 添加腾讯财经历史K线数据接口

- 新增 GetStockHistoricalFromTencentAsync 方法
- 腾讯财经历史数据免费无限制,解决 Tiingo 429 限流问题
- 获取历史数据时优先使用腾讯财经,失败后降级 Tiingo
- 支持日/周/月K线数据
- 数据格式: [日期, 开盘, 收盘, 最高, 最低, 成交量]
This commit is contained in:
OpenClaw Agent 2026-03-15 10:06:14 +00:00
parent da86aa43e6
commit 267b0bd6ba

View File

@ -1,6 +1,7 @@
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using AssetManager.Data; using AssetManager.Data;
using AssetManager.Models.DTOs; using AssetManager.Models.DTOs;
@ -202,7 +203,7 @@ public class MarketDataService : IMarketDataService
} }
/// <summary> /// <summary>
/// 获取股票历史数据 /// 获取股票历史数据(优先使用腾讯财经,免费无限制)
/// </summary> /// </summary>
/// <param name="symbol">股票代码</param> /// <param name="symbol">股票代码</param>
/// <param name="timeframe">时间周期</param> /// <param name="timeframe">时间周期</param>
@ -212,15 +213,137 @@ public class MarketDataService : IMarketDataService
{ {
try try
{ {
_logger.LogInformation($"Requesting stock historical data for symbol: {symbol}, timeframe: {timeframe}, limit: {limit}"); // 优先使用腾讯财经(免费无限制)
var tencentData = await GetStockHistoricalFromTencentAsync(symbol, timeframe, limit);
if (tencentData.Any())
{
return tencentData;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "腾讯财经历史数据获取失败,降级使用 Tiingo: {Symbol}", symbol);
}
// 降级使用 Tiingo
return await GetStockHistoricalFromTiingoAsync(symbol, timeframe, limit);
}
/// <summary>
/// 从腾讯财经获取历史K线数据
/// </summary>
private async Task<List<MarketDataResponse>> GetStockHistoricalFromTencentAsync(string symbol, string timeframe, int limit)
{
try
{
_logger.LogInformation("获取腾讯财经历史数据: {Symbol}", symbol);
// 注册GBK编码支持
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
// 腾讯财经K线接口
// us{symbol} 表示美股day/week/month 表示日/周/月K线
var klineType = timeframe switch
{
"1d" or "daily" => "day",
"1w" or "weekly" => "week",
"1M" or "monthly" => "month",
_ => "day"
};
var url = $"http://web.ifzq.gtimg.cn/appstock/app/fqkline/get?_var=kline_{klineType}qfq&param=us{symbol.ToUpper()},{klineType},,{Math.Min(limit, 320)}&qrcode=1&asrd=1";
var responseBytes = await _httpClient.GetByteArrayAsync(url);
var response = Encoding.GetEncoding("GBK").GetString(responseBytes);
if (string.IsNullOrEmpty(response) || !response.Contains("kline"))
{
_logger.LogWarning("腾讯财经返回无效数据: {Symbol}", symbol);
return new List<MarketDataResponse>();
}
// 解析JSON
var jsonStart = response.IndexOf('{');
if (jsonStart < 0) return new List<MarketDataResponse>();
var jsonStr = response.Substring(jsonStart);
var jsonDoc = JsonDocument.Parse(jsonStr);
var root = jsonDoc.RootElement;
// 数据路径: data -> us{symbol} -> qfq -> {klineType}
var dataPath = $"us{symbol.ToUpper()}";
if (!root.TryGetProperty("data", out var data) ||
!data.TryGetProperty(dataPath, out var stockData) ||
!stockData.TryGetProperty("qfq", out var qfq))
{
_logger.LogWarning("腾讯财经数据结构异常: {Symbol}", symbol);
return new List<MarketDataResponse>();
}
if (!qfq.TryGetProperty(klineType, out var klineData) || klineData.ValueKind != JsonValueKind.Array)
{
_logger.LogWarning("腾讯财经无K线数据: {Symbol}", symbol);
return new List<MarketDataResponse>();
}
var result = new List<MarketDataResponse>();
foreach (var item in klineData.EnumerateArray())
{
// 数据格式: [日期, 开盘, 收盘, 最高, 最低, 成交量]
var arr = item.EnumerateArray().ToList();
if (arr.Count < 6) continue;
var dateStr = arr[0].GetString();
if (!DateTime.TryParse(dateStr, out var date)) continue;
var open = arr[1].GetDecimal();
var close = arr[2].GetDecimal();
var high = arr[3].GetDecimal();
var low = arr[4].GetDecimal();
var volume = arr[5].GetDecimal();
if (close <= 0) continue;
result.Add(new MarketDataResponse
{
Symbol = symbol,
Open = open,
High = high,
Low = low,
Close = close,
Volume = volume,
Timestamp = date,
AssetType = "Stock"
});
}
// 按日期排序
result = result.OrderBy(x => x.Timestamp).TakeLast(limit).ToList();
_logger.LogInformation("腾讯财经获取 {Symbol} 历史数据 {Count} 条", symbol, result.Count);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "腾讯财经历史数据解析失败: {Symbol}", symbol);
return new List<MarketDataResponse>();
}
}
/// <summary>
/// 从 Tiingo 获取历史数据(降级方案)
/// </summary>
private async Task<List<MarketDataResponse>> GetStockHistoricalFromTiingoAsync(string symbol, string timeframe, int limit)
{
try
{
_logger.LogInformation($"Requesting stock historical data from Tiingo: {symbol}");
var endDate = DateTime.UtcNow; var endDate = DateTime.UtcNow;
var startDate = CalculateStartDate(endDate, timeframe, limit); 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 url = $"https://api.tiingo.com/tiingo/daily/{symbol}/prices?startDate={startDate:yyyy-MM-dd}&endDate={endDate:yyyy-MM-dd}&token={_tiingoApiKey}";
// 使用带重试的请求
var response = await GetWithRetryAsync(url); var response = await GetWithRetryAsync(url);
var data = await response.Content.ReadFromJsonAsync<List<TiingoDailyResponse>>(); var data = await response.Content.ReadFromJsonAsync<List<TiingoDailyResponse>>();
@ -229,8 +352,7 @@ public class MarketDataService : IMarketDataService
return new List<MarketDataResponse>(); return new List<MarketDataResponse>();
} }
// 取最近 limit 条 return data
var result = data
.OrderByDescending(x => x.date) .OrderByDescending(x => x.date)
.Take(limit) .Take(limit)
.OrderBy(x => x.date) .OrderBy(x => x.date)
@ -246,12 +368,10 @@ public class MarketDataService : IMarketDataService
AssetType = "Stock" AssetType = "Stock"
}) })
.ToList(); .ToList();
return result;
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, $"Error getting stock historical data for {symbol}"); _logger.LogError(ex, "Tiingo 历史数据获取失败: {Symbol}", symbol);
throw; throw;
} }
} }