fix: 历史汇率处理bug + Transaction表增加汇率字段

关键修复:
1. BackfillNavHistoryInternalAsync 汇率处理bug
   - holdings 存储目标币种成本,避免卖出时用当前汇率重转历史成本
   - 优先使用交易时保存的汇率

2. Transaction 表新增字段
   - exchange_rate: 交易时汇率
   - total_amount_base: 本位币金额
   - 创建交易时自动保存汇率

3. CalculateAndSaveDailyNavAsync
   - 优先使用 TotalAmountBase 字段计算成本
   - 回退到当前汇率(兼容历史数据)

4. 新增迁移脚本 sql/migrate_financial_fields.sql
This commit is contained in:
OpenClaw Agent 2026-03-25 05:22:50 +00:00
parent c411caea17
commit 19f3cc8679
4 changed files with 123 additions and 21 deletions

View File

@ -57,7 +57,7 @@ public class Transaction
public decimal Price { get; set; }
/// <summary>
/// 交易总金额
/// 交易总金额(原始币种)
/// </summary>
[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; }
/// <summary>
/// 交易时汇率(原始币种 -> 组合本位币)
/// 用于精确计算历史净值,避免汇率变化影响
/// </summary>
[SugarColumn(ColumnName = "exchange_rate", ColumnDataType = "decimal(18,6)", IsNullable = true)]
public decimal? ExchangeRate { get; set; }
/// <summary>
/// 交易总金额(组合本位币)
/// = TotalAmount * ExchangeRate用于精确计算
/// </summary>
[SugarColumn(ColumnName = "total_amount_base", ColumnDataType = "decimal(18,4)", IsNullable = true)]
public decimal? TotalAmountBase { get; set; }
/// <summary>
/// 交易状态 (processing/completed)
/// </summary>

View File

@ -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<string, (decimal shares, decimal cost, string? currency, string? assetType)>();
// 持仓快照:股票代码 -> (数量, 成本[目标币种], 原始币种, 资产类型)
// 关键修复:成本存储为目标币种,避免卖出时用当前汇率重新转换历史成本
var holdings = new Dictionary<string, (decimal shares, decimal costInTargetCurrency, string? currency, string? assetType)>();
// 累计投入成本
// 累计投入成本(目标币种)
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<string> failedSymbols = new List<string>();
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)

View File

@ -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,

View File

@ -0,0 +1,43 @@
-- =============================================
-- 数据库迁移脚本:为金融计算增加关键字段
-- 执行前请备份数据
-- =============================================
-- 1. positions 表添加 total_cost 字段
ALTER TABLE positions ADD COLUMN total_cost DECIMAL(18,4) DEFAULT 0;
-- 初始化 total_costshares * 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;