feat(web/trades): P&L chart with 1D/7D/30D/ALL period selector + green/red zero-crossing color

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
romysaputrasihananda
2026-06-11 16:10:05 +07:00
co-authored by Claude Sonnet 4.6
parent 179b0ee055
commit 3cec0feefb
2 changed files with 138 additions and 82 deletions
+3 -22
View File
@@ -19,11 +19,8 @@ export default async function TradesPage() {
]); ]);
} catch { error = true; } } catch { error = true; }
const aresDeals = deals.filter(d => d.magic === MAGIC && d.type !== 2); const aresDeals = deals.filter(d => d.magic === MAGIC && d.type !== 2);
const startBalance = deals.filter(d => d.type === 2).reduce((s, d) => s + d.profit, 600); const closed = aresDeals.filter(d => d.entry === 1);
const equityData = buildEquityCurve(deals, startBalance);
const closed = aresDeals.filter(d => d.entry === 1);
const wins = closed.filter(d => d.profit > 0); const wins = closed.filter(d => d.profit > 0);
const losses = closed.filter(d => d.profit < 0); const losses = closed.filter(d => d.profit < 0);
const netPnl = closed.reduce((s, d) => s + d.profit + d.swap + d.commission, 0); const netPnl = closed.reduce((s, d) => s + d.profit + d.swap + d.commission, 0);
@@ -76,7 +73,7 @@ export default async function TradesPage() {
{/* Equity curve */} {/* Equity curve */}
<div className="card mb-10"> <div className="card mb-10">
<p className="eyebrow mb-6">Equity Curve</p> <p className="eyebrow mb-6">Equity Curve</p>
<EquityChart data={equityData} currency={currency} /> <EquityChart deals={closed} currency={currency} />
</div> </div>
{/* Trade table */} {/* Trade table */}
@@ -124,19 +121,3 @@ export default async function TradesPage() {
); );
} }
function buildEquityCurve(deals: Deal[], start: number) {
const sorted = deals
.filter(d => d.magic === MAGIC && d.entry === 1)
.sort((a, b) => +new Date(a.time) - +new Date(b.time));
let eq = start;
const pts = [{ date: "Start", equity: eq }];
for (const d of sorted) {
eq += d.profit + d.swap + d.commission;
pts.push({
date: new Date(d.time).toLocaleDateString("en-US", { month: "short", day: "numeric" }),
equity: parseFloat(eq.toFixed(2)),
});
}
return pts;
}
+135 -60
View File
@@ -1,77 +1,152 @@
"use client"; "use client";
import { useState } from "react";
import { import {
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer, ReferenceLine, ResponsiveContainer,
} from "recharts"; } from "recharts";
import { formatCurrency } from "@/lib/format"; import { formatCurrency } from "@/lib/format";
interface DataPoint { date: string; equity: number } interface Deal {
time: string;
profit: number;
swap: number;
commission: number;
}
export default function EquityChart({ data, currency = "USD" }: { type Period = "1D" | "7D" | "30D" | "ALL";
data: DataPoint[]; const PERIODS: Period[] = ["1D", "7D", "30D", "ALL"];
function periodStart(p: Period): Date {
const now = new Date();
if (p === "1D") return new Date(now.getTime() - 86_400_000);
if (p === "7D") return new Date(now.getTime() - 7 * 86_400_000);
if (p === "30D") return new Date(now.getTime() - 30 * 86_400_000);
return new Date("2000-01-01");
}
function fmtTick(iso: string, p: Period) {
const d = new Date(iso);
if (p === "1D") return d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false });
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
export default function EquityChart({ deals, currency = "USD" }: {
deals: Deal[];
currency?: string; currency?: string;
}) { }) {
if (data.length < 2) { const [period, setPeriod] = useState<Period>("ALL");
const since = periodStart(period);
const sorted = deals
.filter(d => new Date(d.time) >= since)
.sort((a, b) => +new Date(a.time) - +new Date(b.time));
let cum = 0;
const pts = [
{ label: "Start", pnl: 0 },
...sorted.map(d => {
cum += d.profit + d.swap + d.commission;
return { label: fmtTick(d.time, period), pnl: parseFloat(cum.toFixed(2)) };
}),
];
const netPnl = pts[pts.length - 1]?.pnl ?? 0;
const isPos = netPnl >= 0;
const minPnl = Math.min(0, ...pts.map(d => d.pnl));
const maxPnl = Math.max(0, ...pts.map(d => d.pnl));
const range = maxPnl - minPnl || 1;
const zeroPct = `${((maxPnl / range) * 100).toFixed(1)}%`;
if (pts.length < 2) {
return ( return (
<div className="flex items-center justify-center h-[260px] text-ink-sub text-sm"> <div className="flex items-center justify-center h-[240px] text-ink-sub text-sm">
Not enough data to render chart. No trades in this period.
</div> </div>
); );
} }
const first = data[0].equity;
const last = data[data.length - 1].equity;
const isPos = last >= first;
const color = isPos ? "#27a644" : "#e5484d";
const min = Math.min(...data.map(d => d.equity));
const max = Math.max(...data.map(d => d.equity));
return ( return (
<ResponsiveContainer width="100%" height={260}> <div>
<AreaChart data={data} margin={{ top: 4, right: 4, left: 0, bottom: 0 }}> {/* Period tabs + net P&L */}
<defs> <div className="flex items-center justify-between mb-5">
<linearGradient id="eq" x1="0" y1="0" x2="0" y2="1"> <div className="flex gap-1">
<stop offset="0%" stopColor={color} stopOpacity={0.12} /> {PERIODS.map(p => (
<stop offset="100%" stopColor={color} stopOpacity={0} /> <button
</linearGradient> key={p}
</defs> onClick={() => setPeriod(p)}
<CartesianGrid strokeDasharray="4 4" stroke="#23252a" vertical={false} /> style={period === p
<XAxis ? { background: "var(--c-s3)", border: "1px solid var(--c-hl-strong)", color: "var(--c-ink)" }
dataKey="date" : { color: "var(--c-ink-sub)", border: "1px solid transparent" }
tick={{ fontSize: 11, fill: "#62666d", fontFamily: "JetBrains Mono, monospace" }} }
tickLine={false} axisLine={false} className="px-3 py-1 rounded-md text-xs font-mono font-medium transition-colors hover:text-ink"
interval="preserveStartEnd" >
/> {p}
<YAxis </button>
domain={[min * 0.997, max * 1.003]} ))}
tick={{ fontSize: 11, fill: "#62666d", fontFamily: "JetBrains Mono, monospace" }} </div>
tickLine={false} axisLine={false} <span className={`font-mono text-sm font-semibold ${isPos ? "text-bull" : "text-bear"}`}>
tickFormatter={v => `$${v.toFixed(0)}`} {isPos ? "+" : ""}{formatCurrency(netPnl, currency)}
width={60} </span>
/> </div>
<Tooltip
formatter={(v: number) => [formatCurrency(v, currency), "Equity"]} <ResponsiveContainer width="100%" height={240}>
contentStyle={{ <AreaChart data={pts} margin={{ top: 4, right: 4, left: 0, bottom: 0 }}>
background: "#141516", <defs>
border: "1px solid #34343a", <linearGradient id="pnlFill" x1="0" y1="0" x2="0" y2="1">
borderRadius: "8px", <stop offset="0%" stopColor="#27a644" stopOpacity={0.18} />
fontSize: "12px", <stop offset={zeroPct} stopColor="#27a644" stopOpacity={0.04} />
fontFamily: "'JetBrains Mono', monospace", <stop offset={zeroPct} stopColor="#e5484d" stopOpacity={0.04} />
color: "#f7f8f8", <stop offset="100%" stopColor="#e5484d" stopOpacity={0.18} />
}} </linearGradient>
labelStyle={{ color: "#8a8f98", marginBottom: 4 }} <linearGradient id="pnlLine" x1="0" y1="0" x2="0" y2="1">
cursor={{ stroke: "#34343a", strokeWidth: 1 }} <stop offset="0%" stopColor="#27a644" />
/> <stop offset={zeroPct} stopColor="#27a644" />
<Area <stop offset={zeroPct} stopColor="#e5484d" />
type="monotone" <stop offset="100%" stopColor="#e5484d" />
dataKey="equity" </linearGradient>
stroke={color} </defs>
strokeWidth={1.5}
fill="url(#eq)" <CartesianGrid strokeDasharray="4 4" stroke="#23252a" vertical={false} />
dot={false} <ReferenceLine y={0} stroke="#34343a" strokeDasharray="3 3" />
activeDot={{ r: 3, strokeWidth: 0, fill: color }}
/> <XAxis
</AreaChart> dataKey="label"
</ResponsiveContainer> tick={{ fontSize: 11, fill: "#62666d", fontFamily: "JetBrains Mono, monospace" }}
tickLine={false} axisLine={false}
interval="preserveStartEnd"
/>
<YAxis
domain={[minPnl - Math.abs(minPnl) * 0.08 - 1, maxPnl + maxPnl * 0.08 + 1]}
tick={{ fontSize: 11, fill: "#62666d", fontFamily: "JetBrains Mono, monospace" }}
tickLine={false} axisLine={false}
tickFormatter={v => `$${v.toFixed(0)}`}
width={60}
/>
<Tooltip
formatter={(v: number) => [`${v >= 0 ? "+" : ""}${formatCurrency(v, currency)}`, "P&L"]}
contentStyle={{
background: "#141516",
border: "1px solid #34343a",
borderRadius: "8px",
fontSize: "12px",
fontFamily: "'JetBrains Mono', monospace",
color: "#f7f8f8",
}}
labelStyle={{ color: "#8a8f98", marginBottom: 4 }}
cursor={{ stroke: "#34343a", strokeWidth: 1 }}
/>
<Area
type="monotone"
dataKey="pnl"
stroke="url(#pnlLine)"
strokeWidth={1.5}
fill="url(#pnlFill)"
dot={false}
activeDot={{ r: 3, strokeWidth: 0, fill: "#5e6ad2" }}
/>
</AreaChart>
</ResponsiveContainer>
</div>
); );
} }