AssetManager.API/AssetManager.Services/PortfolioNavService.cs
OpenClaw Agent 4ce29a1036 refactor: 架构优化 P0-P3
P0 - 安全修复:
- 移除硬编码 API Key,启动时校验必填环境变量

P1 - 高优先级:
- Entity 拆分:Position.cs, Transaction.cs 独立文件
- Controller Facade 封装:IPortfolioFacade 减少依赖注入

P2 - 中优先级:
- Repository 抽象:IPortfolioRepository, IMarketDataRepository
- MarketDataService 拆分:组合模式整合 Tencent/Tiingo/OKX

P3 - 低优先级:
- DTO 命名规范:统一 PascalCase
- 单元测试框架:xUnit + Moq + FluentAssertions
2026-03-15 12:54:05 +00:00

581 lines
22 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);
}
// 查询净值历史
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");
}
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 txAmountInTarget = await _exchangeRateService.ConvertAmountAsync(
tx.TotalAmount, tx.Currency, targetCurrency);
cumulativeCost -= txAmountInTarget;
}
}
}
// 如果没有持仓,跳过
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;
// 计算最大回撤
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
};
}
}