256 lines
7.7 KiB
TypeScript
256 lines
7.7 KiB
TypeScript
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 endOfDay = new Date(targetDateStr + 'T23:59:59.999');
|
|
let closest: RateRecord | null = null;
|
|
for (const rec of records) {
|
|
if (rec.fetchTime <= endOfDay) {
|
|
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;
|
|
|
|
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 }
|
|
);
|
|
}
|
|
}
|