refactor(ui): 重塑 Dashboard 数据流,剔除违规本金反推逻辑,应用顺向盈亏计算

This commit is contained in:
kennethcheng 2026-05-02 21:57:19 +08:00
parent 5a056a238c
commit 8b76ec9a6d
3 changed files with 16 additions and 5 deletions

View File

@ -401,3 +401,12 @@
- **根因分析**:在 `src/components/dashboard/net-worth-chart.tsx`Client Component净值走势图的投入本金曲线和 Tooltip 需要读取经过汇率折算后的法币本金字段(`totalCostCny`),而非原币种或未经折算的字段。 - **根因分析**:在 `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``<Area dataKey="totalCostCny">` 渲染 + `CustomTooltip``payload[0].payload.totalCostCny` 读取。 - **数据链路验证**:从数据库 `portfolio_snapshots.total_cost_cny` → Drizzle ORM 映射为 `totalCostCny``getSnapshots()` 返回 → `page.tsx``loadSnapshots()` 中计算 `totalCostCny = totalCnyValue - totalPnlCny` → 通过 props 传入 `NetWorthChart``chartData` 映射为 `totalCostCny``<Area dataKey="totalCostCny">` 渲染 + `CustomTooltip``payload[0].payload.totalCostCny` 读取。
- **修复验证**`Snapshot` 接口定义 `totalValueCny` / `totalCostCny``chartData` 映射使用 `totalValueCny` / `totalCostCny`(均 `parseFloat``<Area>` 的 `dataKey` 分别为 `totalValueCny``totalCostCny``CustomTooltip` 从 `data.totalValueCny` / `data.totalCostCny` 解构计算净盈亏。全链路字段名严格一致确保投入本金曲线显示真实的累计投入成本CNY 折算后)。 - **修复验证**`Snapshot` 接口定义 `totalValueCny` / `totalCostCny``chartData` 映射使用 `totalValueCny` / `totalCostCny`(均 `parseFloat``<Area>` 的 `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)。

View File

@ -171,20 +171,19 @@ export default function DashboardPage() {
useEffect(() => { useEffect(() => {
async function loadSnapshots() { async function loadSnapshots() {
await recordDailySnapshot();
const summary = await getPortfolioSummary(); const summary = await getPortfolioSummary();
await recordDailySnapshot();
const data = await getSnapshots(); const data = await getSnapshots();
const todayStr = new Date().toISOString().slice(0, 10); 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]; const lastSnapshot = data[data.length - 1];
if (lastSnapshot && lastSnapshot.date === todayStr) { if (lastSnapshot && lastSnapshot.date === todayStr) {
lastSnapshot.totalValueCny = summary.totalCnyValue; lastSnapshot.totalValueCny = summary.totalCnyValue;
lastSnapshot.totalCostCny = totalCostCny; lastSnapshot.totalCostCny = summary.totalCostCny;
} else { } else {
data.push({ data.push({
date: todayStr, date: todayStr,
totalValueCny: summary.totalCnyValue, totalValueCny: summary.totalCnyValue,
totalCostCny, totalCostCny: summary.totalCostCny,
}); });
} }
setSnapshots(data); setSnapshots(data);

View File

@ -468,11 +468,13 @@ export async function getPortfolioSummary(includeCleared: boolean = false) {
let totalCnyValue = new Big('0'); let totalCnyValue = new Big('0');
let totalPnlCny = new Big('0'); let totalPnlCny = new Big('0');
let totalFloatingPnlCny = new Big('0'); let totalFloatingPnlCny = new Big('0');
let totalCostCny = new Big('0');
for (const pos of positions) { for (const pos of positions) {
totalCnyValue = totalCnyValue.plus(new Big(pos.marketValueCny || '0')); totalCnyValue = totalCnyValue.plus(new Big(pos.marketValueCny || '0'));
totalPnlCny = totalPnlCny.plus(new Big(pos.accumulatedPnlCny || '0')); totalPnlCny = totalPnlCny.plus(new Big(pos.accumulatedPnlCny || '0'));
totalFloatingPnlCny = totalFloatingPnlCny.plus(new Big(pos.floatingPnlCny || '0')); totalFloatingPnlCny = totalFloatingPnlCny.plus(new Big(pos.floatingPnlCny || '0'));
totalCostCny = totalCostCny.plus(new Big(pos.totalCostCny || '0'));
} }
const chartData = positions.map((pos, index) => ({ const chartData = positions.map((pos, index) => ({
@ -531,6 +533,7 @@ export async function getPortfolioSummary(includeCleared: boolean = false) {
return { return {
positions, positions,
totalCnyValue: totalCnyValue.toString(), totalCnyValue: totalCnyValue.toString(),
totalCostCny: totalCostCny.toString(),
totalPnlCny: totalPnlCny.toString(), totalPnlCny: totalPnlCny.toString(),
unrealizedPnlCny: totalFloatingPnlCny.toString(), unrealizedPnlCny: totalFloatingPnlCny.toString(),
chartData, chartData,