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:
parent
c411caea17
commit
19f3cc8679
@ -57,7 +57,7 @@ public class Transaction
|
|||||||
public decimal Price { get; set; }
|
public decimal Price { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 交易总金额
|
/// 交易总金额(原始币种)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[SugarColumn(ColumnName = "total_amount", ColumnDataType = "decimal(18,4)")]
|
[SugarColumn(ColumnName = "total_amount", ColumnDataType = "decimal(18,4)")]
|
||||||
public decimal TotalAmount { get; set; }
|
public decimal TotalAmount { get; set; }
|
||||||
@ -68,6 +68,20 @@ public class Transaction
|
|||||||
[SugarColumn(ColumnName = "currency", Length = 10)]
|
[SugarColumn(ColumnName = "currency", Length = 10)]
|
||||||
public string? Currency { get; set; }
|
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>
|
/// <summary>
|
||||||
/// 交易状态 (processing/completed)
|
/// 交易状态 (processing/completed)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@ -199,9 +199,23 @@ public class PortfolioNavService : IPortfolioNavService
|
|||||||
|
|
||||||
if (tx.Type == "buy")
|
if (tx.Type == "buy")
|
||||||
{
|
{
|
||||||
decimal txAmount = tx.TotalAmount;
|
// 优先使用交易时保存的本位币金额,确保历史净值计算一致
|
||||||
decimal txAmountInTarget = await _exchangeRateService.ConvertAmountAsync(
|
decimal txAmountInTarget;
|
||||||
txAmount, tx.Currency, targetCurrency);
|
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;
|
totalCost += txAmountInTarget;
|
||||||
|
|
||||||
// 更新该标的的累计成本和数量
|
// 更新该标的的累计成本和数量
|
||||||
@ -342,10 +356,11 @@ public class PortfolioNavService : IPortfolioNavService
|
|||||||
.ExecuteCommandAsync();
|
.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;
|
decimal cumulativeCost = 0;
|
||||||
|
|
||||||
// 记录创建数量
|
// 记录创建数量
|
||||||
@ -372,21 +387,31 @@ public class PortfolioNavService : IPortfolioNavService
|
|||||||
|
|
||||||
if (tx.Type == "buy")
|
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))
|
if (holdings.ContainsKey(tx.StockCode))
|
||||||
{
|
{
|
||||||
var existing = holdings[tx.StockCode];
|
var existing = holdings[tx.StockCode];
|
||||||
decimal newShares = existing.shares + tx.Amount;
|
decimal newShares = existing.shares + tx.Amount;
|
||||||
decimal newCost = existing.cost + tx.TotalAmount;
|
decimal newCostInTarget = existing.costInTargetCurrency + txAmountInTarget;
|
||||||
holdings[tx.StockCode] = (newShares, newCost, tx.Currency, tx.AssetType);
|
holdings[tx.StockCode] = (newShares, newCostInTarget, tx.Currency, tx.AssetType);
|
||||||
}
|
}
|
||||||
else
|
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;
|
cumulativeCost += txAmountInTarget;
|
||||||
}
|
}
|
||||||
else if (tx.Type == "sell")
|
else if (tx.Type == "sell")
|
||||||
@ -404,7 +429,7 @@ public class PortfolioNavService : IPortfolioNavService
|
|||||||
|
|
||||||
decimal soldRatio = tx.Amount / existing.shares;
|
decimal soldRatio = tx.Amount / existing.shares;
|
||||||
decimal remainingShares = existing.shares - tx.Amount;
|
decimal remainingShares = existing.shares - tx.Amount;
|
||||||
decimal remainingCost = existing.cost * (1 - (decimal)soldRatio);
|
decimal remainingCostInTarget = existing.costInTargetCurrency * (1 - (decimal)soldRatio);
|
||||||
|
|
||||||
if (remainingShares <= 0)
|
if (remainingShares <= 0)
|
||||||
{
|
{
|
||||||
@ -412,15 +437,15 @@ public class PortfolioNavService : IPortfolioNavService
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
holdings[tx.StockCode] = (remainingShares, remainingCost, existing.currency, existing.assetType);
|
holdings[tx.StockCode] = (remainingShares, remainingCostInTarget, existing.currency, existing.assetType);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按比例减少累计投入成本(修复:使用该标的的成本,而非全局累计成本)
|
// 按比例减少累计投入成本(直接用目标币种成本,无需再次汇率转换)
|
||||||
// 先将该标的的成本转换为目标币种
|
decimal costToReduce = existing.costInTargetCurrency * (decimal)soldRatio;
|
||||||
decimal existingCostInTarget = await _exchangeRateService.ConvertAmountAsync(
|
|
||||||
existing.cost, existing.currency ?? targetCurrency, targetCurrency);
|
|
||||||
decimal costToReduce = existingCostInTarget * (decimal)soldRatio;
|
|
||||||
cumulativeCost -= costToReduce;
|
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;
|
bool hasValidPrice = true;
|
||||||
List<string> failedSymbols = new List<string>();
|
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);
|
var priceResult = await GetHistoricalPriceAsync(stockCode, assetType ?? "Stock", date);
|
||||||
if (priceResult == null || priceResult <= 0)
|
if (priceResult == null || priceResult <= 0)
|
||||||
|
|||||||
@ -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
|
var transaction = new Transaction
|
||||||
{
|
{
|
||||||
Id = "trans-" + Guid.NewGuid().ToString().Substring(0, 8),
|
Id = "trans-" + Guid.NewGuid().ToString().Substring(0, 8),
|
||||||
@ -692,6 +710,8 @@ public class PortfolioService : IPortfolioService
|
|||||||
Price = (decimal)request.Price,
|
Price = (decimal)request.Price,
|
||||||
TotalAmount = (decimal)(request.Price * request.Amount),
|
TotalAmount = (decimal)(request.Price * request.Amount),
|
||||||
Currency = request.Currency,
|
Currency = request.Currency,
|
||||||
|
ExchangeRate = exchangeRate,
|
||||||
|
TotalAmountBase = totalAmountBase,
|
||||||
Status = "completed",
|
Status = "completed",
|
||||||
Remark = request.Remark ?? string.Empty,
|
Remark = request.Remark ?? string.Empty,
|
||||||
TransactionTime = transactionTime,
|
TransactionTime = transactionTime,
|
||||||
|
|||||||
43
sql/migrate_financial_fields.sql
Normal file
43
sql/migrate_financial_fields.sql
Normal file
@ -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;
|
||||||
Loading…
Reference in New Issue
Block a user