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:
co-authored by
Claude Sonnet 4.6
parent
ececad8574
commit
78c66a9b1e
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)}`);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user