using AssetManager.Data; using AssetManager.Models.DTOs; using AssetManager.Infrastructure.Services; using SqlSugar; using Microsoft.Extensions.Logging; namespace AssetManager.Services; /// /// 组合净值历史服务实现 /// public class PortfolioNavService : IPortfolioNavService { private readonly ISqlSugarClient _db; private readonly IMarketDataService _marketDataService; private readonly IExchangeRateService _exchangeRateService; private readonly ILogger _logger; public PortfolioNavService( ISqlSugarClient db, IMarketDataService marketDataService, IExchangeRateService exchangeRateService, ILogger logger) { _db = db; _marketDataService = marketDataService; _exchangeRateService = exchangeRateService; _logger = logger; } public async Task GetNavHistoryAsync(string portfolioId, string userId, NavHistoryRequest request) { // 验证权限 var portfolio = await _db.Queryable() .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() .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() .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() .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(), 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 CalculateAndSaveDailyNavAsync(string portfolioId) { var portfolio = await _db.Queryable() .Where(p => p.Id == portfolioId) .FirstAsync(); if (portfolio == null) return false; var today = DateTime.Today; // 检查是否已存在当日净值 var existingNav = await _db.Queryable() .Where(n => n.PortfolioId == portfolioId && n.NavDate == today) .FirstAsync(); if (existingNav != null) { _logger.LogInformation("组合 {PortfolioId} 当日净值已存在,跳过计算", portfolioId); return true; } // 获取持仓 var positions = await _db.Queryable() .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() .Where(n => n.PortfolioId == portfolioId && n.NavDate < today) .OrderByDescending(n => n.NavDate) .FirstAsync(); // 计算累计投入成本(从交易记录汇总,卖出时按比例减少成本) var transactions = await _db.Queryable() .Where(t => t.PortfolioId == portfolioId && t.TransactionTime.Date <= today) .OrderBy(t => t.TransactionTime) .ToListAsync(); decimal totalCost = 0; // 每个标的的累计成本和数量(用于准确计算卖出比例) var holdingsCost = new Dictionary(); var holdingsShares = new Dictionary(); 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 CalculateAllPortfoliosDailyNavAsync() { var portfolios = await _db.Queryable() .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 BackfillNavHistoryAsync(string portfolioId, string userId, bool force = false) { // 验证权限 var portfolio = await _db.Queryable() .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); } /// /// 内部回填方法(已验证权限) /// private async Task BackfillNavHistoryInternalAsync(string portfolioId, Portfolio portfolio, bool force = false) { // 获取所有交易记录,按时间排序 var transactions = await _db.Queryable() .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() .Where(n => n.PortfolioId == portfolioId) .ExecuteCommandAsync(); } // 持仓快照:股票代码 -> (数量, 成本[目标币种], 原始币种, 资产类型) // 关键修复:成本存储为目标币种,避免卖出时用当前汇率重新转换历史成本 var holdings = new Dictionary(); // 累计投入成本(目标币种) decimal cumulativeCost = 0; // 记录创建数量 int recordsCreated = 0; // 遍历每个交易日 for (var date = startDate; date <= endDate; date = date.AddDays(1)) { // 检查是否已存在该日期的净值记录(非强制模式) if (!force) { var existingNav = await _db.Queryable() .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 failedSymbols = new List(); 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() .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 DeleteNavHistoryAfterDateAsync(string portfolioId, DateTime date) { var deleted = await _db.Deleteable() .Where(n => n.PortfolioId == portfolioId && n.NavDate >= date) .ExecuteCommandAsync(); _logger.LogInformation("删除净值记录: PortfolioId={PortfolioId}, Date>={Date}, Count={Count}", portfolioId, date, deleted); return deleted; } /// /// 获取历史价格(优先从缓存读取) /// private async Task GetHistoricalPriceAsync(string symbol, string assetType, DateTime date) { try { // 1. 先从缓存表查特定日期的价格 var cachedPrice = await _db.Queryable() .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; } } /// /// 计算统计指标 /// private NavStatistics CalculateStatistics(List returns, List 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 }; } }