AssetManager.API/AssetManager.Tests/Services/FinancialCalculationTests.cs
OpenClaw Agent c411caea17 feat: Position表增加TotalCost字段 + 金融计算单元测试
1. Position实体增加TotalCost字段
   - 精确追踪卖出后的剩余成本
   - 避免用Shares*AvgPrice计算成本的精度问题

2. PortfolioService逻辑更新
   - 买入时更新TotalCost
   - 卖出时按比例减少TotalCost
   - 所有成本计算改用TotalCost字段

3. 增加关键计算步骤日志
   - 创建/更新持仓时记录成本变化
   - 持仓计算时记录关键数值

4. 新增金融计算单元测试
   - 卖出成本计算测试
   - 汇率变化影响测试
   - 夏普比率计算测试
   - 最大回撤计算测试
   - 边界情况测试

5. 提供数据库迁移SQL脚本
2026-03-25 04:27:40 +00:00

295 lines
9.7 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}