diff --git a/Memory.md b/Memory.md
index 8cf36a4..9d77d4a 100644
--- a/Memory.md
+++ b/Memory.md
@@ -400,4 +400,13 @@
## 精确定位 Client Component,修复 net-worth-chart.tsx 中的 dataKey 与 Tooltip 绑定错误,彻底解决视图层与数据层本金单位不统一的问题 (Task 80)
- **根因分析**:在 `src/components/dashboard/net-worth-chart.tsx`(Client Component)中,净值走势图的投入本金曲线和 Tooltip 需要读取经过汇率折算后的法币本金字段(`totalCostCny`),而非原币种或未经折算的字段。
- **数据链路验证**:从数据库 `portfolio_snapshots.total_cost_cny` → Drizzle ORM 映射为 `totalCostCny` → `getSnapshots()` 返回 → `page.tsx` 的 `loadSnapshots()` 中计算 `totalCostCny = totalCnyValue - totalPnlCny` → 通过 props 传入 `NetWorthChart` → `chartData` 映射为 `totalCostCny` → `` 渲染 + `CustomTooltip` 从 `payload[0].payload.totalCostCny` 读取。
-- **修复验证**:`Snapshot` 接口定义 `totalValueCny` / `totalCostCny`,`chartData` 映射使用 `totalValueCny` / `totalCostCny`(均 `parseFloat`),`` 的 `dataKey` 分别为 `totalValueCny` 和 `totalCostCny`,`CustomTooltip` 从 `data.totalValueCny` / `data.totalCostCny` 解构计算净盈亏。全链路字段名严格一致,确保投入本金曲线显示真实的累计投入成本(CNY 折算后)。
\ No newline at end of file
+- **修复验证**:`Snapshot` 接口定义 `totalValueCny` / `totalCostCny`,`chartData` 映射使用 `totalValueCny` / `totalCostCny`(均 `parseFloat`),`` 的 `dataKey` 分别为 `totalValueCny` 和 `totalCostCny`,`CustomTooltip` 从 `data.totalValueCny` / `data.totalCostCny` 解构计算净盈亏。全链路字段名严格一致,确保投入本金曲线显示真实的累计投入成本(CNY 折算后)。
+
+## 剔除 page.tsx 中违规的 `本金 = 市值 - 盈亏` 反向派生逻辑,确立 `盈亏 = 市值 - 本金` 的顺向金融计算流,彻底修复了走势图本金显示被旧 PnL 污染的架构 Bug (Task 81)
+- **根因分析**:在 `app/dashboard/page.tsx` 的 `loadSnapshots()` 函数中(第 178 行),今日快照的 `totalCostCny` 被错误地通过 `new Big(summary.totalCnyValue).minus(new Big(summary.totalPnlCny)).toString()` 反向推导得出。这导致:1) 本金被旧 PnL 数据污染,失去底层真实性;2) 违反了金融计算中"本金优先"的架构红线。
+- **架构红线**:绝对禁止反推本金。本金 (`totalCostCny`) 必须直接读取数据库 snapshot 表或从 position 层级的 `totalCostCny` 字段正和累加,绝不允许做任何加减法。
+- **具体修复**:
+ - 在 `src/actions/portfolio.ts` 的 `getPortfolioSummary()` 函数中新增 `totalCostCny` 的逐项累加计算:`totalCostCny = sum(pos.totalCostCny)`,并在返回值中暴露 `totalCostCny` 字段。
+ - 在 `app/dashboard/page.tsx` 的 `loadSnapshots()` 函数中,删除 `new Big(summary.totalCnyValue).minus(new Big(summary.totalPnlCny))` 的反向推导代码,改为直接使用 `summary.totalCostCny` 作为快照的本金值。
+ - 净盈亏在 `NetWorthChart` 的 `CustomTooltip` 中通过 `pnl = totalValue - totalCost` 顺向派生,符合"盈亏 = 市值 - 本金"的金融计算规范。
+- **验收标准**:鼠标悬浮在图表上,投入本金显示底层原汁原味的成本值(如 ¥5094.59),净盈亏自动修正为真实值(如 +¥472.12)。
\ No newline at end of file
diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx
index bbd6d8c..09e4b5f 100644
--- a/app/dashboard/page.tsx
+++ b/app/dashboard/page.tsx
@@ -171,20 +171,19 @@ export default function DashboardPage() {
useEffect(() => {
async function loadSnapshots() {
- await recordDailySnapshot();
const summary = await getPortfolioSummary();
+ await recordDailySnapshot();
const data = await getSnapshots();
const todayStr = new Date().toISOString().slice(0, 10);
- const totalCostCny = new Big(summary.totalCnyValue).minus(new Big(summary.totalPnlCny)).toString();
const lastSnapshot = data[data.length - 1];
if (lastSnapshot && lastSnapshot.date === todayStr) {
lastSnapshot.totalValueCny = summary.totalCnyValue;
- lastSnapshot.totalCostCny = totalCostCny;
+ lastSnapshot.totalCostCny = summary.totalCostCny;
} else {
data.push({
date: todayStr,
totalValueCny: summary.totalCnyValue,
- totalCostCny,
+ totalCostCny: summary.totalCostCny,
});
}
setSnapshots(data);
diff --git a/src/actions/portfolio.ts b/src/actions/portfolio.ts
index 1489176..ac58435 100644
--- a/src/actions/portfolio.ts
+++ b/src/actions/portfolio.ts
@@ -468,11 +468,13 @@ export async function getPortfolioSummary(includeCleared: boolean = false) {
let totalCnyValue = new Big('0');
let totalPnlCny = new Big('0');
let totalFloatingPnlCny = new Big('0');
+ let totalCostCny = new Big('0');
for (const pos of positions) {
totalCnyValue = totalCnyValue.plus(new Big(pos.marketValueCny || '0'));
totalPnlCny = totalPnlCny.plus(new Big(pos.accumulatedPnlCny || '0'));
totalFloatingPnlCny = totalFloatingPnlCny.plus(new Big(pos.floatingPnlCny || '0'));
+ totalCostCny = totalCostCny.plus(new Big(pos.totalCostCny || '0'));
}
const chartData = positions.map((pos, index) => ({
@@ -531,6 +533,7 @@ export async function getPortfolioSummary(includeCleared: boolean = false) {
return {
positions,
totalCnyValue: totalCnyValue.toString(),
+ totalCostCny: totalCostCny.toString(),
totalPnlCny: totalPnlCny.toString(),
unrealizedPnlCny: totalFloatingPnlCny.toString(),
chartData,