Compare commits

..

No commits in common. "556f705f754b1e443b0621f3259060f413dd9edf" and "e093b9415750a4b293d669cdc8769f6649d6358d" have entirely different histories.

4 changed files with 47 additions and 151 deletions

View File

@ -37,21 +37,9 @@
## 修复记录
- 修复了全局时区偏移问题,并解决了日期控件手动输入导致的崩溃 Bug。在 `src/libs/utils.ts` 中新增 `nowInShanghai()`、`formatDateForDatetimeLocal()` 和 `parseDateTimeLocalToUTC_v2()` 函数,强制使用 `Asia/Shanghai` (UTC+8) 时区。所有 `new Date()` 调用(包括 Server Actions均已对齐至东八区。日期选择器增加字符串格式校验与解析逻辑避免 `Invalid time value` 错误。
## 资产分布图表按市场维度升级 (Task 32)
- 优化资产分布图表,升级为按市场维度聚合展示,并增强了 Tooltip 的颜色指代与明细交互。
- 在 `portfolio.ts` 中新增 `getMarketFromExchange()` 函数,将资产按交易所归类为 A股 (SSE/SZSE)、港股 (HKEX)、美股 (US)、虚拟币 (CRYPTO)。
- 新增 `marketAllocation` 聚合数据,按市场维度汇总 `totalCnyValue` 并计算占比,自动过滤已清仓资产。
- 升级 `AllocationChart` 组件数据源改为市场聚合数据为各市场设定固定品牌色A股红、港股黄、美股蓝、虚拟币绿并自定义 Tooltip 渲染内容,悬停时清晰展示 `[市场名称] [对应颜色块] [CNY 金额] [占比%]`
## 盈亏引擎重构 (Task 31)
- 重构盈亏计算引擎支持已实现盈亏统计交易按时间正序处理SELL 时基于当时平均成本计算该笔卖出的利润并累加至 `realizedPnlCny`
- 引入摊薄成本与平均成本双重指标:`avgCost = totalBuyCost / totalBuyQuantity`(平均成本);`dilutedCost = (totalBuyCost - realizedPnlCny) / currentQuantity`(摊薄成本,已实现盈亏会摊低持仓成本)。
- 新增持仓天数统计:`holdingDays = today - 第一次 BUY 的日期`(基于上海时区)。
- Dashboard 首页总览区分展示『持仓盈亏 (Unrealized P&L)』和『总盈亏 (Total P&L)』。
- 持仓卡片新增『平均成本』、『摊薄成本』和『持仓天数』展示。
## 分紅業務邏輯與成本算法修復 (Task 33)
- 重構了分紅的會計處理邏輯將其正確計入已實現盈虧DIVIDEND 不再增加持倉數量,而是按 `quantity * price * exchangeRate` 計算分紅金額並累加至 `realizedPnlCny`
- 新增 `totalDividendCny` 字段追蹤累計分紅金額。
- 修正攤薄成本算法:`dilutedCost = (totalBuyCostCny - realizedPnlCny - totalDividendCny) / currentQuantity`,確保極端情況下攤薄成本為負數時精確返回負數,絕不兜底為 0。
- 平均成本 `avgCost = totalBuyCostCny / totalBuyQuantity` 保持不變,僅在遍歷結束後計算。

View File

@ -5,8 +5,17 @@ import AllocationChart from '@/components/dashboard/allocation-chart';
import { SyncButton } from '@/components/assets/sync-button';
import Big from 'big.js';
const CHART_COLORS = [
'#3b82f6',
'#8b5cf6',
'#10b981',
'#f59e0b',
'#ef4444',
'#06b6d4',
];
export default async function DashboardPage() {
const { positions, totalCnyValue, chartData, totalPnlCny, unrealizedPnlCny, marketAllocation } = await getPortfolioSummary();
const { positions, totalCnyValue, chartData, totalPnlCny, unrealizedPnlCny } = await getPortfolioSummary();
const formattedTotal = formatAmount(totalCnyValue);
const formattedTotalPnl = formatAmount(totalPnlCny);
@ -14,6 +23,11 @@ export default async function DashboardPage() {
const totalPnlIsPositive = new Big(totalPnlCny).gte(0);
const unrealizedIsPositive = new Big(unrealizedPnlCny).gte(0);
const displayChartData = chartData.map((item) => ({
...item,
value: Number(item.value),
}));
return (
<div className="space-y-6">
<div>
@ -154,7 +168,7 @@ export default async function DashboardPage() {
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<AllocationChart data={marketAllocation} />
<AllocationChart data={displayChartData} />
</CardContent>
</Card>
</div>

View File

@ -24,8 +24,6 @@ interface Position {
avgCost: string;
dilutedCost: string;
holdingDays: number;
exchange: string;
totalDividendCny: string;
}
interface RawRate {
@ -82,30 +80,6 @@ function calculateCnyValueFromPrice(
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' });
@ -128,7 +102,6 @@ export async function getPortfolioPositions(): Promise<Position[]> {
assetType: assets.type,
assetBaseCurrency: assets.baseCurrency,
assetLatestPrice: assets.latestPrice,
assetExchange: assets.exchange,
executedAt: transactions.executedAt,
})
.from(transactions)
@ -151,7 +124,6 @@ export async function getPortfolioPositions(): Promise<Position[]> {
quantity: Big;
baseCurrency: string;
latestPrice: string;
exchange: string;
// 累计买入指标
totalBuyCostCny: Big;
totalBuyCostNative: Big;
@ -175,12 +147,10 @@ export async function getPortfolioPositions(): Promise<Position[]> {
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'),
totalDividendCny: new Big('0'),
firstBuyDate: null,
});
}
@ -230,10 +200,7 @@ export async function getPortfolioPositions(): Promise<Position[]> {
} 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.realizedPnlCny = holding.realizedPnlCny.plus(dividendCny);
holding.totalDividendCny = holding.totalDividendCny.plus(dividendCny);
holding.quantity = holding.quantity.plus(new Big(tx.quantity));
}
if (tx.assetLatestPrice) {
@ -268,10 +235,10 @@ export async function getPortfolioPositions(): Promise<Position[]> {
avgCost = holding.totalBuyCostCny.div(holding.totalBuyQuantity);
}
// 摊薄成本 = (总买入成本 - 已实现盈亏 - 总分红) / 当前持仓数量
// 摊薄成本 = (总买入成本 - 已实现盈亏) / 当前持仓数量
let dilutedCost = new Big('0');
if (holding.quantity.gt(0)) {
dilutedCost = holding.totalBuyCostCny.minus(holding.realizedPnlCny).minus(holding.totalDividendCny).div(holding.quantity);
dilutedCost = holding.totalBuyCostCny.minus(holding.realizedPnlCny).div(holding.quantity);
}
// 持仓天数
@ -299,8 +266,6 @@ export async function getPortfolioPositions(): Promise<Position[]> {
avgCost: avgCost.toString(),
dilutedCost: dilutedCost.toString(),
holdingDays,
exchange: holding.exchange,
totalDividendCny: holding.totalDividendCny.toString(),
});
}
@ -342,52 +307,11 @@ export async function getPortfolioSummary() {
][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.cnyValue));
} else {
marketMap.set(market, {
market,
totalCnyValue: new Big(pos.cnyValue),
});
}
}
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: unrealizedPnlCny.toString(),
chartData,
marketAllocation,
};
}

View File

@ -1,63 +1,19 @@
'use client';
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer, TooltipProps } from 'recharts';
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts';
interface AllocationChartProps {
data: {
market: string;
name: string;
totalCnyValue: number;
percentage: number;
fill: string;
}[];
data: { name: string; value: number; fill: string }[];
}
interface CustomTooltipProps extends TooltipProps<number, string> {
active?: boolean;
payload?: Array<{ payload: { market: string; totalCnyValue: number; percentage: number; fill: string } }>;
}
function CustomTooltip({ active, payload }: CustomTooltipProps) {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<div style={{
backgroundColor: 'hsl(var(--card))',
borderColor: 'hsl(var(--border))',
borderRadius: '8px',
color: 'hsl(var(--foreground))',
fontSize: '14px',
padding: '10px 14px',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
<span style={{
display: 'inline-block',
width: '12px',
height: '12px',
borderRadius: '3px',
backgroundColor: data.fill,
flexShrink: 0,
}} />
<span style={{ fontWeight: '600', fontSize: '14px' }}>{data.market}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '20px', marginTop: '4px' }}>
<span style={{ color: 'hsl(var(--muted-foreground))' }}></span>
<span style={{ fontWeight: '600' }}>
¥{data.totalCnyValue.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '20px', marginTop: '2px' }}>
<span style={{ color: 'hsl(var(--muted-foreground))' }}></span>
<span style={{ fontWeight: '600' }}>
{data.percentage.toFixed(1)}%
</span>
</div>
</div>
);
}
return null;
}
const CHART_COLORS = [
'#3b82f6',
'#8b5cf6',
'#10b981',
'#f59e0b',
'#ef4444',
'#06b6d4',
];
export default function AllocationChart({ data }: AllocationChartProps) {
if (!data || data.length === 0) {
@ -79,16 +35,30 @@ export default function AllocationChart({ data }: AllocationChartProps) {
innerRadius={60}
outerRadius={110}
paddingAngle={3}
dataKey="totalCnyValue"
dataKey="value"
>
{data.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={entry.fill}
fill={entry.fill || CHART_COLORS[index % CHART_COLORS.length]}
/>
))}
</Pie>
<Tooltip content={<CustomTooltip />} />
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
borderColor: 'hsl(var(--border))',
borderRadius: '8px',
color: 'hsl(var(--foreground))',
fontSize: '14px',
}}
formatter={(value) => {
const num = Number(value);
return [
`¥${num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`,
];
}}
/>
</PieChart>
</ResponsiveContainer>
</div>