From 0ee19ce48a94cecaddbc3c4beeaacaa66e665b88 Mon Sep 17 00:00:00 2001 From: direkturcrypto Date: Tue, 31 Mar 2026 10:51:23 +0700 Subject: [PATCH] fix: remove repricing, add ghost fill recovery with onchain verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repricing was the root cause of double orders and ghost fills: - cancel/replace race caused duplicate active orders in CLOB - cancel API returning OK did not guarantee order was actually gone - ghost fills (CLOB matched, invalid txhash) went undetected Changes: - Remove checkAndReprice entirely — orders placed once and held - Detect ghost fills via getOpenOrders (reliable) instead of checkOrderStatus - recoverFromGhostFill: merge paired shares + market-sell remainder - marketSellToken: verify onchain balance after each attempt, retry up to 3x (sells can also be ghost-filled — shares still in wallet despite CLOB saying done) - Recovery triggers at 30s after detection, not at cut-loss time (prices still fair) - Update README to reflect no-reprice design Co-Authored-By: Claude Sonnet 4.6 --- README.md | 5 +- src/services/makerRebateExecutor.js | 330 ++++++++++++++-------------- 2 files changed, 170 insertions(+), 165 deletions(-) diff --git a/README.md b/README.md index 70ba7bd..3078b9b 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,11 @@ High-frequency market-making on Polymarket's 15-minute BTC/ETH/SOL Up-or-Down ma 5. Automatically queues the next market before the current one closes — zero idle time between markets **Key design decisions:** -- **Never aggressive-reprices when one side is filled** — holds the original bid and waits for market reversion, preventing double exposure +- **No repricing** — orders are placed once and held; no cancel/replace cycles that cause double orders or ghost fills +- **Onchain balance as source of truth** — fill detection uses Polygon RPC balance, not CLOB API responses or WebSocket events alone +- **Ghost fill recovery** — detects CLOB-matched orders with invalid txhash (order gone from book but tokens never arrived), recovers by merging what settled and selling remainder at market before prices skew - **Stops re-entry after a stuck (one-sided) cycle** — protects against accumulating directional exposure in trending markets - **Combined cap always enforced** — cost of YES + NO never exceeds `MAKER_MM_MAX_COMBINED`, guaranteeing profitability on every successful merge -- **WebSocket real-time fill detection** — fills detected via RTDS WebSocket for sub-second response, with onchain balance as source of truth - **Market-neutral** — profits from spread capture only, never depends on price direction **Economics per cycle (default $5/side, 5 shares):** diff --git a/src/services/makerRebateExecutor.js b/src/services/makerRebateExecutor.js index 6f6fcac..7f426e4 100644 --- a/src/services/makerRebateExecutor.js +++ b/src/services/makerRebateExecutor.js @@ -75,145 +75,6 @@ async function getBestAsk(tokenId) { } // ── Bid-based repricing ─────────────────────────────────────────────────────── -// Targets top of bid orderbook: newBid = bestBid + 1 tick (become new top bid). -// Safety cap: newBid < bestAsk (guaranteed maker by construction). -// Only reprices when bid drifts > config.makerMmRepriceThreshold (default 2c). -// If one side is already filled, the other side is capped so combined never exceeds target. -async function checkAndReprice(pos, tag) { - const threshold = config.makerMmRepriceThreshold; - const ts = parseFloat(pos.tickSize); - const MIN_PRICE = getMinPrice(); - const MAX_PRICE = getMaxPrice(); - const oneSideFilled = pos.yes.filled !== pos.no.filled; - const timeSinceFirstFill = pos.firstFillTime ? Date.now() - pos.firstFillTime : 0; - if (oneSideFilled) { - logger.info(`MakerMM${tag}: ${pos.yes.filled ? 'YES' : 'NO'} filled — holding bid, waiting for reversion (${Math.round(timeSinceFirstFill / 1000)}s)`); - return; // Never reprice when one side is already filled — prevent double exposure - } - - const repriceSide = async (side) => { - const s = pos[side]; - if (s.filled) return; - - // Bid-based: target = bestBid + 1 tick (top of bid orderbook) - // Fetch both bid and ask in parallel — ask used as safety cap only - const [bestBid, bestAsk] = await Promise.all([ - getRealPrice(s.tokenId), - getBestAsk(s.tokenId), - ]); - if (!bestBid) return; // no bid data — skip - - let newBid = roundToTick(bestBid + ts, pos.tickSize); - - // Safety: never cross the ask (guaranteed maker) - if (bestAsk && newBid >= bestAsk) { - newBid = roundToTick(bestAsk - ts, pos.tickSize); - } - - // Drift check: compare current bestBid vs entryBid (set once on first reprice) - // entryBid = buyPrice - ts (since buyPrice = bestBid + ts at entry) - const entryBid = s.entryBid ?? (s.buyPrice - ts); - const bidDrift = Math.abs(bestBid - entryBid); - if (bidDrift <= threshold) return; - - // Rebate range cap - newBid = Math.min(newBid, MAX_PRICE); - - // Combined cap always enforced — never allow combined to exceed maxCombined - const otherBid = side === 'yes' ? pos.no.buyPrice : pos.yes.buyPrice; - const maxBid = roundToTick(config.makerMmMaxCombined - otherBid, pos.tickSize); - newBid = Math.min(newBid, maxBid); - - if (newBid < MIN_PRICE) { - logger.info(`MakerMM${tag}: ${side.toUpperCase()} new bid $${newBid} < MIN_PRICE — skip reprice`); - return; - } - if (Math.abs(newBid - s.buyPrice) < ts) return; // no meaningful change after caps - - logger.info( - `MakerMM${tag}: repricing ${side.toUpperCase()} $${s.buyPrice} → $${newBid} ` + - `(bid drift ${(bidDrift * 100).toFixed(0)}c > ${(threshold * 100).toFixed(0)}c threshold)` - ); - - // Re-check filled status — WS fill may have arrived during the async API calls above - if (s.filled) { - logger.money(`MakerMM${tag}: ${side.toUpperCase()} filled during reprice check — skipping cancel`); - if (!pos.firstFillTime) pos.firstFillTime = Date.now(); - return; - } - - const oldOrderId = s.orderId; - const cancelled = await cancelOrder(oldOrderId); - - // Wait 1500ms — matching engine can take up to 5s to settle a fill after - // the API acknowledges a cancel. 300ms is too short to catch most races. - await sleep(1500); - const oldStatus = await checkOrderStatus(oldOrderId); - if (oldStatus === 'filled' || oldStatus === 'partial') { - logger.money(`MakerMM${tag}: ${side.toUpperCase()} filled during reprice cancel (status: ${oldStatus}) — skipping new order`); - s.filled = true; - if (!pos.firstFillTime) pos.firstFillTime = Date.now(); - return; - } - if (!cancelled) { - logger.warn(`MakerMM${tag}: reprice ${side.toUpperCase()} — cancel failed (status: ${oldStatus}), skipping to avoid duplicate`); - return; - } - - // Final WS-fill check before placing new order - if (s.filled) { - logger.money(`MakerMM${tag}: ${side.toUpperCase()} filled while verifying cancel — skipping new order`); - if (!pos.firstFillTime) pos.firstFillTime = Date.now(); - return; - } - - const orderShares = pos.targetShares; - - const result = await placeLimitBuy(s.tokenId, orderShares, newBid, pos.tickSize, pos.negRisk); - if (result.success) { - // One last check: if old order filled while we were placing the new one, cancel it immediately - if (s.filled) { - logger.warn(`MakerMM${tag}: ${side.toUpperCase()} old order filled while placing new — cancelling new order to prevent double fill`); - await cancelOrder(result.orderId); - return; - } - const newOrderId = result.orderId; - s.orderId = newOrderId; - s.buyPrice = newBid; - s.cost = orderShares * newBid; - s.orderShares = orderShares; // may differ from targetShares when loss-compensating - // Track entryBid once — never update so drift tracks from original entry - if (!s.entryBid) s.entryBid = entryBid; - - // Background: matching engine may still fill the old order up to ~6s post-cancel. - // If that happens, cancel the new order immediately to prevent double-fill. - setTimeout(async () => { - try { - if (s.orderId !== newOrderId) return; // already repriced again — skip - const delayedStatus = await checkOrderStatus(oldOrderId); - if (delayedStatus === 'filled' || delayedStatus === 'partial') { - logger.warn( - `MakerMM${tag}: delayed fill on cancelled ${side.toUpperCase()} order — ` + - `cancelling new order ${newOrderId.slice(-8)} to prevent double-fill` - ); - if (!s.filled) { - s.filled = true; - if (!pos.firstFillTime) pos.firstFillTime = Date.now(); - } - await cancelOrder(newOrderId); - } - } catch {} - }, 5000); - } else { - logger.warn(`MakerMM${tag}: reprice ${side.toUpperCase()} failed — order not replaced`); - } - }; - - // Sequential: recheck filled status before each side in case WS fill arrived mid-reprice - await repriceSide('yes'); - if (!pos.no.filled) await repriceSide('no'); -} - // ── Get current market odds ────────────────────────────────────────────────── async function getMarketOdds(yesTokenId, noTokenId) { try { @@ -264,6 +125,110 @@ async function checkOrderStatus(orderId) { return 'unknown'; } +// ── Market sell ─────────────────────────────────────────────────────────────── +// Verifies onchain balance after each attempt — CLOB fill confirmation alone is +// not enough because sells can also be ghost-filled (CLOB says done, txhash invalid, +// shares still in wallet). Retries up to 3 times with onchain verification. +async function marketSellToken(tokenId, shares, tickSize, negRisk, tag) { + if (config.dryRun) { + logger.info(`MakerMM${tag}: [SIM] would market-sell ${shares.toFixed(4)} shares of token ${tokenId.slice(-8)}`); + return true; + } + + const client = getClient(); + const maxAttempts = 3; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + // Snapshot balance before sell — source of truth for whether it went through + const balanceBefore = (await getTokenBalance(tokenId)) ?? 0; + if (balanceBefore < 0.01) { + logger.info(`MakerMM${tag}: sell skipped — balance already 0`); + return true; + } + + const sharesToSell = Math.min(shares, balanceBefore); + + let refPrice = 0.01; + try { + const bidResult = await client.getPrice(tokenId, 'BUY'); + const bid = parseFloat(bidResult?.price ?? bidResult ?? '0'); + if (bid > 0) refPrice = Math.max(bid * 0.97, 0.01); + } catch {} + + try { + const response = await client.createAndPostMarketOrder( + { tokenID: tokenId, side: Side.SELL, amount: sharesToSell, price: refPrice }, + { tickSize, negRisk }, + OrderType.FAK, + ); + + if (!response?.success || parseFloat(response?.takingAmount || '0') === 0) { + logger.warn(`MakerMM${tag}: sell attempt ${attempt}/${maxAttempts} — CLOB rejected (${response?.errorMsg || 'no liquidity'})`); + await sleep(3000); + continue; + } + + // CLOB says filled — wait then verify onchain balance actually decreased + await sleep(8000); + const balanceAfter = (await getTokenBalance(tokenId)) ?? balanceBefore; + const sold = balanceBefore - balanceAfter; + + if (sold >= sharesToSell * 0.5) { + logger.money(`MakerMM${tag}: sold ${sold.toFixed(4)} shares @ ~$${refPrice.toFixed(3)} (attempt ${attempt})`); + return true; + } + + // Balance unchanged → ghost sell, retry + logger.warn(`MakerMM${tag}: sell attempt ${attempt}/${maxAttempts} ghost — CLOB filled but ${balanceAfter.toFixed(4)} shares still onchain, retrying...`); + await sleep(5000 * attempt); + + } catch (err) { + logger.error(`MakerMM${tag}: sell attempt ${attempt}/${maxAttempts} error — ${err.message}`); + await sleep(3000); + } + } + + logger.warn(`MakerMM${tag}: sell failed after ${maxAttempts} attempts — shares remain in wallet (will resolve at market close)`); + return false; +} + +// ── Ghost fill recovery ─────────────────────────────────────────────────────── +// Onchain balance doesn't match what CLOB says was filled (partial or full ghost). +// Strategy: merge whatever paired shares exist, then market-sell any unpaired remainder. +// Handles all partial amounts — caller passes actual onchain balances. +async function recoverFromGhostFill(pos, yesShares, noShares, tag) { + logger.warn( + `MakerMM${tag}: ghost fill recovery — onchain YES=${yesShares.toFixed(4)} NO=${noShares.toFixed(4)} ` + + `(expected ${pos.targetShares} each)` + ); + + const mergeable = Math.floor(Math.min(yesShares, noShares) * 10000) / 10000; + let mergeRecovered = 0; + + if (mergeable >= 1) { + try { + await mergePositions(pos.conditionId, mergeable, pos.negRisk); + mergeRecovered = mergeable; + logger.money(`MakerMM${tag}: ghost recovery merge ${mergeable.toFixed(4)} pairs → $${mergeRecovered.toFixed(2)}`); + } catch (err) { + logger.error(`MakerMM${tag}: ghost recovery merge failed — ${err.message}`); + } + } + + const yesRemainder = parseFloat(Math.max(0, yesShares - mergeable).toFixed(6)); + const noRemainder = parseFloat(Math.max(0, noShares - mergeable).toFixed(6)); + + if (yesRemainder >= 1) { + await marketSellToken(pos.yes.tokenId, yesRemainder, pos.tickSize, pos.negRisk, tag); + } + if (noRemainder >= 1) { + await marketSellToken(pos.no.tokenId, noRemainder, pos.tickSize, pos.negRisk, tag); + } + + pos.totalProfit = mergeRecovered - (pos.yes.cost + pos.no.cost); + pos.status = 'done'; +} + async function placeLimitBuy(tokenId, shares, price, tickSize, negRisk) { if (config.dryRun) { return { success: true, orderId: `sim-buy-${Date.now()}-${tokenId.slice(-6)}` }; @@ -356,7 +321,6 @@ async function monitorUntilFilled(pos, tag, label) { try { let fastFillCheckCount = 0; const maxFastChecks = 10; // 1s polling for first 10s - let lastRepriceCheck = 0; // track last reprice attempt time while (true) { // Safety guard: exit immediately if resolved by any path @@ -377,16 +341,12 @@ async function monitorUntilFilled(pos, tag, label) { const yesShares = parseFloat(Math.max(0, (yesBal || 0) - pos.yes.baseline).toFixed(6)); const noShares = parseFloat(Math.max(0, (noBal || 0) - pos.no.baseline).toFixed(6)); - // Sync fill flags from onchain (overrides any stale WS flag). - // Use s.orderShares if set (loss-compensating reprice may order > targetShares), - // so we wait for the actual order size to fill, not just targetShares. - const yesOrderShares = pos.yes.orderShares ?? pos.targetShares; - const noOrderShares = pos.no.orderShares ?? pos.targetShares; - if (!pos.yes.filled && yesShares >= yesOrderShares * 0.99) { + // Sync fill flags from onchain (source of truth) + if (!pos.yes.filled && yesShares >= pos.targetShares * 0.99) { pos.yes.filled = true; logger.money(`MakerMM${tag}: YES filled (onchain) ${yesShares.toFixed(4)} shares`); } - if (!pos.no.filled && noShares >= noOrderShares * 0.99) { + if (!pos.no.filled && noShares >= pos.targetShares * 0.99) { pos.no.filled = true; logger.money(`MakerMM${tag}: NO filled (onchain) ${noShares.toFixed(4)} shares`); } @@ -408,6 +368,64 @@ async function monitorUntilFilled(pos, tag, label) { if (!pos.firstFillTime) pos.firstFillTime = Date.now(); } + // ── Ghost fill detection via open orders check ──────────────────────── + // More reliable than checkOrderStatus(orderId) which can return 'unknown' + // for ghost fills (invalid txhash → CLOB state is inconsistent). + // If our order is gone from open orders but onchain balance didn't increase + // → order was matched in CLOB but settlement failed (ghost fill). + { + const nowMs = Date.now(); + const client = getClient(); + + if (!pos.yes.filled && !pos.yes.clobFilled && pos.yes.orderId && nowMs - (pos.yes.lastClobCheck || 0) >= 15_000) { + pos.yes.lastClobCheck = nowMs; + try { + const openOrders = await client.getOpenOrders({ asset_id: pos.yes.tokenId }); + const stillOpen = Array.isArray(openOrders) && openOrders.some(o => (o.id ?? o.order_id) === pos.yes.orderId); + if (!stillOpen) { + pos.yes.clobFilled = true; + logger.info(`MakerMM${tag}: YES order gone from CLOB open orders (onchain not yet reflected)`); + } + } catch {} + } + if (!pos.no.filled && !pos.no.clobFilled && pos.no.orderId && nowMs - (pos.no.lastClobCheck || 0) >= 15_000) { + pos.no.lastClobCheck = nowMs; + try { + const openOrders = await client.getOpenOrders({ asset_id: pos.no.tokenId }); + const stillOpen = Array.isArray(openOrders) && openOrders.some(o => (o.id ?? o.order_id) === pos.no.orderId); + if (!stillOpen) { + pos.no.clobFilled = true; + logger.info(`MakerMM${tag}: NO order gone from CLOB open orders (onchain not yet reflected)`); + } + } catch {} + } + + // Ghost fill detection: + // CLOB says order is FILLED but onchain balance < expected after timeout. + // Could be full ghost (0 tokens) or partial (some tokens, but not all). + // Trigger: either side clobFilled AND onchain short of target after 60s. + const yesGhost = pos.yes.clobFilled && yesShares < pos.targetShares * 0.99; + const noGhost = pos.no.clobFilled && noShares < pos.targetShares * 0.99; + + if (yesGhost || noGhost) { + if (!pos.ghostFillSince) pos.ghostFillSince = nowMs; + const waitedSec = Math.round((nowMs - pos.ghostFillSince) / 1000); + if (waitedSec >= 30) { + // 30s is enough to distinguish settlement delay from ghost fill. + // Act now while market prices are still fair — don't wait for cut-loss. + await recoverFromGhostFill(pos, yesShares, noShares, tag); + return; + } else { + logger.info( + `MakerMM${tag}: ghost fill suspected ` + + `(YES CLOB=${pos.yes.clobFilled} onchain=${yesShares.toFixed(4)}, ` + + `NO CLOB=${pos.no.clobFilled} onchain=${noShares.toFixed(4)}) ` + + `— waiting ${waitedSec}s / 30s` + ); + } + } + } + // ── WS fallback: both sides WS-confirmed filled but onchain RPC not reflecting ── // If onchain balance is unavailable (RPC slow/failed) but both filled flags are // set from WS signals, wait a grace period then merge with targetShares as fallback. @@ -511,18 +529,6 @@ async function monitorUntilFilled(pos, tag, label) { } } - // ── Threshold repricing ─────────────────────────────────────────── - // Reprice unfilled side(s) only when price has drifted > threshold. - const repriceNow = Date.now(); - if ( - !pos.yes.filled || !pos.no.filled - ) { - if (repriceNow - lastRepriceCheck >= config.makerMmRepriceSec * 1000) { - lastRepriceCheck = repriceNow; - await checkAndReprice(pos, tag); - } - } - // Fast polling first 10s, then event-driven with 5s fallback fastFillCheckCount++; if (fastFillCheckCount < maxFastChecks) { @@ -803,8 +809,7 @@ export async function executeMakerRebateStrategy(market) { cost: yesCost, orderId: finalYesBuy.orderId, filled: false, - baseline: yesBaseline || 0, // pre-order balance — subtract to get net new fills - entryBid: yesEntryBid, // bestBid at entry — for bid drift tracking + baseline: yesBaseline || 0, }, no: { tokenId: noTokenId, @@ -812,8 +817,7 @@ export async function executeMakerRebateStrategy(market) { cost: noCost, orderId: finalNoBuy.orderId, filled: false, - baseline: noBaseline || 0, // pre-order balance — subtract to get net new fills - entryBid: noEntryBid, // bestBid at entry — for bid drift tracking + baseline: noBaseline || 0, }, totalProfit: 0, };