2e8744ccf0
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>
194 lines
6.4 KiB
TypeScript
194 lines
6.4 KiB
TypeScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { ArbitrageEngine } from "./arbitrage-engine.js";
|
|
import { ExecuteArbitrage } from "../../application/use-cases/execute-arbitrage.js";
|
|
import {
|
|
FakeExecutor,
|
|
FakeInventory,
|
|
FakePolicy,
|
|
FakeQuoteBook,
|
|
FakeRiskGate,
|
|
FakeStore,
|
|
FixedClock,
|
|
SeqIds,
|
|
book,
|
|
} from "../../test-support/test-fakes.js";
|
|
import type { ExchangeId, OpportunityStatus } from "../entities/index.js";
|
|
|
|
const NOW = 1_000_000;
|
|
|
|
interface Harness {
|
|
engine: ArbitrageEngine;
|
|
quotes: FakeQuoteBook;
|
|
store: FakeStore;
|
|
risk: FakeRiskGate;
|
|
executor: FakeExecutor;
|
|
policy: FakePolicy;
|
|
}
|
|
|
|
function harness(opts: { recvTs?: number; buyAsk?: number; sellBid?: number; flickerMs?: number } = {}): Harness {
|
|
const { recvTs = NOW, buyAsk = 100000, sellBid = 100600, flickerMs = 0 } = opts;
|
|
const quotes = new FakeQuoteBook();
|
|
const store = new FakeStore();
|
|
const risk = new FakeRiskGate(true);
|
|
const executor = new FakeExecutor(1);
|
|
const policy = new FakePolicy();
|
|
policy.maxTrade = 0.1;
|
|
policy.flickerMs = flickerMs;
|
|
|
|
quotes.update(book("bybit", [{ price: buyAsk - 10, qty: 1 }], [{ price: buyAsk, qty: 1 }], recvTs));
|
|
quotes.update(book("okx", [{ price: sellBid, qty: 1 }], [{ price: sellBid + 10, qty: 1 }], recvTs));
|
|
|
|
const opportunityExecutor = new ExecuteArbitrage(executor, store, risk);
|
|
const engine = new ArbitrageEngine({
|
|
quotes,
|
|
inventory: new FakeInventory(),
|
|
store,
|
|
risk,
|
|
opportunityExecutor,
|
|
policy,
|
|
clock: new FixedClock(NOW),
|
|
ids: new SeqIds(),
|
|
});
|
|
return { engine, quotes, store, risk, executor, policy };
|
|
}
|
|
|
|
function statusesFor(h: Harness, buy: ExchangeId, sell: ExchangeId): OpportunityStatus[] {
|
|
return h.store.opportunities
|
|
.filter((o) => o.buyExchange === buy && o.sellExchange === sell)
|
|
.map((o) => o.status);
|
|
}
|
|
|
|
function trigger(h: Harness): void {
|
|
h.engine.onBook(h.quotes.getBook("okx")!);
|
|
}
|
|
|
|
test("executes a clean, net-profitable, fresh, confirmed cross", () => {
|
|
const h = harness({ flickerMs: 0 });
|
|
trigger(h);
|
|
|
|
assert.deepEqual(statusesFor(h, "bybit", "okx"), ["executed"]);
|
|
assert.equal(h.executor.calls.length, 1);
|
|
assert.equal(h.store.trades.length, 1);
|
|
assert.equal(h.risk.evaluations.length, 1);
|
|
});
|
|
|
|
test("rejected_fees when the net edge is below the threshold", () => {
|
|
const h = harness({ buyAsk: 100000, sellBid: 100100, flickerMs: 0 });
|
|
trigger(h);
|
|
|
|
assert.deepEqual(statusesFor(h, "bybit", "okx"), ["rejected_fees"]);
|
|
assert.equal(h.executor.calls.length, 0);
|
|
assert.equal(h.store.trades.length, 0);
|
|
});
|
|
|
|
test("rejected_stale when a crossing quote is older than staleMs", () => {
|
|
const h = harness({ recvTs: NOW - 5000, flickerMs: 0 });
|
|
trigger(h);
|
|
|
|
assert.deepEqual(statusesFor(h, "bybit", "okx"), ["rejected_stale"]);
|
|
assert.equal(h.executor.calls.length, 0);
|
|
});
|
|
|
|
test("anti-flicker: first profitable tick is pending_confirm, not executed", () => {
|
|
const h = harness({ flickerMs: 150 });
|
|
trigger(h);
|
|
|
|
assert.deepEqual(statusesFor(h, "bybit", "okx"), ["pending_confirm"]);
|
|
assert.equal(h.executor.calls.length, 0);
|
|
});
|
|
|
|
test("does not execute while the risk gate is closed", () => {
|
|
const h = harness({ flickerMs: 0 });
|
|
h.risk.allow = false;
|
|
trigger(h);
|
|
|
|
assert.deepEqual(statusesFor(h, "bybit", "okx"), ["rejected_risk"]);
|
|
assert.equal(h.executor.calls.length, 0);
|
|
});
|
|
|
|
test("counts ticks processed", () => {
|
|
const h = harness({ flickerMs: 0 });
|
|
trigger(h);
|
|
assert.equal(h.store.ticksProcessed, 1);
|
|
});
|
|
|
|
test("executes only the highest netProfit opportunity when multiple pairs confirm in one tick", () => {
|
|
const quotes = new FakeQuoteBook();
|
|
const store = new FakeStore();
|
|
const risk = new FakeRiskGate(true);
|
|
const executor = new FakeExecutor(1);
|
|
const policy = new FakePolicy();
|
|
policy.maxTrade = 0.1;
|
|
policy.flickerMs = 0;
|
|
|
|
const recvTs = NOW;
|
|
// Small edge: buy bybit, sell okx
|
|
quotes.update(book("bybit", [{ price: 99990, qty: 1 }], [{ price: 100000, qty: 1 }], recvTs));
|
|
quotes.update(book("okx", [{ price: 100600, qty: 1 }], [{ price: 100610, qty: 1 }], recvTs));
|
|
// Large edge: buy kraken (cheaper ask), sell bybit
|
|
quotes.update(book("kraken", [{ price: 99400, qty: 1 }], [{ price: 99500, qty: 1 }], recvTs));
|
|
quotes.update(book("bybit", [{ price: 101500, qty: 1 }], [{ price: 101510, qty: 1 }], recvTs));
|
|
|
|
const opportunityExecutor = new ExecuteArbitrage(executor, store, risk);
|
|
const engine = new ArbitrageEngine({
|
|
quotes,
|
|
inventory: new FakeInventory(),
|
|
store,
|
|
risk,
|
|
opportunityExecutor,
|
|
policy,
|
|
clock: new FixedClock(NOW),
|
|
ids: new SeqIds(),
|
|
});
|
|
|
|
engine.onBook(quotes.getBook("kraken")!);
|
|
|
|
assert.equal(executor.calls.length, 1);
|
|
assert.equal(executor.calls[0]!.op.buyExchange, "kraken");
|
|
assert.equal(executor.calls[0]!.op.sellExchange, "bybit");
|
|
assert.equal(statusesFor({ engine, quotes, store, risk, executor, policy }, "bybit", "okx").length, 0);
|
|
assert.equal(store.trades.length, 1);
|
|
});
|
|
|
|
test("defers lower-profit pair to a later tick after the winner executes", () => {
|
|
const quotes = new FakeQuoteBook();
|
|
const store = new FakeStore();
|
|
const risk = new FakeRiskGate(true);
|
|
const executor = new FakeExecutor(1);
|
|
const policy = new FakePolicy();
|
|
policy.maxTrade = 0.1;
|
|
policy.flickerMs = 0;
|
|
|
|
const recvTs = NOW;
|
|
quotes.update(book("okx", [{ price: 100600, qty: 1 }], [{ price: 100610, qty: 1 }], recvTs));
|
|
quotes.update(book("kraken", [{ price: 99400, qty: 1 }], [{ price: 99500, qty: 1 }], recvTs));
|
|
quotes.update(book("bybit", [{ price: 101500, qty: 1 }], [{ price: 101510, qty: 1 }], recvTs));
|
|
|
|
const clock = new FixedClock(NOW);
|
|
const opportunityExecutor = new ExecuteArbitrage(executor, store, risk);
|
|
const engine = new ArbitrageEngine({
|
|
quotes,
|
|
inventory: new FakeInventory(),
|
|
store,
|
|
risk,
|
|
opportunityExecutor,
|
|
policy,
|
|
clock,
|
|
ids: new SeqIds(),
|
|
});
|
|
|
|
engine.onBook(quotes.getBook("kraken")!);
|
|
assert.equal(executor.calls.length, 1);
|
|
|
|
// Remove other crosses; bybit→okx should execute (anti-flicker already confirmed).
|
|
quotes.update(book("kraken", [{ price: 99400, qty: 1 }], [{ price: 101600, qty: 1 }], recvTs));
|
|
quotes.update(book("bybit", [{ price: 99990, qty: 1 }], [{ price: 100000, qty: 1 }], recvTs));
|
|
clock.t = NOW + 100;
|
|
engine.onBook(quotes.getBook("okx")!);
|
|
|
|
assert.equal(executor.calls.length, 2);
|
|
assert.equal(executor.calls[1]!.op.buyExchange, "bybit");
|
|
assert.equal(executor.calls[1]!.op.sellExchange, "okx");
|
|
});
|