mirror of
https://github.com/RomySaputraSihananda/ares.git
synced 2026-08-17 20:58:08 +00:00
feat(web): add Next.js portfolio with live MT5 dashboard
- Linear design system (dark canvas, lavender accent) - Live account stats, open positions, pending orders (30s polling) - Trade history with equity curve chart - Backtest results table - Proxy API routes — MT5_BASE_URL server-side only, never exposed to browser - Deploy on Vercel: set MT5_BASE_URL env var Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
4fdc3da4ed
commit
9716fc0955
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
import {
|
||||
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
|
||||
interface DataPoint { date: string; equity: number }
|
||||
|
||||
export default function EquityChart({ data, currency = "USD" }: {
|
||||
data: DataPoint[];
|
||||
currency?: string;
|
||||
}) {
|
||||
if (data.length < 2) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[260px] text-ink-sub text-sm">
|
||||
Not enough data to render chart.
|
||||
</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 (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<AreaChart data={data} margin={{ top: 4, right: 4, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="eq" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={color} stopOpacity={0.12} />
|
||||
<stop offset="100%" stopColor={color} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="4 4" stroke="#23252a" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fontSize: 11, fill: "#62666d", fontFamily: "JetBrains Mono, monospace" }}
|
||||
tickLine={false} axisLine={false}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
domain={[min * 0.997, max * 1.003]}
|
||||
tick={{ fontSize: 11, fill: "#62666d", fontFamily: "JetBrains Mono, monospace" }}
|
||||
tickLine={false} axisLine={false}
|
||||
tickFormatter={v => `$${v.toFixed(0)}`}
|
||||
width={60}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(v: number) => [formatCurrency(v, currency), "Equity"]}
|
||||
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="equity"
|
||||
stroke={color}
|
||||
strokeWidth={1.5}
|
||||
fill="url(#eq)"
|
||||
dot={false}
|
||||
activeDot={{ r: 3, strokeWidth: 0, fill: color }}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { AccountInfo, Position, PendingOrder } from "@/lib/mt5";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
import PositionsTable from "./PositionsTable";
|
||||
import PendingOrdersTable from "./PendingOrdersTable";
|
||||
|
||||
const MAGIC = 19730;
|
||||
const POLL_MS = 30_000;
|
||||
|
||||
interface State {
|
||||
account: AccountInfo | null;
|
||||
positions: Position[];
|
||||
orders: PendingOrder[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
ts: Date | null;
|
||||
}
|
||||
|
||||
export default function LiveDashboard() {
|
||||
const [state, setState] = useState<State>({
|
||||
account: null, positions: [], orders: [], loading: true, error: null, ts: null,
|
||||
});
|
||||
|
||||
async function fetchData() {
|
||||
try {
|
||||
const [aRes, pRes, oRes] = await Promise.all([
|
||||
fetch("/api/account"), fetch("/api/positions"), fetch("/api/orders"),
|
||||
]);
|
||||
if (!aRes.ok) throw new Error("bridge error");
|
||||
const [account, positions, orders] = await Promise.all([
|
||||
aRes.json() as Promise<AccountInfo>,
|
||||
pRes.json() as Promise<Position[]>,
|
||||
oRes.json() as Promise<PendingOrder[]>,
|
||||
]);
|
||||
setState({
|
||||
account, positions,
|
||||
orders: Array.isArray(orders) ? orders : [],
|
||||
loading: false, error: null, ts: new Date(),
|
||||
});
|
||||
} catch (e) {
|
||||
setState(prev => ({ ...prev, loading: false, error: String(e) }));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const id = setInterval(fetchData, POLL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const aresPos = state.positions.filter(p => p.magic === MAGIC);
|
||||
const aresOrd = state.orders.filter(o => o.magic === MAGIC);
|
||||
const openPnl = aresPos.reduce((s, p) => s + p.profit, 0);
|
||||
const currency = state.account?.currency ?? "USD";
|
||||
|
||||
if (state.loading) return <LoadingSkeleton />;
|
||||
if (state.error) {
|
||||
return (
|
||||
<div className="card mb-16 text-center text-ink-sub text-sm py-10">
|
||||
Unable to connect to MT5 bridge.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const { account } = state;
|
||||
if (!account) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Account stats */}
|
||||
<section className="mb-10">
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<p className="eyebrow">Account Overview</p>
|
||||
{state.ts && (
|
||||
<span className="text-xs text-ink-ter font-mono">
|
||||
{state.ts.toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<StatTile label="Balance" value={formatCurrency(account.balance, currency)} />
|
||||
<StatTile
|
||||
label="Equity"
|
||||
value={formatCurrency(account.equity, currency)}
|
||||
diff={account.equity !== account.balance
|
||||
? { val: account.equity - account.balance, currency }
|
||||
: undefined}
|
||||
/>
|
||||
<StatTile
|
||||
label="Open P&L"
|
||||
value={formatCurrency(openPnl, currency)}
|
||||
colored={openPnl !== 0 ? openPnl > 0 : undefined}
|
||||
/>
|
||||
<StatTile label="Free Margin" value={formatCurrency(account.margin_free, currency)} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Open positions */}
|
||||
<section className="mb-10">
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<p className="eyebrow">Open Positions</p>
|
||||
{aresPos.length > 0 && (
|
||||
<span className="status-pill status-bull">{aresPos.length}</span>
|
||||
)}
|
||||
</div>
|
||||
{aresPos.length === 0 ? (
|
||||
<Empty>No open positions right now.</Empty>
|
||||
) : (
|
||||
<div className="rounded-lg border border-hl overflow-hidden">
|
||||
<PositionsTable positions={aresPos} currency={currency} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Pending orders */}
|
||||
<section className="mb-16">
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<p className="eyebrow">Pending Orders</p>
|
||||
{aresOrd.length > 0 && (
|
||||
<span className="status-pill status-muted">{aresOrd.length}</span>
|
||||
)}
|
||||
</div>
|
||||
{aresOrd.length === 0 ? (
|
||||
<Empty>No pending orders right now.</Empty>
|
||||
) : (
|
||||
<div className="rounded-lg border border-hl overflow-hidden">
|
||||
<PendingOrdersTable orders={aresOrd} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sub-components ─────────────────────────────────────────────────────────────
|
||||
|
||||
function StatTile({ label, value, diff, colored }: {
|
||||
label: string;
|
||||
value: string;
|
||||
diff?: { val: number; currency: string };
|
||||
colored?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="stat-tile">
|
||||
<p className="text-xs text-ink-sub mb-2">{label}</p>
|
||||
<p className={`font-mono text-xl font-semibold ${
|
||||
colored === true ? "text-bull" : colored === false ? "text-bear" : "text-ink"
|
||||
}`}>{value}</p>
|
||||
{diff && (
|
||||
<p className={`text-xs font-mono mt-1 ${diff.val >= 0 ? "text-bull" : "text-bear"}`}>
|
||||
{diff.val >= 0 ? "+" : ""}{formatCurrency(diff.val, diff.currency)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="card text-center text-ink-sub text-sm py-10">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="mb-16 space-y-10 animate-pulse">
|
||||
<div>
|
||||
<div className="h-3 w-32 bg-s2 rounded mb-5" />
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="stat-tile h-20 bg-s2" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="h-3 w-32 bg-s2 rounded mb-5" />
|
||||
<div className="card h-24 bg-s2" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import clsx from "clsx";
|
||||
|
||||
const links = [
|
||||
{ href: "/", label: "Dashboard" },
|
||||
{ href: "/trades", label: "Trades" },
|
||||
{ href: "/backtest", label: "Backtest" },
|
||||
];
|
||||
|
||||
export default function Nav() {
|
||||
const path = usePathname();
|
||||
return (
|
||||
<nav className="sticky top-0 z-50" style={{ backgroundColor: 'var(--c-canvas)', borderBottom: '1px solid var(--c-hl)' }}>
|
||||
<div className="max-w-5xl mx-auto px-4 sm:px-6 h-14 flex items-center gap-6">
|
||||
{/* wordmark */}
|
||||
<Link href="/" className="font-mono text-xs tracking-widest text-ink-sub uppercase hover:text-ink transition-colors">
|
||||
ARES
|
||||
</Link>
|
||||
|
||||
{/* nav links */}
|
||||
<div className="flex gap-1">
|
||||
{links.map(({ href, label }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={clsx(
|
||||
"px-3 py-1.5 rounded-md text-sm transition-colors",
|
||||
path === href
|
||||
? "bg-s2 text-ink"
|
||||
: "text-ink-sub hover:text-ink hover:bg-s1"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* live indicator */}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-bull pulse-dot" />
|
||||
<span className="text-xs text-ink-sub font-medium tracking-eyebrow uppercase">Live</span>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { PendingOrder } from "@/lib/mt5";
|
||||
|
||||
const ORDER_LABEL: Record<number, { label: string; bull: boolean }> = {
|
||||
2: { label: "BUY LIMIT", bull: true },
|
||||
3: { label: "SELL LIMIT", bull: false },
|
||||
4: { label: "BUY STOP", bull: true },
|
||||
5: { label: "SELL STOP", bull: false },
|
||||
};
|
||||
|
||||
export default function PendingOrdersTable({ orders }: { orders: PendingOrder[] }) {
|
||||
return (
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{["Symbol", "Type", "Vol", "Entry", "Current", "SL", "TP", "Comment"].map(h => (
|
||||
<th key={h}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{orders.map((o) => {
|
||||
const kind = ORDER_LABEL[o.type] ?? { label: `TYPE ${o.type}`, bull: true };
|
||||
return (
|
||||
<tr key={o.ticket}>
|
||||
<td className="font-mono font-medium text-ink">{o.symbol}</td>
|
||||
<td>
|
||||
<span className={`status-pill ${kind.bull ? "status-bull" : "status-bear"}`}>
|
||||
{kind.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="font-mono text-ink-md">{o.volume_current}</td>
|
||||
<td className="font-mono text-ink-md">{o.price_open.toFixed(2)}</td>
|
||||
<td className="font-mono text-ink-md">{o.price_current.toFixed(2)}</td>
|
||||
<td className="font-mono text-ink-sub">{o.sl > 0 ? o.sl.toFixed(2) : "—"}</td>
|
||||
<td className="font-mono text-ink-sub">{o.tp > 0 ? o.tp.toFixed(2) : "—"}</td>
|
||||
<td className="text-xs text-ink-ter">{o.comment || "—"}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Position } from "@/lib/mt5";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
|
||||
export default function PositionsTable({ positions, currency = "USD" }: {
|
||||
positions: Position[];
|
||||
currency?: string;
|
||||
}) {
|
||||
return (
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{["Symbol", "Side", "Volume", "Entry", "Current", "SL", "TP", "P&L"].map(h => (
|
||||
<th key={h}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{positions.map((p) => {
|
||||
const isBuy = p.type === 0;
|
||||
return (
|
||||
<tr key={p.ticket}>
|
||||
<td className="font-mono font-medium text-ink">{p.symbol}</td>
|
||||
<td>
|
||||
<span className={`status-pill ${isBuy ? "status-bull" : "status-bear"}`}>
|
||||
{isBuy ? "BUY" : "SELL"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="font-mono text-ink-md">{p.volume}</td>
|
||||
<td className="font-mono text-ink-md">{p.price_open.toFixed(2)}</td>
|
||||
<td className="font-mono text-ink-md">{p.price_current.toFixed(2)}</td>
|
||||
<td className="font-mono text-ink-sub">{p.sl > 0 ? p.sl.toFixed(2) : "—"}</td>
|
||||
<td className="font-mono text-ink-sub">{p.tp > 0 ? p.tp.toFixed(2) : "—"}</td>
|
||||
<td className={`font-mono font-semibold ${p.profit >= 0 ? "text-bull" : "text-bear"}`}>
|
||||
{formatCurrency(p.profit, currency)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user