mirror of
https://github.com/mauricioabh/arbpulse.git
synced 2026-08-03 02:37:43 +00:00
Initial commit: Arb Pulse monolith with CI and optional Fly deploy.
Real-time BTC cross-exchange arbitrage detection (Kraken, Bybit, OKX, Binance) with React dashboard, GitHub Actions CI, and documented Fly.io deploy workflow. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { StateSnapshot } from "./types";
|
||||
import { subscribeState } from "./api";
|
||||
import { StatsBar } from "./components/StatsBar";
|
||||
import { PriceMatrix } from "./components/PriceMatrix";
|
||||
import { OpportunityFeed } from "./components/OpportunityFeed";
|
||||
import { PnlChart } from "./components/PnlChart";
|
||||
import { TradeLog } from "./components/TradeLog";
|
||||
import { Wallets } from "./components/Wallets";
|
||||
import { Controls } from "./components/Controls";
|
||||
import { ConfigPanel } from "./components/ConfigPanel";
|
||||
|
||||
export function App(): JSX.Element {
|
||||
const [snapshot, setSnapshot] = useState<StateSnapshot | null>(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeState(setSnapshot, setConnected);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1400px] px-4 py-6 lg:px-8">
|
||||
<header className="mb-6 flex items-end justify-between">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-extrabold tracking-tight text-slate-100">
|
||||
Arb<span className="text-accent">Pulse</span>
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500">Real-time cross-exchange BTC arbitrage engine · Kraken · Bybit · OKX · Binance</p>
|
||||
</div>
|
||||
<p className="hidden text-xs text-slate-600 sm:block">
|
||||
BTC/USDT · WebSocket feeds · inventory model
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{!snapshot ? (
|
||||
<div className="flex h-[60vh] items-center justify-center text-slate-500">Connecting to engine…</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
<StatsBar stats={snapshot.stats} connected={connected} />
|
||||
|
||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-3">
|
||||
<div className="space-y-5 lg:col-span-2">
|
||||
<PriceMatrix quotes={snapshot.quotes} />
|
||||
<PnlChart series={snapshot.pnlSeries} />
|
||||
<TradeLog trades={snapshot.recentTrades} />
|
||||
</div>
|
||||
<div className="space-y-5">
|
||||
<Controls stats={snapshot.stats} config={snapshot.config} />
|
||||
<ConfigPanel config={snapshot.config} />
|
||||
<Wallets wallets={snapshot.wallets} rebalances={snapshot.rebalances} />
|
||||
<OpportunityFeed opportunities={snapshot.recentOpportunities} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { StateSnapshot } from "./types";
|
||||
|
||||
/**
|
||||
* Subscribe to the live state stream over SSE. Uses a relative URL so it works
|
||||
* behind the Vite dev proxy and same-origin in production. EventSource
|
||||
* auto-reconnects on drop.
|
||||
*/
|
||||
export function subscribeState(
|
||||
onSnapshot: (s: StateSnapshot) => void,
|
||||
onStatus: (connected: boolean) => void,
|
||||
): () => void {
|
||||
const source = new EventSource("/api/stream");
|
||||
|
||||
source.onopen = () => onStatus(true);
|
||||
source.onerror = () => onStatus(false);
|
||||
source.onmessage = (event) => {
|
||||
try {
|
||||
onSnapshot(JSON.parse(event.data) as StateSnapshot);
|
||||
} catch {
|
||||
/* ignore malformed frame */
|
||||
}
|
||||
};
|
||||
|
||||
return () => source.close();
|
||||
}
|
||||
|
||||
async function post(path: string, body?: unknown): Promise<void> {
|
||||
await fetch(`/api${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export const control = {
|
||||
pause: () => post("/control/pause"),
|
||||
resume: () => post("/control/resume"),
|
||||
reset: () => post("/control/reset"),
|
||||
setDemo: (enabled: boolean) => post("/control/demo", { enabled }),
|
||||
setRecord: (enabled: boolean) => post("/control/record", { enabled }),
|
||||
setThreshold: (pct: number) => post("/control/threshold", { pct }),
|
||||
setMaxTrade: (btc: number) => post("/control/max-trade", { btc }),
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { clsx } from "../format";
|
||||
|
||||
interface CardProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
right?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Card({ title, subtitle, right, children, className }: CardProps): JSX.Element {
|
||||
return (
|
||||
<section
|
||||
className={clsx(
|
||||
"rounded-2xl border border-ink-600/60 bg-ink-800/60 backdrop-blur-sm shadow-xl shadow-black/30",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<header className="flex items-center justify-between gap-3 border-b border-ink-600/50 px-4 py-3">
|
||||
<div>
|
||||
<h2 className="font-display text-sm font-bold uppercase tracking-[0.18em] text-slate-200">{title}</h2>
|
||||
{subtitle && <p className="mt-0.5 text-xs text-slate-500">{subtitle}</p>}
|
||||
</div>
|
||||
{right}
|
||||
</header>
|
||||
<div className="p-4">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { ExchangeId, PublicConfig } from "../types";
|
||||
import { Card } from "./Card";
|
||||
import { patchConfig } from "../config-api";
|
||||
import { clsx, pct, btc } from "../format";
|
||||
|
||||
interface Props {
|
||||
config: PublicConfig;
|
||||
}
|
||||
|
||||
const EXCHANGES: { id: ExchangeId; label: string }[] = [
|
||||
{ id: "kraken", label: "Kraken" },
|
||||
{ id: "bybit", label: "Bybit" },
|
||||
{ id: "okx", label: "OKX" },
|
||||
{ id: "binance", label: "Binance" },
|
||||
];
|
||||
|
||||
const MIN_PROFIT = 0.0001;
|
||||
const MAX_PROFIT = 0.01;
|
||||
const MIN_TRADE = 0.01;
|
||||
const MAX_TRADE = 1.0;
|
||||
const DEBOUNCE_MS = 200;
|
||||
|
||||
function Toggle({
|
||||
label,
|
||||
on,
|
||||
modified,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
on: boolean;
|
||||
modified: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(!on)}
|
||||
className={clsx(
|
||||
"flex items-center justify-between gap-3 rounded-xl border px-3 py-2 text-left text-sm transition",
|
||||
modified ? "border-accent/50 bg-accent/5" : "border-ink-600/50 bg-ink-700/30 hover:border-ink-500",
|
||||
)}
|
||||
>
|
||||
<span className={clsx(modified ? "text-accent" : "text-slate-300")}>{label}</span>
|
||||
<span className={clsx("relative h-5 w-9 rounded-full transition", on ? "bg-accent" : "bg-ink-500")}>
|
||||
<span
|
||||
className={clsx(
|
||||
"absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all",
|
||||
on ? "left-[18px]" : "left-0.5",
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SliderRow({
|
||||
label,
|
||||
valueLabel,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
value,
|
||||
modified,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
valueLabel: string;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
value: number;
|
||||
modified: boolean;
|
||||
onChange: (v: number) => void;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"rounded-xl border px-3 py-2",
|
||||
modified ? "border-accent/50 bg-accent/5" : "border-ink-600/50 bg-ink-700/30",
|
||||
)}
|
||||
>
|
||||
<div className="mb-1 flex items-center justify-between text-sm">
|
||||
<span className={clsx(modified ? "text-accent" : "text-slate-300")}>{label}</span>
|
||||
<span className="font-mono text-accent">{valueLabel}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="w-full accent-accent"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConfigPanel({ config }: Props): JSX.Element {
|
||||
const [minProfit, setMinProfit] = useState(config.minNetProfitPct);
|
||||
const [maxTrade, setMaxTrade] = useState(config.maxTradeBtc);
|
||||
const [flickerMs, setFlickerMs] = useState(config.flickerConfirmMs);
|
||||
const [active, setActive] = useState(config.activeExchanges);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMinProfit(config.minNetProfitPct);
|
||||
setMaxTrade(config.maxTradeBtc);
|
||||
setFlickerMs(config.flickerConfirmMs);
|
||||
setActive(config.activeExchanges);
|
||||
}, [config]);
|
||||
|
||||
const sendPatch = useCallback((patch: Parameters<typeof patchConfig>[0]) => {
|
||||
patchConfig(patch).catch(() => {
|
||||
/* SSE will resync on next snapshot */
|
||||
});
|
||||
}, []);
|
||||
|
||||
const debouncedPatch = useCallback(
|
||||
(patch: Parameters<typeof patchConfig>[0]) => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => sendPatch(patch), DEBOUNCE_MS);
|
||||
},
|
||||
[sendPatch],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const defaults = config.defaults;
|
||||
|
||||
return (
|
||||
<Card title="Live config" subtitle="Applied on next engine tick">
|
||||
<div className="space-y-3">
|
||||
<SliderRow
|
||||
label="Min net profit"
|
||||
valueLabel={pct(minProfit, 2)}
|
||||
min={MIN_PROFIT}
|
||||
max={MAX_PROFIT}
|
||||
step={0.0001}
|
||||
value={minProfit}
|
||||
modified={minProfit !== defaults.minNetProfitPct}
|
||||
onChange={(v) => {
|
||||
setMinProfit(v);
|
||||
debouncedPatch({ minNetProfitPct: v });
|
||||
}}
|
||||
/>
|
||||
|
||||
<SliderRow
|
||||
label="Trade volume"
|
||||
valueLabel={`${btc(maxTrade, 2)} BTC`}
|
||||
min={MIN_TRADE}
|
||||
max={MAX_TRADE}
|
||||
step={0.01}
|
||||
value={maxTrade}
|
||||
modified={maxTrade !== defaults.maxTradeBtc}
|
||||
onChange={(v) => {
|
||||
setMaxTrade(v);
|
||||
debouncedPatch({ maxTradeBtc: v });
|
||||
}}
|
||||
/>
|
||||
|
||||
<SliderRow
|
||||
label="Anti-flicker window"
|
||||
valueLabel={`${flickerMs} ms`}
|
||||
min={0}
|
||||
max={500}
|
||||
step={10}
|
||||
value={flickerMs}
|
||||
modified={flickerMs !== defaults.flickerConfirmMs}
|
||||
onChange={(v) => {
|
||||
setFlickerMs(v);
|
||||
debouncedPatch({ flickerConfirmMs: v });
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-slate-500">Active exchanges</p>
|
||||
{EXCHANGES.map(({ id, label }) => (
|
||||
<Toggle
|
||||
key={id}
|
||||
label={label}
|
||||
on={active[id]}
|
||||
modified={active[id] !== defaults.activeExchanges[id]}
|
||||
onChange={(enabled) => {
|
||||
const next = { ...active, [id]: enabled };
|
||||
setActive(next);
|
||||
sendPatch({ activeExchanges: { [id]: enabled } });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useState } from "react";
|
||||
import type { EngineStats, PublicConfig } from "../types";
|
||||
import { Card } from "./Card";
|
||||
import { control } from "../api";
|
||||
import { clsx, pct } from "../format";
|
||||
|
||||
interface Props {
|
||||
stats: EngineStats;
|
||||
config: PublicConfig;
|
||||
}
|
||||
|
||||
function Toggle({ label, on, onChange }: { label: string; on: boolean; onChange: (v: boolean) => void }): JSX.Element {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(!on)}
|
||||
className="flex items-center justify-between gap-3 rounded-xl border border-ink-600/50 bg-ink-700/30 px-3 py-2 text-left text-sm transition hover:border-ink-500"
|
||||
>
|
||||
<span className="text-slate-300">{label}</span>
|
||||
<span className={clsx("relative h-5 w-9 rounded-full transition", on ? "bg-accent" : "bg-ink-500")}>
|
||||
<span
|
||||
className={clsx(
|
||||
"absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all",
|
||||
on ? "left-[18px]" : "left-0.5",
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function Controls({ stats, config }: Props): JSX.Element {
|
||||
const [threshold, setThreshold] = useState(config.minNetProfitPct);
|
||||
const paused = stats.circuit === "paused";
|
||||
|
||||
return (
|
||||
<Card title="Controls" subtitle="Operator panel">
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{paused ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => control.resume()}
|
||||
className="rounded-xl border border-profit/40 bg-profit/10 px-3 py-2 text-sm font-semibold text-profit transition hover:bg-profit/20"
|
||||
>
|
||||
Resume
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => control.pause()}
|
||||
className="rounded-xl border border-warn/40 bg-warn/10 px-3 py-2 text-sm font-semibold text-warn transition hover:bg-warn/20"
|
||||
>
|
||||
Pause
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => control.reset()}
|
||||
className="rounded-xl border border-loss/40 bg-loss/10 px-3 py-2 text-sm font-semibold text-loss transition hover:bg-loss/20"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Toggle label="Demo mode (synthetic feed)" on={stats.demoMode} onChange={(v) => control.setDemo(v)} />
|
||||
|
||||
<div className="rounded-xl border border-ink-600/50 bg-ink-700/30 px-3 py-2">
|
||||
<div className="mb-1 flex items-center justify-between text-sm">
|
||||
<span className="text-slate-300">Min net edge</span>
|
||||
<span className="font-mono text-accent">{pct(threshold)}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={0.005}
|
||||
step={0.0001}
|
||||
value={threshold}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
setThreshold(v);
|
||||
control.setThreshold(v);
|
||||
}}
|
||||
className="w-full accent-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-ink-600/50 bg-ink-700/30 px-3 py-2 text-xs text-slate-500">
|
||||
<div className="flex justify-between">
|
||||
<span>Taker fees</span>
|
||||
<span className="font-mono">
|
||||
K {pct(config.takerFees.kraken, 2)} · By {pct(config.takerFees.bybit, 2)} · O {pct(config.takerFees.okx, 2)} · Bn {pct(config.takerFees.binance, 2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex justify-between">
|
||||
<span>Stale / confirm</span>
|
||||
<span className="font-mono">
|
||||
{config.staleMs}ms / {config.flickerConfirmMs}ms
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { Opportunity, OpportunityStatus } from "../types";
|
||||
import { Card } from "./Card";
|
||||
import { clsx, pct, timeOf, usd } from "../format";
|
||||
|
||||
interface Props {
|
||||
opportunities: Opportunity[];
|
||||
}
|
||||
|
||||
function statusBadge(status: OpportunityStatus): { label: string; cls: string } {
|
||||
switch (status) {
|
||||
case "executed":
|
||||
return { label: "EXECUTED", cls: "bg-profit/15 text-profit border-profit/40" };
|
||||
case "executed_partial":
|
||||
return { label: "PARTIAL", cls: "bg-profit/10 text-profit border-profit/30" };
|
||||
case "pending_confirm":
|
||||
return { label: "CONFIRMING", cls: "bg-accent/15 text-accent border-accent/40" };
|
||||
case "rejected_fees":
|
||||
return { label: "REJ · FEES", cls: "bg-warn/10 text-warn border-warn/30" };
|
||||
case "rejected_liquidity":
|
||||
return { label: "REJ · LIQ", cls: "bg-warn/10 text-warn border-warn/30" };
|
||||
case "rejected_risk":
|
||||
return { label: "REJ · RISK", cls: "bg-loss/10 text-loss border-loss/30" };
|
||||
case "rejected_stale":
|
||||
return { label: "REJ · STALE", cls: "bg-slate-500/10 text-slate-400 border-slate-500/30" };
|
||||
case "rejected_flicker":
|
||||
return { label: "REJ · FLICKER", cls: "bg-slate-500/10 text-slate-400 border-slate-500/30" };
|
||||
default: {
|
||||
const _exhaustive: never = status;
|
||||
return { label: _exhaustive, cls: "" };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function OpportunityFeed({ opportunities }: Props): JSX.Element {
|
||||
return (
|
||||
<Card title="Live Opportunities" subtitle="Detected divergences, scored net of real fees & slippage">
|
||||
<div className="max-h-[420px] space-y-2 overflow-y-auto pr-1">
|
||||
{opportunities.length === 0 && (
|
||||
<p className="py-8 text-center text-sm text-slate-500">Scanning venues for divergences…</p>
|
||||
)}
|
||||
{opportunities.map((op) => {
|
||||
const badge = statusBadge(op.status);
|
||||
const executed = op.status === "executed" || op.status === "executed_partial";
|
||||
return (
|
||||
<div
|
||||
key={op.id}
|
||||
className="flex items-center gap-3 rounded-xl border border-ink-600/40 bg-ink-700/30 px-3 py-2"
|
||||
>
|
||||
<span className={clsx("w-[110px] shrink-0 rounded-md border px-2 py-1 text-center text-[10px] font-bold tracking-wider", badge.cls)}>
|
||||
{badge.label}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 font-display text-sm">
|
||||
<span className="text-accent">{op.buyExchange}</span>
|
||||
<span className="text-slate-500">→</span>
|
||||
<span className="text-profit">{op.sellExchange}</span>
|
||||
{op.demo && <span className="text-[10px] uppercase tracking-wide text-accent/70">demo</span>}
|
||||
</div>
|
||||
<div className="truncate font-mono text-xs text-slate-500">
|
||||
buy {usd(op.buyVwap)} · sell {usd(op.sellVwap)} · {op.reason}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-right font-mono text-xs tabular-nums">
|
||||
<div className={clsx(executed ? "text-profit" : "text-slate-400")}>
|
||||
{executed ? `+$${usd(op.netProfit)}` : pct(op.grossSpreadPct)}
|
||||
</div>
|
||||
<div className="text-slate-600">{timeOf(op.ts)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { PnlPoint } from "../types";
|
||||
import { Card } from "./Card";
|
||||
import { usd } from "../format";
|
||||
|
||||
interface Props {
|
||||
series: PnlPoint[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight inline SVG P&L curve — no chart library dependency (keeps the
|
||||
* bundle small and the build dependency-free).
|
||||
*/
|
||||
export function PnlChart({ series }: Props): JSX.Element {
|
||||
const width = 640;
|
||||
const height = 220;
|
||||
const pad = 28;
|
||||
|
||||
const content = (() => {
|
||||
if (series.length < 2) {
|
||||
return <p className="py-16 text-center text-sm text-slate-500">P&L curve builds as trades execute…</p>;
|
||||
}
|
||||
|
||||
const xs = series.map((p) => p.ts);
|
||||
const ys = series.map((p) => p.pnl);
|
||||
const minX = Math.min(...xs);
|
||||
const maxX = Math.max(...xs);
|
||||
const minY = Math.min(0, ...ys);
|
||||
const maxY = Math.max(0, ...ys);
|
||||
const spanX = maxX - minX || 1;
|
||||
const spanY = maxY - minY || 1;
|
||||
|
||||
const px = (x: number) => pad + ((x - minX) / spanX) * (width - pad * 2);
|
||||
const py = (y: number) => height - pad - ((y - minY) / spanY) * (height - pad * 2);
|
||||
|
||||
const line = series.map((p, i) => `${i === 0 ? "M" : "L"}${px(p.ts).toFixed(1)},${py(p.pnl).toFixed(1)}`).join(" ");
|
||||
const area = `${line} L${px(maxX).toFixed(1)},${py(minY).toFixed(1)} L${px(minX).toFixed(1)},${py(minY).toFixed(1)} Z`;
|
||||
const last = ys[ys.length - 1] ?? 0;
|
||||
const positive = last >= 0;
|
||||
const stroke = positive ? "#3ddc97" : "#ff5c7c";
|
||||
const zeroY = py(0);
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${width} ${height}`} className="h-[220px] w-full" preserveAspectRatio="none">
|
||||
<defs>
|
||||
<linearGradient id="pnlfill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={stroke} stopOpacity="0.28" />
|
||||
<stop offset="100%" stopColor={stroke} stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<line x1={pad} y1={zeroY} x2={width - pad} y2={zeroY} stroke="#2b3a4f" strokeWidth="1" strokeDasharray="4 4" />
|
||||
<path d={area} fill="url(#pnlfill)" />
|
||||
<path d={line} fill="none" stroke={stroke} strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
})();
|
||||
|
||||
const last = series[series.length - 1]?.pnl ?? 0;
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="Cumulative P&L"
|
||||
subtitle="Realized, net of fees"
|
||||
right={
|
||||
<span className={`font-mono text-lg font-semibold tabular-nums ${last >= 0 ? "text-profit" : "text-loss"}`}>
|
||||
${usd(last)}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{content}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { BestQuote, ExchangeId } from "../types";
|
||||
import { Card } from "./Card";
|
||||
import { clsx, usd } from "../format";
|
||||
|
||||
interface Props {
|
||||
quotes: BestQuote[];
|
||||
}
|
||||
|
||||
const LABELS: Record<ExchangeId, string> = {
|
||||
kraken: "Kraken",
|
||||
bybit: "Bybit",
|
||||
okx: "OKX",
|
||||
binance: "Binance",
|
||||
};
|
||||
|
||||
function statusDot(status: BestQuote["status"]): string {
|
||||
switch (status) {
|
||||
case "live":
|
||||
return "bg-profit";
|
||||
case "stale":
|
||||
return "bg-warn";
|
||||
case "down":
|
||||
return "bg-loss";
|
||||
case "connecting":
|
||||
return "bg-slate-500";
|
||||
default: {
|
||||
const _exhaustive: never = status;
|
||||
return _exhaustive;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function PriceMatrix({ quotes }: Props): JSX.Element {
|
||||
// Highlight the best (lowest) ask and best (highest) bid across venues.
|
||||
const asks = quotes.map((q) => q.ask).filter((v): v is number => v !== null);
|
||||
const bids = quotes.map((q) => q.bid).filter((v): v is number => v !== null);
|
||||
const minAsk = asks.length ? Math.min(...asks) : null;
|
||||
const maxBid = bids.length ? Math.max(...bids) : null;
|
||||
const crossed = minAsk !== null && maxBid !== null && minAsk < maxBid;
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="Order Book — Best Bid / Ask"
|
||||
subtitle="BTC/USDT across venues"
|
||||
right={
|
||||
crossed ? (
|
||||
<span className="rounded-full border border-profit/40 bg-profit/10 px-3 py-1 text-xs font-semibold text-profit">
|
||||
DIVERGENCE
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<div className="overflow-hidden rounded-xl border border-ink-600/50">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-ink-700/50 text-left text-xs uppercase tracking-wider text-slate-400">
|
||||
<th className="px-4 py-2 font-medium">Exchange</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Bid</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Bid Qty</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Ask</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Ask Qty</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Spread</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="font-mono tabular-nums">
|
||||
{quotes.map((q) => {
|
||||
const spread = q.ask !== null && q.bid !== null ? q.ask - q.bid : null;
|
||||
return (
|
||||
<tr key={q.exchange} className="border-t border-ink-600/40">
|
||||
<td className="px-4 py-2.5">
|
||||
<span className="flex items-center gap-2 font-display text-sm">
|
||||
<span className={clsx("h-2 w-2 rounded-full", statusDot(q.status))} />
|
||||
{LABELS[q.exchange]}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
className={clsx(
|
||||
"px-4 py-2.5 text-right",
|
||||
q.bid !== null && q.bid === maxBid ? "font-semibold text-profit" : "text-slate-300",
|
||||
)}
|
||||
>
|
||||
{q.bid !== null ? usd(q.bid) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right text-slate-500">{q.bidQty?.toFixed(3) ?? "—"}</td>
|
||||
<td
|
||||
className={clsx(
|
||||
"px-4 py-2.5 text-right",
|
||||
q.ask !== null && q.ask === minAsk ? "font-semibold text-accent" : "text-slate-300",
|
||||
)}
|
||||
>
|
||||
{q.ask !== null ? usd(q.ask) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right text-slate-500">{q.askQty?.toFixed(3) ?? "—"}</td>
|
||||
<td className="px-4 py-2.5 text-right text-slate-400">{spread !== null ? usd(spread) : "—"}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { EngineStats } from "../types";
|
||||
import { clsx, usd } from "../format";
|
||||
|
||||
interface Props {
|
||||
stats: EngineStats;
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
function Stat({ label, value, tone }: { label: string; value: string; tone?: "profit" | "loss" | "warn" }): JSX.Element {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] uppercase tracking-widest text-slate-500">{label}</span>
|
||||
<span
|
||||
className={clsx(
|
||||
"font-mono text-lg font-semibold tabular-nums",
|
||||
tone === "profit" && "text-profit",
|
||||
tone === "loss" && "text-loss",
|
||||
tone === "warn" && "text-warn",
|
||||
!tone && "text-slate-100",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function circuitTone(c: EngineStats["circuit"]): { label: string; cls: string } {
|
||||
switch (c) {
|
||||
case "running":
|
||||
return { label: "RUNNING", cls: "bg-profit/15 text-profit border-profit/40" };
|
||||
case "paused":
|
||||
return { label: "PAUSED", cls: "bg-warn/15 text-warn border-warn/40" };
|
||||
case "tripped":
|
||||
return { label: "BREAKER TRIPPED", cls: "bg-loss/15 text-loss border-loss/40" };
|
||||
default: {
|
||||
const _exhaustive: never = c;
|
||||
return { label: _exhaustive, cls: "" };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function StatsBar({ stats, connected }: Props): JSX.Element {
|
||||
const circuit = circuitTone(stats.circuit);
|
||||
const pnlTone = stats.realizedPnl >= 0 ? "profit" : "loss";
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-8 gap-y-4 rounded-2xl border border-ink-600/60 bg-ink-800/60 px-5 py-4 backdrop-blur-sm">
|
||||
<Stat label="Realized P&L" value={`$${usd(stats.realizedPnl)}`} tone={pnlTone} />
|
||||
<Stat label="Trades" value={String(stats.tradesExecuted)} />
|
||||
<Stat label="Opportunities" value={String(stats.opportunitiesDetected)} />
|
||||
<Stat label="Rejected" value={String(stats.tradesRejected)} tone="warn" />
|
||||
<Stat label="Ticks" value={stats.ticksProcessed.toLocaleString()} />
|
||||
<Stat label="Engine /tick" value={`${stats.avgTickMs.toFixed(3)}ms`} />
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{stats.demoMode && (
|
||||
<span className="rounded-full border border-accent/40 bg-accent/15 px-3 py-1 text-xs font-semibold uppercase tracking-wider text-accent">
|
||||
Demo / Simulated Feed
|
||||
</span>
|
||||
)}
|
||||
<span className={clsx("rounded-full border px-3 py-1 text-xs font-semibold tracking-wider", circuit.cls)}>
|
||||
{circuit.label}
|
||||
</span>
|
||||
<span
|
||||
className={clsx(
|
||||
"flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold tracking-wider",
|
||||
connected ? "border-profit/40 bg-profit/10 text-profit" : "border-loss/40 bg-loss/10 text-loss",
|
||||
)}
|
||||
>
|
||||
<span className={clsx("h-2 w-2 rounded-full", connected ? "bg-profit" : "bg-loss")} />
|
||||
{connected ? "LIVE" : "OFFLINE"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Trade } from "../types";
|
||||
import { Card } from "./Card";
|
||||
import { btc, clsx, timeOf, usd } from "../format";
|
||||
|
||||
interface Props {
|
||||
trades: Trade[];
|
||||
}
|
||||
|
||||
export function TradeLog({ trades }: Props): JSX.Element {
|
||||
return (
|
||||
<Card title="Trade Log" subtitle="Simulated executions with real fills">
|
||||
<div className="max-h-[360px] overflow-y-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-ink-800">
|
||||
<tr className="text-left text-xs uppercase tracking-wider text-slate-400">
|
||||
<th className="px-2 py-2 font-medium">Time</th>
|
||||
<th className="px-2 py-2 font-medium">Route</th>
|
||||
<th className="px-2 py-2 text-right font-medium">Vol</th>
|
||||
<th className="px-2 py-2 text-right font-medium">Buy</th>
|
||||
<th className="px-2 py-2 text-right font-medium">Sell</th>
|
||||
<th className="px-2 py-2 text-right font-medium">Fees</th>
|
||||
<th className="px-2 py-2 text-right font-medium">Net P&L</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="font-mono tabular-nums">
|
||||
{trades.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="py-8 text-center text-slate-500">
|
||||
No trades executed yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{trades.map((t) => (
|
||||
<tr key={t.id} className="border-t border-ink-600/40">
|
||||
<td className="px-2 py-2 text-slate-500">{timeOf(t.ts)}</td>
|
||||
<td className="px-2 py-2">
|
||||
<span className="font-display text-xs">
|
||||
<span className="text-accent">{t.buyExchange}</span>
|
||||
<span className="text-slate-600"> → </span>
|
||||
<span className="text-profit">{t.sellExchange}</span>
|
||||
</span>
|
||||
{t.partial && <span className="ml-1 text-[10px] uppercase text-warn">partial</span>}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right text-slate-400">{btc(t.volumeBtc)}</td>
|
||||
<td className="px-2 py-2 text-right text-slate-400">{usd(t.execBuyVwap)}</td>
|
||||
<td className="px-2 py-2 text-right text-slate-400">{usd(t.execSellVwap)}</td>
|
||||
<td className="px-2 py-2 text-right text-slate-500">{usd(t.feeBuy + t.feeSell)}</td>
|
||||
<td className={clsx("px-2 py-2 text-right font-semibold", t.netProfit >= 0 ? "text-profit" : "text-loss")}>
|
||||
{t.netProfit >= 0 ? "+" : ""}
|
||||
{usd(t.netProfit)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { RebalanceEvent, Wallet } from "../types";
|
||||
import { Card } from "./Card";
|
||||
import { btc, timeOf, usd } from "../format";
|
||||
|
||||
interface Props {
|
||||
wallets: Wallet[];
|
||||
rebalances: RebalanceEvent[];
|
||||
}
|
||||
|
||||
export function Wallets({ wallets, rebalances }: Props): JSX.Element {
|
||||
return (
|
||||
<Card title="Wallets & Inventory" subtitle="Pre-positioned per venue">
|
||||
<div className="space-y-2">
|
||||
{wallets.map((w) => (
|
||||
<div
|
||||
key={w.exchange}
|
||||
className="flex items-center justify-between rounded-xl border border-ink-600/40 bg-ink-700/30 px-3 py-2"
|
||||
>
|
||||
<span className="font-display text-sm capitalize">{w.exchange}</span>
|
||||
<div className="flex gap-6 font-mono text-sm tabular-nums">
|
||||
<span className="text-slate-300">
|
||||
<span className="text-slate-500">$</span>
|
||||
{usd(w.usdt, 0)}
|
||||
</span>
|
||||
<span className="text-warn">
|
||||
{btc(w.btc, 4)} <span className="text-slate-500">BTC</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{rebalances.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h3 className="mb-2 text-[10px] uppercase tracking-widest text-slate-500">Recent Rebalances</h3>
|
||||
<div className="max-h-28 space-y-1 overflow-y-auto">
|
||||
{rebalances.map((r) => (
|
||||
<div key={r.id} className="flex items-center justify-between font-mono text-xs text-slate-500">
|
||||
<span>
|
||||
{r.fromExchange} → {r.toExchange} · {r.asset === "BTC" ? btc(r.amount, 4) : usd(r.amount, 0)} {r.asset}
|
||||
</span>
|
||||
<span className="text-slate-600">{timeOf(r.ts)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { PublicConfig, ExchangeId } from "./types";
|
||||
|
||||
export interface ConfigPatch {
|
||||
minNetProfitPct?: number;
|
||||
maxTradeBtc?: number;
|
||||
flickerConfirmMs?: number;
|
||||
activeExchanges?: Partial<Record<ExchangeId, boolean>>;
|
||||
}
|
||||
|
||||
async function parseJson<T>(res: Response): Promise<T> {
|
||||
const body = (await res.json()) as { success: boolean; data?: T; error?: string };
|
||||
if (!body.success) throw new Error(body.error ?? "request failed");
|
||||
return body.data as T;
|
||||
}
|
||||
|
||||
export async function getConfig(): Promise<PublicConfig> {
|
||||
const res = await fetch("/api/config");
|
||||
return parseJson<PublicConfig>(res);
|
||||
}
|
||||
|
||||
export async function patchConfig(patch: ConfigPatch): Promise<PublicConfig> {
|
||||
const res = await fetch("/api/config", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
return parseJson<PublicConfig>(res);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export function usd(n: number, digits = 2): string {
|
||||
return n.toLocaleString("en-US", { minimumFractionDigits: digits, maximumFractionDigits: digits });
|
||||
}
|
||||
|
||||
export function pct(fraction: number, digits = 3): string {
|
||||
return `${(fraction * 100).toFixed(digits)}%`;
|
||||
}
|
||||
|
||||
export function btc(n: number, digits = 5): string {
|
||||
return n.toFixed(digits);
|
||||
}
|
||||
|
||||
export function ago(ts: number, now: number): string {
|
||||
const s = Math.max(0, Math.round((now - ts) / 1000));
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.floor(s / 60);
|
||||
return `${m}m ${s % 60}s`;
|
||||
}
|
||||
|
||||
export function timeOf(ts: number): string {
|
||||
return new Date(ts).toLocaleTimeString("en-US", { hour12: false });
|
||||
}
|
||||
|
||||
export function clsx(...parts: Array<string | false | null | undefined>): string {
|
||||
return parts.filter(Boolean).join(" ");
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background:
|
||||
radial-gradient(1200px 600px at 80% -10%, rgba(91, 140, 255, 0.12), transparent 60%),
|
||||
radial-gradient(900px 500px at -10% 110%, rgba(61, 220, 151, 0.08), transparent 55%),
|
||||
#0a0e14;
|
||||
color: #d7e0ec;
|
||||
font-family: "Sora", ui-sans-serif, system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #2b3a4f;
|
||||
border-radius: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@keyframes flash {
|
||||
from {
|
||||
background-color: rgba(91, 140, 255, 0.18);
|
||||
}
|
||||
to {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
.flash {
|
||||
animation: flash 0.6s ease-out;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import "./index.css";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (!root) throw new Error("root element not found");
|
||||
|
||||
ReactDOM.createRoot(root).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,130 @@
|
||||
// Mirror of the backend StateSnapshot contract (see src/domain/entities/index.ts).
|
||||
|
||||
export type ExchangeId = "kraken" | "bybit" | "okx" | "binance";
|
||||
export type FeedStatus = "connecting" | "live" | "stale" | "down";
|
||||
export type CircuitState = "running" | "paused" | "tripped";
|
||||
|
||||
export type OpportunityStatus =
|
||||
| "executed"
|
||||
| "executed_partial"
|
||||
| "rejected_fees"
|
||||
| "rejected_liquidity"
|
||||
| "rejected_risk"
|
||||
| "rejected_flicker"
|
||||
| "rejected_stale"
|
||||
| "pending_confirm";
|
||||
|
||||
export interface BestQuote {
|
||||
exchange: ExchangeId;
|
||||
bid: number | null;
|
||||
bidQty: number | null;
|
||||
ask: number | null;
|
||||
askQty: number | null;
|
||||
recvTs: number | null;
|
||||
status: FeedStatus;
|
||||
ageMs: number | null;
|
||||
}
|
||||
|
||||
export interface Opportunity {
|
||||
id: string;
|
||||
ts: number;
|
||||
buyExchange: ExchangeId;
|
||||
sellExchange: ExchangeId;
|
||||
topBuyAsk: number;
|
||||
topSellBid: number;
|
||||
volumeBtc: number;
|
||||
buyVwap: number;
|
||||
sellVwap: number;
|
||||
grossSpread: number;
|
||||
grossSpreadPct: number;
|
||||
feeBuy: number;
|
||||
feeSell: number;
|
||||
netProfit: number;
|
||||
netProfitPct: number;
|
||||
status: OpportunityStatus;
|
||||
reason: string;
|
||||
demo: boolean;
|
||||
}
|
||||
|
||||
export interface Trade {
|
||||
id: string;
|
||||
ts: number;
|
||||
buyExchange: ExchangeId;
|
||||
sellExchange: ExchangeId;
|
||||
volumeBtc: number;
|
||||
requestedBtc: number;
|
||||
buyVwap: number;
|
||||
sellVwap: number;
|
||||
execBuyVwap: number;
|
||||
execSellVwap: number;
|
||||
feeBuy: number;
|
||||
feeSell: number;
|
||||
netProfit: number;
|
||||
netProfitPct: number;
|
||||
partial: boolean;
|
||||
demo: boolean;
|
||||
}
|
||||
|
||||
export interface Wallet {
|
||||
exchange: ExchangeId;
|
||||
usdt: number;
|
||||
btc: number;
|
||||
}
|
||||
|
||||
export interface RebalanceEvent {
|
||||
id: string;
|
||||
ts: number;
|
||||
fromExchange: ExchangeId;
|
||||
toExchange: ExchangeId;
|
||||
asset: "BTC" | "USDT";
|
||||
amount: number;
|
||||
withdrawalFee: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface EngineStats {
|
||||
uptimeMs: number;
|
||||
ticksProcessed: number;
|
||||
opportunitiesDetected: number;
|
||||
tradesExecuted: number;
|
||||
tradesRejected: number;
|
||||
realizedPnl: number;
|
||||
consecutiveLosses: number;
|
||||
circuit: CircuitState;
|
||||
demoMode: boolean;
|
||||
avgTickMs: number;
|
||||
}
|
||||
|
||||
export interface PnlPoint {
|
||||
ts: number;
|
||||
pnl: number;
|
||||
}
|
||||
|
||||
export interface PublicConfig {
|
||||
minNetProfitPct: number;
|
||||
maxTradeBtc: number;
|
||||
staleMs: number;
|
||||
flickerConfirmMs: number;
|
||||
latencyMs: number;
|
||||
activeExchanges: Record<ExchangeId, boolean>;
|
||||
defaults: {
|
||||
minNetProfitPct: number;
|
||||
maxTradeBtc: number;
|
||||
flickerConfirmMs: number;
|
||||
activeExchanges: Record<ExchangeId, boolean>;
|
||||
};
|
||||
takerFees: Record<ExchangeId, number>;
|
||||
withdrawalFeesBtc: Record<ExchangeId, number>;
|
||||
}
|
||||
|
||||
export interface StateSnapshot {
|
||||
ts: number;
|
||||
quotes: BestQuote[];
|
||||
wallets: Wallet[];
|
||||
stats: EngineStats;
|
||||
recentOpportunities: Opportunity[];
|
||||
recentTrades: Trade[];
|
||||
rebalances: RebalanceEvent[];
|
||||
pnlSeries: PnlPoint[];
|
||||
config: PublicConfig;
|
||||
}
|
||||
Reference in New Issue
Block a user