AssetManager.API/AssetManager.Services/PortfolioNavService.cs
OpenClaw Agent 19f3cc8679 fix: 历史汇率处理bug + Transaction表增加汇率字段
关键修复:
1. BackfillNavHistoryInternalAsync 汇率处理bug
   - holdings 存储目标币种成本,避免卖出时用当前汇率重转历史成本
   - 优先使用交易时保存的汇率

2. Transaction 表新增字段
   - exchange_rate: 交易时汇率
   - total_amount_base: 本位币金额
   - 创建交易时自动保存汇率

3. CalculateAndSaveDailyNavAsync
   - 优先使用 TotalAmountBase 字段计算成本
   - 回退到当前汇率(兼容历史数据)

4. 新增迁移脚本 sql/migrate_financial_fields.sql
2026-03-25 05:22:50 +00:00

670 lines
26 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using AssetManager.Data;
using AssetManager.Models.DTOs;
using AssetManager.Infrastructure.Services;
using SqlSugar;
using Microsoft.Extensions.Logging;
namespace AssetManager.Services;
/// <summary>
/// 组合净值历史服务实现
/// </summary>
public class PortfolioNavService : IPortfolioNavService
{
private readonly ISqlSugarClient _db;
private readonly IMarketDataService _marketDataService;
private readonly IExchangeRateService _exchangeRateService;
private readonly ILogger<PortfolioNavService> _logger;
public PortfolioNavService(
ISqlSugarClient db,
IMarketDataService marketDataService,
IExchangeRateService exchangeRateService,
ILogger<PortfolioNavService> logger)
{
_db = db;
_marketDataService = marketDataService;
_exchangeRateService = exchangeRateService;
_logger = logger;
}
public async Task<NavHistoryResponse> GetNavHistoryAsync(string portfolioId, string userId, NavHistoryRequest request)
{
// 验证权限
var portfolio = await _db.Queryable<Portfolio>()
.Where(p => p.Id == portfolioId && p.UserId == userId)
.FirstAsync();
if (portfolio == null)
{
throw new Exception("Portfolio not found or access denied");
}
// 设置默认日期范围最近30天
var endDate = request.EndDate ?? DateTime.Today;
var startDate = request.StartDate ?? endDate.AddDays(-30);
// 检查是否有历史数据,没有则自动回填
var existingCount = await _db.Queryable<PortfolioNavHistory>()
.Where(n => n.PortfolioId == portfolioId)
.CountAsync();
if (existingCount == 0)
{
_logger.LogInformation("组合 {PortfolioId} 无净值历史数据,自动开始回填", portfolioId);
await BackfillNavHistoryInternalAsync(portfolioId, portfolio);
}
else
{
// 有历史数据,检查是否需要计算今日净值
var today = DateTime.Today;
var todayNavExists = await _db.Queryable<PortfolioNavHistory>()
.Where(n => n.PortfolioId == portfolioId && n.NavDate == today)
.AnyAsync();
if (!todayNavExists)
{
_logger.LogInformation("组合 {PortfolioId} 今日净值不存在,自动计算", portfolioId);
try
{
await CalculateAndSaveDailyNavAsync(portfolioId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "组合 {PortfolioId} 今日净值计算失败,将使用历史数据", portfolioId);
}
}
}
// 查询净值历史
var navHistory = await _db.Queryable<PortfolioNavHistory>()
.Where(n => n.PortfolioId == portfolioId)
.Where(n => n.NavDate >= startDate && n.NavDate <= endDate)
.OrderBy(n => n.NavDate)
.ToListAsync();
// 如果没有历史数据
if (!navHistory.Any())
{
return new NavHistoryResponse
{
PortfolioId = portfolioId,
Currency = portfolio.Currency,
NavHistory = new List<NavHistoryItem>(),
Statistics = new NavStatistics()
};
}
// 计算统计指标
var returns = navHistory.Select(n => (double)n.DailyReturn).Where(r => r != 0).ToList();
var statistics = CalculateStatistics(returns, navHistory);
return new NavHistoryResponse
{
PortfolioId = portfolioId,
Currency = portfolio.Currency,
NavHistory = navHistory.Select(n => new NavHistoryItem
{
Date = n.NavDate.ToString("yyyy-MM-dd"),
Nav = (double)n.Nav,
TotalValue = (double)n.TotalValue,
TotalCost = (double)n.TotalCost,
DailyReturn = (double)n.DailyReturn,
CumulativeReturn = (double)n.CumulativeReturn
}).ToList(),
Statistics = statistics
};
}
public async Task<bool> CalculateAndSaveDailyNavAsync(string portfolioId)
{
var portfolio = await _db.Queryable<Portfolio>()
.Where(p => p.Id == portfolioId)
.FirstAsync();
if (portfolio == null) return false;
var today = DateTime.Today;
// 检查是否已存在当日净值
var existingNav = await _db.Queryable<PortfolioNavHistory>()
.Where(n => n.PortfolioId == portfolioId && n.NavDate == today)
.FirstAsync();
if (existingNav != null)
{
_logger.LogInformation("组合 {PortfolioId} 当日净值已存在,跳过计算", portfolioId);
return true;
}
// 获取持仓
var positions = await _db.Queryable<Position>()
.Where(pos => pos.PortfolioId == portfolioId)
.ToListAsync();
if (!positions.Any())
{
_logger.LogInformation("组合 {PortfolioId} 无持仓,跳过净值计算", portfolioId);
return false;
}
string targetCurrency = portfolio.Currency ?? "CNY";
decimal totalValue = 0;
// 计算总市值
foreach (var pos in positions)
{
if (pos.StockCode == null || pos.Currency == null) continue;
decimal currentPrice = pos.AvgPrice;
try
{
var priceResponse = await _marketDataService.GetPriceAsync(pos.StockCode, pos.AssetType ?? "Stock");
if (priceResponse.Price > 0)
{
currentPrice = priceResponse.Price;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "获取 {StockCode} 价格失败,使用成本价", pos.StockCode);
}
decimal positionValue = pos.Shares * currentPrice;
decimal positionValueInTarget = await _exchangeRateService.ConvertAmountAsync(
positionValue, pos.Currency, targetCurrency);
totalValue += positionValueInTarget;
}
// 获取昨日净值
var yesterdayNav = await _db.Queryable<PortfolioNavHistory>()
.Where(n => n.PortfolioId == portfolioId && n.NavDate < today)
.OrderByDescending(n => n.NavDate)
.FirstAsync();
// 计算累计投入成本(从交易记录汇总,卖出时按比例减少成本)
var transactions = await _db.Queryable<Transaction>()
.Where(t => t.PortfolioId == portfolioId && t.TransactionTime.Date <= today)
.OrderBy(t => t.TransactionTime)
.ToListAsync();
decimal totalCost = 0;
// 每个标的的累计成本和数量(用于准确计算卖出比例)
var holdingsCost = new Dictionary<string, decimal>();
var holdingsShares = new Dictionary<string, decimal>();
foreach (var tx in transactions)
{
if (tx.StockCode == null) continue;
if (tx.Type == "buy")
{
// 优先使用交易时保存的本位币金额,确保历史净值计算一致
decimal txAmountInTarget;
if (tx.TotalAmountBase.HasValue && tx.TotalAmountBase > 0)
{
txAmountInTarget = tx.TotalAmountBase.Value;
_logger.LogDebug("使用交易时汇率: {StockCode}, 金额={Amount}, 本位币金额={BaseAmount}",
tx.StockCode, tx.TotalAmount, txAmountInTarget);
}
else
{
// 回退到当前汇率(历史数据可能不准确)
txAmountInTarget = await _exchangeRateService.ConvertAmountAsync(
tx.TotalAmount, tx.Currency, targetCurrency);
_logger.LogDebug("使用当前汇率: {StockCode}, 金额={Amount}, 本位币金额={BaseAmount}",
tx.StockCode, tx.TotalAmount, txAmountInTarget);
}
totalCost += txAmountInTarget;
// 更新该标的的累计成本和数量
if (holdingsCost.ContainsKey(tx.StockCode))
{
holdingsCost[tx.StockCode] += txAmountInTarget;
holdingsShares[tx.StockCode] += tx.Amount;
}
else
{
holdingsCost[tx.StockCode] = txAmountInTarget;
holdingsShares[tx.StockCode] = tx.Amount;
}
}
else if (tx.Type == "sell")
{
// 卖出时按比例减少该标的的累计成本
if (holdingsCost.ContainsKey(tx.StockCode) && holdingsShares[tx.StockCode] > 0)
{
decimal currentShares = holdingsShares[tx.StockCode];
decimal soldRatio = tx.Amount / currentShares;
decimal costToReduce = holdingsCost[tx.StockCode] * soldRatio;
holdingsCost[tx.StockCode] -= costToReduce;
holdingsShares[tx.StockCode] -= tx.Amount;
totalCost -= costToReduce;
}
}
}
// 计算净值
decimal nav = totalCost > 0 ? totalValue / totalCost : 1.0m;
decimal dailyReturn = 0;
decimal cumulativeReturn = totalCost > 0 ? (totalValue - totalCost) / totalCost * 100 : 0;
if (yesterdayNav != null && yesterdayNav.TotalValue > 0)
{
dailyReturn = (totalValue - yesterdayNav.TotalValue) / yesterdayNav.TotalValue * 100;
}
// 保存净值记录
var navRecord = new PortfolioNavHistory
{
Id = "nav-" + Guid.NewGuid().ToString().Substring(0, 8),
PortfolioId = portfolioId,
NavDate = today,
TotalValue = totalValue,
TotalCost = totalCost,
Nav = nav,
DailyReturn = dailyReturn,
CumulativeReturn = cumulativeReturn,
Currency = targetCurrency,
PositionCount = positions.Count,
Source = "calculated",
CreatedAt = DateTime.Now
};
await _db.Insertable(navRecord).ExecuteCommandAsync();
_logger.LogInformation("组合 {PortfolioId} 净值计算完成: NAV={Nav}, 日收益={DailyReturn}%",
portfolioId, nav, dailyReturn);
return true;
}
public async Task<int> CalculateAllPortfoliosDailyNavAsync()
{
var portfolios = await _db.Queryable<Portfolio>()
.Where(p => p.Status == "运行中")
.ToListAsync();
int successCount = 0;
foreach (var portfolio in portfolios)
{
try
{
var result = await CalculateAndSaveDailyNavAsync(portfolio.Id);
if (result) successCount++;
}
catch (Exception ex)
{
_logger.LogError(ex, "组合 {PortfolioId} 净值计算失败", portfolio.Id);
}
}
_logger.LogInformation("批量净值计算完成: 成功 {Success}/{Total}", successCount, portfolios.Count);
return successCount;
}
public async Task<BackfillNavResponse> BackfillNavHistoryAsync(string portfolioId, string userId, bool force = false)
{
// 验证权限
var portfolio = await _db.Queryable<Portfolio>()
.Where(p => p.Id == portfolioId && p.UserId == userId)
.FirstAsync();
if (portfolio == null)
{
throw new Exception("Portfolio not found or access denied");
}
return await BackfillNavHistoryInternalAsync(portfolioId, portfolio, force);
}
/// <summary>
/// 内部回填方法(已验证权限)
/// </summary>
private async Task<BackfillNavResponse> BackfillNavHistoryInternalAsync(string portfolioId, Portfolio portfolio, bool force = false)
{
// 获取所有交易记录,按时间排序
var transactions = await _db.Queryable<Transaction>()
.Where(t => t.PortfolioId == portfolioId)
.OrderBy(t => t.TransactionTime)
.ToListAsync();
if (!transactions.Any())
{
return new BackfillNavResponse
{
PortfolioId = portfolioId,
RecordsCreated = 0,
Message = "无交易记录,无法计算净值历史"
};
}
// 确定起始日期(最早交易日期)
var startDate = transactions.Min(t => t.TransactionTime).Date;
var endDate = DateTime.Today;
string targetCurrency = portfolio.Currency ?? "CNY";
_logger.LogInformation("开始回填净值历史: {PortfolioId}, 日期范围: {StartDate} ~ {EndDate}",
portfolioId, startDate, endDate);
// 如果强制重新计算,先删除所有历史记录
if (force)
{
await _db.Deleteable<PortfolioNavHistory>()
.Where(n => n.PortfolioId == portfolioId)
.ExecuteCommandAsync();
}
// 持仓快照:股票代码 -> (数量, 成本[目标币种], 原始币种, 资产类型)
// 关键修复:成本存储为目标币种,避免卖出时用当前汇率重新转换历史成本
var holdings = new Dictionary<string, (decimal shares, decimal costInTargetCurrency, string? currency, string? assetType)>();
// 累计投入成本(目标币种)
decimal cumulativeCost = 0;
// 记录创建数量
int recordsCreated = 0;
// 遍历每个交易日
for (var date = startDate; date <= endDate; date = date.AddDays(1))
{
// 检查是否已存在该日期的净值记录(非强制模式)
if (!force)
{
var existingNav = await _db.Queryable<PortfolioNavHistory>()
.Where(n => n.PortfolioId == portfolioId && n.NavDate == date)
.FirstAsync();
if (existingNav != null) continue;
}
// 处理当天的所有交易
var todayTransactions = transactions.Where(t => t.TransactionTime.Date == date).ToList();
foreach (var tx in todayTransactions)
{
if (tx.StockCode == null) continue;
if (tx.Type == "buy")
{
// 优先使用交易时保存的本位币金额,确保历史净值计算一致
decimal txAmountInTarget;
if (tx.TotalAmountBase.HasValue && tx.TotalAmountBase > 0)
{
txAmountInTarget = tx.TotalAmountBase.Value;
}
else
{
// 回退到当前汇率(历史数据可能不准确)
txAmountInTarget = await _exchangeRateService.ConvertAmountAsync(
tx.TotalAmount, tx.Currency, targetCurrency);
}
if (holdings.ContainsKey(tx.StockCode))
{
var existing = holdings[tx.StockCode];
decimal newShares = existing.shares + tx.Amount;
decimal newCostInTarget = existing.costInTargetCurrency + txAmountInTarget;
holdings[tx.StockCode] = (newShares, newCostInTarget, tx.Currency, tx.AssetType);
}
else
{
holdings[tx.StockCode] = (tx.Amount, txAmountInTarget, tx.Currency, tx.AssetType);
}
cumulativeCost += txAmountInTarget;
}
else if (tx.Type == "sell")
{
if (holdings.ContainsKey(tx.StockCode))
{
var existing = holdings[tx.StockCode];
// 安全检查:防止除零
if (existing.shares <= 0)
{
holdings.Remove(tx.StockCode);
continue;
}
decimal soldRatio = tx.Amount / existing.shares;
decimal remainingShares = existing.shares - tx.Amount;
decimal remainingCostInTarget = existing.costInTargetCurrency * (1 - (decimal)soldRatio);
if (remainingShares <= 0)
{
holdings.Remove(tx.StockCode);
}
else
{
holdings[tx.StockCode] = (remainingShares, remainingCostInTarget, existing.currency, existing.assetType);
}
// 按比例减少累计投入成本(直接用目标币种成本,无需再次汇率转换)
decimal costToReduce = existing.costInTargetCurrency * (decimal)soldRatio;
cumulativeCost -= costToReduce;
_logger.LogDebug("卖出成本计算: {StockCode}, 卖出比例={Ratio}, 减少成本={CostToReduce}, 剩余累计成本={CumulativeCost}",
tx.StockCode, soldRatio.ToString("P2"), costToReduce, cumulativeCost);
}
}
}
// 如果没有持仓,跳过
if (!holdings.Any()) continue;
// 计算当日市值
decimal totalValue = 0;
bool hasValidPrice = true;
List<string> failedSymbols = new List<string>();
foreach (var (stockCode, (shares, costInTargetCurrency, currency, assetType)) in holdings)
{
var priceResult = await GetHistoricalPriceAsync(stockCode, assetType ?? "Stock", date);
if (priceResult == null || priceResult <= 0)
{
hasValidPrice = false;
failedSymbols.Add(stockCode);
continue;
}
decimal price = priceResult.Value;
decimal positionValue = shares * price;
decimal positionValueInTarget = await _exchangeRateService.ConvertAmountAsync(
positionValue, currency ?? targetCurrency, targetCurrency);
totalValue += positionValueInTarget;
}
// 如果有任何持仓价格获取失败,跳过该日期,不写入错误数据
if (!hasValidPrice)
{
_logger.LogWarning("跳过日期 {Date},以下持仓价格获取失败: {Symbols}",
date.ToString("yyyy-MM-dd"), string.Join(", ", failedSymbols));
continue;
}
// 计算净值
decimal nav = cumulativeCost > 0 ? totalValue / cumulativeCost : 1.0m;
decimal cumulativeReturn = cumulativeCost > 0 ? (totalValue - cumulativeCost) / cumulativeCost * 100 : 0;
// 获取昨日净值以计算日收益率
var yesterdayNav = await _db.Queryable<PortfolioNavHistory>()
.Where(n => n.PortfolioId == portfolioId && n.NavDate < date)
.OrderByDescending(n => n.NavDate)
.FirstAsync();
decimal dailyReturn = 0;
if (yesterdayNav != null && yesterdayNav.TotalValue > 0)
{
dailyReturn = (totalValue - yesterdayNav.TotalValue) / yesterdayNav.TotalValue * 100;
}
// 保存净值记录
var navRecord = new PortfolioNavHistory
{
Id = "nav-" + Guid.NewGuid().ToString().Substring(0, 8),
PortfolioId = portfolioId,
NavDate = date,
TotalValue = totalValue,
TotalCost = cumulativeCost,
Nav = nav,
DailyReturn = dailyReturn,
CumulativeReturn = cumulativeReturn,
Currency = targetCurrency,
PositionCount = holdings.Count,
Source = "backfill",
CreatedAt = DateTime.Now
};
await _db.Insertable(navRecord).ExecuteCommandAsync();
recordsCreated++;
// 每100条记录输出一次进度
if (recordsCreated % 100 == 0)
{
_logger.LogInformation("回填进度: {Count} 条记录已创建", recordsCreated);
}
}
_logger.LogInformation("净值历史回填完成: {PortfolioId}, 共创建 {Count} 条记录", portfolioId, recordsCreated);
return new BackfillNavResponse
{
PortfolioId = portfolioId,
RecordsCreated = recordsCreated,
StartDate = startDate,
EndDate = endDate,
Message = $"成功回填 {recordsCreated} 条净值记录"
};
}
public async Task<int> DeleteNavHistoryAfterDateAsync(string portfolioId, DateTime date)
{
var deleted = await _db.Deleteable<PortfolioNavHistory>()
.Where(n => n.PortfolioId == portfolioId && n.NavDate >= date)
.ExecuteCommandAsync();
_logger.LogInformation("删除净值记录: PortfolioId={PortfolioId}, Date>={Date}, Count={Count}",
portfolioId, date, deleted);
return deleted;
}
/// <summary>
/// 获取历史价格(优先从缓存读取)
/// </summary>
private async Task<decimal?> GetHistoricalPriceAsync(string symbol, string assetType, DateTime date)
{
try
{
// 1. 先从缓存表查特定日期的价格
var cachedPrice = await _db.Queryable<MarketKlineCache>()
.Where(k => k.Symbol == symbol.ToUpper()
&& k.AssetType == assetType.ToUpper()
&& k.Timeframe == "1D"
&& k.Timestamp.Date == date.Date)
.FirstAsync();
if (cachedPrice != null && cachedPrice.Close > 0)
{
_logger.LogDebug("缓存命中: {Symbol} {Date}, 价格: {Price}", symbol, date.ToString("yyyy-MM-dd"), cachedPrice.Close);
return cachedPrice.Close;
}
// 2. 缓存未命中,尝试获取该日期附近的历史数据
var historicalData = await _marketDataService.GetHistoricalDataAsync(symbol, assetType, "1d", 30);
// 精确匹配日期
var priceOnDate = historicalData.FirstOrDefault(d => d.Timestamp.Date == date.Date);
if (priceOnDate != null && priceOnDate.Close > 0)
{
return priceOnDate.Close;
}
// 找最近的交易日价格
var nearestPrice = historicalData
.Where(d => d.Timestamp.Date <= date.Date)
.OrderByDescending(d => d.Timestamp)
.FirstOrDefault();
if (nearestPrice != null && nearestPrice.Close > 0)
{
_logger.LogDebug("使用最近交易日价格: {Symbol} {Date} → {ActualDate}",
symbol, date.ToString("yyyy-MM-dd"), nearestPrice.Timestamp.ToString("yyyy-MM-dd"));
return nearestPrice.Close;
}
// 3. 最后尝试获取实时价格
var currentPrice = await _marketDataService.GetPriceAsync(symbol, assetType);
if (currentPrice != null && currentPrice.Price > 0)
{
return currentPrice.Price;
}
_logger.LogWarning("无法获取有效价格: {Symbol}, {Date}", symbol, date);
return null;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "获取历史价格失败: {Symbol}, {Date}", symbol, date);
return null;
}
}
/// <summary>
/// 计算统计指标
/// </summary>
private NavStatistics CalculateStatistics(List<double> returns, List<PortfolioNavHistory> history)
{
if (!history.Any()) return new NavStatistics();
var maxReturn = returns.Any() ? returns.Max() : 0;
var minReturn = returns.Any() ? returns.Min() : 0;
// 计算最大回撤(修复:用第一条记录的 Nav 作为初始 peak
double maxDrawdown = 0;
var orderedHistory = history.OrderBy(h => h.NavDate).ToList();
double peak = (double)orderedHistory.First().Nav;
foreach (var item in orderedHistory)
{
var nav = (double)item.Nav;
if (nav > peak) peak = nav;
var drawdown = (peak - nav) / peak * 100;
if (drawdown > maxDrawdown) maxDrawdown = drawdown;
}
// 计算夏普比率(修复:收益率从百分比转为小数)
double sharpeRatio = 0;
double volatility = 0;
if (returns.Any())
{
// 收益率从百分比形式转换为小数形式(如 0.5% -> 0.005
var decimalReturns = returns.Select(r => r / 100.0).ToList();
var avgReturn = decimalReturns.Average();
var stdDev = Math.Sqrt(decimalReturns.Sum(r => Math.Pow(r - avgReturn, 2)) / decimalReturns.Count);
// 年化
double annualizedReturn = avgReturn * 252;
double annualizedVol = stdDev * Math.Sqrt(252);
volatility = annualizedVol * 100; // 转回百分比形式显示
sharpeRatio = annualizedVol > 0 ? (annualizedReturn - 0.03) / annualizedVol : 0; // 3% 无风险利率
}
// 总收益率
var totalReturn = history.Any()
? (double)(history.Last().CumulativeReturn)
: 0;
return new NavStatistics
{
MaxReturn = Math.Round(maxReturn, 2),
MinReturn = Math.Round(minReturn, 2),
MaxDrawdown = Math.Round(maxDrawdown, 2),
SharpeRatio = Math.Round(sharpeRatio, 2),
Volatility = Math.Round(volatility, 2),
TotalReturn = Math.Round(totalReturn, 2),
TradingDays = history.Count
};
}
}