From 05ca501f402742583ebf8e90326ab6184cb4037f Mon Sep 17 00:00:00 2001 From: OpenClaw Agent Date: Fri, 13 Mar 2026 16:08:59 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E7=BB=84=E5=90=88?= =?UTF-8?q?=E5=87=80=E5=80=BC=E5=8E=86=E5=8F=B2=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端实现: - 新增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表迁移脚本 - 支持组合级别的净值历史记录 --- .../Controllers/PortfolioController.cs | 173 ++++++ AssetManager.API/Program.cs | 1 + AssetManager.Data/PortfolioNavHistory.cs | 82 +++ AssetManager.Models/DTOs/PortfolioDTO.cs | 53 ++ AssetManager.Services/IPortfolioNavService.cs | 34 ++ AssetManager.Services/PortfolioNavService.cs | 523 ++++++++++++++++++ migrations/001_create_nav_history_table.sql | 34 ++ 7 files changed, 900 insertions(+) mode change 100644 => 100755 AssetManager.API/Controllers/PortfolioController.cs mode change 100644 => 100755 AssetManager.API/Program.cs create mode 100644 AssetManager.Data/PortfolioNavHistory.cs mode change 100644 => 100755 AssetManager.Models/DTOs/PortfolioDTO.cs create mode 100644 AssetManager.Services/IPortfolioNavService.cs create mode 100644 AssetManager.Services/PortfolioNavService.cs create mode 100644 migrations/001_create_nav_history_table.sql diff --git a/AssetManager.API/Controllers/PortfolioController.cs b/AssetManager.API/Controllers/PortfolioController.cs old mode 100644 new mode 100755 index d735500..5d81a94 --- a/AssetManager.API/Controllers/PortfolioController.cs +++ b/AssetManager.API/Controllers/PortfolioController.cs @@ -17,6 +17,7 @@ public class PortfolioController : ControllerBase { private readonly ILogger _logger; private readonly IPortfolioService _portfolioService; + private readonly IPortfolioNavService _navService; private readonly IStrategyEngine _strategyEngine; private readonly IStrategyService _strategyService; private readonly DatabaseService _databaseService; @@ -24,12 +25,14 @@ public class PortfolioController : ControllerBase public PortfolioController( ILogger logger, IPortfolioService portfolioService, + IPortfolioNavService navService, IStrategyEngine strategyEngine, IStrategyService strategyService, DatabaseService databaseService) { _logger = logger; _portfolioService = portfolioService; + _navService = navService; _strategyEngine = strategyEngine; _strategyService = strategyService; _databaseService = databaseService; @@ -479,4 +482,174 @@ public class PortfolioController : ControllerBase message = "删除成功" }); } + + /// + /// 获取组合净值历史曲线 + /// + /// 投资组合ID + /// 开始日期(可选,默认30天前) + /// 结束日期(可选,默认今天) + /// 间隔(daily/weekly/monthly,默认daily) + /// 净值历史数据和统计指标 + [HttpGet("{id}/nav-history")] + public async Task>> GetNavHistory( + string id, + [FromQuery] DateTime? startDate = null, + [FromQuery] DateTime? endDate = null, + [FromQuery] string? interval = "daily") + { + var userId = GetCurrentUserId(); + if (string.IsNullOrEmpty(userId)) + { + return Unauthorized(new ApiResponse + { + code = AssetManager.Models.StatusCodes.Unauthorized, + data = null, + message = "用户未授权" + }); + } + + _logger.LogInformation("Request to get NAV history for portfolio: {PortfolioId}", id); + + try + { + var request = new NavHistoryRequest + { + startDate = startDate, + endDate = endDate, + interval = interval + }; + + var response = await _navService.GetNavHistoryAsync(id, userId, request); + + _logger.LogInformation("NAV history retrieved successfully: {PortfolioId}, {Count} records", + id, response.navHistory?.Count ?? 0); + + return Ok(new ApiResponse + { + code = AssetManager.Models.StatusCodes.Success, + data = response, + message = "success" + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "获取净值历史失败: {PortfolioId}", id); + return Ok(new ApiResponse + { + code = AssetManager.Models.StatusCodes.InternalServerError, + data = null, + message = ex.Message + }); + } + } + + /// + /// 回填组合净值历史(基于交易记录完整计算) + /// + /// 投资组合ID + /// 是否强制重新计算(默认false,跳过已存在的记录) + /// 回填结果 + [HttpPost("{id}/nav-history/backfill")] + public async Task>> BackfillNavHistory( + string id, + [FromQuery] bool force = false) + { + var userId = GetCurrentUserId(); + if (string.IsNullOrEmpty(userId)) + { + return Unauthorized(new ApiResponse + { + code = AssetManager.Models.StatusCodes.Unauthorized, + data = null, + message = "用户未授权" + }); + } + + _logger.LogInformation("Request to backfill NAV history for portfolio: {PortfolioId}, force: {Force}", id, force); + + try + { + var response = await _navService.BackfillNavHistoryAsync(id, userId, force); + + _logger.LogInformation("NAV history backfilled successfully: {PortfolioId}, {Count} records", + id, response.recordsCreated); + + return Ok(new ApiResponse + { + code = AssetManager.Models.StatusCodes.Success, + data = response, + message = "success" + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "回填净值历史失败: {PortfolioId}", id); + return Ok(new ApiResponse + { + code = AssetManager.Models.StatusCodes.InternalServerError, + data = null, + message = ex.Message + }); + } + } + + /// + /// 计算单个组合当日净值(手动触发) + /// + /// 投资组合ID + /// 计算结果 + [HttpPost("{id}/nav-history/calculate")] + public async Task>> CalculateDailyNav(string id) + { + var userId = GetCurrentUserId(); + if (string.IsNullOrEmpty(userId)) + { + return Unauthorized(new ApiResponse + { + code = AssetManager.Models.StatusCodes.Unauthorized, + data = false, + message = "用户未授权" + }); + } + + _logger.LogInformation("Request to calculate daily NAV for portfolio: {PortfolioId}", id); + + try + { + // 验证权限 + var portfolio = _databaseService.GetDb().Queryable() + .Where(p => p.Id == id && p.UserId == userId) + .First(); + + if (portfolio == null) + { + return NotFound(new ApiResponse + { + code = AssetManager.Models.StatusCodes.NotFound, + data = false, + message = "投资组合不存在" + }); + } + + var result = await _navService.CalculateAndSaveDailyNavAsync(id); + + return Ok(new ApiResponse + { + code = AssetManager.Models.StatusCodes.Success, + data = result, + message = result ? "净值计算成功" : "净值计算失败" + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "计算净值失败: {PortfolioId}", id); + return Ok(new ApiResponse + { + code = AssetManager.Models.StatusCodes.InternalServerError, + data = false, + message = ex.Message + }); + } + } } diff --git a/AssetManager.API/Program.cs b/AssetManager.API/Program.cs old mode 100644 new mode 100755 index 0cb89b5..ee6338c --- a/AssetManager.API/Program.cs +++ b/AssetManager.API/Program.cs @@ -86,6 +86,7 @@ builder.Services.AddMemoryCache(); // 添加内存缓存 builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/AssetManager.Data/PortfolioNavHistory.cs b/AssetManager.Data/PortfolioNavHistory.cs new file mode 100644 index 0000000..dca5c98 --- /dev/null +++ b/AssetManager.Data/PortfolioNavHistory.cs @@ -0,0 +1,82 @@ +using SqlSugar; + +namespace AssetManager.Data; + +/// +/// 组合净值历史表 - 记录每日净值和收益 +/// +[SugarTable("portfolio_nav_history")] +public class PortfolioNavHistory +{ + /// + /// 主键 + /// + [SugarColumn(IsPrimaryKey = true, IsIdentity = false)] + public string? Id { get; set; } + + /// + /// 所属组合ID + /// + [SugarColumn(ColumnName = "portfolio_id", IndexGroupNameList = new string[] { "idx_portfolio_date" })] + public string? PortfolioId { get; set; } + + /// + /// 净值日期 + /// + [SugarColumn(ColumnName = "nav_date", IndexGroupNameList = new string[] { "idx_portfolio_date" })] + public DateTime NavDate { get; set; } + + /// + /// 总资产价值(组合本位币) + /// + [SugarColumn(ColumnName = "total_value", ColumnDataType = "decimal(18,4)")] + public decimal TotalValue { get; set; } + + /// + /// 总投入成本(组合本位币) + /// + [SugarColumn(ColumnName = "total_cost", ColumnDataType = "decimal(18,4)")] + public decimal TotalCost { get; set; } + + /// + /// 单位净值(初始=1.0) + /// + [SugarColumn(ColumnName = "nav", ColumnDataType = "decimal(18,8)")] + public decimal Nav { get; set; } + + /// + /// 日收益率(%) + /// + [SugarColumn(ColumnName = "daily_return", ColumnDataType = "decimal(10,4)")] + public decimal DailyReturn { get; set; } + + /// + /// 累计收益率(%) + /// + [SugarColumn(ColumnName = "cumulative_return", ColumnDataType = "decimal(10,4)")] + public decimal CumulativeReturn { get; set; } + + /// + /// 本位币 + /// + [SugarColumn(ColumnName = "currency", Length = 10)] + public string? Currency { get; set; } + + /// + /// 持仓数量 + /// + [SugarColumn(ColumnName = "position_count")] + public int PositionCount { get; set; } + + /// + /// 数据来源(calculated/backfill/estimated) + /// + [SugarColumn(ColumnName = "source", Length = 20)] + public string? Source { get; set; } + + /// + /// 创建时间 + /// + [SugarColumn(ColumnName = "created_at")] + public DateTime CreatedAt { get; set; } +} diff --git a/AssetManager.Models/DTOs/PortfolioDTO.cs b/AssetManager.Models/DTOs/PortfolioDTO.cs old mode 100644 new mode 100755 index f6a7cfd..aafdd1b --- a/AssetManager.Models/DTOs/PortfolioDTO.cs +++ b/AssetManager.Models/DTOs/PortfolioDTO.cs @@ -155,3 +155,56 @@ public class TotalAssetsResponse public string? todayProfitCurrency { get; set; } public double totalReturnRate { get; set; } } + +// ===== 净值历史相关 DTO ===== + +public class NavHistoryRequest +{ + public DateTime? startDate { get; set; } + public DateTime? endDate { get; set; } + public string? interval { get; set; } = "daily"; // daily, weekly, monthly +} + +public class NavHistoryResponse +{ + public string? portfolioId { get; set; } + public string? currency { get; set; } + public List? navHistory { get; set; } + public NavStatistics? statistics { get; set; } +} + +public class NavItem +{ + public string? date { get; set; } + public double nav { get; set; } + public double totalValue { get; set; } + public double totalCost { get; set; } + public double dailyReturn { get; set; } + public double cumulativeReturn { get; set; } +} + +public class NavStatistics +{ + public double maxReturn { get; set; } + public double minReturn { get; set; } + public double maxDrawdown { get; set; } + public double sharpeRatio { get; set; } + public double volatility { get; set; } + public double totalReturn { get; set; } + public int tradingDays { get; set; } +} + +public class BackfillNavRequest +{ + public string? portfolioId { get; set; } + public bool force { get; set; } = false; // 是否强制重新计算 +} + +public class BackfillNavResponse +{ + public string? portfolioId { get; set; } + public int recordsCreated { get; set; } + public DateTime? startDate { get; set; } + public DateTime? endDate { get; set; } + public string? message { get; set; } +} diff --git a/AssetManager.Services/IPortfolioNavService.cs b/AssetManager.Services/IPortfolioNavService.cs new file mode 100644 index 0000000..8d6e17f --- /dev/null +++ b/AssetManager.Services/IPortfolioNavService.cs @@ -0,0 +1,34 @@ +using AssetManager.Models.DTOs; + +namespace AssetManager.Services; + +/// +/// 组合净值历史服务接口 +/// +public interface IPortfolioNavService +{ + /// + /// 获取组合净值历史曲线 + /// + Task GetNavHistoryAsync(string portfolioId, string userId, NavHistoryRequest request); + + /// + /// 计算并保存单个组合当日净值 + /// + Task CalculateAndSaveDailyNavAsync(string portfolioId); + + /// + /// 批量计算所有组合当日净值(定时任务调用) + /// + Task CalculateAllPortfoliosDailyNavAsync(); + + /// + /// 回填历史净值(基于交易记录完整计算) + /// + Task BackfillNavHistoryAsync(string portfolioId, string userId, bool force = false); + + /// + /// 删除指定日期之后的净值记录(交易修改后重新计算) + /// + Task DeleteNavHistoryAfterDateAsync(string portfolioId, DateTime date); +} diff --git a/AssetManager.Services/PortfolioNavService.cs b/AssetManager.Services/PortfolioNavService.cs new file mode 100644 index 0000000..4e7301a --- /dev/null +++ b/AssetManager.Services/PortfolioNavService.cs @@ -0,0 +1,523 @@ +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 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 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 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) + .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 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"); + } + + // 获取所有交易记录,按时间排序 + 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 (date.DayOfWeek == DayOfWeek.Saturday || date.DayOfWeek == DayOfWeek.Sunday) + // continue; + + // 检查是否已存在该日期的净值记录(非强制模式) + 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 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() + .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 + { + // 先尝试获取历史数据 + 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; + } + } + + /// + /// 计算统计指标 + /// + 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; + + // 计算最大回撤 + 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 + }; + } +} diff --git a/migrations/001_create_nav_history_table.sql b/migrations/001_create_nav_history_table.sql new file mode 100644 index 0000000..e8bb9a0 --- /dev/null +++ b/migrations/001_create_nav_history_table.sql @@ -0,0 +1,34 @@ +-- ============================================= +-- 净值历史表迁移脚本 +-- 创建时间: 2026-03-13 +-- 说明: 用于记录组合每日净值和收益数据 +-- ============================================= + +-- 创建净值历史表 +CREATE TABLE IF NOT EXISTS portfolio_nav_history ( + id VARCHAR(50) PRIMARY KEY COMMENT '主键ID', + portfolio_id VARCHAR(50) NOT NULL COMMENT '组合ID', + nav_date DATE NOT NULL COMMENT '净值日期', + total_value DECIMAL(18,4) DEFAULT 0 COMMENT '总资产价值(本位币)', + total_cost DECIMAL(18,4) DEFAULT 0 COMMENT '累计投入成本(本位币)', + nav DECIMAL(18,8) DEFAULT 1.0 COMMENT '单位净值', + daily_return DECIMAL(10,4) DEFAULT 0 COMMENT '日收益率(%)', + cumulative_return DECIMAL(10,4) DEFAULT 0 COMMENT '累计收益率(%)', + currency VARCHAR(10) DEFAULT 'CNY' COMMENT '本位币', + position_count INT DEFAULT 0 COMMENT '持仓数量', + source VARCHAR(20) DEFAULT 'calculated' COMMENT '数据来源', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='组合净值历史表'; + +-- 创建组合日期联合索引 +CREATE INDEX idx_portfolio_date ON portfolio_nav_history(portfolio_id, nav_date); + +-- 创建唯一约束(同一组合同一天只能有一条记录) +ALTER TABLE portfolio_nav_history ADD CONSTRAINT uk_portfolio_date UNIQUE(portfolio_id, nav_date); + +-- 为portfolio表的total_value字段添加注释 +ALTER TABLE portfolios MODIFY COLUMN total_value DECIMAL(18,4) DEFAULT 0 COMMENT '当前总市值(冗余字段,实时计算)'; + +-- 为portfolio表添加净值相关字段 +ALTER TABLE portfolios ADD COLUMN IF NOT EXISTS initial_cost DECIMAL(18,4) DEFAULT 0 COMMENT '初始投入成本'; +ALTER TABLE portfolios ADD COLUMN IF NOT EXISTS last_nav_date DATE COMMENT '最后净值计算日期';