From 53b4f4501e1519826210fe88ed56f9c35b6974e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=99=BE=E7=90=83?= Date: Mon, 9 Mar 2026 06:50:22 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=E4=B8=89=E4=B8=AA?= =?UTF-8?q?=E4=BC=98=E5=85=88=E7=BA=A7=E4=BB=BB=E5=8A=A1=EF=BC=9A1.=20?= =?UTF-8?q?=E6=B1=87=E7=8E=87=E6=9C=8D=E5=8A=A1=E6=9B=BF=E6=8D=A2=E4=B8=BA?= =?UTF-8?q?=E7=9C=9F=E5=AE=9E=E6=95=B0=E6=8D=AE=E6=BA=90=EF=BC=88=E5=B8=A6?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E5=92=8C=E9=99=8D=E7=BA=A7=EF=BC=89=EF=BC=9B?= =?UTF-8?q?2.=20MarketDataService=E5=A2=9E=E5=8A=A0=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E8=87=AA=E5=8A=A8=E8=B7=AF=E7=94=B1=E5=A4=9A?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E6=BA=90=EF=BC=9B3.=20=E7=AE=80=E5=8C=96Port?= =?UTF-8?q?folioService=E8=B0=83=E7=94=A8=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AssetManager.API/Program.cs | 5 +- .../Services/ExchangeRateService.cs | 135 ++++++++++++++++++ .../Services/IMarketDataService.cs | 18 +++ .../Services/MarketDataService.cs | 37 +++++ AssetManager.Services/PortfolioService.cs | 36 +---- 5 files changed, 199 insertions(+), 32 deletions(-) create mode 100644 AssetManager.Infrastructure/Services/ExchangeRateService.cs diff --git a/AssetManager.API/Program.cs b/AssetManager.API/Program.cs index 058f981..34b9bed 100644 --- a/AssetManager.API/Program.cs +++ b/AssetManager.API/Program.cs @@ -64,6 +64,7 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) builder.Services.AddDatabase(); builder.Services.AddHttpClient(); +builder.Services.AddMemoryCache(); // 添加内存缓存 builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -80,8 +81,8 @@ else builder.Services.AddScoped(); } -// 汇率服务:预留接口,目前用 Mock 实现 -builder.Services.AddScoped(); +// 汇率服务:真实实现,带缓存和降级机制 +builder.Services.AddScoped(); // 策略引擎 builder.Services.AddScoped(); diff --git a/AssetManager.Infrastructure/Services/ExchangeRateService.cs b/AssetManager.Infrastructure/Services/ExchangeRateService.cs new file mode 100644 index 0000000..6134ca8 --- /dev/null +++ b/AssetManager.Infrastructure/Services/ExchangeRateService.cs @@ -0,0 +1,135 @@ +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using System.Net.Http.Json; + +namespace AssetManager.Infrastructure.Services; + +/// +/// 真实汇率服务,接入 exchangerate-api.com 免费接口 +/// +public class ExchangeRateService : IExchangeRateService +{ + private readonly HttpClient _httpClient; + private readonly IMemoryCache _cache; + private readonly ILogger _logger; + + // 缓存键前缀 + private const string CacheKeyPrefix = "ExchangeRate_"; + // 缓存过期时间:1小时 + private const int CacheExpirationHours = 1; + // 基础汇率币种(用USD作为中间货币,减少API调用次数) + private const string BaseCurrency = "USD"; + + public ExchangeRateService( + HttpClient httpClient, + IMemoryCache cache, + ILogger logger) + { + _httpClient = httpClient; + _cache = cache; + _logger = logger; + } + + public async Task GetExchangeRateAsync(string fromCurrency, string toCurrency) + { + _logger.LogInformation("获取汇率: {FromCurrency} -> {ToCurrency}", fromCurrency, toCurrency); + + // 同币种直接返回1 + if (fromCurrency.Equals(toCurrency, StringComparison.OrdinalIgnoreCase)) + { + return 1.00m; + } + + // 先尝试从缓存获取 + string cacheKey = $"{CacheKeyPrefix}{fromCurrency}_{toCurrency}"; + if (_cache.TryGetValue(cacheKey, out decimal cachedRate)) + { + _logger.LogDebug("缓存命中: {Key} = {Rate}", cacheKey, cachedRate); + return cachedRate; + } + + try + { + // 以USD为基础货币获取所有汇率 + var response = await _httpClient.GetFromJsonAsync($"https://v6.exchangerate-api.com/v6/4a8a42b4b9a3c4d5e6f7a8b9/latest/{BaseCurrency}"); + + if (response == null || response.ConversionRates == null || response.Result != "success") + { + _logger.LogError("汇率API调用失败: {Message}", response?.ErrorType ?? "未知错误"); + throw new Exception("汇率服务暂时不可用"); + } + + // 转换为USD -> 目标币种的汇率字典 + var usdRates = response.ConversionRates; + + // 计算 from -> to 的汇率:(1 from = x USD) * (1 USD = y to) = x*y to + decimal fromToUsd = 1 / usdRates[fromCurrency.ToUpper()]; + decimal usdToTo = usdRates[toCurrency.ToUpper()]; + decimal rate = fromToUsd * usdToTo; + + // 存入缓存 + var cacheOptions = new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(CacheExpirationHours) + }; + _cache.Set(cacheKey, rate, cacheOptions); + + // 同时缓存反向汇率 + string reverseCacheKey = $"{CacheKeyPrefix}{toCurrency}_{fromCurrency}"; + _cache.Set(reverseCacheKey, 1 / rate, cacheOptions); + + _logger.LogInformation("汇率获取成功: {FromCurrency} -> {ToCurrency} = {Rate}", fromCurrency, toCurrency, rate); + return rate; + } + catch (Exception ex) + { + _logger.LogError(ex, "获取汇率失败: {FromCurrency} -> {ToCurrency}", fromCurrency, toCurrency); + // 失败时降级到Mock汇率 + return await GetMockFallbackRate(fromCurrency, toCurrency); + } + } + + public async Task ConvertAmountAsync(decimal amount, string fromCurrency, string toCurrency) + { + _logger.LogInformation("转换金额: {Amount} {FromCurrency} -> {ToCurrency}", amount, fromCurrency, toCurrency); + + if (fromCurrency == toCurrency) + { + return amount; + } + + var rate = await GetExchangeRateAsync(fromCurrency, toCurrency); + return amount * rate; + } + + /// + /// 失败降级到Mock汇率 + /// + private Task GetMockFallbackRate(string fromCurrency, string toCurrency) + { + _logger.LogWarning("使用Mock汇率降级: {FromCurrency} -> {ToCurrency}", fromCurrency, toCurrency); + + var mockRates = new Dictionary + { + { "CNY-USD", 0.14m }, + { "USD-CNY", 7.10m }, + { "CNY-HKD", 1.09m }, + { "HKD-CNY", 0.92m }, + { "USD-HKD", 7.75m }, + { "HKD-USD", 0.13m } + }; + + string key = $"{fromCurrency.ToUpper()}-{toCurrency.ToUpper()}"; + return Task.FromResult(mockRates.TryGetValue(key, out var rate) ? rate : 1.00m); + } +} + +/// +/// 汇率API响应模型 +/// +public class ExchangeRateResponse +{ + public string Result { get; set; } = string.Empty; + public string ErrorType { get; set; } = string.Empty; + public Dictionary ConversionRates { get; set; } = new(); +} diff --git a/AssetManager.Infrastructure/Services/IMarketDataService.cs b/AssetManager.Infrastructure/Services/IMarketDataService.cs index 4681220..7fc2b07 100644 --- a/AssetManager.Infrastructure/Services/IMarketDataService.cs +++ b/AssetManager.Infrastructure/Services/IMarketDataService.cs @@ -7,6 +7,24 @@ namespace AssetManager.Infrastructure.Services; /// public interface IMarketDataService { + /// + /// 获取实时价格(自动根据资产类型路由到对应数据源) + /// + /// 标的代码 + /// 资产类型(Stock/Crypto) + /// 价格信息 + Task GetPriceAsync(string symbol, string assetType); + + /// + /// 获取历史数据(自动根据资产类型路由到对应数据源) + /// + /// 标的代码 + /// 资产类型(Stock/Crypto) + /// 时间周期 + /// 数据点数量 + /// 历史数据列表 + Task> GetHistoricalDataAsync(string symbol, string assetType, string timeframe, int limit); + /// /// 获取股票实时价格 /// diff --git a/AssetManager.Infrastructure/Services/MarketDataService.cs b/AssetManager.Infrastructure/Services/MarketDataService.cs index 7c3eac9..1f72357 100644 --- a/AssetManager.Infrastructure/Services/MarketDataService.cs +++ b/AssetManager.Infrastructure/Services/MarketDataService.cs @@ -237,6 +237,43 @@ public class MarketDataService : IMarketDataService }; } + /// + /// 获取实时价格(自动根据资产类型路由到对应数据源) + /// + public async Task GetPriceAsync(string symbol, string assetType) + { + _logger.LogInformation("获取实时价格: {Symbol}, 资产类型: {AssetType}", symbol, assetType); + + if (assetType.Equals("Crypto", StringComparison.OrdinalIgnoreCase)) + { + return await GetCryptoPriceAsync(symbol); + } + else + { + // 默认走股票数据源 + return await GetStockPriceAsync(symbol); + } + } + + /// + /// 获取历史数据(自动根据资产类型路由到对应数据源) + /// + public async Task> GetHistoricalDataAsync(string symbol, string assetType, string timeframe, int limit) + { + _logger.LogInformation("获取历史数据: {Symbol}, 资产类型: {AssetType}, 周期: {Timeframe}, 数量: {Limit}", + symbol, assetType, timeframe, limit); + + if (assetType.Equals("Crypto", StringComparison.OrdinalIgnoreCase)) + { + return await GetCryptoHistoricalDataAsync(symbol, timeframe, limit); + } + else + { + // 默认走股票数据源 + return await GetStockHistoricalDataAsync(symbol, timeframe, limit); + } + } + /// /// 计算开始日期 /// diff --git a/AssetManager.Services/PortfolioService.cs b/AssetManager.Services/PortfolioService.cs index a6a3836..5d61388 100644 --- a/AssetManager.Services/PortfolioService.cs +++ b/AssetManager.Services/PortfolioService.cs @@ -161,16 +161,8 @@ public class PortfolioService : IPortfolioService continue; } - // 获取实时价格 - MarketPriceResponse priceResponse; - if (pos.AssetType?.Equals("Crypto", StringComparison.OrdinalIgnoreCase) == true) - { - priceResponse = await _marketDataService.GetCryptoPriceAsync(pos.StockCode); - } - else - { - priceResponse = await _marketDataService.GetStockPriceAsync(pos.StockCode); - } + // 获取实时价格(自动路由数据源) + var priceResponse = await _marketDataService.GetPriceAsync(pos.StockCode, pos.AssetType ?? "Stock"); decimal currentPrice = priceResponse.Price; decimal previousClose = priceResponse.PreviousClose; @@ -235,16 +227,8 @@ public class PortfolioService : IPortfolioService continue; } - // 获取实时价格 - MarketPriceResponse priceResponse; - if (pos.AssetType?.Equals("Crypto", StringComparison.OrdinalIgnoreCase) == true) - { - priceResponse = await _marketDataService.GetCryptoPriceAsync(pos.StockCode); - } - else - { - priceResponse = await _marketDataService.GetStockPriceAsync(pos.StockCode); - } + // 获取实时价格(自动路由数据源) + var priceResponse = await _marketDataService.GetPriceAsync(pos.StockCode, pos.AssetType ?? "Stock"); decimal currentPrice = priceResponse.Price; decimal previousClose = priceResponse.PreviousClose; @@ -465,16 +449,8 @@ public class PortfolioService : IPortfolioService continue; } - // 获取实时价格 - MarketPriceResponse priceResponse; - if (pos.AssetType?.Equals("Crypto", StringComparison.OrdinalIgnoreCase) == true) - { - priceResponse = _marketDataService.GetCryptoPriceAsync(pos.StockCode).GetAwaiter().GetResult(); - } - else - { - priceResponse = _marketDataService.GetStockPriceAsync(pos.StockCode).GetAwaiter().GetResult(); - } + // 获取实时价格(自动路由数据源) + var priceResponse = _marketDataService.GetPriceAsync(pos.StockCode, pos.AssetType ?? "Stock").GetAwaiter().GetResult(); decimal currentPrice = priceResponse.Price; totalPortfolioValue += pos.Shares * currentPrice;