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(); // 每个标的的累计成本
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 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")
{
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 failedSymbols = new List();
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()
.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
};
}
}