diff --git a/AssetManager.Data/Transaction.cs b/AssetManager.Data/Transaction.cs
index fdda41f..d828914 100644
--- a/AssetManager.Data/Transaction.cs
+++ b/AssetManager.Data/Transaction.cs
@@ -57,7 +57,7 @@ public class Transaction
public decimal Price { get; set; }
///
- /// 交易总金额
+ /// 交易总金额(原始币种)
///
[SugarColumn(ColumnName = "total_amount", ColumnDataType = "decimal(18,4)")]
public decimal TotalAmount { get; set; }
@@ -68,6 +68,20 @@ public class Transaction
[SugarColumn(ColumnName = "currency", Length = 10)]
public string? Currency { get; set; }
+ ///
+ /// 交易时汇率(原始币种 -> 组合本位币)
+ /// 用于精确计算历史净值,避免汇率变化影响
+ ///
+ [SugarColumn(ColumnName = "exchange_rate", ColumnDataType = "decimal(18,6)", IsNullable = true)]
+ public decimal? ExchangeRate { get; set; }
+
+ ///
+ /// 交易总金额(组合本位币)
+ /// = TotalAmount * ExchangeRate,用于精确计算
+ ///
+ [SugarColumn(ColumnName = "total_amount_base", ColumnDataType = "decimal(18,4)", IsNullable = true)]
+ public decimal? TotalAmountBase { get; set; }
+
///
/// 交易状态 (processing/completed)
///
diff --git a/AssetManager.Services/PortfolioNavService.cs b/AssetManager.Services/PortfolioNavService.cs
index ad010b6..acf469f 100644
--- a/AssetManager.Services/PortfolioNavService.cs
+++ b/AssetManager.Services/PortfolioNavService.cs
@@ -199,9 +199,23 @@ public class PortfolioNavService : IPortfolioNavService
if (tx.Type == "buy")
{
- decimal txAmount = tx.TotalAmount;
- decimal txAmountInTarget = await _exchangeRateService.ConvertAmountAsync(
- txAmount, tx.Currency, targetCurrency);
+ // 优先使用交易时保存的本位币金额,确保历史净值计算一致
+ decimal txAmountInTarget;
+ if (tx.TotalAmountBase.HasValue && tx.TotalAmountBase > 0)
+ {
+ txAmountInTarget = tx.TotalAmountBase.Value;
+ _logger.LogDebug("使用交易时汇率: {StockCode}, 金额={Amount}, 本位币金额={BaseAmount}",
+ tx.StockCode, tx.TotalAmount, txAmountInTarget);
+ }
+ else
+ {
+ // 回退到当前汇率(历史数据可能不准确)
+ txAmountInTarget = await _exchangeRateService.ConvertAmountAsync(
+ tx.TotalAmount, tx.Currency, targetCurrency);
+ _logger.LogDebug("使用当前汇率: {StockCode}, 金额={Amount}, 本位币金额={BaseAmount}",
+ tx.StockCode, tx.TotalAmount, txAmountInTarget);
+ }
+
totalCost += txAmountInTarget;
// 更新该标的的累计成本和数量
@@ -342,10 +356,11 @@ public class PortfolioNavService : IPortfolioNavService
.ExecuteCommandAsync();
}
- // 持仓快照:股票代码 -> (数量, 成本)
- var holdings = new Dictionary();
+ // 持仓快照:股票代码 -> (数量, 成本[目标币种], 原始币种, 资产类型)
+ // 关键修复:成本存储为目标币种,避免卖出时用当前汇率重新转换历史成本
+ var holdings = new Dictionary();
- // 累计投入成本
+ // 累计投入成本(目标币种)
decimal cumulativeCost = 0;
// 记录创建数量
@@ -372,21 +387,31 @@ public class PortfolioNavService : IPortfolioNavService
if (tx.Type == "buy")
{
+ // 优先使用交易时保存的本位币金额,确保历史净值计算一致
+ decimal txAmountInTarget;
+ if (tx.TotalAmountBase.HasValue && tx.TotalAmountBase > 0)
+ {
+ txAmountInTarget = tx.TotalAmountBase.Value;
+ }
+ else
+ {
+ // 回退到当前汇率(历史数据可能不准确)
+ txAmountInTarget = await _exchangeRateService.ConvertAmountAsync(
+ tx.TotalAmount, tx.Currency, targetCurrency);
+ }
+
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);
+ decimal newCostInTarget = existing.costInTargetCurrency + txAmountInTarget;
+ holdings[tx.StockCode] = (newShares, newCostInTarget, tx.Currency, tx.AssetType);
}
else
{
- holdings[tx.StockCode] = (tx.Amount, tx.TotalAmount, tx.Currency, tx.AssetType);
+ holdings[tx.StockCode] = (tx.Amount, txAmountInTarget, tx.Currency, tx.AssetType);
}
- // 累计投入成本(转换为目标币种)
- decimal txAmountInTarget = await _exchangeRateService.ConvertAmountAsync(
- tx.TotalAmount, tx.Currency, targetCurrency);
cumulativeCost += txAmountInTarget;
}
else if (tx.Type == "sell")
@@ -404,7 +429,7 @@ public class PortfolioNavService : IPortfolioNavService
decimal soldRatio = tx.Amount / existing.shares;
decimal remainingShares = existing.shares - tx.Amount;
- decimal remainingCost = existing.cost * (1 - (decimal)soldRatio);
+ decimal remainingCostInTarget = existing.costInTargetCurrency * (1 - (decimal)soldRatio);
if (remainingShares <= 0)
{
@@ -412,15 +437,15 @@ public class PortfolioNavService : IPortfolioNavService
}
else
{
- holdings[tx.StockCode] = (remainingShares, remainingCost, existing.currency, existing.assetType);
+ holdings[tx.StockCode] = (remainingShares, remainingCostInTarget, existing.currency, existing.assetType);
}
- // 按比例减少累计投入成本(修复:使用该标的的成本,而非全局累计成本)
- // 先将该标的的成本转换为目标币种
- decimal existingCostInTarget = await _exchangeRateService.ConvertAmountAsync(
- existing.cost, existing.currency ?? targetCurrency, targetCurrency);
- decimal costToReduce = existingCostInTarget * (decimal)soldRatio;
+ // 按比例减少累计投入成本(直接用目标币种成本,无需再次汇率转换)
+ decimal costToReduce = existing.costInTargetCurrency * (decimal)soldRatio;
cumulativeCost -= costToReduce;
+
+ _logger.LogDebug("卖出成本计算: {StockCode}, 卖出比例={Ratio}, 减少成本={CostToReduce}, 剩余累计成本={CumulativeCost}",
+ tx.StockCode, soldRatio.ToString("P2"), costToReduce, cumulativeCost);
}
}
}
@@ -433,7 +458,7 @@ public class PortfolioNavService : IPortfolioNavService
bool hasValidPrice = true;
List failedSymbols = new List();
- foreach (var (stockCode, (shares, cost, currency, assetType)) in holdings)
+ foreach (var (stockCode, (shares, costInTargetCurrency, currency, assetType)) in holdings)
{
var priceResult = await GetHistoricalPriceAsync(stockCode, assetType ?? "Stock", date);
if (priceResult == null || priceResult <= 0)
diff --git a/AssetManager.Services/PortfolioService.cs b/AssetManager.Services/PortfolioService.cs
index b3f7bb2..3c2bb22 100755
--- a/AssetManager.Services/PortfolioService.cs
+++ b/AssetManager.Services/PortfolioService.cs
@@ -680,6 +680,24 @@ public class PortfolioService : IPortfolioService
}
}
+ // 获取交易时汇率并保存(用于历史净值计算)
+ string baseCurrency = portfolio.Currency ?? "CNY";
+ decimal? exchangeRate = null;
+ decimal? totalAmountBase = null;
+
+ if (!string.IsNullOrEmpty(request.Currency) && !request.Currency.Equals(baseCurrency, StringComparison.OrdinalIgnoreCase))
+ {
+ exchangeRate = await _exchangeRateService.GetExchangeRateAsync(request.Currency, baseCurrency);
+ totalAmountBase = (decimal)(request.Price * request.Amount) * exchangeRate.Value;
+ _logger.LogInformation("交易汇率: {FromCurrency} -> {ToCurrency} = {Rate}, 原始金额={Amount}, 本位币金额={BaseAmount}",
+ request.Currency, baseCurrency, exchangeRate, request.Price * request.Amount, totalAmountBase);
+ }
+ else
+ {
+ exchangeRate = 1.0m;
+ totalAmountBase = (decimal)(request.Price * request.Amount);
+ }
+
var transaction = new Transaction
{
Id = "trans-" + Guid.NewGuid().ToString().Substring(0, 8),
@@ -692,6 +710,8 @@ public class PortfolioService : IPortfolioService
Price = (decimal)request.Price,
TotalAmount = (decimal)(request.Price * request.Amount),
Currency = request.Currency,
+ ExchangeRate = exchangeRate,
+ TotalAmountBase = totalAmountBase,
Status = "completed",
Remark = request.Remark ?? string.Empty,
TransactionTime = transactionTime,
diff --git a/sql/migrate_financial_fields.sql b/sql/migrate_financial_fields.sql
new file mode 100644
index 0000000..542e3cb
--- /dev/null
+++ b/sql/migrate_financial_fields.sql
@@ -0,0 +1,43 @@
+-- =============================================
+-- 数据库迁移脚本:为金融计算增加关键字段
+-- 执行前请备份数据
+-- =============================================
+
+-- 1. positions 表添加 total_cost 字段
+ALTER TABLE positions ADD COLUMN total_cost DECIMAL(18,4) DEFAULT 0;
+
+-- 初始化 total_cost(shares * avg_price)
+UPDATE positions SET total_cost = shares * avg_price WHERE total_cost = 0 OR total_cost IS NULL;
+
+-- 验证 positions 更新
+SELECT 'positions' as table_name, COUNT(*) as total,
+ SUM(CASE WHEN total_cost > 0 THEN 1 ELSE 0 END) as with_cost
+FROM positions;
+
+-- =============================================
+
+-- 2. transactions 表添加汇率相关字段
+ALTER TABLE transactions ADD COLUMN exchange_rate DECIMAL(18,6) DEFAULT NULL;
+ALTER TABLE transactions ADD COLUMN total_amount_base DECIMAL(18,4) DEFAULT NULL;
+
+-- 为已有交易记录填充当前汇率(历史数据只能用当前汇率近似)
+-- 注意:这会导致历史净值计算有汇率偏差,建议手动修正重要历史交易
+-- UPDATE transactions t
+-- JOIN portfolios p ON t.portfolio_id = p.id
+-- SET t.exchange_rate = 1.0, t.total_amount_base = t.total_amount
+-- WHERE t.currency = p.currency OR t.currency IS NULL;
+
+-- 验证 transactions 更新
+SELECT 'transactions' as table_name, COUNT(*) as total,
+ SUM(CASE WHEN total_amount_base IS NOT NULL THEN 1 ELSE 0 END) as with_base_amount
+FROM transactions;
+
+-- =============================================
+
+-- SQLite 版本(如果使用 SQLite)
+-- ALTER TABLE positions ADD COLUMN total_cost REAL DEFAULT 0;
+-- UPDATE positions SET total_cost = shares * avg_price;
+
+-- SQLite 不支持多个 ADD COLUMN,需要分开执行
+-- ALTER TABLE transactions ADD COLUMN exchange_rate REAL DEFAULT NULL;
+-- ALTER TABLE transactions ADD COLUMN total_amount_base REAL DEFAULT NULL;