โ† Back
โ˜†
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...