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;