Compare commits
5 Commits
2570144112
...
b76a6ef577
| Author | SHA1 | Date | |
|---|---|---|---|
| b76a6ef577 | |||
| 211074cd97 | |||
| 993c7d819a | |||
| e692d47b6a | |||
| 3e81c1dc5b |
36
Memory.md
36
Memory.md
@ -1,5 +1,41 @@
|
|||||||
# 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`。
|
||||||
|
|||||||
255
app/api/debug/snapshot/route.ts
Normal file
255
app/api/debug/snapshot/route.ts
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
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 } from 'lucide-react';
|
import { ChevronDown, ChevronUp, Plus, Edit3, Trash2, Upload, Download, Eye } from 'lucide-react';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
|
|
||||||
const txTypeMap: Record<string, string> = {
|
const txTypeMap: Record<string, string> = {
|
||||||
@ -55,6 +55,59 @@ 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);
|
||||||
@ -91,11 +144,21 @@ 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();
|
const summary = await getPortfolioSummary(true);
|
||||||
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);
|
||||||
@ -271,8 +334,29 @@ export default function DashboardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||||
<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(): Promise<Position[]> {
|
export async function getPortfolioPositions(includeCleared: boolean = false): Promise<Position[]> {
|
||||||
const allTransactions = await db
|
const allTransactions = await db
|
||||||
.select({
|
.select({
|
||||||
id: transactions.id,
|
id: transactions.id,
|
||||||
@ -334,11 +334,17 @@ export async function getPortfolioPositions(): Promise<Position[]> {
|
|||||||
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) {
|
||||||
if (holding.quantity.lte(0)) continue;
|
const hasPosition = holding.quantity.gt(CLEARED_QUANTITY_TOLERANCE);
|
||||||
|
|
||||||
|
if (!hasPosition && !includeCleared) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const cnyValue = calculateCnyValueFromPrice(
|
const cnyValue = calculateCnyValueFromPrice(
|
||||||
holding.quantity,
|
hasPosition ? holding.quantity : new Big('0'),
|
||||||
holding.latestPrice,
|
holding.latestPrice,
|
||||||
holding.baseCurrency,
|
holding.baseCurrency,
|
||||||
rateMap
|
rateMap
|
||||||
@ -442,8 +448,8 @@ export async function getPortfolioPositions(): Promise<Position[]> {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPortfolioSummary() {
|
export async function getPortfolioSummary(includeCleared: boolean = false) {
|
||||||
const positions = await getPortfolioPositions();
|
const positions = await getPortfolioPositions(includeCleared);
|
||||||
|
|
||||||
// 单一事实来源:复用 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();
|
const positions = await getPortfolioPositions(false);
|
||||||
|
|
||||||
// 统一使用 engine 输出的 marketValueCny / accumulatedPnlCny
|
// 统一使用 engine 输出的 marketValueCny / accumulatedPnlCny
|
||||||
const totalValueCny = positions.reduce(
|
const totalValueCny = positions.reduce(
|
||||||
@ -213,6 +213,95 @@ 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 })
|
||||||
@ -242,85 +331,11 @@ 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);
|
||||||
@ -346,6 +361,8 @@ 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))];
|
||||||
@ -361,23 +378,26 @@ 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 priceStrForMetrics = priceStr || assetLatestPriceMap.get(assetId) || '0';
|
const costPrice = new Big(assetTxs.reduce((sum, t) => {
|
||||||
|
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(assetFxRate);
|
.times(snapshotFxRate);
|
||||||
|
|
||||||
totalValueCny = totalValueCny.plus(posValueCny);
|
totalValueCny = totalValueCny.plus(posValueCny);
|
||||||
totalCostCny = totalCostCny.plus(posCostCny);
|
totalCostCny = totalCostCny.plus(posCostCny);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user