stock-portfolio_byQwen3.6/src/actions/portfolio.ts

240 lines
6.6 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;
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,
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;
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,
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,
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,
};
}