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:
Mauricio Barragan
2026-06-08 21:03:33 -06:00
parent 908aefb2d8
commit 2e8744ccf0
87 changed files with 10414 additions and 0 deletions
+58
View File
@@ -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>
);
}
+43
View File
@@ -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 }),
};
+30
View File
@@ -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>
);
}
+200
View File
@@ -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>
);
}
+105
View File
@@ -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>
);
}
+75
View File
@@ -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>
);
}
+72
View File
@@ -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>
);
}
+103
View File
@@ -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>
);
}
+76
View File
@@ -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>
);
}
+59
View File
@@ -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>
);
}
+50
View File
@@ -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>
);
}
+28
View File
@@ -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);
}
+26
View File
@@ -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(" ");
}
+48
View File
@@ -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;
}
+13
View File
@@ -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>,
);
+130
View File
@@ -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;
}