Files
stock-portfolio_byQwen3.6/src/actions/snapshots.ts
T

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,
};
}