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:
co-authored by
Cursor
parent
908aefb2d8
commit
2e8744ccf0
@@ -0,0 +1,58 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { bootstrap } from "../../composition/bootstrap.js";
|
||||
import { runtime } from "../../infrastructure/config/runtime.js";
|
||||
import { FakeMarketDataFeedFactory } from "../../test-support/fake-market-data-feed.js";
|
||||
import { book, FixedClock } from "../../test-support/test-fakes.js";
|
||||
import { SyntheticFeed } from "../../infrastructure/demo/synthetic-feed.js";
|
||||
|
||||
const NOW = 2_000_000;
|
||||
|
||||
test("fixture feed drives detection through bootstrap without network", () => {
|
||||
runtime.demoMode = false;
|
||||
runtime.flickerConfirmMs = 0;
|
||||
runtime.minNetProfitPct = 0.0001;
|
||||
|
||||
const fixtures = [
|
||||
book("bybit", [{ price: 99_990, qty: 2 }], [{ price: 100_000, qty: 2 }], NOW),
|
||||
book("okx", [{ price: 100_600, qty: 2 }], [{ price: 100_610, qty: 2 }], NOW),
|
||||
book("kraken", [{ price: 100_200, qty: 2 }], [{ price: 100_210, qty: 2 }], NOW),
|
||||
];
|
||||
|
||||
const ctx = bootstrap({
|
||||
feedFactory: new FakeMarketDataFeedFactory(fixtures),
|
||||
demoFeed: new SyntheticFeed(),
|
||||
clock: new FixedClock(NOW),
|
||||
});
|
||||
|
||||
ctx.start();
|
||||
|
||||
const snap = ctx.application.getSnapshot();
|
||||
ctx.stop();
|
||||
|
||||
assert.ok(snap.stats.ticksProcessed >= 2, `ticks ${snap.stats.ticksProcessed}`);
|
||||
const executed = snap.recentOpportunities.filter((o) => o.status === "executed" || o.status === "executed_partial");
|
||||
assert.ok(executed.length >= 1, "expected at least one executed opportunity from fixture cross");
|
||||
assert.ok(snap.stats.tradesExecuted >= 1, `trades ${snap.stats.tradesExecuted}`);
|
||||
});
|
||||
|
||||
test("NDJSON-shaped fixture lines replay as order books", () => {
|
||||
const line = {
|
||||
ts: NOW,
|
||||
exchange: "bybit" as const,
|
||||
bids: [{ price: 99_000, qty: 1 }],
|
||||
asks: [{ price: 99_010, qty: 1 }],
|
||||
};
|
||||
const fixtures = [book(line.exchange, line.bids, line.asks, line.ts)];
|
||||
|
||||
runtime.demoMode = false;
|
||||
runtime.flickerConfirmMs = 0;
|
||||
|
||||
const ctx = bootstrap({
|
||||
feedFactory: new FakeMarketDataFeedFactory(fixtures),
|
||||
clock: new FixedClock(NOW),
|
||||
});
|
||||
ctx.start();
|
||||
assert.equal(ctx.application.getSnapshot().stats.ticksProcessed, 1);
|
||||
ctx.stop();
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { EXCHANGE_IDS, type ConfigPatch, type ExchangeId } from "../../domain/entities/index.js";
|
||||
import type { IQuoteBook, IRiskGate } from "../../domain/ports/ports.js";
|
||||
import type { StartMarketData } from "./start-market-data.js";
|
||||
|
||||
const MIN_PROFIT_PCT = 0.0001;
|
||||
const MAX_PROFIT_PCT = 0.01;
|
||||
const MIN_TRADE_BTC = 0.01;
|
||||
const MAX_TRADE_BTC = 1.0;
|
||||
const MAX_FLICKER_MS = 500;
|
||||
|
||||
export interface RuntimePort {
|
||||
demoMode: boolean;
|
||||
recordFeed: boolean;
|
||||
minNetProfitPct: number;
|
||||
maxTradeBtc: number;
|
||||
flickerConfirmMs: number;
|
||||
activeExchanges: Record<ExchangeId, boolean>;
|
||||
}
|
||||
|
||||
export interface FeedRecorderControl {
|
||||
close(): void;
|
||||
}
|
||||
|
||||
/** Operator controls — mutates runtime via injected port, never reads process.env. */
|
||||
export class ControlService {
|
||||
constructor(
|
||||
private readonly runtime: RuntimePort,
|
||||
private readonly risk: IRiskGate,
|
||||
private readonly quotes: IQuoteBook,
|
||||
private readonly resetState: () => void,
|
||||
private readonly marketData: StartMarketData,
|
||||
private readonly recorder: FeedRecorderControl,
|
||||
) {}
|
||||
|
||||
pause(): void {
|
||||
this.risk.pause();
|
||||
}
|
||||
|
||||
resume(): void {
|
||||
this.risk.resume();
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.resetState();
|
||||
}
|
||||
|
||||
setDemoMode(enabled: boolean): void {
|
||||
if (this.runtime.demoMode === enabled) return;
|
||||
this.runtime.demoMode = enabled;
|
||||
this.marketData.switchDemo(enabled);
|
||||
}
|
||||
|
||||
setRecordFeed(enabled: boolean): void {
|
||||
this.runtime.recordFeed = enabled;
|
||||
if (!enabled) this.recorder.close();
|
||||
}
|
||||
|
||||
setThreshold(pct: number): string | null {
|
||||
if (!Number.isFinite(pct) || pct < MIN_PROFIT_PCT || pct > MAX_PROFIT_PCT) {
|
||||
return `minNetProfitPct must be between ${MIN_PROFIT_PCT} and ${MAX_PROFIT_PCT}`;
|
||||
}
|
||||
this.runtime.minNetProfitPct = pct;
|
||||
return null;
|
||||
}
|
||||
|
||||
setMaxTradeBtc(btc: number): string | null {
|
||||
if (!Number.isFinite(btc) || btc < MIN_TRADE_BTC || btc > MAX_TRADE_BTC) {
|
||||
return `maxTradeBtc must be between ${MIN_TRADE_BTC} and ${MAX_TRADE_BTC}`;
|
||||
}
|
||||
this.runtime.maxTradeBtc = btc;
|
||||
return null;
|
||||
}
|
||||
|
||||
patchConfig(patch: ConfigPatch): string | null {
|
||||
if (patch.minNetProfitPct !== undefined) {
|
||||
const pct = patch.minNetProfitPct;
|
||||
if (!Number.isFinite(pct) || pct < MIN_PROFIT_PCT || pct > MAX_PROFIT_PCT) {
|
||||
return `minNetProfitPct must be between ${MIN_PROFIT_PCT} and ${MAX_PROFIT_PCT}`;
|
||||
}
|
||||
this.runtime.minNetProfitPct = pct;
|
||||
}
|
||||
|
||||
if (patch.maxTradeBtc !== undefined) {
|
||||
const btc = patch.maxTradeBtc;
|
||||
if (!Number.isFinite(btc) || btc < MIN_TRADE_BTC || btc > MAX_TRADE_BTC) {
|
||||
return `maxTradeBtc must be between ${MIN_TRADE_BTC} and ${MAX_TRADE_BTC}`;
|
||||
}
|
||||
this.runtime.maxTradeBtc = btc;
|
||||
}
|
||||
|
||||
if (patch.flickerConfirmMs !== undefined) {
|
||||
const ms = patch.flickerConfirmMs;
|
||||
if (!Number.isFinite(ms) || ms < 0 || ms > MAX_FLICKER_MS) {
|
||||
return `flickerConfirmMs must be between 0 and ${MAX_FLICKER_MS}`;
|
||||
}
|
||||
this.runtime.flickerConfirmMs = ms;
|
||||
}
|
||||
|
||||
if (patch.activeExchanges !== undefined) {
|
||||
const next = { ...this.runtime.activeExchanges };
|
||||
for (const id of EXCHANGE_IDS) {
|
||||
const enabled = patch.activeExchanges[id];
|
||||
if (enabled !== undefined) next[id] = enabled;
|
||||
}
|
||||
if (!EXCHANGE_IDS.some((id) => next[id])) {
|
||||
return "at least one exchange must remain active";
|
||||
}
|
||||
for (const id of EXCHANGE_IDS) {
|
||||
if (this.runtime.activeExchanges[id] === next[id]) continue;
|
||||
this.runtime.activeExchanges[id] = next[id];
|
||||
this.marketData.applyExchangeToggle(id, next[id], (ex) => this.clearExchangeBook(ex));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private clearExchangeBook(exchange: ExchangeId): void {
|
||||
this.quotes.update({
|
||||
exchange,
|
||||
bids: [],
|
||||
asks: [],
|
||||
recvTs: 0,
|
||||
exchangeTs: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Opportunity } from "../../domain/entities/index.js";
|
||||
import type { IOpportunityExecutor, IRiskGate, IStateStore, ITradeExecutor } from "../../domain/ports/ports.js";
|
||||
|
||||
/** Risk gate + simulated execution + store update after a confirmed opportunity. */
|
||||
export class ExecuteArbitrage implements IOpportunityExecutor {
|
||||
constructor(
|
||||
private readonly executor: ITradeExecutor,
|
||||
private readonly store: IStateStore,
|
||||
private readonly risk: IRiskGate,
|
||||
) {}
|
||||
|
||||
execute(op: Opportunity, now: number): void {
|
||||
const trade = this.executor.execute(op, now);
|
||||
this.store.addTrade(trade);
|
||||
this.risk.evaluate(now);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { OrderBook } from "../../domain/entities/index.js";
|
||||
import type { ArbitrageEngine } from "../../domain/services/arbitrage-engine.js";
|
||||
|
||||
export interface FeedRecorderPort {
|
||||
record(book: OrderBook): void;
|
||||
}
|
||||
|
||||
/** Hot path: record optional NDJSON, then drive detection on the updated book. */
|
||||
export class ProcessOrderBookUpdate {
|
||||
constructor(
|
||||
private readonly engine: ArbitrageEngine,
|
||||
private readonly recorder: FeedRecorderPort,
|
||||
private readonly isExchangeActive: (exchange: OrderBook["exchange"]) => boolean,
|
||||
) {}
|
||||
|
||||
run(book: OrderBook): void {
|
||||
if (!this.isExchangeActive(book.exchange)) return;
|
||||
this.recorder.record(book);
|
||||
this.engine.onBook(book);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { IRebalancer } from "../../domain/ports/ports.js";
|
||||
|
||||
/** Periodic inventory correction (withdrawal fee only here, not per trade). */
|
||||
export class RebalanceInventory {
|
||||
constructor(private readonly rebalancer: IRebalancer) {}
|
||||
|
||||
tick(now: number): void {
|
||||
this.rebalancer.tick(now);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { EXCHANGE_IDS, type ExchangeId } from "../../domain/entities/index.js";
|
||||
import type { MarketDataFeed, MarketDataFeedFactory } from "../../domain/ports/ports.js";
|
||||
|
||||
export type FeedMode = "real" | "demo" | "stopped";
|
||||
|
||||
export interface DemoFeedPort {
|
||||
start(): void;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts real WS connectors or the synthetic demo feed. Listeners are wired
|
||||
* once in composition; this class only controls lifecycle.
|
||||
*/
|
||||
export class StartMarketData {
|
||||
private connectors = new Map<ExchangeId, MarketDataFeed>();
|
||||
private mode: FeedMode = "stopped";
|
||||
|
||||
constructor(
|
||||
private readonly feedFactory: MarketDataFeedFactory,
|
||||
private readonly demoFeed: DemoFeedPort,
|
||||
private readonly onBook: (book: import("../../domain/entities/index.js").OrderBook) => void,
|
||||
private readonly isDemoMode: () => boolean,
|
||||
private readonly isExchangeActive: (id: ExchangeId) => boolean,
|
||||
) {}
|
||||
|
||||
getMode(): FeedMode {
|
||||
return this.mode;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.isDemoMode()) {
|
||||
this.mode = "demo";
|
||||
this.demoFeed.start();
|
||||
} else {
|
||||
this.mode = "real";
|
||||
for (const id of EXCHANGE_IDS) {
|
||||
if (this.isExchangeActive(id)) this.startConnector(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
startConnector(id: ExchangeId): void {
|
||||
if (this.connectors.has(id)) return;
|
||||
const connector = this.feedFactory.create(id);
|
||||
connector.onBook((book) => this.onBook(book));
|
||||
connector.start();
|
||||
this.connectors.set(id, connector);
|
||||
}
|
||||
|
||||
stopConnector(id: ExchangeId): void {
|
||||
const connector = this.connectors.get(id);
|
||||
if (!connector) return;
|
||||
connector.stop();
|
||||
this.connectors.delete(id);
|
||||
}
|
||||
|
||||
switchDemo(enabled: boolean): void {
|
||||
this.stop();
|
||||
if (enabled) {
|
||||
this.mode = "demo";
|
||||
this.demoFeed.start();
|
||||
} else {
|
||||
this.mode = "real";
|
||||
for (const id of EXCHANGE_IDS) {
|
||||
if (this.isExchangeActive(id)) this.startConnector(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
applyExchangeToggle(id: ExchangeId, enabled: boolean, clearBook: (exchange: ExchangeId) => void): void {
|
||||
if (this.mode === "demo") {
|
||||
if (!enabled) clearBook(id);
|
||||
return;
|
||||
}
|
||||
if (enabled) {
|
||||
this.startConnector(id);
|
||||
} else {
|
||||
this.stopConnector(id);
|
||||
clearBook(id);
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.demoFeed.stop();
|
||||
for (const c of this.connectors.values()) c.stop();
|
||||
this.connectors.clear();
|
||||
this.mode = "stopped";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { StartMarketData } from "./start-market-data.js";
|
||||
|
||||
export class StopMarketData {
|
||||
constructor(private readonly marketData: StartMarketData) {}
|
||||
|
||||
run(): void {
|
||||
this.marketData.stop();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ArbitrageEngine } from "../../domain/services/arbitrage-engine.js";
|
||||
import type { RebalanceInventory } from "./rebalance-inventory.js";
|
||||
|
||||
/** Periodic risk cooldown reset + rebalancer tick (independent of feed). */
|
||||
export class TickRiskAndRebalance {
|
||||
constructor(
|
||||
private readonly engine: ArbitrageEngine,
|
||||
private readonly rebalance: RebalanceInventory,
|
||||
) {}
|
||||
|
||||
tick(now: number): void {
|
||||
this.engine.tick(now);
|
||||
this.rebalance.tick(now);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user