245 lines
6.7 KiB
TypeScript
245 lines
6.7 KiB
TypeScript
'use server';
|
|
|
|
import { db } from '@/db';
|
|
import { transactions, assets, exchangeRates } from '@/db/schema';
|
|
import Big from 'big.js';
|
|
import { desc, eq } from 'drizzle-orm';
|
|
|
|
interface Position {
|
|
assetId: string;
|
|
symbol: string;
|
|
name: string | null;
|
|
type: string;
|
|
quantity: string;
|
|
baseCurrency: string;
|
|
cnyValue: string;
|
|
totalCostCny: string;
|
|
pnlCny: string;
|
|
totalCostNative: string;
|
|
pnlNative: string;
|
|
}
|
|
|
|
interface RawRate {
|
|
fromCurrency: string;
|
|
toCurrency: string;
|
|
rate: string;
|
|
}
|
|
|
|
function buildRateMap(rates: RawRate[]): Map<string, string> {
|
|
const map = new Map<string, string>();
|
|
for (const r of rates) {
|
|
map.set(`${r.fromCurrency}_${r.toCurrency}`, r.rate);
|
|
}
|
|
return map;
|
|
}
|
|
|
|
function getRate(
|
|
rateMap: Map<string, string>,
|
|
from: string,
|
|
to: string
|
|
): string | null {
|
|
const direct = rateMap.get(`${from}_${to}`);
|
|
if (direct) return direct;
|
|
return null;
|
|
}
|
|
|
|
function calculateCnyValueFromPrice(
|
|
quantity: Big,
|
|
latestPrice: string,
|
|
baseCurrency: string,
|
|
rateMap: Map<string, string>
|
|
): Big {
|
|
const price = new Big(latestPrice || '0');
|
|
|
|
if (baseCurrency === 'CNY') {
|
|
return quantity.times(price);
|
|
}
|
|
|
|
const directRate = getRate(rateMap, baseCurrency, 'CNY');
|
|
if (directRate) {
|
|
return quantity.times(price).times(new Big(directRate));
|
|
}
|
|
|
|
const usdToCny = getRate(rateMap, 'USD', 'CNY');
|
|
if (!usdToCny) {
|
|
return new Big('0');
|
|
}
|
|
|
|
const usdRate = getRate(rateMap, baseCurrency, 'USD');
|
|
if (usdRate) {
|
|
return quantity.times(price).times(new Big(usdRate)).times(new Big(usdToCny));
|
|
}
|
|
|
|
return new Big('0');
|
|
}
|
|
|
|
export async function getPortfolioPositions(): Promise<Position[]> {
|
|
const allTransactions = await db
|
|
.select({
|
|
txType: transactions.txType,
|
|
quantity: transactions.quantity,
|
|
price: transactions.price,
|
|
exchangeRate: transactions.exchangeRate,
|
|
txCurrency: transactions.txCurrency,
|
|
assetId: transactions.assetId,
|
|
assetSymbol: assets.symbol,
|
|
assetName: assets.name,
|
|
assetType: assets.type,
|
|
assetBaseCurrency: assets.baseCurrency,
|
|
assetLatestPrice: assets.latestPrice,
|
|
})
|
|
.from(transactions)
|
|
.leftJoin(assets, eq(assets.id, transactions.assetId))
|
|
.orderBy(desc(transactions.executedAt));
|
|
|
|
const rates = await db.select({
|
|
fromCurrency: exchangeRates.fromCurrency,
|
|
toCurrency: exchangeRates.toCurrency,
|
|
rate: exchangeRates.rate,
|
|
}).from(exchangeRates);
|
|
|
|
const rateMap = buildRateMap(rates);
|
|
|
|
const holdings = new Map<string, {
|
|
assetId: string;
|
|
symbol: string;
|
|
name: string | null;
|
|
type: string;
|
|
quantity: Big;
|
|
baseCurrency: string;
|
|
latestPrice: string;
|
|
totalCostCny: Big;
|
|
totalCostNative: Big;
|
|
}>();
|
|
|
|
for (const tx of allTransactions) {
|
|
if (!tx.assetId) continue;
|
|
|
|
const existing = holdings.get(tx.assetId);
|
|
if (!existing) {
|
|
holdings.set(tx.assetId, {
|
|
assetId: tx.assetId,
|
|
symbol: tx.assetSymbol || tx.assetId,
|
|
name: tx.assetName,
|
|
type: tx.assetType || 'CASH',
|
|
quantity: new Big('0'),
|
|
baseCurrency: tx.assetBaseCurrency || '',
|
|
latestPrice: tx.assetLatestPrice || '0',
|
|
totalCostCny: new Big('0'),
|
|
totalCostNative: new Big('0'),
|
|
});
|
|
}
|
|
|
|
const holding = holdings.get(tx.assetId)!;
|
|
|
|
if (tx.txType === 'BUY') {
|
|
holding.quantity = holding.quantity.plus(new Big(tx.quantity));
|
|
const costPerUnit = new Big(tx.quantity).times(new Big(tx.price));
|
|
holding.totalCostNative = holding.totalCostNative.plus(costPerUnit);
|
|
let appliedRate = tx.exchangeRate;
|
|
if ((!appliedRate || appliedRate === '1' || appliedRate === '1.00000000') && tx.txCurrency !== 'CNY') {
|
|
const fallbackRate = getRate(rateMap, tx.txCurrency, 'CNY');
|
|
if (fallbackRate) {
|
|
appliedRate = fallbackRate;
|
|
}
|
|
}
|
|
const costCny = costPerUnit.times(new Big(appliedRate || '1'));
|
|
holding.totalCostCny = holding.totalCostCny.plus(costCny);
|
|
} else if (tx.txType === 'SELL') {
|
|
holding.quantity = holding.quantity.minus(new Big(tx.quantity));
|
|
const sellCostPerUnit = new Big(tx.quantity).times(new Big(tx.price));
|
|
holding.totalCostNative = holding.totalCostNative.minus(sellCostPerUnit);
|
|
let appliedRate = tx.exchangeRate;
|
|
if ((!appliedRate || appliedRate === '1' || appliedRate === '1.00000000') && tx.txCurrency !== 'CNY') {
|
|
const fallbackRate = getRate(rateMap, tx.txCurrency, 'CNY');
|
|
if (fallbackRate) {
|
|
appliedRate = fallbackRate;
|
|
}
|
|
}
|
|
const sellCostCny = sellCostPerUnit.times(new Big(appliedRate || '1'));
|
|
holding.totalCostCny = holding.totalCostCny.minus(sellCostCny);
|
|
} else if (tx.txType === 'AIRDROP') {
|
|
holding.quantity = holding.quantity.plus(new Big(tx.quantity));
|
|
} else if (tx.txType === 'DIVIDEND') {
|
|
holding.quantity = holding.quantity.plus(new Big(tx.quantity));
|
|
}
|
|
|
|
if (tx.assetLatestPrice) {
|
|
holding.latestPrice = tx.assetLatestPrice;
|
|
}
|
|
}
|
|
|
|
const result: Position[] = [];
|
|
let totalCnyValue = new Big('0');
|
|
let totalPnlCny = new Big('0');
|
|
|
|
for (const [_, holding] of holdings) {
|
|
if (holding.quantity.lte(0)) continue;
|
|
|
|
const cnyValue = calculateCnyValueFromPrice(
|
|
holding.quantity,
|
|
holding.latestPrice,
|
|
holding.baseCurrency,
|
|
rateMap
|
|
);
|
|
|
|
totalCnyValue = totalCnyValue.plus(cnyValue);
|
|
|
|
const pnlCny = cnyValue.minus(holding.totalCostCny);
|
|
totalPnlCny = totalPnlCny.plus(pnlCny);
|
|
|
|
const currentNativeValue = new Big(holding.latestPrice).times(holding.quantity);
|
|
const pnlNative = currentNativeValue.minus(holding.totalCostNative);
|
|
|
|
result.push({
|
|
assetId: holding.assetId,
|
|
symbol: holding.symbol,
|
|
name: holding.name,
|
|
type: holding.type,
|
|
quantity: holding.quantity.toString(),
|
|
baseCurrency: holding.baseCurrency,
|
|
cnyValue: cnyValue.toString(),
|
|
totalCostCny: holding.totalCostCny.toString(),
|
|
pnlCny: pnlCny.toString(),
|
|
totalCostNative: holding.totalCostNative.toString(),
|
|
pnlNative: pnlNative.toString(),
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
export async function getPortfolioSummary() {
|
|
const positions = await getPortfolioPositions();
|
|
|
|
const totalCnyValue = positions.reduce(
|
|
(sum, pos) => sum.plus(new Big(pos.cnyValue)),
|
|
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),
|
|
fill: [
|
|
'#3b82f6',
|
|
'#8b5cf6',
|
|
'#10b981',
|
|
'#f59e0b',
|
|
'#ef4444',
|
|
'#06b6d4',
|
|
][index % 6],
|
|
}));
|
|
|
|
return {
|
|
positions,
|
|
totalCnyValue: totalCnyValue.toString(),
|
|
totalPnlCny: totalPnlCny.toString(),
|
|
chartData,
|
|
};
|
|
}
|