改进: 1. 修复引号解析逻辑(更健壮) 2. 减少字段验证从 36 到 5(只需价格字段) 3. 添加详细日志追踪解析过程 4. 记录请求 URL 和原始响应 日志关键词: - [腾讯财经] 请求URL - [腾讯财经] 原始响应 - [腾讯财经] 字段数量 - [腾讯财经] 成功
204 lines
7.7 KiB
C#
204 lines
7.7 KiB
C#
using System.Text;
|
||
using System.Text.Json;
|
||
using AssetManager.Data;
|
||
using AssetManager.Models.DTOs;
|
||
using Microsoft.Extensions.Logging;
|
||
|
||
namespace AssetManager.Infrastructure.Services;
|
||
|
||
/// <summary>
|
||
/// 腾讯财经市场数据服务接口
|
||
/// </summary>
|
||
public interface ITencentMarketService
|
||
{
|
||
/// <summary>
|
||
/// 获取股票实时价格
|
||
/// </summary>
|
||
Task<MarketPriceResponse> GetStockPriceAsync(string symbol);
|
||
|
||
/// <summary>
|
||
/// 获取股票历史K线数据
|
||
/// <para>⚠️ 注意:腾讯历史K线接口 (web.ifzq.gtimg.cn) 已废弃,此方法会抛出异常</para>
|
||
/// </summary>
|
||
[Obsolete("腾讯历史K线接口已废弃,请使用 Yahoo 或 Tiingo")]
|
||
Task<List<MarketDataResponse>> GetStockHistoricalDataAsync(string symbol, string timeframe, int limit);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 腾讯财经市场数据服务实现
|
||
/// <para>实时价格接口 (qt.gtimg.cn) 正常可用</para>
|
||
/// <para>历史K线接口 (web.ifzq.gtimg.cn) 已废弃</para>
|
||
/// </summary>
|
||
public class TencentMarketService : ITencentMarketService
|
||
{
|
||
private readonly ILogger<TencentMarketService> _logger;
|
||
private readonly HttpClient _httpClient;
|
||
|
||
public TencentMarketService(ILogger<TencentMarketService> logger, IHttpClientFactory httpClientFactory)
|
||
{
|
||
_logger = logger;
|
||
_httpClient = httpClientFactory.CreateClient();
|
||
|
||
// 注册GBK编码支持
|
||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||
}
|
||
|
||
public async Task<MarketPriceResponse> GetStockPriceAsync(string symbol)
|
||
{
|
||
_logger.LogInformation("[腾讯财经] 获取股票价格: {Symbol}", symbol);
|
||
|
||
// 腾讯财经美股接口:前缀us,大写代码
|
||
var url = $"http://qt.gtimg.cn/q=us{symbol.ToUpper()}";
|
||
_logger.LogInformation("[腾讯财经] 请求URL: {Url}", url);
|
||
|
||
var responseBytes = await _httpClient.GetByteArrayAsync(url);
|
||
var response = Encoding.GetEncoding("GBK").GetString(responseBytes);
|
||
_logger.LogDebug("[腾讯财经] 原始响应: {Response}", response);
|
||
|
||
if (string.IsNullOrEmpty(response))
|
||
{
|
||
throw new Exception($"腾讯财经接口返回空数据,标的: {symbol}");
|
||
}
|
||
|
||
// 解析返回数据:v_usUPRO="200~..." 格式
|
||
var quoteStart = response.IndexOf('"');
|
||
var quoteEnd = response.LastIndexOf('"');
|
||
|
||
if (quoteStart < 0 || quoteEnd <= quoteStart)
|
||
{
|
||
throw new Exception($"腾讯财经接口返回格式错误(无引号),标的: {symbol},响应: {response.Substring(0, Math.Min(200, response.Length))}");
|
||
}
|
||
|
||
var dataPart = response.Substring(quoteStart + 1, quoteEnd - quoteStart - 1);
|
||
var parts = dataPart.Split('~');
|
||
|
||
_logger.LogDebug("[腾讯财经] 字段数量: {Count}, parts[3]={Price}, parts[4]={PrevClose}", parts.Length, parts.Length > 3 ? parts[3] : "N/A", parts.Length > 4 ? parts[4] : "N/A");
|
||
|
||
if (parts.Length < 5)
|
||
{
|
||
throw new Exception($"腾讯财经返回字段不足,标的: {symbol},字段数: {parts.Length}");
|
||
}
|
||
|
||
// 提取字段:[3]=最新价 [4]=昨收价
|
||
if (!decimal.TryParse(parts[3], out var currentPrice) || currentPrice <= 0)
|
||
{
|
||
throw new Exception($"解析最新价失败,标的: {symbol},返回值: {parts[3]}");
|
||
}
|
||
|
||
if (!decimal.TryParse(parts[4], out var prevClose) || prevClose <= 0)
|
||
{
|
||
prevClose = currentPrice; // 解析失败用当前价当昨收
|
||
}
|
||
|
||
_logger.LogInformation("[腾讯财经] 成功: {Symbol} 最新价={CurrentPrice},昨收价={PrevClose}",
|
||
symbol, currentPrice, prevClose);
|
||
|
||
return new MarketPriceResponse
|
||
{
|
||
Symbol = symbol,
|
||
Price = currentPrice,
|
||
PreviousClose = prevClose,
|
||
Timestamp = DateTime.UtcNow,
|
||
AssetType = "Stock"
|
||
};
|
||
}
|
||
|
||
public async Task<List<MarketDataResponse>> GetStockHistoricalDataAsync(string symbol, string timeframe, int limit)
|
||
{
|
||
_logger.LogInformation("获取腾讯财经历史数据: {Symbol}", symbol);
|
||
|
||
// 腾讯财经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¶m=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))
|
||
{
|
||
throw new Exception($"腾讯财经历史数据接口返回空数据,标的: {symbol}");
|
||
}
|
||
|
||
// 解析JSON
|
||
var jsonStart = response.IndexOf('{');
|
||
if (jsonStart < 0)
|
||
{
|
||
throw new Exception($"腾讯财经历史数据接口返回非JSON格式,标的: {symbol}");
|
||
}
|
||
|
||
var jsonStr = response.Substring(jsonStart);
|
||
using var jsonDoc = JsonDocument.Parse(jsonStr);
|
||
var root = jsonDoc.RootElement;
|
||
|
||
// 检查API错误响应
|
||
if (root.TryGetProperty("code", out var codeEl) && codeEl.GetInt32() != 0)
|
||
{
|
||
var errMsg = root.TryGetProperty("msg", out var msgEl) ? msgEl.GetString() : "未知错误";
|
||
throw new Exception($"腾讯财经历史数据接口错误: {errMsg},标的: {symbol}");
|
||
}
|
||
|
||
// 检查 data 字段类型(错误响应时 data 是空数组 [])
|
||
if (!root.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Object)
|
||
{
|
||
throw new Exception($"腾讯财经历史数据接口返回无效数据结构,标的: {symbol}");
|
||
}
|
||
|
||
// 数据路径: data -> us{symbol} -> qfq -> {klineType}
|
||
var dataPath = $"us{symbol.ToUpper()}";
|
||
if (!data.TryGetProperty(dataPath, out var stockData) ||
|
||
!stockData.TryGetProperty("qfq", out var qfq))
|
||
{
|
||
throw new Exception($"腾讯财经历史数据接口无该标的K线数据,标的: {symbol}");
|
||
}
|
||
|
||
if (!qfq.TryGetProperty(klineType, out var klineData) || klineData.ValueKind != JsonValueKind.Array)
|
||
{
|
||
throw new Exception($"腾讯财经历史数据接口K线数据格式异常,标的: {symbol}");
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|