P0 - 安全修复: - 移除硬编码 API Key,启动时校验必填环境变量 P1 - 高优先级: - Entity 拆分:Position.cs, Transaction.cs 独立文件 - Controller Facade 封装:IPortfolioFacade 减少依赖注入 P2 - 中优先级: - Repository 抽象:IPortfolioRepository, IMarketDataRepository - MarketDataService 拆分:组合模式整合 Tencent/Tiingo/OKX P3 - 低优先级: - DTO 命名规范:统一 PascalCase - 单元测试框架:xUnit + Moq + FluentAssertions
173 lines
6.1 KiB
C#
173 lines
6.1 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线数据
|
||
/// </summary>
|
||
Task<List<MarketDataResponse>> GetStockHistoricalDataAsync(string symbol, string timeframe, int limit);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 腾讯财经市场数据服务实现(免费无限制,支持盘前盘后)
|
||
/// </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()}";
|
||
var responseBytes = await _httpClient.GetByteArrayAsync(url);
|
||
var response = Encoding.GetEncoding("GBK").GetString(responseBytes);
|
||
|
||
if (string.IsNullOrEmpty(response) || !response.Contains("~"))
|
||
{
|
||
throw new Exception($"腾讯财经接口返回无效数据,标的: {symbol}");
|
||
}
|
||
|
||
// 解析返回数据
|
||
var parts = response.Split('"')[1].Split('~');
|
||
if (parts.Length < 36)
|
||
{
|
||
throw new Exception($"腾讯财经返回字段不足,标的: {symbol}");
|
||
}
|
||
|
||
// 提取字段:[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.LogDebug("腾讯财经接口返回 {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) || !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;
|
||
}
|
||
}
|