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)
|
||||
|
||||
## 重构时光机底层引擎,引入基于 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)
|
||||
- 在 `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`。
|
||||
|
||||
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 { deleteTransaction } from '@/actions/transaction';
|
||||
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';
|
||||
|
||||
const txTypeMap: Record<string, string> = {
|
||||
@ -55,6 +55,59 @@ function formatNative(value: string, baseCurrency: string): string {
|
||||
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 } {
|
||||
const isPositive = new Big(value).gte(0);
|
||||
const symbol = getCurrencySymbol(baseCurrency);
|
||||
@ -91,11 +144,21 @@ export default function DashboardPage() {
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
const [importAssetId, setImportAssetId] = useState<string>('');
|
||||
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(() => {
|
||||
async function loadData() {
|
||||
const summary = await getPortfolioSummary();
|
||||
const summary = await getPortfolioSummary(true);
|
||||
const allAssets = await getAssets();
|
||||
setPositionsRaw(summary.positions);
|
||||
setPositions(summary.positions);
|
||||
setTotalCnyValue(summary.totalCnyValue);
|
||||
setTotalPnlCny(summary.totalPnlCny);
|
||||
@ -271,8 +334,29 @@ export default function DashboardPage() {
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<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>
|
||||
<CardContent>
|
||||
{positions.length === 0 ? (
|
||||
|
||||
@ -173,7 +173,7 @@ function getTodayInShanghai(): Date {
|
||||
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
|
||||
.select({
|
||||
id: transactions.id,
|
||||
@ -334,11 +334,17 @@ export async function getPortfolioPositions(): Promise<Position[]> {
|
||||
const today = getTodayInShanghai();
|
||||
const result: Position[] = [];
|
||||
|
||||
const CLEARED_QUANTITY_TOLERANCE = new Big('1e-8');
|
||||
|
||||
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(
|
||||
holding.quantity,
|
||||
hasPosition ? holding.quantity : new Big('0'),
|
||||
holding.latestPrice,
|
||||
holding.baseCurrency,
|
||||
rateMap
|
||||
@ -442,8 +448,8 @@ export async function getPortfolioPositions(): Promise<Position[]> {
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getPortfolioSummary() {
|
||||
const positions = await getPortfolioPositions();
|
||||
export async function getPortfolioSummary(includeCleared: boolean = false) {
|
||||
const positions = await getPortfolioPositions(includeCleared);
|
||||
|
||||
// 单一事实来源:复用 getPortfolioPositions 已汇率折算的结果
|
||||
let totalCnyValue = new Big('0');
|
||||
|
||||
@ -24,7 +24,7 @@ function getTodayInShanghai(): string {
|
||||
}
|
||||
|
||||
export async function recordDailySnapshot() {
|
||||
const positions = await getPortfolioPositions();
|
||||
const positions = await getPortfolioPositions(false);
|
||||
|
||||
// 统一使用 engine 输出的 marketValueCny / accumulatedPnlCny
|
||||
const totalValueCny = positions.reduce(
|
||||
@ -213,6 +213,95 @@ export async function getEffectivePrice(
|
||||
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() {
|
||||
const [earliest] = await db
|
||||
.select({ executedAt: transactions.executedAt })
|
||||
@ -242,85 +331,11 @@ export async function reconstructPortfolioHistory() {
|
||||
.select({
|
||||
id: assets.id,
|
||||
baseCurrency: assets.baseCurrency,
|
||||
latestPrice: assets.latestPrice,
|
||||
})
|
||||
.from(assets);
|
||||
const assetBaseCurrencyMap = new Map<string, string>();
|
||||
const assetLatestPriceMap = new Map<string, string>();
|
||||
for (const a of allAssets) {
|
||||
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);
|
||||
@ -346,6 +361,8 @@ export async function reconstructPortfolioHistory() {
|
||||
let totalValueCny = new Big('0');
|
||||
let totalCostCny = new Big('0');
|
||||
|
||||
const dailyRates = await buildDailyRatesMap(dateStr);
|
||||
|
||||
const uniqueAssetIds = [...new Set(historicalTx.filter(t =>
|
||||
t.txType === 'BUY' || t.txType === 'SELL' || t.txType === 'DIVIDEND'
|
||||
).map(t => t.assetId))];
|
||||
@ -361,23 +378,26 @@ export async function reconstructPortfolioHistory() {
|
||||
fee: t.fee.toString(),
|
||||
}));
|
||||
|
||||
const priceStr = await getEffectivePrice(assetId, currentDate);
|
||||
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 assetFxRate = new Big(getHistoricalRate(baseCurrency, 'CNY', dateStr) || '1');
|
||||
|
||||
// 市值和本金双双乘以历史汇率
|
||||
const posValueCny = new Big(metrics.marketValue).times(assetFxRate);
|
||||
|
||||
// 投入本金 = (市值 - 累计盈亏) * 历史汇率,确保逻辑自洽
|
||||
const posValueCny = new Big(metrics.marketValue).times(snapshotFxRate);
|
||||
const posCostCny = new Big(metrics.marketValue)
|
||||
.minus(metrics.accumulatedPnl)
|
||||
.times(assetFxRate);
|
||||
.times(snapshotFxRate);
|
||||
|
||||
totalValueCny = totalValueCny.plus(posValueCny);
|
||||
totalCostCny = totalCostCny.plus(posCostCny);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user