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:
romysaputrasihananda
2026-06-11 14:51:55 +07:00
co-authored by Claude Sonnet 4.6
parent 4fdc3da4ed
commit 9716fc0955
26 changed files with 3854 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import { getAccount } from "@/lib/mt5";
export const dynamic = "force-dynamic";
export async function GET() {
try {
const data = await getAccount();
return NextResponse.json(data);
} catch (e) {
return NextResponse.json({ error: String(e) }, { status: 502 });
}
}
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
import { NextRequest } from "next/server";
import { getDeals } from "@/lib/mt5";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
const { searchParams } = req.nextUrl;
const dateFrom = searchParams.get("date_from") ?? "2025-01-01T00:00:00";
const dateTo = searchParams.get("date_to") ?? new Date().toISOString().slice(0, 19);
const symbol = searchParams.get("symbol") ?? undefined;
try {
const data = await getDeals(dateFrom, dateTo, symbol);
return NextResponse.json(data);
} catch (e) {
return NextResponse.json({ error: String(e) }, { status: 502 });
}
}
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { getOrders } from "@/lib/mt5";
import { NextRequest } from "next/server";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
const symbol = req.nextUrl.searchParams.get("symbol") ?? undefined;
try {
const data = await getOrders(symbol);
return NextResponse.json(data);
} catch (e) {
return NextResponse.json({ error: String(e) }, { status: 502 });
}
}
+13
View File
@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import { getPositions } from "@/lib/mt5";
export const dynamic = "force-dynamic";
export async function GET() {
try {
const data = await getPositions();
return NextResponse.json(data);
} catch (e) {
return NextResponse.json({ error: String(e) }, { status: 502 });
}
}
+155
View File
@@ -0,0 +1,155 @@
import Link from "next/link";
const results = [
{ period: "1 Month", tf: "M5", risk: "1%", trades: 159, wr: 55.3, pf: 1.42, ret: 43.2, dd: -11.5, highlight: true },
{ period: "1 Month", tf: "M5", risk: "5%", trades: 159, wr: 55.3, pf: 1.26, ret: 390, dd: -156, highlight: false },
{ period: "1 Week", tf: "M5", risk: "1%", trades: 34, wr: 47.1, pf: 0.94, ret: -6.2, dd: -8.1, highlight: false },
{ period: "Yesterday", tf: "M1", risk: "1%", trades: 36, wr: 47.2, pf: 1.05, ret: 6.6, dd: -51, highlight: false },
{ period: "Yesterday", tf: "M5", risk: "1%", trades: 3, wr: 66.7, pf: null, ret: null, dd: null, highlight: false, note: "Too few trades" },
];
const params = [
["Timeframe", "M5"],
["Symbol", "XAUUSDm"],
["EMA Period", "20"],
["Min FVG Pips", "3"],
["Min SL Pips", "5"],
["Min RR", "1.5×"],
["FVG Expiry", "10 candles"],
["Body PCT Min", "60%"],
["Close PCT Min", "80%"],
];
export default function BacktestPage() {
return (
<>
<section className="pt-6 pb-12">
<p className="eyebrow mb-4">Quantitative Analysis</p>
<h1 className="text-4xl font-semibold tracking-[-0.032em] text-ink mb-3">
Backtest Results
</h1>
<p className="text-[15px] text-ink-sub max-w-xl">
Historical simulation on real MT5 tick data. Includes spread costs, commission, and slippage.
</p>
</section>
{/* Highlight */}
<div className="card-featured p-10 mb-10">
<div className="flex flex-col sm:flex-row sm:items-start gap-4 mb-8">
<div>
<span className="status-pill status-bull mb-3 inline-block">Recommended</span>
<h2 className="text-2xl font-semibold tracking-tight-sm text-ink">M5 · 1 Month · 1% Risk</h2>
<p className="text-sm text-ink-sub mt-1">XAUUSDm · Exness Demo</p>
</div>
<Link href="/trades" className="btn-primary sm:ml-auto shrink-0">View Live Trades </Link>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-6 pt-8 border-t border-hl">
{[
{ label: "Profit Factor", value: "1.42", note: "> 1.3 = good" },
{ label: "Net Return", value: "+43.2%", note: "on $600 balance" },
{ label: "Max Drawdown", value: "11.5%", note: "manageable" },
{ label: "Win Rate", value: "55.3%", note: "159 trades" },
].map(({ label, value, note }) => (
<div key={label}>
<p className="text-xs text-ink-sub mb-1.5">{label}</p>
<p className="font-mono text-xl font-semibold text-ink">{value}</p>
<p className="text-xs text-ink-ter mt-1">{note}</p>
</div>
))}
</div>
</div>
{/* Results table */}
<div className="rounded-lg border border-hl overflow-hidden mb-10">
<div className="px-6 py-4 border-b border-hl">
<p className="eyebrow">All Runs</p>
</div>
<div className="overflow-x-auto">
<table className="data-table">
<thead>
<tr>
{["Period", "TF", "Risk", "Trades", "Win Rate", "Profit Factor", "Return", "Max DD", ""].map(h => (
<th key={h}>{h}</th>
))}
</tr>
</thead>
<tbody>
{results.map((r, i) => (
<tr key={i} className={r.highlight ? "bg-s2" : ""}>
<td className="font-medium text-ink">{r.period}</td>
<td className="font-mono text-ink-sub">{r.tf}</td>
<td className="font-mono text-ink-sub">{r.risk}</td>
<td className="font-mono text-ink-md">{r.trades}</td>
<td className="font-mono text-ink-md">{r.wr.toFixed(1)}%</td>
<td className="font-mono font-medium">
{r.pf != null
? <span className={r.pf >= 1.3 ? "text-bull" : r.pf < 1 ? "text-bear" : "text-ink-md"}>{r.pf.toFixed(2)}</span>
: <span className="text-ink-ter"></span>}
</td>
<td className="font-mono font-medium">
{r.ret != null
? <span className={r.ret >= 0 ? "text-bull" : "text-bear"}>{r.ret >= 0 ? "+" : ""}{r.ret.toFixed(1)}%</span>
: <span className="text-ink-ter"></span>}
</td>
<td className="font-mono">
{r.dd != null
? <span className={Math.abs(r.dd) > 30 ? "text-bear" : Math.abs(r.dd) > 15 ? "text-amber-400" : "text-ink-md"}>{r.dd.toFixed(1)}%</span>
: <span className="text-ink-ter"></span>}
</td>
<td className="text-xs text-ink-ter">{r.note ?? ""}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Params + How it works */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-10">
<div className="card">
<p className="eyebrow mb-6">Parameters</p>
<dl className="divide-y divide-hl">
{params.map(([k, v]) => (
<div key={k} className="flex justify-between py-3">
<dt className="text-sm text-ink-sub">{k}</dt>
<dd className="font-mono text-sm font-medium text-ink">{v}</dd>
</div>
))}
</dl>
</div>
<div className="card">
<p className="eyebrow mb-6">How It Works</p>
<ol className="space-y-5">
{[
["Detect Impulse", "3-candle momentum: body ≥ 60% of range, close in top/bottom 20%."],
["Find FVG", "Measure gap between candle 1 high and candle 3 low (bull) or reverse."],
["EMA Filter", "Long only above EMA-20, short only below. Strict trend confirmation."],
["Place Limit", "Set limit at FVG midpoint. Auto-cancel after 10 candles if unfilled."],
["Manage Risk", "SL at FVG boundary, TP at ≥ 1.5× RR, size at exactly 1% risk."],
].map(([title, desc], i) => (
<li key={i} className="flex gap-4">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-s2 border border-hl text-xs font-mono text-ink-sub flex items-center justify-center">
{i + 1}
</span>
<div>
<p className="text-sm font-medium text-ink">{title}</p>
<p className="text-sm text-ink-sub mt-0.5 leading-relaxed">{desc}</p>
</div>
</li>
))}
</ol>
</div>
</div>
{/* Disclaimer */}
<div className="rounded-lg border border-hl p-5">
<p className="text-xs text-ink-ter leading-relaxed">
<span className="text-ink-sub font-medium">Disclaimer </span>
Backtests simulate on historical data and do not account for requotes, broker restrictions, or changing market regimes.
Past results do not guarantee future performance. For informational purposes only.
</p>
</div>
</>
);
}
+128
View File
@@ -0,0 +1,128 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: dark;
--c-canvas: #010102;
--c-s1: #0f1011;
--c-s2: #141516;
--c-s3: #18191a;
--c-hl: #23252a;
--c-hl-strong: #34343a;
--c-ink: #f7f8f8;
--c-ink-md: #d0d6e0;
--c-ink-sub: #8a8f98;
--c-ink-ter: #62666d;
--c-accent: #5e6ad2;
--c-accent-hov: #828fff;
--c-bull: #27a644;
--c-bear: #e5484d;
}
html, body {
background-color: var(--c-canvas) !important;
color: var(--c-ink);
font-family: 'Inter', -apple-system, system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
/* ── Buttons ─────────────────────────────────────────────────────────────── */
.btn-primary {
display: inline-flex; align-items: center; gap: 8px;
border-radius: 8px; background: var(--c-accent); color: #fff;
font-size: 14px; font-weight: 500; padding: 8px 14px;
transition: background 150ms; border: none; cursor: pointer; text-decoration: none;
}
.btn-primary:hover { background: var(--c-accent-hov); }
.btn-primary:active { background: #5e69d1; }
.btn-secondary {
display: inline-flex; align-items: center; gap: 8px;
border-radius: 8px; background: var(--c-s1); color: var(--c-ink);
font-size: 14px; font-weight: 500; padding: 8px 14px;
border: 1px solid var(--c-hl); transition: background 150ms; cursor: pointer; text-decoration: none;
}
.btn-secondary:hover { background: var(--c-s2); }
.btn-inverse {
display: inline-flex; align-items: center; gap: 8px;
border-radius: 8px; background: #fff; color: #000;
font-size: 14px; font-weight: 500; padding: 8px 14px;
transition: background 150ms; border: none; cursor: pointer; text-decoration: none;
}
.btn-inverse:hover { background: #f5f5f5; }
/* ── Cards ───────────────────────────────────────────────────────────────── */
.card {
background: var(--c-s1);
border-radius: 12px;
border: 1px solid var(--c-hl);
padding: 24px;
}
.card-featured {
background: var(--c-s2);
border-radius: 12px;
border: 1px solid var(--c-hl-strong);
padding: 24px;
}
/* ── Stat tile ───────────────────────────────────────────────────────────── */
.stat-tile {
background: var(--c-s1);
border-radius: 12px;
border: 1px solid var(--c-hl);
padding: 20px;
}
/* ── Status pills ────────────────────────────────────────────────────────── */
.status-pill {
display: inline-flex; align-items: center;
border-radius: 9999px; padding: 2px 8px;
font-size: 12px; font-weight: 500;
}
.status-bull { background: rgba(39,166,68,.12); color: var(--c-bull); }
.status-bear { background: rgba(229,72,77,.12); color: var(--c-bear); }
.status-muted { background: var(--c-s2); color: var(--c-ink-md); }
/* ── Eyebrow label ───────────────────────────────────────────────────────── */
.eyebrow {
font-size: 13px; font-weight: 500;
letter-spacing: 0.04em; text-transform: uppercase;
color: var(--c-ink-sub);
}
/* ── Data table ──────────────────────────────────────────────────────────── */
.data-table { width: 100%; font-size: 14px; }
.data-table thead tr {
border-bottom: 1px solid var(--c-hl);
background: var(--c-s1);
text-align: left;
}
.data-table thead th {
padding: 12px 16px;
font-size: 11px; font-weight: 500;
letter-spacing: 0.06em; text-transform: uppercase;
color: var(--c-ink-sub); white-space: nowrap;
}
.data-table tbody tr {
border-bottom: 1px solid var(--c-hl);
transition: background 100ms;
}
.data-table tbody tr:last-child { border-bottom: none; }
.data-table tbody tr:hover { background: var(--c-s2); }
.data-table tbody td { padding: 12px 16px; }
/* ── Scrollbar ───────────────────────────────────────────────────────────── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: var(--c-s1); }
::-webkit-scrollbar-thumb { background: var(--c-hl); border-radius: 4px; }
/* ── Pulse animation ─────────────────────────────────────────────────────── */
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.pulse-dot { animation: pulse-dot 2s ease-in-out infinite; }
+50
View File
@@ -0,0 +1,50 @@
import type { Metadata } from "next";
import "./globals.css";
import Nav from "@/components/Nav";
export const metadata: Metadata = {
title: "ARES — Automated Trading Bot",
description: "Live forward-test results for ARES, an M5 Momentum FVG scalper built in Rust.",
openGraph: {
title: "ARES Trading Bot",
description: "Live forward-test · M5 Momentum FVG · XAUUSDm",
type: "website",
},
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className="min-h-screen antialiased">
<Nav />
<main className="max-w-5xl mx-auto px-4 sm:px-6 py-12">
{children}
</main>
<footer className="border-t border-hl mt-section">
<div className="max-w-5xl mx-auto px-6 py-16 grid grid-cols-1 sm:grid-cols-3 gap-10">
<div>
<span className="font-mono text-xs tracking-widest text-ink-sub uppercase">ARES</span>
<p className="text-sm text-ink-sub mt-3 leading-relaxed">
M5 Momentum FVG Scalper<br />Built in Rust · XAUUSDm
</p>
</div>
<div>
<p className="text-xs font-medium text-ink-sub uppercase tracking-eyebrow mb-4">Navigation</p>
<ul className="space-y-2 text-sm text-ink-sub">
<li><a href="/" className="hover:text-ink transition-colors">Dashboard</a></li>
<li><a href="/trades" className="hover:text-ink transition-colors">Trades</a></li>
<li><a href="/backtest" className="hover:text-ink transition-colors">Backtest</a></li>
</ul>
</div>
<div>
<p className="text-xs font-medium text-ink-sub uppercase tracking-eyebrow mb-4">Disclaimer</p>
<p className="text-xs text-ink-ter leading-relaxed">
Past performance does not guarantee future results. Demo account forward test. Not financial advice.
</p>
</div>
</div>
</footer>
</body>
</html>
);
}
+88
View File
@@ -0,0 +1,88 @@
import Link from "next/link";
import LiveDashboard from "@/components/LiveDashboard";
export default function DashboardPage() {
return (
<>
{/* Hero */}
<section className="pt-6 pb-16">
<p className="eyebrow mb-5">Live Forward Test · Demo Account</p>
<h1 className="text-5xl font-semibold tracking-[-0.032em] text-ink leading-[1.10] mb-5">
ARES Trading Bot
</h1>
<p className="text-[18px] text-ink-md leading-relaxed max-w-2xl mb-8">
M5 Momentum FVG scalper built in Rust. Targets Fair Value Gaps on XAUUSDm
with EMA-20 trend filter, automatic position sizing, and Telegram alerts.
</p>
<div className="flex gap-3 flex-wrap">
<Link href="/trades" className="btn-primary">View Trades</Link>
<Link href="/backtest" className="btn-secondary">Backtest Results</Link>
</div>
</section>
{/* Live data — client-side polling */}
<LiveDashboard />
{/* Strategy */}
<section className="mb-16">
<p className="eyebrow mb-6">Strategy</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<FeatureCard
icon="◈"
title="Momentum FVG"
body="3-candle pattern: pre → impulse → post. Body ≥ 60% of range, close in top/bottom 20%."
/>
<FeatureCard
icon="◎"
title="EMA-20 Filter"
body="Long only above EMA-20, short only below. Eliminates counter-trend signals entirely."
/>
<FeatureCard
icon="◉"
title="Risk Management"
body="Fixed % risk per trade. Minimum 1.5× RR. SL auto-set at FVG boundary."
/>
</div>
</section>
{/* Backtest CTA */}
<section className="card-featured p-10 mb-16">
<div className="flex flex-col sm:flex-row sm:items-center gap-6 mb-8">
<div>
<p className="eyebrow mb-2">Backtest Highlight</p>
<h2 className="text-2xl font-semibold tracking-tight-sm text-ink">
M5 · 1 Month · 1% Risk
</h2>
<p className="text-sm text-ink-sub mt-1">XAUUSDm · 159 trades · Exness Demo</p>
</div>
<Link href="/backtest" className="btn-primary sm:ml-auto shrink-0">
Full Results
</Link>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-6 pt-6 border-t border-hl">
{[
{ label: "Profit Factor", value: "1.42" },
{ label: "Net Return", value: "+43.2%" },
{ label: "Max Drawdown", value: "11.5%" },
{ label: "Win Rate", value: "55.3%" },
].map(({ label, value }) => (
<div key={label}>
<p className="text-xs text-ink-sub mb-1.5">{label}</p>
<p className="font-mono text-xl font-semibold tracking-tight-md">{value}</p>
</div>
))}
</div>
</section>
</>
);
}
function FeatureCard({ icon, title, body }: { icon: string; title: string; body: string }) {
return (
<div className="card">
<span className="text-accent text-lg mb-4 block">{icon}</span>
<p className="font-medium text-[15px] text-ink mb-2 tracking-tight">{title}</p>
<p className="text-sm text-ink-sub leading-relaxed">{body}</p>
</div>
);
}
+137
View File
@@ -0,0 +1,137 @@
import { getAccount, getDeals, type Deal } from "@/lib/mt5";
import { formatCurrency, formatDate } from "@/lib/format";
import EquityChart from "@/components/EquityChart";
export const revalidate = 60;
const MAGIC = 19730;
export default async function TradesPage() {
let deals: Deal[] = [];
let account = null;
let error = false;
try {
const now = new Date().toISOString().slice(0, 19);
[account, deals] = await Promise.all([
getAccount(),
getDeals("2025-01-01T00:00:00", now),
]);
} catch { error = true; }
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 equityData = buildEquityCurve(deals, startBalance);
const closed = aresDeals.filter(d => d.entry === 1);
const wins = 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 grossW = wins.reduce((s, d) => s + d.profit, 0);
const grossL = Math.abs(losses.reduce((s, d) => s + d.profit, 0));
const pf = grossL > 0 ? grossW / grossL : 0;
const wr = closed.length > 0 ? (wins.length / closed.length) * 100 : 0;
const currency = account?.currency ?? "USD";
return (
<>
<section className="pt-6 pb-12">
<p className="eyebrow mb-4">Live Forward Test</p>
<h1 className="text-4xl font-semibold tracking-[-0.032em] text-ink mb-3">
Trade History
</h1>
<p className="text-[15px] text-ink-sub">
All closed trades from ARES · magic {MAGIC}
</p>
</section>
{error ? (
<div className="card text-center text-ink-sub text-sm mb-12 py-10">
Unable to fetch trade data.
</div>
) : (
<>
{/* Stats */}
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3 mb-10">
{[
{ label: "Total Trades", value: closed.length.toString(), mono: true },
{ label: "Win Rate", value: `${wr.toFixed(1)}%`, mono: true },
{ label: "Profit Factor",value: pf > 0 ? pf.toFixed(2) : "—", mono: true },
{ label: "Net P&L", value: formatCurrency(netPnl, currency), mono: true, colored: netPnl !== 0 ? netPnl > 0 : undefined },
{ label: "W / L", value: `${wins.length} / ${losses.length}`, mono: true },
].map(({ label, value, mono, colored }) => (
<div key={label} className="stat-tile">
<p className="text-xs text-ink-sub mb-2">{label}</p>
<p className={`${mono ? "font-mono" : ""} text-xl font-semibold ${
colored === true ? "text-bull" : colored === false ? "text-bear" : "text-ink"
}`}>{value}</p>
</div>
))}
</div>
{/* Equity curve */}
<div className="card mb-10">
<p className="eyebrow mb-6">Equity Curve</p>
<EquityChart data={equityData} currency={currency} />
</div>
{/* Trade table */}
<div className="rounded-lg border border-hl overflow-hidden">
<div className="px-6 py-4 border-b border-hl">
<p className="eyebrow">Closed Trades</p>
</div>
{closed.length === 0 ? (
<div className="text-center text-ink-sub text-sm py-12">No closed trades yet.</div>
) : (
<div className="overflow-x-auto">
<table className="data-table">
<thead>
<tr>
{["Time", "Symbol", "Side", "Volume", "Price", "P&L"].map(h => (
<th key={h}>{h}</th>
))}
</tr>
</thead>
<tbody>
{closed.slice().reverse().map((d) => (
<tr key={d.ticket}>
<td className="text-ink-sub text-xs whitespace-nowrap">{formatDate(d.time)}</td>
<td className="font-mono font-medium text-ink">{d.symbol || "—"}</td>
<td>
<span className={`status-pill ${d.type === 0 ? "status-bull" : "status-bear"}`}>
{d.type === 0 ? "BUY" : "SELL"}
</span>
</td>
<td className="font-mono text-ink-md">{d.volume}</td>
<td className="font-mono text-ink-md">{d.price.toFixed(2)}</td>
<td className={`font-mono font-semibold ${d.profit >= 0 ? "text-bull" : "text-bear"}`}>
{formatCurrency(d.profit, currency)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</>
)}
</>
);
}
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;
}