From e19278c665e45aeedf777939dfb0c09c220bce60 Mon Sep 17 00:00:00 2001 From: direkturcrypto Date: Fri, 27 Mar 2026 04:43:56 +0700 Subject: [PATCH 1/8] feat: add defensive pivot mode for 5m markets when neither side fills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When neither limit sell fills within MM_DEFENSIVE_TIMEOUT (default 120s), the bot enters defensive mode: - Cancel both limit sells - Wait until 30s before market close - If worst side price < MM_DEFENSIVE_WORST_THRESHOLD (default 10c): market sell worst side, hold best side for resolution (best ≈ 90c+) - If worst side ≥ threshold: merge back to USDC (safe $0 P&L) Only active for 5-minute markets (MM_DURATION=5m). New config: MM_DEFENSIVE_ENABLED (default true) MM_DEFENSIVE_TIMEOUT (default 120s) MM_DEFENSIVE_WORST_THRESHOLD (default 0.10) Co-Authored-By: Claude Opus 4.6 --- src/config/index.js | 8 +++ src/services/mmExecutor.js | 112 +++++++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/src/config/index.js b/src/config/index.js index 19d4fb4..a5e691a 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -67,6 +67,14 @@ const config = { mmAdaptiveMinCombined: parseFloat(process.env.MM_ADAPTIVE_MIN_COMBINED || '1.20'), // min combined sell (both legs) to qualify for limit mmAdaptiveMonitorSec: parseInt(process.env.MM_ADAPTIVE_MONITOR_SEC || '5', 10), + // ── Defensive Pivot (5m markets only) ───────────────────────── + // When NEITHER side fills within timeout, enter defensive mode: + // At 30s before close, if worst side < threshold → market sell worst, keep best + // Otherwise merge back to USDC (zero P&L) + mmDefensiveEnabled: process.env.MM_DEFENSIVE_ENABLED !== 'false', // default on + mmDefensiveTimeout: parseInt(process.env.MM_DEFENSIVE_TIMEOUT || '120', 10), // secs without fill → defensive + mmDefensiveWorstThreshold: parseFloat(process.env.MM_DEFENSIVE_WORST_THRESHOLD || '0.10'), // sell worst if price < this + // ── Recovery Buy (after cut-loss) ───────────────────────────── // When enabled: after cutting loss, monitor prices for 10s and // market-buy the dominant side if it's above threshold and rising/stable. diff --git a/src/services/mmExecutor.js b/src/services/mmExecutor.js index 570f340..8886548 100644 --- a/src/services/mmExecutor.js +++ b/src/services/mmExecutor.js @@ -229,6 +229,18 @@ async function monitorAndManage(pos) { break; } + // ── Defensive pivot: neither filled after timeout (5m markets only) ── + if (config.mmDefensiveEnabled && config.mmDuration === '5m' + && !pos.yes.filled && !pos.no.filled && !pos._defensiveActive) { + const elapsed = (Date.now() - new Date(pos.enteredAt).getTime()) / 1000; + if (elapsed >= config.mmDefensiveTimeout) { + pos._defensiveActive = true; + logger.warn(`MM: neither side filled after ${Math.round(elapsed)}s — entering defensive mode | ${label}`); + await defensivePivot(pos); + break; + } + } + // ── Cut-loss time ──────────────────────────────────────────────────── if (msRemaining <= config.mmCutLossTime * 1000) { logger.warn(`MM: cut-loss triggered (${Math.round(msRemaining / 1000)}s left) — ${label}`); @@ -321,6 +333,106 @@ async function cutLossNeitherFilled(pos) { await attemptRecoveryBuy(pos); } +// ── Defensive Pivot (5m markets, neither side filled) ──────────────────────── + +/** + * Defensive pivot: neither side has filled after MM_DEFENSIVE_TIMEOUT. + * + * Strategy: + * 1. Cancel both limit sells, keep monitoring + * 2. Wait until 30s before close + * 3. Check prices: identify worst (lower price) and best (higher price) side + * 4. If worst < MM_DEFENSIVE_WORST_THRESHOLD (default 10c): + * → market sell worst side, keep best side (let it resolve at close) + * → since YES+NO ≈ $1, best side is ~90c+ → profit potential + * 5. If worst ≥ threshold: market is still uncertain → merge back ($0 P&L) + */ +async function defensivePivot(pos) { + const { conditionId, tickSize, negRisk } = pos; + const label = pos.question.substring(0, 40); + const threshold = config.mmDefensiveWorstThreshold; + + // Cancel both limit sells immediately — we'll decide at 30s mark + await cancelOrder(pos.yes.orderId); + await cancelOrder(pos.no.orderId); + logger.info(`MM defensive: cancelled both limit sells — waiting for 30s before close | ${label}`); + + // Wait until 30s before close, checking every 5s if one side fills via partial + while (true) { + const msLeft = new Date(pos.endTime).getTime() - Date.now(); + + if (msLeft <= 30_000) break; // 30s mark reached + if (msLeft <= 0) { + pos.status = 'expired'; + return; + } + + await sleep(5000); + } + + // Read current prices for both sides + const [yesPrice, noPrice] = await Promise.all([ + getMidprice(pos.yes.tokenId), + getMidprice(pos.no.tokenId), + ]); + + logger.info(`MM defensive: 30s mark — YES=$${yesPrice.toFixed(3)}, NO=$${noPrice.toFixed(3)} | threshold=$${threshold} | ${label}`); + + // Determine worst and best sides + const worstKey = yesPrice <= noPrice ? 'yes' : 'no'; + const bestKey = worstKey === 'yes' ? 'no' : 'yes'; + const worstPrice = Math.min(yesPrice, noPrice); + const bestPrice = Math.max(yesPrice, noPrice); + + // ── Decision: pivot or merge? ───────────────────────────────────────── + if (worstPrice < threshold) { + // Worst side < 10c → market is decisive, pivot! + logger.trade(`MM defensive: worst side ${worstKey.toUpperCase()} @ $${worstPrice.toFixed(3)} < $${threshold} — selling worst, keeping ${bestKey.toUpperCase()} @ $${bestPrice.toFixed(3)}`); + + const worstSide = pos[worstKey]; + const bestSide = pos[bestKey]; + + // Get actual on-chain balances + const [worstBalance, bestBalance] = await Promise.all([ + getTokenBalance(worstSide.tokenId), + getTokenBalance(bestSide.tokenId), + ]); + const worstShares = worstBalance !== null ? worstBalance : worstSide.shares; + const bestShares = bestBalance !== null ? bestBalance : bestSide.shares; + + // Market sell worst side + if (worstShares >= 0.001) { + const result = await marketSell(worstSide.tokenId, worstShares, tickSize, negRisk); + worstSide.fillPrice = result.fillPrice; + worstSide.filled = true; + logger.warn(`MM defensive: sold ${worstKey.toUpperCase()} ${worstShares.toFixed(3)} sh @ $${result.fillPrice.toFixed(3)}`); + } else { + worstSide.fillPrice = 0; + worstSide.filled = true; + } + + // Best side: let it resolve at market close (hold the tokens) + // The market will resolve and we can redeem via the redeemer + // Best side price is ~90c+ so payout ≈ $1 per share if it wins + logger.money(`MM defensive: holding ${bestKey.toUpperCase()} ${bestShares.toFixed(3)} sh @ ~$${bestPrice.toFixed(3)} — waiting for resolution`); + logger.info(`MM defensive: expected payout if ${bestKey.toUpperCase()} wins: ~$${bestShares.toFixed(2)} | cost was $${(bestSide.entryPrice * bestShares).toFixed(2)}`); + + // Mark best side as filled at entry price for now — actual payout handled by redeemer + bestSide.fillPrice = bestSide.entryPrice; + bestSide.filled = true; + pos.status = 'done'; + + const worstPnl = worstSide.fillPrice + ? (worstSide.fillPrice - worstSide.entryPrice) * worstShares + : 0; + logger.info(`MM defensive: worst side P&L: $${worstPnl.toFixed(2)} | best side will be redeemed after resolution`); + } else { + // Worst side ≥ 10c → market uncertain, safer to merge + logger.info(`MM defensive: worst side ${worstKey.toUpperCase()} @ $${worstPrice.toFixed(3)} ≥ $${threshold} — market uncertain, merging back to USDC`); + await cutLossNeitherFilled(pos); + } +} + async function adaptiveLegCL(pos, unfilledKey) { const s = pos[unfilledKey]; const { tickSize, negRisk } = pos; From 8a5962d0a54b16ed22535e875ca60e5c95609d2a Mon Sep 17 00:00:00 2001 From: direkturcrypto Date: Fri, 27 Mar 2026 04:54:42 +0700 Subject: [PATCH 2/8] fix: measure defensive timeout from market open time, not bot entry time Market start = endTime - 5 minutes, so defensive triggers at exactly 120s after the market opens regardless of when the bot entered. Co-Authored-By: Claude Opus 4.6 --- src/services/mmExecutor.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/services/mmExecutor.js b/src/services/mmExecutor.js index 8886548..a24f381 100644 --- a/src/services/mmExecutor.js +++ b/src/services/mmExecutor.js @@ -232,10 +232,13 @@ async function monitorAndManage(pos) { // ── Defensive pivot: neither filled after timeout (5m markets only) ── if (config.mmDefensiveEnabled && config.mmDuration === '5m' && !pos.yes.filled && !pos.no.filled && !pos._defensiveActive) { - const elapsed = (Date.now() - new Date(pos.enteredAt).getTime()) / 1000; + // Measure from market open time (endTime - duration), not bot entry time + const marketDurationMs = 5 * 60 * 1000; + const marketStartMs = new Date(pos.endTime).getTime() - marketDurationMs; + const elapsed = (Date.now() - marketStartMs) / 1000; if (elapsed >= config.mmDefensiveTimeout) { pos._defensiveActive = true; - logger.warn(`MM: neither side filled after ${Math.round(elapsed)}s — entering defensive mode | ${label}`); + logger.warn(`MM: neither side filled after ${Math.round(elapsed)}s since market open — entering defensive mode | ${label}`); await defensivePivot(pos); break; } From cc90176861159310fa3481f3207cb7853ce819f4 Mon Sep 17 00:00:00 2001 From: direkturcrypto Date: Fri, 27 Mar 2026 05:01:38 +0700 Subject: [PATCH 3/8] feat: implement tiered floor + standing order for adaptive CL (5m markets) Adaptive CL now uses progressive floor lowering for 5-minute markets: Phase 1 (>180s left): breakeven floor (e.g. 40c for 60c fill) Phase 2 (90-180s): floor - 10c (accept small loss to escape) Phase 3 (30-90s): floor - 20c + emergency cut if price < 10c Phase 4 (<30s): force market sell Key improvements: - Standing order: immediately place limit at breakeven floor so brief bounces get caught (don't wait for polling to detect price >= floor) - Emergency cut: market sell immediately if price < 10c in phase 3 (market is decisive, bounce unlikely) - Non-5m markets still use the existing fixed floor (mmAdaptiveMinCombined) Co-Authored-By: Claude Opus 4.6 --- src/services/mmExecutor.js | 116 ++++++++++++++++++++++++++++++------- 1 file changed, 94 insertions(+), 22 deletions(-) diff --git a/src/services/mmExecutor.js b/src/services/mmExecutor.js index a24f381..8e80b8e 100644 --- a/src/services/mmExecutor.js +++ b/src/services/mmExecutor.js @@ -450,8 +450,37 @@ async function adaptiveLegCL(pos, unfilledKey) { const filledLegPrice = pos[filledKey].fillPrice ?? config.mmSellPrice; const minAdaptivePrice = Math.max(0, config.mmAdaptiveMinCombined - filledLegPrice); + // ── Tiered floors (5m markets): progressively lower floor over time ──── + // breakevenFloor: filledLeg + unfilledLeg = $1.00 → zero net P&L + const breakevenFloor = Math.max(0, 1.00 - filledLegPrice); + const floorDrop = config.mmDefensiveEnabled ? 0.10 : 0; + const emergencyPrice = config.mmDefensiveWorstThreshold; // default 0.10 + + const is5m = config.mmDuration === '5m'; + + /** + * Get the current floor based on time remaining (5m markets only). + * Other durations use the fixed mmAdaptiveMinCombined floor. + * + * Phase 1 (> 180s left): breakevenFloor (e.g. 0.40 for 60c fill) + * Phase 2 (90–180s): breakevenFloor - 0.10 (e.g. 0.30) + * Phase 3 (30–90s): breakevenFloor - 0.20 (e.g. 0.20) + * Phase 4 (< 30s): market sell + */ + function getTieredFloor(msLeft) { + if (!is5m) return minAdaptivePrice; // non-5m: use fixed floor + if (msLeft > 180_000) return breakevenFloor; + if (msLeft > 90_000) return Math.max(0.01, breakevenFloor - floorDrop); + if (msLeft > 30_000) return Math.max(0.01, breakevenFloor - floorDrop * 2); + return 0; // phase 4: market sell + } + logger.warn(`MM: one leg filled — starting adaptive CL for ${unfilledKey.toUpperCase()} | ${label}`); - logger.info(`MM adaptive CL: filled leg @ $${filledLegPrice.toFixed(3)} | min floor for combined ≥ $${config.mmAdaptiveMinCombined.toFixed(2)}: $${minAdaptivePrice.toFixed(3)}`); + if (is5m) { + logger.info(`MM adaptive CL: filled @ $${filledLegPrice.toFixed(3)} | breakeven floor: $${breakevenFloor.toFixed(3)} | tiered: $${breakevenFloor.toFixed(2)} → $${Math.max(0.01, breakevenFloor - floorDrop).toFixed(2)} → $${Math.max(0.01, breakevenFloor - floorDrop * 2).toFixed(2)}`); + } else { + logger.info(`MM adaptive CL: filled leg @ $${filledLegPrice.toFixed(3)} | min floor for combined ≥ $${config.mmAdaptiveMinCombined.toFixed(2)}: $${minAdaptivePrice.toFixed(3)}`); + } // Cancel the unfilled leg's old GTC order immediately await cancelOrder(s.orderId); @@ -482,25 +511,30 @@ async function adaptiveLegCL(pos, unfilledKey) { return; } - logger.info(`MM adaptive CL: monitoring ${unfilledKey.toUpperCase()} — limit only when price ≥ $${minAdaptivePrice.toFixed(3)}, market-sell only at CL time`); - + // Place standing order at breakeven floor immediately (5m) so brief bounces get caught let activeOrderId = null; let activeLimitPrice = 0; + let currentFloor = is5m ? breakevenFloor : minAdaptivePrice; + + if (is5m && sellShares >= CLOB_MIN_ORDER_SHARES) { + logger.info(`MM adaptive CL: placing standing limit sell @ $${breakevenFloor.toFixed(3)} (breakeven floor)`); + const standing = await placeLimitSell(s.tokenId, sellShares, breakevenFloor, tickSize, negRisk); + if (standing.success) { + activeOrderId = standing.orderId; + activeLimitPrice = breakevenFloor; + } + } else { + logger.info(`MM adaptive CL: monitoring ${unfilledKey.toUpperCase()} — floor $${currentFloor.toFixed(3)}, market-sell at CL time`); + } // ── Continuous monitoring loop ───────────────────────────────────────────── - // Every poll cycle: - // 1. CL time → cancel limit, market sell (last resort) - // 2. Check fill → done - // 3. Read current price - // 4a. Price < floor OR dropped >5% → cancel limit, keep watching - // 4b. Price improved >2% → cancel and re-place higher - // 5a. price >= floor → place/maintain limit at min(currentPrice, mmSellPrice) - // 5b. price < floor → no limit placed, log & wait (never sell below profit floor) + let lastPhaseLog = ''; + while (true) { const msLeft = new Date(pos.endTime).getTime() - Date.now(); - // ── CL time: last resort market sell ─────────────────────────────── - if (msLeft <= config.mmCutLossTime * 1000) { + // ── Phase 4 / CL time: force market sell ──────────────────────────── + if (msLeft <= (is5m ? 30_000 : config.mmCutLossTime * 1000)) { if (activeOrderId) { await cancelOrder(activeOrderId); activeOrderId = null; @@ -508,6 +542,27 @@ async function adaptiveLegCL(pos, unfilledKey) { break; } + // ── Update tiered floor ───────────────────────────────────────────── + const newFloor = getTieredFloor(msLeft); + if (newFloor !== currentFloor) { + const phase = msLeft > 180_000 ? '1-breakeven' : msLeft > 90_000 ? '2-controlled' : '3-emergency'; + if (phase !== lastPhaseLog) { + logger.info(`MM adaptive CL: phase ${phase} — floor $${currentFloor.toFixed(3)} → $${newFloor.toFixed(3)} (${Math.round(msLeft / 1000)}s left)`); + lastPhaseLog = phase; + } + // If floor lowered and we have an active order above new floor, keep it + // Only cancel+re-place if the floor dropped below our current limit + if (activeOrderId && activeLimitPrice > newFloor) { + // Current limit is above new floor — that's fine, keep it + } else if (activeOrderId && activeLimitPrice < newFloor) { + // Floor raised (shouldn't happen in tiered, but safety) + await cancelOrder(activeOrderId); + activeOrderId = null; + activeLimitPrice = 0; + } + currentFloor = newFloor; + } + // ── Check fill ────────────────────────────────────────────────────── if (activeOrderId) { let filled = false; @@ -536,17 +591,27 @@ async function adaptiveLegCL(pos, unfilledKey) { continue; } + // ── Emergency cut: price < 10c in phase 3 → market sell immediately ─ + if (is5m && msLeft <= 90_000 && currentPrice < emergencyPrice) { + logger.warn(`MM adaptive CL: EMERGENCY — price $${currentPrice.toFixed(3)} < $${emergencyPrice} with ${Math.round(msLeft / 1000)}s left — market selling now`); + if (activeOrderId) { + await cancelOrder(activeOrderId); + activeOrderId = null; + } + break; // fall through to market sell below + } + const targetPrice = Math.min(currentPrice, config.mmSellPrice); // ── Adjust or cancel active limit ─────────────────────────────────── if (activeOrderId) { - const belowFloor = currentPrice < minAdaptivePrice; + const belowFloor = currentPrice < currentFloor; const droppedHard = currentPrice < activeLimitPrice * 0.95; const priceImproved = targetPrice > activeLimitPrice * 1.02; if (belowFloor || droppedHard) { const reason = belowFloor - ? `below floor $${minAdaptivePrice.toFixed(3)} (combined $${(filledLegPrice + currentPrice).toFixed(3)} < $${config.mmAdaptiveMinCombined.toFixed(2)})` + ? `below floor $${currentFloor.toFixed(3)}` : `dropped >5% from limit $${activeLimitPrice.toFixed(3)}`; logger.info(`MM adaptive CL: price $${currentPrice.toFixed(3)} ${reason} — cancelling limit, watching for recovery`); await cancelOrder(activeOrderId); @@ -561,7 +626,7 @@ async function adaptiveLegCL(pos, unfilledKey) { } } - // ── Place limit only above the profitable floor ───────────────────── + // ── Place limit at floor or above ─────────────────────────────────── if (!activeOrderId) { // Re-check actual balance — partial fills may have reduced it const currentBalance = await getTokenBalance(s.tokenId); @@ -587,15 +652,21 @@ async function adaptiveLegCL(pos, unfilledKey) { return; } - if (currentPrice >= minAdaptivePrice) { - logger.info(`MM adaptive CL: placing limit sell @ $${targetPrice.toFixed(3)} (mid: $${currentPrice.toFixed(3)}, combined: $${(filledLegPrice + targetPrice).toFixed(3)}, ${Math.round(msLeft / 1000)}s left)`); - const result = await placeLimitSell(s.tokenId, remainingShares, targetPrice, tickSize, negRisk); + // Place at max(currentPrice, floor) — standing order strategy + const sellPrice = Math.max(currentPrice, currentFloor); + const limitPrice = Math.min(sellPrice, config.mmSellPrice); + + if (currentPrice >= currentFloor || is5m) { + // 5m: always place at floor or above (standing order catches bounces) + // non-5m: only place when price >= floor + logger.info(`MM adaptive CL: placing limit sell @ $${limitPrice.toFixed(3)} (mid: $${currentPrice.toFixed(3)}, floor: $${currentFloor.toFixed(3)}, ${Math.round(msLeft / 1000)}s left)`); + const result = await placeLimitSell(s.tokenId, remainingShares, limitPrice, tickSize, negRisk); if (result.success) { activeOrderId = result.orderId; - activeLimitPrice = targetPrice; + activeLimitPrice = limitPrice; } } else { - logger.info(`MM adaptive CL: price $${currentPrice.toFixed(3)} below floor $${minAdaptivePrice.toFixed(3)} (combined $${(filledLegPrice + currentPrice).toFixed(3)}) — waiting for recovery (${Math.round(msLeft / 1000)}s left)`); + logger.info(`MM adaptive CL: price $${currentPrice.toFixed(3)} below floor $${currentFloor.toFixed(3)} — waiting for recovery (${Math.round(msLeft / 1000)}s left)`); } } @@ -615,7 +686,8 @@ async function adaptiveLegCL(pos, unfilledKey) { return; } - logger.warn(`MM adaptive CL: CL time reached — market-selling ${finalShares.toFixed(3)} ${unfilledKey.toUpperCase()} shares`); + const exitReason = is5m ? 'phase 4 force exit (<30s)' : 'CL time reached'; + logger.warn(`MM adaptive CL: ${exitReason} — market-selling ${finalShares.toFixed(3)} ${unfilledKey.toUpperCase()} shares`); const result = await marketSell(s.tokenId, finalShares, tickSize, negRisk); s.fillPrice = result.fillPrice; const pnl = (s.fillPrice - s.entryPrice) * finalShares; From df09403f186278e9610b545c22919e867ed7522f Mon Sep 17 00:00:00 2001 From: direkturcrypto Date: Fri, 27 Mar 2026 05:09:02 +0700 Subject: [PATCH 4/8] fix: change defensive pivot trigger from 30s to 45s before close Co-Authored-By: Claude Opus 4.6 --- src/services/mmExecutor.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/services/mmExecutor.js b/src/services/mmExecutor.js index 8e80b8e..8e99483 100644 --- a/src/services/mmExecutor.js +++ b/src/services/mmExecutor.js @@ -343,7 +343,7 @@ async function cutLossNeitherFilled(pos) { * * Strategy: * 1. Cancel both limit sells, keep monitoring - * 2. Wait until 30s before close + * 2. Wait until 45s before close * 3. Check prices: identify worst (lower price) and best (higher price) side * 4. If worst < MM_DEFENSIVE_WORST_THRESHOLD (default 10c): * → market sell worst side, keep best side (let it resolve at close) @@ -358,13 +358,13 @@ async function defensivePivot(pos) { // Cancel both limit sells immediately — we'll decide at 30s mark await cancelOrder(pos.yes.orderId); await cancelOrder(pos.no.orderId); - logger.info(`MM defensive: cancelled both limit sells — waiting for 30s before close | ${label}`); + logger.info(`MM defensive: cancelled both limit sells — waiting for 45s before close | ${label}`); // Wait until 30s before close, checking every 5s if one side fills via partial while (true) { const msLeft = new Date(pos.endTime).getTime() - Date.now(); - if (msLeft <= 30_000) break; // 30s mark reached + if (msLeft <= 45_000) break; // 45s mark reached if (msLeft <= 0) { pos.status = 'expired'; return; @@ -379,7 +379,7 @@ async function defensivePivot(pos) { getMidprice(pos.no.tokenId), ]); - logger.info(`MM defensive: 30s mark — YES=$${yesPrice.toFixed(3)}, NO=$${noPrice.toFixed(3)} | threshold=$${threshold} | ${label}`); + logger.info(`MM defensive: 45s mark — YES=$${yesPrice.toFixed(3)}, NO=$${noPrice.toFixed(3)} | threshold=$${threshold} | ${label}`); // Determine worst and best sides const worstKey = yesPrice <= noPrice ? 'yes' : 'no'; From 1da662313d51febc7a39edeec2bb99a31d053da3 Mon Sep 17 00:00:00 2001 From: direkturcrypto Date: Fri, 27 Mar 2026 05:15:01 +0700 Subject: [PATCH 5/8] fix: use mid price for initial adaptive CL sell instead of fixed breakeven floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Check mid price first, place limit at market price (capped at mmSellPrice). Only falls back to breakeven floor if mid price is below it. Example: YES filled @ 70c, NO mid = 38c → sell @ 38c (not 30c breakeven) This gives profit potential instead of always targeting breakeven. Co-Authored-By: Claude Opus 4.6 --- src/services/mmExecutor.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/services/mmExecutor.js b/src/services/mmExecutor.js index 8e99483..8e6d216 100644 --- a/src/services/mmExecutor.js +++ b/src/services/mmExecutor.js @@ -517,11 +517,17 @@ async function adaptiveLegCL(pos, unfilledKey) { let currentFloor = is5m ? breakevenFloor : minAdaptivePrice; if (is5m && sellShares >= CLOB_MIN_ORDER_SHARES) { - logger.info(`MM adaptive CL: placing standing limit sell @ $${breakevenFloor.toFixed(3)} (breakeven floor)`); - const standing = await placeLimitSell(s.tokenId, sellShares, breakevenFloor, tickSize, negRisk); + // Check mid price first — place at market price (not just breakeven floor) + const initMid = await getMidprice(s.tokenId); + // Use mid price if above floor, otherwise use floor as safety net + const initSellPrice = initMid >= currentFloor + ? Math.min(initMid, config.mmSellPrice) + : currentFloor; + logger.info(`MM adaptive CL: mid=$${initMid.toFixed(3)}, placing initial limit sell @ $${initSellPrice.toFixed(3)} (floor=$${currentFloor.toFixed(3)})`); + const standing = await placeLimitSell(s.tokenId, sellShares, initSellPrice, tickSize, negRisk); if (standing.success) { activeOrderId = standing.orderId; - activeLimitPrice = breakevenFloor; + activeLimitPrice = initSellPrice; } } else { logger.info(`MM adaptive CL: monitoring ${unfilledKey.toUpperCase()} — floor $${currentFloor.toFixed(3)}, market-sell at CL time`); From ba250572b993798b315a8fed7278b1aa8213fc63 Mon Sep 17 00:00:00 2001 From: direkturcrypto Date: Fri, 27 Mar 2026 05:25:11 +0700 Subject: [PATCH 6/8] fix: cancel orders immediately when defensive timeout reached MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Orders must be cancelled BEFORE the monitoring loop can detect fills, otherwise the limit sell at 70c can still get filled at minute 3 and trigger adaptive CL instead of defensive pivot. Now: timeout reached → cancel both orders → enter defensivePivot Co-Authored-By: Claude Opus 4.6 --- src/services/mmExecutor.js | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/services/mmExecutor.js b/src/services/mmExecutor.js index 8e6d216..2c96e8f 100644 --- a/src/services/mmExecutor.js +++ b/src/services/mmExecutor.js @@ -237,8 +237,13 @@ async function monitorAndManage(pos) { const marketStartMs = new Date(pos.endTime).getTime() - marketDurationMs; const elapsed = (Date.now() - marketStartMs) / 1000; if (elapsed >= config.mmDefensiveTimeout) { + // Cancel both orders FIRST so they can't fill while we wait + logger.warn(`MM: neither side filled after ${Math.round(elapsed)}s since market open — cancelling orders & entering defensive mode | ${label}`); + await cancelOrder(pos.yes.orderId); + await cancelOrder(pos.no.orderId); + pos.yes.orderId = null; + pos.no.orderId = null; pos._defensiveActive = true; - logger.warn(`MM: neither side filled after ${Math.round(elapsed)}s since market open — entering defensive mode | ${label}`); await defensivePivot(pos); break; } @@ -342,7 +347,7 @@ async function cutLossNeitherFilled(pos) { * Defensive pivot: neither side has filled after MM_DEFENSIVE_TIMEOUT. * * Strategy: - * 1. Cancel both limit sells, keep monitoring + * 1. Orders already cancelled by caller (monitorAndManage) * 2. Wait until 45s before close * 3. Check prices: identify worst (lower price) and best (higher price) side * 4. If worst < MM_DEFENSIVE_WORST_THRESHOLD (default 10c): @@ -355,12 +360,10 @@ async function defensivePivot(pos) { const label = pos.question.substring(0, 40); const threshold = config.mmDefensiveWorstThreshold; - // Cancel both limit sells immediately — we'll decide at 30s mark - await cancelOrder(pos.yes.orderId); - await cancelOrder(pos.no.orderId); - logger.info(`MM defensive: cancelled both limit sells — waiting for 45s before close | ${label}`); + // Orders already cancelled by monitorAndManage before entering here + logger.info(`MM defensive: waiting for 45s before close | ${label}`); - // Wait until 30s before close, checking every 5s if one side fills via partial + // Wait until 45s before close, checking every 5s while (true) { const msLeft = new Date(pos.endTime).getTime() - Date.now(); From b05fed92075745658899d30492c5e60bdda3fc12 Mon Sep 17 00:00:00 2001 From: direkturcrypto Date: Fri, 27 Mar 2026 13:24:33 +0700 Subject: [PATCH 7/8] fix: add on-chain balance fallback for fill detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLOB API (client.getOrder) can be stale — order filled on-chain but API still shows unfilled. This caused the bot to miss fills and incorrectly enter defensive mode or keep monitoring dead orders. Now isOrderFilled does: 1. Check CLOB API (existing) with retry 2. Fallback: check on-chain token balance via CTF contract If balance < 5% of original shares → treat as filled Fixes issue where Polymarket UI shows filled but bot doesn't detect it. Co-Authored-By: Claude Opus 4.6 --- src/services/mmExecutor.js | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/src/services/mmExecutor.js b/src/services/mmExecutor.js index 2c96e8f..3b133fe 100644 --- a/src/services/mmExecutor.js +++ b/src/services/mmExecutor.js @@ -110,22 +110,37 @@ async function marketSell(tokenId, shares, tickSize, negRisk) { // ── Order status check ──────────────────────────────────────────────────────── -async function isOrderFilled(orderId, shares) { +async function isOrderFilled(orderId, shares, tokenId = null) { if (!orderId || orderId.startsWith('sim-')) return false; const MAX_FILL_RETRIES = 2; for (let attempt = 1; attempt <= MAX_FILL_RETRIES; attempt++) { try { const client = getClient(); const order = await client.getOrder(orderId); - if (!order) return false; + if (!order) break; // order gone — fall through to balance check if (order.status === 'MATCHED') return true; const matched = parseFloat(order.size_matched || '0'); - return matched >= shares * 0.99; + if (matched >= shares * 0.99) return true; + // CLOB says not filled — trust it if we have no tokenId for balance check + if (!tokenId) return false; + // Otherwise fall through to balance check below + break; } catch (err) { - logger.warn(`MM: isOrderFilled error (attempt ${attempt}/${MAX_FILL_RETRIES}): ${err.message}`); + logger.warn(`MM: isOrderFilled CLOB error (attempt ${attempt}/${MAX_FILL_RETRIES}): ${err.message}`); if (attempt < MAX_FILL_RETRIES) await sleep(2000); } } + + // Fallback: check on-chain token balance + // If we placed a SELL and our balance is now ~0, the order was filled + if (tokenId) { + const balance = await getTokenBalance(tokenId); + if (balance !== null && balance < shares * 0.05) { + logger.warn(`MM: CLOB API missed fill — on-chain balance ${balance.toFixed(3)} ≈ 0 (expected ${shares}) → treating as filled`); + return true; + } + } + return false; } @@ -187,7 +202,7 @@ async function monitorAndManage(pos) { const hitPrice = await simPriceHitTarget(pos.yes.tokenId); if (hitPrice) { filled = true; pos.yes.fillPrice = hitPrice; } } else { - filled = await isOrderFilled(pos.yes.orderId, pos.yes.shares); + filled = await isOrderFilled(pos.yes.orderId, pos.yes.shares, pos.yes.tokenId); if (filled) pos.yes.fillPrice = config.mmSellPrice; } if (filled) { @@ -204,7 +219,7 @@ async function monitorAndManage(pos) { const hitPrice = await simPriceHitTarget(pos.no.tokenId); if (hitPrice) { filled = true; pos.no.fillPrice = hitPrice; } } else { - filled = await isOrderFilled(pos.no.orderId, pos.no.shares); + filled = await isOrderFilled(pos.no.orderId, pos.no.shares, pos.no.tokenId); if (filled) pos.no.fillPrice = config.mmSellPrice; } if (filled) { @@ -579,7 +594,7 @@ async function adaptiveLegCL(pos, unfilledKey) { const hitPrice = await simPriceHitTarget(s.tokenId); if (hitPrice) { filled = true; s.fillPrice = hitPrice; } } else { - filled = await isOrderFilled(activeOrderId, sellShares); + filled = await isOrderFilled(activeOrderId, sellShares, s.tokenId); if (filled) s.fillPrice = activeLimitPrice; } From 565f98571a3ce72e9f0f84f3c5fb357ef6fb5229 Mon Sep 17 00:00:00 2001 From: direkturcrypto Date: Fri, 27 Mar 2026 13:26:23 +0700 Subject: [PATCH 8/8] fix: add proxy-patch import to mm-bot and mm entry points Both sniper and dumper had proxy-patch.cjs imported but market maker entry points (mm-bot.js, mm.js) were missing it, causing direct connections without proxy. Co-Authored-By: Claude Opus 4.6 --- src/mm-bot.js | 3 +++ src/mm.js | 3 +++ 2 files changed, 6 insertions(+) diff --git a/src/mm-bot.js b/src/mm-bot.js index 3153d17..a60aa6b 100644 --- a/src/mm-bot.js +++ b/src/mm-bot.js @@ -6,6 +6,9 @@ * pm2 logs polymarket-mm */ +// Set proxy before any network calls +import './utils/proxy-patch.cjs'; + import { validateMMConfig } from './config/index.js'; import config from './config/index.js'; import logger from './utils/logger.js'; diff --git a/src/mm.js b/src/mm.js index 2feef7b..707c05d 100644 --- a/src/mm.js +++ b/src/mm.js @@ -6,6 +6,9 @@ * npm run mm-sim (simulation / dry-run) */ +// Set proxy before any network calls +import './utils/proxy-patch.cjs'; + import { validateMMConfig } from './config/index.js'; import config from './config/index.js'; import logger from './utils/logger.js';