Compare commits
No commits in common. "b76a6ef5778d3eabf080a78106e2c653b31135b4" and "2570144112a1036de3ce8ee71a5446c7f67c7058" have entirely different histories.
b76a6ef577
...
2570144112
36
Memory.md
36
Memory.md
@ -1,41 +1,5 @@
|
|||||||
# Omniledger 架构与开发记忆 (Memory)
|
# Omniledger 架构与开发记忆 (Memory)
|
||||||
|
|
||||||
## 重构时光机底层引擎,引入基于 lte 的历史价格/汇率向后穿透查询,解决数据断层导致的 0 价格黑洞与汇率串用 Bug (Task 72)
|
|
||||||
- 在 `src/actions/snapshots.ts` 的 `reconstructPortfolioHistory()` 中,将汇率获取从"一次性全量加载"重构为"按天循环顶部动态构建":每天 `targetDate` 循环开始时调用 `buildDailyRatesMap(dateStr)`,查询 `exchange_rates_history` 中 `fetch_time <= targetDate` 的所有记录,按 `(fromCurrency, toCurrency)` 分组构建当日汇率字典,O(1) 内存访问。
|
|
||||||
- **汇率兜底安全值**:USD → 7.22,HKD → 0.92,CNY → 1,确保新系统建的老账单查不到历史汇率时不会崩溃。
|
|
||||||
- **价格向后穿透修复**:废弃 `getEffectivePrice` 中查不到价格时回退到 `latestPrice` 的逻辑,改为新增 `getHistoricalPriceWithFallback(assetId, dateStr, fallbackCostPrice)` 函数,使用 `lte(date, targetDate)` + `orderBy(desc)` + `limit(1)` 查询历史价格;若该资产连一笔历史价格都没有(如 NXE),将持仓成本价(`totalCost / quantity`)作为 `snapshotPrice` 传入,保证市值不归零。
|
|
||||||
- **币种汇率精准匹配**:在资产计算循环中,严格根据 `baseCurrency` 从 `dailyRates` 字典中取值(如 USD 资产取 `dailyRates['USD']`,HKD 资产取 `dailyRates['HKD']`),彻底杜绝 USD/HKD 汇率串用问题。
|
|
||||||
- 同步修复 `app/api/debug/snapshot/route.ts` X光机接口:废弃原有的 `getHistoricalPrice`(未 await 执行导致恒返回 null),全面接入 `buildDailyRatesMap` + `getHistoricalPriceWithFallback` 双引擎,确保调试接口与时光机引擎逻辑完全一致。
|
|
||||||
|
|
||||||
## 新增 /api/debug/snapshot X光透视接口,用于针对特定日期的历史资产快照进行逐笔对账,排查历史总资产异常波动的元凶 (Task 71)
|
|
||||||
- 在 `app/api/debug/snapshot/route.ts` 创建 GET 接口,接收 `date` 或 `targetDate` 查询参数(默认 `2026-04-30`)。
|
|
||||||
- 复用 `src/actions/snapshots.ts` 中 `getHistoricalPositions()` 的核心持仓推演逻辑:从 `transactions` 表获取目标日期 23:59:59 之前的所有流水,按资产聚合计算 `quantity`(持仓量)和 `totalCost`(累计投入成本,SELL 时按平均成本扣减)。
|
|
||||||
- 对每个持仓资产,通过 `assetPricesHistory` 表按 `(assetId, date <= targetDate)` 降序 Limit 1 获取当日收盘价(断点结转),通过 `exchangeRatesHistory` 表按就近匹配策略获取当日汇率(Base Currency → CNY)。
|
|
||||||
- 返回逐笔明细数组 `details`,每项包含 `symbol`、`quantity`、`snapshotPrice`、`snapshotFxRate`、`calculatedMarketValueCny`(持仓量 × 快照价 × 快照汇率)、`calculatedCostCny`(累计成本 × 快照汇率),以及汇总的 `totalMarketValue` 和 `totalCost`。
|
|
||||||
- 结果按 `calculatedMarketValueCny` 降序排列,`snapshotPrice` 和 `snapshotFxRate` 经 Big.js 去零清洗,确保可读性。
|
|
||||||
- 访问 `http://localhost:3000/api/debug/snapshot?date=2026-04-30` 可验证,2026-04-30 快照覆盖 19 个资产,总市值 961,037.69 CNY,总成本 1,545,059.23 CNY。
|
|
||||||
|
|
||||||
## 优化 exportToCSV 功能,基于 type 和 baseCurrency 的交叉判定注入了'市场'属性分类列,便于在外部进行资产敞口分析 (Task 70)
|
|
||||||
|
|
||||||
## 优化 exportToCSV 功能,基于 type 和 baseCurrency 的交叉判定注入了'市场'属性分类列,便于在外部进行资产敞口分析 (Task 70)
|
|
||||||
- 在 `app/dashboard/page.tsx` 的 `exportToCSV()` 函数中新增 `getMarketName(item)` 纯函数,实现市场维度的智能推导。
|
|
||||||
- 判定优先级:`type === 'CRYPTO'` → `baseCurrency` 硬核锚定 (USD→美股, HKD→港股, CNY/RMB→A股) → 正则兜底 (5位数字→港股, 60/00/30开头→A股) → 默认"其他市场"。
|
|
||||||
- CSV 表头在"代码"之后插入"市场"列,`rows` 映射严格对齐,确保 BTC 显示"虚拟币"、谷歌显示"美股"、小米显示"港股"、上海机场显示"A股"。
|
|
||||||
- 文件仍带 `\uFEFF` BOM 头,Excel 打开中文不乱码。
|
|
||||||
|
|
||||||
## 优化 exportToCSV 数据净洗逻辑,利用防科学计数法的纯正则处理去除现价/成本价末尾的无意义零 (Task 69)
|
|
||||||
- 在 `app/dashboard/page.tsx` 的 `exportToCSV()` 函数顶部注入 `stripTrailingZeros` 纯字符串去零工具函数。
|
|
||||||
- 该函数内置防御性设计:对 null/undefined/空值返回 `"0"`;仅对包含小数点的字符串执行正则处理(`/0+$/` 剥离尾随零 → `/\.$/` 剥离末尾小数点),彻底杜绝极小数值(如 `0.00000001`)被 `String()` 转换后触发科学计数法(`1e-8`)。
|
|
||||||
- 将 CSV 映射层中的"成本价"字段(`avgCostNative`)和"现价"字段(`latestPrice`)包裹 `stripTrailingZeros()`,使小米 `29.020` 输出为 `29.02`、整数价格 `29.00` 输出为 `29`,而"总市值""浮动盈亏""累计盈亏"等法币资产字段保留 `.toFixed(2)` 的两位小数格式以维持表格对齐。
|
|
||||||
|
|
||||||
## 新增持仓明细的已清仓资产显示开关(基于 1e-8 精度容差过滤),并实装注入 UTF-8 BOM 的客户端 CSV 导出功能 (Task 68)
|
|
||||||
- 在 `src/actions/portfolio.ts` 的 `getPortfolioPositions()` 函数中新增 `includeCleared: boolean = false` 参数,将清仓判定从 `quantity === 0` 升级为 `Big.js` 的 `1e-8` 精度容差过滤(`holding.quantity.gt('1e-8')`),杜绝浮点数灰尘资产被错误保留或隐藏。
|
|
||||||
- 当 `!includeCleared` 时,自动过滤掉持仓量 ≤ 1e-8 的已清仓资产;当 `includeCleared` 为 true 时,已清仓资产会被返回,其 `marketValue` 和 `floatingPnl` 为 0,但**保留真实计算出的 `accumulatedPnl`(累计盈亏)字段**,确保历史盈亏数据不丢失。
|
|
||||||
- 在 `app/dashboard/page.tsx` 的"持仓明细"卡片头部新增 Checkbox(显示"👁 显示历史持仓")和 Button("📥 导出 CSV"),Checkbox 切换时基于 `Big(pos.quantity).gt('1e-8')` 在前端动态过滤列表,无需额外请求。
|
|
||||||
- 编写 `exportToCSV()` 客户端导出函数:定义中文字段表头(资产名称、代码、持仓量、成本价、现价、总市值、浮动盈亏、累计盈亏),将当前表格数据映射为 CSV 格式,**注入 `\uFEFF` UTF-8 BOM 头**并创建 Blob 触发下载,彻底解决 Excel 打开中文乱码问题。
|
|
||||||
- 文件名格式为 `portfolio_details_YYYY-MM-DD.csv`,所有数值字段用双引号包裹防止逗号破坏 CSV 格式。
|
|
||||||
- 同步更新 `getPortfolioSummary(includeCleared)` 和 `recordDailySnapshot()` 以传递参数。
|
|
||||||
|
|
||||||
## 修复腾讯行情接口 URL 拼接逻辑,剔除导致数据残缺的 s_ (简易版) 前缀,确保所有市场强制获取包含时间戳的全量报文 (Task 67)
|
## 修复腾讯行情接口 URL 拼接逻辑,剔除导致数据残缺的 s_ (简易版) 前缀,确保所有市场强制获取包含时间戳的全量报文 (Task 67)
|
||||||
- 在 `app/api/cron/fetch-prices/route.ts` 的 `fetchStockPrice()` 函数与 `src/actions/market.ts` 的 `getTencentSymbol()` 函数中,将美股资产的前缀映射从 `'s_us'` 强制重构为 `'us'`。
|
- 在 `app/api/cron/fetch-prices/route.ts` 的 `fetchStockPrice()` 函数与 `src/actions/market.ts` 的 `getTencentSymbol()` 函数中,将美股资产的前缀映射从 `'s_us'` 强制重构为 `'us'`。
|
||||||
- **根因分析:** 腾讯财经 gtimg 接口使用 `s_us` 前缀时返回的是"简易版"报文(仅 ~10 个字段),缺失 Index 30 的日期时间字段;使用 `us` 前缀时返回"全量版"报文(60+ 个字段),包含完整的交易时间戳 `2026-05-01 16:00:06`。
|
- **根因分析:** 腾讯财经 gtimg 接口使用 `s_us` 前缀时返回的是"简易版"报文(仅 ~10 个字段),缺失 Index 30 的日期时间字段;使用 `us` 前缀时返回"全量版"报文(60+ 个字段),包含完整的交易时间戳 `2026-05-01 16:00:06`。
|
||||||
|
|||||||
@ -1,255 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
import { db } from '@/db';
|
|
||||||
import {
|
|
||||||
transactions,
|
|
||||||
assets,
|
|
||||||
assetPricesHistory,
|
|
||||||
exchangeRatesHistory,
|
|
||||||
} from '@/db/schema';
|
|
||||||
import { and, asc, desc, eq, lte } from 'drizzle-orm';
|
|
||||||
import Big from 'big.js';
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
|
|
||||||
function formatDateString(date: Date): string {
|
|
||||||
const yyyy = date.getFullYear();
|
|
||||||
const mm = String(date.getMonth() + 1).padStart(2, '0');
|
|
||||||
const dd = String(date.getDate()).padStart(2, '0');
|
|
||||||
return `${yyyy}-${mm}-${dd}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RateRecord {
|
|
||||||
rate: string;
|
|
||||||
fetchTime: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function buildDailyRatesMap(targetDateStr: string): Promise<Record<string, Big>> {
|
|
||||||
const allRates = await db
|
|
||||||
.select({
|
|
||||||
fromCurrency: exchangeRatesHistory.fromCurrency,
|
|
||||||
toCurrency: exchangeRatesHistory.toCurrency,
|
|
||||||
rate: exchangeRatesHistory.rate,
|
|
||||||
fetchTime: exchangeRatesHistory.fetchTime,
|
|
||||||
})
|
|
||||||
.from(exchangeRatesHistory)
|
|
||||||
.where(lte(exchangeRatesHistory.fetchTime, new Date(targetDateStr + 'T23:59:59')))
|
|
||||||
.orderBy(asc(exchangeRatesHistory.fetchTime));
|
|
||||||
|
|
||||||
const ratesCache = new Map<string, RateRecord[]>();
|
|
||||||
for (const rec of allRates) {
|
|
||||||
const key = `${rec.fromCurrency}_${rec.toCurrency}`;
|
|
||||||
if (!ratesCache.has(key)) {
|
|
||||||
ratesCache.set(key, []);
|
|
||||||
}
|
|
||||||
ratesCache.get(key)!.push({ rate: rec.rate, fetchTime: rec.fetchTime });
|
|
||||||
}
|
|
||||||
|
|
||||||
function getClosestRateForDate(currencyPair: string): string | null {
|
|
||||||
const records = ratesCache.get(currencyPair);
|
|
||||||
if (!records || records.length === 0) return null;
|
|
||||||
const targetDt = new Date(targetDateStr + 'T00:00:00Z');
|
|
||||||
let closest: RateRecord | null = null;
|
|
||||||
for (const rec of records) {
|
|
||||||
if (rec.fetchTime <= targetDt) {
|
|
||||||
closest = rec;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return closest?.rate ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveRate(from: string, to: string): string | null {
|
|
||||||
const directKey = `${from}_${to}`;
|
|
||||||
const directRate = getClosestRateForDate(directKey);
|
|
||||||
if (directRate) return directRate;
|
|
||||||
|
|
||||||
const usdKey = `USD_${to}`;
|
|
||||||
const usdRate = getClosestRateForDate(usdKey);
|
|
||||||
if (!usdRate) return null;
|
|
||||||
if (from === 'USD') return usdRate;
|
|
||||||
|
|
||||||
const fromToUsdKey = `${from}_USD`;
|
|
||||||
const fromToUsdRate = getClosestRateForDate(fromToUsdKey);
|
|
||||||
if (fromToUsdRate) {
|
|
||||||
return new Big(fromToUsdRate).times(new Big(usdRate)).toString();
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hkdRate = resolveRate('HKD', 'CNY');
|
|
||||||
const usdRate = resolveRate('USD', 'CNY');
|
|
||||||
|
|
||||||
return {
|
|
||||||
USD: new Big(usdRate || '7.22'),
|
|
||||||
HKD: new Big(hkdRate || '0.92'),
|
|
||||||
CNY: new Big(1),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getHistoricalPriceWithFallback(assetId: string, dateStr: string, fallbackCostPrice: string): Promise<string> {
|
|
||||||
const [record] = await db
|
|
||||||
.select({ price: assetPricesHistory.price })
|
|
||||||
.from(assetPricesHistory)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(assetPricesHistory.assetId, assetId),
|
|
||||||
lte(assetPricesHistory.date, dateStr)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.orderBy(desc(assetPricesHistory.date))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (record?.price) {
|
|
||||||
return record.price;
|
|
||||||
}
|
|
||||||
|
|
||||||
return fallbackCostPrice;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(req: Request) {
|
|
||||||
const { searchParams } = new URL(req.url);
|
|
||||||
const targetDateParam = searchParams.get('date') ?? searchParams.get('targetDate');
|
|
||||||
|
|
||||||
let targetDateStr: string;
|
|
||||||
if (targetDateParam) {
|
|
||||||
targetDateStr = targetDateParam.trim();
|
|
||||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(targetDateStr)) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Invalid date format. Use YYYY-MM-DD, e.g. 2026-04-30' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
targetDateStr = '2026-04-30';
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetDate = new Date(targetDateStr + 'T23:59:59');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const allTransactions = await db
|
|
||||||
.select({
|
|
||||||
assetId: transactions.assetId,
|
|
||||||
txType: transactions.txType,
|
|
||||||
quantity: transactions.quantity,
|
|
||||||
price: transactions.price,
|
|
||||||
exchangeRate: transactions.exchangeRate,
|
|
||||||
executedAt: transactions.executedAt,
|
|
||||||
})
|
|
||||||
.from(transactions)
|
|
||||||
.where(lte(transactions.executedAt, targetDate))
|
|
||||||
.orderBy(asc(transactions.executedAt));
|
|
||||||
|
|
||||||
const holdings = new Map<string, {
|
|
||||||
quantity: Big;
|
|
||||||
totalCost: Big;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
for (const tx of allTransactions) {
|
|
||||||
if (!tx.assetId) continue;
|
|
||||||
|
|
||||||
const existing = holdings.get(tx.assetId);
|
|
||||||
if (!existing) {
|
|
||||||
holdings.set(tx.assetId, {
|
|
||||||
quantity: new Big('0'),
|
|
||||||
totalCost: new Big('0'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const holding = holdings.get(tx.assetId)!;
|
|
||||||
const qty = new Big(tx.quantity);
|
|
||||||
|
|
||||||
if (tx.txType === 'BUY') {
|
|
||||||
holding.quantity = holding.quantity.plus(qty);
|
|
||||||
const cost = qty.times(new Big(tx.price)).times(new Big(tx.exchangeRate || '1'));
|
|
||||||
holding.totalCost = holding.totalCost.plus(cost);
|
|
||||||
} else if (tx.txType === 'SELL') {
|
|
||||||
let avgCostPerUnit = new Big('0');
|
|
||||||
if (holding.quantity.gt(0)) {
|
|
||||||
avgCostPerUnit = holding.totalCost.div(holding.quantity);
|
|
||||||
}
|
|
||||||
const sellCost = avgCostPerUnit.times(qty);
|
|
||||||
holding.quantity = holding.quantity.minus(qty);
|
|
||||||
holding.totalCost = holding.totalCost.minus(sellCost);
|
|
||||||
} else if (tx.txType === 'AIRDROP') {
|
|
||||||
holding.quantity = holding.quantity.plus(qty);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const allAssets = await db
|
|
||||||
.select({
|
|
||||||
id: assets.id,
|
|
||||||
symbol: assets.symbol,
|
|
||||||
baseCurrency: assets.baseCurrency,
|
|
||||||
})
|
|
||||||
.from(assets);
|
|
||||||
|
|
||||||
const assetMap = new Map<string, { symbol: string; baseCurrency: string }>();
|
|
||||||
for (const a of allAssets) {
|
|
||||||
assetMap.set(a.id, { symbol: a.symbol, baseCurrency: a.baseCurrency || 'USD' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const dailyRates = await buildDailyRatesMap(targetDateStr);
|
|
||||||
|
|
||||||
const details: Array<{
|
|
||||||
symbol: string;
|
|
||||||
quantity: number;
|
|
||||||
snapshotPrice: string;
|
|
||||||
snapshotFxRate: string;
|
|
||||||
calculatedMarketValueCny: string;
|
|
||||||
calculatedCostCny: string;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
let totalMarketValue = new Big('0');
|
|
||||||
let totalCost = new Big('0');
|
|
||||||
|
|
||||||
for (const [assetId, holding] of holdings) {
|
|
||||||
if (holding.quantity.lte(0)) continue;
|
|
||||||
|
|
||||||
const assetInfo = assetMap.get(assetId);
|
|
||||||
if (!assetInfo) continue;
|
|
||||||
|
|
||||||
const costPrice = holding.totalCost.div(holding.quantity).toString();
|
|
||||||
|
|
||||||
const snapshotPrice = await getHistoricalPriceWithFallback(assetId, targetDateStr, costPrice);
|
|
||||||
|
|
||||||
const currency = (assetInfo.baseCurrency || 'CNY').toUpperCase();
|
|
||||||
const snapshotFxRate = dailyRates[currency] || dailyRates['USD'] || new Big(1);
|
|
||||||
|
|
||||||
const qtyNum = Number(holding.quantity.toString());
|
|
||||||
const priceNum = new Big(snapshotPrice);
|
|
||||||
const fxNum = snapshotFxRate;
|
|
||||||
|
|
||||||
const calcMarketValueCny = holding.quantity.times(priceNum).times(fxNum);
|
|
||||||
const calcCostCny = holding.totalCost.times(fxNum);
|
|
||||||
|
|
||||||
totalMarketValue = totalMarketValue.plus(calcMarketValueCny);
|
|
||||||
totalCost = totalCost.plus(calcCostCny);
|
|
||||||
|
|
||||||
details.push({
|
|
||||||
symbol: assetInfo.symbol,
|
|
||||||
quantity: qtyNum,
|
|
||||||
snapshotPrice: new Big(snapshotPrice).toString(),
|
|
||||||
snapshotFxRate: new Big(fxNum.toString()).toString(),
|
|
||||||
calculatedMarketValueCny: calcMarketValueCny.toString(),
|
|
||||||
calculatedCostCny: calcCostCny.toString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
details.sort((a, b) => new Big(b.calculatedMarketValueCny).minus(new Big(a.calculatedMarketValueCny)).toNumber());
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
targetDate: targetDateStr,
|
|
||||||
totalMarketValue: Number(totalMarketValue.toString()),
|
|
||||||
totalCost: Number(totalCost.toString()),
|
|
||||||
details,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Snapshot Debug Error]', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Internal server error', details: String(error) },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -31,7 +31,7 @@ import { AddTransactionDialog } from '@/components/transactions/add-transaction-
|
|||||||
import { UpdateTransactionDialog } from '@/components/transactions/update-transaction-dialog';
|
import { UpdateTransactionDialog } from '@/components/transactions/update-transaction-dialog';
|
||||||
import { deleteTransaction } from '@/actions/transaction';
|
import { deleteTransaction } from '@/actions/transaction';
|
||||||
import { importHistoricalPrices } from '@/actions/market';
|
import { importHistoricalPrices } from '@/actions/market';
|
||||||
import { ChevronDown, ChevronUp, Plus, Edit3, Trash2, Upload, Download, Eye } from 'lucide-react';
|
import { ChevronDown, ChevronUp, Plus, Edit3, Trash2, Upload } from 'lucide-react';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
|
|
||||||
const txTypeMap: Record<string, string> = {
|
const txTypeMap: Record<string, string> = {
|
||||||
@ -55,59 +55,6 @@ function formatNative(value: string, baseCurrency: string): string {
|
|||||||
return `${symbol}${formatted}`;
|
return `${symbol}${formatted}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportToCSV(positions: any[]) {
|
|
||||||
const stripTrailingZeros = (val: any): string => {
|
|
||||||
if (val === null || val === undefined || val === '') return "0";
|
|
||||||
let str = String(val);
|
|
||||||
if (str.includes('.')) {
|
|
||||||
str = str.replace(/0+$/, '');
|
|
||||||
str = str.replace(/\.$/, '');
|
|
||||||
}
|
|
||||||
return str;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getMarketName = (item: any): string => {
|
|
||||||
if (item.type === 'CRYPTO' || item.assetType === 'CRYPTO') return '虚拟币';
|
|
||||||
const currency = (item.baseCurrency || '').toUpperCase();
|
|
||||||
if (currency === 'USD') return '美股';
|
|
||||||
if (currency === 'HKD') return '港股';
|
|
||||||
if (currency === 'CNY' || currency === 'RMB') return 'A股';
|
|
||||||
const symbol = (item.symbol || '').toLowerCase();
|
|
||||||
if (/^\d{5}$/.test(symbol)) return '港股';
|
|
||||||
if (/^(60|00|30)\d{4}$/.test(symbol) || symbol.startsWith('sh') || symbol.startsWith('sz')) return 'A股';
|
|
||||||
return '其他市场';
|
|
||||||
};
|
|
||||||
|
|
||||||
const headers = ["资产名称", "代码", "市场", "持仓量", "成本价", "现价", "总市值", "浮动盈亏", "累计盈亏"];
|
|
||||||
|
|
||||||
const rows = positions.map(item => [
|
|
||||||
item.name || item.symbol,
|
|
||||||
item.symbol,
|
|
||||||
getMarketName(item),
|
|
||||||
item.quantity || '0',
|
|
||||||
stripTrailingZeros(new Big(item.avgCostNative || '0').toFixed(2)),
|
|
||||||
stripTrailingZeros(item.latestPrice || '0'),
|
|
||||||
new Big(item.marketValueNative || '0').toFixed(2),
|
|
||||||
new Big(item.floatingPnlNative || '0').toFixed(2),
|
|
||||||
new Big(item.cumulativePnlNative || '0').toFixed(2),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const csvContent = [
|
|
||||||
headers.join(","),
|
|
||||||
...rows.map(e => e.map(val => `"${val}"`).join(","))
|
|
||||||
].join("\n");
|
|
||||||
|
|
||||||
const blob = new Blob(["\uFEFF" + csvContent], { type: 'text/csv;charset=utf-8;' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.setAttribute("href", url);
|
|
||||||
link.setAttribute("download", `portfolio_details_${new Date().toISOString().split('T')[0]}.csv`);
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPnl(value: string, percent: string, baseCurrency: string): { text: string; className: string } {
|
function formatPnl(value: string, percent: string, baseCurrency: string): { text: string; className: string } {
|
||||||
const isPositive = new Big(value).gte(0);
|
const isPositive = new Big(value).gte(0);
|
||||||
const symbol = getCurrencySymbol(baseCurrency);
|
const symbol = getCurrencySymbol(baseCurrency);
|
||||||
@ -144,21 +91,11 @@ export default function DashboardPage() {
|
|||||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||||
const [importAssetId, setImportAssetId] = useState<string>('');
|
const [importAssetId, setImportAssetId] = useState<string>('');
|
||||||
const [importText, setImportText] = useState('');
|
const [importText, setImportText] = useState('');
|
||||||
const [showCleared, setShowCleared] = useState(false);
|
|
||||||
const [positionsRaw, setPositionsRaw] = useState<any[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const filtered = showCleared
|
|
||||||
? positionsRaw
|
|
||||||
: positionsRaw.filter(pos => new Big(pos.quantity || '0').gt('1e-8'));
|
|
||||||
setPositions(filtered);
|
|
||||||
}, [showCleared, positionsRaw]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
const summary = await getPortfolioSummary(true);
|
const summary = await getPortfolioSummary();
|
||||||
const allAssets = await getAssets();
|
const allAssets = await getAssets();
|
||||||
setPositionsRaw(summary.positions);
|
|
||||||
setPositions(summary.positions);
|
setPositions(summary.positions);
|
||||||
setTotalCnyValue(summary.totalCnyValue);
|
setTotalCnyValue(summary.totalCnyValue);
|
||||||
setTotalPnlCny(summary.totalPnlCny);
|
setTotalPnlCny(summary.totalPnlCny);
|
||||||
@ -334,29 +271,8 @@ export default function DashboardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
<CardHeader>
|
||||||
<CardTitle>持仓明细</CardTitle>
|
<CardTitle>持仓明细</CardTitle>
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<label className="flex items-center gap-2 cursor-pointer text-sm text-muted-foreground hover:text-foreground transition-colors">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={showCleared}
|
|
||||||
onChange={(e) => setShowCleared(e.target.checked)}
|
|
||||||
className="h-4 w-4 rounded border-border text-primary focus:ring-primary cursor-pointer"
|
|
||||||
/>
|
|
||||||
<Eye className="h-4 w-4" />
|
|
||||||
显示历史持仓
|
|
||||||
</label>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => exportToCSV(positions)}
|
|
||||||
disabled={positions.length === 0}
|
|
||||||
>
|
|
||||||
<Download className="h-4 w-4 mr-1" />
|
|
||||||
导出 CSV
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{positions.length === 0 ? (
|
{positions.length === 0 ? (
|
||||||
|
|||||||
@ -173,7 +173,7 @@ function getTodayInShanghai(): Date {
|
|||||||
return new Date(utcDate.getTime() + shanghaiOffset);
|
return new Date(utcDate.getTime() + shanghaiOffset);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPortfolioPositions(includeCleared: boolean = false): Promise<Position[]> {
|
export async function getPortfolioPositions(): Promise<Position[]> {
|
||||||
const allTransactions = await db
|
const allTransactions = await db
|
||||||
.select({
|
.select({
|
||||||
id: transactions.id,
|
id: transactions.id,
|
||||||
@ -334,17 +334,11 @@ export async function getPortfolioPositions(includeCleared: boolean = false): Pr
|
|||||||
const today = getTodayInShanghai();
|
const today = getTodayInShanghai();
|
||||||
const result: Position[] = [];
|
const result: Position[] = [];
|
||||||
|
|
||||||
const CLEARED_QUANTITY_TOLERANCE = new Big('1e-8');
|
|
||||||
|
|
||||||
for (const [_, holding] of holdings) {
|
for (const [_, holding] of holdings) {
|
||||||
const hasPosition = holding.quantity.gt(CLEARED_QUANTITY_TOLERANCE);
|
if (holding.quantity.lte(0)) continue;
|
||||||
|
|
||||||
if (!hasPosition && !includeCleared) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cnyValue = calculateCnyValueFromPrice(
|
const cnyValue = calculateCnyValueFromPrice(
|
||||||
hasPosition ? holding.quantity : new Big('0'),
|
holding.quantity,
|
||||||
holding.latestPrice,
|
holding.latestPrice,
|
||||||
holding.baseCurrency,
|
holding.baseCurrency,
|
||||||
rateMap
|
rateMap
|
||||||
@ -448,8 +442,8 @@ export async function getPortfolioPositions(includeCleared: boolean = false): Pr
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPortfolioSummary(includeCleared: boolean = false) {
|
export async function getPortfolioSummary() {
|
||||||
const positions = await getPortfolioPositions(includeCleared);
|
const positions = await getPortfolioPositions();
|
||||||
|
|
||||||
// 单一事实来源:复用 getPortfolioPositions 已汇率折算的结果
|
// 单一事实来源:复用 getPortfolioPositions 已汇率折算的结果
|
||||||
let totalCnyValue = new Big('0');
|
let totalCnyValue = new Big('0');
|
||||||
|
|||||||
@ -24,7 +24,7 @@ function getTodayInShanghai(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function recordDailySnapshot() {
|
export async function recordDailySnapshot() {
|
||||||
const positions = await getPortfolioPositions(false);
|
const positions = await getPortfolioPositions();
|
||||||
|
|
||||||
// 统一使用 engine 输出的 marketValueCny / accumulatedPnlCny
|
// 统一使用 engine 输出的 marketValueCny / accumulatedPnlCny
|
||||||
const totalValueCny = positions.reduce(
|
const totalValueCny = positions.reduce(
|
||||||
@ -213,95 +213,6 @@ export async function getEffectivePrice(
|
|||||||
return record?.price ?? null;
|
return record?.price ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RateRecord {
|
|
||||||
rate: string;
|
|
||||||
fetchTime: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function buildDailyRatesMap(targetDateStr: string): Promise<Record<string, Big>> {
|
|
||||||
const allRates = await db
|
|
||||||
.select({
|
|
||||||
fromCurrency: exchangeRatesHistory.fromCurrency,
|
|
||||||
toCurrency: exchangeRatesHistory.toCurrency,
|
|
||||||
rate: exchangeRatesHistory.rate,
|
|
||||||
fetchTime: exchangeRatesHistory.fetchTime,
|
|
||||||
})
|
|
||||||
.from(exchangeRatesHistory)
|
|
||||||
.where(lte(exchangeRatesHistory.fetchTime, new Date(targetDateStr + 'T23:59:59')))
|
|
||||||
.orderBy(asc(exchangeRatesHistory.fetchTime));
|
|
||||||
|
|
||||||
const ratesCache = new Map<string, RateRecord[]>();
|
|
||||||
for (const rec of allRates) {
|
|
||||||
const key = `${rec.fromCurrency}_${rec.toCurrency}`;
|
|
||||||
if (!ratesCache.has(key)) {
|
|
||||||
ratesCache.set(key, []);
|
|
||||||
}
|
|
||||||
ratesCache.get(key)!.push({ rate: rec.rate, fetchTime: rec.fetchTime });
|
|
||||||
}
|
|
||||||
|
|
||||||
function getClosestRateForDate(currencyPair: string): string | null {
|
|
||||||
const records = ratesCache.get(currencyPair);
|
|
||||||
if (!records || records.length === 0) return null;
|
|
||||||
const targetDt = new Date(targetDateStr + 'T00:00:00Z');
|
|
||||||
let closest: RateRecord | null = null;
|
|
||||||
for (const rec of records) {
|
|
||||||
if (rec.fetchTime <= targetDt) {
|
|
||||||
closest = rec;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return closest?.rate ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveRate(from: string, to: string): string | null {
|
|
||||||
const directKey = `${from}_${to}`;
|
|
||||||
const directRate = getClosestRateForDate(directKey);
|
|
||||||
if (directRate) return directRate;
|
|
||||||
|
|
||||||
const usdKey = `USD_${to}`;
|
|
||||||
const usdRate = getClosestRateForDate(usdKey);
|
|
||||||
if (!usdRate) return null;
|
|
||||||
if (from === 'USD') return usdRate;
|
|
||||||
|
|
||||||
const fromToUsdKey = `${from}_USD`;
|
|
||||||
const fromToUsdRate = getClosestRateForDate(fromToUsdKey);
|
|
||||||
if (fromToUsdRate) {
|
|
||||||
return new Big(fromToUsdRate).times(new Big(usdRate)).toString();
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hkdRate = resolveRate('HKD', 'CNY');
|
|
||||||
const usdRate = resolveRate('USD', 'CNY');
|
|
||||||
|
|
||||||
return {
|
|
||||||
USD: new Big(usdRate || '7.22'),
|
|
||||||
HKD: new Big(hkdRate || '0.92'),
|
|
||||||
CNY: new Big(1),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getHistoricalPriceWithFallback(assetId: string, dateStr: string, fallbackCostPrice: string): Promise<string> {
|
|
||||||
const [record] = await db
|
|
||||||
.select({ price: assetPricesHistory.price })
|
|
||||||
.from(assetPricesHistory)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(assetPricesHistory.assetId, assetId),
|
|
||||||
lte(assetPricesHistory.date, dateStr)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.orderBy(desc(assetPricesHistory.date))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (record?.price) {
|
|
||||||
return record.price;
|
|
||||||
}
|
|
||||||
|
|
||||||
return fallbackCostPrice;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function reconstructPortfolioHistory() {
|
export async function reconstructPortfolioHistory() {
|
||||||
const [earliest] = await db
|
const [earliest] = await db
|
||||||
.select({ executedAt: transactions.executedAt })
|
.select({ executedAt: transactions.executedAt })
|
||||||
@ -331,11 +242,85 @@ export async function reconstructPortfolioHistory() {
|
|||||||
.select({
|
.select({
|
||||||
id: assets.id,
|
id: assets.id,
|
||||||
baseCurrency: assets.baseCurrency,
|
baseCurrency: assets.baseCurrency,
|
||||||
|
latestPrice: assets.latestPrice,
|
||||||
})
|
})
|
||||||
.from(assets);
|
.from(assets);
|
||||||
const assetBaseCurrencyMap = new Map<string, string>();
|
const assetBaseCurrencyMap = new Map<string, string>();
|
||||||
|
const assetLatestPriceMap = new Map<string, string>();
|
||||||
for (const a of allAssets) {
|
for (const a of allAssets) {
|
||||||
assetBaseCurrencyMap.set(a.id, a.baseCurrency);
|
assetBaseCurrencyMap.set(a.id, a.baseCurrency);
|
||||||
|
assetLatestPriceMap.set(a.id, a.latestPrice || '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
const allRatesHistory = await db
|
||||||
|
.select({
|
||||||
|
fromCurrency: exchangeRatesHistory.fromCurrency,
|
||||||
|
toCurrency: exchangeRatesHistory.toCurrency,
|
||||||
|
rate: exchangeRatesHistory.rate,
|
||||||
|
fetchTime: exchangeRatesHistory.fetchTime,
|
||||||
|
})
|
||||||
|
.from(exchangeRatesHistory)
|
||||||
|
.orderBy(asc(exchangeRatesHistory.fetchTime));
|
||||||
|
|
||||||
|
// 构建汇率缓存:按 (fromCurrency, toCurrency) 分组,fetchTime 已升序排列
|
||||||
|
interface RateRecord {
|
||||||
|
rate: string;
|
||||||
|
fetchTime: Date;
|
||||||
|
}
|
||||||
|
const ratesCache = new Map<string, RateRecord[]>();
|
||||||
|
|
||||||
|
for (const rec of allRatesHistory) {
|
||||||
|
const key = `${rec.fromCurrency}_${rec.toCurrency}`;
|
||||||
|
if (!ratesCache.has(key)) {
|
||||||
|
ratesCache.set(key, []);
|
||||||
|
}
|
||||||
|
ratesCache.get(key)!.push({
|
||||||
|
rate: rec.rate,
|
||||||
|
fetchTime: rec.fetchTime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClosestRateForDate(
|
||||||
|
currencyPair: string,
|
||||||
|
targetDateStr: string
|
||||||
|
): string | null {
|
||||||
|
const records = ratesCache.get(currencyPair);
|
||||||
|
if (!records || records.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetDate = new Date(targetDateStr + 'T00:00:00Z');
|
||||||
|
let closest: RateRecord | null = null;
|
||||||
|
|
||||||
|
for (const rec of records) {
|
||||||
|
if (rec.fetchTime <= targetDate) {
|
||||||
|
closest = rec;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return closest?.rate ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHistoricalRate(from: string, to: string, dateStr: string): string | null {
|
||||||
|
const directKey = `${from}_${to}`;
|
||||||
|
const directRate = getClosestRateForDate(directKey, dateStr);
|
||||||
|
if (directRate) return directRate;
|
||||||
|
|
||||||
|
const usdKey = `USD_${to}`;
|
||||||
|
const usdRate = getClosestRateForDate(usdKey, dateStr);
|
||||||
|
if (!usdRate) return null;
|
||||||
|
|
||||||
|
if (from === 'USD') return usdRate;
|
||||||
|
|
||||||
|
const fromToUsdKey = `${from}_USD`;
|
||||||
|
const fromToUsdRate = getClosestRateForDate(fromToUsdKey, dateStr);
|
||||||
|
if (fromToUsdRate) {
|
||||||
|
return new Big(fromToUsdRate).times(new Big(usdRate)).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.delete(portfolioSnapshots);
|
await db.delete(portfolioSnapshots);
|
||||||
@ -361,8 +346,6 @@ export async function reconstructPortfolioHistory() {
|
|||||||
let totalValueCny = new Big('0');
|
let totalValueCny = new Big('0');
|
||||||
let totalCostCny = new Big('0');
|
let totalCostCny = new Big('0');
|
||||||
|
|
||||||
const dailyRates = await buildDailyRatesMap(dateStr);
|
|
||||||
|
|
||||||
const uniqueAssetIds = [...new Set(historicalTx.filter(t =>
|
const uniqueAssetIds = [...new Set(historicalTx.filter(t =>
|
||||||
t.txType === 'BUY' || t.txType === 'SELL' || t.txType === 'DIVIDEND'
|
t.txType === 'BUY' || t.txType === 'SELL' || t.txType === 'DIVIDEND'
|
||||||
).map(t => t.assetId))];
|
).map(t => t.assetId))];
|
||||||
@ -378,26 +361,23 @@ export async function reconstructPortfolioHistory() {
|
|||||||
fee: t.fee.toString(),
|
fee: t.fee.toString(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const priceStr = await getEffectivePrice(assetId, currentDate);
|
||||||
const baseCurrency = assetBaseCurrencyMap.get(assetId) || 'USD';
|
const baseCurrency = assetBaseCurrencyMap.get(assetId) || 'USD';
|
||||||
|
|
||||||
const costPrice = new Big(assetTxs.reduce((sum, t) => {
|
const priceStrForMetrics = priceStr || assetLatestPriceMap.get(assetId) || '0';
|
||||||
if (t.txType === 'BUY') return sum.plus(new Big(t.price).times(new Big(t.quantity)));
|
|
||||||
if (t.txType === 'SELL') return sum.minus(new Big(t.price).times(new Big(t.quantity)));
|
|
||||||
return sum;
|
|
||||||
}, new Big('0')).div(new Big(assetTxs.reduce((s, t) => t.txType === 'BUY' ? s.plus(t.quantity) : s, new Big('0'))).gt(0) ? new Big(assetTxs.reduce((s, t) => t.txType === 'BUY' ? s.plus(t.quantity) : s, new Big('0'))).toString() : '1')).toString();
|
|
||||||
|
|
||||||
const snapshotPrice = await getHistoricalPriceWithFallback(assetId, dateStr, costPrice);
|
|
||||||
|
|
||||||
const currency = (baseCurrency || 'CNY').toUpperCase();
|
|
||||||
const snapshotFxRate = dailyRates[currency] || dailyRates['USD'] || new Big(1);
|
|
||||||
|
|
||||||
const priceStrForMetrics = snapshotPrice;
|
|
||||||
const metrics = calculateAssetMetrics(assetTxs, priceStrForMetrics);
|
const metrics = calculateAssetMetrics(assetTxs, priceStrForMetrics);
|
||||||
|
|
||||||
const posValueCny = new Big(metrics.marketValue).times(snapshotFxRate);
|
// 使用历史汇率就近匹配策略获取当日汇率
|
||||||
|
const assetFxRate = new Big(getHistoricalRate(baseCurrency, 'CNY', dateStr) || '1');
|
||||||
|
|
||||||
|
// 市值和本金双双乘以历史汇率
|
||||||
|
const posValueCny = new Big(metrics.marketValue).times(assetFxRate);
|
||||||
|
|
||||||
|
// 投入本金 = (市值 - 累计盈亏) * 历史汇率,确保逻辑自洽
|
||||||
const posCostCny = new Big(metrics.marketValue)
|
const posCostCny = new Big(metrics.marketValue)
|
||||||
.minus(metrics.accumulatedPnl)
|
.minus(metrics.accumulatedPnl)
|
||||||
.times(snapshotFxRate);
|
.times(assetFxRate);
|
||||||
|
|
||||||
totalValueCny = totalValueCny.plus(posValueCny);
|
totalValueCny = totalValueCny.plus(posValueCny);
|
||||||
totalCostCny = totalCostCny.plus(posCostCny);
|
totalCostCny = totalCostCny.plus(posCostCny);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user