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表迁移脚本
- 支持组合级别的净值历史记录
This commit is contained in:
OpenClaw Agent 2026-03-13 16:08:59 +00:00
parent f5d693ac66
commit 05ca501f40
7 changed files with 900 additions and 0 deletions

173
AssetManager.API/Controllers/PortfolioController.cs Normal file → Executable file
View File

@ -17,6 +17,7 @@ public class PortfolioController : ControllerBase
{
private readonly ILogger<PortfolioController> _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<PortfolioController> 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 = "删除成功"
});
}
/// <summary>
/// 获取组合净值历史曲线
/// </summary>
/// <param name="id">投资组合ID</param>
/// <param name="startDate">开始日期可选默认30天前</param>
/// <param name="endDate">结束日期(可选,默认今天)</param>
/// <param name="interval">间隔daily/weekly/monthly默认daily</param>
/// <returns>净值历史数据和统计指标</returns>
[HttpGet("{id}/nav-history")]
public async Task<ActionResult<ApiResponse<NavHistoryResponse>>> 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<NavHistoryResponse>
{
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<NavHistoryResponse>
{
code = AssetManager.Models.StatusCodes.Success,
data = response,
message = "success"
});
}
catch (Exception ex)
{
_logger.LogError(ex, "获取净值历史失败: {PortfolioId}", id);
return Ok(new ApiResponse<NavHistoryResponse>
{
code = AssetManager.Models.StatusCodes.InternalServerError,
data = null,
message = ex.Message
});
}
}
/// <summary>
/// 回填组合净值历史(基于交易记录完整计算)
/// </summary>
/// <param name="id">投资组合ID</param>
/// <param name="force">是否强制重新计算默认false跳过已存在的记录</param>
/// <returns>回填结果</returns>
[HttpPost("{id}/nav-history/backfill")]
public async Task<ActionResult<ApiResponse<BackfillNavResponse>>> BackfillNavHistory(
string id,
[FromQuery] bool force = false)
{
var userId = GetCurrentUserId();
if (string.IsNullOrEmpty(userId))
{
return Unauthorized(new ApiResponse<BackfillNavResponse>
{
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<BackfillNavResponse>
{
code = AssetManager.Models.StatusCodes.Success,
data = response,
message = "success"
});
}
catch (Exception ex)
{
_logger.LogError(ex, "回填净值历史失败: {PortfolioId}", id);
return Ok(new ApiResponse<BackfillNavResponse>
{
code = AssetManager.Models.StatusCodes.InternalServerError,
data = null,
message = ex.Message
});
}
}
/// <summary>
/// 计算单个组合当日净值(手动触发)
/// </summary>
/// <param name="id">投资组合ID</param>
/// <returns>计算结果</returns>
[HttpPost("{id}/nav-history/calculate")]
public async Task<ActionResult<ApiResponse<bool>>> CalculateDailyNav(string id)
{
var userId = GetCurrentUserId();
if (string.IsNullOrEmpty(userId))
{
return Unauthorized(new ApiResponse<bool>
{
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<Portfolio>()
.Where(p => p.Id == id && p.UserId == userId)
.First();
if (portfolio == null)
{
return NotFound(new ApiResponse<bool>
{
code = AssetManager.Models.StatusCodes.NotFound,
data = false,
message = "投资组合不存在"
});
}
var result = await _navService.CalculateAndSaveDailyNavAsync(id);
return Ok(new ApiResponse<bool>
{
code = AssetManager.Models.StatusCodes.Success,
data = result,
message = result ? "净值计算成功" : "净值计算失败"
});
}
catch (Exception ex)
{
_logger.LogError(ex, "计算净值失败: {PortfolioId}", id);
return Ok(new ApiResponse<bool>
{
code = AssetManager.Models.StatusCodes.InternalServerError,
data = false,
message = ex.Message
});
}
}
}

1
AssetManager.API/Program.cs Normal file → Executable file
View File

@ -86,6 +86,7 @@ builder.Services.AddMemoryCache(); // 添加内存缓存
builder.Services.AddScoped<AssetManager.Services.Services.WechatService>();
builder.Services.AddScoped<AssetManager.Services.Services.JwtService>();
builder.Services.AddScoped<AssetManager.Services.IPortfolioService, AssetManager.Services.PortfolioService>();
builder.Services.AddScoped<AssetManager.Services.IPortfolioNavService, AssetManager.Services.PortfolioNavService>();
builder.Services.AddScoped<AssetManager.Services.IStrategyService, AssetManager.Services.StrategyService>();
builder.Services.AddScoped<AssetManager.Services.ITickerService, AssetManager.Services.TickerService>();

View File

@ -0,0 +1,82 @@
using SqlSugar;
namespace AssetManager.Data;
/// <summary>
/// 组合净值历史表 - 记录每日净值和收益
/// </summary>
[SugarTable("portfolio_nav_history")]
public class PortfolioNavHistory
{
/// <summary>
/// 主键
/// </summary>
[SugarColumn(IsPrimaryKey = true, IsIdentity = false)]
public string? Id { get; set; }
/// <summary>
/// 所属组合ID
/// </summary>
[SugarColumn(ColumnName = "portfolio_id", IndexGroupNameList = new string[] { "idx_portfolio_date" })]
public string? PortfolioId { get; set; }
/// <summary>
/// 净值日期
/// </summary>
[SugarColumn(ColumnName = "nav_date", IndexGroupNameList = new string[] { "idx_portfolio_date" })]
public DateTime NavDate { get; set; }
/// <summary>
/// 总资产价值(组合本位币)
/// </summary>
[SugarColumn(ColumnName = "total_value", ColumnDataType = "decimal(18,4)")]
public decimal TotalValue { get; set; }
/// <summary>
/// 总投入成本(组合本位币)
/// </summary>
[SugarColumn(ColumnName = "total_cost", ColumnDataType = "decimal(18,4)")]
public decimal TotalCost { get; set; }
/// <summary>
/// 单位净值(初始=1.0
/// </summary>
[SugarColumn(ColumnName = "nav", ColumnDataType = "decimal(18,8)")]
public decimal Nav { get; set; }
/// <summary>
/// 日收益率(%
/// </summary>
[SugarColumn(ColumnName = "daily_return", ColumnDataType = "decimal(10,4)")]
public decimal DailyReturn { get; set; }
/// <summary>
/// 累计收益率(%
/// </summary>
[SugarColumn(ColumnName = "cumulative_return", ColumnDataType = "decimal(10,4)")]
public decimal CumulativeReturn { get; set; }
/// <summary>
/// 本位币
/// </summary>
[SugarColumn(ColumnName = "currency", Length = 10)]
public string? Currency { get; set; }
/// <summary>
/// 持仓数量
/// </summary>
[SugarColumn(ColumnName = "position_count")]
public int PositionCount { get; set; }
/// <summary>
/// 数据来源calculated/backfill/estimated
/// </summary>
[SugarColumn(ColumnName = "source", Length = 20)]
public string? Source { get; set; }
/// <summary>
/// 创建时间
/// </summary>
[SugarColumn(ColumnName = "created_at")]
public DateTime CreatedAt { get; set; }
}

53
AssetManager.Models/DTOs/PortfolioDTO.cs Normal file → Executable file
View File

@ -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<NavItem>? 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; }
}

View File

@ -0,0 +1,34 @@
using AssetManager.Models.DTOs;
namespace AssetManager.Services;
/// <summary>
/// 组合净值历史服务接口
/// </summary>
public interface IPortfolioNavService
{
/// <summary>
/// 获取组合净值历史曲线
/// </summary>
Task<NavHistoryResponse> GetNavHistoryAsync(string portfolioId, string userId, NavHistoryRequest request);
/// <summary>
/// 计算并保存单个组合当日净值
/// </summary>
Task<bool> CalculateAndSaveDailyNavAsync(string portfolioId);
/// <summary>
/// 批量计算所有组合当日净值(定时任务调用)
/// </summary>
Task<int> CalculateAllPortfoliosDailyNavAsync();
/// <summary>
/// 回填历史净值(基于交易记录完整计算)
/// </summary>
Task<BackfillNavResponse> BackfillNavHistoryAsync(string portfolioId, string userId, bool force = false);
/// <summary>
/// 删除指定日期之后的净值记录(交易修改后重新计算)
/// </summary>
Task<int> DeleteNavHistoryAfterDateAsync(string portfolioId, DateTime date);
}

View File

@ -0,0 +1,523 @@
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
};
}
}

View File

@ -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 '最后净值计算日期';