Files
polymarket-terminal/src/services/mmExecutor.js
T
direkturcrypto 455f265ed0 feat: WebSocket realtime fill detection + parallel API calls for MM
Replace 10s polling loop with RTDS WebSocket event-driven fill detection
(~100ms latency). Parallelize YES/NO side checks and cancel operations.
Polling kept as 30s safety net fallback.

- Add mmWsFillWatcher.js: subscribes to RTDS activity feed, filters by
  own proxy wallet, emits 'fill' events for watched tokens
- Refactor monitorAndManage(): event-driven loop with parallel checks
- Extract checkSideFill() for parallel-safe per-side fill detection
- Wire up WS watcher lifecycle in mm.js and mm-bot.js

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 22:21:50 +07:00

1136 lines
50 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.
/**
* mmExecutor.js
* Executes the market-maker strategy for a single Bitcoin 5-minute market:
* 1. Call CTF splitPosition — deposit USDC, receive equal YES+NO tokens at $0.50 flat
* 2. Place GTC limit sells at mmSellPrice for both YES and NO
* 3. Monitor until both fills or cut-loss time triggers
* 4. On cut-loss:
* - If NEITHER side filled → mergePositions (burn YES+NO, recover USDC, zero loss)
* - If ONE side already sold → cancel the other, market-sell remaining tokens
*/
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 { splitPosition, mergePositions } from './ctf.js';
import { mmFillWatcher } from './mmWsFillWatcher.js';
import logger from '../utils/logger.js';
// CTF contract for on-chain balance queries
const CTF_ADDRESS = '0x4D97DCd97eC945f40cF65F87097ACe5EA0476045';
const CTF_BALANCE_ABI = ['function balanceOf(address account, uint256 id) view returns (uint256)'];
// Polymarket CLOB minimum order size (shares)
const CLOB_MIN_ORDER_SHARES = 5;
/**
* Get actual on-chain ERC1155 token balance for the proxy wallet.
* Used before market-sell to avoid 'not enough balance' errors from partial fills.
*/
async function getTokenBalance(tokenId) {
try {
const provider = await getPolygonProvider();
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; // fallback: caller will use pos.shares
}
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Fallback poll interval — WS handles the fast path, this is the safety net
const POLL_INTERVAL_MS = 30_000;
/**
* Wait for a fill event from WebSocket OR timeout (polling fallback).
* Returns early if WS delivers a fill for any of the watched token IDs.
* @param {string[]} tokenIds - token IDs to listen for
* @param {number} timeoutMs - max wait time before returning for poll check
* @returns {Promise<{tokenId: string, size: number, price: number} | 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); // timeout — caller does poll check
}, timeoutMs);
});
}
// In-memory store of all active MM positions (conditionId → position)
const activePositions = new Map();
export function getActiveMMPositions() {
return Array.from(activePositions.values());
}
// ── Order helpers ─────────────────────────────────────────────────────────────
async function placeLimitSell(tokenId, shares, price, tickSize, negRisk) {
if (config.dryRun) {
return { success: true, orderId: `sim-${Date.now()}-${tokenId.slice(-6)}` };
}
const client = getClient();
try {
const res = await client.createAndPostOrder(
{ tokenID: tokenId, side: Side.SELL, price, size: shares },
{ tickSize, negRisk },
OrderType.GTC,
);
if (!res?.success) return { success: false };
return { success: true, orderId: res.orderID };
} catch (err) {
logger.error('MM limit sell 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 }); // SDK expects { orderID } object
return true;
} catch (err) {
logger.warn('MM cancel order error:', err.message);
return false;
}
}
async function marketSell(tokenId, shares, tickSize, negRisk) {
if (config.dryRun) {
try {
const client = getClient();
const mp = await client.getMidpoint(tokenId);
const price = parseFloat(mp?.mid ?? mp ?? '0') || 0;
return { success: true, fillPrice: price };
} catch {
return { success: true, fillPrice: 0 };
}
}
const client = getClient();
try {
const res = await client.createAndPostMarketOrder(
{ tokenID: tokenId, side: Side.SELL, amount: shares, price: 0.01 },
{ tickSize, negRisk },
OrderType.FOK,
);
if (!res?.success) return { success: false, fillPrice: 0 };
return { success: true, fillPrice: parseFloat(res.price || '0') };
} catch (err) {
logger.error('MM market sell error:', err.message);
return { success: false, fillPrice: 0 };
}
}
// ── Order status check ────────────────────────────────────────────────────────
async function isOrderFilled(orderId, shares, tokenId = null) {
if (!orderId || orderId.startsWith('sim-')) return false;
const MAX_FILL_RETRIES = 2;
for (let attempt = 1; attempt <= MAX_FILL_RETRIES; attempt++) {
try {
const client = getClient();
const order = await client.getOrder(orderId);
if (!order) break; // order gone — fall through to balance check
if (order.status === 'MATCHED') return true;
const matched = parseFloat(order.size_matched || '0');
if (matched >= shares * 0.99) return true;
// CLOB says not filled — trust it if we have no tokenId for balance check
if (!tokenId) return false;
// Otherwise fall through to balance check below
break;
} catch (err) {
logger.warn(`MM: isOrderFilled CLOB error (attempt ${attempt}/${MAX_FILL_RETRIES}): ${err.message}`);
if (attempt < MAX_FILL_RETRIES) await sleep(2000);
}
}
// Fallback: check on-chain token balance
// If we placed a SELL and our balance is now ~0, the order was filled
if (tokenId) {
const balance = await getTokenBalance(tokenId);
if (balance !== null && balance < shares * 0.05) {
logger.warn(`MM: CLOB API missed fill — on-chain balance ${balance.toFixed(3)} ≈ 0 (expected ${shares}) → treating as filled`);
return true;
}
}
return false;
}
/**
* Check how many shares of an order have been partially filled.
* Returns { matched, remaining, total }.
*/
async function getPartialFillInfo(orderId, originalShares, tokenId = null) {
let matched = 0;
if (orderId && !orderId.startsWith('sim-')) {
try {
const client = getClient();
const order = await client.getOrder(orderId);
if (order) {
if (order.status === 'MATCHED') {
matched = parseFloat(order.original_size || order.size || String(originalShares));
} else {
matched = parseFloat(order.size_matched || '0');
}
}
} catch { /* ignore */ }
}
// Cross-check with on-chain balance for accuracy
if (tokenId) {
const balance = await getTokenBalance(tokenId);
if (balance !== null) {
const onChainMatched = originalShares - balance;
if (onChainMatched > matched) {
matched = Math.max(0, onChainMatched);
}
return { matched, remaining: balance, total: originalShares };
}
}
return { matched, remaining: originalShares - matched, total: originalShares };
}
/**
* Get partial fill amount for an order (how many shares already matched).
* Returns 0 on error.
*/
async function getOrderMatched(orderId) {
if (!orderId || orderId.startsWith('sim-')) return 0;
try {
const client = getClient();
const order = await client.getOrder(orderId);
if (!order) return 0;
if (order.status === 'MATCHED') return parseFloat(order.original_size || order.size || '0');
return parseFloat(order.size_matched || '0');
} catch {
return 0;
}
}
// For simulation: check if market price has reached the sell target
async function simPriceHitTarget(tokenId) {
try {
const client = getClient();
const mp = await client.getMidpoint(tokenId);
const price = parseFloat(mp?.mid ?? mp ?? '0');
return price >= config.mmSellPrice ? price : null;
} catch {
return null;
}
}
// Get current mid price for a token (0 on error)
async function getMidprice(tokenId) {
try {
const mp = await getClient().getMidpoint(tokenId);
return parseFloat(mp?.mid ?? mp ?? '0') || 0;
} catch { return 0; }
}
// ── Per-side fill check (parallel-safe) ──────────────────────────────────────
/**
* Check one side (yes/no) for fills and partial fills.
* Returns true if this side became fully filled during this check.
* Safe to run in parallel for both sides.
*/
async function checkSideFill(pos, key) {
const s = pos[key];
if (s.filled) return false;
const label = key.toUpperCase();
let filled = false;
if (config.dryRun) {
const hitPrice = await simPriceHitTarget(s.tokenId);
if (hitPrice) { filled = true; s.fillPrice = hitPrice; }
} else {
filled = await isOrderFilled(s.orderId, s.shares, s.tokenId);
if (filled) s.fillPrice = config.mmSellPrice;
}
if (filled) {
s.filled = true;
const pnl = (s.fillPrice - s.entryPrice) * s.shares;
logger.money(`MM${config.dryRun ? '[SIM]' : ''}: ${label} filled @ $${s.fillPrice.toFixed(3)} | P&L $${pnl.toFixed(2)}`);
return true;
}
// Partial fill handling (live only)
if (config.dryRun) return false;
const info = await getPartialFillInfo(s.orderId, s.shares, s.tokenId);
if (info.matched > 0 && info.remaining > 0 && info.remaining < s.shares * 0.90) {
logger.warn(`MM: ${label} partially filled — ${info.matched.toFixed(3)}/${info.total.toFixed(3)} matched, ${info.remaining.toFixed(3)} remaining`);
await cancelOrder(s.orderId);
s.orderId = null;
s.shares = info.remaining;
s._partialRevenue = (s._partialRevenue || 0) + info.matched * config.mmSellPrice;
if (info.remaining < CLOB_MIN_ORDER_SHARES) {
logger.warn(`MM: ${label} remaining ${info.remaining.toFixed(3)} < ${CLOB_MIN_ORDER_SHARES} min — market selling remainder`);
const result = await marketSell(s.tokenId, info.remaining, pos.tickSize, pos.negRisk);
s.fillPrice = config.mmSellPrice;
s.filled = true;
const pnl = (s._partialRevenue + result.fillPrice * info.remaining) - s.entryPrice * info.total;
logger.money(`MM: ${label} fully sold (partial+market) | P&L $${pnl.toFixed(2)}`);
return true;
} else {
const res = await placeLimitSell(s.tokenId, info.remaining, config.mmSellPrice, pos.tickSize, pos.negRisk);
if (res.success) {
s.orderId = res.orderId;
logger.info(`MM: ${label} re-placed limit sell for ${info.remaining.toFixed(3)} shares @ $${config.mmSellPrice}`);
}
}
}
return false;
}
// ── Core monitoring loop (event-driven + parallel) ───────────────────────────
async function monitorAndManage(pos) {
const label = pos.question.substring(0, 40);
// Register tokens with WS fill watcher for instant fill detection
mmFillWatcher.watch(pos.yes.tokenId);
mmFillWatcher.watch(pos.no.tokenId);
// Handle WS fill events — mark side as filled immediately
const onWsFill = (event) => {
for (const key of ['yes', 'no']) {
if (!pos[key].filled && event.tokenId === pos[key].tokenId && event.side === 'SELL') {
pos[key].filled = true;
pos[key].fillPrice = event.price || config.mmSellPrice;
const pnl = (pos[key].fillPrice - pos[key].entryPrice) * pos[key].shares;
logger.money(`MM: ${key.toUpperCase()} filled (WS realtime) @ $${pos[key].fillPrice.toFixed(3)} | P&L $${pnl.toFixed(2)}`);
}
}
};
mmFillWatcher.on('fill', onWsFill);
try {
await _monitorLoop(pos, label);
} finally {
// Cleanup WS listeners
mmFillWatcher.removeListener('fill', onWsFill);
mmFillWatcher.unwatch(pos.yes.tokenId);
mmFillWatcher.unwatch(pos.no.tokenId);
}
// Final P&L log
const totalPnl = calcPnl(pos);
const sign = totalPnl >= 0 ? '+' : '';
if (pos.status !== 'done') {
logger.info(`MM: strategy ended (${pos.status}) | P&L: ${sign}$${totalPnl.toFixed(2)} | ${label}`);
}
}
async function _monitorLoop(pos, label) {
while (true) {
const msRemaining = new Date(pos.endTime).getTime() - Date.now();
if (msRemaining <= 0) {
logger.warn(`MM: market expired — ${label}`);
pos.status = 'expired';
break;
}
// ── Check YES + NO sides in parallel ────────────────────
await Promise.all([
checkSideFill(pos, 'yes'),
checkSideFill(pos, 'no'),
]);
// ── Both filled → done ──────────────────────────────────
if (pos.yes.filled && pos.no.filled) {
pos.status = 'done';
const totalPnl = calcPnl(pos);
logger.money(`MM: BOTH sides filled! Total P&L: $${totalPnl.toFixed(2)} | ${label}`);
break;
}
// ── Exactly one leg filled → adaptive cut-loss (if enabled) ────────
if (config.mmAdaptiveCL && pos.yes.filled !== pos.no.filled) {
const unfilledKey = pos.yes.filled ? 'no' : 'yes';
await adaptiveLegCL(pos, unfilledKey);
break;
}
// ── Defensive pivot: neither filled after timeout (5m markets only) ──
if (config.mmDefensiveEnabled && config.mmDuration === '5m'
&& !pos.yes.filled && !pos.no.filled && !pos._defensiveActive) {
const marketDurationMs = 5 * 60 * 1000;
const marketStartMs = new Date(pos.endTime).getTime() - marketDurationMs;
const elapsed = (Date.now() - marketStartMs) / 1000;
if (elapsed >= config.mmDefensiveTimeout) {
// Cancel both orders FIRST so they can't fill while we wait
logger.warn(`MM: neither side filled after ${Math.round(elapsed)}s since market open — cancelling orders | ${label}`);
await Promise.all([
cancelOrder(pos.yes.orderId),
cancelOrder(pos.no.orderId),
]);
pos.yes.orderId = null;
pos.no.orderId = null;
// Re-check fills after cancellation — CLOB may have filled one side
// between our last check and the cancel (race condition)
await sleep(2000);
const [yesBalance, noBalance] = await Promise.all([
getTokenBalance(pos.yes.tokenId),
getTokenBalance(pos.no.tokenId),
]);
for (const [key, balance] of [['yes', yesBalance], ['no', noBalance]]) {
if (!pos[key].filled && balance !== null && balance < pos[key].shares * 0.05) {
logger.warn(`MM: ${key.toUpperCase()} actually filled (on-chain balance ${balance.toFixed(3)} ≈ 0) — detected after cancel`);
pos[key].filled = true;
pos[key].fillPrice = config.mmSellPrice;
const pnl = (pos[key].fillPrice - pos[key].entryPrice) * pos[key].shares;
logger.money(`MM: ${key.toUpperCase()} filled @ $${pos[key].fillPrice.toFixed(3)} | P&L $${pnl.toFixed(2)}`);
}
}
// If one side is now filled, go to adaptive CL instead of defensive pivot
if (pos.yes.filled !== pos.no.filled) {
const unfilledKey = pos.yes.filled ? 'no' : 'yes';
logger.warn(`MM: one side filled after cancel — switching to adaptive CL for ${unfilledKey.toUpperCase()} instead of defensive pivot`);
await adaptiveLegCL(pos, unfilledKey);
break;
}
// If both filled (unlikely but possible), we're done
if (pos.yes.filled && pos.no.filled) {
pos.status = 'done';
const totalPnl = calcPnl(pos);
logger.money(`MM: BOTH sides filled! Total P&L: $${totalPnl.toFixed(2)} | ${label}`);
break;
}
// Neither filled — proceed with defensive pivot
pos._defensiveActive = true;
await defensivePivot(pos);
break;
}
}
// ── Cut-loss time ────────────────────────────────────────────────────
if (msRemaining <= config.mmCutLossTime * 1000) {
logger.warn(`MM: cut-loss triggered (${Math.round(msRemaining / 1000)}s left) — ${label}`);
pos.status = 'cutting';
const oneLegFilled = pos.yes.filled !== pos.no.filled;
if (!config.mmAdaptiveCL && oneLegFilled) {
const unfilledKey = pos.yes.filled ? 'no' : 'yes';
await cutLossOneLegFilled(pos, unfilledKey);
pos.status = 'done';
} else {
await cutLossNeitherFilled(pos);
}
break;
}
// ── Wait for WS fill event or polling fallback ───────────────────────
// WS gives us instant fill detection; polling at 30s is just a safety net
const watchTokens = [];
if (!pos.yes.filled) watchTokens.push(pos.yes.tokenId);
if (!pos.no.filled) watchTokens.push(pos.no.tokenId);
const wsEvent = await waitForFillOrTimeout(watchTokens, POLL_INTERVAL_MS);
if (wsEvent) {
// WS detected a fill — the onWsFill listener already updated pos,
// but loop back immediately to run the decision logic
logger.info(`MM: WS fill event received — re-checking immediately`);
}
}
}
// Legacy one-leg CL: cancel unfilled order, immediate market sell (no patience)
async function cutLossOneLegFilled(pos, unfilledKey) {
const s = pos[unfilledKey];
const { tickSize, negRisk } = pos;
logger.warn(`MM: cancelling ${unfilledKey.toUpperCase()} limit order and market-selling...`);
await cancelOrder(s.orderId);
const actualShares = await getTokenBalance(s.tokenId);
const sellShares = actualShares !== null ? actualShares : s.shares;
if (sellShares < 0.001) {
logger.warn(`MM: ${unfilledKey.toUpperCase()} balance is 0 — already fully sold via partial fills`);
s.fillPrice = config.mmSellPrice;
s.filled = true;
return;
}
logger.warn(`MM: ${unfilledKey.toUpperCase()} actual balance: ${sellShares.toFixed(3)} shares (original: ${s.shares})`);
const result = await marketSell(s.tokenId, sellShares, tickSize, negRisk);
s.fillPrice = result.fillPrice;
s.filled = true;
const pnl = (s.fillPrice - s.entryPrice) * sellShares;
logger.warn(`MM: ${unfilledKey.toUpperCase()} cut @ $${s.fillPrice.toFixed(3)} | sold ${sellShares.toFixed(3)} sh | P&L $${pnl.toFixed(2)}`);
}
async function cutLossNeitherFilled(pos) {
const { conditionId } = pos;
// ── Best case: neither side sold → cancel both, merge back to USDC ──
logger.warn('MM: neither side filled — cancelling orders and merging back to USDC...');
await Promise.all([
cancelOrder(pos.yes.orderId),
cancelOrder(pos.no.orderId),
]);
// Read actual on-chain balances (may differ from original if partially consumed)
const [yesActual, noActual] = await Promise.all([
getTokenBalance(pos.yes.tokenId),
getTokenBalance(pos.no.tokenId),
]);
// mergePositions needs equal amounts — use the minimum actual balance
const yesShares = yesActual ?? pos.yes.shares;
const noShares = noActual ?? pos.no.shares;
const mergeAmt = Math.min(yesShares, noShares);
if (mergeAmt < 0.001) {
logger.warn('MM: balances too low to merge — nothing to recover');
} else {
const recovered = await mergePositions(conditionId, mergeAmt);
logger.money(`MM: merge complete — recovered ~$${recovered.toFixed ? recovered.toFixed(2) : recovered} USDC (P&L ≈ $0)`);
}
// Mark both sides closed at entry price
pos.yes.fillPrice = pos.yes.entryPrice;
pos.yes.filled = true;
pos.no.fillPrice = pos.no.entryPrice;
pos.no.filled = true;
pos.status = 'done';
// Optional recovery buy (enabled via MM_RECOVERY_BUY=true)
await attemptRecoveryBuy(pos);
}
// ── Defensive Pivot (5m markets, neither side filled) ────────────────────────
/**
* Defensive pivot: neither side has filled after MM_DEFENSIVE_TIMEOUT.
*
* Strategy:
* 1. Orders already cancelled by caller (monitorAndManage)
* 2. Wait until 45s before close
* 3. Check prices: identify worst (lower price) and best (higher price) side
* 4. If worst < MM_DEFENSIVE_WORST_THRESHOLD (default 10c):
* → market sell worst side, keep best side (let it resolve at close)
* → since YES+NO ≈ $1, best side is ~90c+ → profit potential
* 5. If worst ≥ threshold: market is still uncertain → merge back ($0 P&L)
*/
async function defensivePivot(pos) {
const { conditionId, tickSize, negRisk } = pos;
const label = pos.question.substring(0, 40);
const threshold = config.mmDefensiveWorstThreshold;
// Orders already cancelled by monitorAndManage before entering here
logger.info(`MM defensive: waiting for 45s before close | ${label}`);
// Wait until 45s before close, checking every 5s
while (true) {
const msLeft = new Date(pos.endTime).getTime() - Date.now();
if (msLeft <= 45_000) break; // 45s mark reached
if (msLeft <= 0) {
pos.status = 'expired';
return;
}
await sleep(5000);
}
// Read current prices for both sides
const [yesPrice, noPrice] = await Promise.all([
getMidprice(pos.yes.tokenId),
getMidprice(pos.no.tokenId),
]);
logger.info(`MM defensive: 45s mark — YES=$${yesPrice.toFixed(3)}, NO=$${noPrice.toFixed(3)} | threshold=$${threshold} | ${label}`);
// Determine worst and best sides
const worstKey = yesPrice <= noPrice ? 'yes' : 'no';
const bestKey = worstKey === 'yes' ? 'no' : 'yes';
const worstPrice = Math.min(yesPrice, noPrice);
const bestPrice = Math.max(yesPrice, noPrice);
// ── Decision: pivot or merge? ─────────────────────────────────────────
if (worstPrice < threshold) {
// Worst side < 10c → market is decisive, pivot!
logger.trade(`MM defensive: worst side ${worstKey.toUpperCase()} @ $${worstPrice.toFixed(3)} < $${threshold} — selling worst, keeping ${bestKey.toUpperCase()} @ $${bestPrice.toFixed(3)}`);
const worstSide = pos[worstKey];
const bestSide = pos[bestKey];
// Get actual on-chain balances
const [worstBalance, bestBalance] = await Promise.all([
getTokenBalance(worstSide.tokenId),
getTokenBalance(bestSide.tokenId),
]);
const worstShares = worstBalance !== null ? worstBalance : worstSide.shares;
const bestShares = bestBalance !== null ? bestBalance : bestSide.shares;
// Market sell worst side
if (worstShares >= 0.001) {
const result = await marketSell(worstSide.tokenId, worstShares, tickSize, negRisk);
worstSide.fillPrice = result.fillPrice;
worstSide.filled = true;
logger.warn(`MM defensive: sold ${worstKey.toUpperCase()} ${worstShares.toFixed(3)} sh @ $${result.fillPrice.toFixed(3)}`);
} else {
worstSide.fillPrice = 0;
worstSide.filled = true;
}
// Best side: let it resolve at market close (hold the tokens)
// The market will resolve and we can redeem via the redeemer
// Best side price is ~90c+ so payout ≈ $1 per share if it wins
logger.money(`MM defensive: holding ${bestKey.toUpperCase()} ${bestShares.toFixed(3)} sh @ ~$${bestPrice.toFixed(3)} — waiting for resolution`);
logger.info(`MM defensive: expected payout if ${bestKey.toUpperCase()} wins: ~$${bestShares.toFixed(2)} | cost was $${(bestSide.entryPrice * bestShares).toFixed(2)}`);
// Mark best side as filled at entry price for now — actual payout handled by redeemer
bestSide.fillPrice = bestSide.entryPrice;
bestSide.filled = true;
pos.status = 'done';
const worstPnl = worstSide.fillPrice
? (worstSide.fillPrice - worstSide.entryPrice) * worstShares
: 0;
logger.info(`MM defensive: worst side P&L: $${worstPnl.toFixed(2)} | best side will be redeemed after resolution`);
} else {
// Worst side ≥ 10c → market uncertain, safer to merge
logger.info(`MM defensive: worst side ${worstKey.toUpperCase()} @ $${worstPrice.toFixed(3)} ≥ $${threshold} — market uncertain, merging back to USDC`);
await cutLossNeitherFilled(pos);
}
}
async function adaptiveLegCL(pos, unfilledKey) {
const s = pos[unfilledKey];
const { tickSize, negRisk } = pos;
const label = pos.question.substring(0, 40);
const pollMs = config.mmAdaptiveMonitorSec * 1000;
// ── Minimum floor: unfilled leg must sell at least this price ──────────────
// Ensures: filledLegPrice + unfilledLegPrice >= mmAdaptiveMinCombined
// Example: filledLeg=0.60, minCombined=1.20 → floor=0.60
// filledLeg=0.55, minCombined=1.20 → floor=0.65
const filledKey = unfilledKey === 'yes' ? 'no' : 'yes';
const filledLegPrice = pos[filledKey].fillPrice ?? config.mmSellPrice;
const minAdaptivePrice = Math.max(0, config.mmAdaptiveMinCombined - filledLegPrice);
// ── Tiered floors (5m markets): progressively lower floor over time ────
// Start from minAdaptivePrice (MM_ADAPTIVE_MIN_COMBINED - filledPrice), then drop per phase
const floorDrop = config.mmDefensiveEnabled ? 0.10 : 0;
const emergencyPrice = config.mmDefensiveWorstThreshold; // default 0.10
const is5m = config.mmDuration === '5m';
/**
* Get the current floor based on time remaining (5m markets only).
* Other durations use the fixed mmAdaptiveMinCombined floor.
*
* Phase 1 (> 180s left): minAdaptivePrice (from MM_ADAPTIVE_MIN_COMBINED)
* Phase 2 (90180s): minAdaptivePrice - 0.10
* Phase 3 (3090s): minAdaptivePrice - 0.20
* Phase 4 (< 30s): market sell
*/
function getTieredFloor(msLeft) {
if (!is5m) return minAdaptivePrice; // non-5m: use fixed floor
if (msLeft > 180_000) return minAdaptivePrice;
if (msLeft > 90_000) return Math.max(0.01, minAdaptivePrice - floorDrop);
if (msLeft > 30_000) return Math.max(0.01, minAdaptivePrice - floorDrop * 2);
return 0; // phase 4: market sell
}
logger.warn(`MM: one leg filled — starting adaptive CL for ${unfilledKey.toUpperCase()} | ${label}`);
if (is5m) {
logger.info(`MM adaptive CL: filled @ $${filledLegPrice.toFixed(3)} | floor (minCombined $${config.mmAdaptiveMinCombined.toFixed(2)}): $${minAdaptivePrice.toFixed(3)} | tiered: $${minAdaptivePrice.toFixed(2)} → $${Math.max(0.01, minAdaptivePrice - floorDrop).toFixed(2)} → $${Math.max(0.01, minAdaptivePrice - floorDrop * 2).toFixed(2)}`);
} else {
logger.info(`MM adaptive CL: filled leg @ $${filledLegPrice.toFixed(3)} | min floor for combined ≥ $${config.mmAdaptiveMinCombined.toFixed(2)}: $${minAdaptivePrice.toFixed(3)}`);
}
// Cancel the unfilled leg's old GTC order immediately
await cancelOrder(s.orderId);
s.orderId = null;
// Read actual on-chain balance once — reused for all subsequent sell orders
const actualShares = await getTokenBalance(s.tokenId);
const sellShares = actualShares !== null ? actualShares : s.shares;
if (sellShares < 0.001) {
logger.warn(`MM adaptive CL: ${unfilledKey.toUpperCase()} balance is 0 — already fully sold`);
s.fillPrice = config.mmSellPrice;
s.filled = true;
pos.status = 'done';
return;
}
// If remaining shares below CLOB minimum, market sell immediately instead of trying limit
if (sellShares < CLOB_MIN_ORDER_SHARES) {
logger.warn(`MM adaptive CL: ${unfilledKey.toUpperCase()} remaining ${sellShares.toFixed(3)} shares < ${CLOB_MIN_ORDER_SHARES} minimum — market selling immediately`);
const result = await marketSell(s.tokenId, sellShares, tickSize, negRisk);
s.fillPrice = result.fillPrice;
s.filled = true;
pos.status = 'done';
const pnl = (s.fillPrice - s.entryPrice) * sellShares;
const combined = filledLegPrice + s.fillPrice;
logger.warn(`MM adaptive CL: ${unfilledKey.toUpperCase()} market-sold ${sellShares.toFixed(3)} sh @ $${s.fillPrice.toFixed(3)} | combined $${combined.toFixed(3)} | P&L $${pnl.toFixed(2)}`);
return;
}
// Place standing order at breakeven floor immediately (5m) so brief bounces get caught
let activeOrderId = null;
let activeLimitPrice = 0;
let currentFloor = minAdaptivePrice;
if (is5m && sellShares >= CLOB_MIN_ORDER_SHARES) {
// Check mid price first — place at market price (not just breakeven floor)
const initMid = await getMidprice(s.tokenId);
// Use mid price if above floor, otherwise use floor as safety net
const initSellPrice = initMid >= currentFloor
? Math.min(initMid, config.mmSellPrice)
: currentFloor;
logger.info(`MM adaptive CL: mid=$${initMid.toFixed(3)}, placing initial limit sell @ $${initSellPrice.toFixed(3)} (floor=$${currentFloor.toFixed(3)})`);
const standing = await placeLimitSell(s.tokenId, sellShares, initSellPrice, tickSize, negRisk);
if (standing.success) {
activeOrderId = standing.orderId;
activeLimitPrice = initSellPrice;
}
} else {
logger.info(`MM adaptive CL: monitoring ${unfilledKey.toUpperCase()} — floor $${currentFloor.toFixed(3)}, market-sell at CL time`);
}
// ── Continuous monitoring loop ─────────────────────────────────────────────
let lastPhaseLog = '';
while (true) {
const msLeft = new Date(pos.endTime).getTime() - Date.now();
// ── Phase 4 / CL time: force market sell ────────────────────────────
if (msLeft <= (is5m ? 30_000 : config.mmCutLossTime * 1000)) {
if (activeOrderId) {
await cancelOrder(activeOrderId);
activeOrderId = null;
}
break;
}
// ── Update tiered floor ─────────────────────────────────────────────
const newFloor = getTieredFloor(msLeft);
if (newFloor !== currentFloor) {
const phase = msLeft > 180_000 ? '1-breakeven' : msLeft > 90_000 ? '2-controlled' : '3-emergency';
if (phase !== lastPhaseLog) {
logger.info(`MM adaptive CL: phase ${phase} — floor $${currentFloor.toFixed(3)} → $${newFloor.toFixed(3)} (${Math.round(msLeft / 1000)}s left)`);
lastPhaseLog = phase;
}
// If floor lowered and we have an active order above new floor, keep it
// Only cancel+re-place if the floor dropped below our current limit
if (activeOrderId && activeLimitPrice > newFloor) {
// Current limit is above new floor — that's fine, keep it
} else if (activeOrderId && activeLimitPrice < newFloor) {
// Floor raised (shouldn't happen in tiered, but safety)
await cancelOrder(activeOrderId);
activeOrderId = null;
activeLimitPrice = 0;
}
currentFloor = newFloor;
}
// ── Check fill ──────────────────────────────────────────────────────
if (activeOrderId) {
let filled = false;
if (config.dryRun) {
const hitPrice = await simPriceHitTarget(s.tokenId);
if (hitPrice) { filled = true; s.fillPrice = hitPrice; }
} else {
filled = await isOrderFilled(activeOrderId, sellShares, s.tokenId);
if (filled) s.fillPrice = activeLimitPrice;
}
if (filled) {
const pnl = (s.fillPrice - s.entryPrice) * sellShares;
const combined = filledLegPrice + s.fillPrice;
logger.money(`MM adaptive CL: ${unfilledKey.toUpperCase()} limit filled @ $${s.fillPrice.toFixed(3)} | combined $${combined.toFixed(3)} | P&L $${pnl.toFixed(2)}`);
s.filled = true;
pos.status = 'done';
return;
}
}
// ── Read current price ──────────────────────────────────────────────
const currentPrice = await getMidprice(s.tokenId);
if (currentPrice <= 0) {
await sleep(pollMs);
continue;
}
// ── Emergency cut: price < 10c in phase 3 → market sell immediately ─
if (is5m && msLeft <= 90_000 && currentPrice < emergencyPrice) {
logger.warn(`MM adaptive CL: EMERGENCY — price $${currentPrice.toFixed(3)} < $${emergencyPrice} with ${Math.round(msLeft / 1000)}s left — market selling now`);
if (activeOrderId) {
await cancelOrder(activeOrderId);
activeOrderId = null;
}
break; // fall through to market sell below
}
const targetPrice = Math.min(currentPrice, config.mmSellPrice);
// ── Adjust or cancel active limit ───────────────────────────────────
if (activeOrderId) {
const belowFloor = currentPrice < currentFloor;
const droppedHard = currentPrice < activeLimitPrice * 0.95;
const priceImproved = targetPrice > activeLimitPrice * 1.02;
if (belowFloor || droppedHard) {
const reason = belowFloor
? `below floor $${currentFloor.toFixed(3)}`
: `dropped >5% from limit $${activeLimitPrice.toFixed(3)}`;
logger.info(`MM adaptive CL: price $${currentPrice.toFixed(3)} ${reason} — cancelling limit, watching for recovery`);
await cancelOrder(activeOrderId);
activeOrderId = null;
activeLimitPrice = 0;
} else if (priceImproved) {
logger.info(`MM adaptive CL: price improved $${activeLimitPrice.toFixed(3)} → $${currentPrice.toFixed(3)} — raising limit to $${targetPrice.toFixed(3)}`);
await cancelOrder(activeOrderId);
activeOrderId = null;
activeLimitPrice = 0;
}
}
// ── Place limit at floor or above ───────────────────────────────────
if (!activeOrderId) {
// Re-check actual balance — partial fills may have reduced it
const currentBalance = await getTokenBalance(s.tokenId);
const remainingShares = currentBalance !== null ? currentBalance : sellShares;
if (remainingShares < 0.001) {
logger.warn(`MM adaptive CL: ${unfilledKey.toUpperCase()} balance is 0 — fully sold via partial fills`);
s.fillPrice = config.mmSellPrice;
s.filled = true;
pos.status = 'done';
return;
}
if (remainingShares < CLOB_MIN_ORDER_SHARES) {
logger.warn(`MM adaptive CL: ${unfilledKey.toUpperCase()} remaining ${remainingShares.toFixed(3)} shares < ${CLOB_MIN_ORDER_SHARES} minimum — market selling`);
const result = await marketSell(s.tokenId, remainingShares, tickSize, negRisk);
s.fillPrice = result.fillPrice;
s.filled = true;
pos.status = 'done';
const pnl = (s.fillPrice - s.entryPrice) * remainingShares;
const combined = filledLegPrice + s.fillPrice;
logger.warn(`MM adaptive CL: ${unfilledKey.toUpperCase()} market-sold ${remainingShares.toFixed(3)} sh @ $${s.fillPrice.toFixed(3)} | combined $${combined.toFixed(3)} | P&L $${pnl.toFixed(2)}`);
return;
}
// Place at max(currentPrice, floor) — standing order strategy
const sellPrice = Math.max(currentPrice, currentFloor);
const limitPrice = Math.min(sellPrice, config.mmSellPrice);
if (currentPrice >= currentFloor || is5m) {
// 5m: always place at floor or above (standing order catches bounces)
// non-5m: only place when price >= floor
logger.info(`MM adaptive CL: placing limit sell @ $${limitPrice.toFixed(3)} (mid: $${currentPrice.toFixed(3)}, floor: $${currentFloor.toFixed(3)}, ${Math.round(msLeft / 1000)}s left)`);
const result = await placeLimitSell(s.tokenId, remainingShares, limitPrice, tickSize, negRisk);
if (result.success) {
activeOrderId = result.orderId;
activeLimitPrice = limitPrice;
}
} else {
logger.info(`MM adaptive CL: price $${currentPrice.toFixed(3)} below floor $${currentFloor.toFixed(3)} — waiting for recovery (${Math.round(msLeft / 1000)}s left)`);
}
}
await sleep(pollMs);
}
// ── Fallback: market sell at CL time ───────────────────────────────────────
// Re-check actual balance before market sell (partial fills may have occurred)
const finalBalance = await getTokenBalance(s.tokenId);
const finalShares = finalBalance !== null ? finalBalance : sellShares;
if (finalShares < 0.001) {
logger.warn(`MM adaptive CL: ${unfilledKey.toUpperCase()} balance is 0 at CL time — already fully sold`);
s.fillPrice = config.mmSellPrice;
s.filled = true;
pos.status = 'done';
return;
}
const exitReason = is5m ? 'phase 4 force exit (<30s)' : 'CL time reached';
logger.warn(`MM adaptive CL: ${exitReason} — market-selling ${finalShares.toFixed(3)} ${unfilledKey.toUpperCase()} shares`);
const result = await marketSell(s.tokenId, finalShares, tickSize, negRisk);
s.fillPrice = result.fillPrice;
const pnl = (s.fillPrice - s.entryPrice) * finalShares;
const combined = filledLegPrice + s.fillPrice;
logger.warn(`MM adaptive CL: ${unfilledKey.toUpperCase()} market-sold @ $${s.fillPrice.toFixed(3)} | combined $${combined.toFixed(3)} | sold ${finalShares.toFixed(3)} sh | P&L $${pnl.toFixed(2)}`);
s.filled = true;
pos.status = 'done';
}
// ── Recovery buy ──────────────────────────────────────────────────────────────
/**
* After a cut-loss, optionally take a directional bet on the dominant side.
*
* Criteria (all must pass):
* 1. MM_RECOVERY_BUY=true in .env
* 2. One side's price is above MM_RECOVERY_THRESHOLD (default 70%)
* 3. That price is stable or rising over a 10-second sample (1 fetch/second)
* 4. Wallet balance is sufficient for the recovery size
*/
async function attemptRecoveryBuy(pos) {
if (!config.mmRecoveryBuy) return;
const { tickSize, negRisk } = pos;
const label = pos.question.substring(0, 40);
const recoverySize = config.mmRecoverySize > 0 ? config.mmRecoverySize : config.mmTradeSize;
const client = getClient();
logger.info(`MM recovery: monitoring prices for 10s | ${label}`);
// ── Sample both sides once per second for 10 seconds ─────────
const samples = { yes: [], no: [] };
for (let i = 0; i < 10; i++) {
for (const [key, tokenId] of [['yes', pos.yes.tokenId], ['no', pos.no.tokenId]]) {
try {
const mp = await client.getMidpoint(tokenId);
const price = parseFloat(mp?.mid ?? mp ?? '0') || 0;
samples[key].push(price);
} catch { /* skip */ }
}
if (i < 9) await sleep(1000);
}
// ── Determine eligible side ───────────────────────────────────
// Need: last price ≥ threshold AND last price ≥ first price (not declining)
let candidate = null;
for (const [key, tokenId] of [['yes', pos.yes.tokenId], ['no', pos.no.tokenId]]) {
const arr = samples[key];
if (arr.length < 2) continue;
const firstPrice = arr[0];
const lastPrice = arr[arr.length - 1];
if (lastPrice >= config.mmRecoveryThreshold && lastPrice >= firstPrice) {
candidate = { side: key.toUpperCase(), tokenId, price: lastPrice };
break;
}
}
if (!candidate) {
logger.info(`MM recovery: no eligible side — need price ≥ ${config.mmRecoveryThreshold} and rising/stable`);
return;
}
// ── Balance check ─────────────────────────────────────────────
if (!config.dryRun) {
const balance = await getUsdcBalance();
if (balance < recoverySize) {
logger.warn(`MM recovery: insufficient balance $${balance.toFixed(2)} < $${recoverySize} needed`);
return;
}
}
logger.trade(`MM recovery${config.dryRun ? '[SIM]' : ''}: buying ${candidate.side} @ $${candidate.price.toFixed(3)} | size $${recoverySize}`);
// ── Market buy ────────────────────────────────────────────────
let entryPrice = candidate.price;
let filledShares = recoverySize / entryPrice; // default estimate
if (config.dryRun) {
logger.money(`MM recovery[SIM]: bought ${filledShares.toFixed(3)} ${candidate.side} @ $${entryPrice.toFixed(3)}`);
} else {
try {
const res = await client.createAndPostMarketOrder(
{ tokenID: candidate.tokenId, side: Side.BUY, amount: recoverySize, price: 0.99 },
{ tickSize, negRisk },
OrderType.FOK,
);
if (!res?.success) {
logger.warn(`MM recovery: order not filled — ${res?.errorMsg || 'no fill'}`);
return;
}
entryPrice = parseFloat(res.price || String(candidate.price));
filledShares = parseFloat(res.takingAmount || String(recoverySize / entryPrice));
logger.money(`MM recovery: FILLED ${candidate.side} ${filledShares.toFixed(3)} sh @ $${entryPrice.toFixed(3)} | potential payout $${filledShares.toFixed(2)}`);
} catch (err) {
logger.error(`MM recovery: buy error — ${err.message}`);
return;
}
}
// ── Monitor for 30s — cut loss if price worsens ───────────────
logger.info(`MM recovery: holding ${candidate.side} — will cut if price < $${entryPrice.toFixed(3)} after 30s`);
await sleep(30_000);
// Skip second CL if market is already closed or about to close (< 5s left)
const msLeft = new Date(pos.endTime).getTime() - Date.now();
if (msLeft < 5_000) {
logger.info(`MM recovery: market closing — skipping 2nd CL, letting position resolve`);
return;
}
// Check current price
let currentPrice = entryPrice;
try {
const mp = await client.getMidpoint(candidate.tokenId);
currentPrice = parseFloat(mp?.mid ?? mp ?? String(entryPrice)) || entryPrice;
} catch { /* use entryPrice as fallback */ }
if (currentPrice >= entryPrice) {
logger.success(`MM recovery: price holding $${currentPrice.toFixed(3)} ≥ entry $${entryPrice.toFixed(3)} — keeping position`);
return;
}
// Price has worsened — cut loss
const priceDrop = ((entryPrice - currentPrice) / entryPrice * 100).toFixed(1);
logger.warn(`MM recovery: price dropped $${entryPrice.toFixed(3)} → $${currentPrice.toFixed(3)} (-${priceDrop}%) — cutting loss`);
if (config.dryRun) {
const simPnl = (currentPrice - entryPrice) * filledShares;
logger.warn(`MM recovery[SIM]: 2nd CL @ $${currentPrice.toFixed(3)} | P&L $${simPnl.toFixed(2)}`);
return;
}
try {
const sellRes = await client.createAndPostMarketOrder(
{ tokenID: candidate.tokenId, side: Side.SELL, amount: filledShares, price: 0.01 },
{ tickSize, negRisk },
OrderType.FOK,
);
if (sellRes?.success) {
const sellPrice = parseFloat(sellRes.price || String(currentPrice));
const pnl = (sellPrice - entryPrice) * filledShares;
logger.warn(`MM recovery: 2nd CL sold @ $${sellPrice.toFixed(3)} | P&L $${pnl.toFixed(2)}`);
} else {
logger.warn(`MM recovery: 2nd CL sell failed — ${sellRes?.errorMsg || 'no fill'} — position will resolve at close`);
}
} catch (err) {
logger.error(`MM recovery: 2nd CL sell error — ${err.message}`);
}
}
function calcPnl(pos) {
const yesPnl = pos.yes.filled
? (pos.yes.fillPrice - pos.yes.entryPrice) * pos.yes.shares
: 0;
const noPnl = pos.no.filled
? (pos.no.fillPrice - pos.no.entryPrice) * pos.no.shares
: 0;
return yesPnl + noPnl;
}
// ── Main entry point ──────────────────────────────────────────────────────────
export async function executeMMStrategy(market) {
const { asset, conditionId, question, endTime, yesTokenId, noTokenId, negRisk, tickSize } = market;
const tag = asset ? `[${asset.toUpperCase()}]` : '';
const label = question.substring(0, 40);
const sim = config.dryRun ? '[SIM] ' : '';
logger.info(`MM${tag}: ${sim}entering — ${label}`);
// ── Balance check ───────────────────────────────────────────
const totalNeeded = config.mmTradeSize * 2; // $10 total → 10 YES + 10 NO
if (!config.dryRun) {
const balance = await getUsdcBalance();
if (balance < totalNeeded) {
logger.error(`MM${tag}: insufficient balance $${balance.toFixed(2)} (need $${totalNeeded})`);
return;
}
}
// ── Split USDC into YES+NO via CTF splitPosition ────────────
// Deposit mmTradeSize*2 USDC → get mmTradeSize*2 YES + mmTradeSize*2 NO tokens
// Entry price is exactly $0.50 per token on both sides (no spread, no slippage)
logger.trade(`MM${tag}: ${sim}splitPosition $${totalNeeded} USDC → YES + NO @ $0.50`);
let shares;
try {
shares = await splitPosition(conditionId, totalNeeded, negRisk);
} catch (err) {
logger.error(`MM${tag}: splitPosition failed — ${err.message}`);
return;
}
const entryPrice = 0.50;
logger.info(`MM${tag}: split done — ${shares} YES + ${shares} NO @ $${entryPrice}`);
// ── Place limit sells (parallel) ────────────────────────────
logger.info(`MM${tag}: ${sim}placing limit sells @ $${config.mmSellPrice}`);
const [yesSell, noSell] = await Promise.all([
placeLimitSell(yesTokenId, shares, config.mmSellPrice, tickSize, negRisk),
placeLimitSell(noTokenId, shares, config.mmSellPrice, tickSize, negRisk),
]);
if (!yesSell.success || !noSell.success) {
logger.error(`MM${tag}: failed to place limit sells — cutting immediately`);
}
// ── Build position object ───────────────────────────────────
const pos = {
asset: asset || 'btc',
conditionId,
question,
endTime,
tickSize,
negRisk,
status: 'monitoring',
enteredAt: new Date().toISOString(),
yes: {
tokenId: yesTokenId,
shares,
entryPrice,
entryCost: config.mmTradeSize, // $5 per side
orderId: yesSell.orderId,
filled: !yesSell.success, // mark as needing cut if sell failed
fillPrice: null,
},
no: {
tokenId: noTokenId,
shares,
entryPrice,
entryCost: config.mmTradeSize,
orderId: noSell.orderId,
filled: !noSell.success,
fillPrice: null,
},
};
activePositions.set(conditionId, pos);
// ── Monitor (runs until done/cut/expired) ───────────────────
await monitorAndManage(pos);
activePositions.delete(conditionId);
}