mirror of
https://github.com/mauricioabh/arbpulse.git
synced 2026-08-07 12:37:44 +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,184 @@
|
||||
/**
|
||||
* Domain types for the cross-exchange BTC arbitrage engine.
|
||||
* All prices are quoted in USDT (BTC/USDT on every exchange — apples-to-apples,
|
||||
* no USD/USDT basis distortion).
|
||||
*/
|
||||
|
||||
export type ExchangeId = "kraken" | "bybit" | "okx" | "binance";
|
||||
|
||||
export const EXCHANGE_IDS: ExchangeId[] = ["kraken", "bybit", "okx", "binance"];
|
||||
|
||||
/** A single order book price level. */
|
||||
export interface Level {
|
||||
price: number;
|
||||
qty: number;
|
||||
}
|
||||
|
||||
/** Normalized order book for one exchange. Bids desc, asks asc. */
|
||||
export interface OrderBook {
|
||||
exchange: ExchangeId;
|
||||
bids: Level[];
|
||||
asks: Level[];
|
||||
/** Local receive timestamp (ms epoch). */
|
||||
recvTs: number;
|
||||
/** Exchange-provided timestamp if available (ms epoch). */
|
||||
exchangeTs: number | null;
|
||||
}
|
||||
|
||||
/** Connection status of an exchange feed. */
|
||||
export type FeedStatus = "connecting" | "live" | "stale" | "down";
|
||||
|
||||
/** Best bid/ask snapshot exposed to the UI. */
|
||||
export interface BestQuote {
|
||||
exchange: ExchangeId;
|
||||
bid: number | null;
|
||||
bidQty: number | null;
|
||||
ask: number | null;
|
||||
askQty: number | null;
|
||||
recvTs: number | null;
|
||||
status: FeedStatus;
|
||||
/** ms since last update. */
|
||||
ageMs: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why an opportunity was or wasn't executed.
|
||||
* Exhaustive union — handle every case.
|
||||
*/
|
||||
export type OpportunityStatus =
|
||||
| "executed"
|
||||
| "executed_partial"
|
||||
| "rejected_fees"
|
||||
| "rejected_liquidity"
|
||||
| "rejected_risk"
|
||||
| "rejected_flicker"
|
||||
| "rejected_stale"
|
||||
| "pending_confirm";
|
||||
|
||||
export interface Opportunity {
|
||||
id: string;
|
||||
ts: number;
|
||||
buyExchange: ExchangeId;
|
||||
sellExchange: ExchangeId;
|
||||
/** Best (top-of-book) ask on the buy side, before walking depth. */
|
||||
topBuyAsk: number;
|
||||
/** Best (top-of-book) bid on the sell side, before walking depth. */
|
||||
topSellBid: number;
|
||||
/** Volume actually evaluated (BTC), after liquidity + wallet caps. */
|
||||
volumeBtc: number;
|
||||
/** Volume-weighted average buy price for volumeBtc. */
|
||||
buyVwap: number;
|
||||
/** Volume-weighted average sell price for volumeBtc. */
|
||||
sellVwap: number;
|
||||
grossSpread: number;
|
||||
grossSpreadPct: number;
|
||||
feeBuy: number;
|
||||
feeSell: number;
|
||||
netProfit: number;
|
||||
netProfitPct: number;
|
||||
status: OpportunityStatus;
|
||||
reason: string;
|
||||
/** True when generated by the synthetic demo injector. */
|
||||
demo: boolean;
|
||||
}
|
||||
|
||||
/** An executed (simulated) trade. */
|
||||
export interface Trade {
|
||||
id: string;
|
||||
ts: number;
|
||||
buyExchange: ExchangeId;
|
||||
sellExchange: ExchangeId;
|
||||
volumeBtc: number;
|
||||
requestedBtc: number;
|
||||
buyVwap: number;
|
||||
sellVwap: number;
|
||||
/** Buy VWAP after simulated latency drift. */
|
||||
execBuyVwap: number;
|
||||
/** Sell VWAP after simulated latency drift. */
|
||||
execSellVwap: number;
|
||||
feeBuy: number;
|
||||
feeSell: number;
|
||||
/** Net realized P&L in USDT (negative = loss). */
|
||||
netProfit: number;
|
||||
netProfitPct: number;
|
||||
partial: boolean;
|
||||
demo: boolean;
|
||||
}
|
||||
|
||||
/** Per-exchange simulated wallet (pre-positioned inventory model). */
|
||||
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 type CircuitState = "running" | "paused" | "tripped";
|
||||
|
||||
export interface EngineStats {
|
||||
uptimeMs: number;
|
||||
ticksProcessed: number;
|
||||
opportunitiesDetected: number;
|
||||
tradesExecuted: number;
|
||||
tradesRejected: number;
|
||||
realizedPnl: number;
|
||||
consecutiveLosses: number;
|
||||
circuit: CircuitState;
|
||||
demoMode: boolean;
|
||||
/** Average engine processing time per tick (ms). */
|
||||
avgTickMs: number;
|
||||
}
|
||||
|
||||
/** Full snapshot pushed to the dashboard over SSE. */
|
||||
export interface StateSnapshot {
|
||||
ts: number;
|
||||
quotes: BestQuote[];
|
||||
wallets: Wallet[];
|
||||
stats: EngineStats;
|
||||
recentOpportunities: Opportunity[];
|
||||
recentTrades: Trade[];
|
||||
rebalances: RebalanceEvent[];
|
||||
pnlSeries: PnlPoint[];
|
||||
config: PublicConfig;
|
||||
}
|
||||
|
||||
export interface PnlPoint {
|
||||
ts: number;
|
||||
pnl: number;
|
||||
}
|
||||
|
||||
/** Partial update body for PATCH /api/config. */
|
||||
export interface ConfigPatch {
|
||||
minNetProfitPct?: number;
|
||||
maxTradeBtc?: number;
|
||||
flickerConfirmMs?: number;
|
||||
activeExchanges?: Partial<Record<ExchangeId, boolean>>;
|
||||
}
|
||||
|
||||
/** Engine config surfaced to the UI (no secrets). */
|
||||
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>;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type {
|
||||
CircuitState,
|
||||
ExchangeId,
|
||||
Opportunity,
|
||||
OrderBook,
|
||||
RebalanceEvent,
|
||||
Trade,
|
||||
Wallet,
|
||||
} from "../entities/index.js";
|
||||
|
||||
/**
|
||||
* Hexagonal ports for the arbitrage core. Concrete adapters live in
|
||||
* infrastructure; application use cases orchestrate them. Core domain services
|
||||
* depend ONLY on these interfaces.
|
||||
*/
|
||||
|
||||
/** Wall-clock source (injectable for deterministic tests). */
|
||||
export interface IClock {
|
||||
now(): number;
|
||||
}
|
||||
|
||||
/** Opaque event-id generator. */
|
||||
export interface IIdGenerator {
|
||||
next(prefix: string): string;
|
||||
}
|
||||
|
||||
/** Latest normalized order book per exchange + staleness. */
|
||||
export interface IQuoteBook {
|
||||
update(book: OrderBook): void;
|
||||
getBook(exchange: ExchangeId): OrderBook | undefined;
|
||||
isFresh(exchange: ExchangeId, now: number): boolean;
|
||||
}
|
||||
|
||||
/** Per-exchange pre-positioned inventory (USDT + BTC). */
|
||||
export interface IInventory {
|
||||
get(exchange: ExchangeId): Wallet;
|
||||
maxBuyableBtc(exchange: ExchangeId, vwapWithFee: number): number;
|
||||
sellableBtc(exchange: ExchangeId): number;
|
||||
applyBuy(exchange: ExchangeId, btc: number, quoteCost: number): void;
|
||||
applySell(exchange: ExchangeId, btc: number, quoteProceeds: number): void;
|
||||
applyTransfer(
|
||||
from: ExchangeId,
|
||||
to: ExchangeId,
|
||||
asset: "BTC" | "USDT",
|
||||
amount: number,
|
||||
fee: number,
|
||||
): void;
|
||||
}
|
||||
|
||||
/** Simulates execution of a validated opportunity into a realized trade. */
|
||||
export interface ITradeExecutor {
|
||||
execute(op: Opportunity, now: number): Trade;
|
||||
}
|
||||
|
||||
/** Post-detection execution pipeline (simulated fill + store + risk). */
|
||||
export interface IOpportunityExecutor {
|
||||
execute(op: Opportunity, now: number): void;
|
||||
}
|
||||
|
||||
/** Circuit breaker / execution gate. */
|
||||
export interface IRiskGate {
|
||||
canExecute(): boolean;
|
||||
evaluate(now: number): void;
|
||||
tick(now: number): void;
|
||||
pause(): void;
|
||||
resume(): void;
|
||||
}
|
||||
|
||||
/** In-memory state store (history + counters + P&L curve). */
|
||||
export interface IStateStore {
|
||||
ticksProcessed: number;
|
||||
tradesRejected: number;
|
||||
circuit: CircuitState;
|
||||
consecutiveLosses: number;
|
||||
recordTickTime(ms: number): void;
|
||||
addOpportunity(op: Opportunity): void;
|
||||
addTrade(trade: Trade): void;
|
||||
addRebalance(event: RebalanceEvent): void;
|
||||
}
|
||||
|
||||
/** A market-data source emitting normalized order books. */
|
||||
export interface MarketDataFeed {
|
||||
onBook(listener: (book: OrderBook) => void): void;
|
||||
start(): void;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
/** Builds a per-exchange market-data feed. */
|
||||
export interface MarketDataFeedFactory {
|
||||
create(id: ExchangeId): MarketDataFeed;
|
||||
}
|
||||
|
||||
/** Periodic inventory drift correction between venues. */
|
||||
export interface IRebalancer {
|
||||
tick(now: number): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trading policy: fees, thresholds and mode flags. Methods (not fields) so
|
||||
* live-tunable values are read fresh on each call.
|
||||
*/
|
||||
export interface TradingPolicy {
|
||||
takerFee(exchange: ExchangeId): number;
|
||||
withdrawalFeeBtc(exchange: ExchangeId): number;
|
||||
minNetProfitPct(): number;
|
||||
maxTradeBtc(): number;
|
||||
flickerConfirmMs(): number;
|
||||
latencySlippageBps(): number;
|
||||
circuitBreakerLosses(): number;
|
||||
circuitBreakerCooldownMs(): number;
|
||||
rebalanceIntervalMs(): number;
|
||||
rebalanceMinBtc(): number;
|
||||
rebalanceMinUsdt(): number;
|
||||
isDemo(): boolean;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
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");
|
||||
});
|
||||
@@ -0,0 +1,289 @@
|
||||
import { walkBook, totalDepthBtc } from "./vwap.js";
|
||||
import {
|
||||
EXCHANGE_IDS,
|
||||
type ExchangeId,
|
||||
type Opportunity,
|
||||
type OpportunityStatus,
|
||||
type OrderBook,
|
||||
} from "../entities/index.js";
|
||||
import type {
|
||||
IClock,
|
||||
IIdGenerator,
|
||||
IInventory,
|
||||
IOpportunityExecutor,
|
||||
IQuoteBook,
|
||||
IRiskGate,
|
||||
IStateStore,
|
||||
TradingPolicy,
|
||||
} from "../ports/ports.js";
|
||||
import { netProfit, netProfitPct, takerFeeCost } from "./pricing.js";
|
||||
|
||||
const DUST_BTC = 1e-5;
|
||||
const REJECT_THROTTLE_MS = 800;
|
||||
|
||||
/** Scored pair ready for execution — emitted and executed only if selected as best. */
|
||||
interface ExecutableCandidate {
|
||||
buy: ExchangeId;
|
||||
sell: ExchangeId;
|
||||
topAsk: number;
|
||||
topBid: number;
|
||||
volume: number;
|
||||
buyVwap: number;
|
||||
sellVwap: number;
|
||||
feeBuy: number;
|
||||
feeSell: number;
|
||||
netProfit: number;
|
||||
netProfitPct: number;
|
||||
partial: boolean;
|
||||
now: number;
|
||||
}
|
||||
|
||||
/** Collaborators are ports (interfaces), never concrete classes. */
|
||||
export interface EngineDeps {
|
||||
quotes: IQuoteBook;
|
||||
inventory: IInventory;
|
||||
store: IStateStore;
|
||||
risk: IRiskGate;
|
||||
opportunityExecutor: IOpportunityExecutor;
|
||||
policy: TradingPolicy;
|
||||
clock: IClock;
|
||||
ids: IIdGenerator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core arbitrage detection. On every order-book tick it re-evaluates all
|
||||
* ordered exchange pairs (buy on A, sell on B).
|
||||
*/
|
||||
export class ArbitrageEngine {
|
||||
private pending = new Map<string, number>();
|
||||
private lastEmit = new Map<string, number>();
|
||||
|
||||
constructor(private readonly deps: EngineDeps) {}
|
||||
|
||||
onBook(book: OrderBook): void {
|
||||
const start = performance.now();
|
||||
this.deps.quotes.update(book);
|
||||
this.deps.store.ticksProcessed += 1;
|
||||
this.evaluate(this.deps.clock.now());
|
||||
this.deps.store.recordTickTime(performance.now() - start);
|
||||
}
|
||||
|
||||
tick(now: number): void {
|
||||
this.deps.risk.tick(now);
|
||||
}
|
||||
|
||||
private evaluate(now: number): void {
|
||||
const executables: ExecutableCandidate[] = [];
|
||||
for (const buy of EXCHANGE_IDS) {
|
||||
for (const sell of EXCHANGE_IDS) {
|
||||
if (buy === sell) continue;
|
||||
const candidate = this.scorePair(buy, sell, now);
|
||||
if (candidate) executables.push(candidate);
|
||||
}
|
||||
}
|
||||
if (executables.length === 0) return;
|
||||
|
||||
const best = executables.reduce((a, b) => (this.compareCandidates(a, b) < 0 ? b : a));
|
||||
const key = `${best.buy}->${best.sell}`;
|
||||
const status: OpportunityStatus = best.partial ? "executed_partial" : "executed";
|
||||
const opportunity = this.emit(
|
||||
{ ...best, status, reason: "executed" },
|
||||
false,
|
||||
);
|
||||
this.deps.opportunityExecutor.execute(opportunity, now);
|
||||
this.pending.delete(key);
|
||||
this.lastEmit.set(key, now);
|
||||
}
|
||||
|
||||
/** Higher netProfit wins; tie-break netProfitPct, then lexicographic (buy, sell). */
|
||||
private compareCandidates(a: ExecutableCandidate, b: ExecutableCandidate): number {
|
||||
if (a.netProfit !== b.netProfit) return a.netProfit - b.netProfit;
|
||||
if (a.netProfitPct !== b.netProfitPct) return a.netProfitPct - b.netProfitPct;
|
||||
if (a.buy !== b.buy) return a.buy < b.buy ? -1 : 1;
|
||||
return a.sell < b.sell ? -1 : a.sell === b.sell ? 0 : 1;
|
||||
}
|
||||
|
||||
/** Score one pair; emit rejections and pending_confirm inline; return executable if confirmed. */
|
||||
private scorePair(buy: ExchangeId, sell: ExchangeId, now: number): ExecutableCandidate | null {
|
||||
const key = `${buy}->${sell}`;
|
||||
const buyBook = this.deps.quotes.getBook(buy);
|
||||
const sellBook = this.deps.quotes.getBook(sell);
|
||||
const topAsk = buyBook?.asks[0];
|
||||
const topBid = sellBook?.bids[0];
|
||||
|
||||
if (!buyBook || !sellBook || !topAsk || !topBid) {
|
||||
this.pending.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (topAsk.price >= topBid.price) {
|
||||
this.pending.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!this.deps.quotes.isFresh(buy, now) || !this.deps.quotes.isFresh(sell, now)) {
|
||||
this.emitRejection(buy, sell, topAsk.price, topBid.price, "rejected_stale", "stale quote", now);
|
||||
this.pending.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
const feeBuyRate = this.deps.policy.takerFee(buy);
|
||||
const feeSellRate = this.deps.policy.takerFee(sell);
|
||||
|
||||
const askDepth = totalDepthBtc(buyBook.asks);
|
||||
const bidDepth = totalDepthBtc(sellBook.bids);
|
||||
const buyable = this.deps.inventory.maxBuyableBtc(buy, topAsk.price * (1 + feeBuyRate));
|
||||
const sellable = this.deps.inventory.sellableBtc(sell);
|
||||
const requested = this.deps.policy.maxTradeBtc();
|
||||
const target = Math.min(requested, askDepth, bidDepth, buyable, sellable);
|
||||
|
||||
if (target < DUST_BTC) {
|
||||
this.emitRejection(buy, sell, topAsk.price, topBid.price, "rejected_liquidity", "no liquidity or inventory", now);
|
||||
this.pending.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
const buySide = walkBook(buyBook.asks, target);
|
||||
const sellSide = walkBook(sellBook.bids, target);
|
||||
const volume = Math.min(buySide.filledBtc, sellSide.filledBtc);
|
||||
if (volume < DUST_BTC) {
|
||||
this.emitRejection(buy, sell, topAsk.price, topBid.price, "rejected_liquidity", "insufficient depth", now);
|
||||
this.pending.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
const buyVwap = buySide.vwap;
|
||||
const sellVwap = sellSide.vwap;
|
||||
const feeBuy = takerFeeCost(buyVwap, volume, feeBuyRate);
|
||||
const feeSell = takerFeeCost(sellVwap, volume, feeSellRate);
|
||||
const net = netProfit(buyVwap, sellVwap, volume, feeBuyRate, feeSellRate);
|
||||
const notional = buyVwap * volume;
|
||||
const netPct = netProfitPct(net, notional);
|
||||
const partial = volume < requested - DUST_BTC;
|
||||
|
||||
const base = {
|
||||
buy,
|
||||
sell,
|
||||
topAsk: topAsk.price,
|
||||
topBid: topBid.price,
|
||||
volume,
|
||||
buyVwap,
|
||||
sellVwap,
|
||||
feeBuy,
|
||||
feeSell,
|
||||
netProfit: net,
|
||||
netProfitPct: netPct,
|
||||
now,
|
||||
};
|
||||
|
||||
if (netPct <= this.deps.policy.minNetProfitPct()) {
|
||||
this.emitRejection(buy, sell, topAsk.price, topBid.price, "rejected_fees", "net edge below threshold", now);
|
||||
this.pending.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstTs = this.pending.get(key) ?? now;
|
||||
if (!this.pending.has(key)) this.pending.set(key, now);
|
||||
if (now - firstTs < this.deps.policy.flickerConfirmMs()) {
|
||||
this.emit({ ...base, status: "pending_confirm", reason: "confirming edge persistence", partial }, true);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!this.deps.risk.canExecute()) {
|
||||
this.emitRejection(buy, sell, topAsk.price, topBid.price, "rejected_risk", "circuit breaker active", now);
|
||||
this.pending.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return { ...base, partial };
|
||||
}
|
||||
|
||||
private emitRejection(
|
||||
buy: ExchangeId,
|
||||
sell: ExchangeId,
|
||||
topAsk: number,
|
||||
topBid: number,
|
||||
status: OpportunityStatus,
|
||||
reason: string,
|
||||
now: number,
|
||||
): void {
|
||||
const key = `${buy}->${sell}`;
|
||||
const last = this.lastEmit.get(key) ?? 0;
|
||||
if (now - last < REJECT_THROTTLE_MS) return;
|
||||
this.lastEmit.set(key, now);
|
||||
this.deps.store.tradesRejected += 1;
|
||||
const gross = topBid - topAsk;
|
||||
this.deps.store.addOpportunity({
|
||||
id: this.deps.ids.next("opp"),
|
||||
ts: now,
|
||||
buyExchange: buy,
|
||||
sellExchange: sell,
|
||||
topBuyAsk: topAsk,
|
||||
topSellBid: topBid,
|
||||
volumeBtc: 0,
|
||||
buyVwap: topAsk,
|
||||
sellVwap: topBid,
|
||||
grossSpread: gross,
|
||||
grossSpreadPct: topAsk > 0 ? gross / topAsk : 0,
|
||||
feeBuy: 0,
|
||||
feeSell: 0,
|
||||
netProfit: 0,
|
||||
netProfitPct: 0,
|
||||
status,
|
||||
reason,
|
||||
demo: this.deps.policy.isDemo(),
|
||||
});
|
||||
}
|
||||
|
||||
private emit(
|
||||
p: {
|
||||
buy: ExchangeId;
|
||||
sell: ExchangeId;
|
||||
topAsk: number;
|
||||
topBid: number;
|
||||
volume: number;
|
||||
buyVwap: number;
|
||||
sellVwap: number;
|
||||
feeBuy: number;
|
||||
feeSell: number;
|
||||
netProfit: number;
|
||||
netProfitPct: number;
|
||||
status: OpportunityStatus;
|
||||
reason: string;
|
||||
partial: boolean;
|
||||
now: number;
|
||||
},
|
||||
throttled: boolean,
|
||||
): Opportunity {
|
||||
const key = `${p.buy}->${p.sell}`;
|
||||
const gross = p.topBid - p.topAsk;
|
||||
const opportunity: Opportunity = {
|
||||
id: this.deps.ids.next("opp"),
|
||||
ts: p.now,
|
||||
buyExchange: p.buy,
|
||||
sellExchange: p.sell,
|
||||
topBuyAsk: p.topAsk,
|
||||
topSellBid: p.topBid,
|
||||
volumeBtc: p.volume,
|
||||
buyVwap: p.buyVwap,
|
||||
sellVwap: p.sellVwap,
|
||||
grossSpread: gross,
|
||||
grossSpreadPct: p.topAsk > 0 ? gross / p.topAsk : 0,
|
||||
feeBuy: p.feeBuy,
|
||||
feeSell: p.feeSell,
|
||||
netProfit: p.netProfit,
|
||||
netProfitPct: p.netProfitPct,
|
||||
status: p.status,
|
||||
reason: p.reason,
|
||||
demo: this.deps.policy.isDemo(),
|
||||
};
|
||||
|
||||
if (throttled) {
|
||||
const last = this.lastEmit.get(key) ?? 0;
|
||||
if (p.now - last < REJECT_THROTTLE_MS) return opportunity;
|
||||
this.lastEmit.set(key, p.now);
|
||||
}
|
||||
this.deps.store.addOpportunity(opportunity);
|
||||
return opportunity;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { netProfit, netProfitPct, takerFeeCost } from "./pricing.js";
|
||||
|
||||
test("takerFeeCost = vwap * volume * rate", () => {
|
||||
assert.equal(takerFeeCost(100, 2, 0.001), 0.2);
|
||||
});
|
||||
|
||||
test("netProfit is positive when gross edge beats both taker fees", () => {
|
||||
const net = netProfit(100000, 100600, 0.1, 0.001, 0.001);
|
||||
assert.ok(Math.abs(net - 39.94) < 1e-9, `got ${net}`);
|
||||
});
|
||||
|
||||
test("netProfit is negative when fees exceed gross edge", () => {
|
||||
const net = netProfit(100, 100.1, 1, 0.001, 0.001);
|
||||
assert.ok(net < 0, `expected negative, got ${net}`);
|
||||
});
|
||||
|
||||
test("netProfit equals proceeds-minus-cost expansion (executor parity)", () => {
|
||||
const buyVwap = 100020;
|
||||
const sellVwap = 100579.88;
|
||||
const vol = 0.1;
|
||||
const fb = 0.001;
|
||||
const fs = 0.001;
|
||||
const feeBuy = takerFeeCost(buyVwap, vol, fb);
|
||||
const feeSell = takerFeeCost(sellVwap, vol, fs);
|
||||
const expected = sellVwap * vol - feeSell - (buyVwap * vol + feeBuy);
|
||||
assert.equal(netProfit(buyVwap, sellVwap, vol, fb, fs), expected);
|
||||
});
|
||||
|
||||
test("netProfitPct divides by notional and guards zero", () => {
|
||||
assert.equal(netProfitPct(40, 10000), 0.004);
|
||||
assert.equal(netProfitPct(40, 0), 0);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Pure net-profit math — single source of truth for the arbitrage P&L formula.
|
||||
* Slippage is already in the VWAPs — never subtract it again here.
|
||||
*
|
||||
* profit = sellVwap * vol * (1 - feeSell) - buyVwap * vol * (1 + feeBuy)
|
||||
*/
|
||||
|
||||
/** Taker fee paid on one leg (quote currency). */
|
||||
export function takerFeeCost(vwap: number, volumeBtc: number, feeRate: number): number {
|
||||
return vwap * volumeBtc * feeRate;
|
||||
}
|
||||
|
||||
/** Net profit in quote currency (USDT). */
|
||||
export function netProfit(
|
||||
buyVwap: number,
|
||||
sellVwap: number,
|
||||
volumeBtc: number,
|
||||
feeBuyRate: number,
|
||||
feeSellRate: number,
|
||||
): number {
|
||||
const feeBuy = takerFeeCost(buyVwap, volumeBtc, feeBuyRate);
|
||||
const feeSell = takerFeeCost(sellVwap, volumeBtc, feeSellRate);
|
||||
const proceeds = sellVwap * volumeBtc - feeSell;
|
||||
const cost = buyVwap * volumeBtc + feeBuy;
|
||||
return proceeds - cost;
|
||||
}
|
||||
|
||||
/** Net profit as a fraction of notional (buy-side cost basis). */
|
||||
export function netProfitPct(net: number, notional: number): number {
|
||||
return notional > 0 ? net / notional : 0;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { walkBook, totalDepthBtc } from "./vwap.js";
|
||||
import type { Level } from "../entities/index.js";
|
||||
|
||||
const asks: Level[] = [
|
||||
{ price: 100, qty: 1 },
|
||||
{ price: 101, qty: 2 },
|
||||
{ price: 102, qty: 3 },
|
||||
];
|
||||
|
||||
test("walkBook fills fully within first level", () => {
|
||||
const r = walkBook(asks, 0.5);
|
||||
assert.equal(r.filledBtc, 0.5);
|
||||
assert.equal(r.vwap, 100);
|
||||
assert.equal(r.fullyFilled, true);
|
||||
});
|
||||
|
||||
test("walkBook computes VWAP across multiple levels (slippage)", () => {
|
||||
const r = walkBook(asks, 2);
|
||||
assert.equal(r.filledBtc, 2);
|
||||
assert.equal(r.vwap, 100.5);
|
||||
assert.equal(r.fullyFilled, true);
|
||||
});
|
||||
|
||||
test("walkBook returns partial fill when depth is insufficient", () => {
|
||||
const r = walkBook(asks, 10);
|
||||
assert.equal(r.filledBtc, 6);
|
||||
assert.equal(r.fullyFilled, false);
|
||||
});
|
||||
|
||||
test("walkBook handles zero/negative target", () => {
|
||||
assert.equal(walkBook(asks, 0).filledBtc, 0);
|
||||
assert.equal(walkBook(asks, -1).vwap, 0);
|
||||
});
|
||||
|
||||
test("net profit is negative when fees exceed gross edge", () => {
|
||||
const vol = 1;
|
||||
const buyVwap = 100;
|
||||
const sellVwap = 100.1;
|
||||
const feeBuy = 0.001;
|
||||
const feeSell = 0.001;
|
||||
const net = sellVwap * vol * (1 - feeSell) - buyVwap * vol * (1 + feeBuy);
|
||||
assert.ok(net < 0, `expected negative net, got ${net}`);
|
||||
});
|
||||
|
||||
test("totalDepthBtc sums all levels", () => {
|
||||
assert.equal(totalDepthBtc(asks), 6);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Level } from "../entities/index.js";
|
||||
|
||||
export interface VwapResult {
|
||||
filledBtc: number;
|
||||
quote: number;
|
||||
vwap: number;
|
||||
fullyFilled: boolean;
|
||||
}
|
||||
|
||||
export function walkBook(levels: Level[], targetBtc: number): VwapResult {
|
||||
if (targetBtc <= 0) {
|
||||
return { filledBtc: 0, quote: 0, vwap: 0, fullyFilled: false };
|
||||
}
|
||||
|
||||
let filled = 0;
|
||||
let quote = 0;
|
||||
|
||||
for (const level of levels) {
|
||||
if (filled >= targetBtc) break;
|
||||
const take = Math.min(level.qty, targetBtc - filled);
|
||||
quote += take * level.price;
|
||||
filled += take;
|
||||
}
|
||||
|
||||
const vwap = filled > 0 ? quote / filled : 0;
|
||||
return {
|
||||
filledBtc: filled,
|
||||
quote,
|
||||
vwap,
|
||||
fullyFilled: filled >= targetBtc - 1e-12,
|
||||
};
|
||||
}
|
||||
|
||||
export function totalDepthBtc(levels: Level[]): number {
|
||||
let sum = 0;
|
||||
for (const level of levels) sum += level.qty;
|
||||
return sum;
|
||||
}
|
||||
Reference in New Issue
Block a user