3a4dbf2b02
- 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>
834 lines
40 KiB
JavaScript
834 lines
40 KiB
JavaScript
/**
|
||
* 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 };
|
||
}
|