mirror of
https://github.com/RomySaputraSihananda/ares.git
synced 2026-08-16 12:18:07 +00:00
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:
co-authored by
Claude Sonnet 4.6
parent
179b0ee055
commit
3cec0feefb
+3
-22
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user