AssetManager.API/AssetManager.Services/PortfolioNavService.cs
OpenClaw Agent 2a297081b0 fix: 修复多个金融计算bug
1. 卖出时累计成本计算:改为按比例减少成本,而非用卖出金额抵扣
2. 夏普比率计算:收益率从百分比转为小数后计算,修正年化公式
3. 最大回撤初始值:使用首条记录的净值作为初始peak,而非硬编码1.0
2026-03-25 04:03:37 +00:00

635 lines
24 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>(); // 每个标的的累计成本
foreach (var tx in transactions)
{
if (tx.StockCode == null) continue;
if (tx.Type == "buy")
{
decimal txAmount = tx.TotalAmount;
decimal txAmountInTarget = await _exchangeRateService.ConvertAmountAsync(
txAmount, tx.Currency, targetCurrency);
totalCost += txAmountInTarget;
// 更新该标的的累计成本
if (holdingsCost.ContainsKey(tx.StockCode))
{
holdingsCost[tx.StockCode] += txAmountInTarget;
}
else
{
holdingsCost[tx.StockCode] = txAmountInTarget;
}
}
else if (tx.Type == "sell")
{
// 卖出时按比例减少该标的的累计成本
if (holdingsCost.ContainsKey(tx.StockCode) && tx.Amount > 0)
{
// 需要知道当时该标的的总数量来计算比例
// 从 Position 表获取当前持仓数量(不精确,但作为近似)
var position = positions.FirstOrDefault(p => p.StockCode == tx.StockCode);
if (position != null && position.Shares > 0)
{
// 近似:用当前持仓数量 + 卖出数量 作为原来数量
decimal originalShares = position.Shares + tx.Amount;
decimal soldRatio = tx.Amount / originalShares;
decimal costToReduce = holdingsCost[tx.StockCode] * soldRatio;
holdingsCost[tx.StockCode] -= costToReduce;
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 cost, 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")
{
if (holdings.ContainsKey(tx.StockCode))
{
var existing = holdings[tx.StockCode];
decimal newShares = existing.shares + tx.Amount;
decimal newCost = existing.cost + tx.TotalAmount;
holdings[tx.StockCode] = (newShares, newCost, tx.Currency, tx.AssetType);
}
else
{
holdings[tx.StockCode] = (tx.Amount, tx.TotalAmount, tx.Currency, tx.AssetType);
}
// 累计投入成本(转换为目标币种)
decimal txAmountInTarget = await _exchangeRateService.ConvertAmountAsync(
tx.TotalAmount, tx.Currency, targetCurrency);
cumulativeCost += txAmountInTarget;
}
else if (tx.Type == "sell")
{
if (holdings.ContainsKey(tx.StockCode))
{
var existing = holdings[tx.StockCode];
decimal soldRatio = tx.Amount / existing.shares;
decimal remainingShares = existing.shares - tx.Amount;
decimal remainingCost = existing.cost * (1 - (decimal)soldRatio);
if (remainingShares <= 0)
{
holdings.Remove(tx.StockCode);
}
else
{
holdings[tx.StockCode] = (remainingShares, remainingCost, existing.currency, existing.assetType);
}
// 按比例减少累计投入成本(关键修复:不再用卖出金额)
decimal costToReduce = cumulativeCost * (decimal)soldRatio;
cumulativeCost -= costToReduce;
}
}
}
// 如果没有持仓,跳过
if (!holdings.Any()) continue;
// 计算当日市值
decimal totalValue = 0;
bool hasValidPrice = true;
List<string> failedSymbols = new List<string>();
foreach (var (stockCode, (shares, cost, 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
};
}
}