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:
parent
f5d693ac66
commit
05ca501f40
173
AssetManager.API/Controllers/PortfolioController.cs
Normal file → Executable file
173
AssetManager.API/Controllers/PortfolioController.cs
Normal file → Executable 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
1
AssetManager.API/Program.cs
Normal file → Executable 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>();
|
||||
|
||||
|
||||
82
AssetManager.Data/PortfolioNavHistory.cs
Normal file
82
AssetManager.Data/PortfolioNavHistory.cs
Normal 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
53
AssetManager.Models/DTOs/PortfolioDTO.cs
Normal file → Executable 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; }
|
||||
}
|
||||
|
||||
34
AssetManager.Services/IPortfolioNavService.cs
Normal file
34
AssetManager.Services/IPortfolioNavService.cs
Normal 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);
|
||||
}
|
||||
523
AssetManager.Services/PortfolioNavService.cs
Normal file
523
AssetManager.Services/PortfolioNavService.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
34
migrations/001_create_nav_history_table.sql
Normal file
34
migrations/001_create_nav_history_table.sql
Normal 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 '最后净值计算日期';
|
||||
Loading…
Reference in New Issue
Block a user