581 lines
22 KiB
C#
581 lines
22 KiB
C#
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<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)
|
||
.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
|
||
};
|
||
}
|
||
}
|