AssetManager.API/AssetManager.Services/PortfolioNavService.cs
OpenClaw Agent 05ca501f40 feat: 新增组合净值历史功能
后端实现:
- 新增PortfolioNavHistory实体,记录每日净值、成本、收益率
- 实现IPortfolioNavService接口,支持净值计算和历史回填
- 支持基于交易记录完整计算历史净值(买入卖出都会更新)
- 计算统计指标:最大回撤、夏普比率、波动率

新增API:
- GET /api/v1/portfolio/{id}/nav-history - 获取净值曲线
- POST /api/v1/portfolio/{id}/nav-history/backfill - 回填历史净值
- POST /api/v1/portfolio/{id}/nav-history/calculate - 计算当日净值

数据库:
- 新增portfolio_nav_history表迁移脚本
- 支持组合级别的净值历史记录
2026-03-13 16:08:59 +00:00

524 lines
19 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 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<NavItem>(),
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 NavItem
{
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)
.ToListAsync();
decimal totalCost = 0;
foreach (var tx in transactions)
{
if (tx.Type == "buy")
{
decimal txAmount = tx.TotalAmount;
decimal txAmountInTarget = await _exchangeRateService.ConvertAmountAsync(
txAmount, tx.Currency, targetCurrency);
totalCost += txAmountInTarget;
}
else if (tx.Type == "sell")
{
decimal txAmount = tx.TotalAmount;
decimal txAmountInTarget = await _exchangeRateService.ConvertAmountAsync(
txAmount, tx.Currency, targetCurrency);
totalCost -= txAmountInTarget;
}
}
// 计算净值
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");
}
// 获取所有交易记录,按时间排序
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 (date.DayOfWeek == DayOfWeek.Saturday || date.DayOfWeek == DayOfWeek.Sunday)
// continue;
// 检查是否已存在该日期的净值记录(非强制模式)
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 txAmountInTarget = await _exchangeRateService.ConvertAmountAsync(
tx.TotalAmount, tx.Currency, targetCurrency);
cumulativeCost -= txAmountInTarget;
}
}
}
// 如果没有持仓,跳过
if (!holdings.Any()) continue;
// 计算当日市值
decimal totalValue = 0;
foreach (var (stockCode, (shares, cost, currency, assetType)) in holdings)
{
decimal price = await GetHistoricalPriceAsync(stockCode, assetType ?? "Stock", date);
decimal positionValue = shares * price;
decimal positionValueInTarget = await _exchangeRateService.ConvertAmountAsync(
positionValue, currency ?? targetCurrency, targetCurrency);
totalValue += positionValueInTarget;
}
// 计算净值
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
{
// 先尝试获取历史数据
var historicalData = await _marketDataService.GetHistoricalDataAsync(symbol, assetType, "1d", 365);
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)
{
return nearestPrice.Close;
}
// 最后尝试获取实时价格
var currentPrice = await _marketDataService.GetPriceAsync(symbol, assetType);
return currentPrice.Price;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "获取历史价格失败: {Symbol}, {Date}", symbol, date);
return 0;
}
}
/// <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;
// 计算最大回撤
double maxDrawdown = 0;
double peak = 1.0;
foreach (var item in history.OrderBy(h => h.NavDate))
{
var nav = (double)item.Nav;
if (nav > peak) peak = nav;
var drawdown = (peak - nav) / peak * 100;
if (drawdown > maxDrawdown) maxDrawdown = drawdown;
}
// 计算夏普比率(简化版,假设无风险利率=3%年化)
double sharpeRatio = 0;
double volatility = 0;
if (returns.Any())
{
var avgReturn = returns.Average();
var stdDev = Math.Sqrt(returns.Sum(r => Math.Pow(r - avgReturn, 2)) / returns.Count);
volatility = stdDev * Math.Sqrt(252); // 年化波动率
sharpeRatio = stdDev > 0 ? (avgReturn * 252 - 3) / (stdDev * Math.Sqrt(252)) : 0;
}
// 总收益率
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
};
}
}