import type { PortfolioStats } from '../../hooks/usePortfolioHistory';
import { formatUsd } from '../../utils/format';
import { useT } from '../../i18n/LanguageContext';
interface Props {
stats: PortfolioStats | null;
loading: boolean;
}
export default function PnlDashboard({ stats, loading }: Props) {
const { t } = useT();
if (loading) {
return (
<div className="pnl-empty">{t('pnl.loading')}</div>
);
}
if (!stats || !stats.summary || stats.summary.totalTrades === 0) {
return (
<div className="pnl-empty">
<p>{t('pnl.empty')}</p>
<p className="pnl-empty-sub">{t('pnl.emptySub')}</p>
</div>
);
}
const s = stats.summary;
// Net P&L = (proceeds + current holdings) โ cost. Covers realized, unrealized,
// and resolved-but-unredeemed losers. Falls back to realized for older payloads.
const netPnl = s.totalPnl ?? s.totalRealizedPnl;
const pnlClass = netPnl >= 0 ? 'pnl-pos' : 'pnl-neg';
const fmtSigned = (v: number) => `${v >= 0 ? '+' : 'โ'}${formatUsd(Math.abs(v))}`;
// Unrealized = mark-to-market of open positions minus their cost basis, i.e.
// Net โ Realized. Shown (not gross open value) so realized + unrealized = net.
const unrealizedPnl = s.totalPnl != null ? s.totalPnl - s.totalRealizedPnl : 0;
// Find best/worst day
const daily = stats.dailyPnl || [];
let bestDay = daily.length > 0 ? daily[0] : null;
let worstDay = daily.length > 0 ? daily[0] : null;
for (const d of daily) {
if (d.pnl > (bestDay?.pnl ?? -Infinity)) bestDay = d;
if (d.pnl < (worstDay?.pnl ?? Infinity)) worstDay = d;
}
// Cumulative P&L for the bar chart
const cumulative = daily.reduce<Array<typeof daily[number] & { cumPnl: number }>>((acc, d) => {
const prev = acc.length > 0 ? acc[acc.length - 1].cumPnl : 0;
acc.push({ ...d, cumPnl: prev + d.pnl });
return acc;
}, []);
// Scale for bar chart
const maxAbs = Math.max(...daily.map(d => Math.abs(d.pnl)), 1);
return (
<div className="pnl-dash">
{/* KPI cards */}
<div className="pnl-kpis">
<div className="pnl-kpi">
<span className="pnl-kpi-label">{t('pnl.netPnl')}</span>
<span className={`pnl-kpi-value ${pnlClass}`}>
{netPnl >= 0 ? '+' : 'โ'}{formatUsd(Math.abs(netPnl))}
</span>
<span className="pnl-kpi-sub">
{t('pnl.realized')} {fmtSigned(s.totalRealizedPnl)} ยท {t('pnl.unrealized')} {fmtSigned(unrealizedPnl)}
</span>
</div>
<div className="pnl-kpi">
<span className="pnl-kpi-label">{t('pnl.winRate')}</span>
<span className="pnl-kpi-value">{stats.winRate.toFixed(0)}%</span>
<span className="pnl-kpi-sub">{t('pnl.winLossTrades', { w: stats.winningTrades, l: stats.losingTrades })}</span>
</div>
<div className="pnl-kpi">
<span className="pnl-kpi-label">{t('pnl.totalTrades')}</span>
<span className="pnl-kpi-value">{s.totalTrades}</span>
<span className="pnl-kpi-sub">{t('pnl.markets', { n: s.uniqueMarkets })}</span>
</div>
<div className="pnl-kpi">
<span className="pnl-kpi-label">{t('pnl.profitFactor')}</span>
<span className={`pnl-kpi-value ${stats.profitFactor == null || stats.profitFactor >= 1 ? 'pnl-pos' : 'pnl-neg'}`}>
{stats.profitFactor == null ? 'โ' : stats.profitFactor.toFixed(2)}
</span>
<span className="pnl-kpi-sub">
{t('pnl.avgWin')} +{formatUsd(stats.avgWin)} ยท {t('pnl.avgLoss')} โ{formatUsd(stats.avgLoss)}
</span>
</div>
</div>
{/* Daily P&L bars */}
{daily.length > 0 && (
<div className="pnl-section">
<h3 className="pnl-section-title">{t('pnl.dailyPnl')}</h3>
<div className="pnl-bars">
{daily.slice(-30).map(d => {
// Bars diverge from the 50% midline, so each half only has half the
// track height โ scale to that (รท2) or a full-magnitude bar overflows.
const pct = (Math.abs(d.pnl) / maxAbs) * 50;
const isPos = d.pnl >= 0;
return (
<div key={d.date} className="pnl-bar-col" title={`${d.date}: ${d.pnl >= 0 ? '+' : 'โ'}$${Math.abs(d.pnl).toFixed(2)}`}>
<div className="pnl-bar-track">
{isPos ? (
<div className="pnl-bar pnl-bar-green" style={{ height: `${pct}%`, bottom: '50%' }} />
) : (
<div className="pnl-bar pnl-bar-red" style={{ height: `${pct}%`, top: '50%' }} />
)}
</div>
</div>
);
})}
</div>
<div className="pnl-bars-legend">
<span>{t('pnl.lastDays', { n: Math.min(daily.length, 30) })}</span>
</div>
</div>
)}
{/* Equity curve โ cumulative running total of P&L, drawn as a line so it
reads as a trajectory (not per-day values that look like the daily bars). */}
{cumulative.length > 1 && (() => {
const pts = cumulative.slice(-30);
const vals = pts.map(c => c.cumPnl);
const minV = Math.min(0, ...vals);
const maxV = Math.max(0, ...vals);
const range = (maxV - minV) || 1;
const W = 100, H = 40, n = pts.length;
const x = (i: number) => (n === 1 ? 0 : (i / (n - 1)) * W);
const y = (v: number) => H - ((v - minV) / range) * H;
const zeroY = y(0);
const endVal = vals[vals.length - 1];
const up = endVal >= 0;
const line = pts.map((c, i) => `${x(i).toFixed(2)},${y(c.cumPnl).toFixed(2)}`).join(' ');
const area = `0,${zeroY.toFixed(2)} ${line} ${W},${zeroY.toFixed(2)}`;
return (
<div className="pnl-section">
<h3 className="pnl-section-title">{t('pnl.equityCurve')}</h3>
<div className="pnl-spark">
<svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" className="pnl-spark-svg">
<polygon points={area} className={up ? 'pnl-spark-area-pos' : 'pnl-spark-area-neg'} />
<line x1="0" y1={zeroY} x2={W} y2={zeroY} className="pnl-spark-zero" vectorEffect="non-scaling-stroke" />
<polyline points={line} className={up ? 'pnl-spark-line-pos' : 'pnl-spark-line-neg'} vectorEffect="non-scaling-stroke" />
</svg>
</div>
<div className="pnl-spark-foot">
<span className="pnl-eq-date">{pts[0].date.slice(5)}</span>
<span className={up ? 'pnl-pos' : 'pnl-neg'}>
{t('pnl.cumulative')} {endVal >= 0 ? '+' : 'โ'}{formatUsd(Math.abs(endVal))}
</span>
<span className="pnl-eq-date">{pts[n - 1].date.slice(5)}</span>
</div>
</div>
);
})()}
{/* Best / Worst day */}
{(bestDay || worstDay) && (
<div className="pnl-section">
<h3 className="pnl-section-title">{t('pnl.highlights')}</h3>
<div className="pnl-highlights">
{bestDay && bestDay.pnl > 0 && (
<div className="pnl-highlight">
<span className="pnl-hl-label">{t('pnl.bestDay')}</span>
<span className="pnl-pos">{bestDay.date} โ +{formatUsd(bestDay.pnl)}</span>
</div>
)}
{worstDay && worstDay.pnl < 0 && (
<div className="pnl-highlight">
<span className="pnl-hl-label">{t('pnl.worstDay')}</span>
<span className="pnl-neg">{worstDay.date} โ โ{formatUsd(Math.abs(worstDay.pnl))}</span>
</div>
)}
<div className="pnl-highlight">
<span className="pnl-hl-label">{t('pnl.volumeTraded')}</span>
<span>{formatUsd(s.totalBought + s.totalSold)}</span>
</div>
</div>
</div>
)}
</div>
);
}
๐ Git History
6c47fa4chore: local Polikopi project home + Phase 1 redesign artifacts12 days ago
Show last diff
Loading...