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
+184
View File
@@ -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>;
}
+115
View File
@@ -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");
});
+289
View File
@@ -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;
}
}
+34
View File
@@ -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);
});
+31
View File
@@ -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;
}
+49
View File
@@ -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);
});
+38
View File
@@ -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;
}