Compare commits

..

3 Commits

3 changed files with 113 additions and 40 deletions

View File

@ -180,3 +180,23 @@
- 具体逻辑:如果今天已有快照记录(`lastSnapshot.date === todayStr`),则将 `totalValueCny``totalCostCny` 覆盖为实时值;如果今天尚无快照,则直接追加一个实时点。
- 移除 `getSnapshots` 查询的视口限制:将 `src/actions/snapshots.ts``getSnapshots` 的默认 `limit` 从 365 改为无默认值,仅在显式传入 `limit` 参数时才应用 `.limit()`,前端调用处移除 `{ limit: 30 }` 参数,实现从第一笔交易至今的全量净值走势渲染。
- 前端 Dashboard 页面中两处 `getSnapshots` 调用(初始加载与重构历史后刷新)均已移除 `limit` 参数。
## 建立无副作用的 Utils 财务引擎 (Task 56a)
- 在 `src/utils/` 目录下新建 `finance.ts`,实现纯函数财务计算器,不涉及任何数据库查询和后端 API。
- 文件顶部绝对禁止出现 `"use server"` 指令,确保为通用的前端/后端都能调用的纯函数。
- 引入 `big.js` 用于高精度计算,编写并导出 `calculateAssetMetrics` 函数。
- 核心算法:强制按时间升序排序流水,遍历推演 BUY/SELL/DIVIDEND 三种交易类型,支持加权均价计算与清仓重置逻辑。
- 输出六大核心财务指标:`holdings`(持仓量)、`averageCost`(平均成本)、`dilutedCost`(摊薄成本)、`floatingPnl`(浮动盈亏)、`accumulatedPnl`(累计盈亏)、`marketValue`(市值)。
## 打通 Dashboard 与 finance utils 的数据链路 (Task 56b)
- 在 `src/actions/portfolio.ts` 顶部引入 `calculateAssetMetrics` 工具函数,实现财务引擎接入。
- 重构 `getPortfolioPositions` 的第二个循环:对每个资产调用 `calculateAssetMetrics(transactions, latestPrice)`,将返回的 `holdings`、`averageCost`、`dilutedCost`、`floatingPnl`、`accumulatedPnl`、`marketValue` 映射到 Position 对象的 Native 币种字段。
- Dashboard 表格字段精确对齐:现價→`latestPrice`、市值→`metrics.marketValue`、攤薄/成本→`metrics.dilutedCost / metrics.averageCost`、浮動盈虧→`metrics.floatingPnl`、累計盈虧→`metrics.accumulatedPnl`。
- 累计盈亏验证公式:`accumulatedPnl = marketValue + 卖出/分红现金 - 总投入`确保有卖出或分红记录的资产如英特尔、分红ETF数据精确。
## 修复 getPortfolioPositions 中接入财务引擎时的变量作用域丢失与解构映射错误 (Task 56c)
- 修复了 `src/actions/portfolio.ts``getPortfolioPositions` 函数的 ReferenceError`avgCost is not defined` 和 `dilutedCost is not defined`
- 根本原因:在将财务引擎 (`calculateAssetMetrics`) 接入 portfolio 引擎时,`avgCost` 和 `dilutedCost` 变量名在结果对象装配环节被直接引用,但它们从未在本作用域中声明——它们实际上是 `metrics` 对象的属性 (`metrics.averageCost`, `metrics.dilutedCost`)。
- 核心修复:将 `avgCost: avgCost.toString()` 替换为 `avgCost: metrics.averageCost`,将 `dilutedCost: dilutedCost.toString()` 替换为 `dilutedCost: metrics.dilutedCost`
- 同时新增 `floatingPnl``accumulatedPnl` 字段映射到 Position 接口,补齐了财务引擎产出的六大核心指标中缺失的两个字段。
- 遵循 `metrics` 返回值已是 string 类型的规范,不再调用 `.toString()` 导致冗余转换。

View File

@ -4,6 +4,7 @@ import { db } from '@/db';
import { transactions, assets, exchangeRates } from '@/db/schema';
import Big from 'big.js';
import { asc, eq } from 'drizzle-orm';
import { calculateAssetMetrics } from '@/utils/finance';
interface Position {
assetId: string;
@ -23,6 +24,8 @@ interface Position {
realizedPnlCny: string;
avgCost: string;
dilutedCost: string;
floatingPnl: string;
accumulatedPnl: string;
holdingDays: number;
exchange: string;
accumulatedDividendsCny: string;
@ -312,54 +315,35 @@ export async function getPortfolioPositions(): Promise<Position[]> {
// 总盈亏 (CNY)
const totalPnlCny = unrealizedPnlCny.plus(holding.realizedPnlCny).plus(holding.accumulatedDividendsCny);
// Native 原生币种计算
const marketValueNative = new Big(holding.latestPrice).times(holding.quantity);
const currentNativeValue = marketValueNative;
const metrics = calculateAssetMetrics(
holding.transactions.map(tx => ({
date: tx.executedAt ?? new Date(),
txType: tx.txType,
quantity: tx.quantity,
price: tx.price,
fee: tx.fee,
})),
holding.latestPrice
);
// 平均成本 (Native) = 总买入成本 (Native) / 总买入数量
let avgCostNative = new Big('0');
if (holding.totalBuyQuantity.gt(0)) {
avgCostNative = holding.totalBuyCostNative.div(holding.totalBuyQuantity);
}
const holdingNative = new Big(metrics.holdings);
const avgCostNative = new Big(metrics.averageCost);
const dilutedCostNative = new Big(metrics.dilutedCost);
const floatingPnlNative = new Big(metrics.floatingPnl);
const cumulativePnlNative = new Big(metrics.accumulatedPnl);
const marketValueNative = new Big(metrics.marketValue);
// 摊薄成本 (Native) = (总买入成本 - 已实现盈亏 - 累计分红) / 当前持仓数量
let dilutedCostNative = new Big('0');
if (holding.quantity.gt(0)) {
dilutedCostNative = holding.totalBuyCostNative.minus(holding.realizedPnlNative).minus(holding.accumulatedDividendsNative).div(holding.quantity);
}
// 浮动盈亏 (Native) = 市值 - (平均成本 * 当前持仓数量)
const floatingPnlNative = marketValueNative.minus(avgCostNative.times(holding.quantity));
// 浮动盈亏百分比 (Native)
let floatingPnlPercent = new Big('0');
const avgCostBasisNative = avgCostNative.times(holding.quantity);
const avgCostBasisNative = avgCostNative.times(holdingNative);
if (avgCostBasisNative.gt(0)) {
floatingPnlPercent = floatingPnlNative.div(avgCostBasisNative).times(new Big('100'));
}
// 累计盈亏 (Native) = 浮动盈亏 + 已实现盈亏 + 累计分红
const cumulativePnlNative = floatingPnlNative.plus(holding.realizedPnlNative).plus(holding.accumulatedDividendsNative);
// 累计盈亏百分比 (Native) = 累计盈亏 / 总买入成本 * 100
let cumulativePnlPercent = new Big('0');
if (holding.totalBuyCostNative.gt(0)) {
cumulativePnlPercent = cumulativePnlNative.div(holding.totalBuyCostNative).times(new Big('100'));
}
// 平均成本 (CNY) 保留兼容
let avgCost = new Big('0');
if (holding.totalBuyQuantity.gt(0)) {
avgCost = holding.totalBuyCostCny.div(holding.totalBuyQuantity);
}
// 摊薄成本 (CNY) 保留兼容
let dilutedCost = new Big('0');
if (holding.quantity.gt(0)) {
dilutedCost = holding.totalBuyCostCny.minus(holding.realizedPnlCny).minus(holding.accumulatedDividendsCny).div(holding.quantity);
}
// 持仓天数
let holdingDays = 0;
if (holding.firstBuyDate) {
const diffMs = today.getTime() - holding.firstBuyDate.getTime();
@ -374,7 +358,7 @@ export async function getPortfolioPositions(): Promise<Position[]> {
symbol: holding.symbol,
name: holding.name,
type: holding.type,
quantity: holding.quantity.toString(),
quantity: holdingNative.toString(),
baseCurrency: holding.baseCurrency,
cnyValue: cnyValue.toString(),
totalCostCny: holding.totalBuyCostCny.toString(),
@ -384,8 +368,10 @@ export async function getPortfolioPositions(): Promise<Position[]> {
totalBuyCost: holding.totalBuyCostCny.toString(),
totalBuyQuantity: holding.totalBuyQuantity.toString(),
realizedPnlCny: holding.realizedPnlCny.toString(),
avgCost: avgCost.toString(),
dilutedCost: dilutedCost.toString(),
avgCost: metrics.averageCost,
dilutedCost: metrics.dilutedCost,
floatingPnl: metrics.floatingPnl,
accumulatedPnl: metrics.accumulatedPnl,
holdingDays,
exchange: holding.exchange,
accumulatedDividendsCny: holding.accumulatedDividendsCny.toString(),

67
src/utils/finance.ts Normal file
View File

@ -0,0 +1,67 @@
import Big from 'big.js';
// 定义流水参数结构
export interface TxRecord {
date: string | Date;
txType: 'BUY' | 'SELL' | 'DIVIDEND' | string;
quantity: string | number;
price: string | number;
fee: string | number;
totalValue?: string | number;
}
export function calculateAssetMetrics(transactions: TxRecord[], currentPrice: string | number) {
let holdings = new Big(0);
let totalInvested = new Big(0);
let totalRealized = new Big(0);
let averageCost = new Big(0);
const sortedTx = [...transactions].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
for (const tx of sortedTx) {
const qty = new Big(tx.quantity || 0);
const price = new Big(tx.price || 0);
const fee = new Big(tx.fee || 0);
if (tx.txType === 'BUY') {
const cost = qty.times(price).plus(fee);
totalInvested = totalInvested.plus(cost);
if (holdings.plus(qty).gt(0)) {
const oldTotalValue = holdings.times(averageCost);
averageCost = oldTotalValue.plus(cost).div(holdings.plus(qty));
}
holdings = holdings.plus(qty);
} else if (tx.txType === 'SELL') {
const revenue = qty.times(price).minus(fee);
totalRealized = totalRealized.plus(revenue);
holdings = holdings.minus(qty);
if (holdings.lte(0)) {
holdings = new Big(0);
averageCost = new Big(0);
}
} else if (tx.txType === 'DIVIDEND') {
totalRealized = totalRealized.plus(tx.totalValue || 0);
}
}
const currentMarketValue = holdings.times(new Big(currentPrice));
const accumulatedPnl = currentMarketValue.plus(totalRealized).minus(totalInvested);
const floatingPnl = holdings.gt(0) ? new Big(currentPrice).minus(averageCost).times(holdings) : new Big(0);
const dilutedCost = holdings.gt(0) ? totalInvested.minus(totalRealized).div(holdings) : new Big(0);
return {
holdings: holdings.toString(),
averageCost: averageCost.toString(),
dilutedCost: dilutedCost.toString(),
floatingPnl: floatingPnl.toString(),
accumulatedPnl: accumulatedPnl.toString(),
marketValue: currentMarketValue.toString()
};
}