feat(ledger): 编写时光机核心辅助函数,支持历史持仓计算与价格断点结转

This commit is contained in:
2026-04-30 11:00:04 +08:00
parent 209cdd3625
commit 7bd2eb1e86
2 changed files with 101 additions and 3 deletions
+94 -2
View File
@@ -1,9 +1,9 @@
'use server';
import { db } from '@/db';
import { portfolioSnapshots } from '@/db/schema';
import { portfolioSnapshots, transactions, assetPricesHistory } from '@/db/schema';
import { getPortfolioPositions } from './portfolio';
import { desc, eq, gte, sql } from 'drizzle-orm';
import { asc, desc, eq, gte, lte, sql } from 'drizzle-orm';
import Big from 'big.js';
function getTodayInShanghai(): string {
@@ -105,3 +105,95 @@ export async function getSnapshots(params?: {
return snapshots.reverse();
}
interface HistoricalPosition {
assetId: string;
quantity: string;
totalCost: string;
}
export async function getHistoricalPositions(targetDate: Date): Promise<HistoricalPosition[]> {
const dateStr = targetDate.toISOString().split('T')[0];
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(
transactions.executedAt.lte(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 = targetDate.toISOString().split('T')[0];
const [record] = await db
.select({
price: assetPricesHistory.price,
})
.from(assetPricesHistory)
.where(eq(assetPricesHistory.assetId, assetId))
.where(lte(assetPricesHistory.date, dateStr))
.orderBy(desc(assetPricesHistory.date))
.limit(1);
return record?.price ?? null;
}