d3e5567c97
When one leg fills, instead of immediately market-selling the unfilled leg, enter a continuous monitoring loop that: - Places a limit sell only when price >= minAdaptivePrice floor (floor = mmAdaptiveMinCombined - filledLegPrice, default combined 1.20) - Chases price upward (>2% improvement → re-place limit higher) - Cancels limit on dip >5% or below floor, then waits for recovery - Market-sells only as last resort when CL time is reached New config: MM_ADAPTIVE_CL (toggle), MM_ADAPTIVE_MIN_COMBINED (floor), MM_ADAPTIVE_MONITOR_SEC (poll interval). Legacy immediate market-sell path preserved when MM_ADAPTIVE_CL=false. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
675 lines
30 KiB
JavaScript
675 lines
30 KiB
JavaScript
/**
|
|
* 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 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)'];
|
|
|
|
/**
|
|
* 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));
|
|
|
|
// 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) {
|
|
if (!orderId || orderId.startsWith('sim-')) return false;
|
|
try {
|
|
const client = getClient();
|
|
const order = await client.getOrder(orderId);
|
|
if (!order) return false;
|
|
if (order.status === 'MATCHED') return true;
|
|
const matched = parseFloat(order.size_matched || '0');
|
|
return matched >= shares * 0.99;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// 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; }
|
|
}
|
|
|
|
// ── Core monitoring loop ──────────────────────────────────────────────────────
|
|
|
|
async function monitorAndManage(pos) {
|
|
const label = pos.question.substring(0, 40);
|
|
|
|
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 side ──────────────────────────────────────
|
|
if (!pos.yes.filled) {
|
|
let filled = false;
|
|
if (config.dryRun) {
|
|
const hitPrice = await simPriceHitTarget(pos.yes.tokenId);
|
|
if (hitPrice) { filled = true; pos.yes.fillPrice = hitPrice; }
|
|
} else {
|
|
filled = await isOrderFilled(pos.yes.orderId, pos.yes.shares);
|
|
if (filled) pos.yes.fillPrice = config.mmSellPrice;
|
|
}
|
|
if (filled) {
|
|
pos.yes.filled = true;
|
|
const pnl = (pos.yes.fillPrice - pos.yes.entryPrice) * pos.yes.shares;
|
|
logger.money(`MM${config.dryRun ? '[SIM]' : ''}: YES filled @ $${pos.yes.fillPrice.toFixed(3)} | P&L $${pnl.toFixed(2)}`);
|
|
}
|
|
}
|
|
|
|
// ── Check NO side ───────────────────────────────────────
|
|
if (!pos.no.filled) {
|
|
let filled = false;
|
|
if (config.dryRun) {
|
|
const hitPrice = await simPriceHitTarget(pos.no.tokenId);
|
|
if (hitPrice) { filled = true; pos.no.fillPrice = hitPrice; }
|
|
} else {
|
|
filled = await isOrderFilled(pos.no.orderId, pos.no.shares);
|
|
if (filled) pos.no.fillPrice = config.mmSellPrice;
|
|
}
|
|
if (filled) {
|
|
pos.no.filled = true;
|
|
const pnl = (pos.no.fillPrice - pos.no.entryPrice) * pos.no.shares;
|
|
logger.money(`MM${config.dryRun ? '[SIM]' : ''}: NO filled @ $${pos.no.fillPrice.toFixed(3)} | P&L $${pnl.toFixed(2)}`);
|
|
}
|
|
}
|
|
|
|
// ── 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;
|
|
}
|
|
|
|
// ── 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) {
|
|
// Legacy: one side sold → immediate market sell on the other
|
|
const unfilledKey = pos.yes.filled ? 'no' : 'yes';
|
|
await cutLossOneLegFilled(pos, unfilledKey);
|
|
pos.status = 'done';
|
|
} else {
|
|
// Neither filled → cancel both + merge back to USDC
|
|
await cutLossNeitherFilled(pos);
|
|
}
|
|
break;
|
|
}
|
|
|
|
await sleep(10_000);
|
|
}
|
|
|
|
// 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}`);
|
|
}
|
|
}
|
|
|
|
// 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 cancelOrder(pos.yes.orderId);
|
|
await 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);
|
|
}
|
|
|
|
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);
|
|
|
|
logger.warn(`MM: one leg filled — starting adaptive CL for ${unfilledKey.toUpperCase()} | ${label}`);
|
|
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;
|
|
}
|
|
|
|
logger.info(`MM adaptive CL: monitoring ${unfilledKey.toUpperCase()} — limit only when price ≥ $${minAdaptivePrice.toFixed(3)}, market-sell only at CL time`);
|
|
|
|
let activeOrderId = null;
|
|
let activeLimitPrice = 0;
|
|
|
|
// ── Continuous monitoring loop ─────────────────────────────────────────────
|
|
// Every poll cycle:
|
|
// 1. CL time → cancel limit, market sell (last resort)
|
|
// 2. Check fill → done
|
|
// 3. Read current price
|
|
// 4a. Price < floor OR dropped >5% → cancel limit, keep watching
|
|
// 4b. Price improved >2% → cancel and re-place higher
|
|
// 5a. price >= floor → place/maintain limit at min(currentPrice, mmSellPrice)
|
|
// 5b. price < floor → no limit placed, log & wait (never sell below profit floor)
|
|
while (true) {
|
|
const msLeft = new Date(pos.endTime).getTime() - Date.now();
|
|
|
|
// ── CL time: last resort market sell ───────────────────────────────
|
|
if (msLeft <= config.mmCutLossTime * 1000) {
|
|
if (activeOrderId) {
|
|
await cancelOrder(activeOrderId);
|
|
activeOrderId = null;
|
|
}
|
|
break;
|
|
}
|
|
|
|
// ── 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);
|
|
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;
|
|
}
|
|
|
|
const targetPrice = Math.min(currentPrice, config.mmSellPrice);
|
|
|
|
// ── Adjust or cancel active limit ───────────────────────────────────
|
|
if (activeOrderId) {
|
|
const belowFloor = currentPrice < minAdaptivePrice;
|
|
const droppedHard = currentPrice < activeLimitPrice * 0.95;
|
|
const priceImproved = targetPrice > activeLimitPrice * 1.02;
|
|
|
|
if (belowFloor || droppedHard) {
|
|
const reason = belowFloor
|
|
? `below floor $${minAdaptivePrice.toFixed(3)} (combined $${(filledLegPrice + currentPrice).toFixed(3)} < $${config.mmAdaptiveMinCombined.toFixed(2)})`
|
|
: `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 only above the profitable floor ─────────────────────
|
|
if (!activeOrderId) {
|
|
if (currentPrice >= minAdaptivePrice) {
|
|
logger.info(`MM adaptive CL: placing limit sell @ $${targetPrice.toFixed(3)} (mid: $${currentPrice.toFixed(3)}, combined: $${(filledLegPrice + targetPrice).toFixed(3)}, ${Math.round(msLeft / 1000)}s left)`);
|
|
const result = await placeLimitSell(s.tokenId, sellShares, targetPrice, tickSize, negRisk);
|
|
if (result.success) {
|
|
activeOrderId = result.orderId;
|
|
activeLimitPrice = targetPrice;
|
|
}
|
|
} else {
|
|
logger.info(`MM adaptive CL: price $${currentPrice.toFixed(3)} below floor $${minAdaptivePrice.toFixed(3)} (combined $${(filledLegPrice + currentPrice).toFixed(3)}) — waiting for recovery (${Math.round(msLeft / 1000)}s left)`);
|
|
}
|
|
}
|
|
|
|
await sleep(pollMs);
|
|
}
|
|
|
|
// ── Fallback: market sell at CL time ───────────────────────────────────────
|
|
logger.warn(`MM adaptive CL: CL time reached — market-selling ${sellShares.toFixed(3)} ${unfilledKey.toUpperCase()} shares`);
|
|
const result = await marketSell(s.tokenId, sellShares, tickSize, negRisk);
|
|
s.fillPrice = result.fillPrice;
|
|
const pnl = (s.fillPrice - s.entryPrice) * sellShares;
|
|
const combined = filledLegPrice + s.fillPrice;
|
|
logger.warn(`MM adaptive CL: ${unfilledKey.toUpperCase()} market-sold @ $${s.fillPrice.toFixed(3)} | combined $${combined.toFixed(3)} | sold ${sellShares.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 ───────────────────────────────────────
|
|
logger.info(`MM${tag}: ${sim}placing limit sells @ $${config.mmSellPrice}`);
|
|
const yesSell = await placeLimitSell(yesTokenId, shares, config.mmSellPrice, tickSize, negRisk);
|
|
const noSell = await 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);
|
|
}
|