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
parent 4fdc3da4ed
commit 9716fc0955
26 changed files with 3854 additions and 0 deletions
+5
View File
@@ -1,3 +1,8 @@
/target
.env
.ares_state_*.json
# web portfolio
web/.next/
web/node_modules/
web/.env.local
+3
View File
@@ -0,0 +1,3 @@
# MT5 bridge URL — server-side only, never exposed to browser
# On Vercel: add this as an Environment Variable (not NEXT_PUBLIC_)
MT5_BASE_URL=https://mt5.romys.my.id
+5
View File
@@ -0,0 +1,5 @@
.next/
node_modules/
.env.local
.env*.local
*.log
+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;
}
+77
View File
@@ -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>
);
}
+183
View File
@@ -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>
);
}
+48
View File
@@ -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>
);
}
+43
View File
@@ -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>
);
}
+42
View File
@@ -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>
);
}
+22
View File
@@ -0,0 +1,22 @@
export function formatCurrency(n: number, currency = "USD"): string {
return new Intl.NumberFormat("en-US", {
style: "currency", currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(n);
}
export function formatPct(n: number, decimals = 1): string {
return `${n >= 0 ? "+" : ""}${n.toFixed(decimals)}%`;
}
export function formatDate(iso: string): string {
return new Date(iso).toLocaleString("en-US", {
month: "short", day: "numeric",
hour: "2-digit", minute: "2-digit",
});
}
export function formatPrice(n: number, digits = 2): string {
return n.toFixed(digits);
}
+102
View File
@@ -0,0 +1,102 @@
// Server-side only — bridge URL never leaves this file
async function apiFetch<T>(path: string): Promise<T> {
const base = process.env.MT5_BASE_URL ?? "http://localhost:8000";
const res = await fetch(`${base}${path}`, {
next: { revalidate: 0 }, // always fresh
});
if (!res.ok) throw new Error(`MT5 bridge ${path}${res.status}`);
return res.json();
}
// ── Types ────────────────────────────────────────────────────────────────────
export interface AccountInfo {
login: number;
balance: number;
equity: number;
profit: number;
margin: number;
margin_free: number;
margin_level: number;
currency: string;
leverage: number;
name: string;
server: string;
}
export interface Position {
ticket: number;
symbol: string;
type: number; // 0=buy, 1=sell
volume: number;
price_open: number;
price_current: number;
sl: number;
tp: number;
profit: number;
swap: number;
comment: string;
magic: number;
time: string;
}
export interface PendingOrder {
ticket: number;
symbol: string;
type: number; // 2=BUY_LIMIT, 3=SELL_LIMIT, 4=BUY_STOP, 5=SELL_STOP
volume_initial: number;
volume_current: number;
price_open: number;
price_current: number;
sl: number;
tp: number;
magic: number;
comment: string;
time_setup: string;
}
export interface Deal {
ticket: number;
order: number;
time: string;
type: number; // 0=buy, 1=sell, 2=balance
entry: number; // 0=in, 1=out
symbol: string;
volume: number;
price: number;
commission: number;
swap: number;
profit: number;
comment: string;
magic: number;
}
// ── Wrappers ──────────────────────────────────────────────────────────────────
interface DataVec<T> { data: T[]; count: number }
export async function getAccount(): Promise<AccountInfo> {
const w = await apiFetch<DataVec<AccountInfo>>("/account");
const item = w.data[0];
if (!item) throw new Error("empty /account response");
return item;
}
export async function getPositions(): Promise<Position[]> {
const w = await apiFetch<DataVec<Position>>("/positions");
return w.data;
}
export async function getOrders(symbol?: string): Promise<PendingOrder[]> {
const path = symbol ? `/orders?symbol=${encodeURIComponent(symbol)}` : "/orders";
const w = await apiFetch<DataVec<PendingOrder>>(path);
return w.data;
}
export async function getDeals(dateFrom: string, dateTo: string, symbol?: string): Promise<Deal[]> {
let path = `/history/deals?date_from=${encodeURIComponent(dateFrom)}&date_to=${encodeURIComponent(dateTo)}`;
if (symbol) path += `&symbol=${encodeURIComponent(symbol)}`;
const w = await apiFetch<DataVec<Deal>>(path);
return w.data;
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// bridge URL is server-side only — never exposed to client
};
export default nextConfig;
+2551
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "ares-portfolio",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "^15.3.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"recharts": "^2.15.3",
"date-fns": "^4.1.0",
"clsx": "^2.1.1"
},
"devDependencies": {
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"typescript": "^5"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+69
View File
@@ -0,0 +1,69 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
// Linear surface ladder
canvas: "#010102",
"s1": "#0f1011",
"s2": "#141516",
"s3": "#18191a",
// Hairlines
"hl": "#23252a",
"hl-strong":"#34343a",
// Text
ink: "#f7f8f8",
"ink-md": "#d0d6e0",
"ink-sub": "#8a8f98",
"ink-ter": "#62666d",
// Accent (lavender-blue — use sparingly)
accent: "#5e6ad2",
"accent-hover": "#828fff",
"accent-focus": "#5e69d1",
// Inverse (rare)
"inv-canvas": "#ffffff",
"inv-ink": "#000000",
// Trading semantic
bull: "#27a644",
bear: "#e5484d",
"bull-subtle": "rgba(39,166,68,0.12)",
"bear-subtle": "rgba(229,72,77,0.12)",
},
borderRadius: {
xs: "4px",
sm: "6px",
md: "8px",
lg: "12px",
xl: "16px",
xxl: "24px",
pill: "9999px",
},
fontFamily: {
sans: ["Inter", "-apple-system", "system-ui", "Segoe UI", "sans-serif"],
mono: ["'JetBrains Mono'", "'Geist Mono'", "ui-monospace", "SF Mono", "Menlo", "monospace"],
},
letterSpacing: {
"tight-xl": "-0.18em",
"tight-lg": "-0.032em",
"tight-md": "-0.025em",
"tight-sm": "-0.015em",
"eyebrow": "0.03em",
},
spacing: {
xxs: "4px",
xs: "8px",
sm: "12px",
md: "16px",
lg: "24px",
xl: "32px",
xxl: "48px",
section: "96px",
},
},
},
plugins: [],
};
export default config;
+40
View File
@@ -0,0 +1,40 @@
{
"compilerOptions": {
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
},
"target": "ES2017"
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}