using System.Text; using System.Text.Json; using AssetManager.Data; using AssetManager.Models.DTOs; using Microsoft.Extensions.Logging; namespace AssetManager.Infrastructure.Services; /// /// 腾讯财经市场数据服务接口 /// public interface ITencentMarketService { /// /// 获取股票实时价格 /// Task GetStockPriceAsync(string symbol); /// /// 获取股票历史K线数据 /// ⚠️ 注意:腾讯历史K线接口 (web.ifzq.gtimg.cn) 已废弃,此方法会抛出异常 /// [Obsolete("腾讯历史K线接口已废弃,请使用 Yahoo 或 Tiingo")] Task> GetStockHistoricalDataAsync(string symbol, string timeframe, int limit); } /// /// 腾讯财经市场数据服务实现 /// 实时价格接口 (qt.gtimg.cn) 正常可用 /// 历史K线接口 (web.ifzq.gtimg.cn) 已废弃 /// public class TencentMarketService : ITencentMarketService { private readonly ILogger _logger; private readonly HttpClient _httpClient; public TencentMarketService(ILogger logger, IHttpClientFactory httpClientFactory) { _logger = logger; _httpClient = httpClientFactory.CreateClient(); // 注册GBK编码支持 Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); } public async Task 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> 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(); 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; } }