From 78c66a9b1eee4d9b824f7ba6fd4be9bd12b00f20 Mon Sep 17 00:00:00 2001 From: direkturcrypto Date: Wed, 1 Apr 2026 14:19:11 +0700 Subject: [PATCH] feat: cancel cheap side on expensive fill + auto-redeem after resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MAKER_MM_CANCEL_CHEAP_ON_EXP_FILL=true: when the expensive side fills first, immediately cancel the cheap side order and hold the token. After market ends, polls on-chain payoutDenominator until resolved, then calls redeemPositions to recover USDC. - Default false — existing symmetric maker behavior unchanged. - Redeem polls every 15s for up to 10 minutes after market close. Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 5 ++ src/config/index.js | 2 + src/services/makerRebateExecutor.js | 71 ++++++++++++++++++++++++++++- 3 files changed, 77 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index fd77233..b39f89d 100644 --- a/.env.example +++ b/.env.example @@ -102,6 +102,11 @@ MAKER_MM_REENTRY_ENABLED=true MAKER_MM_MIN_PRICE=0.30 MAKER_MM_MAX_PRICE=0.69 +# Cancel cheap side when expensive side fills first, then hold and auto-redeem at resolution +# Example: YES=5c, NO=94c → NO fills first → cancel YES, hold NO, redeem after market ends +# Default false (symmetric maker behavior — wait for both sides) +MAKER_MM_CANCEL_CHEAP_ON_EXP_FILL=false + # ── Current Market Entry (optional) ───────────────────────────── # Allow entering markets that are already in progress # Useful for catching mid-market opportunities diff --git a/src/config/index.js b/src/config/index.js index 60f582e..6cd06e3 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -100,6 +100,8 @@ const config = { makerMmRepriceThreshold: parseFloat(process.env.MAKER_MM_REPRICE_THRESHOLD || '0.02'), // reprice if bid drifts > this (default 2c) makerMmMinPrice: parseFloat(process.env.MAKER_MM_MIN_PRICE || '0.30'), // min bid for rebate range (both sides) makerMmMaxPrice: parseFloat(process.env.MAKER_MM_MAX_PRICE || '0.69'), // max bid for rebate range (both sides) + // When true: if expensive side fills first, cancel cheap side and hold to redemption + makerMmCancelCheapOnExpFill: process.env.MAKER_MM_CANCEL_CHEAP_ON_EXP_FILL === 'true', // ── Current Market Settings ──────────────────────────────────── // Enable trading on current active market (not just next market) diff --git a/src/services/makerRebateExecutor.js b/src/services/makerRebateExecutor.js index 1d17210..7d8fbd0 100644 --- a/src/services/makerRebateExecutor.js +++ b/src/services/makerRebateExecutor.js @@ -12,7 +12,7 @@ import { Side, OrderType } from '@polymarket/clob-client'; import { ethers } from 'ethers'; import config from '../config/index.js'; import { getClient, getUsdcBalance, getPolygonProvider } from './client.js'; -import { mergePositions } from './ctf.js'; +import { mergePositions, redeemPositions } from './ctf.js'; import { mmFillWatcher } from './mmWsFillWatcher.js'; import logger from '../utils/logger.js'; @@ -351,6 +351,24 @@ async function monitorUntilFilled(pos, tag, label) { logger.money(`MakerMM${tag}: NO filled (onchain) ${noShares.toFixed(4)} shares`); } + // ── Cancel cheap side when expensive fills first ────────────────────── + // When enabled: if the expensive side fills and cheap side hasn't, + // cancel the cheap order and hold the expensive token to redeem at resolution. + if (config.makerMmCancelCheapOnExpFill) { + const expSide = pos.yes.buyPrice >= pos.no.buyPrice ? 'yes' : 'no'; + const cheapSide = expSide === 'yes' ? 'no' : 'yes'; + if (pos[expSide].filled && !pos[cheapSide].filled) { + logger.info( + `MakerMM${tag}: ${expSide.toUpperCase()} ($${pos[expSide].buyPrice}) filled first — ` + + `cancelling cheap ${cheapSide.toUpperCase()} ($${pos[cheapSide].buyPrice}) order` + ); + await cancelOrder(pos[cheapSide].orderId); + pos.holdingSide = expSide; + pos.status = 'holding'; + return; + } + } + // ── Over-position safety net ──────────────────────────────────────── // If one side's balance is > 1.5x target AND the current order is still open, // a double-fill occurred (old cancelled order + new order both filled). @@ -569,6 +587,51 @@ async function executeMerge(pos, shares, tag) { } } +// ── Auto-redeem after market resolution ────────────────────────────────────── +// Used when holding a single-sided position (expensive side filled, cheap cancelled). +// Polls until the market resolves on-chain, then calls redeemPositions. + +async function waitAndRedeem(pos, tag) { + const endMs = new Date(pos.endTime).getTime(); + const waitForEndMs = endMs - Date.now(); + + if (waitForEndMs > 0) { + logger.info(`MakerMM${tag}: holding ${pos.holdingSide.toUpperCase()} — waiting ${Math.round(waitForEndMs / 1000)}s for market to end...`); + await sleep(waitForEndMs); + } + + if (config.dryRun) { + logger.info(`MakerMM${tag}: [SIM] would redeem ${pos.holdingSide.toUpperCase()} after resolution`); + return; + } + + logger.info(`MakerMM${tag}: market ended — polling for on-chain resolution...`); + const provider = getPolygonProvider(); + const ctf = new ethers.Contract(CTF_ADDRESS, ['function payoutDenominator(bytes32 conditionId) view returns (uint256)'], provider); + const maxWaitMs = 10 * 60 * 1000; // 10 minutes max + const pollMs = 15_000; + const start = Date.now(); + + while (Date.now() - start < maxWaitMs) { + try { + const denom = await ctf.payoutDenominator(pos.conditionId); + if (!denom.isZero()) { + logger.info(`MakerMM${tag}: market resolved — redeeming ${pos.holdingSide.toUpperCase()} tokens...`); + await redeemPositions(pos.conditionId, pos.negRisk); + logger.money(`MakerMM${tag}: redemption complete`); + return; + } + } catch (err) { + logger.warn(`MakerMM${tag}: resolution poll error — ${err.message}`); + } + const elapsedSec = Math.round((Date.now() - start) / 1000); + logger.info(`MakerMM${tag}: not resolved yet (${elapsedSec}s / ${maxWaitMs / 1000}s) — retrying in ${pollMs / 1000}s...`); + await sleep(pollMs); + } + + logger.warn(`MakerMM${tag}: market not resolved after ${maxWaitMs / 60000} minutes — skipping auto-redeem (tokens remain in wallet)`); +} + // ── Main entry ─────────────────────────────────────────────────────────────── export async function executeMakerRebateStrategy(market) { @@ -839,6 +902,12 @@ export async function executeMakerRebateStrategy(market) { await monitorUntilFilled(pos, tag, label); activePositions.delete(conditionId); + // If holding a single-sided position (expensive filled, cheap cancelled) — wait and redeem + if (pos.holdingSide) { + await waitAndRedeem(pos, tag); + return { oneSided: false }; // not a stuck one-sided cycle, intentional hold + } + const sign = pos.totalProfit >= 0 ? '+' : ''; logger.info(`MakerMM${tag}: done | P&L: ${sign}$${pos.totalProfit.toFixed(2)}`);