feat(ledger): 引入 latestPrice 字段与历史成本追踪,实装 P&L 盈亏计算引擎

This commit is contained in:
kennethcheng 2026-04-28 00:57:33 +08:00
parent 27c3b76bba
commit b9186d4699
6 changed files with 241 additions and 101 deletions

View File

@ -1,5 +1,6 @@
import { getAssets } from '@/actions/asset'; import { getAssets } from '@/actions/asset';
import { AddAssetDialog } from '@/components/assets/add-asset-dialog'; import { AddAssetDialog } from '@/components/assets/add-asset-dialog';
import { UpdatePriceDialog } from '@/components/assets/update-price-dialog';
import { import {
Table, Table,
TableBody, TableBody,
@ -15,33 +16,35 @@ export default async function AssetsPage() {
const typeLabels: Record<string, string> = { const typeLabels: Record<string, string> = {
STOCK: '股票', STOCK: '股票',
CRYPTO: '加密货币', CRYPTO: '加密貨幣',
CASH: '金', CASH: '金',
}; };
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold"></h1> <h1 className="text-2xl font-bold"></h1>
<AddAssetDialog /> <AddAssetDialog />
</div> </div>
<div className="rounded-md border"> <div className="rounded-md border">
<Table> <Table>
<TableCaption></TableCaption> <TableCaption></TableCaption>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead> <TableHead> (Latest Price)</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{assets.length === 0 ? ( {assets.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground"> <TableCell colSpan={6} className="text-center text-muted-foreground">
"添加资产" "添加資產"
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
@ -50,11 +53,15 @@ export default async function AssetsPage() {
<TableCell className="font-medium">{asset.symbol}</TableCell> <TableCell className="font-medium">{asset.symbol}</TableCell>
<TableCell>{typeLabels[asset.type] || asset.type}</TableCell> <TableCell>{typeLabels[asset.type] || asset.type}</TableCell>
<TableCell>{asset.baseCurrency}</TableCell> <TableCell>{asset.baseCurrency}</TableCell>
<TableCell>{asset.latestPrice}</TableCell>
<TableCell> <TableCell>
{asset.createdAt {asset.createdAt
? new Date(asset.createdAt).toLocaleString('zh-CN') ? new Date(asset.createdAt).toLocaleString('zh-CN')
: '-'} : '-'}
</TableCell> </TableCell>
<TableCell>
<UpdatePriceDialog assetId={asset.id} currentPrice={asset.latestPrice || '0'} />
</TableCell>
</TableRow> </TableRow>
)) ))
)} )}

View File

@ -2,6 +2,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { getPortfolioSummary } from '@/actions/portfolio'; import { getPortfolioSummary } from '@/actions/portfolio';
import { formatQuantity, formatAmount } from '@/lib/formatters'; import { formatQuantity, formatAmount } from '@/lib/formatters';
import AllocationChart from '@/components/dashboard/allocation-chart'; import AllocationChart from '@/components/dashboard/allocation-chart';
import Big from 'big.js';
const CHART_COLORS = [ const CHART_COLORS = [
'#3b82f6', '#3b82f6',
@ -13,9 +14,11 @@ const CHART_COLORS = [
]; ];
export default async function DashboardPage() { export default async function DashboardPage() {
const { positions, totalCnyValue, chartData } = await getPortfolioSummary(); const { positions, totalCnyValue, chartData, totalPnlCny } = await getPortfolioSummary();
const formattedTotal = formatAmount(totalCnyValue); const formattedTotal = formatAmount(totalCnyValue);
const formattedPnl = formatAmount(totalPnlCny);
const pnlIsPositive = new Big(totalPnlCny).gte(0);
const displayChartData = chartData.map((item) => ({ const displayChartData = chartData.map((item) => ({
...item, ...item,
@ -42,6 +45,12 @@ export default async function DashboardPage() {
(CNY) (CNY)
</span> </span>
</div> </div>
<div className="mt-2 flex items-center gap-2">
<span className="text-sm text-muted-foreground">:</span>
<span className={`text-lg font-semibold ${pnlIsPositive ? 'text-green-500' : 'text-red-500'}`}>
{pnlIsPositive ? '+' : ''}{formattedPnl}
</span>
</div>
</CardContent> </CardContent>
</Card> </Card>
@ -53,38 +62,56 @@ export default async function DashboardPage() {
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
positions.map((pos) => ( positions.map((pos) => {
<Card key={pos.assetId}> const posPnl = new Big(pos.pnlCny);
<CardHeader> const posPnlPositive = posPnl.gte(0);
<CardTitle className="flex items-center justify-between"> const formattedPosPnl = formatAmount(pos.pnlCny);
<span>{pos.symbol}</span>
<span className="text-sm font-normal text-muted-foreground"> return (
{pos.type} <Card key={pos.assetId}>
</span> <CardHeader>
</CardTitle> <CardTitle className="flex items-center justify-between">
</CardHeader> <span>{pos.symbol}</span>
<CardContent> <span className="text-sm font-normal text-muted-foreground">
<div className="space-y-2"> {pos.type}
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-medium">
{formatQuantity(pos.quantity, pos.type)}
</span> </span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-medium">
{formatQuantity(pos.quantity, pos.type)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-medium">{pos.baseCurrency}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">CNY </span>
<span className="font-medium text-green-600">
¥{formatAmount(pos.cnyValue)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"> (CNY)</span>
<span className="font-medium">
¥{formatAmount(pos.totalCostCny)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className={`font-semibold ${posPnlPositive ? 'text-green-500' : 'text-red-500'}`}>
{posPnlPositive ? '+' : ''}{formattedPosPnl}
</span>
</div>
</div> </div>
<div className="flex justify-between"> </CardContent>
<span className="text-muted-foreground"></span> </Card>
<span className="font-medium">{pos.baseCurrency}</span> );
</div> })
<div className="flex justify-between">
<span className="text-muted-foreground">CNY </span>
<span className="font-medium text-green-600">
¥{formatAmount(pos.cnyValue)}
</span>
</div>
</div>
</CardContent>
</Card>
))
)} )}
</div> </div>

View File

@ -2,6 +2,8 @@
import { db } from '@/db'; import { db } from '@/db';
import { assets, assetTypeEnum } from '@/db/schema'; import { assets, assetTypeEnum } from '@/db/schema';
import { eq } from 'drizzle-orm';
import { revalidatePath } from 'next/cache';
import { z } from 'zod'; import { z } from 'zod';
const createAssetSchema = z.object({ const createAssetSchema = z.object({
@ -34,4 +36,24 @@ export async function createAsset(params: z.infer<typeof createAssetSchema>) {
export async function getAssets() { export async function getAssets() {
return db.select().from(assets); 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;
}
} }

View File

@ -12,6 +12,8 @@ interface Position {
quantity: string; quantity: string;
baseCurrency: string; baseCurrency: string;
cnyValue: string; cnyValue: string;
totalCostCny: string;
pnlCny: string;
} }
interface RawRate { interface RawRate {
@ -38,19 +40,21 @@ function getRate(
return null; return null;
} }
function calculateCnyValue( function calculateCnyValueFromPrice(
quantity: Big, quantity: Big,
latestPrice: string,
baseCurrency: string, baseCurrency: string,
rateMap: Map<string, string>, rateMap: Map<string, string>
cryptoPrices: Map<string, string>
): Big { ): Big {
const price = new Big(latestPrice || '0');
if (baseCurrency === 'CNY') { if (baseCurrency === 'CNY') {
return quantity; return quantity.times(price);
} }
const directRate = getRate(rateMap, baseCurrency, 'CNY'); const directRate = getRate(rateMap, baseCurrency, 'CNY');
if (directRate) { if (directRate) {
return quantity.times(directRate); return quantity.times(price).times(directRate);
} }
const usdToCny = getRate(rateMap, 'USD', 'CNY'); const usdToCny = getRate(rateMap, 'USD', 'CNY');
@ -58,17 +62,9 @@ function calculateCnyValue(
return new Big('0'); 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'); const usdRate = getRate(rateMap, baseCurrency, 'USD');
if (usdRate) { if (usdRate) {
const usdValue = quantity.times(usdRate); return quantity.times(price).times(usdRate).times(usdToCny);
return usdValue.times(usdToCny);
} }
return new Big('0'); return new Big('0');
@ -79,11 +75,13 @@ export async function getPortfolioPositions(): Promise<Position[]> {
.select({ .select({
txType: transactions.txType, txType: transactions.txType,
quantity: transactions.quantity, quantity: transactions.quantity,
price: transactions.price,
exchangeRate: transactions.exchangeRate,
assetId: transactions.assetId, assetId: transactions.assetId,
assetSymbol: assets.symbol, assetSymbol: assets.symbol,
assetType: assets.type, assetType: assets.type,
assetBaseCurrency: assets.baseCurrency, assetBaseCurrency: assets.baseCurrency,
assetPrice: transactions.price, assetLatestPrice: assets.latestPrice,
}) })
.from(transactions) .from(transactions)
.leftJoin(assets, eq(assets.id, transactions.assetId)) .leftJoin(assets, eq(assets.id, transactions.assetId))
@ -96,6 +94,7 @@ export async function getPortfolioPositions(): Promise<Position[]> {
quantity: Big; quantity: Big;
baseCurrency: string; baseCurrency: string;
latestPrice: string; latestPrice: string;
totalCostCny: Big;
}>(); }>();
for (const tx of allTransactions) { for (const tx of allTransactions) {
@ -109,22 +108,28 @@ export async function getPortfolioPositions(): Promise<Position[]> {
type: tx.assetType || 'CASH', type: tx.assetType || 'CASH',
quantity: new Big('0'), quantity: new Big('0'),
baseCurrency: tx.assetBaseCurrency || '', baseCurrency: tx.assetBaseCurrency || '',
latestPrice: tx.assetPrice || '0', latestPrice: tx.assetLatestPrice || '0',
totalCostCny: new Big('0'),
}); });
} }
const holding = holdings.get(tx.assetId)!; const holding = holdings.get(tx.assetId)!;
if (tx.txType === 'BUY' || tx.txType === 'AIRDROP') { if (tx.txType === 'BUY') {
holding.quantity = holding.quantity.plus(tx.quantity); 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') { } else if (tx.txType === 'SELL') {
holding.quantity = holding.quantity.minus(tx.quantity); holding.quantity = holding.quantity.minus(tx.quantity);
} else if (tx.txType === 'AIRDROP') {
holding.quantity = holding.quantity.plus(tx.quantity);
} else if (tx.txType === 'DIVIDEND') { } else if (tx.txType === 'DIVIDEND') {
holding.quantity = holding.quantity.plus(tx.quantity); holding.quantity = holding.quantity.plus(tx.quantity);
} }
if (tx.assetPrice) { if (tx.assetLatestPrice) {
holding.latestPrice = tx.assetPrice; holding.latestPrice = tx.assetLatestPrice;
} }
} }
@ -136,56 +141,25 @@ export async function getPortfolioPositions(): Promise<Position[]> {
const rateMap = buildRateMap(rates); 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[] = []; const result: Position[] = [];
let totalCnyValue = new Big('0'); let totalCnyValue = new Big('0');
let totalPnlCny = new Big('0');
for (const [_, holding] of holdings) { for (const [_, holding] of holdings) {
if (holding.quantity.lte(0)) continue; if (holding.quantity.lte(0)) continue;
let cnyValue: Big; const cnyValue = calculateCnyValueFromPrice(
holding.quantity,
if (holding.type === 'CRYPTO') { holding.latestPrice,
const symbol = holding.symbol.toUpperCase(); holding.baseCurrency,
const btcToUsd = getRate(rateMap, symbol, 'USD'); rateMap
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');
}
}
}
totalCnyValue = totalCnyValue.plus(cnyValue); totalCnyValue = totalCnyValue.plus(cnyValue);
const pnlCny = cnyValue.minus(holding.totalCostCny);
totalPnlCny = totalPnlCny.plus(pnlCny);
result.push({ result.push({
assetId: holding.assetId, assetId: holding.assetId,
symbol: holding.symbol, symbol: holding.symbol,
@ -193,6 +167,8 @@ export async function getPortfolioPositions(): Promise<Position[]> {
quantity: holding.quantity.toString(), quantity: holding.quantity.toString(),
baseCurrency: holding.baseCurrency, baseCurrency: holding.baseCurrency,
cnyValue: cnyValue.toString(), cnyValue: cnyValue.toString(),
totalCostCny: holding.totalCostCny.toString(),
pnlCny: pnlCny.toString(),
}); });
} }
@ -207,6 +183,11 @@ export async function getPortfolioSummary() {
new Big('0') new Big('0')
); );
const totalPnlCny = positions.reduce(
(sum, pos) => sum.plus(new Big(pos.pnlCny)),
new Big('0')
);
const chartData = positions.map((pos, index) => ({ const chartData = positions.map((pos, index) => ({
name: pos.symbol, name: pos.symbol,
value: new Big(pos.cnyValue), value: new Big(pos.cnyValue),
@ -223,6 +204,7 @@ export async function getPortfolioSummary() {
return { return {
positions, positions,
totalCnyValue: totalCnyValue.toString(), totalCnyValue: totalCnyValue.toString(),
totalPnlCny: totalPnlCny.toString(),
chartData, chartData,
}; };
} }

View File

@ -0,0 +1,101 @@
'use client';
import { useState, useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { useRouter } from 'next/navigation';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { RefreshCw } from 'lucide-react';
import { updateAssetPrice } from '@/actions/asset';
const updatePriceSchema = z.object({
newPrice: z.string().min(1, '價格不能為空'),
});
type UpdatePriceForm = z.infer<typeof updatePriceSchema>;
interface UpdatePriceDialogProps {
assetId: string;
currentPrice: string;
}
export function UpdatePriceDialog({ assetId, currentPrice }: UpdatePriceDialogProps) {
const [open, setOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const router = useRouter();
const form = useForm<UpdatePriceForm>({
resolver: zodResolver(updatePriceSchema),
defaultValues: {
newPrice: currentPrice,
},
});
function onSubmit(values: UpdatePriceForm) {
startTransition(async () => {
await updateAssetPrice({ assetId, newPrice: values.newPrice });
setOpen(false);
router.refresh();
});
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<RefreshCw className="h-3 w-3 mr-1" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="newPrice"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="輸入價格數值" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="submit" disabled={isPending}>
{isPending ? '提交中...' : '確認更新'}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@ -20,6 +20,7 @@ export const assets = pgTable("assets", {
symbol: varchar("symbol", { length: 20 }).notNull().unique(), symbol: varchar("symbol", { length: 20 }).notNull().unique(),
type: assetTypeEnum("type").notNull(), type: assetTypeEnum("type").notNull(),
baseCurrency: varchar("base_currency", { length: 10 }).notNull(), baseCurrency: varchar("base_currency", { length: 10 }).notNull(),
latestPrice: numeric("latest_price", { precision: 36, scale: 18 }).default('0').notNull(),
createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }) createdAt: timestamp("created_at", { withTimezone: true, mode: "date" })
.defaultNow(), .defaultNow(),
}); });