From fa6bee47ad624ef3185a257d5518c12deb63c70e Mon Sep 17 00:00:00 2001 From: ryder777777 Date: Fri, 23 Oct 2026 21:32:23 +0000 Subject: [PATCH] feat(agents): implement cross-market consistency arbitrage strategy (#108) --- oracledesk-stellar/agents/bin/arb.ts | 64 ++++ .../agents/core/arb/arb.test.ts | 246 ++++++++++++++ oracledesk-stellar/agents/core/arb/detect.ts | 317 ++++++++++++++++++ oracledesk-stellar/agents/core/arb/execute.ts | 162 +++++++++ .../agents/core/arb/relations.ts | 210 ++++++++++++ 5 files changed, 999 insertions(+) create mode 100644 oracledesk-stellar/agents/bin/arb.ts create mode 100644 oracledesk-stellar/agents/core/arb/arb.test.ts create mode 100644 oracledesk-stellar/agents/core/arb/detect.ts create mode 100644 oracledesk-stellar/agents/core/arb/execute.ts create mode 100644 oracledesk-stellar/agents/core/arb/relations.ts diff --git a/oracledesk-stellar/agents/bin/arb.ts b/oracledesk-stellar/agents/bin/arb.ts new file mode 100644 index 0000000..3e005f5 --- /dev/null +++ b/oracledesk-stellar/agents/bin/arb.ts @@ -0,0 +1,64 @@ +#!/usr/bin/env -S npx tsx +/** + * Cross-market consistency arbitrage CLI (#108). + * + * Scans configured or auto-discovered market relations for Dutch books and + * monotonic threshold violations, sizes multi-leg FPMM trades accounting for + * curvature and fees, and executes them with pre-quote checks and unwind + * recovery. + */ +import "dotenv/config"; +import { pathToFileURL } from "node:url"; +import { LocalTraceStore } from "../core/trace.js"; +import { + discoverMonotonicChains, + loadRelations, + validateRelations, + type ArbMarketSnapshot, + type MarketRelation, +} from "../core/arb/relations.js"; +import { scanAllArbitrage } from "../core/arb/detect.js"; +import { executeArbitrage } from "../core/arb/execute.js"; +import { loadAgentConfig } from "../core/config.js"; +import { buildAdapter } from "../runtime/wiring.js"; +import type { StellarAdapter } from "../stellar/adapter.js"; + +export async function runArbScan( + adapter: StellarAdapter, + markets: ArbMarketSnapshot[], + relations: MarketRelation[], + env: NodeJS.ProcessEnv = process.env, +) { + const traceStore = new LocalTraceStore(env.TRACE_STORE_DIR ?? "./.traces"); + const marketsById = new Map(markets.map((m) => [m.marketId, m])); + const autoChains = discoverMonotonicChains(markets); + const validated = validateRelations([...relations, ...autoChains], marketsById); + const opportunities = scanAllArbitrage(validated, marketsById, { + minProfitStroops: BigInt(env.ARB_MIN_PROFIT_STROOPS ?? "1000"), + }); + + const results = []; + for (const opp of opportunities) { + console.log(`[arb] opportunity (${opp.kind}): ${opp.reasoning}`); + const exec = await executeArbitrage(opp, marketsById, adapter, traceStore); + results.push({ opp, exec }); + } + return results; +} + +const isMain = + process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href; +if (isMain) { + loadAgentConfig({ role: "trader", env: process.env, argv: process.argv.slice(2) }) + .then((cfg) => { + const adapter = buildAdapter(cfg); + const relations = process.env.ARB_RELATIONS_PATH + ? loadRelations(process.env.ARB_RELATIONS_PATH) + : []; + return runArbScan(adapter, [], relations); + }) + .catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/oracledesk-stellar/agents/core/arb/arb.test.ts b/oracledesk-stellar/agents/core/arb/arb.test.ts new file mode 100644 index 0000000..615154c --- /dev/null +++ b/oracledesk-stellar/agents/core/arb/arb.test.ts @@ -0,0 +1,246 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { LocalTraceStore } from "../trace.js"; +import { seedReserves } from "../fpmm.js"; +import { + discoverMonotonicChains, + RelationValidationError, + validateRelations, + type ArbMarketSnapshot, + type ExhaustivePartitionRelation, + type MonotonicChainRelation, +} from "./relations.js"; +import { detectDutchBook, detectMonotonicViolation, sizeDutchBook } from "./detect.js"; +import { executeArbitrage } from "./execute.js"; +import type { StellarAdapter } from "../../stellar/adapter.js"; + +function makeMarket( + marketId: number, + yesBps: bigint, + seed: bigint = 1_000_000_000n, + feeBps: bigint = 100n, + priceSpec?: ArbMarketSnapshot["priceSpec"], +): ArbMarketSnapshot { + const s = seedReserves(seed, yesBps); + return { + marketId, + status: "Open", + reserveYes: s.reserveYes, + reserveNo: s.reserveNo, + feeBps, + closeTime: 1800000000, + priceSpec, + }; +} + +test("detectDutchBook identifies a profitable 3-way exhaustive partition underpricing YES", () => { + // 28% + 28% + 28% = 84% (< 100%) + const m1 = makeMarket(1, 2800n); + const m2 = makeMarket(2, 2800n); + const m3 = makeMarket(3, 2800n); + const map = new Map([ + [1, m1], + [2, m2], + [3, m3], + ]); + const rel: ExhaustivePartitionRelation = { + kind: "exhaustive_partition", + markets: [1, 2, 3], + label: "3-way election winner", + }; + + const opp = detectDutchBook(rel, map, { minProfitStroops: 100n }); + assert.ok(opp, "expected Dutch book opportunity"); + assert.equal(opp.kind, "dutch_book_yes"); + assert.equal(opp.legs.length, 3); + assert.ok(opp.guaranteedProfit > 0n); + + // Guaranteed payout must exceed total collateral in ALL 3 partition outcomes + for (const winningLeg of opp.legs) { + assert.ok( + winningLeg.expectedShares - opp.totalCollateralIn >= opp.guaranteedProfit, + "every partition resolution must yield >= guaranteedProfit", + ); + } +}); + +test("detectDutchBook skips when fees erase a narrow mispricing", () => { + // 33% + 33% + 33% = 99% (100 bps raw edge), but 200 bps fee per leg erases profit + const m1 = makeMarket(1, 3300n, 1_000_000_000n, 200n); + const m2 = makeMarket(2, 3300n, 1_000_000_000n, 200n); + const m3 = makeMarket(3, 3300n, 1_000_000_000n, 200n); + const map = new Map([ + [1, m1], + [2, m2], + [3, m3], + ]); + const rel: ExhaustivePartitionRelation = { + kind: "exhaustive_partition", + markets: [1, 2, 3], + label: "tight partition", + }; + + const opp = detectDutchBook(rel, map, { minProfitStroops: 100n }); + assert.equal(opp, null); +}); + +test("detectDutchBook buys NO on all legs when mutually exclusive YES probabilities sum > 100%", () => { + // 45% + 45% + 45% = 135% (> 100%) + const m1 = makeMarket(10, 4500n); + const m2 = makeMarket(11, 4500n); + const m3 = makeMarket(12, 4500n); + const map = new Map([ + [10, m1], + [11, m2], + [12, m3], + ]); + const rel: ExhaustivePartitionRelation = { + kind: "exhaustive_partition", + markets: [10, 11, 12], + label: "overpriced partition", + }; + + const opp = detectDutchBook(rel, map, { minProfitStroops: 100n }); + assert.ok(opp, "expected NO-bundle Dutch book"); + assert.equal(opp.kind, "dutch_book_no"); + assert.ok(opp.legs.every((l) => l.outcome === "No")); + assert.ok(opp.guaranteedProfit > 0n); +}); + +test("discoverMonotonicChains and detectMonotonicViolation find inverted threshold markets", () => { + // Market 20: XLM > $0.20 priced at 35% YES + // Market 21: XLM > $0.30 priced at 60% YES (impossible: >$0.30 implies >$0.20) + const m20 = makeMarket(20, 3500n, 1_000_000_000n, 50n, { + oracle: "CORACLE", + asset: "XLM", + comparison: "Gt", + threshold: 2_000_000n, + }); + const m21 = makeMarket(21, 6000n, 1_000_000_000n, 50n, { + oracle: "CORACLE", + asset: "XLM", + comparison: "Gt", + threshold: 3_000_000n, + }); + + const chains = discoverMonotonicChains([m21, m20]); + assert.equal(chains.length, 1); + assert.deepEqual( + chains[0].chain.map((c) => c.marketId), + [20, 21], + ); + + const map = new Map([ + [20, m20], + [21, m21], + ]); + const opp = detectMonotonicViolation(chains[0], map, { minProfitStroops: 100n }); + assert.ok(opp, "expected monotonic violation opportunity"); + assert.equal(opp.kind, "monotonic_violation"); + assert.equal(opp.legs[0].marketId, 20); + assert.equal(opp.legs[0].outcome, "Yes"); + assert.equal(opp.legs[1].marketId, 21); + assert.equal(opp.legs[1].outcome, "No"); + assert.ok(opp.guaranteedProfit > 0n); +}); + +test("sizeDutchBook guaranteedProfit is non-negative across all valid outcomes for random pools", () => { + let state = 42; + const rand = (min: number, max: number) => { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0; + return min + (state % (max - min + 1)); + }; + + for (let i = 0; i < 40; i++) { + const p1 = BigInt(rand(1500, 3000)); + const p2 = BigInt(rand(1500, 3000)); + const p3 = BigInt(rand(1500, 3000)); + const markets = [ + makeMarket(1, p1, BigInt(rand(50_000_000, 500_000_000)), BigInt(rand(0, 150))), + makeMarket(2, p2, BigInt(rand(50_000_000, 500_000_000)), BigInt(rand(0, 150))), + makeMarket(3, p3, BigInt(rand(50_000_000, 500_000_000)), BigInt(rand(0, 150))), + ]; + const sized = sizeDutchBook(markets, ["Yes", "Yes", "Yes"], 1n); + if (sized) { + assert.ok(sized.guaranteedProfit > 0n); + for (const leg of sized.legs) { + assert.ok(leg.expectedShares - sized.totalCollateralIn >= sized.guaranteedProfit); + } + } + } +}); + +test("validateRelations rejects duplicate/self-referential markets and skips Resolved markets", () => { + const m1 = makeMarket(1, 4000n); + const m2 = { ...makeMarket(2, 6000n), status: "Resolved" as const }; + const map = new Map([ + [1, m1], + [2, m2], + ]); + + assert.throws( + () => + validateRelations( + [{ kind: "exhaustive_partition", markets: [1, 1], label: "self-ref" }], + map, + ), + RelationValidationError, + ); + + const active = validateRelations( + [{ kind: "exhaustive_partition", markets: [1, 2], label: "one resolved" }], + map, + ); + assert.equal(active.length, 0); +}); + +test("executeArbitrage unwinds leg 1 when leg 2 fails in live/simulated execution", async () => { + const m20 = makeMarket(20, 3000n, 1_000_000_000n, 50n); + const m21 = makeMarket(21, 6500n, 1_000_000_000n, 50n); + const map = new Map([ + [20, m20], + [21, m21], + ]); + const rel: MonotonicChainRelation = { + kind: "monotonic_chain", + chain: [ + { marketId: 20, threshold: 2_000_000n }, + { marketId: 21, threshold: 3_000_000n }, + ], + comparison: "Gt", + asset: "XLM", + }; + const opp = detectMonotonicViolation(rel, map)!; + assert.ok(opp); + + const sellCalls: unknown[] = []; + let buyCallCount = 0; + const fakeAdapter = { + mode: "dry-run", + agent: "GARB", + async quoteBuy({ market_id }: { market_id: bigint }) { + const leg = opp.legs.find((l) => BigInt(l.marketId) === market_id)!; + return leg.expectedShares; + }, + async agentBuyV2(args: unknown) { + buyCallCount++; + if (buyCallCount === 2) { + throw new Error("Simulated slippage revert on leg 2"); + } + return { dryRun: true as const, contractId: "CTREASURY", method: "agent_buy_v2", args }; + }, + async agentSellV2(args: unknown) { + sellCalls.push(args); + return { dryRun: true as const, contractId: "CTREASURY", method: "agent_sell_v2", args }; + }, + } as unknown as StellarAdapter; + + const traceStore = new LocalTraceStore(mkdtempSync(join(tmpdir(), "arb-test-"))); + const res = await executeArbitrage(opp, map, fakeAdapter, traceStore); + assert.equal(res.status, "unwound_partial"); + assert.deepEqual(res.unwoundLegs, [20]); + assert.equal(sellCalls.length, 1); +}); diff --git a/oracledesk-stellar/agents/core/arb/detect.ts b/oracledesk-stellar/agents/core/arb/detect.ts new file mode 100644 index 0000000..d1e0e34 --- /dev/null +++ b/oracledesk-stellar/agents/core/arb/detect.ts @@ -0,0 +1,317 @@ +/** + * Cross-market Dutch-book and monotonic-violation arbitrage detector and + * optimal multi-leg FPMM trade sizer (#108). + */ +import { BPS, calcBuy, collateralForShares, priceYesBps } from "../fpmm.js"; +import type { + ArbMarketSnapshot, + ExhaustivePartitionRelation, + MarketRelation, + MonotonicChainRelation, + MutuallyExclusiveRelation, +} from "./relations.js"; + +export interface ArbLeg { + marketId: number; + outcome: "Yes" | "No"; + collateralIn: bigint; + expectedShares: bigint; + limitPriceBps: number; +} + +export interface ArbOpportunity { + relation: MarketRelation; + kind: "dutch_book_yes" | "dutch_book_no" | "monotonic_violation"; + legs: ArbLeg[]; + totalCollateralIn: bigint; + /** Guaranteed minimum payout across all valid joint outcome resolutions. */ + guaranteedPayout: bigint; + /** Guaranteed net profit (`guaranteedPayout - totalCollateralIn`) in stroops. */ + guaranteedProfit: bigint; + reasoning: string; +} + +export interface ArbDetectOptions { + /** Minimum guaranteed profit in stroops to emit an opportunity (default 1000). */ + minProfitStroops?: bigint; + /** Maximum total collateral across all legs for one opportunity. */ + maxTotalCollateralStroops?: bigint; +} + +function reservesForOutcome( + m: ArbMarketSnapshot, + outcome: "Yes" | "No", +): { rBought: bigint; rOther: bigint } { + return outcome === "Yes" + ? { rBought: m.reserveYes, rOther: m.reserveNo } + : { rBought: m.reserveNo, rOther: m.reserveYes }; +} + +/** + * Computes the total collateral needed to acquire `targetShares` on every leg, + * along with the guaranteed net profit for the bundle. + * + * - For an N-leg `dutch_book_yes` on an exhaustive partition, exactly 1 YES + * resolves to 1 stroop per share, so `guaranteedPayout = targetShares`. + * - For an N-leg `dutch_book_no` on mutually exclusive markets (or exhaustive + * partition), at least `N - 1` markets resolve NO, so buying `targetShares` + * of NO on every leg pays at least `(N - 1) * targetShares`. + * - For a 2-leg `monotonic_violation` (`YES` on lower threshold `A`, `NO` on + * higher threshold `B` where `YES_B => YES_A`), the valid joint states + * `(NO_A, NO_B)`, `(YES_A, NO_B)`, `(YES_A, YES_B)` pay `s`, `2s`, `s` + * respectively, so `guaranteedPayout = targetShares`. + */ +export function evaluateBundleAtShares( + markets: ArbMarketSnapshot[], + outcomes: Array<"Yes" | "No">, + payoutMultiplier: bigint, + targetShares: bigint, +): { + legs: ArbLeg[]; + totalCollateralIn: bigint; + guaranteedPayout: bigint; + guaranteedProfit: bigint; +} | null { + if (targetShares <= 0n) return null; + let totalCollateralIn = 0n; + const legs: ArbLeg[] = []; + + for (let i = 0; i < markets.length; i++) { + const m = markets[i]; + const outcome = outcomes[i]; + const { rBought, rOther } = reservesForOutcome(m, outcome); + try { + const c = collateralForShares(rBought, rOther, targetShares, m.feeBps); + const q = calcBuy(rBought, rOther, c, m.feeBps); + const avgPriceBps = Number((c * BPS + q.sharesOut - 1n) / q.sharesOut); + const limitPriceBps = Math.min(9999, Math.max(1, avgPriceBps + 25)); + totalCollateralIn += c; + legs.push({ + marketId: m.marketId, + outcome, + collateralIn: c, + expectedShares: q.sharesOut, + limitPriceBps, + }); + } catch { + return null; + } + } + + const minShares = legs.reduce( + (acc, l) => (l.expectedShares < acc ? l.expectedShares : acc), + legs[0].expectedShares, + ); + const guaranteedPayout = minShares * payoutMultiplier; + const guaranteedProfit = guaranteedPayout - totalCollateralIn; + return { + legs, + totalCollateralIn, + guaranteedPayout, + guaranteedProfit, + }; +} + +/** + * Finds the target share count `s > 0` maximizing `guaranteedProfit(s)` + * (`payoutMultiplier * s - sum_i c_i(s)`), which is concave because each + * FPMM leg's cost `c_i(s)` is strictly convex in `s`. + */ +export function sizeDutchBook( + markets: ArbMarketSnapshot[], + outcomes: Array<"Yes" | "No">, + payoutMultiplier: bigint, + maxTotalCollateralStroops?: bigint, +): { + legs: ArbLeg[]; + totalCollateralIn: bigint; + guaranteedPayout: bigint; + guaranteedProfit: bigint; +} | null { + const minReserve = markets.reduce((acc, m) => { + const r = m.reserveYes < m.reserveNo ? m.reserveYes : m.reserveNo; + return r < acc ? r : acc; + }, markets[0].reserveYes); + + let lo = 100n; + let hi = minReserve; + if (hi <= lo) hi = lo + 1000n; + + // Ensure hi respects maxTotalCollateralStroops if specified + if (maxTotalCollateralStroops !== undefined) { + while (hi > lo) { + const ev = evaluateBundleAtShares(markets, outcomes, payoutMultiplier, hi); + if (ev && ev.totalCollateralIn <= maxTotalCollateralStroops) break; + hi = (lo + hi) / 2n; + } + } + + // Ternary search on concave profit function profit(s) + let best = evaluateBundleAtShares(markets, outcomes, payoutMultiplier, lo); + for (let iter = 0; iter < 48 && hi - lo > 3n; iter++) { + const m1 = lo + (hi - lo) / 3n; + const m2 = hi - (hi - lo) / 3n; + const e1 = evaluateBundleAtShares(markets, outcomes, payoutMultiplier, m1); + const e2 = evaluateBundleAtShares(markets, outcomes, payoutMultiplier, m2); + if (!e1 && !e2) { + hi = m1; + continue; + } + if (e1 && (!best || e1.guaranteedProfit > best.guaranteedProfit)) best = e1; + if (e2 && (!best || e2.guaranteedProfit > best.guaranteedProfit)) best = e2; + + const p1 = e1 ? e1.guaranteedProfit : -1_000_000_000_000_000_000n; + const p2 = e2 ? e2.guaranteedProfit : -1_000_000_000_000_000_000n; + if (p1 < p2) { + lo = m1; + } else { + hi = m2; + } + } + + for (let s = lo; s <= hi; s += (hi - lo > 10n ? (hi - lo) / 10n : 1n)) { + const ev = evaluateBundleAtShares(markets, outcomes, payoutMultiplier, s); + if ( + ev && + (maxTotalCollateralStroops === undefined || + ev.totalCollateralIn <= maxTotalCollateralStroops) && + (!best || ev.guaranteedProfit > best.guaranteedProfit) + ) { + best = ev; + } + if (hi === lo) break; + } + + if (!best || best.guaranteedProfit <= 0n) return null; + if ( + maxTotalCollateralStroops !== undefined && + best.totalCollateralIn > maxTotalCollateralStroops + ) { + return null; + } + return best; +} + +export function detectDutchBook( + relation: ExhaustivePartitionRelation | MutuallyExclusiveRelation, + marketsById: ReadonlyMap, + opts: ArbDetectOptions = {}, +): ArbOpportunity | null { + const minProfit = opts.minProfitStroops ?? 1000n; + const markets: ArbMarketSnapshot[] = []; + for (const id of relation.markets) { + const m = marketsById.get(id); + if (!m || m.status !== "Open") return null; + markets.push(m); + } + + const sumYesBps = markets.reduce( + (acc, m) => acc + priceYesBps(m.reserveYes, m.reserveNo), + 0n, + ); + + // Case 1: Exhaustive partition underpriced YES bundle (sum P(YES_i) < 10000 bps) + if (relation.kind === "exhaustive_partition" && sumYesBps < BPS) { + const outcomes = markets.map(() => "Yes" as const); + const sized = sizeDutchBook(markets, outcomes, 1n, opts.maxTotalCollateralStroops); + if (sized && sized.guaranteedProfit >= minProfit) { + return { + relation, + kind: "dutch_book_yes", + ...sized, + reasoning: `Exhaustive partition "${relation.label}" sums to ${sumYesBps} bps (< 10000 bps); buying YES across [${relation.markets.join(", ")}] locks in ${sized.guaranteedProfit} stroops guaranteed profit.`, + }; + } + } + + // Case 2: Overpriced YES bundle (sum P(YES_i) > 10000 bps) -> buy NO on all N legs + // At most 1 YES can be true, so at least N - 1 legs resolve NO. + if (sumYesBps > BPS) { + const outcomes = markets.map(() => "No" as const); + const payoutMultiplier = BigInt(markets.length - 1); + const sized = sizeDutchBook( + markets, + outcomes, + payoutMultiplier, + opts.maxTotalCollateralStroops, + ); + if (sized && sized.guaranteedProfit >= minProfit) { + return { + relation, + kind: "dutch_book_no", + ...sized, + reasoning: `${relation.kind} "${relation.label}" sums to ${sumYesBps} bps (> 10000 bps); buying NO across [${relation.markets.join(", ")}] guarantees at least ${payoutMultiplier} winning legs and ${sized.guaranteedProfit} stroops net profit.`, + }; + } + } + + return null; +} + +export function detectMonotonicViolation( + relation: MonotonicChainRelation, + marketsById: ReadonlyMap, + opts: ArbDetectOptions = {}, +): ArbOpportunity | null { + const minProfit = opts.minProfitStroops ?? 1000n; + let bestOpp: ArbOpportunity | null = null; + + for (let i = 0; i < relation.chain.length; i++) { + const mA = marketsById.get(relation.chain[i].marketId); + if (!mA || mA.status !== "Open") continue; + const pA = priceYesBps(mA.reserveYes, mA.reserveNo); + + for (let j = i + 1; j < relation.chain.length; j++) { + const mB = marketsById.get(relation.chain[j].marketId); + if (!mB || mB.status !== "Open") continue; + const pB = priceYesBps(mB.reserveYes, mB.reserveNo); + + // In a valid chain, YES_B => YES_A so P(YES_A) >= P(YES_B). + // A violation occurs when P(YES_A) < P(YES_B): buy YES_A and buy NO_B. + if (pA < pB) { + const sized = sizeDutchBook( + [mA, mB], + ["Yes", "No"], + 1n, + opts.maxTotalCollateralStroops, + ); + if ( + sized && + sized.guaranteedProfit >= minProfit && + (!bestOpp || sized.guaranteedProfit > bestOpp.guaranteedProfit) + ) { + bestOpp = { + relation, + kind: "monotonic_violation", + ...sized, + reasoning: `Monotonic violation on ${relation.asset} (${relation.comparison}): market ${mA.marketId} (threshold ${relation.chain[i].threshold}) prices YES at ${pA} bps < market ${mB.marketId} (threshold ${relation.chain[j].threshold}) at ${pB} bps; buying YES_${mA.marketId} + NO_${mB.marketId} locks in ${sized.guaranteedProfit} stroops guaranteed profit.`, + }; + } + } + } + } + + return bestOpp; +} + +export function scanAllArbitrage( + relations: MarketRelation[], + marketsById: ReadonlyMap, + opts: ArbDetectOptions = {}, +): ArbOpportunity[] { + const results: ArbOpportunity[] = []; + for (const rel of relations) { + const opp = + rel.kind === "monotonic_chain" + ? detectMonotonicViolation(rel, marketsById, opts) + : detectDutchBook(rel, marketsById, opts); + if (opp) results.push(opp); + } + return results.sort((a, b) => + a.guaranteedProfit === b.guaranteedProfit + ? 0 + : a.guaranteedProfit > b.guaranteedProfit + ? -1 + : 1, + ); +} diff --git a/oracledesk-stellar/agents/core/arb/execute.ts b/oracledesk-stellar/agents/core/arb/execute.ts new file mode 100644 index 0000000..872b7ed --- /dev/null +++ b/oracledesk-stellar/agents/core/arb/execute.ts @@ -0,0 +1,162 @@ +/** + * Multi-leg arbitrage executor with pre-execution re-quoting, single shared + * reasoning trace, and automatic unwind recovery if a middle leg reverts (#108). + */ +import { BPS, collateralOutForShares } from "../fpmm.js"; +import { buildTrace, type TraceStore } from "../trace.js"; +import type { StellarAdapter } from "../../stellar/adapter.js"; +import type { ArbLeg, ArbOpportunity } from "./detect.js"; +import type { ArbMarketSnapshot } from "./relations.js"; + +export interface ExecutedArbLeg { + leg: ArbLeg; + sharesReceived: bigint; +} + +export interface ArbExecutionResult { + status: "completed" | "aborted_precheck" | "unwound_partial"; + traceHash: string; + traceCid: string; + completedLegs: ExecutedArbLeg[]; + unwoundLegs: number[]; + error?: string; +} + +export interface ExecuteArbitrageOptions { + slippageBps?: bigint; + deadlineSecs?: number; + now?: () => number; +} + +export async function executeArbitrage( + opp: ArbOpportunity, + marketsById: ReadonlyMap, + adapter: StellarAdapter, + traceStore: TraceStore, + opts: ExecuteArbitrageOptions = {}, +): Promise { + const slippageBps = opts.slippageBps ?? 50n; + const deadlineSecs = opts.deadlineSecs ?? 60; + const now = opts.now ?? (() => Math.floor(Date.now() / 1000)); + + const built = buildTrace({ + agent: "trader", + marketId: opp.legs[0]?.marketId ?? null, + action: "buy", + reasoning: opp.reasoning, + inputs: { + kind: opp.kind, + relation: opp.relation, + legs: opp.legs, + totalCollateralIn: opp.totalCollateralIn, + guaranteedPayout: opp.guaranteedPayout, + guaranteedProfit: opp.guaranteedProfit, + }, + }); + const { cid } = await traceStore.put(built.hash, built.json); + + // Step 1: Pre-check fresh quotes on all legs before submitting any transaction + const freshQuotes: bigint[] = []; + for (const leg of opp.legs) { + const quoted = await adapter.quoteBuy({ + market_id: BigInt(leg.marketId), + outcome: { tag: leg.outcome, values: undefined as void }, + collateral_in: leg.collateralIn, + }); + const minAcceptable = (leg.expectedShares * (BPS - slippageBps)) / BPS; + if (quoted < minAcceptable) { + return { + status: "aborted_precheck", + traceHash: built.hash, + traceCid: cid, + completedLegs: [], + unwoundLegs: [], + error: `Pre-quote on market ${leg.marketId} (${quoted}) fell below min_shares_out (${minAcceptable})`, + }; + } + freshQuotes.push(quoted); + } + + // Step 2: Execute legs sequentially with tight slippage and limit_price_bps + const completedLegs: ExecutedArbLeg[] = []; + const deadline = BigInt(now() + deadlineSecs); + + for (let i = 0; i < opp.legs.length; i++) { + const leg = opp.legs[i]; + const quoted = freshQuotes[i]; + const minSharesOut = (quoted * (BPS - slippageBps)) / BPS; + try { + const res = await adapter.agentBuyV2({ + market_id: BigInt(leg.marketId), + outcome: { tag: leg.outcome, values: undefined as void }, + collateral_in: leg.collateralIn, + min_shares_out: minSharesOut, + limit_price_bps: leg.limitPriceBps, + deadline, + }); + const actualShares = res.dryRun ? quoted : res.value; + completedLegs.push({ leg, sharesReceived: actualShares }); + + if (!res.dryRun) { + await adapter.publishTrace({ + agent: adapter.agent, + market_id: BigInt(leg.marketId), + action: "buy", + trace_hash: Buffer.from(built.hash, "hex"), + ipfs_cid: cid, + }); + } + } catch (err) { + // Middle leg failed: unwind already-executed legs in reverse order + const unwoundLegs: number[] = []; + for (let j = completedLegs.length - 1; j >= 0; j--) { + const done = completedLegs[j]; + const snap = marketsById.get(done.leg.marketId); + let collateralOut = (done.leg.collateralIn * 9500n) / BPS; + if (snap) { + const rSold = done.leg.outcome === "Yes" ? snap.reserveYes : snap.reserveNo; + const rOther = done.leg.outcome === "Yes" ? snap.reserveNo : snap.reserveYes; + try { + collateralOut = collateralOutForShares( + rSold, + rOther, + done.sharesReceived, + snap.feeBps, + ); + } catch { + // Fallback to conservative 95% estimate + } + } + try { + await adapter.agentSellV2({ + market_id: BigInt(done.leg.marketId), + outcome: { tag: done.leg.outcome, values: undefined as void }, + collateral_out: (collateralOut * (BPS - slippageBps)) / BPS, + max_shares_in: done.sharesReceived, + limit_price_bps: 1, + deadline, + }); + unwoundLegs.push(done.leg.marketId); + } catch { + // Best-effort unwind + } + } + return { + status: "unwound_partial", + traceHash: built.hash, + traceCid: cid, + completedLegs, + unwoundLegs, + error: (err as Error).message, + }; + } + } + + return { + status: "completed", + traceHash: built.hash, + traceCid: cid, + completedLegs, + unwoundLegs: [], + }; +} diff --git a/oracledesk-stellar/agents/core/arb/relations.ts b/oracledesk-stellar/agents/core/arb/relations.ts new file mode 100644 index 0000000..7133e10 --- /dev/null +++ b/oracledesk-stellar/agents/core/arb/relations.ts @@ -0,0 +1,210 @@ +/** + * Cross-market logical relation schemas, loader, validator, and automatic + * monotonic-threshold chain discovery (#108). + */ +import { readFileSync } from "node:fs"; + +export interface ExhaustivePartitionRelation { + kind: "exhaustive_partition"; + markets: number[]; + label: string; +} + +export interface MutuallyExclusiveRelation { + kind: "mutually_exclusive"; + markets: number[]; + label: string; +} + +export interface MonotonicChainEntry { + marketId: number; + threshold: bigint; +} + +export interface MonotonicChainRelation { + kind: "monotonic_chain"; + /** Ordered so that entry[i] YES implies entry[i-1] YES (i.e. P(YES_0) >= P(YES_1) >= ...). */ + chain: MonotonicChainEntry[]; + comparison: "Gt" | "Gte" | "Lt" | "Lte"; + asset: string; +} + +export type MarketRelation = + | ExhaustivePartitionRelation + | MutuallyExclusiveRelation + | MonotonicChainRelation; + +export interface ArbMarketSnapshot { + marketId: number; + status: "Open" | "Closed" | "Resolved" | "Voided"; + reserveYes: bigint; + reserveNo: bigint; + feeBps: bigint; + closeTime: number; + priceSpec?: { + oracle: string; + asset: string; + comparison: "Gt" | "Gte" | "Lt" | "Lte"; + threshold: bigint; + }; +} + +export class RelationValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "RelationValidationError"; + } +} + +export function loadRelations(path: string): MarketRelation[] { + const raw = JSON.parse(readFileSync(path, "utf8")) as unknown[]; + if (!Array.isArray(raw)) { + throw new RelationValidationError("relations config must be a JSON array"); + } + return raw.map((item) => { + const obj = item as Record; + if (obj.kind === "exhaustive_partition" || obj.kind === "mutually_exclusive") { + return { + kind: obj.kind, + markets: (obj.markets as number[]).map(Number), + label: String(obj.label ?? obj.kind), + }; + } + if (obj.kind === "monotonic_chain") { + const chain = (obj.chain as Array<{ marketId: number; threshold: string | number | bigint }>).map( + (c) => ({ + marketId: Number(c.marketId), + threshold: BigInt(c.threshold), + }), + ); + return { + kind: "monotonic_chain", + chain, + comparison: (obj.comparison as "Gt" | "Gte" | "Lt" | "Lte") ?? "Gt", + asset: String(obj.asset ?? ""), + }; + } + throw new RelationValidationError(`unknown relation kind ${String(obj.kind)}`); + }); +} + +/** + * Validates relations against the active market snapshot and filters out any + * relation referencing a missing or non-Open market. Throws + * `RelationValidationError` on structural errors (self-reference, duplicates, + * fewer than 2 markets, or unsorted monotonic thresholds). + */ +export function validateRelations( + relations: MarketRelation[], + marketsById: ReadonlyMap, +): MarketRelation[] { + const active: MarketRelation[] = []; + + for (const rel of relations) { + if (rel.kind === "exhaustive_partition" || rel.kind === "mutually_exclusive") { + if (rel.markets.length < 2) { + throw new RelationValidationError(`${rel.label}: relation requires at least 2 markets`); + } + const seen = new Set(); + for (const id of rel.markets) { + if (seen.has(id)) { + throw new RelationValidationError( + `${rel.label}: duplicate or self-referential market ID ${id}`, + ); + } + seen.add(id); + } + const allOpen = rel.markets.every((id) => marketsById.get(id)?.status === "Open"); + if (allOpen) active.push(rel); + continue; + } + + if (rel.chain.length < 2) { + throw new RelationValidationError( + `monotonic_chain(${rel.asset}): requires at least 2 markets`, + ); + } + const seen = new Set(); + for (let i = 0; i < rel.chain.length; i++) { + const entry = rel.chain[i]; + if (seen.has(entry.marketId)) { + throw new RelationValidationError( + `monotonic_chain(${rel.asset}): duplicate market ID ${entry.marketId}`, + ); + } + seen.add(entry.marketId); + if (i > 0) { + const prev = rel.chain[i - 1]; + const isAscending = rel.comparison === "Gt" || rel.comparison === "Gte"; + if (isAscending && entry.threshold <= prev.threshold) { + throw new RelationValidationError( + `monotonic_chain(${rel.asset}): Gt/Gte chain must have strictly increasing thresholds`, + ); + } + if (!isAscending && entry.threshold >= prev.threshold) { + throw new RelationValidationError( + `monotonic_chain(${rel.asset}): Lt/Lte chain must have strictly decreasing thresholds`, + ); + } + } + } + const allOpen = rel.chain.every((c) => marketsById.get(c.marketId)?.status === "Open"); + if (allOpen) active.push(rel); + } + + return active; +} + +/** + * Automatically groups open price-mode markets with identical + * `(oracle, asset, comparison, closeTime)` into monotonic threshold chains. + * + * - For `Gt`/`Gte`: sorted by ascending threshold (`K_0 < K_1 < ...`), because + * `S_T > K_1 => S_T > K_0`, so `P(YES_0) >= P(YES_1)`. + * - For `Lt`/`Lte`: sorted by descending threshold (`K_0 > K_1 > ...`), because + * `S_T < K_1 => S_T < K_0`, so `P(YES_0) >= P(YES_1)`. + */ +export function discoverMonotonicChains(markets: ArbMarketSnapshot[]): MonotonicChainRelation[] { + const groups = new Map(); + for (const m of markets) { + if (m.status !== "Open" || !m.priceSpec) continue; + const key = `${m.priceSpec.oracle}|${m.priceSpec.asset}|${m.priceSpec.comparison}|${m.closeTime}`; + const list = groups.get(key) ?? []; + list.push(m); + groups.set(key, list); + } + + const chains: MonotonicChainRelation[] = []; + for (const group of groups.values()) { + if (group.length < 2) continue; + const comp = group[0].priceSpec!.comparison; + const asset = group[0].priceSpec!.asset; + const sorted = [...group].sort((a, b) => { + const ta = a.priceSpec!.threshold; + const tb = b.priceSpec!.threshold; + if (ta === tb) return a.marketId - b.marketId; + if (comp === "Gt" || comp === "Gte") { + return ta < tb ? -1 : 1; + } + return ta > tb ? -1 : 1; + }); + + // Deduplicate identical thresholds + const unique: MonotonicChainEntry[] = []; + for (const m of sorted) { + const t = m.priceSpec!.threshold; + if (unique.length === 0 || unique[unique.length - 1].threshold !== t) { + unique.push({ marketId: m.marketId, threshold: t }); + } + } + if (unique.length >= 2) { + chains.push({ + kind: "monotonic_chain", + chain: unique, + comparison: comp, + asset, + }); + } + } + return chains; +}