diff --git a/AssetManager.Data/Position.cs b/AssetManager.Data/Position.cs
index c6459a1..927d2d3 100644
--- a/AssetManager.Data/Position.cs
+++ b/AssetManager.Data/Position.cs
@@ -50,6 +50,13 @@ public class Position
[SugarColumn(ColumnName = "avg_price", ColumnDataType = "decimal(18,4)")]
public decimal AvgPrice { get; set; }
+ ///
+ /// 剩余成本(原始币种)
+ /// 用于精确追踪卖出后的剩余成本,避免汇率变化影响成本计算
+ ///
+ [SugarColumn(ColumnName = "total_cost", ColumnDataType = "decimal(18,4)")]
+ public decimal TotalCost { get; set; }
+
///
/// 标的币种
///
diff --git a/AssetManager.Services/PortfolioService.cs b/AssetManager.Services/PortfolioService.cs
index 74c928a..b3f7bb2 100755
--- a/AssetManager.Services/PortfolioService.cs
+++ b/AssetManager.Services/PortfolioService.cs
@@ -127,11 +127,15 @@ public class PortfolioService : IPortfolioService
AssetType = string.IsNullOrEmpty(stock.AssetType) ? "Stock" : stock.AssetType,
Shares = (decimal)stock.Amount,
AvgPrice = (decimal)stock.Price,
+ TotalCost = (decimal)(stock.Price * stock.Amount), // 初始成本 = 价格 × 数量
Currency = request.Currency,
CreatedAt = buyTime,
UpdatedAt = DateTime.Now
};
+ _logger.LogInformation("创建持仓: {StockCode}, 数量={Shares}, 均价={AvgPrice}, 成本={TotalCost}",
+ stock.Code, stock.Amount, stock.Price, position.TotalCost);
+
_db.Insertable(position).ExecuteCommand();
// 创建交易记录
@@ -266,7 +270,7 @@ public class PortfolioService : IPortfolioService
}
decimal positionValue = pos.Shares * currentPrice;
- decimal positionCost = pos.Shares * pos.AvgPrice;
+ decimal positionCost = pos.TotalCost; // 使用 TotalCost 字段
decimal positionTodayProfit = previousClose > 0 ? pos.Shares * (currentPrice - previousClose) : 0;
// 汇率转换(汇率服务有缓存,速度很快)
@@ -415,7 +419,7 @@ public class PortfolioService : IPortfolioService
}
decimal positionValue = pos.Shares * currentPrice;
- decimal costValue = pos.Shares * pos.AvgPrice;
+ decimal costValue = pos.TotalCost; // 使用 TotalCost 字段
decimal todayProfit = previousClose > 0 ? pos.Shares * (currentPrice - previousClose) : 0;
// 汇率转换
@@ -495,9 +499,12 @@ public class PortfolioService : IPortfolioService
}
decimal positionValue = pos.Shares * CurrentPrice;
- decimal cost = pos.Shares * pos.AvgPrice;
+ decimal cost = pos.TotalCost; // 使用 TotalCost 字段,精确追踪卖出后的剩余成本
decimal TodayProfit = previousClose > 0 ? pos.Shares * (CurrentPrice - previousClose) : 0;
+ _logger.LogDebug("持仓 {StockCode}: 数量={Shares}, 当前价={CurrentPrice}, 市值={Value}, 成本={Cost}",
+ pos.StockCode, pos.Shares, CurrentPrice, positionValue, cost);
+
// 转换为组合本位币(先转换,再计算盈亏率,避免汇率变化影响)
decimal positionValueInTarget = await _exchangeRateService.ConvertAmountAsync(positionValue, pos.Currency, targetCurrency);
decimal costInTarget = await _exchangeRateService.ConvertAmountAsync(cost, pos.Currency, targetCurrency);
@@ -702,18 +709,33 @@ public class PortfolioService : IPortfolioService
{
if (request.Type == "buy")
{
- // 计算新的平均价格
+ // 计算新的平均价格和总成本
+ var buyAmount = (decimal)request.Amount * (decimal)request.Price;
var newTotalShares = position.Shares + (decimal)request.Amount;
- var newTotalCost = (position.Shares * position.AvgPrice) + ((decimal)request.Amount * (decimal)request.Price);
+ var newTotalCost = position.TotalCost + buyAmount;
position.AvgPrice = newTotalCost / newTotalShares;
+ position.TotalCost = newTotalCost;
position.Shares = newTotalShares;
position.UpdatedAt = DateTime.Now;
+
+ _logger.LogInformation("买入更新持仓: {StockCode}, +{Amount}股@{Price}, 新成本={TotalCost}, 新均价={AvgPrice}",
+ position.StockCode, request.Amount, request.Price, position.TotalCost, position.AvgPrice);
+
_db.Updateable(position).ExecuteCommand();
}
else if (request.Type == "sell")
{
+ // 按比例减少成本
+ var sellRatio = (decimal)request.Amount / position.Shares;
+ var costToReduce = position.TotalCost * sellRatio;
+
position.Shares -= (decimal)request.Amount;
+ position.TotalCost -= costToReduce;
position.UpdatedAt = DateTime.Now;
+
+ _logger.LogInformation("卖出更新持仓: {StockCode}, -{Amount}股@{Price}, 减少成本={CostToReduce}, 剩余成本={TotalCost}",
+ position.StockCode, request.Amount, request.Price, costToReduce, position.TotalCost);
+
if (position.Shares <= 0)
{
_db.Deleteable(position).ExecuteCommand();
@@ -736,10 +758,15 @@ public class PortfolioService : IPortfolioService
AssetType = string.IsNullOrEmpty(request.AssetType) ? "Stock" : request.AssetType,
Shares = (decimal)request.Amount,
AvgPrice = (decimal)request.Price,
+ TotalCost = (decimal)(request.Price * request.Amount),
Currency = request.Currency,
CreatedAt = DateTime.Now,
UpdatedAt = DateTime.Now
};
+
+ _logger.LogInformation("创建新持仓: {StockCode}, 数量={Shares}, 均价={AvgPrice}, 成本={TotalCost}",
+ position.StockCode, position.Shares, position.AvgPrice, position.TotalCost);
+
_db.Insertable(position).ExecuteCommand();
}
diff --git a/AssetManager.Tests/Services/FinancialCalculationTests.cs b/AssetManager.Tests/Services/FinancialCalculationTests.cs
new file mode 100644
index 0000000..80f33b7
--- /dev/null
+++ b/AssetManager.Tests/Services/FinancialCalculationTests.cs
@@ -0,0 +1,294 @@
+using Xunit;
+using System;
+using System.Collections.Generic;
+
+namespace AssetManager.Tests.Services;
+
+///
+/// 金融计算相关单元测试
+/// 重点测试:卖出成本计算、汇率变化影响、边界情况
+///
+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 { 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 { 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 { 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
+}
diff --git a/sql/add_total_cost_column.sql b/sql/add_total_cost_column.sql
new file mode 100644
index 0000000..73d1fdb
--- /dev/null
+++ b/sql/add_total_cost_column.sql
@@ -0,0 +1,17 @@
+-- 为 positions 表添加 total_cost 字段
+-- 执行前请备份数据
+
+-- 1. 添加字段
+ALTER TABLE positions ADD COLUMN total_cost DECIMAL(18,4) DEFAULT 0;
+
+-- 2. 根据现有数据初始化 total_cost(shares * avg_price)
+UPDATE positions SET total_cost = shares * avg_price WHERE total_cost = 0 OR total_cost IS NULL;
+
+-- 3. 验证更新结果
+SELECT id, stock_code, shares, avg_price, total_cost, shares * avg_price as calculated_cost
+FROM positions
+LIMIT 10;
+
+-- SQLite 版本(如果使用 SQLite)
+-- ALTER TABLE positions ADD COLUMN total_cost REAL DEFAULT 0;
+-- UPDATE positions SET total_cost = shares * avg_price;