mirror of
https://github.com/RomySaputraSihananda/ares.git
synced 2026-07-27 18:47:53 +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:
@@ -1,3 +1,8 @@
|
||||
/target
|
||||
.env
|
||||
.ares_state_*.json
|
||||
|
||||
# web portfolio
|
||||
web/.next/
|
||||
web/node_modules/
|
||||
web/.env.local
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
.next/
|
||||
node_modules/
|
||||
.env.local
|
||||
.env*.local
|
||||
*.log
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
Vendored
+6
@@ -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.
|
||||
@@ -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;
|
||||
Generated
+2551
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user