feat(ledger): 编写时光机核心辅助函数,支持历史持仓计算与价格断点结转
This commit is contained in:
parent
209cdd3625
commit
7bd2eb1e86
@ -133,4 +133,10 @@
|
||||
- 在 `src/actions/market.ts` 中新增 `importHistoricalPrices(assetId, data)` Server Action,遍歷數據數組對 `assetPricesHistory` 表執行批量 Upsert(基於 `(assetId, date)` 聯合唯一索引的 `onConflictDoUpdate`),衝突時用新價格覆蓋舊價格。
|
||||
- 前端 `app/dashboard/page.tsx` 的持倉明細表「操作」列與展開區域均新增【導入價格】按鈕。
|
||||
- 點擊按鈕彈出 Dialog,內含 `<textarea>` 文本域,支持用戶從 Excel 直接複製粘貼,格式為 `YYYY-MM-DD, 價格`(每行一條)。
|
||||
- 前端按換行和逗號解析文本,生成 `{date, price}` 數組後調用 `importHistoricalPrices` Action,導入完成後 Toast 提示成功/失敗條數並刷新頁面。
|
||||
- 前端按換行和逗號解析文本,生成 `{date, price}` 數組後調用 `importHistoricalPrices` Action,導入完成後 Toast 提示成功/失敗條數並刷新頁面。
|
||||
|
||||
## 历史净值重构引擎 - 底层查询辅助函数 (Task 50)
|
||||
- 为历史净值重构引擎开发底层查询辅助函数,实现特定日期的持仓快照与基于降序 Limit 1 的价格断点结转逻辑。
|
||||
- 在 `src/actions/snapshots.ts` 中新增 `getHistoricalPositions(targetDate)` 函数:从 `transactions` 表查询所有 `executedAt <= targetDate` 的流水,按时间正序遍历,按资产聚合计算出该日期下的 `quantity`(当前持仓)和 `totalCost`(累计投入本金,SELL 时按平均成本扣减),过滤掉已清仓资产。
|
||||
- 在 `src/actions/snapshots.ts` 中新增 `getEffectivePrice(assetId, targetDate)` 函数:在 `assetPricesHistory` 表中查询指定 `assetId` 且 `date <= targetDate` 的记录,按照 `date` 降序排列 (`desc`) 并 `.limit(1)` 取第一条,实现价格断点结转(Forward-Fill)逻辑——如果目标当天没有导入价格,系统自动抓取该资产在目标日期之前「最新」的一次有效价格。
|
||||
- 两个函数均使用 `Big.js` 进行高精度数值计算,为历史净值时光机功能提供底层数据支撑。
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user