diff --git a/Memory.md b/Memory.md index 53428b1..88e5d85 100644 --- a/Memory.md +++ b/Memory.md @@ -1,5 +1,15 @@ # Omniledger 架构与开发记忆 (Memory) +## 新增 /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股) → 默认"其他市场"。 diff --git a/app/api/debug/snapshot/route.ts b/app/api/debug/snapshot/route.ts new file mode 100644 index 0000000..cb6c127 --- /dev/null +++ b/app/api/debug/snapshot/route.ts @@ -0,0 +1,253 @@ +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; +} + +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(); + + 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(); + for (const a of allAssets) { + assetMap.set(a.id, { symbol: a.symbol, baseCurrency: a.baseCurrency || 'USD' }); + } + + const allRatesHistory = await db + .select({ + fromCurrency: exchangeRatesHistory.fromCurrency, + toCurrency: exchangeRatesHistory.toCurrency, + rate: exchangeRatesHistory.rate, + fetchTime: exchangeRatesHistory.fetchTime, + }) + .from(exchangeRatesHistory) + .orderBy(asc(exchangeRatesHistory.fetchTime)); + + const ratesCache = new Map(); + 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, dateStr: string): string | null { + const records = ratesCache.get(currencyPair); + if (!records || records.length === 0) { + return null; + } + const targetDt = new Date(dateStr + '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 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; + } + + function getHistoricalPrice(assetId: string, dateStr: string): string | null { + const record = db + .select({ price: assetPricesHistory.price }) + .from(assetPricesHistory) + .where( + and( + eq(assetPricesHistory.assetId, assetId), + lte(assetPricesHistory.date, dateStr) + ) + ) + .orderBy(desc(assetPricesHistory.date)) + .limit(1); + return null; + } + + 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 priceRecord = await db + .select({ price: assetPricesHistory.price }) + .from(assetPricesHistory) + .where( + and( + eq(assetPricesHistory.assetId, assetId), + lte(assetPricesHistory.date, targetDateStr) + ) + ) + .orderBy(desc(assetPricesHistory.date)) + .limit(1); + + const snapshotPrice = priceRecord[0]?.price ?? '0'; + + const fxRate = getHistoricalRate(assetInfo.baseCurrency, 'CNY', targetDateStr) + ?? (assetInfo.baseCurrency === 'CNY' ? '1' : '7.2'); + + const qtyNum = Number(holding.quantity.toString()); + const priceNum = new Big(snapshotPrice); + const fxNum = new Big(fxRate); + + 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(fxRate).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 } + ); + } +}