diff --git a/.env.example b/.env.example index 813283d..5b0db78 100644 --- a/.env.example +++ b/.env.example @@ -160,6 +160,36 @@ SNIPER_SCHEDULE_ETH=11:40-15:40,16:40-19:40 SNIPER_SCHEDULE_SOL=09:40-12:40,21:40-23:40 SNIPER_SCHEDULE_XRP=18:40-20:40,08:40-09:50 +# ───────────────────────────────────────────── +# MARKET MAKER v2 (maker.js / npm run maker-sim) +# Buy Low, Sell High — no splitPosition. +# Places limit BUY on UP+DOWN at low price, sells at target when filled. +# Only one side will fill — the other gets cancelled. +# ───────────────────────────────────────────── +# Comma-separated assets +MAKER_ASSETS=btc + +# Market duration: "5m" or "15m" +MAKER_DURATION=5m + +# Limit BUY price (e.g. 0.02 = 2 cents per share) +MAKER_BUY_PRICE=0.02 + +# Limit SELL price (e.g. 0.03 = 3 cents per share) +MAKER_SELL_PRICE=0.03 + +# Shares per side (e.g. 50 shares × 0.02 = $1.00 cost per side) +MAKER_TRADE_SIZE=50 + +# Seconds before market close to trigger cut-loss +MAKER_CUT_LOSS_TIME=60 + +# How often to poll for new markets (seconds) +MAKER_POLL_INTERVAL=10 + +# How fast to check order fill status (milliseconds) +MAKER_MONITOR_MS=2000 + # ───────────────────────────────────────────── # PROXY (Polymarket API only, NOT Polygon RPC) # Supports HTTP/HTTPS/SOCKS5 proxies diff --git a/package.json b/package.json index 0d7238c..e06a76d 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,13 @@ "sniper-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/sniper.js", "sniper-tui": "DRY_RUN=false node src/sniper-tui.js", "sniper-tui-sim": "DRY_RUN=true node src/sniper-tui.js", - "sniper-tui-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/sniper-tui.js" + "sniper-tui-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/sniper-tui.js", + "maker": "DRY_RUN=false node src/maker.js", + "maker-sim": "DRY_RUN=true node src/maker.js", + "maker-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/maker.js", + "maker-bot": "node src/maker-bot.js", + "maker-bot-sim": "DRY_RUN=true node src/maker-bot.js", + "maker-bot-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/maker-bot.js" }, "keywords": [ "polymarket", diff --git a/src/config/index.js b/src/config/index.js index 06b84d8..9d3b562 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -103,6 +103,19 @@ const config = { return schedule; })(), + // ── Market Maker v2 (Buy Low, Sell High) ────────────────────── + // Places limit BUY on UP+DOWN at low price, sells at higher price when filled. + // No splitPosition — pure orderbook strategy. + makerAssets: (process.env.MAKER_ASSETS || 'btc') + .split(',').map((s) => s.trim().toLowerCase()).filter(Boolean), + makerDuration: process.env.MAKER_DURATION || '5m', + makerBuyPrice: parseFloat(process.env.MAKER_BUY_PRICE || '0.02'), + makerSellPrice: parseFloat(process.env.MAKER_SELL_PRICE || '0.03'), + makerTradeSize: parseFloat(process.env.MAKER_TRADE_SIZE || '50'), // shares per side + makerCutLossTime: parseInt(process.env.MAKER_CUT_LOSS_TIME || '60', 10), + makerPollInterval: parseInt(process.env.MAKER_POLL_INTERVAL || '10', 10) * 1000, + makerMonitorMs: parseInt(process.env.MAKER_MONITOR_MS || '2000', 10), // how fast to poll order status + // ── Proxy (Polymarket API only, NOT Polygon RPC) ────────────── // Supports HTTP/HTTPS. Example: http://user:pass@host:port proxyUrl: process.env.PROXY_URL || '', @@ -135,4 +148,19 @@ export function validateMMConfig() { throw new Error('MM_SELL_PRICE must be between 0 and 1'); } +// Validation for maker bot (buy low, sell high) +export function validateMakerConfig() { + const required = ['privateKey', 'proxyWallet']; + const missing = required.filter((key) => !config[key]); + if (missing.length > 0) { + throw new Error(`Missing required config: ${missing.join(', ')}. Check your .env file.`); + } + if (config.makerBuyPrice <= 0 || config.makerBuyPrice >= 1) + throw new Error('MAKER_BUY_PRICE must be between 0 and 1'); + if (config.makerSellPrice <= config.makerBuyPrice) + throw new Error('MAKER_SELL_PRICE must be greater than MAKER_BUY_PRICE'); + if (config.makerTradeSize <= 0) + throw new Error('MAKER_TRADE_SIZE must be > 0'); +} + export default config; diff --git a/src/maker-bot.js b/src/maker-bot.js new file mode 100644 index 0000000..44bf89a --- /dev/null +++ b/src/maker-bot.js @@ -0,0 +1,170 @@ +/** + * maker-bot.js — Buy Low, Sell High Market Maker, PM2 / VPS entry point (no TUI) + * + * Plain-text stdout output, compatible with: + * pm2 start ecosystem.config.cjs --only polymarket-maker + * pm2 logs polymarket-maker + */ + +import './utils/proxy-patch.cjs'; + +import { validateMakerConfig } from './config/index.js'; +import config from './config/index.js'; +import logger from './utils/logger.js'; +import { initClient, getUsdcBalance } from './services/client.js'; +import { startMakerDetector, stopMakerDetector } from './services/makerDetector.js'; +import { executeMakerStrategy, getActiveMakerPositions } from './services/makerExecutor.js'; +import { OrderbookWs } from './services/makerWs.js'; + +logger.interceptConsole(); + +// ── Validate config ──────────────────────────────────────────────────────────── + +try { + validateMakerConfig(); +} catch (err) { + logger.error(`Config error: ${err.message}`); + process.exit(1); +} + +// ── Init CLOB client ────────────────────────────────────────────────────────── + +try { + await initClient(); +} catch (err) { + logger.error(`Client init error: ${err.message}`); + process.exit(1); +} + +// ── WebSocket orderbook (for sim mode) ─────────────────────────────────────── + +const orderbookWs = new OrderbookWs(); + +// ── Periodic status log ────────────────────────────────────────────────────── + +async function printStatus() { + try { + let balanceStr = 'SIM'; + if (!config.dryRun) { + try { balanceStr = `$${(await getUsdcBalance()).toFixed(2)} USDC`; } catch { balanceStr = 'N/A'; } + } + + const positions = getActiveMakerPositions(); + const mode = config.dryRun ? 'SIMULATION' : 'LIVE'; + + logger.info(`--- MAKER Status [${mode}] | Balance: ${balanceStr} | Active: ${positions.length} ---`); + + for (const pos of positions) { + const assetTag = pos.asset ? `[${pos.asset.toUpperCase()}] ` : ''; + const label = pos.question.substring(0, 50); + const msLeft = new Date(pos.endTime).getTime() - Date.now(); + const secsLeft = Math.max(0, Math.round(msLeft / 1000)); + const timeStr = secsLeft > 60 ? `${Math.floor(secsLeft / 60)}m${secsLeft % 60}s` : `${secsLeft}s`; + const pnl = pos.totalRevenue - pos.totalCost; + const sign = pnl >= 0 ? '+' : ''; + + logger.info( + ` ${assetTag}${label} | ${pos.status} | ${timeStr} left` + + ` | UP: ${pos.up.buyFilled.toFixed(1)}sh bought, ${pos.up.totalSellFilled.toFixed(1)}sh sold` + + ` | DOWN: ${pos.down.buyFilled.toFixed(1)}sh bought, ${pos.down.totalSellFilled.toFixed(1)}sh sold` + + ` | P&L: ${sign}$${pnl.toFixed(4)}`, + ); + + // Orderbook snapshot in sim mode + if (config.dryRun) { + for (const [label, tokenId] of [['UP', pos.up.tokenId], ['DOWN', pos.down.tokenId]]) { + const book = orderbookWs.getBook(tokenId); + const bestBid = book.bids[0]; + const bestAsk = book.asks[0]; + if (bestBid || bestAsk) { + logger.info( + ` ${label} book: bid $${bestBid?.price.toFixed(3) || '-'} × ${bestBid?.size.toFixed(0) || '-'}` + + ` | ask $${bestAsk?.price.toFixed(3) || '-'} × ${bestAsk?.size.toFixed(0) || '-'}`, + ); + } + } + } + } + } catch (err) { + logger.warn(`Status check error: ${err.message}`); + } +} + +// ── Market handler with per-asset queue ────────────────────────────────────── + +const pendingByAsset = new Map(); + +async function runStrategy(market) { + if (config.dryRun) { + orderbookWs.subscribe(market.conditionId, [market.yesTokenId, market.noTokenId]); + } + + try { + await executeMakerStrategy(market); + } catch (err) { + logger.error(`MAKER strategy error (${market.asset?.toUpperCase()}): ${err.message}`); + } + + const queued = pendingByAsset.get(market.asset); + if (queued) { + pendingByAsset.delete(market.asset); + const secsLeft = Math.round((new Date(queued.endTime).getTime() - Date.now()) / 1000); + + if (secsLeft > config.makerCutLossTime) { + logger.success(`MAKER[${market.asset?.toUpperCase()}]: executing queued market (${secsLeft}s left)`); + runStrategy(queued); + } else { + logger.warn(`MAKER[${market.asset?.toUpperCase()}]: queued market expired (${secsLeft}s left)`); + } + } +} + +async function handleNewMarket(market) { + const active = getActiveMakerPositions(); + const isAssetBusy = active.some((p) => p.asset === market.asset); + + if (isAssetBusy) { + pendingByAsset.set(market.asset, market); + logger.warn(`MAKER[${market.asset?.toUpperCase()}]: queued — will enter after current position clears`); + return; + } + + runStrategy(market); +} + +// ── Timers ─────────────────────────────────────────────────────────────────── + +const statusTimer = setInterval(printStatus, 60_000); + +// ── Graceful shutdown ───────────────────────────────────────────────────────── + +function shutdown() { + logger.warn('MAKER: shutting down...'); + stopMakerDetector(); + orderbookWs.shutdown(); + clearInterval(statusTimer); + setTimeout(() => process.exit(0), 300); +} + +process.on('SIGINT', shutdown); +process.on('SIGTERM', shutdown); + +// ── Start ───────────────────────────────────────────────────────────────────── + +const mode = config.dryRun ? 'SIMULATION' : 'LIVE'; +const costPerSide = config.makerTradeSize * config.makerBuyPrice; +const profitPerCycle = (config.makerSellPrice - config.makerBuyPrice) * config.makerTradeSize; + +logger.info(`=== Market Maker v2 [${mode}] ===`); +logger.info(`Assets : ${config.makerAssets.join(', ').toUpperCase()}`); +logger.info(`Duration : ${config.makerDuration}`); +logger.info(`Buy @ : $${config.makerBuyPrice} per share`); +logger.info(`Sell @ : $${config.makerSellPrice} per share`); +logger.info(`Size : ${config.makerTradeSize} shares/side`); +logger.info(`Cost/side : $${costPerSide.toFixed(2)}`); +logger.info(`Profit : $${profitPerCycle.toFixed(2)} per cycle`); +logger.info(`Cut loss : ${config.makerCutLossTime}s before close`); +logger.info('=========================================='); + +startMakerDetector(handleNewMarket); +logger.success(`MAKER bot started — watching for ${config.makerDuration} ${config.makerAssets.join('/')} markets...`); diff --git a/src/maker.js b/src/maker.js new file mode 100644 index 0000000..bcfdf45 --- /dev/null +++ b/src/maker.js @@ -0,0 +1,258 @@ +/** + * maker.js + * TUI version — Buy Low, Sell High Market Maker (blessed dashboard). + * + * Strategy: Place limit BUY on UP+DOWN at low price, sell at target when filled. + * No splitPosition — pure orderbook-based market making. + * + * Run with: npm run maker (live) + * npm run maker-sim (simulation with real orderbook via WebSocket) + */ + +import './utils/proxy-patch.cjs'; + +import { validateMakerConfig } from './config/index.js'; +import config from './config/index.js'; +import logger from './utils/logger.js'; +import { initClient, getUsdcBalance } from './services/client.js'; +import { initDashboard, appendLog, updateStatus, isDashboardActive } from './ui/dashboard.js'; +import { startMakerDetector, stopMakerDetector } from './services/makerDetector.js'; +import { executeMakerStrategy, getActiveMakerPositions } from './services/makerExecutor.js'; +import { OrderbookWs } from './services/makerWs.js'; + +// ── Validate config ──────────────────────────────────────────────────────────── + +try { + validateMakerConfig(); +} catch (err) { + console.error(`Config error: ${err.message}`); + process.exit(1); +} + +// ── Init TUI ────────────────────────────────────────────────────────────────── + +initDashboard(); +logger.setOutput(appendLog); + +// ── Init CLOB client ────────────────────────────────────────────────────────── + +try { + await initClient(); +} catch (err) { + logger.error(`Client init error: ${err.message}`); + process.exit(1); +} + +// ── WebSocket orderbook (for sim visualization) ────────────────────────────── + +const orderbookWs = new OrderbookWs(); +let activeWsTokens = { up: null, down: null }; + +// ── Status panel refresh ────────────────────────────────────────────────────── + +async function buildStatusContent() { + const lines = []; + + // Balance + let balance = '?'; + if (!config.dryRun) { + try { balance = (await getUsdcBalance()).toFixed(2); } catch { /* ignore */ } + } else { + balance = '{yellow-fg}SIM{/yellow-fg}'; + } + lines.push('{bold}BALANCE{/bold}'); + lines.push(` USDC.e: {green-fg}$${balance}{/green-fg}`); + lines.push(''); + + // Mode + lines.push('{bold}MODE{/bold}'); + lines.push(` ${config.dryRun ? '{yellow-fg}SIMULATION{/yellow-fg}' : '{green-fg}LIVE{/green-fg}'}`); + lines.push(''); + + // Maker Config + lines.push('{bold}MAKER CONFIG{/bold}'); + lines.push(` Assets : ${config.makerAssets.join(', ').toUpperCase()}`); + lines.push(` Duration : ${config.makerDuration}`); + lines.push(` Buy @ : $${config.makerBuyPrice} per share`); + lines.push(` Sell @ : $${config.makerSellPrice} per share`); + lines.push(` Size : ${config.makerTradeSize} shares/side`); + lines.push(` Cost/side: $${(config.makerTradeSize * config.makerBuyPrice).toFixed(2)}`); + lines.push(` Profit : $${((config.makerSellPrice - config.makerBuyPrice) * config.makerTradeSize).toFixed(2)}/cycle`); + lines.push(` Cut loss : ${config.makerCutLossTime}s before close`); + lines.push(''); + + // Active positions + const positions = getActiveMakerPositions(); + lines.push(`{bold}ACTIVE POSITIONS (${positions.length}){/bold}`); + + if (positions.length === 0) { + lines.push(' {gray-fg}Waiting for market...{/gray-fg}'); + } else { + for (const pos of positions) { + const assetTag = pos.asset ? `[${pos.asset.toUpperCase()}] ` : ''; + const label = pos.question.substring(0, 32); + const msLeft = new Date(pos.endTime).getTime() - Date.now(); + const secsLeft = Math.max(0, Math.round(msLeft / 1000)); + const timeStr = secsLeft > 60 + ? `${Math.floor(secsLeft / 60)}m${secsLeft % 60}s` + : `{red-fg}${secsLeft}s{/red-fg}`; + + lines.push(` {cyan-fg}${assetTag}${label}{/cyan-fg}`); + lines.push(` Status : ${pos.status} | Time left: ${timeStr}`); + + // UP side + const upFill = pos.up.buyFilled > 0 + ? `{green-fg}BOUGHT ${pos.up.buyFilled.toFixed(1)}sh{/green-fg}` + : '{gray-fg}waiting...{/gray-fg}'; + const upSold = pos.up.totalSellFilled > 0 + ? ` → {green-fg}SOLD ${pos.up.totalSellFilled.toFixed(1)}sh{/green-fg}` + : pos.up.sellOrders.length > 0 ? ' → {yellow-fg}selling...{/yellow-fg}' : ''; + lines.push(` UP ${upFill}${upSold}`); + + // DOWN side + const downFill = pos.down.buyFilled > 0 + ? `{green-fg}BOUGHT ${pos.down.buyFilled.toFixed(1)}sh{/green-fg}` + : '{gray-fg}waiting...{/gray-fg}'; + const downSold = pos.down.totalSellFilled > 0 + ? ` → {green-fg}SOLD ${pos.down.totalSellFilled.toFixed(1)}sh{/green-fg}` + : pos.down.sellOrders.length > 0 ? ' → {yellow-fg}selling...{/yellow-fg}' : ''; + lines.push(` DOWN ${downFill}${downSold}`); + + // P&L + const pnl = pos.totalRevenue - pos.totalCost; + const pnlColor = pnl >= 0 ? 'green' : 'red'; + lines.push(` P&L: {${pnlColor}-fg}$${pnl.toFixed(4)}{/${pnlColor}-fg}`); + lines.push(''); + } + } + + // Orderbook display (simulation mode) + if (config.dryRun && activeWsTokens.up) { + lines.push('{bold}LIVE ORDERBOOK{/bold}'); + + for (const [label, tokenId] of [['UP', activeWsTokens.up], ['DOWN', activeWsTokens.down]]) { + if (!tokenId) continue; + const book = orderbookWs.getBook(tokenId); + const lastPrice = orderbookWs.getLastPrice(tokenId); + + lines.push(` {cyan-fg}${label}{/cyan-fg} (last: $${lastPrice.toFixed(3)})`); + + // Top 3 asks (reversed so lowest is closest to spread) + const topAsks = book.asks.slice(0, 3).reverse(); + for (const ask of topAsks) { + lines.push(` {red-fg}ASK $${ask.price.toFixed(3)} × ${ask.size.toFixed(0)}{/red-fg}`); + } + + // Spread + const bestBid = book.bids[0]?.price || 0; + const bestAsk = book.asks[0]?.price || 0; + if (bestBid && bestAsk) { + lines.push(` {gray-fg}--- spread: $${(bestAsk - bestBid).toFixed(3)} ---{/gray-fg}`); + } + + // Top 3 bids + const topBids = book.bids.slice(0, 3); + for (const bid of topBids) { + lines.push(` {green-fg}BID $${bid.price.toFixed(3)} × ${bid.size.toFixed(0)}{/green-fg}`); + } + + // Recent trades + const trades = orderbookWs.getRecentTrades(tokenId, 3); + if (trades.length > 0) { + lines.push(` Trades:`); + for (const t of trades.reverse()) { + const color = t.side === 'BUY' ? 'green' : 'red'; + lines.push(` {${color}-fg}${t.side} $${t.price.toFixed(3)} × ${t.size.toFixed(0)}{/${color}-fg}`); + } + } + lines.push(''); + } + } + + return '\n' + lines.join('\n'); +} + +let refreshTimer = null; + +function startRefresh() { + refreshTimer = setInterval(async () => { + if (!isDashboardActive()) return; + updateStatus(await buildStatusContent()); + }, 2000); + buildStatusContent().then(updateStatus); +} + +// ── Market handler with per-asset queue ────────────────────────────────────── + +const pendingByAsset = new Map(); + +async function runStrategy(market) { + // Connect WebSocket for orderbook visualization in sim mode + if (config.dryRun) { + activeWsTokens = { up: market.yesTokenId, down: market.noTokenId }; + orderbookWs.subscribe(market.conditionId, [market.yesTokenId, market.noTokenId]); + } + + try { + await executeMakerStrategy(market); + } catch (err) { + logger.error(`MAKER strategy error (${market.asset?.toUpperCase()}): ${err.message}`); + } + + // Disconnect WS after strategy ends + if (config.dryRun) { + activeWsTokens = { up: null, down: null }; + } + + // Process queued market + const queued = pendingByAsset.get(market.asset); + if (queued) { + pendingByAsset.delete(market.asset); + const endMs = new Date(queued.endTime).getTime(); + const secsLeft = Math.round((endMs - Date.now()) / 1000); + + if (secsLeft > config.makerCutLossTime) { + logger.success(`MAKER[${market.asset?.toUpperCase()}]: executing queued market (${secsLeft}s left)`); + runStrategy(queued); + } else { + logger.warn(`MAKER[${market.asset?.toUpperCase()}]: queued market expired (${secsLeft}s left)`); + } + } +} + +async function handleNewMarket(market) { + const active = getActiveMakerPositions(); + const isAssetBusy = active.some((p) => p.asset === market.asset); + + if (isAssetBusy) { + pendingByAsset.set(market.asset, market); + logger.warn(`MAKER[${market.asset?.toUpperCase()}]: queued — will enter after current position clears`); + return; + } + + runStrategy(market); +} + +// ── Graceful shutdown ───────────────────────────────────────────────────────── + +function shutdown() { + logger.warn('MAKER: shutting down...'); + stopMakerDetector(); + orderbookWs.shutdown(); + if (refreshTimer) clearInterval(refreshTimer); + process.exit(0); +} + +process.on('SIGINT', shutdown); +process.on('SIGTERM', shutdown); + +// ── Start ───────────────────────────────────────────────────────────────────── + +const costPerSide = config.makerTradeSize * config.makerBuyPrice; +const profitPerCycle = (config.makerSellPrice - config.makerBuyPrice) * config.makerTradeSize; +logger.info(`MAKER starting — ${config.dryRun ? 'SIMULATION' : 'LIVE'}`); +logger.info(`Assets: ${config.makerAssets.join(', ').toUpperCase()} | BUY @ $${config.makerBuyPrice} → SELL @ $${config.makerSellPrice}`); +logger.info(`Size: ${config.makerTradeSize} sh/side | Cost: $${costPerSide.toFixed(2)}/side | Profit: $${profitPerCycle.toFixed(2)}/cycle`); + +startRefresh(); +startMakerDetector(handleNewMarket); diff --git a/src/services/makerDetector.js b/src/services/makerDetector.js new file mode 100644 index 0000000..e1dd701 --- /dev/null +++ b/src/services/makerDetector.js @@ -0,0 +1,130 @@ +/** + * makerDetector.js + * Detects upcoming markets for the Maker strategy (buy low, sell high). + * Same slug-based detection as mmDetector but reads from MAKER_* config. + */ + +import config from '../config/index.js'; +import logger from '../utils/logger.js'; +import { proxyFetch } from '../utils/proxy.js'; + +const SLOT_SEC = config.makerDuration === '15m' ? 900 : 300; + +let pollTimer = null; +let onMarketCb = null; +const seenKeys = new Set(); + +function currentSlot() { + return Math.floor(Date.now() / 1000 / SLOT_SEC) * SLOT_SEC; +} + +function nextSlot() { + return currentSlot() + SLOT_SEC; +} + +async function fetchBySlug(asset, slotTimestamp) { + const slug = `${asset}-updown-${config.makerDuration}-${slotTimestamp}`; + try { + const resp = await proxyFetch(`${config.gammaHost}/markets/slug/${slug}`); + if (!resp.ok) return null; + const data = await resp.json(); + return data?.conditionId ? data : null; + } catch { + return null; + } +} + +function extractMarketData(market, asset) { + const conditionId = market.conditionId || market.condition_id || ''; + if (!conditionId) return null; + + let tokenIds = market.clobTokenIds ?? market.clob_token_ids; + if (typeof tokenIds === 'string') { + try { tokenIds = JSON.parse(tokenIds); } catch { tokenIds = null; } + } + + let yesTokenId, noTokenId; + if (Array.isArray(tokenIds) && tokenIds.length >= 2) { + [yesTokenId, noTokenId] = tokenIds; + } else if (Array.isArray(market.tokens) && market.tokens.length >= 2) { + yesTokenId = market.tokens[0]?.token_id ?? market.tokens[0]?.tokenId; + noTokenId = market.tokens[1]?.token_id ?? market.tokens[1]?.tokenId; + } + + if (!yesTokenId || !noTokenId) return null; + + return { + asset, + conditionId, + question: market.question || market.title || '', + endTime: market.endDate || market.end_date_iso || market.endDateIso, + eventStartTime: market.eventStartTime || market.event_start_time, + yesTokenId: String(yesTokenId), + noTokenId: String(noTokenId), + negRisk: market.negRisk ?? market.neg_risk ?? false, + tickSize: String(market.orderPriceMinTickSize ?? market.minimum_tick_size ?? market.minimumTickSize ?? '0.01'), + }; +} + +async function scheduleAsset(asset, slotTimestamp) { + const key = `${asset}-${slotTimestamp}`; + if (seenKeys.has(key)) return; + + const market = await fetchBySlug(asset, slotTimestamp); + if (!market) return; + + const data = extractMarketData(market, asset); + if (!data) { + logger.warn(`MAKER: skipping ${asset.toUpperCase()} slot ${slotTimestamp} — missing token IDs`); + seenKeys.add(key); + return; + } + + seenKeys.add(key); + + const openAt = data.eventStartTime ? new Date(data.eventStartTime).getTime() : slotTimestamp * 1000; + const elapsedSec = Math.round((Date.now() - openAt) / 1000); + if (elapsedSec > 15) { + logger.info(`MAKER: ${asset.toUpperCase()} next slot already ${elapsedSec}s old — skipping`); + return; + } + + const secsUntilOpen = Math.round((openAt - Date.now()) / 1000); + if (secsUntilOpen > 0) { + logger.success(`MAKER: ${asset.toUpperCase()} found "${data.question.slice(0, 40)}" — placing orders (${secsUntilOpen}s before open)`); + } else { + logger.success(`MAKER: ${asset.toUpperCase()} found "${data.question.slice(0, 40)}" — placing orders now`); + } + + if (onMarketCb) onMarketCb(data); +} + +async function poll() { + try { + const next = nextSlot(); + await Promise.all(config.makerAssets.map((asset) => scheduleAsset(asset, next))); + } catch (err) { + logger.error('MAKER detector poll error:', err.message); + } +} + +export function startMakerDetector(onNewMarket) { + onMarketCb = onNewMarket; + seenKeys.clear(); + + poll(); + pollTimer = setInterval(poll, config.makerPollInterval); + + const ns = nextSlot(); + const secsUntil = ns - Math.floor(Date.now() / 1000); + logger.info(`MAKER detector started — assets: ${config.makerAssets.join(', ').toUpperCase()} | duration: ${config.makerDuration}`); + logger.info(`Next slot: *-updown-${config.makerDuration}-${ns} (opens in ${secsUntil}s)`); + logger.info(`Strategy: BUY @ $${config.makerBuyPrice} → SELL @ $${config.makerSellPrice} | ${config.makerTradeSize} shares/side`); +} + +export function stopMakerDetector() { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } +} diff --git a/src/services/makerExecutor.js b/src/services/makerExecutor.js new file mode 100644 index 0000000..eb007e5 --- /dev/null +++ b/src/services/makerExecutor.js @@ -0,0 +1,416 @@ +/** + * makerExecutor.js + * Buy Low, Sell High Market Maker — no splitPosition. + * + * Flow: + * 1. Place concurrent limit BUY on UP + DOWN at makerBuyPrice (e.g. 2c) + * 2. Monitor both orders in parallel (multi-thread style) + * 3. When one side fills (even partial): + * a. Immediately place limit SELL for filled shares at makerSellPrice (e.g. 3c) + * b. Cancel the other side's buy order + * 4. Partial fills → partial sells placed immediately + * 5. Monitor sell orders until all filled or cut-loss + * 6. Cut-loss: market sell remaining tokens + */ + +import { Side, OrderType } from '@polymarket/clob-client'; +import config from '../config/index.js'; +import { getClient } from './client.js'; +import logger from '../utils/logger.js'; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// In-memory store of active maker positions +const activePositions = new Map(); + +export function getActiveMakerPositions() { + return Array.from(activePositions.values()); +} + +// ── Order helpers ───────────────────────────────────────────────────────────── + +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) return { success: false }; + return { success: true, orderId: res.orderID }; + } catch (err) { + logger.error('MAKER limit buy error:', err.message); + return { success: false }; + } +} + +async function placeLimitSell(tokenId, shares, price, tickSize, negRisk) { + if (config.dryRun) { + return { success: true, orderId: `sim-sell-${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('MAKER limit sell error:', err.message); + return { success: false }; + } +} + +async function cancelOrder(orderId) { + if (config.dryRun || !orderId || orderId.startsWith('sim-')) return true; + try { + await getClient().cancelOrder({ orderID: orderId }); + return true; + } catch (err) { + logger.warn('MAKER cancel order error:', err.message); + return false; + } +} + +async function marketSell(tokenId, shares, tickSize, negRisk) { + if (config.dryRun) { + try { + const mp = await getClient().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('MAKER market sell error:', err.message); + return { success: false, fillPrice: 0 }; + } +} + +// ── Order status ────────────────────────────────────────────────────────────── + +async function getOrderFill(orderId) { + if (!orderId || orderId.startsWith('sim-')) { + return { matched: 0, status: 'LIVE', fullyFilled: false }; + } + try { + const order = await getClient().getOrder(orderId); + if (!order) return { matched: 0, status: 'UNKNOWN', fullyFilled: false }; + const matched = parseFloat(order.size_matched || '0'); + return { + matched, + status: order.status, + fullyFilled: order.status === 'MATCHED', + }; + } catch { + return { matched: 0, status: 'ERROR', fullyFilled: false }; + } +} + +// Simulation: check if market price would fill our order +async function simCheckFill(tokenId, side, price) { + try { + const mp = await getClient().getMidpoint(tokenId); + const midPrice = parseFloat(mp?.mid ?? mp ?? '0') || 0; + if (side === 'buy' && midPrice <= price) return midPrice; + if (side === 'sell' && midPrice >= price) return midPrice; + return null; + } catch { + return null; + } +} + +// ── Core strategy ───────────────────────────────────────────────────────────── + +export async function executeMakerStrategy(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] ' : ''; + const { makerBuyPrice, makerSellPrice, makerTradeSize, makerMonitorMs } = config; + + logger.info(`MAKER${tag}: ${sim}entering — ${label}`); + logger.info(`MAKER${tag}: BUY @ $${makerBuyPrice} → SELL @ $${makerSellPrice} | ${makerTradeSize} shares/side`); + + // ── 1. Place BUY UP + DOWN concurrently ────────────────────── + logger.trade(`MAKER${tag}: ${sim}placing BUY UP + DOWN @ $${makerBuyPrice}`); + + const [upBuy, downBuy] = await Promise.all([ + placeLimitBuy(yesTokenId, makerTradeSize, makerBuyPrice, tickSize, negRisk), + placeLimitBuy(noTokenId, makerTradeSize, makerBuyPrice, tickSize, negRisk), + ]); + + if (!upBuy.success && !downBuy.success) { + logger.error(`MAKER${tag}: both buy orders failed — aborting`); + return; + } + + if (upBuy.success) logger.trade(`MAKER${tag}: ${sim}UP BUY placed | order ${upBuy.orderId}`); + if (downBuy.success) logger.trade(`MAKER${tag}: ${sim}DOWN BUY placed | order ${downBuy.orderId}`); + + // ── 2. Build position state ────────────────────────────────── + const pos = { + asset: asset || 'btc', + conditionId, + question, + endTime, + tickSize, + negRisk, + status: 'buying', + enteredAt: new Date().toISOString(), + up: { + tokenId: yesTokenId, + buyOrderId: upBuy.success ? upBuy.orderId : null, + buyFilled: 0, + sellOrders: [], // { orderId, shares, filled, fillPrice } + totalSellFilled: 0, + cancelled: !upBuy.success, + }, + down: { + tokenId: noTokenId, + buyOrderId: downBuy.success ? downBuy.orderId : null, + buyFilled: 0, + sellOrders: [], + totalSellFilled: 0, + cancelled: !downBuy.success, + }, + winner: null, // 'up' or 'down' + totalCost: 0, + totalRevenue: 0, + }; + + activePositions.set(conditionId, pos); + + // ── 3. Monitor buy orders (concurrent multi-thread style) ──── + try { + await monitorBuyPhase(pos, tag, sim); + await monitorSellPhase(pos, tag, sim); + } catch (err) { + logger.error(`MAKER${tag}: strategy error — ${err.message}`); + } + + // ── Final P&L ──────────────────────────────────────────────── + const pnl = pos.totalRevenue - pos.totalCost; + const sign = pnl >= 0 ? '+' : ''; + logger.money(`MAKER${tag}: ${sim}strategy complete | cost $${pos.totalCost.toFixed(4)} | revenue $${pos.totalRevenue.toFixed(4)} | P&L ${sign}$${pnl.toFixed(4)}`); + + activePositions.delete(conditionId); +} + +// ── Buy phase: monitor both sides concurrently ─────────────────────────────── + +async function monitorBuyPhase(pos, tag, sim) { + const { makerBuyPrice, makerSellPrice, makerMonitorMs, makerCutLossTime } = config; + + // Run two concurrent monitors — first full fill wins + const monitorSide = async (sideKey) => { + const side = pos[sideKey]; + const otherKey = sideKey === 'up' ? 'down' : 'up'; + const sideName = sideKey.toUpperCase(); + + if (!side.buyOrderId) return; // order failed at placement + + while (!pos.winner) { + const msLeft = new Date(pos.endTime).getTime() - Date.now(); + + // Cut-loss check + if (msLeft <= makerCutLossTime * 1000) { + logger.warn(`MAKER${tag}: buy phase cut-loss (${Math.round(msLeft / 1000)}s left) — cancelling buy orders`); + break; + } + + // Check fill + let fill; + if (config.dryRun) { + const hitPrice = await simCheckFill(side.tokenId, 'buy', makerBuyPrice); + if (hitPrice !== null) { + fill = { matched: config.makerTradeSize, fullyFilled: true }; + } else { + fill = { matched: 0, fullyFilled: false }; + } + } else { + fill = await getOrderFill(side.buyOrderId); + } + + // New fills detected → place sell immediately + const newFill = fill.matched - side.buyFilled; + if (newFill > 0) { + side.buyFilled = fill.matched; + pos.totalCost += newFill * makerBuyPrice; + + logger.money(`MAKER${tag}: ${sim}${sideName} BUY filled ${newFill.toFixed(2)} shares @ $${makerBuyPrice} (total: ${side.buyFilled.toFixed(2)}/${config.makerTradeSize})`); + + // Place sell immediately for the newly filled amount + const sellResult = await placeLimitSell(side.tokenId, newFill, makerSellPrice, pos.tickSize, pos.negRisk); + if (sellResult.success) { + side.sellOrders.push({ + orderId: sellResult.orderId, + shares: newFill, + filled: false, + fillPrice: null, + }); + logger.trade(`MAKER${tag}: ${sim}${sideName} SELL placed ${newFill.toFixed(2)} shares @ $${makerSellPrice}`); + } + } + + // Fully filled → we have a winner + if (fill.fullyFilled) { + pos.winner = sideKey; + pos.status = 'selling'; + logger.success(`MAKER${tag}: ${sim}${sideName} fully filled! Cancelling ${otherKey.toUpperCase()} buy...`); + + // Cancel the other side's buy + const other = pos[otherKey]; + if (other.buyOrderId && !other.cancelled) { + await cancelOrder(other.buyOrderId); + other.cancelled = true; + logger.info(`MAKER${tag}: ${otherKey.toUpperCase()} buy cancelled`); + } + return; + } + + await sleep(makerMonitorMs); + } + }; + + // Run both side monitors concurrently — race to first full fill + await Promise.race([ + monitorSide('up'), + monitorSide('down'), + ]); + + // If no winner (cut-loss), cancel all remaining buy orders + if (!pos.winner) { + pos.status = 'cut-buy'; + for (const key of ['up', 'down']) { + const side = pos[key]; + if (side.buyOrderId && !side.cancelled) { + await cancelOrder(side.buyOrderId); + side.cancelled = true; + } + } + // If any partial fills exist, still process sells + const anyFill = pos.up.buyFilled > 0 || pos.down.buyFilled > 0; + if (anyFill) { + pos.winner = pos.up.buyFilled >= pos.down.buyFilled ? 'up' : 'down'; + pos.status = 'selling'; + logger.warn(`MAKER${tag}: partial fill — selling ${pos.winner.toUpperCase()} ${pos[pos.winner].buyFilled.toFixed(2)} shares`); + } else { + pos.status = 'done'; + logger.warn(`MAKER${tag}: no fills during buy phase — exiting with $0 loss`); + } + } +} + +// ── Sell phase: monitor all sell orders ─────────────────────────────────────── + +async function monitorSellPhase(pos, tag, sim) { + if (pos.status === 'done') return; // nothing to sell + + const { makerSellPrice, makerMonitorMs, makerCutLossTime } = config; + const winnerKey = pos.winner; + if (!winnerKey) return; + + const side = pos[winnerKey]; + const sideName = winnerKey.toUpperCase(); + + logger.info(`MAKER${tag}: monitoring ${side.sellOrders.length} sell order(s) for ${sideName}`); + + while (true) { + const msLeft = new Date(pos.endTime).getTime() - Date.now(); + + // Cut-loss: market sell remaining + if (msLeft <= makerCutLossTime * 1000) { + logger.warn(`MAKER${tag}: sell phase cut-loss (${Math.round(msLeft / 1000)}s left)`); + await cutLossSells(pos, side, sideName, tag, sim); + break; + } + + // Check all sell orders concurrently + const checks = await Promise.all( + side.sellOrders.map(async (so) => { + if (so.filled) return true; + + let filled = false; + if (config.dryRun) { + const hitPrice = await simCheckFill(side.tokenId, 'sell', makerSellPrice); + if (hitPrice !== null) { + filled = true; + so.fillPrice = hitPrice; + } + } else { + const fill = await getOrderFill(so.orderId); + if (fill.fullyFilled || fill.matched >= so.shares * 0.99) { + filled = true; + so.fillPrice = makerSellPrice; + } + } + + if (filled) { + so.filled = true; + side.totalSellFilled += so.shares; + pos.totalRevenue += so.shares * (so.fillPrice || makerSellPrice); + logger.money(`MAKER${tag}: ${sim}${sideName} SELL filled ${so.shares.toFixed(2)} shares @ $${(so.fillPrice || makerSellPrice).toFixed(3)}`); + } + + return so.filled; + }) + ); + + // All sells filled → done + if (checks.every(Boolean) && side.sellOrders.length > 0) { + pos.status = 'done'; + logger.success(`MAKER${tag}: ${sim}all sells filled!`); + break; + } + + // Market expired + if (msLeft <= 0) { + pos.status = 'expired'; + logger.warn(`MAKER${tag}: market expired`); + break; + } + + await sleep(makerMonitorMs); + } +} + +async function cutLossSells(pos, side, sideName, tag, sim) { + const { makerSellPrice } = config; + + // Cancel unfilled sell orders and market sell + let remainingShares = 0; + for (const so of side.sellOrders) { + if (!so.filled) { + await cancelOrder(so.orderId); + remainingShares += so.shares; + } + } + + if (remainingShares > 0) { + logger.warn(`MAKER${tag}: ${sim}market-selling ${remainingShares.toFixed(2)} ${sideName} shares`); + const result = await marketSell(side.tokenId, remainingShares, pos.tickSize, pos.negRisk); + if (result.success) { + pos.totalRevenue += remainingShares * result.fillPrice; + logger.warn(`MAKER${tag}: ${sim}${sideName} CL sold @ $${result.fillPrice.toFixed(3)}`); + } + } + + pos.status = 'done'; +} diff --git a/src/services/makerWs.js b/src/services/makerWs.js new file mode 100644 index 0000000..9ae62b0 --- /dev/null +++ b/src/services/makerWs.js @@ -0,0 +1,291 @@ +/** + * makerWs.js + * WebSocket client for Polymarket CLOB orderbook + trade data. + * Used by the simulation to display real-time orderbook and simulate fills. + * + * Endpoints: + * - Book updates (bids/asks) + * - Last trade price + * - Trade history + */ + +import WebSocket from 'ws'; +import logger from '../utils/logger.js'; + +const WS_URL = 'wss://ws-subscriptions-clob.polymarket.com/ws/market'; +const PING_INTERVAL = 10_000; +const RECONNECT_DELAY = 3000; +const MAX_RECONNECT_DELAY = 30_000; + +export class OrderbookWs { + constructor() { + this.ws = null; + this.pingTimer = null; + this.reconnectTimer = null; + this.reconnectDelay = RECONNECT_DELAY; + this.isShutdown = false; + + // Subscribed assets + this.assetIds = []; + this.conditionId = null; + + // Orderbook state per asset + this.books = new Map(); // assetId → { bids: [], asks: [] } + + // Recent trades per asset + this.trades = new Map(); // assetId → [{ price, side, size, timestamp }] + + // Last trade price per asset + this.lastPrice = new Map(); // assetId → number + + // Callbacks + this.onBookUpdate = null; // (assetId, book) => void + this.onTradeUpdate = null; // (assetId, trade) => void + this.onPriceUpdate = null; // (assetId, price) => void + } + + subscribe(conditionId, assetIds) { + this.conditionId = conditionId; + this.assetIds = assetIds; + + for (const id of assetIds) { + this.books.set(id, { bids: [], asks: [] }); + this.trades.set(id, []); + } + + this.connect(); + } + + connect() { + if (this.isShutdown) return; + + this.ws = new WebSocket(WS_URL); + + this.ws.on('open', () => { + logger.info('MAKER WS: connected to orderbook feed'); + this.reconnectDelay = RECONNECT_DELAY; + + // Subscribe to book + trades for all assets + for (const assetId of this.assetIds) { + this.ws.send(JSON.stringify({ + auth: {}, + type: 'subscribe', + markets: [this.conditionId], + assets_ids: [assetId], + channels: ['book', 'trades'], + })); + } + + this.startPing(); + }); + + this.ws.on('message', (raw) => { + this.handleMessage(raw); + }); + + this.ws.on('ping', () => { + this.ws?.pong(); + }); + + this.ws.on('close', () => { + this.cleanup(true); + }); + + this.ws.on('error', (err) => { + logger.warn(`MAKER WS error: ${err.message}`); + this.cleanup(true); + }); + } + + handleMessage(raw) { + let msg; + try { + msg = JSON.parse(raw.toString()); + } catch { + const text = raw.toString().trim(); + if (text === 'ping') this.ws?.send('pong'); + return; + } + + if (msg.type === 'ping' || msg === 'ping') { + this.ws?.send('pong'); + return; + } + + // Handle different event types from the CLOB WS + const events = Array.isArray(msg) ? msg : [msg]; + + for (const evt of events) { + const assetId = evt.asset_id; + if (!assetId || !this.assetIds.includes(assetId)) continue; + + switch (evt.event_type) { + case 'book': + this.handleBook(assetId, evt); + break; + case 'last_trade_price': + this.handleLastPrice(assetId, evt); + break; + case 'tick_size_change': + break; // ignore + default: + // Could be trade data + if (evt.price && evt.side) { + this.handleTrade(assetId, evt); + } + break; + } + } + } + + handleBook(assetId, evt) { + const bids = (evt.bids || []).map((b) => ({ + price: parseFloat(b.price), + size: parseFloat(b.size), + })).sort((a, b) => b.price - a.price); + + const asks = (evt.asks || []).map((a) => ({ + price: parseFloat(a.price), + size: parseFloat(a.size), + })).sort((a, b) => a.price - b.price); + + this.books.set(assetId, { bids, asks, timestamp: evt.timestamp }); + + if (this.onBookUpdate) { + this.onBookUpdate(assetId, { bids, asks }); + } + } + + handleLastPrice(assetId, evt) { + const price = parseFloat(evt.price || '0'); + this.lastPrice.set(assetId, price); + + if (this.onPriceUpdate) { + this.onPriceUpdate(assetId, price); + } + } + + handleTrade(assetId, evt) { + const trade = { + price: parseFloat(evt.price || '0'), + side: evt.side || '', + size: parseFloat(evt.size || evt.amount || '0'), + timestamp: evt.timestamp || new Date().toISOString(), + }; + + const trades = this.trades.get(assetId) || []; + trades.push(trade); + if (trades.length > 50) trades.splice(0, trades.length - 50); + this.trades.set(assetId, trades); + + if (this.onTradeUpdate) { + this.onTradeUpdate(assetId, trade); + } + } + + /** + * Check if a simulated order would fill based on current orderbook. + * @param {string} assetId - token ID + * @param {'buy'|'sell'} side - order side + * @param {number} price - limit price + * @param {number} size - order size + * @returns {{ filled: number, avgPrice: number } | null} + */ + checkSimFill(assetId, side, price, size) { + const book = this.books.get(assetId); + if (!book) return null; + + if (side === 'buy') { + // Buy order fills against asks at or below our price + const eligible = book.asks.filter((a) => a.price <= price); + if (eligible.length === 0) return null; + + let filled = 0; + let totalCost = 0; + for (const ask of eligible) { + const take = Math.min(ask.size, size - filled); + filled += take; + totalCost += take * ask.price; + if (filled >= size) break; + } + + if (filled > 0) { + return { filled: Math.min(filled, size), avgPrice: totalCost / filled }; + } + } else { + // Sell order fills against bids at or above our price + const eligible = book.bids.filter((b) => b.price >= price); + if (eligible.length === 0) return null; + + let filled = 0; + let totalRevenue = 0; + for (const bid of eligible) { + const take = Math.min(bid.size, size - filled); + filled += take; + totalRevenue += take * bid.price; + if (filled >= size) break; + } + + if (filled > 0) { + return { filled: Math.min(filled, size), avgPrice: totalRevenue / filled }; + } + } + + return null; + } + + getBook(assetId) { + return this.books.get(assetId) || { bids: [], asks: [] }; + } + + getLastPrice(assetId) { + return this.lastPrice.get(assetId) || 0; + } + + getRecentTrades(assetId, limit = 10) { + const trades = this.trades.get(assetId) || []; + return trades.slice(-limit); + } + + startPing() { + this.stopPing(); + this.pingTimer = setInterval(() => { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send('ping'); + } + }, PING_INTERVAL); + } + + stopPing() { + if (this.pingTimer) { + clearInterval(this.pingTimer); + this.pingTimer = null; + } + } + + cleanup(reconnect = true) { + this.stopPing(); + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + if (this.ws) { + this.ws.removeAllListeners(); + if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) { + this.ws.terminate(); + } + this.ws = null; + } + if (reconnect && !this.isShutdown) { + this.reconnectTimer = setTimeout(() => { + this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_RECONNECT_DELAY); + this.connect(); + }, this.reconnectDelay); + } + } + + shutdown() { + this.isShutdown = true; + this.cleanup(false); + } +}