Files
polymarket-terminal/src/services/makerRebateExecutor.js
T
direkturcrypto 3a4dbf2b02 feat: add MAKER_MM_REENTRY_ENABLED config + fix float combined check
- MAKER_MM_REENTRY_ENABLED=false disables re-entry (one cycle per market)
- Fix floating point bug: 0.1+0.5=0.6000000000000001 caused "combined > max"
  loop when MAKER_MM_MAX_COMBINED=0.60 — now rounds combined to 4dp before compare

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 14:19:25 +07:00

834 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* makerRebateExecutor.js
* Simplified Maker Rebate MM strategy:
* 1. Fetch YES orderbook
* 2. Deduce NO price from YES (YES + NO ≈ $1.00)
* 3. Place BUY limit once on both sides (NO repricing)
* 4. Wait for 100% fill with SAME share count on both sides
* 5. Merge YES+NO → $1.00 USDC → profit + maker rebates
*/
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 { mmFillWatcher } from './mmWsFillWatcher.js';
import logger from '../utils/logger.js';
const CTF_ADDRESS = '0x4D97DCd97eC945f40cF65F87097ACe5EA0476045';
const CTF_BALANCE_ABI = ['function balanceOf(address account, uint256 id) view returns (uint256)'];
const CLOB_MIN_ORDER_SHARES = 5;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Price range limits — configurable via MAKER_MM_MIN_PRICE / MAKER_MM_MAX_PRICE
// Both sides must be within this range to qualify for maker rebates
const getMinPrice = () => config.makerMmMinPrice;
const getMaxPrice = () => config.makerMmMaxPrice;
const activePositions = new Map();
export function getActiveMakerPositions() {
return Array.from(activePositions.values());
}
// Export for use in maker-mm-bot.js
export { getMarketOdds };
// ── Price helpers ────────────────────────────────────────────────────────────
async function getRealPrice(tokenId) {
const client = getClient();
try {
const result = await client.getPrice(tokenId, 'BUY');
const price = parseFloat(result?.price ?? result ?? '0');
if (price > 0 && price < 1) return price;
} catch (err) {
logger.warn(`MakerMM: getPrice error — ${err.message}`);
}
try {
const mp = await client.getMidpoint(tokenId);
const price = parseFloat(mp?.mid ?? mp ?? '0');
if (price > 0 && price < 1) return price;
} catch {}
return null;
}
function roundToTick(price, tickSize) {
const ts = parseFloat(tickSize);
const rounded = Math.round(price / ts) * ts;
const decimals = tickSize.toString().split('.')[1]?.length || 2;
return Math.max(0.01, Math.min(0.99, parseFloat(rounded.toFixed(decimals))));
}
// ── Get best ask via getPrice(SELL) — the lowest price a seller will accept ────
// Used as a safety cap to ensure our bid never crosses the ask (taker prevention).
async function getBestAsk(tokenId) {
const client = getClient();
try {
const result = await client.getPrice(tokenId, 'SELL');
const price = parseFloat(result?.price ?? result ?? '0');
return (price > 0 && price < 1) ? price : null;
} catch (err) {
logger.warn(`MakerMM: getBestAsk error — ${err.message}`);
return null;
}
}
// ── Bid-based repricing ───────────────────────────────────────────────────────
// ── Get current market odds ──────────────────────────────────────────────────
async function getMarketOdds(yesTokenId, noTokenId) {
try {
const [yesPrice, noPrice] = await Promise.all([
getRealPrice(yesTokenId),
getRealPrice(noTokenId),
]);
if (yesPrice && noPrice) {
return { yes: yesPrice, no: noPrice, max: Math.max(yesPrice, noPrice) };
}
} catch (err) {
logger.warn(`MakerMM: getMarketOdds error — ${err.message}`);
}
return null;
}
// ── Order helpers ────────────────────────────────────────────────────────────
/**
* Check order status via CLOB API
* Returns true if order is filled (even if createAndPostOrder returned false)
*/
async function checkOrderStatus(orderId) {
if (!orderId || orderId.startsWith('filled-') || orderId.startsWith('sim-')) return null;
try {
const client = getClient();
const order = await client.getOrder(orderId);
// Order might be: OPEN, FILLED, PARTIAL_FILLED, CANCELLED, etc.
if (order?.status === 'FILLED' || order?.status === 'FILLED_FULLY') {
return 'filled';
}
if (order?.status === 'PARTIAL_FILLED' || order?.status === 'FILLED_PARTIALLY') {
return 'partial';
}
if (order?.status === 'CANCELLED' || order?.status === 'CANCELLED_BY_USER' || order?.status === 'EXPIRED') {
return 'cancelled';
}
if (order?.status === 'OPEN') {
return 'open';
}
} catch (err) {
// Order not found or API error - consider as unknown
logger.debug(`MakerMM: order status check failed for ${orderId?.slice(-8)}${err.message}`);
}
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)}` };
}
const client = getClient();
try {
const res = await client.createAndPostOrder(
{ tokenID: tokenId, side: Side.BUY, price, size: shares },
{ tickSize, negRisk },
OrderType.GTC,
);
if (!res?.success) {
logger.error(`MakerMM: limit buy failed — response: ${JSON.stringify(res)}`);
return { success: false };
}
return { success: true, orderId: res.orderID };
} catch (err) {
logger.error(`MakerMM: limit buy error — ${err.message}`);
return { success: false };
}
}
async function cancelOrder(orderId) {
if (config.dryRun || !orderId || orderId.startsWith('sim-')) return true;
try {
const client = getClient();
await client.cancelOrder({ orderID: orderId });
return true;
} catch (err) {
logger.warn(`MakerMM: cancel error — ${err.message}`);
return false;
}
}
// ── Fill detection ───────────────────────────────────────────────────────────
async function getTokenBalance(tokenId) {
try {
const provider = getPolygonProvider(); // singleton — no await needed
const ctf = new ethers.Contract(CTF_ADDRESS, CTF_BALANCE_ABI, provider);
const raw = await ctf.balanceOf(config.proxyWallet, tokenId);
return parseFloat(ethers.utils.formatUnits(raw, 6));
} catch { return null; }
}
function waitForFillOrTimeout(tokenIds, timeoutMs) {
return new Promise((resolve) => {
let timer;
const onFill = (event) => {
if (tokenIds.includes(event.tokenId)) {
clearTimeout(timer);
mmFillWatcher.removeListener('fill', onFill);
resolve(event);
}
};
mmFillWatcher.on('fill', onFill);
timer = setTimeout(() => {
mmFillWatcher.removeListener('fill', onFill);
resolve(null);
}, timeoutMs);
});
}
// ── Core monitoring ───────────────────────────────────────────────────────────
async function monitorUntilFilled(pos, tag, label) {
mmFillWatcher.watch(pos.yes.tokenId);
mmFillWatcher.watch(pos.no.tokenId);
// WS fill events: early signal only — onchain balance is the source of truth.
// Side filter removed: RTDS may report side from taker perspective (SELL),
// not our maker perspective. We're already gated by proxyWallet + tokenId.
const onWsFill = (event) => {
// WS is used only as a wake-up signal — do NOT set pos.filled here.
// Setting filled=true from WS on a partial fill (e.g. 2 of 5 shares) would
// make the loop think the side is done and skip the onchain balance check,
// leaving the position stuck. Onchain balance is the sole source of truth.
if (event.tokenId === pos.yes.tokenId) {
logger.money(`MakerMM${tag}: YES fill signal (WS) ${event.size?.toFixed(2) || '?'} @ $${event.price?.toFixed(3) || pos.yes.buyPrice.toFixed(3)}`);
}
if (event.tokenId === pos.no.tokenId) {
logger.money(`MakerMM${tag}: NO fill signal (WS) ${event.size?.toFixed(2) || '?'} @ $${event.price?.toFixed(3) || pos.no.buyPrice.toFixed(3)}`);
}
};
mmFillWatcher.on('fill', onWsFill);
// Brief pause to let WebSocket register token subscriptions
await sleep(50);
try {
let fastFillCheckCount = 0;
const maxFastChecks = 10; // 1s polling for first 10s
while (true) {
// Safety guard: exit immediately if resolved by any path
if (pos.status === 'done') return;
// ── Onchain balance — source of truth, checked FIRST ──────────────
const [yesBal, noBal] = await Promise.all([
getTokenBalance(pos.yes.tokenId),
getTokenBalance(pos.no.tokenId),
]);
// NET new shares only — subtract baseline to exclude leftover tokens
// from previous cycles on the same tokenId. Without this, re-entry
// would see old balance >= 0.5x target and trigger a false early merge
// while the new orders are still open in the orderbook.
// Use toFixed(6) — full precision to avoid rounding UP past actual token balance.
// toFixed(4) could round 4.910199 → 4.9102 (4910200 wei) when Safe has 4910199 → revert.
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 (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 >= pos.targetShares * 0.99) {
pos.no.filled = true;
logger.money(`MakerMM${tag}: NO filled (onchain) ${noShares.toFixed(4)} shares`);
}
// ── 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).
// Cancel the open order immediately so it doesn't also fill.
if (yesShares > pos.targetShares * 1.5 && pos.yes.orderId && !pos.yes.filled) {
logger.warn(`MakerMM${tag}: YES over-position (${yesShares.toFixed(4)} > 1.5x target=${pos.targetShares}) — cancelling open order to stop double-fill`);
await cancelOrder(pos.yes.orderId);
pos.yes.filled = true;
if (!pos.firstFillTime) pos.firstFillTime = Date.now();
}
if (noShares > pos.targetShares * 1.5 && pos.no.orderId && !pos.no.filled) {
logger.warn(`MakerMM${tag}: NO over-position (${noShares.toFixed(4)} > 1.5x target=${pos.targetShares}) — cancelling open order to stop double-fill`);
await cancelOrder(pos.no.orderId);
pos.no.filled = true;
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.
if (pos.yes.filled && pos.no.filled && yesShares < pos.targetShares * 0.5 && noShares < pos.targetShares * 0.5) {
if (!pos.bothFilledSince) pos.bothFilledSince = Date.now();
const waitedSec = Math.round((Date.now() - pos.bothFilledSince) / 1000);
if (waitedSec >= 15) {
logger.warn(
`MakerMM${tag}: both sides WS-filled but onchain shows YES=${yesShares} NO=${noShares} after ${waitedSec}s ` +
`— RPC may be stale, merging with target ${pos.targetShares} shares`
);
await executeMerge(pos, pos.targetShares, tag);
if (pos.status === 'done') return;
} else {
logger.info(`MakerMM${tag}: both WS-filled, waiting for onchain confirmation (${waitedSec}s / 15s grace)...`);
}
}
// Both sides have net balance ≥ 50% target → merge
if (yesShares >= pos.targetShares * 0.5 && noShares >= pos.targetShares * 0.5) {
pos.bothFilledSince = null; // onchain confirmed — clear WS fallback timer
const minShares = Math.min(yesShares, noShares);
const isFull = yesShares >= pos.targetShares * 0.99 && noShares >= pos.targetShares * 0.99;
logger.success(
`MakerMM${tag}: ${isFull ? 'FULL' : 'PARTIAL'} fill — ` +
`YES=${yesShares.toFixed(4)} NO=${noShares.toFixed(4)}, merging ${minShares.toFixed(4)} shares`
);
pos.yes.filled = true;
pos.no.filled = true;
await executeMerge(pos, minShares, tag);
if (pos.status === 'done') return;
// Merge call errored — but tx may have confirmed onchain despite the RPC error
// (common: tx.wait() timeout while tx was already included in a block).
// Re-check balance to avoid looping forever on an empty position.
const [yesRecheck, noRecheck] = await Promise.all([
getTokenBalance(pos.yes.tokenId),
getTokenBalance(pos.no.tokenId),
]);
const yesNetRecheck = Math.max(0, (yesRecheck || 0) - pos.yes.baseline);
const noNetRecheck = Math.max(0, (noRecheck || 0) - pos.no.baseline);
if (yesNetRecheck < pos.targetShares * 0.1 && noNetRecheck < pos.targetShares * 0.1) {
logger.success(`MakerMM${tag}: merge confirmed onchain (RPC reported error but tx went through)`);
pos.status = 'done';
pos.totalProfit = minShares - (pos.yes.cost + pos.no.cost);
return;
}
pos.mergeFailCount = (pos.mergeFailCount || 0) + 1;
const backoffSec = Math.min(5 * pos.mergeFailCount, 30); // 5s, 10s, 15s … max 30s
logger.warn(`MakerMM${tag}: merge failed (attempt ${pos.mergeFailCount}) — tokens still present (YES=${yesNetRecheck.toFixed(6)} NO=${noNetRecheck.toFixed(6)}), retrying in ${backoffSec}s`);
await sleep(backoffSec * 1000);
}
// ── Cut-loss check (AFTER balance check) ──────────────────────────
const msRemaining = new Date(pos.endTime).getTime() - Date.now();
if (msRemaining <= config.makerMmCutLossTime * 1000) {
logger.warn(`MakerMM${tag}: cut-loss — net YES=${yesShares.toFixed(4)} NO=${noShares.toFixed(4)}`);
if (yesShares >= 1 && noShares >= 1) {
// Both sides have net fills — emergency merge to recover USDC
const minShares = Math.min(yesShares, noShares);
logger.warn(`MakerMM${tag}: emergency merge ${minShares.toFixed(4)} shares`);
await executeMerge(pos, minShares, tag);
} else {
// One or neither side net-filled — cancel open orders, log held tokens
await Promise.all([
cancelOrder(pos.yes.orderId),
cancelOrder(pos.no.orderId),
]);
if (yesShares > 0 || noShares > 0) {
logger.warn(`MakerMM${tag}: tokens held — net YES=${yesShares.toFixed(4)} NO=${noShares.toFixed(4)} (cannot merge)`);
pos.totalProfit = -((yesShares > 0 ? pos.yes.cost : 0) + (noShares > 0 ? pos.no.cost : 0));
pos.oneSided = true; // flag: cycle ended with one-sided fill
} else {
logger.info(`MakerMM${tag}: no net fills — orders cancelled, zero loss`);
pos.totalProfit = 0;
}
pos.status = 'done';
}
return;
}
// ── One side filled — log status and keep waiting ─────────────────
if (pos.yes.filled !== pos.no.filled) {
const filledKey = pos.yes.filled ? 'yes' : 'no';
const now = Date.now();
if (now < pos.marketOpenTime) {
logger.info(`MakerMM${tag}: ${filledKey.toUpperCase()} filled — market not open yet (${Math.round((pos.marketOpenTime - now) / 1000)}s), waiting...`);
} else {
if (!pos.firstFillTime) {
pos.firstFillTime = now;
logger.info(`MakerMM${tag}: ${filledKey.toUpperCase()} filled first — waiting for other side...`);
} else {
const elapsedMin = Math.floor((now - pos.firstFillTime) / 60000);
if (elapsedMin > 0 && elapsedMin % 5 === 0 && pos.lastLogMin !== elapsedMin) {
pos.lastLogMin = elapsedMin;
logger.info(`MakerMM${tag}: still waiting for ${filledKey === 'yes' ? 'NO' : 'YES'}${elapsedMin}m elapsed`);
}
}
}
}
// Fast polling first 10s, then event-driven with 5s fallback
fastFillCheckCount++;
if (fastFillCheckCount < maxFastChecks) {
await sleep(1000);
} else {
await waitForFillOrTimeout([pos.yes.tokenId, pos.no.tokenId], 5000);
}
}
} finally {
mmFillWatcher.removeListener('fill', onWsFill);
mmFillWatcher.unwatch(pos.yes.tokenId);
mmFillWatcher.unwatch(pos.no.tokenId);
// Cancel any residual open orders — can happen when loss-compensating reprice
// placed extra shares (e.g. 6 NO) but merge triggered after 5 filled,
// leaving 1 remaining NO share still open in the orderbook.
await Promise.all([
cancelOrder(pos.yes.orderId),
cancelOrder(pos.no.orderId),
]).catch(() => {});
}
}
async function executeMerge(pos, shares, tag) {
const totalCost = pos.yes.cost + pos.no.cost;
const recovered = shares; // Merge returns $1 per share
pos.totalProfit = recovered - totalCost;
try {
await mergePositions(pos.conditionId, shares, pos.negRisk);
// Orders are already fully filled at this point — no cancel needed
logger.money(`MakerMM${tag}: MERGED ${shares.toFixed(4)} shares → $${recovered.toFixed(2)} | cost $${totalCost.toFixed(2)} | P&L $${pos.totalProfit.toFixed(2)}`);
pos.status = 'done';
} catch (err) {
logger.error(`MakerMM${tag}: merge failed — ${err.message}`);
// Don't change status — let monitor loop continue
}
}
// ── Main entry ───────────────────────────────────────────────────────────────
export async function executeMakerRebateStrategy(market) {
const { asset, conditionId, question, endTime, eventStartTime, yesTokenId, noTokenId, negRisk, tickSize } = market;
const tag = asset ? `[${asset.toUpperCase()}]` : '';
const label = question.substring(0, 40);
const sim = config.dryRun ? '[SIM] ' : '';
// Market officially opens at eventStartTime (not when we detect it)
const marketOpenTime = eventStartTime ? new Date(eventStartTime).getTime() : Date.now();
// Wait until 10 seconds after market open before placing any orders.
// Orders placed too early (pre-open or first few seconds) tend to open at a loss
// due to wide spreads and erratic pricing before liquidity stabilizes.
const ENTRY_DELAY_MS = 10_000;
const entryNotBefore = marketOpenTime + ENTRY_DELAY_MS;
const waitMs = entryNotBefore - Date.now();
if (waitMs > 0) {
logger.info(`MakerMM${tag}: ${sim}waiting ${Math.round(waitMs / 1000)}s for market to stabilize (open +10s)...`);
await sleep(waitMs);
}
logger.info(`MakerMM${tag}: ${sim}entering — ${label}`);
// ── Wait for real YES price ─────────────────────────────────
const POLL_SEC = 3;
const ts = parseFloat(tickSize);
let yesBid, noBid, combined;
let yesEntryBid, noEntryBid; // best bid at time of entry — stored for drift tracking
const waitStart = Date.now();
const MIN_PRICE = getMinPrice();
const MAX_PRICE = getMaxPrice();
while (true) {
const msRemaining = new Date(endTime).getTime() - Date.now();
if (msRemaining <= config.makerMmCutLossTime * 1000) {
logger.warn(`MakerMM${tag}: market closing — aborting`);
return;
}
// ── Bid-based pricing: bid = bestBid + 1_tick (top of orderbook, guaranteed maker) ──
// We become the new top bid, getting fill priority over existing bids.
// Safety cap: newBid < bestAsk ensures we never accidentally cross and become a taker.
const [yesBestBid, yesAsk, noBestBid, noAsk] = await Promise.all([
getRealPrice(yesTokenId),
getBestAsk(yesTokenId),
getRealPrice(noTokenId),
getBestAsk(noTokenId),
]);
if (!yesBestBid || !noBestBid) {
logger.info(`MakerMM${tag}: waiting — no bid data (YES: ${yesBestBid ?? 'null'}, NO: ${noBestBid ?? 'null'})`);
await sleep(POLL_SEC * 1000);
continue;
}
// Target = bestBid + 1 tick; safety cap below ask
yesBid = roundToTick(yesBestBid + ts, tickSize);
if (yesAsk && yesBid >= yesAsk) yesBid = roundToTick(yesAsk - ts, tickSize);
// Range check on YES bid
if (yesBid < MIN_PRICE || yesBid > MAX_PRICE) {
logger.info(`MakerMM${tag}: waiting — YES bid $${yesBid.toFixed(3)} (need ${MIN_PRICE}-${MAX_PRICE})`);
await sleep(POLL_SEC * 1000);
continue;
}
// NO bid: fill remaining combined budget
noBid = roundToTick(config.makerMmMaxCombined - yesBid, tickSize);
// Safety: ensure NO is also strictly below NO ask (maker)
if (noAsk && noBid >= noAsk) noBid = roundToTick(noAsk - ts, tickSize);
// Range check on NO bid
if (noBid < MIN_PRICE || noBid > MAX_PRICE) {
logger.info(`MakerMM${tag}: waiting — NO bid $${noBid.toFixed(3)} (need ${MIN_PRICE}-${MAX_PRICE})`);
await sleep(POLL_SEC * 1000);
continue;
}
combined = parseFloat((yesBid + noBid).toFixed(4));
if (combined > config.makerMmMaxCombined) {
logger.info(`MakerMM${tag}: combined $${combined.toFixed(4)} > max — waiting`);
await sleep(POLL_SEC * 1000);
continue;
}
// If combined is more than 1 tick below target the market spread is too tight.
// Wait for better conditions instead of entering with lower-than-expected profit.
const minCombined = parseFloat((config.makerMmMaxCombined - ts).toFixed(4));
if (combined < minCombined) {
logger.info(`MakerMM${tag}: spread too tight — combined $${combined.toFixed(4)} < target $${config.makerMmMaxCombined} — waiting`);
await sleep(POLL_SEC * 1000);
continue;
}
yesEntryBid = yesBestBid;
noEntryBid = noBestBid;
const waitSec = ((Date.now() - waitStart) / 1000).toFixed(1);
logger.success(`MakerMM${tag}: ready after ${waitSec}s — YES $${yesBid} + NO $${noBid} = $${combined.toFixed(4)} (topBid YES:$${yesBestBid} NO:$${noBestBid})`);
break;
}
// ── Calculate shares ──────────────────────────────────────────
const targetShares = config.makerMmTradeSize;
if (targetShares < CLOB_MIN_ORDER_SHARES) {
logger.warn(`MakerMM${tag}: shares ${targetShares} < min ${CLOB_MIN_ORDER_SHARES} — skipping`);
return;
}
const yesCost = targetShares * yesBid;
const noCost = targetShares * noBid;
const totalCost = yesCost + noCost;
if (!config.dryRun) {
const balance = await getUsdcBalance();
if (balance < totalCost) {
logger.error(`MakerMM${tag}: insufficient balance $${balance.toFixed(2)} (need $${totalCost.toFixed(2)})`);
return;
}
}
// ── Snapshot balance BEFORE placing orders ────────────────────────────────
// Critical for re-entry: same tokenIds are reused each cycle, so leftover
// tokens from a previous cycle would otherwise fool the fill-detection logic
// into thinking the new orders filled instantly, causing a new cycle to start
// while the actual new orders remain open in the orderbook.
const [yesBaseline, noBaseline] = await Promise.all([
getTokenBalance(yesTokenId),
getTokenBalance(noTokenId),
]);
if ((yesBaseline || 0) > 0 || (noBaseline || 0) > 0) {
logger.info(`MakerMM${tag}: pre-order baseline — YES=${(yesBaseline || 0).toFixed(4)} NO=${(noBaseline || 0).toFixed(4)} (leftover from prior cycle)`);
}
// ── Place orders ONCE (NO repricing) ──────────────────────
logger.trade(`MakerMM${tag}: placing BUY — YES $${yesBid} × ${targetShares} + NO $${noBid} × ${targetShares} = $${totalCost.toFixed(2)}`);
const [yesBuy, noBuy] = await Promise.all([
placeLimitBuy(yesTokenId, targetShares, yesBid, tickSize, negRisk),
placeLimitBuy(noTokenId, targetShares, noBid, tickSize, negRisk),
]);
logger.info(`MakerMM${tag}: order results — YES: ${yesBuy.success ? 'OK' : 'FAIL'} (id=${yesBuy.orderId?.slice(-8) || 'none'}), NO: ${noBuy.success ? 'OK' : 'FAIL'} (id=${noBuy.orderId?.slice(-8) || 'none'})`);
// If one side failed, check if actually filled on-chain OR via order book before retrying
let finalYesBuy = yesBuy;
let finalNoBuy = noBuy;
const maxRetries = 3;
for (let attempt = 1; attempt <= maxRetries && (!finalYesBuy.success || !finalNoBuy.success); attempt++) {
// Check 1: On-chain balance (most reliable) — compare against baseline
const [yesBalance, noBalance] = await Promise.all([
getTokenBalance(yesTokenId),
getTokenBalance(noTokenId),
]);
const yesNet = (yesBalance || 0) - (yesBaseline || 0);
const noNet = (noBalance || 0) - (noBaseline || 0);
// Check 2: Order status via CLOB API (backup check)
const [yesOrderStatus, noOrderStatus] = await Promise.all([
finalYesBuy.success ? null : checkOrderStatus(yesBuy.orderId),
finalNoBuy.success ? null : checkOrderStatus(noBuy.orderId),
]);
if (yesOrderStatus || noOrderStatus) {
logger.info(`MakerMM${tag}: order status check — YES: ${yesOrderStatus || 'N/A'}, NO: ${noOrderStatus || 'N/A'}`);
}
// Use net (new) balance to determine if actually filled — not total balance
if (!finalYesBuy.success && (
yesNet >= targetShares * 0.5 ||
yesOrderStatus === 'filled' ||
yesOrderStatus === 'partial'
)) {
logger.success(`MakerMM${tag}: YES already filled (net: ${yesNet.toFixed(4)}, order: ${yesOrderStatus}) — no retry`);
finalYesBuy = { success: true, orderId: yesBuy.orderId || `filled-${Date.now()}` };
}
if (!finalNoBuy.success && (
noNet >= targetShares * 0.5 ||
noOrderStatus === 'filled' ||
noOrderStatus === 'partial'
)) {
logger.success(`MakerMM${tag}: NO already filled (net: ${noNet.toFixed(4)}, order: ${noOrderStatus}) — no retry`);
finalNoBuy = { success: true, orderId: noBuy.orderId || `filled-${Date.now()}` };
}
if (finalYesBuy.success && finalNoBuy.success) break;
// Cancel existing order before retry to avoid duplicate orders
if (!finalYesBuy.success) {
logger.warn(`MakerMM${tag}: retrying YES order (attempt ${attempt}/${maxRetries})...`);
await cancelOrder(yesBuy.orderId);
await sleep(500);
finalYesBuy = await placeLimitBuy(yesTokenId, targetShares, yesBid, tickSize, negRisk);
if (finalYesBuy.success) {
logger.success(`MakerMM${tag}: YES order succeeded on retry ${attempt}`);
}
}
if (!finalNoBuy.success) {
logger.warn(`MakerMM${tag}: retrying NO order (attempt ${attempt}/${maxRetries})...`);
await cancelOrder(noBuy.orderId);
await sleep(500);
finalNoBuy = await placeLimitBuy(noTokenId, targetShares, noBid, tickSize, negRisk);
if (finalNoBuy.success) {
logger.success(`MakerMM${tag}: NO order succeeded on retry ${attempt}`);
}
}
}
if (!finalYesBuy.success || !finalNoBuy.success) {
logger.error(`MakerMM${tag}: order failed after retries — YES: ${finalYesBuy.success}, NO: ${finalNoBuy.success}`);
await Promise.all([
finalYesBuy.success ? cancelOrder(finalYesBuy.orderId) : null,
finalNoBuy.success ? cancelOrder(finalNoBuy.orderId) : null,
]);
return;
}
// ── Build position and wait ─────────────────────────────────
const pos = {
asset: asset || 'btc',
conditionId,
question,
endTime,
marketOpenTime,
tickSize,
negRisk,
status: 'monitoring',
targetShares,
yes: {
tokenId: yesTokenId,
buyPrice: yesBid,
cost: yesCost,
orderId: finalYesBuy.orderId,
filled: false,
baseline: yesBaseline || 0,
},
no: {
tokenId: noTokenId,
buyPrice: noBid,
cost: noCost,
orderId: finalNoBuy.orderId,
filled: false,
baseline: noBaseline || 0,
},
totalProfit: 0,
};
activePositions.set(conditionId, pos);
await monitorUntilFilled(pos, tag, label);
activePositions.delete(conditionId);
const sign = pos.totalProfit >= 0 ? '+' : '';
logger.info(`MakerMM${tag}: done | P&L: ${sign}$${pos.totalProfit.toFixed(2)}`);
return { oneSided: pos.oneSided ?? false };
}