1. Position实体增加TotalCost字段 - 精确追踪卖出后的剩余成本 - 避免用Shares*AvgPrice计算成本的精度问题 2. PortfolioService逻辑更新 - 买入时更新TotalCost - 卖出时按比例减少TotalCost - 所有成本计算改用TotalCost字段 3. 增加关键计算步骤日志 - 创建/更新持仓时记录成本变化 - 持仓计算时记录关键数值 4. 新增金融计算单元测试 - 卖出成本计算测试 - 汇率变化影响测试 - 夏普比率计算测试 - 最大回撤计算测试 - 边界情况测试 5. 提供数据库迁移SQL脚本
295 lines
9.7 KiB
C#
295 lines
9.7 KiB
C#
using Xunit;
|
||
using System;
|
||
using System.Collections.Generic;
|
||
|
||
namespace AssetManager.Tests.Services;
|
||
|
||
/// <summary>
|
||
/// 金融计算相关单元测试
|
||
/// 重点测试:卖出成本计算、汇率变化影响、边界情况
|
||
/// </summary>
|
||
public class FinancialCalculationTests
|
||
{
|
||
#region 卖出成本计算测试
|
||
|
||
[Fact]
|
||
public void SellCostCalculation_BuyThenSell_ProportionalCostReduction()
|
||
{
|
||
// 场景:买入 100 股 @ 10 元,然后卖出 50 股
|
||
// 预期:卖出后剩余成本 = 1000 * (50/100) = 500 元
|
||
|
||
decimal initialShares = 100;
|
||
decimal initialCost = 1000; // 100 * 10
|
||
decimal sellShares = 50;
|
||
|
||
decimal sellRatio = sellShares / initialShares;
|
||
decimal costToReduce = initialCost * sellRatio;
|
||
decimal remainingCost = initialCost - costToReduce;
|
||
|
||
Assert.Equal(500, remainingCost);
|
||
}
|
||
|
||
[Fact]
|
||
public void SellCostCalculation_PartialSellWithProfit_CostUnchangedByPrice()
|
||
{
|
||
// 场景:买入 100 股 @ 10 元,然后卖出 50 股 @ 15 元(盈利卖出)
|
||
// 预期:剩余成本应该按比例减少,与卖出价格无关
|
||
|
||
decimal initialShares = 100;
|
||
decimal initialCost = 1000;
|
||
decimal sellShares = 50;
|
||
decimal sellPrice = 15; // 卖出价格
|
||
|
||
decimal sellRatio = sellShares / initialShares;
|
||
decimal costToReduce = initialCost * sellRatio; // 应该用成本计算,不是卖出金额
|
||
decimal remainingCost = initialCost - costToReduce;
|
||
|
||
// 错误的计算方式(旧 bug):用卖出金额减少成本
|
||
decimal wrongCostToReduce = sellShares * sellPrice; // 750
|
||
decimal wrongRemainingCost = initialCost - wrongCostToReduce; // 250(错误!)
|
||
|
||
Assert.Equal(500, remainingCost); // 正确
|
||
Assert.Equal(250, wrongRemainingCost); // 错误的计算结果
|
||
Assert.True(remainingCost > wrongRemainingCost, "正确计算的剩余成本应该大于错误的计算结果");
|
||
}
|
||
|
||
[Fact]
|
||
public void SellCostCalculation_MultiplePartialSells_AccurateTracking()
|
||
{
|
||
// 场景:多次分批卖出
|
||
// 1. 买入 100 股 @ 10 元 = 1000 成本
|
||
// 2. 卖出 30 股
|
||
// 3. 卖出 20 股
|
||
// 4. 卖出 25 股
|
||
// 预期:剩余 25 股,成本 250 元
|
||
|
||
decimal shares = 100;
|
||
decimal totalCost = 1000;
|
||
|
||
// 第一次卖出 30 股
|
||
decimal sellRatio1 = 30 / shares;
|
||
totalCost -= totalCost * sellRatio1;
|
||
shares -= 30;
|
||
Assert.Equal(700, totalCost);
|
||
Assert.Equal(70, shares);
|
||
|
||
// 第二次卖出 20 股
|
||
decimal sellRatio2 = 20 / shares;
|
||
totalCost -= totalCost * sellRatio2;
|
||
shares -= 20;
|
||
Assert.Equal(500, totalCost);
|
||
Assert.Equal(50, shares);
|
||
|
||
// 第三次卖出 25 股
|
||
decimal sellRatio3 = 25 / shares;
|
||
totalCost -= totalCost * sellRatio3;
|
||
shares -= 25;
|
||
Assert.Equal(250, totalCost);
|
||
Assert.Equal(25, shares);
|
||
}
|
||
|
||
[Fact]
|
||
public void SellCostCalculation_SellAllShares_CostBecomesZero()
|
||
{
|
||
// 场景:卖出全部持仓
|
||
decimal shares = 100;
|
||
decimal totalCost = 1000;
|
||
decimal sellShares = 100;
|
||
|
||
decimal sellRatio = sellShares / shares;
|
||
totalCost -= totalCost * sellRatio;
|
||
shares -= sellShares;
|
||
|
||
Assert.Equal(0, totalCost);
|
||
Assert.Equal(0, shares);
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region 汇率变化影响测试
|
||
|
||
[Fact]
|
||
public void ExchangeRateImpact_ProfitCalculation_WithRateChange()
|
||
{
|
||
// 场景:
|
||
// 买入时:100 USD @ 汇率 7.0 → 700 CNY 成本
|
||
// 当前:120 USD @ 汇率 7.5 → 900 CNY 市值
|
||
//
|
||
// 错误计算(原始币种):(120-100)/100 = 20%
|
||
// 正确计算(目标币种):(900-700)/700 = 28.57%
|
||
|
||
decimal buyPriceUsd = 100;
|
||
decimal currentPriceUsd = 120;
|
||
decimal buyRate = 7.0m;
|
||
decimal currentRate = 7.5m;
|
||
|
||
decimal costInCny = buyPriceUsd * buyRate; // 700
|
||
decimal valueInCny = currentPriceUsd * currentRate; // 900
|
||
|
||
decimal wrongProfitRate = (currentPriceUsd - buyPriceUsd) / buyPriceUsd * 100; // 20%
|
||
decimal correctProfitRate = (valueInCny - costInCny) / costInCny * 100; // 28.57%
|
||
|
||
Assert.Equal(20, Math.Round(wrongProfitRate, 2));
|
||
Assert.Equal(28.57m, Math.Round((decimal)correctProfitRate, 2));
|
||
Assert.True(correctProfitRate > (decimal)wrongProfitRate, "汇率升值时,正确计算的收益率应该更高");
|
||
}
|
||
|
||
[Fact]
|
||
public void ExchangeRateImpact_ProfitCalculation_WithRateDrop()
|
||
{
|
||
// 场景:
|
||
// 买入时:100 USD @ 汇率 7.5 → 750 CNY 成本
|
||
// 当前:120 USD @ 汇率 7.0 → 840 CNY 市值
|
||
//
|
||
// 错误计算:20% 盈利
|
||
// 正确计算:(840-750)/750 = 12% 盈利
|
||
|
||
decimal buyPriceUsd = 100;
|
||
decimal currentPriceUsd = 120;
|
||
decimal buyRate = 7.5m;
|
||
decimal currentRate = 7.0m;
|
||
|
||
decimal costInCny = buyPriceUsd * buyRate;
|
||
decimal valueInCny = currentPriceUsd * currentRate;
|
||
|
||
decimal wrongProfitRate = (currentPriceUsd - buyPriceUsd) / buyPriceUsd * 100;
|
||
decimal correctProfitRate = (valueInCny - costInCny) / costInCny * 100;
|
||
|
||
Assert.Equal(20, Math.Round(wrongProfitRate, 2));
|
||
Assert.Equal(12m, Math.Round((decimal)correctProfitRate, 2));
|
||
Assert.True(correctProfitRate < (decimal)wrongProfitRate, "汇率贬值时,正确计算的收益率应该更低");
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region 夏普比率计算测试
|
||
|
||
[Fact]
|
||
public void SharpeRatio_PercentageToDecimal_CorrectConversion()
|
||
{
|
||
// 场景:日收益率数据为百分比形式(如 0.5 表示 0.5%)
|
||
var returns = new List<double> { 0.5, -0.3, 0.8, 0.2, -0.1 }; // 百分比形式
|
||
|
||
// 错误计算:直接用百分比
|
||
var wrongAvgReturn = returns.Average();
|
||
var wrongSharpe = wrongAvgReturn * 252 - 3; // 年化收益 - 无风险利率
|
||
|
||
// 正确计算:先转为小数
|
||
var decimalReturns = returns.Select(r => r / 100.0).ToList();
|
||
var avgReturn = decimalReturns.Average();
|
||
var stdDev = Math.Sqrt(decimalReturns.Sum(r => Math.Pow(r - avgReturn, 2)) / decimalReturns.Count);
|
||
var annualizedReturn = avgReturn * 252;
|
||
var annualizedVol = stdDev * Math.Sqrt(252);
|
||
var correctSharpe = annualizedVol > 0 ? (annualizedReturn - 0.03) / annualizedVol : 0;
|
||
|
||
// 错误的年化收益约为 111.3,正确的年化收益约为 0.555
|
||
Assert.True(Math.Abs(wrongSharpe) > 100, "错误计算的夏普比率数值异常大");
|
||
Assert.True(Math.Abs(correctSharpe) < 10, "正确计算的夏普比率数值合理");
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region 最大回撤计算测试
|
||
|
||
[Fact]
|
||
public void MaxDrawdown_Calculation_CorrectPeakTracking()
|
||
{
|
||
// 场景:净值序列 [1.0, 1.2, 1.1, 1.3, 1.0, 0.9]
|
||
// 最大回撤应该发生在 peak=1.3, nav=0.9 时
|
||
// 回撤 = (1.3 - 0.9) / 1.3 = 30.77%
|
||
|
||
var navHistory = new List<decimal> { 1.0m, 1.2m, 1.1m, 1.3m, 1.0m, 0.9m };
|
||
|
||
decimal maxDrawdown = 0;
|
||
decimal peak = navHistory.First(); // 使用第一条记录作为初始 peak
|
||
|
||
foreach (var nav in navHistory)
|
||
{
|
||
if (nav > peak) peak = nav;
|
||
var drawdown = (peak - nav) / peak * 100;
|
||
if (drawdown > maxDrawdown) maxDrawdown = drawdown;
|
||
}
|
||
|
||
Assert.Equal(30.77m, Math.Round(maxDrawdown, 2));
|
||
}
|
||
|
||
[Fact]
|
||
public void MaxDrawdown_InitialPeakNotOne_CorrectCalculation()
|
||
{
|
||
// 场景:初始净值不是 1.0
|
||
var navHistory = new List<decimal> { 2.5m, 2.8m, 2.4m, 2.6m, 2.2m };
|
||
|
||
decimal maxDrawdown = 0;
|
||
decimal peak = navHistory.First(); // 正确:使用 2.5 作为初始 peak
|
||
|
||
foreach (var nav in navHistory)
|
||
{
|
||
if (nav > peak) peak = nav;
|
||
var drawdown = (peak - nav) / peak * 100;
|
||
if (drawdown > maxDrawdown) maxDrawdown = drawdown;
|
||
}
|
||
|
||
// 最大回撤在 peak=2.8, nav=2.2 时
|
||
// 回撤 = (2.8 - 2.2) / 2.8 = 21.43%
|
||
Assert.Equal(21.43m, Math.Round(maxDrawdown, 2));
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region 边界情况测试
|
||
|
||
[Fact]
|
||
public void EdgeCase_ZeroShares_DivisionByZeroProtection()
|
||
{
|
||
// 场景:持仓数量为 0 时,卖出比例计算应该有保护
|
||
decimal shares = 0;
|
||
decimal sellShares = 10;
|
||
decimal totalCost = 1000;
|
||
|
||
// 应该检查 shares > 0 再计算
|
||
if (shares > 0)
|
||
{
|
||
decimal sellRatio = sellShares / shares;
|
||
totalCost -= totalCost * sellRatio;
|
||
}
|
||
|
||
Assert.Equal(1000, totalCost); // 不应该被修改
|
||
}
|
||
|
||
[Fact]
|
||
public void EdgeCase_ZeroCost_NavDefaultToOne()
|
||
{
|
||
// 场景:成本为 0 时,净值应该默认为 1.0
|
||
decimal totalValue = 1000;
|
||
decimal totalCost = 0;
|
||
|
||
decimal nav = totalCost > 0 ? totalValue / totalCost : 1.0m;
|
||
|
||
Assert.Equal(1.0m, nav);
|
||
}
|
||
|
||
[Fact]
|
||
public void EdgeCase_NegativeReturn_StillCorrect()
|
||
{
|
||
// 场景:亏损情况
|
||
decimal totalValue = 800;
|
||
decimal totalCost = 1000;
|
||
|
||
decimal cumulativeReturn = totalCost > 0 ? (totalValue - totalCost) / totalCost * 100 : 0;
|
||
|
||
Assert.Equal(-20, cumulativeReturn);
|
||
}
|
||
|
||
[Fact]
|
||
public void EdgeCase_SellMoreThanHeld_ShouldFail()
|
||
{
|
||
// 场景:卖出数量超过持仓数量
|
||
decimal heldShares = 50;
|
||
decimal sellShares = 60;
|
||
|
||
Assert.True(sellShares > heldShares, "卖出数量超过持仓,应该在校验层拒绝");
|
||
}
|
||
|
||
#endregion
|
||
}
|