feat(ledger): 引入 latestPrice 字段与历史成本追踪,实装 P&L 盈亏计算引擎
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
|
||||
import { db } from '@/db';
|
||||
import { assets, assetTypeEnum } from '@/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { z } from 'zod';
|
||||
|
||||
const createAssetSchema = z.object({
|
||||
@@ -34,4 +36,24 @@ export async function createAsset(params: z.infer<typeof createAssetSchema>) {
|
||||
|
||||
export async function getAssets() {
|
||||
return db.select().from(assets);
|
||||
}
|
||||
|
||||
const updatePriceSchema = z.object({
|
||||
assetId: z.string().min(1, 'Asset ID is required'),
|
||||
newPrice: z.string().min(1, 'Price is required'),
|
||||
});
|
||||
|
||||
export async function updateAssetPrice(params: z.infer<typeof updatePriceSchema>) {
|
||||
const validation = updatePriceSchema.safeParse(params);
|
||||
if (!validation.success) {
|
||||
return { success: false, error: validation.error.issues[0].message };
|
||||
}
|
||||
|
||||
try {
|
||||
await db.update(assets).set({ latestPrice: params.newPrice }).where(eq(assets.id, params.assetId));
|
||||
revalidatePath('/dashboard');
|
||||
return { success: true };
|
||||
} catch (error: unknown) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
+42
-60
@@ -12,6 +12,8 @@ interface Position {
|
||||
quantity: string;
|
||||
baseCurrency: string;
|
||||
cnyValue: string;
|
||||
totalCostCny: string;
|
||||
pnlCny: string;
|
||||
}
|
||||
|
||||
interface RawRate {
|
||||
@@ -38,19 +40,21 @@ function getRate(
|
||||
return null;
|
||||
}
|
||||
|
||||
function calculateCnyValue(
|
||||
function calculateCnyValueFromPrice(
|
||||
quantity: Big,
|
||||
latestPrice: string,
|
||||
baseCurrency: string,
|
||||
rateMap: Map<string, string>,
|
||||
cryptoPrices: Map<string, string>
|
||||
rateMap: Map<string, string>
|
||||
): Big {
|
||||
const price = new Big(latestPrice || '0');
|
||||
|
||||
if (baseCurrency === 'CNY') {
|
||||
return quantity;
|
||||
return quantity.times(price);
|
||||
}
|
||||
|
||||
const directRate = getRate(rateMap, baseCurrency, 'CNY');
|
||||
if (directRate) {
|
||||
return quantity.times(directRate);
|
||||
return quantity.times(price).times(directRate);
|
||||
}
|
||||
|
||||
const usdToCny = getRate(rateMap, 'USD', 'CNY');
|
||||
@@ -58,17 +62,9 @@ function calculateCnyValue(
|
||||
return new Big('0');
|
||||
}
|
||||
|
||||
const priceKey = `${baseCurrency}_USD`;
|
||||
const cryptoPrice = cryptoPrices.get(priceKey);
|
||||
if (cryptoPrice) {
|
||||
const usdValue = quantity.times(cryptoPrice);
|
||||
return usdValue.times(usdToCny);
|
||||
}
|
||||
|
||||
const usdRate = getRate(rateMap, baseCurrency, 'USD');
|
||||
if (usdRate) {
|
||||
const usdValue = quantity.times(usdRate);
|
||||
return usdValue.times(usdToCny);
|
||||
return quantity.times(price).times(usdRate).times(usdToCny);
|
||||
}
|
||||
|
||||
return new Big('0');
|
||||
@@ -79,11 +75,13 @@ export async function getPortfolioPositions(): Promise<Position[]> {
|
||||
.select({
|
||||
txType: transactions.txType,
|
||||
quantity: transactions.quantity,
|
||||
price: transactions.price,
|
||||
exchangeRate: transactions.exchangeRate,
|
||||
assetId: transactions.assetId,
|
||||
assetSymbol: assets.symbol,
|
||||
assetType: assets.type,
|
||||
assetBaseCurrency: assets.baseCurrency,
|
||||
assetPrice: transactions.price,
|
||||
assetLatestPrice: assets.latestPrice,
|
||||
})
|
||||
.from(transactions)
|
||||
.leftJoin(assets, eq(assets.id, transactions.assetId))
|
||||
@@ -96,6 +94,7 @@ export async function getPortfolioPositions(): Promise<Position[]> {
|
||||
quantity: Big;
|
||||
baseCurrency: string;
|
||||
latestPrice: string;
|
||||
totalCostCny: Big;
|
||||
}>();
|
||||
|
||||
for (const tx of allTransactions) {
|
||||
@@ -109,22 +108,28 @@ export async function getPortfolioPositions(): Promise<Position[]> {
|
||||
type: tx.assetType || 'CASH',
|
||||
quantity: new Big('0'),
|
||||
baseCurrency: tx.assetBaseCurrency || '',
|
||||
latestPrice: tx.assetPrice || '0',
|
||||
latestPrice: tx.assetLatestPrice || '0',
|
||||
totalCostCny: new Big('0'),
|
||||
});
|
||||
}
|
||||
|
||||
const holding = holdings.get(tx.assetId)!;
|
||||
|
||||
if (tx.txType === 'BUY' || tx.txType === 'AIRDROP') {
|
||||
if (tx.txType === 'BUY') {
|
||||
holding.quantity = holding.quantity.plus(tx.quantity);
|
||||
const costPerUnit = tx.quantity.times(tx.price);
|
||||
const costCny = costPerUnit.times(tx.exchangeRate || '1');
|
||||
holding.totalCostCny = holding.totalCostCny.plus(costCny);
|
||||
} else if (tx.txType === 'SELL') {
|
||||
holding.quantity = holding.quantity.minus(tx.quantity);
|
||||
} else if (tx.txType === 'AIRDROP') {
|
||||
holding.quantity = holding.quantity.plus(tx.quantity);
|
||||
} else if (tx.txType === 'DIVIDEND') {
|
||||
holding.quantity = holding.quantity.plus(tx.quantity);
|
||||
}
|
||||
|
||||
if (tx.assetPrice) {
|
||||
holding.latestPrice = tx.assetPrice;
|
||||
if (tx.assetLatestPrice) {
|
||||
holding.latestPrice = tx.assetLatestPrice;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,56 +141,25 @@ export async function getPortfolioPositions(): Promise<Position[]> {
|
||||
|
||||
const rateMap = buildRateMap(rates);
|
||||
|
||||
const cryptoSymbols = new Set(['BTC', 'ETH', 'SOL', 'BNB', 'XRP', 'ADA', 'DOGE', 'AVAX', 'MATIC', 'DOT']);
|
||||
const cryptoPrices = new Map<string, string>();
|
||||
for (const [_, holding] of holdings) {
|
||||
if (holding.type === 'CRYPTO' && cryptoSymbols.has(holding.symbol.toUpperCase())) {
|
||||
const priceKey = `${holding.symbol}_USD`;
|
||||
const usdRate = getRate(rateMap, holding.symbol, 'USD');
|
||||
if (usdRate) {
|
||||
cryptoPrices.set(priceKey, usdRate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result: Position[] = [];
|
||||
let totalCnyValue = new Big('0');
|
||||
let totalPnlCny = new Big('0');
|
||||
|
||||
for (const [_, holding] of holdings) {
|
||||
if (holding.quantity.lte(0)) continue;
|
||||
|
||||
let cnyValue: Big;
|
||||
|
||||
if (holding.type === 'CRYPTO') {
|
||||
const symbol = holding.symbol.toUpperCase();
|
||||
const btcToUsd = getRate(rateMap, symbol, 'USD');
|
||||
const usdToCny = getRate(rateMap, 'USD', 'CNY');
|
||||
|
||||
if (btcToUsd && usdToCny) {
|
||||
const usdValue = holding.quantity.times(holding.latestPrice || '1');
|
||||
cnyValue = usdValue.times(usdToCny);
|
||||
} else {
|
||||
cnyValue = new Big('0');
|
||||
}
|
||||
} else if (holding.baseCurrency === 'CNY') {
|
||||
cnyValue = holding.quantity.times(holding.latestPrice || '1');
|
||||
} else {
|
||||
const directRate = getRate(rateMap, holding.baseCurrency, 'CNY');
|
||||
if (directRate) {
|
||||
cnyValue = holding.quantity.times(holding.latestPrice || '1').times(directRate);
|
||||
} else {
|
||||
const usdRate = getRate(rateMap, holding.baseCurrency, 'USD');
|
||||
const usdToCny = getRate(rateMap, 'USD', 'CNY');
|
||||
if (usdRate && usdToCny) {
|
||||
cnyValue = holding.quantity.times(holding.latestPrice || '1').times(usdRate).times(usdToCny);
|
||||
} else {
|
||||
cnyValue = new Big('0');
|
||||
}
|
||||
}
|
||||
}
|
||||
const cnyValue = calculateCnyValueFromPrice(
|
||||
holding.quantity,
|
||||
holding.latestPrice,
|
||||
holding.baseCurrency,
|
||||
rateMap
|
||||
);
|
||||
|
||||
totalCnyValue = totalCnyValue.plus(cnyValue);
|
||||
|
||||
const pnlCny = cnyValue.minus(holding.totalCostCny);
|
||||
totalPnlCny = totalPnlCny.plus(pnlCny);
|
||||
|
||||
result.push({
|
||||
assetId: holding.assetId,
|
||||
symbol: holding.symbol,
|
||||
@@ -193,6 +167,8 @@ export async function getPortfolioPositions(): Promise<Position[]> {
|
||||
quantity: holding.quantity.toString(),
|
||||
baseCurrency: holding.baseCurrency,
|
||||
cnyValue: cnyValue.toString(),
|
||||
totalCostCny: holding.totalCostCny.toString(),
|
||||
pnlCny: pnlCny.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -207,6 +183,11 @@ export async function getPortfolioSummary() {
|
||||
new Big('0')
|
||||
);
|
||||
|
||||
const totalPnlCny = positions.reduce(
|
||||
(sum, pos) => sum.plus(new Big(pos.pnlCny)),
|
||||
new Big('0')
|
||||
);
|
||||
|
||||
const chartData = positions.map((pos, index) => ({
|
||||
name: pos.symbol,
|
||||
value: new Big(pos.cnyValue),
|
||||
@@ -223,6 +204,7 @@ export async function getPortfolioSummary() {
|
||||
return {
|
||||
positions,
|
||||
totalCnyValue: totalCnyValue.toString(),
|
||||
totalPnlCny: totalPnlCny.toString(),
|
||||
chartData,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user