diff --git a/.env.example b/.env.example index a7ccecf..d24da2c 100644 --- a/.env.example +++ b/.env.example @@ -167,10 +167,10 @@ SNIPER_SCHEDULE_XRP=18:40-20:40,08:40-09:50 # Only one side will fill — the other gets cancelled. # ───────────────────────────────────────────── # Comma-separated assets -MAKER_ASSETS=btc +MAKER_ASSETS=btc,eth,sol,xrp -# Market duration: "5m" or "15m" -MAKER_DURATION=5m +# Market duration: comma-separated, e.g. "5m" or "5m,15m" for both +MAKER_DURATION=5m,15m # Limit BUY price (e.g. 0.02 = 2 cents per share) MAKER_BUY_PRICE=0.02 diff --git a/AGENT.MD b/AGENT.MD index d5869d4..589d0f9 100644 --- a/AGENT.MD +++ b/AGENT.MD @@ -11,7 +11,8 @@ Developer and AI agent reference for understanding, extending, and maintaining t | Bot | Entry Point | Command | Purpose | |---|---|---|---| | Copy Trade | `src/index.js` | `npm start` | Mirror trades from a target trader wallet | -| Market Maker | `src/mm.js` | `npm run mm` | Provide liquidity on 5m/15m binary markets | +| Market Maker v1 | `src/mm.js` | `npm run mm` | Provide liquidity via splitPosition on 5m/15m markets | +| Market Maker v2 | `src/maker.js` | `npm run maker` | Buy low, sell high — pure orderbook maker (no splitPosition) | | Orderbook Sniper | `src/sniper.js` | `npm run sniper` | Place low-price GTC orders to catch panic dumps | --- @@ -91,9 +92,10 @@ All files use **ES Modules** (`import`/`export`). Do not use `require()`. - Loads all settings from `.env` via `dotenv` - Parses and validates required fields per bot type -- Exports two validator functions: +- Exports three validator functions: - `validateCopyTradeConfig()` — for `src/index.js` - `validateMMConfig()` — for `src/mm.js` and `src/sniper.js` + - `validateMakerConfig()` — for `src/maker.js` - Key exported fields: `privateKey`, `proxyWallet`, `traderAddress`, `dryRun`, `mmTradeSize`, etc. ### Client (`src/services/client.js`) @@ -171,6 +173,23 @@ Used exclusively by the Market Maker bot. Interacts with the Gnosis Safe proxy w - `cutLoss(...)` — Cancels remaining orders; merges back or market-sells residual tokens - `attemptRecoveryBuy(...)` — Optional directional bet post cut-loss (controlled by `MM_RECOVERY_BUY`) +### Maker Detector (`src/services/makerDetector.js`) + +- Slot-based detection for the Maker v2 bot (buy low, sell high) +- Supports **multiple durations** simultaneously (e.g. `MAKER_DURATION=5m,15m`) +- Polls all `asset × duration` combinations in parallel +- Each detected market includes a `duration` field for concurrent queue management +- Same slug format as other bots: `{asset}-updown-{duration}-{timestamp}` + +### Maker Executor (`src/services/makerExecutor.js`) + +- `executeMakerStrategy(market)` — Full flow: place limit BUY on UP+DOWN → monitor fills → place limit SELL → monitor sells +- **CL at 10s before close:** Cancels unfilled buy orders, places sells for any filled positions (retry 3x for settlement delays) +- **Sell phase:** Sell orders stay live until market close (no early cancellation) +- **Multi-market concurrent:** Each position keyed by `conditionId`, queue keyed by `${asset}-${duration}` +- Simulation mode with full P&L tracking (win/loss/skip stats) +- `placeLimitSellWithRetry()` — Retries sell placement 3x with increasing delay for on-chain settlement + ### Sniper Detector (`src/services/sniperDetector.js`) - Scans for 5-minute markets for a configurable list of assets diff --git a/README.md b/README.md index 1e58976..c29c284 100644 --- a/README.md +++ b/README.md @@ -34,13 +34,23 @@ - **Per-Market Queue** — Concurrent events for the same market are serialized to prevent duplicate buys - **Dry Run Mode** — Simulate the full flow without placing real orders -### Market Maker Bot +### Market Maker v1 Bot (Split Position) - **Automated Liquidity** — Splits USDC into YES+NO tokens and places limit sells on both sides at $0.50 entry - **Cut-Loss Protection** — Merges unsold tokens back to USDC before market close - **Recovery Buy** — Optional directional bet after a cut-loss triggers - **Multi-Asset** — Supports BTC, ETH, SOL, and any 5m/15m Polymarket market - **Simulation Mode** — Full dry-run with P&L tracking +### Market Maker v2 Bot (Buy Low, Sell High) +- **Pure Orderbook** — Places limit BUY on both UP+DOWN at low price (e.g. 2c), sells at higher price (e.g. 3c) when filled. No splitPosition needed +- **Cut-Loss at 10s** — Cancels unfilled buy orders 10 seconds before market close; sells positions as much as possible +- **Sell Retry 3x** — Retries sell placement with delay for on-chain token settlement +- **Multi-Asset** — Supports BTC, ETH, SOL, XRP simultaneously +- **Multi-Duration** — Run 5m and 15m markets concurrently (`MAKER_DURATION=5m,15m`) +- **Concurrent Markets** — Each asset+duration combination runs independently (e.g. BTC/5m and BTC/15m don't block each other) +- **Simulation Mode** — Full dry-run with win/loss/skip tracking and P&L stats +- **Proxy Support** — All API calls go through `PROXY_URL` if configured + ### Orderbook Sniper Bot - **3-Tier Strategy** — Places GTC BUY orders at 3c, 2c, and 1c with weighted sizing (20%/30%/50%) - **Multi-Asset** — Targets ETH, SOL, XRP, and more simultaneously @@ -138,6 +148,20 @@ Leave these blank to have the client auto-derive credentials from your private k | `MM_RECOVERY_THRESHOLD` | Minimum dominant-side price to qualify for recovery | `0.70` | | `MM_RECOVERY_SIZE` | USDC for recovery buy (0 = use `MM_TRADE_SIZE`) | `0` | +### Market Maker v2 (Maker) Settings + +| Variable | Description | Default | +|---|---|---| +| `MAKER_ASSETS` | Comma-separated assets (e.g. `btc,eth,sol,xrp`) | `btc` | +| `MAKER_DURATION` | Comma-separated durations (e.g. `5m` or `5m,15m`) | `5m` | +| `MAKER_BUY_PRICE` | Limit BUY price per share (e.g. `0.02` = 2c) | `0.02` | +| `MAKER_SELL_PRICE` | Limit SELL price per share (e.g. `0.03` = 3c) | `0.03` | +| `MAKER_TRADE_SIZE` | Shares per side (e.g. `50` × $0.02 = $1.00/side) | `50` | +| `MAKER_POLL_INTERVAL` | Seconds between new market polls | `10` | +| `MAKER_MONITOR_MS` | Milliseconds between order fill checks | `2000` | + +**Cut-Loss:** At 10 seconds before market close, unfilled buy orders are cancelled. Any filled positions get sell orders placed (retry 3x). Sell orders stay live until market close. + ### Orderbook Sniper Settings **3-Tier Strategy:** Places orders at 3 price levels with weighted sizing @@ -179,6 +203,11 @@ npm run mm # live trading npm run mm-sim # simulation (DRY_RUN=true) npm run mm-dev # simulation + auto-reload +# Market Maker v2 (Maker) Bot +npm run maker # live trading +npm run maker-sim # simulation +npm run maker-dev # simulation + auto-reload + # Orderbook Sniper Bot npm run sniper # live trading npm run sniper-sim # simulation @@ -195,10 +224,15 @@ npm run bot # live trading npm run bot-sim # simulation npm run bot-dev # simulation + auto-reload -# Market Maker Bot +# Market Maker v1 Bot npm run mm-bot # live trading npm run mm-bot-sim # simulation npm run mm-bot-dev # simulation + auto-reload + +# Market Maker v2 (Maker) Bot +npm run maker-bot # live trading +npm run maker-bot-sim # simulation +npm run maker-bot-dev # simulation + auto-reload ``` > **Always test with `DRY_RUN=true` (or `*-sim` scripts) first** before committing real funds. @@ -288,7 +322,7 @@ Redeemer loop (every REDEEM_INTERVAL seconds) → Check on-chain payout → redeemPositions via Gnosis Safe ``` -### Market Maker Flow +### Market Maker v1 Flow (Split Position) ``` New Market Detected @@ -311,6 +345,37 @@ Collect Cancel orders → Merge YES+NO back to USDC profit (recovery buy optional) ``` +### Market Maker v2 Flow (Buy Low, Sell High) + +``` +Detector polls all asset × duration combos (e.g. BTC/5m, ETH/15m) + │ + ▼ +Place limit BUY on UP + DOWN @ MAKER_BUY_PRICE (e.g. $0.02) + │ + ▼ +Monitor both sides concurrently + │ + ┌────┴─────────────────┐ + │ │ + One side fills CL at 10s before close + │ │ + ▼ ▼ +Cancel other buy Cancel unfilled buys +Place SELL @ 3c Place SELL for any fills (retry 3x) + │ │ + └──────┬───────────────┘ + ▼ +Monitor sells until market close + │ + ┌────┴────┐ + │ │ + Sell fills Market closes + │ │ + ▼ ▼ + WIN $ Tokens resolve on-chain +``` + --- ## Project Structure @@ -320,8 +385,10 @@ polymarket-terminal/ ├── src/ │ ├── index.js — Copy trade bot (TUI) │ ├── bot.js — Copy trade bot (plain log / PM2) -│ ├── mm.js — Market maker bot (TUI) -│ ├── mm-bot.js — Market maker bot (plain log / PM2) +│ ├── mm.js — Market maker v1 bot (TUI) +│ ├── mm-bot.js — Market maker v1 bot (plain log / PM2) +│ ├── maker.js — Market maker v2 bot (TUI) +│ ├── maker-bot.js — Market maker v2 bot (plain log / PM2) │ ├── sniper.js — Orderbook sniper bot │ │ │ ├── config/ @@ -336,8 +403,11 @@ polymarket-terminal/ │ │ ├── autoSell.js — Auto limit-sell placement │ │ ├── redeemer.js — Market resolution check & CTF redemption │ │ ├── ctf.js — On-chain CTF contract interactions -│ │ ├── mmDetector.js — Market detection for market maker -│ │ ├── mmExecutor.js — Market maker strategy execution +│ │ ├── mmDetector.js — Market detection for MM v1 +│ │ ├── mmExecutor.js — MM v1 strategy execution +│ │ ├── makerDetector.js — Market detection for Maker v2 (multi-duration) +│ │ ├── makerExecutor.js — Maker v2 strategy execution +│ │ ├── makerWs.js — Orderbook WebSocket for Maker v2 │ │ ├── sniperDetector.js — Market detection for sniper │ │ └── sniperExecutor.js — Orderbook sniper order placement │ │ diff --git a/src/config/index.js b/src/config/index.js index cc144dd..bc0ecf2 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -108,7 +108,8 @@ const config = { // 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', + makerDurations: (process.env.MAKER_DURATION || '5m') + .split(',').map((s) => s.trim().toLowerCase()).filter(Boolean), 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 diff --git a/src/maker-bot.js b/src/maker-bot.js index a8e922a..a76bf47 100644 --- a/src/maker-bot.js +++ b/src/maker-bot.js @@ -98,7 +98,14 @@ async function printStatus() { const pendingByAsset = new Map(); +function slotKey(market) { + return `${market.asset}-${market.duration || '5m'}`; +} + async function runStrategy(market) { + const key = slotKey(market); + const tag = `${market.asset?.toUpperCase()}/${market.duration || '5m'}`; + if (config.dryRun) { orderbookWs.subscribe(market.conditionId, [market.yesTokenId, market.noTokenId]); } @@ -106,30 +113,32 @@ async function runStrategy(market) { try { await executeMakerStrategy(market); } catch (err) { - logger.error(`MAKER strategy error (${market.asset?.toUpperCase()}): ${err.message}`); + logger.error(`MAKER strategy error (${tag}): ${err.message}`); } - const queued = pendingByAsset.get(market.asset); + const queued = pendingByAsset.get(key); if (queued) { - pendingByAsset.delete(market.asset); + pendingByAsset.delete(key); const secsLeft = Math.round((new Date(queued.endTime).getTime() - Date.now()) / 1000); if (secsLeft > 30) { - logger.success(`MAKER[${market.asset?.toUpperCase()}]: executing queued market (${secsLeft}s left)`); + logger.success(`MAKER[${tag}]: executing queued market (${secsLeft}s left)`); runStrategy(queued); } else { - logger.warn(`MAKER[${market.asset?.toUpperCase()}]: queued market expired (${secsLeft}s left)`); + logger.warn(`MAKER[${tag}]: queued market expired (${secsLeft}s left)`); } } } async function handleNewMarket(market) { + const key = slotKey(market); + const tag = `${market.asset?.toUpperCase()}/${market.duration || '5m'}`; const active = getActiveMakerPositions(); - const isAssetBusy = active.some((p) => p.asset === market.asset); + const isSlotBusy = active.some((p) => p.asset === market.asset && p.duration === (market.duration || '5m')); - if (isAssetBusy) { - pendingByAsset.set(market.asset, market); - logger.warn(`MAKER[${market.asset?.toUpperCase()}]: queued — will enter after current position clears`); + if (isSlotBusy) { + pendingByAsset.set(key, market); + logger.warn(`MAKER[${tag}]: queued — will enter after current position clears`); return; } @@ -161,14 +170,14 @@ const profitPerCycle = (config.makerSellPrice - config.makerBuyPrice) * config.m logger.info(`=== Market Maker v2 [${mode}] ===`); logger.info(`Assets : ${config.makerAssets.join(', ').toUpperCase()}`); -logger.info(`Duration : ${config.makerDuration}`); +logger.info(`Duration : ${config.makerDurations.join(', ')}`); 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 : NONE (hold to resolution)`); +logger.info(`CL : cancel buys at 10s, sell until close`); logger.info('=========================================='); startMakerDetector(handleNewMarket); -logger.success(`MAKER bot started — watching for ${config.makerDuration} ${config.makerAssets.join('/')} markets...`); +logger.success(`MAKER bot started — watching for ${config.makerDurations.join('+')} ${config.makerAssets.join('/')} markets...`); diff --git a/src/maker.js b/src/maker.js index f9a4002..2c76226 100644 --- a/src/maker.js +++ b/src/maker.js @@ -93,13 +93,13 @@ async function buildStatusContent() { // Maker Config lines.push('{bold}MAKER CONFIG{/bold}'); lines.push(` Assets : ${config.makerAssets.join(', ').toUpperCase()}`); - lines.push(` Duration : ${config.makerDuration}`); + lines.push(` Duration : ${config.makerDurations.join(', ')}`); 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(` No CL : hold to resolution if sell unfilled`); + lines.push(` CL : cancel buys at ${config.makerCLSeconds || 10}s, sell until close`); lines.push(''); // Active positions @@ -110,7 +110,7 @@ async function buildStatusContent() { lines.push(' {gray-fg}Waiting for market...{/gray-fg}'); } else { for (const pos of positions) { - const assetTag = pos.asset ? `[${pos.asset.toUpperCase()}] ` : ''; + const assetTag = pos.asset ? `[${pos.asset.toUpperCase()}/${pos.duration || '5m'}] ` : ''; 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)); @@ -200,7 +200,14 @@ function startRefresh() { const pendingByAsset = new Map(); +function slotKey(market) { + return `${market.asset}-${market.duration || '5m'}`; +} + async function runStrategy(market) { + const key = slotKey(market); + const tag = `${market.asset?.toUpperCase()}/${market.duration || '5m'}`; + // Connect WebSocket for orderbook visualization in sim mode if (config.dryRun) { activeWsTokens = { up: market.yesTokenId, down: market.noTokenId }; @@ -210,7 +217,7 @@ async function runStrategy(market) { try { await executeMakerStrategy(market); } catch (err) { - logger.error(`MAKER strategy error (${market.asset?.toUpperCase()}): ${err.message}`); + logger.error(`MAKER strategy error (${tag}): ${err.message}`); } // Disconnect WS after strategy ends @@ -219,28 +226,30 @@ async function runStrategy(market) { } // Process queued market - const queued = pendingByAsset.get(market.asset); + const queued = pendingByAsset.get(key); if (queued) { - pendingByAsset.delete(market.asset); + pendingByAsset.delete(key); const endMs = new Date(queued.endTime).getTime(); const secsLeft = Math.round((endMs - Date.now()) / 1000); if (secsLeft > 30) { - logger.success(`MAKER[${market.asset?.toUpperCase()}]: executing queued market (${secsLeft}s left)`); + logger.success(`MAKER[${tag}]: executing queued market (${secsLeft}s left)`); runStrategy(queued); } else { - logger.warn(`MAKER[${market.asset?.toUpperCase()}]: queued market expired (${secsLeft}s left)`); + logger.warn(`MAKER[${tag}]: queued market expired (${secsLeft}s left)`); } } } async function handleNewMarket(market) { + const key = slotKey(market); + const tag = `${market.asset?.toUpperCase()}/${market.duration || '5m'}`; const active = getActiveMakerPositions(); - const isAssetBusy = active.some((p) => p.asset === market.asset); + const isSlotBusy = active.some((p) => p.asset === market.asset && p.duration === (market.duration || '5m')); - if (isAssetBusy) { - pendingByAsset.set(market.asset, market); - logger.warn(`MAKER[${market.asset?.toUpperCase()}]: queued — will enter after current position clears`); + if (isSlotBusy) { + pendingByAsset.set(key, market); + logger.warn(`MAKER[${tag}]: queued — will enter after current position clears`); return; } @@ -265,7 +274,7 @@ process.on('SIGTERM', shutdown); 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(`Assets: ${config.makerAssets.join(', ').toUpperCase()} | Durations: ${config.makerDurations.join(', ')} | BUY @ $${config.makerBuyPrice} → SELL @ $${config.makerSellPrice}`); logger.info(`Size: ${config.makerTradeSize} sh/side | Cost: $${costPerSide.toFixed(2)}/side | Profit: $${profitPerCycle.toFixed(2)}/cycle`); startRefresh(); diff --git a/src/services/makerDetector.js b/src/services/makerDetector.js index e1dd701..f29bf20 100644 --- a/src/services/makerDetector.js +++ b/src/services/makerDetector.js @@ -1,29 +1,34 @@ /** * makerDetector.js * Detects upcoming markets for the Maker strategy (buy low, sell high). - * Same slug-based detection as mmDetector but reads from MAKER_* config. + * Supports multiple assets AND multiple durations (e.g. 5m,15m). */ 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; +const DURATION_SECS = { '5m': 300, '15m': 900 }; let pollTimer = null; let onMarketCb = null; const seenKeys = new Set(); -function currentSlot() { - return Math.floor(Date.now() / 1000 / SLOT_SEC) * SLOT_SEC; +function slotSec(duration) { + return DURATION_SECS[duration] || 300; } -function nextSlot() { - return currentSlot() + SLOT_SEC; +function currentSlot(duration) { + const sec = slotSec(duration); + return Math.floor(Date.now() / 1000 / sec) * sec; } -async function fetchBySlug(asset, slotTimestamp) { - const slug = `${asset}-updown-${config.makerDuration}-${slotTimestamp}`; +function nextSlot(duration) { + return currentSlot(duration) + slotSec(duration); +} + +async function fetchBySlug(asset, duration, slotTimestamp) { + const slug = `${asset}-updown-${duration}-${slotTimestamp}`; try { const resp = await proxyFetch(`${config.gammaHost}/markets/slug/${slug}`); if (!resp.ok) return null; @@ -34,7 +39,7 @@ async function fetchBySlug(asset, slotTimestamp) { } } -function extractMarketData(market, asset) { +function extractMarketData(market, asset, duration) { const conditionId = market.conditionId || market.condition_id || ''; if (!conditionId) return null; @@ -55,6 +60,7 @@ function extractMarketData(market, asset) { return { asset, + duration, conditionId, question: market.question || market.title || '', endTime: market.endDate || market.end_date_iso || market.endDateIso, @@ -66,16 +72,16 @@ function extractMarketData(market, asset) { }; } -async function scheduleAsset(asset, slotTimestamp) { - const key = `${asset}-${slotTimestamp}`; +async function scheduleAsset(asset, duration, slotTimestamp) { + const key = `${asset}-${duration}-${slotTimestamp}`; if (seenKeys.has(key)) return; - const market = await fetchBySlug(asset, slotTimestamp); + const market = await fetchBySlug(asset, duration, slotTimestamp); if (!market) return; - const data = extractMarketData(market, asset); + const data = extractMarketData(market, asset, duration); if (!data) { - logger.warn(`MAKER: skipping ${asset.toUpperCase()} slot ${slotTimestamp} — missing token IDs`); + logger.warn(`MAKER: skipping ${asset.toUpperCase()} ${duration} slot ${slotTimestamp} — missing token IDs`); seenKeys.add(key); return; } @@ -85,15 +91,15 @@ async function scheduleAsset(asset, slotTimestamp) { 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`); + logger.info(`MAKER: ${asset.toUpperCase()} ${duration} 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)`); + logger.success(`MAKER: ${asset.toUpperCase()} ${duration} 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`); + logger.success(`MAKER: ${asset.toUpperCase()} ${duration} found "${data.question.slice(0, 40)}" — placing orders now`); } if (onMarketCb) onMarketCb(data); @@ -101,8 +107,14 @@ async function scheduleAsset(asset, slotTimestamp) { async function poll() { try { - const next = nextSlot(); - await Promise.all(config.makerAssets.map((asset) => scheduleAsset(asset, next))); + const tasks = []; + for (const duration of config.makerDurations) { + const next = nextSlot(duration); + for (const asset of config.makerAssets) { + tasks.push(scheduleAsset(asset, duration, next)); + } + } + await Promise.all(tasks); } catch (err) { logger.error('MAKER detector poll error:', err.message); } @@ -115,10 +127,13 @@ export function startMakerDetector(onNewMarket) { 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)`); + const durStr = config.makerDurations.join(', '); + for (const duration of config.makerDurations) { + const ns = nextSlot(duration); + const secsUntil = ns - Math.floor(Date.now() / 1000); + logger.info(`MAKER detector — ${duration}: next slot *-updown-${duration}-${ns} (opens in ${secsUntil}s)`); + } + logger.info(`MAKER detector started — assets: ${config.makerAssets.join(', ').toUpperCase()} | durations: ${durStr}`); logger.info(`Strategy: BUY @ $${config.makerBuyPrice} → SELL @ $${config.makerSellPrice} | ${config.makerTradeSize} shares/side`); } diff --git a/src/services/makerExecutor.js b/src/services/makerExecutor.js index e74ecb9..80ce6c0 100644 --- a/src/services/makerExecutor.js +++ b/src/services/makerExecutor.js @@ -11,8 +11,9 @@ * b. Place limit SELL for filled shares at makerSellPrice (e.g. 3c) * c. Cancel the other side's buy order * 5. Partial fills → partial sells placed immediately - * 6. Monitor sell orders until filled or 10s before market close - * 7. CL at 10s: cancel unfilled sell orders (tokens resolve on-chain) + * 6. CL at 10s before close: cancel unfilled buy orders + * 7. Place sells for any filled positions (retry 3x if settlement pending) + * 8. Monitor sell orders until filled or market close */ import { Side, OrderType } from '@polymarket/clob-client'; @@ -188,8 +189,8 @@ async function simCheckFill(tokenId, side, price) { // ── Core strategy ───────────────────────────────────────────────────────────── export async function executeMakerStrategy(market) { - const { asset, conditionId, question, endTime, yesTokenId, noTokenId, negRisk, tickSize } = market; - const tag = asset ? `[${asset.toUpperCase()}]` : ''; + const { asset, duration, conditionId, question, endTime, yesTokenId, noTokenId, negRisk, tickSize } = market; + const tag = asset ? `[${asset.toUpperCase()}/${duration || '5m'}]` : ''; const label = question.substring(0, 40); const sim = config.dryRun ? '[SIM] ' : ''; const { makerBuyPrice, makerSellPrice, makerTradeSize, makerMonitorMs } = config; @@ -228,6 +229,7 @@ export async function executeMakerStrategy(market) { // ── 2. Build position state ────────────────────────────────── const pos = { asset: asset || 'btc', + duration: duration || '5m', conditionId, question, endTime, @@ -306,7 +308,15 @@ async function monitorBuyPhase(pos, tag, sim) { while (!pos.winner) { const msLeft = new Date(pos.endTime).getTime() - Date.now(); - if (msLeft <= 0) break; + // CL: cancel unfilled buy at 10s before market close + if (msLeft <= CL_SECONDS * 1000) { + if (side.buyOrderId && !side.cancelled) { + logger.warn(`MAKER${tag}: CL ${CL_SECONDS}s — cancelling ${sideName} buy`); + await cancelOrder(side.buyOrderId); + side.cancelled = true; + } + break; + } // Check fill let fill; @@ -376,6 +386,45 @@ async function monitorBuyPhase(pos, tag, sim) { monitorSide('down'), ]); + // Cleanup: cancel any remaining unfilled buy orders + for (const key of ['up', 'down']) { + const s = pos[key]; + if (s.buyOrderId && !s.cancelled) { + await cancelOrder(s.buyOrderId); + s.cancelled = true; + logger.info(`MAKER${tag}: cancelled ${key.toUpperCase()} buy order`); + } + } + + // Place sells for filled buys that don't have sell orders yet + for (const key of ['up', 'down']) { + const s = pos[key]; + const soldShares = s.sellOrders.reduce((sum, so) => sum + so.shares, 0); + const unsold = s.buyFilled - soldShares; + if (unsold > 0) { + logger.info(`MAKER${tag}: placing sell for ${key.toUpperCase()} ${unsold.toFixed(2)} unsold shares`); + if (!config.dryRun) { + logger.info(`MAKER${tag}: waiting ${SELL_DELAY_MS / 1000}s for on-chain settlement...`); + await sleep(SELL_DELAY_MS); + } + const sellResult = await placeLimitSellWithRetry( + s.tokenId, unsold, makerSellPrice, + pos.tickSize, pos.negRisk, tag, + ); + if (sellResult.success) { + s.sellOrders.push({ + orderId: sellResult.orderId, + shares: unsold, + filled: false, + fillPrice: null, + }); + logger.trade(`MAKER${tag}: ${sim}${key.toUpperCase()} SELL placed ${unsold.toFixed(2)} shares @ $${makerSellPrice}`); + } else { + logger.error(`MAKER${tag}: ${key.toUpperCase()} SELL failed after ${MAX_SELL_RETRIES} retries — tokens held to resolution`); + } + } + } + if (!pos.winner) { const anyFill = pos.up.buyFilled > 0 || pos.down.buyFilled > 0; if (anyFill) { @@ -389,7 +438,7 @@ async function monitorBuyPhase(pos, tag, sim) { } } -// ── Sell phase: monitor sell orders, CL at 10s before close ────────────────── +// ── Sell phase: monitor sell orders until market close ──────────────────────── async function monitorSellPhase(pos, tag, sim) { if (pos.status === 'done') return; @@ -413,19 +462,12 @@ async function monitorSellPhase(pos, tag, sim) { while (true) { const msLeft = new Date(pos.endTime).getTime() - Date.now(); - // ── CL at 10s: cancel unfilled sells, let tokens resolve on-chain ── - if (msLeft <= CL_SECONDS * 1000) { - let unfilledCount = 0; - for (const so of side.sellOrders) { - if (!so.filled) { - await cancelOrder(so.orderId); - unfilledCount++; - } - } - if (unfilledCount > 0) { - logger.warn(`MAKER${tag}: CL ${CL_SECONDS}s — cancelled ${unfilledCount} unfilled sell order(s), held to resolution`); - } + // Market closed — unfilled sells resolve on-chain + if (msLeft <= 0) { pos.status = side.totalSellFilled > 0 ? 'done' : 'expired-holding'; + if (pos.status === 'expired-holding') { + logger.warn(`MAKER${tag}: market closed — unfilled sells held to resolution`); + } break; }