1. CreatePortfolioAsync: 初始建仓交易保存汇率信息 - 设置 ExchangeRate 和 TotalAmountBase 字段 - 支持跨币种初始建仓 2. ExchangeRateService: 增强 Mock 汇率降级 - 扩展支持 EUR、GBP、JPY - 未知货币对记录 Error 级别日志 3. PositionItem: 增加 Shares 属性 - 保留完整精度(解决 Amount int 截断问题)
170 lines
6.0 KiB
C#
Executable File
170 lines
6.0 KiB
C#
Executable File
using Microsoft.Extensions.Caching.Memory;
|
||
using Microsoft.Extensions.Logging;
|
||
using System.Net.Http.Json;
|
||
|
||
namespace AssetManager.Infrastructure.Services;
|
||
|
||
/// <summary>
|
||
/// 真实汇率服务,接入 exchangerate-api.com 免费接口
|
||
/// </summary>
|
||
public class ExchangeRateService : IExchangeRateService
|
||
{
|
||
private readonly HttpClient _httpClient;
|
||
private readonly IMemoryCache _cache;
|
||
private readonly ILogger<ExchangeRateService> _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<ExchangeRateService> logger)
|
||
{
|
||
_httpClient = httpClient;
|
||
_cache = cache;
|
||
_logger = logger;
|
||
}
|
||
|
||
public async Task<decimal> 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
|
||
{
|
||
// 使用免费的开放汇率接口,无需API key
|
||
var response = await _httpClient.GetFromJsonAsync<OpenExchangeRateResponse>($"https://open.er-api.com/v6/latest/{BaseCurrency}");
|
||
|
||
if (response == null || response.Rates == null || response.Result != "success")
|
||
{
|
||
_logger.LogError("汇率API调用失败: {Message}", response?.ErrorType ?? "未知错误");
|
||
throw new Exception("汇率服务暂时不可用");
|
||
}
|
||
|
||
// 转换为USD -> 目标币种的汇率字典
|
||
var usdRates = response.Rates;
|
||
|
||
// 计算 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<decimal> 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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 失败降级到Mock汇率
|
||
/// </summary>
|
||
private Task<decimal> GetMockFallbackRate(string fromCurrency, string toCurrency)
|
||
{
|
||
_logger.LogWarning("使用Mock汇率降级: {FromCurrency} -> {ToCurrency}", fromCurrency, toCurrency);
|
||
|
||
var mockRates = new Dictionary<string, decimal>
|
||
{
|
||
{ "CNY-USD", 0.14m },
|
||
{ "USD-CNY", 7.10m },
|
||
{ "CNY-HKD", 1.09m },
|
||
{ "HKD-CNY", 0.92m },
|
||
{ "USD-HKD", 7.75m },
|
||
{ "HKD-USD", 0.13m },
|
||
{ "EUR-USD", 1.08m },
|
||
{ "USD-EUR", 0.93m },
|
||
{ "EUR-CNY", 7.70m },
|
||
{ "CNY-EUR", 0.13m },
|
||
{ "GBP-USD", 1.27m },
|
||
{ "USD-GBP", 0.79m },
|
||
{ "GBP-CNY", 9.00m },
|
||
{ "CNY-GBP", 0.11m },
|
||
{ "JPY-USD", 0.0067m },
|
||
{ "USD-JPY", 149.50m },
|
||
{ "JPY-CNY", 0.048m },
|
||
{ "CNY-JPY", 20.90m }
|
||
};
|
||
|
||
string key = $"{fromCurrency.ToUpper()}-{toCurrency.ToUpper()}";
|
||
|
||
if (mockRates.TryGetValue(key, out var rate))
|
||
{
|
||
_logger.LogWarning("Mock汇率命中: {Key} = {Rate},计算结果可能不准确", key, rate);
|
||
return Task.FromResult(rate);
|
||
}
|
||
|
||
// 未知货币对,记录严重警告
|
||
_logger.LogError("未知货币对,无法提供Mock汇率: {FromCurrency} -> {ToCurrency},返回1.0可能导致计算错误",
|
||
fromCurrency, toCurrency);
|
||
|
||
// 返回1.0但记录严重警告,调用方应该检查日志
|
||
return Task.FromResult(1.00m);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 汇率API响应模型(exchangerate-api.com)
|
||
/// </summary>
|
||
public class ExchangeRateResponse
|
||
{
|
||
public string Result { get; set; } = string.Empty;
|
||
public string ErrorType { get; set; } = string.Empty;
|
||
public Dictionary<string, decimal> ConversionRates { get; set; } = new();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 开放汇率API响应模型(er-api.com)
|
||
/// </summary>
|
||
public class OpenExchangeRateResponse
|
||
{
|
||
public string Result { get; set; } = string.Empty;
|
||
public string ErrorType { get; set; } = string.Empty;
|
||
public Dictionary<string, decimal> Rates { get; set; } = new();
|
||
}
|