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

521 lines
16 KiB
TypeScript

'use server';
import { db } from '@/db';
import { transactions, assets, exchangeRates, exchangeRatesHistory } from '@/db/schema';
import Big from 'big.js';
import { asc, desc, eq } from 'drizzle-orm';
import { calculateAssetMetrics } from '@/utils/finance';
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;
// 新增:双重成本与盈亏指标
totalBuyCost: string;
totalBuyQuantity: string;
realizedPnlCny: string;
avgCost: string;
dilutedCost: string;
dilutedCostCny: string;
floatingPnl: string;
floatingPnlCny: string;
accumulatedPnl: string;
accumulatedPnlCny: string;
marketValueCny: string;
holdingDays: number;
exchange: string;
accumulatedDividendsCny: string;
accumulatedDividendsNative: string;
// Native 原生币种盈亏指标
totalBuyCostNative: string;
realizedPnlNative: string;
avgCostNative: string;
dilutedCostNative: string;
marketValueNative: string;
floatingPnlNative: string;
floatingPnlPercent: string;
cumulativePnlNative: string;
cumulativePnlPercent: string;
latestPrice: string;
transactions: TransactionRecord[];
}
interface TransactionRecord {
id: string;
txType: string;
quantity: string;
price: string;
fee: string;
txCurrency: string;
executedAt: Date | null;
}
interface RawRate {
fromCurrency: string;
toCurrency: string;
rate: string;
}
async function getLatestRatesMap(): Promise<Record<string, Big>> {
const usdResult = await db
.select({
rate: exchangeRatesHistory.rate,
})
.from(exchangeRatesHistory)
.where(eq(exchangeRatesHistory.fromCurrency, 'USD'))
.orderBy(desc(exchangeRatesHistory.fetchTime))
.limit(1);
const hkdResult = await db
.select({
rate: exchangeRatesHistory.rate,
})
.from(exchangeRatesHistory)
.where(eq(exchangeRatesHistory.fromCurrency, 'HKD'))
.orderBy(desc(exchangeRatesHistory.fetchTime))
.limit(1);
const dbUsd = usdResult[0];
const dbHkd = hkdResult[0];
return {
CNY: new Big(1),
USD: new Big(dbUsd?.rate || 7.2),
HKD: new Big(dbHkd?.rate || 0.9),
};
}
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');
}
function getMarketFromExchange(exchange: string): string {
if (!exchange) return '未知';
const upper = exchange.toUpperCase();
if (upper === 'SSE' || upper === 'SZSE') return 'A股';
if (upper === 'HKEX') return '港股';
if (upper === 'CRYPTO') return '虚拟币';
return '美股';
}
const MARKET_COLORS: Record<string, string> = {
'A股': '#ef4444',
'港股': '#f59e0b',
'美股': '#3b82f6',
'虚拟币': '#10b981',
};
interface MarketAllocation {
market: string;
name: string;
totalCnyValue: number;
percentage: number;
fill: string;
}
function getTodayInShanghai(): Date {
const now = new Date();
const utcStr = now.toLocaleString('en-US', { timeZone: 'UTC' });
const utcDate = new Date(utcStr);
const shanghaiOffset = 8 * 60 * 60 * 1000;
return new Date(utcDate.getTime() + shanghaiOffset);
}
export async function getPortfolioPositions(): Promise<Position[]> {
const allTransactions = await db
.select({
id: transactions.id,
txType: transactions.txType,
quantity: transactions.quantity,
price: transactions.price,
fee: transactions.fee,
exchangeRate: transactions.exchangeRate,
txCurrency: transactions.txCurrency,
assetId: transactions.assetId,
assetSymbol: assets.symbol,
assetName: assets.name,
assetType: assets.type,
assetBaseCurrency: assets.baseCurrency,
assetLatestPrice: assets.latestPrice,
assetExchange: assets.exchange,
executedAt: transactions.executedAt,
})
.from(transactions)
.leftJoin(assets, eq(assets.id, transactions.assetId))
.orderBy(asc(transactions.executedAt));
const dynamicRateMap = await getLatestRatesMap();
const rateMap = new Map<string, string>();
for (const [currency, rate] of Object.entries(dynamicRateMap)) {
if (currency !== 'CNY') {
rateMap.set(`${currency}_CNY`, rate.toString());
}
}
const holdings = new Map<string, {
assetId: string;
symbol: string;
name: string | null;
type: string;
quantity: Big;
baseCurrency: string;
latestPrice: string;
exchange: string;
// 累计买入指标
totalBuyCostCny: Big;
totalBuyCostNative: Big;
totalBuyQuantity: Big;
// 已实现盈亏
realizedPnlCny: Big;
realizedPnlNative: Big;
accumulatedDividendsCny: Big;
accumulatedDividendsNative: Big;
// 首次买入日期
firstBuyDate: Date | null;
// 原始流水明细
transactions: TransactionRecord[];
}>();
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',
exchange: tx.assetExchange || 'US',
totalBuyCostCny: new Big('0'),
totalBuyCostNative: new Big('0'),
totalBuyQuantity: new Big('0'),
realizedPnlCny: new Big('0'),
realizedPnlNative: new Big('0'),
accumulatedDividendsCny: new Big('0'),
accumulatedDividendsNative: new Big('0'),
firstBuyDate: null,
transactions: [],
});
}
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.totalBuyCostNative = holding.totalBuyCostNative.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.totalBuyCostCny = holding.totalBuyCostCny.plus(costCny);
holding.totalBuyQuantity = holding.totalBuyQuantity.plus(new Big(tx.quantity));
// 记录首次买入日期
if (!holding.firstBuyDate && tx.executedAt) {
holding.firstBuyDate = new Date(tx.executedAt);
}
} else if (tx.txType === 'SELL') {
// 计算卖出时的平均成本 (Native)
let avgCostPerUnitNative = new Big('0');
if (holding.totalBuyQuantity.gt(0)) {
avgCostPerUnitNative = holding.totalBuyCostNative.div(holding.totalBuyQuantity);
}
// 已实现盈亏 = (卖出价 - 平均成本) * 卖出数量 (Native)
const sellRevenueNative = new Big(tx.quantity).times(new Big(tx.price));
const costBasisNative = avgCostPerUnitNative.times(new Big(tx.quantity));
const realizedPnlNative = sellRevenueNative.minus(costBasisNative);
holding.realizedPnlNative = holding.realizedPnlNative.plus(realizedPnlNative);
// 已实现盈亏 (CNY) 保留兼容
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 sellRevenueCny = new Big(tx.quantity).times(new Big(tx.price)).times(new Big(appliedRate || '1'));
let avgCostPerUnitCny = new Big('0');
if (holding.totalBuyQuantity.gt(0)) {
avgCostPerUnitCny = holding.totalBuyCostCny.div(holding.totalBuyQuantity);
}
const costBasisCny = avgCostPerUnitCny.times(new Big(tx.quantity));
const realizedPnlCny = sellRevenueCny.minus(costBasisCny);
holding.realizedPnlCny = holding.realizedPnlCny.plus(realizedPnlCny);
holding.quantity = holding.quantity.minus(new Big(tx.quantity));
} else if (tx.txType === 'AIRDROP') {
holding.quantity = holding.quantity.plus(new Big(tx.quantity));
} else if (tx.txType === 'DIVIDEND') {
const dividendAmountNative = new Big(tx.quantity).times(new Big(tx.price));
const dividendCny = dividendAmountNative.times(new Big(tx.exchangeRate || '1'));
holding.accumulatedDividendsCny = holding.accumulatedDividendsCny.plus(dividendCny);
holding.accumulatedDividendsNative = holding.accumulatedDividendsNative.plus(dividendAmountNative);
}
if (tx.assetLatestPrice) {
holding.latestPrice = tx.assetLatestPrice;
}
holding.transactions.push({
id: tx.id,
txType: tx.txType,
quantity: tx.quantity,
price: tx.price,
fee: tx.fee,
txCurrency: tx.txCurrency,
executedAt: tx.executedAt,
});
}
const today = getTodayInShanghai();
const result: Position[] = [];
for (const [_, holding] of holdings) {
if (holding.quantity.lte(0)) continue;
const cnyValue = calculateCnyValueFromPrice(
holding.quantity,
holding.latestPrice,
holding.baseCurrency,
rateMap
);
// 未实现盈亏 (CNY)
const unrealizedPnlCny = cnyValue.minus(holding.totalBuyCostCny);
// 总盈亏 (CNY)
const totalPnlCny = unrealizedPnlCny.plus(holding.realizedPnlCny).plus(holding.accumulatedDividendsCny);
const metrics = calculateAssetMetrics(
holding.transactions.map(tx => ({
date: tx.executedAt ?? new Date(),
txType: tx.txType,
quantity: tx.quantity,
price: tx.price,
fee: tx.fee,
})),
holding.latestPrice
);
// 从动态汇率字典获取资产对人民币的汇率
const currencyKey = holding.baseCurrency || 'CNY';
const fxRate = dynamicRateMap[currencyKey] || new Big(1);
// 将引擎返回的原生币种金额折算为 CNY
const marketValueCny = new Big(metrics.marketValue).times(fxRate).toString();
const floatingPnlCny = new Big(metrics.floatingPnl).times(fxRate).toString();
const accumulatedPnlCny = new Big(metrics.accumulatedPnl).times(fxRate).toString();
const dilutedCostCny = new Big(metrics.dilutedCost).times(fxRate).toString();
const holdingNative = new Big(metrics.holdings);
const avgCostNative = new Big(metrics.averageCost);
const dilutedCostNative = new Big(metrics.dilutedCost);
const floatingPnlNative = new Big(metrics.floatingPnl);
const cumulativePnlNative = new Big(metrics.accumulatedPnl);
const marketValueNative = new Big(metrics.marketValue);
let floatingPnlPercent = new Big('0');
const avgCostBasisNative = avgCostNative.times(holdingNative);
if (avgCostBasisNative.gt(0)) {
floatingPnlPercent = floatingPnlNative.div(avgCostBasisNative).times(new Big('100'));
}
let cumulativePnlPercent = new Big('0');
if (holding.totalBuyCostNative.gt(0)) {
cumulativePnlPercent = cumulativePnlNative.div(holding.totalBuyCostNative).times(new Big('100'));
}
let holdingDays = 0;
if (holding.firstBuyDate) {
const diffMs = today.getTime() - holding.firstBuyDate.getTime();
holdingDays = Math.max(0, Math.floor(diffMs / (1000 * 60 * 60 * 24)));
}
// Native 原生币种总盈亏 (保留兼容)
const pnlNative = marketValueNative.minus(holding.totalBuyCostNative).plus(holding.accumulatedDividendsNative);
result.push({
assetId: holding.assetId,
symbol: holding.symbol,
name: holding.name,
type: holding.type,
quantity: holdingNative.toString(),
baseCurrency: holding.baseCurrency,
cnyValue: cnyValue.toString(),
totalCostCny: holding.totalBuyCostCny.toString(),
pnlCny: totalPnlCny.toString(),
totalCostNative: holding.totalBuyCostNative.toString(),
pnlNative: pnlNative.toString(),
totalBuyCost: holding.totalBuyCostCny.toString(),
totalBuyQuantity: holding.totalBuyQuantity.toString(),
realizedPnlCny: holding.realizedPnlCny.toString(),
avgCost: metrics.averageCost,
dilutedCost: metrics.dilutedCost,
dilutedCostCny,
floatingPnl: metrics.floatingPnl,
floatingPnlCny,
accumulatedPnl: metrics.accumulatedPnl,
accumulatedPnlCny,
marketValueCny,
holdingDays,
exchange: holding.exchange,
accumulatedDividendsCny: holding.accumulatedDividendsCny.toString(),
accumulatedDividendsNative: holding.accumulatedDividendsNative.toString(),
// Native 原生币种盈亏指标
totalBuyCostNative: holding.totalBuyCostNative.toString(),
realizedPnlNative: holding.realizedPnlNative.toString(),
avgCostNative: avgCostNative.toString(),
dilutedCostNative: dilutedCostNative.toString(),
marketValueNative: marketValueNative.toString(),
floatingPnlNative: floatingPnlNative.toString(),
floatingPnlPercent: floatingPnlPercent.toString(),
cumulativePnlNative: cumulativePnlNative.toString(),
cumulativePnlPercent: cumulativePnlPercent.toString(),
latestPrice: holding.latestPrice,
transactions: holding.transactions,
});
}
return result;
}
export async function getPortfolioSummary() {
const positions = await getPortfolioPositions();
// 单一事实来源:复用 getPortfolioPositions 已汇率折算的结果
let totalCnyValue = new Big('0');
let totalPnlCny = new Big('0');
let totalFloatingPnlCny = new Big('0');
for (const pos of positions) {
totalCnyValue = totalCnyValue.plus(new Big(pos.marketValueCny || '0'));
totalPnlCny = totalPnlCny.plus(new Big(pos.accumulatedPnlCny || '0'));
totalFloatingPnlCny = totalFloatingPnlCny.plus(new Big(pos.floatingPnlCny || '0'));
}
const chartData = positions.map((pos, index) => ({
name: pos.symbol,
value: new Big(pos.marketValueCny || '0').toNumber(),
fill: [
'#3b82f6',
'#8b5cf6',
'#10b981',
'#f59e0b',
'#ef4444',
'#06b6d4',
][index % 6],
}));
// 按市场维度聚合资产分布
const marketMap = new Map<string, {
market: string;
totalCnyValue: Big;
}>();
for (const pos of positions) {
const market = getMarketFromExchange(pos.exchange);
const existing = marketMap.get(market);
if (existing) {
existing.totalCnyValue = existing.totalCnyValue.plus(new Big(pos.marketValueCny || '0'));
} else {
marketMap.set(market, {
market,
totalCnyValue: new Big(pos.marketValueCny || '0'),
});
}
}
const marketAllocation: MarketAllocation[] = [];
let grandTotal = new Big('0');
for (const [, data] of marketMap) {
grandTotal = grandTotal.plus(data.totalCnyValue);
}
for (const [, data] of marketMap) {
const percentage = grandTotal.gt(0)
? data.totalCnyValue.div(grandTotal).times(100)
: new Big('0');
marketAllocation.push({
market: data.market,
name: data.market,
totalCnyValue: Number(data.totalCnyValue.toString()),
percentage: Number(percentage.toString()),
fill: MARKET_COLORS[data.market] || '#6b7280',
});
}
marketAllocation.sort((a, b) => b.totalCnyValue - a.totalCnyValue);
return {
positions,
totalCnyValue: totalCnyValue.toString(),
totalPnlCny: totalPnlCny.toString(),
unrealizedPnlCny: totalFloatingPnlCny.toString(),
chartData,
marketAllocation,
};
}