feat: cancel cheap side on expensive fill + auto-redeem after resolution

- 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 <noreply@anthropic.com>
This commit is contained in:
direkturcrypto
2026-04-01 14:19:11 +07:00
co-authored by Claude Sonnet 4.6
parent ececad8574
commit 78c66a9b1e
3 changed files with 77 additions and 1 deletions
+5
View File
@@ -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
+2
View File
@@ -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)
+70 -1
View File
@@ -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)}`);