chore(debug): 新增历史快照单日明细透视 API,辅助定位净值曲线数据空洞
This commit is contained in:
parent
993c7d819a
commit
211074cd97
10
Memory.md
10
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股) → 默认"其他市场"。
|
||||
|
||||
253
app/api/debug/snapshot/route.ts
Normal file
253
app/api/debug/snapshot/route.ts
Normal file
@ -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<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 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<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, 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user