466 lines
13 KiB
TypeScript
466 lines
13 KiB
TypeScript
'use server';
|
|
|
|
import { db } from '@/db';
|
|
import { portfolioSnapshots, transactions, assetPricesHistory, assets, exchangeRatesHistory } from '@/db/schema';
|
|
import { getPortfolioPositions } from './portfolio';
|
|
import { and, asc, desc, eq, gte, lte, sql } from 'drizzle-orm';
|
|
import Big from 'big.js';
|
|
import { calculateAssetMetrics } from '@/utils/finance';
|
|
|
|
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}`;
|
|
}
|
|
|
|
function getTodayInShanghai(): string {
|
|
const now = new Date();
|
|
const utcStr = now.toLocaleString('en-US', { timeZone: 'UTC' });
|
|
const utcDate = new Date(utcStr);
|
|
const shanghaiOffset = 8 * 60 * 60 * 1000;
|
|
const shanghaiDate = new Date(utcDate.getTime() + shanghaiOffset);
|
|
return formatDateString(shanghaiDate);
|
|
}
|
|
|
|
export async function recordDailySnapshot() {
|
|
const positions = await getPortfolioPositions(false);
|
|
|
|
// 统一使用 engine 输出的 marketValueCny / accumulatedPnlCny
|
|
const totalValueCny = positions.reduce(
|
|
(sum, pos) => sum.plus(new Big(pos.marketValueCny || '0')),
|
|
new Big(0)
|
|
).toString();
|
|
|
|
const totalCostCny = positions.reduce(
|
|
(sum, pos) => sum.plus(new Big(pos.totalCostCny || '0')),
|
|
new Big(0)
|
|
).toString();
|
|
|
|
const dateStr = getTodayInShanghai();
|
|
|
|
const existing = await db
|
|
.select()
|
|
.from(portfolioSnapshots)
|
|
.where(eq(portfolioSnapshots.date, dateStr))
|
|
.limit(1);
|
|
|
|
const now = new Date();
|
|
|
|
if (existing.length > 0) {
|
|
await db
|
|
.update(portfolioSnapshots)
|
|
.set({
|
|
totalValueCny,
|
|
totalCostCny,
|
|
updatedAt: now,
|
|
})
|
|
.where(eq(portfolioSnapshots.date, dateStr));
|
|
|
|
return {
|
|
success: true,
|
|
action: 'updated',
|
|
date: dateStr,
|
|
totalValueCny,
|
|
totalCostCny,
|
|
};
|
|
}
|
|
|
|
await db
|
|
.insert(portfolioSnapshots)
|
|
.values({
|
|
date: dateStr,
|
|
totalValueCny,
|
|
totalCostCny,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
action: 'inserted',
|
|
date: dateStr,
|
|
totalValueCny,
|
|
totalCostCny,
|
|
};
|
|
}
|
|
|
|
export async function getSnapshots(params?: {
|
|
limit?: number;
|
|
startDate?: string;
|
|
endDate?: string;
|
|
}) {
|
|
const { limit, startDate, endDate } = params || {};
|
|
|
|
let query = db
|
|
.select()
|
|
.from(portfolioSnapshots)
|
|
.orderBy(desc(portfolioSnapshots.date))
|
|
.$dynamic();
|
|
|
|
if (startDate) {
|
|
query = query.where(gte(portfolioSnapshots.date, startDate));
|
|
}
|
|
if (endDate) {
|
|
query = query.where(
|
|
lte(portfolioSnapshots.date, endDate)
|
|
);
|
|
}
|
|
|
|
const snapshots = limit ? await query.limit(limit) : await query;
|
|
|
|
return snapshots.reverse();
|
|
}
|
|
|
|
interface HistoricalPosition {
|
|
assetId: string;
|
|
quantity: string;
|
|
totalCost: string;
|
|
}
|
|
|
|
export async function getHistoricalPositions(targetDate: Date): Promise<HistoricalPosition[]> {
|
|
const dateStr = formatDateString(targetDate);
|
|
|
|
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 result: HistoricalPosition[] = [];
|
|
for (const [assetId, holding] of holdings) {
|
|
if (holding.quantity.lte(0)) continue;
|
|
result.push({
|
|
assetId,
|
|
quantity: holding.quantity.toString(),
|
|
totalCost: holding.totalCost.toString(),
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
export async function getEffectivePrice(
|
|
assetId: string,
|
|
targetDate: Date
|
|
): Promise<string | null> {
|
|
const dateStr = formatDateString(targetDate);
|
|
|
|
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);
|
|
|
|
return record?.price ?? null;
|
|
}
|
|
|
|
async function buildDailyRatesMap(targetDateStr: string): Promise<Record<string, Big>> {
|
|
const boundaryString = `${targetDateStr} 23:59:59`;
|
|
|
|
// 获取 USD/CNY — 取目标时间点之前最后一条 USD->CNY 记录
|
|
const usdRecords = await db
|
|
.select({
|
|
rate: exchangeRatesHistory.rate,
|
|
fetchTime: exchangeRatesHistory.fetchTime,
|
|
})
|
|
.from(exchangeRatesHistory)
|
|
.where(
|
|
and(
|
|
eq(exchangeRatesHistory.fromCurrency, 'USD'),
|
|
eq(exchangeRatesHistory.toCurrency, 'CNY'),
|
|
lte(exchangeRatesHistory.fetchTime, sql`${boundaryString}`)
|
|
)
|
|
)
|
|
.orderBy(desc(exchangeRatesHistory.fetchTime))
|
|
.limit(1);
|
|
|
|
// 获取 HKD/CNY — 取目标时间点之前最后一条 HKD->CNY 记录
|
|
const hkdRecords = await db
|
|
.select({
|
|
rate: exchangeRatesHistory.rate,
|
|
fetchTime: exchangeRatesHistory.fetchTime,
|
|
})
|
|
.from(exchangeRatesHistory)
|
|
.where(
|
|
and(
|
|
eq(exchangeRatesHistory.fromCurrency, 'HKD'),
|
|
eq(exchangeRatesHistory.toCurrency, 'CNY'),
|
|
lte(exchangeRatesHistory.fetchTime, sql`${boundaryString}`)
|
|
)
|
|
)
|
|
.orderBy(desc(exchangeRatesHistory.fetchTime))
|
|
.limit(1);
|
|
|
|
// 若 HKD->CNY 不存在,尝试走 HKD->USD 再 USD->CNY 的交叉换算
|
|
let hkdRateStr: string | null = hkdRecords[0]?.rate ?? null;
|
|
if (!hkdRateStr) {
|
|
const hkdUsdRecords = await db
|
|
.select({
|
|
rate: exchangeRatesHistory.rate,
|
|
fetchTime: exchangeRatesHistory.fetchTime,
|
|
})
|
|
.from(exchangeRatesHistory)
|
|
.where(
|
|
and(
|
|
eq(exchangeRatesHistory.fromCurrency, 'HKD'),
|
|
eq(exchangeRatesHistory.toCurrency, 'USD'),
|
|
lte(exchangeRatesHistory.fetchTime, sql`${boundaryString}`)
|
|
)
|
|
)
|
|
.orderBy(desc(exchangeRatesHistory.fetchTime))
|
|
.limit(1);
|
|
|
|
const usdToCnyRate = usdRecords[0]?.rate ?? null;
|
|
if (hkdUsdRecords[0]?.rate && usdToCnyRate) {
|
|
hkdRateStr = new Big(hkdUsdRecords[0].rate).times(new Big(usdToCnyRate)).toString();
|
|
}
|
|
}
|
|
|
|
const usdRateStr = usdRecords[0]?.rate ?? null;
|
|
|
|
console.log(`[FX Fetch] Date: ${targetDateStr}, USD: ${usdRateStr}, HKD: ${hkdRateStr}`);
|
|
|
|
return {
|
|
USD: new Big(usdRateStr || '7.22'),
|
|
HKD: new Big(hkdRateStr || '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 })
|
|
.from(transactions)
|
|
.orderBy(asc(transactions.executedAt))
|
|
.limit(1);
|
|
|
|
if (!earliest) {
|
|
return {
|
|
success: true,
|
|
message: 'No transactions found, nothing to reconstruct.',
|
|
daysReconstructed: 0,
|
|
};
|
|
}
|
|
|
|
const earliestDate = new Date(earliest.executedAt);
|
|
const utcStr = earliestDate.toLocaleString('en-US', { timeZone: 'UTC' });
|
|
const utcDate = new Date(utcStr);
|
|
const shanghaiOffset = 8 * 60 * 60 * 1000;
|
|
const shanghaiDate = new Date(utcDate.getTime() + shanghaiOffset);
|
|
let currentDate = new Date(shanghaiDate);
|
|
currentDate.setHours(0, 0, 0, 0);
|
|
|
|
const todayStr = getTodayInShanghai();
|
|
|
|
const allAssets = await db
|
|
.select({
|
|
id: assets.id,
|
|
baseCurrency: assets.baseCurrency,
|
|
})
|
|
.from(assets);
|
|
const assetBaseCurrencyMap = new Map<string, string>();
|
|
for (const a of allAssets) {
|
|
assetBaseCurrencyMap.set(a.id, a.baseCurrency);
|
|
}
|
|
|
|
await db.delete(portfolioSnapshots);
|
|
|
|
let daysReconstructed = 0;
|
|
|
|
while (formatDateString(currentDate) <= todayStr) {
|
|
const dateStr = formatDateString(currentDate);
|
|
|
|
const historicalTx = await db
|
|
.select({
|
|
assetId: transactions.assetId,
|
|
executedAt: transactions.executedAt,
|
|
txType: transactions.txType,
|
|
quantity: transactions.quantity,
|
|
price: transactions.price,
|
|
fee: transactions.fee,
|
|
exchangeRate: transactions.exchangeRate,
|
|
})
|
|
.from(transactions)
|
|
.where(lte(transactions.executedAt, currentDate))
|
|
.orderBy(asc(transactions.executedAt));
|
|
|
|
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))];
|
|
|
|
for (const assetId of uniqueAssetIds) {
|
|
const assetTxs = historicalTx
|
|
.filter(t => t.assetId === assetId && (t.txType === 'BUY' || t.txType === 'SELL' || t.txType === 'DIVIDEND'))
|
|
.map(t => ({
|
|
date: new Date(t.executedAt).toISOString().split('T')[0],
|
|
txType: t.txType,
|
|
quantity: t.quantity.toString(),
|
|
price: t.price.toString(),
|
|
fee: t.fee.toString(),
|
|
}));
|
|
|
|
const baseCurrency = assetBaseCurrencyMap.get(assetId) || 'USD';
|
|
|
|
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 posValueCny = new Big(metrics.marketValue).times(snapshotFxRate);
|
|
|
|
// 使用交易时的真实汇率计算法币本金,而非直接用 metrics.accumulatedCost
|
|
let calculatedFiatCost = new Big(0);
|
|
const rawTxs = historicalTx.filter(t => t.assetId === assetId && (t.txType === 'BUY' || t.txType === 'SELL' || t.txType === 'DIVIDEND'));
|
|
let currentQty = new Big(0);
|
|
for (const tx of rawTxs) {
|
|
const qty = new Big(tx.quantity);
|
|
const fx = new Big(tx.exchangeRate || '1');
|
|
const price = new Big(tx.price);
|
|
|
|
if (tx.txType === 'BUY') {
|
|
currentQty = currentQty.plus(qty);
|
|
calculatedFiatCost = calculatedFiatCost.plus(qty.times(price).times(fx));
|
|
} else if (tx.txType === 'SELL') {
|
|
let avgFiatCostPerUnit = new Big(0);
|
|
if (currentQty.gt(0)) {
|
|
avgFiatCostPerUnit = calculatedFiatCost.div(currentQty);
|
|
}
|
|
calculatedFiatCost = calculatedFiatCost.minus(avgFiatCostPerUnit.times(qty));
|
|
currentQty = currentQty.minus(qty);
|
|
}
|
|
}
|
|
|
|
const posCostCny = calculatedFiatCost.gt(0) ? calculatedFiatCost : new Big(0);
|
|
|
|
totalValueCny = totalValueCny.plus(posValueCny);
|
|
totalCostCny = totalCostCny.plus(posCostCny);
|
|
}
|
|
|
|
const existing = await db
|
|
.select()
|
|
.from(portfolioSnapshots)
|
|
.where(eq(portfolioSnapshots.date, dateStr))
|
|
.limit(1);
|
|
|
|
const now = new Date();
|
|
|
|
if (existing.length > 0) {
|
|
await db
|
|
.update(portfolioSnapshots)
|
|
.set({
|
|
totalValueCny: totalValueCny.toString(),
|
|
totalCostCny: totalCostCny.toString(),
|
|
updatedAt: now,
|
|
})
|
|
.where(eq(portfolioSnapshots.date, dateStr));
|
|
} else {
|
|
await db
|
|
.insert(portfolioSnapshots)
|
|
.values({
|
|
date: dateStr,
|
|
totalValueCny: totalValueCny.toString(),
|
|
totalCostCny: totalCostCny.toString(),
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
}
|
|
|
|
daysReconstructed++;
|
|
|
|
currentDate.setDate(currentDate.getDate() + 1);
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
daysReconstructed,
|
|
};
|
|
}
|