From ef249ad971b20254e98208e66e65da940e1ad492 Mon Sep 17 00:00:00 2001 From: direkturcrypto Date: Mon, 30 Mar 2026 21:00:43 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20maker=20rebate=20MM=20=E2=80=94=20HFT?= =?UTF-8?q?=20market-making=20with=20spread=20capture=20on=2015m=20markets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Place simultaneous maker limit BUY on YES+NO sides (combined ≈ $0.98) - Merge filled pairs via CTF contract → capture spread as profit - WebSocket RTDS real-time fill detection + onchain balance as source of truth - No aggressive repricing when one side is filled (prevents double exposure) - Stop re-entry after one-sided stuck cycle (prevents directional accumulation) - Combined cap always enforced — profitable merge guaranteed - Auto-queue next market before current closes (zero idle time) - Remove loss-compensating martingale logic from reprice flow - Add .env.example with full documentation for all strategies - Update README with grant-ready project description --- .env.example | 251 +++++---- README.md | 446 +++++---------- package-lock.json | 1 + package.json | 4 + src/config/index.js | 38 ++ src/maker-mm-bot.js | 290 ++++++++++ src/maker-mm.js | 219 ++++++++ src/services/client.js | 19 +- src/services/ctf.js | 72 ++- src/services/makerRebateExecutor.js | 827 ++++++++++++++++++++++++++++ src/services/mmDetector.js | 60 +- 11 files changed, 1773 insertions(+), 454 deletions(-) create mode 100644 src/maker-mm-bot.js create mode 100644 src/maker-mm.js create mode 100644 src/services/makerRebateExecutor.js diff --git a/.env.example b/.env.example index 8018147..9daf578 100644 --- a/.env.example +++ b/.env.example @@ -1,181 +1,192 @@ +# ══════════════════════════════════════════════════════════════════ +# polymarket-terminal — Environment Configuration +# Copy this file to .env and fill in your values +# ══════════════════════════════════════════════════════════════════ + # ───────────────────────────────────────────── -# WALLET SETUP +# WALLET SETUP (required for all strategies) # ───────────────────────────────────────────── -# EOA private key — used for SIGNING only, does NOT hold USDC + +# EOA private key — used for SIGNING only, does NOT hold funds +# Never share this key or commit it to version control PRIVATE_KEY=0xYOUR_EOA_PRIVATE_KEY_HERE # Polymarket Proxy Wallet — the address shown when you click "Deposit" on Polymarket -# This is where you deposit USDC.e, and where trades are funded from -# How to find: Login to polymarket.com → Profile → Deposit → copy the address +# This is where you deposit USDC.e and where all trades are funded from +# How to find: polymarket.com → Profile → Deposit → copy the address PROXY_WALLET_ADDRESS=0xYOUR_PROXY_WALLET_ADDRESS_HERE # ───────────────────────────────────────────── # POLYGON RPC # ───────────────────────────────────────────── + +# Public RPC (default, no key required) POLYGON_RPC_URL=https://polygon.lava.build +# Alternative RPCs: +# POLYGON_RPC_URL=https://polygon-rpc.com +# POLYGON_RPC_URL=https://rpc.ankr.com/polygon + # ───────────────────────────────────────────── # POLYMARKET API CREDENTIALS (optional) -# Leave blank to auto-derive from your private key +# Leave blank to auto-derive from your private key on first run # ───────────────────────────────────────────── CLOB_API_KEY= CLOB_API_SECRET= CLOB_API_PASSPHRASE= # ───────────────────────────────────────────── -# TRADER TO COPY -# Use the proxy wallet address of the trader (visible on their Polymarket profile) +# PROXY (optional — Polymarket API only, NOT Polygon RPC) +# Supports HTTP, HTTPS, and SOCKS5 proxies +# Leave empty to connect directly +# Example: http://user:pass@proxy.example.com:8080 # ───────────────────────────────────────────── -TRADER_ADDRESS=0xTRADER_PROXY_WALLET_ADDRESS +PROXY_URL= # ───────────────────────────────────────────── -# TRADE SIZING +# DRY RUN — simulate without placing real orders +# Always test with DRY_RUN=true first! # ───────────────────────────────────────────── +DRY_RUN=true + + +# ══════════════════════════════════════════════════════════════════ +# MAKER REBATE MM (npm run maker-mm-bot) +# High-frequency market-making on 15m BTC Up/Down markets. +# Places maker limit orders on both YES and NO sides, merges filled +# pairs back to USDC, and captures the bid-ask spread + maker rebate. +# ══════════════════════════════════════════════════════════════════ + +# Assets to market-make (comma-separated slugs: btc, eth, sol, xrp) +MAKER_MM_ASSETS=btc + +# Market duration to target: "5m" or "15m" +MAKER_MM_DURATION=15m + +# Number of shares per side per cycle (minimum 5) +# Total USDC deployed per cycle ≈ MAKER_MM_TRADE_SIZE × combined_price +MAKER_MM_TRADE_SIZE=5 + +# Maximum combined bid (YES + NO) — controls spread profit +# $0.98 combined = $0.02 profit per share when both fill +# Lower = more profit per pair but lower fill rate (e.g. 0.95 = $0.05/share) +MAKER_MM_MAX_COMBINED=0.98 + +# Reprice check interval (seconds) — how often to check for bid drift +MAKER_MM_REPRICE_SEC=10 + +# Minimum bid drift (in dollars) before repricing — prevents over-trading +# Default 0.02 = only reprice if best bid moved more than 2 cents +MAKER_MM_REPRICE_THRESHOLD=0.02 + +# Seconds before market close to force-exit open positions (cut-loss) +MAKER_MM_CUT_LOSS_TIME=60 + +# Max seconds after market open to enter (entry window) +# After this window, bot waits for the next market +MAKER_MM_ENTRY_WINDOW=45 + +# How often to poll for new markets (seconds) +MAKER_MM_POLL_INTERVAL=5 + +# Delay between re-entry cycles within the same market (seconds) +MAKER_MM_REENTRY_DELAY=30 + +# Price range for maker rebate eligibility (both sides must be in range) +# Polymarket rebates are earned on orders within ~30-70% range +MAKER_MM_MIN_PRICE=0.30 +MAKER_MM_MAX_PRICE=0.69 + +# ── Current Market Entry (optional) ───────────────────────────── +# Allow entering markets that are already in progress +# Useful for catching mid-market opportunities +CURRENT_MARKET_ENABLED=true + +# Maximum odds (% as decimal) to allow entry into a running market +# 0.70 = skip if either YES or NO is above 70% +CURRENT_MARKET_MAX_ODDS=0.70 + + +# ══════════════════════════════════════════════════════════════════ +# COPY TRADER (npm run bot) +# Mirrors trades from a target trader's Polymarket wallet. +# ══════════════════════════════════════════════════════════════════ + +# Proxy wallet address of the trader to copy +# Visible on their Polymarket profile URL +TRADER_ADDRESS=0xTRADER_PROXY_WALLET_ADDRESS + +# ── Trade Sizing ───────────────────────────────────────────────── # SIZE_MODE: -# "percentage" = SIZE_PERCENT% of MAX_POSITION_SIZE per market entry -# (e.g. MAX_POSITION_SIZE=$10, SIZE_PERCENT=50 → buy $5 per entry) +# "percentage" = SIZE_PERCENT% of MAX_POSITION_SIZE per entry # "balance" = SIZE_PERCENT% of your current USDC.e balance per entry -# (e.g. balance=$100, SIZE_PERCENT=10 → buy $10 per entry) -# Note: sizing is independent of the trader's individual fill size. -# Limit orders can fill in many small chunks — we always use our own sizing. SIZE_MODE=balance SIZE_PERCENT=10 # Minimum trade size in USDC (skip if calculated size is below this) MIN_TRADE_SIZE=1 -# Maximum total position per market in USDC (won't buy more once this is reached) +# Maximum total USDC position per market MAX_POSITION_SIZE=10 -# ───────────────────────────────────────────── -# AUTO SELL -# ───────────────────────────────────────────── +# ── Auto Sell ──────────────────────────────────────────────────── AUTO_SELL_ENABLED=true AUTO_SELL_PROFIT_PERCENT=10 -# Sell mode when copying trader's sell -# "market" = sell at market price immediately +# "market" = sell immediately at market price # "limit" = place limit order at trader's sell price SELL_MODE=market -# ───────────────────────────────────────────── -# INTERVALS -# ───────────────────────────────────────────── -# How often (seconds) to check for resolved markets to redeem +# ── Intervals ──────────────────────────────────────────────────── REDEEM_INTERVAL=60 - -# Skip BUY if the market closes within this many seconds from now -# Default 300 = skip if market ends in less than 5 minutes MIN_MARKET_TIME_LEFT=300 - -# When FAK finds no liquidity (e.g. copying into "next market" before it opens), -# fall back to a GTC limit order and wait this many seconds for it to fill. -# Set to 0 to disable the GTC fallback entirely. GTC_FALLBACK_TIMEOUT=60 -# ───────────────────────────────────────────── -# DRY RUN (set true to simulate without real trades) -# ───────────────────────────────────────────── -DRY_RUN=true -# ───────────────────────────────────────────── -# MARKET MAKER (mm.js / npm run mm-sim) -# ───────────────────────────────────────────── -# Comma-separated assets to market-make (same slug format as sniper) -MM_ASSETS=btc +# ══════════════════════════════════════════════════════════════════ +# ORDERBOOK SNIPER (npm run sniper) +# Places 3-tier GTC limit buy orders at panic-dump price levels. +# ══════════════════════════════════════════════════════════════════ -# Market duration: "5m" (5-minute) or "15m" (15-minute) -MM_DURATION=5m - -# USDC amount per side (total exposure = 2x this) -MM_TRADE_SIZE=5 - -# Limit sell price target (e.g. 0.60 = sell at $0.60) -MM_SELL_PRICE=0.60 - -# Seconds before market close to trigger cut-loss -MM_CUT_LOSS_TIME=60 - -# Keyword to match market question (case-insensitive) -MM_MARKET_KEYWORD=Bitcoin Up or Down - -# Max seconds after market open to enter (0 = at open only) -MM_ENTRY_WINDOW=45 - -# How often to poll for new markets (seconds) -MM_POLL_INTERVAL=10 - -# ── Recovery Buy (after cut-loss) ─────────────────────────── -# After cut-loss triggers, monitor prices for 10s and market-buy -# the dominant side if criteria are met. Does not affect the main -# MM flow — purely an opt-in add-on. -# -# Enable recovery buy -MM_RECOVERY_BUY=false - -# Minimum price the dominant side must be at (and rising/stable) to qualify -MM_RECOVERY_THRESHOLD=0.70 - -# USDC size for the recovery buy (0 = use MM_TRADE_SIZE) -MM_RECOVERY_SIZE=0 - -# Enable adaptive CL when one leg fills (true = patient limit orders, false = immediate market sell) -MM_ADAPTIVE_CL=true - -# Minimum combined sell price (both legs) required to place a limit order. -# Formula: filledLegPrice + unfilledLegPrice >= MM_ADAPTIVE_MIN_COMBINED -# Example: filledLeg=0.60, MM_ADAPTIVE_MIN_COMBINED=1.20 → floor=0.60 (won't limit-sell below $0.60) -# filledLeg=0.55, MM_ADAPTIVE_MIN_COMBINED=1.20 → floor=0.65 -# If price is below floor, bot waits for recovery. Market-sell only at CL time as last resort. -MM_ADAPTIVE_MIN_COMBINED=1.20 - -# Poll interval (seconds) for the adaptive CL loop after one leg fills. -# Smaller = more responsive to price changes, more API calls. -MM_ADAPTIVE_MONITOR_SEC=5 - -# ───────────────────────────────────────────── -# ORDERBOOK SNIPER (sniper.js / npm run sniper-sim) -# 3-Tier Strategy: Places GTC BUY orders at 3 prices with weighted sizing -# Tier 1 (3c): smallest size | Tier 2 (2c): medium | Tier 3 (1c): largest -# Catches panic dumps at multiple price levels with optimal capital allocation -# ───────────────────────────────────────────── # Comma-separated assets to snipe SNIPER_ASSETS=eth,sol,xrp -# 3-Tier pricing (high to low) -SNIPER_TIER1_PRICE=0.03 # Highest price, smallest allocation (20%) -SNIPER_TIER2_PRICE=0.02 # Mid price, medium allocation (30%) -SNIPER_TIER3_PRICE=0.01 # Lowest price, largest allocation (50%) +# 3-Tier pricing (descending) — orders placed at these prices +SNIPER_TIER1_PRICE=0.03 +SNIPER_TIER2_PRICE=0.02 +SNIPER_TIER3_PRICE=0.01 -# Max total shares per side (min 5 shares per tier) -# Example: 15 shares → 3@3c + 5@2c + 7@1c -# Example: 30 shares → 6@3c + 9@2c + 15@1c +# Max total shares to deploy (split across tiers: 20% / 30% / 50%) SNIPER_MAX_SHARES=15 -# ── Sniper Sizing Multiplier (UTC+8) ─────────────────────── -# Time-based bet sizing multiplier. Format: HH:MM-HH:MM:factor,... -# Multiplies SNIPER_MAX_SHARES during the specified time windows. -# Default = 1.0x outside any window. -# Example: US afternoon bump + Asia morning discount +# Time-based sizing multiplier (UTC+8). Format: HH:MM-HH:MM:factor,... SNIPER_MULTIPLIERS=21:00-00:00:1.41,06:00-12:00:0.85 -# ── Sniper Pause After Win ───────────────────────────────── -# Number of 5-min rounds to pause an asset after a win is detected. -# Set to 0 to disable. +# Rounds to pause an asset after detecting a win (5-min intervals) SNIPER_PAUSE_ROUNDS_AFTER_WIN=3 -# ── Sniper Session Schedule (all times UTC+8) ────────────── -# Format: HH:MM-HH:MM,HH:MM-HH:MM (comma-separated sessions) -# Assets without a schedule entry are always active. +# Active session schedules per asset (UTC+8). Format: HH:MM-HH:MM,... SNIPER_SCHEDULE_BTC=19:40-22:40,03:40-06:10 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 -# ───────────────────────────────────────────── -# PROXY (Polymarket API only, NOT Polygon RPC) -# Supports HTTP/HTTPS/SOCKS5 proxies -# Leave empty to connect directly (no proxy) -# Example: http://user:pass@proxy.example.com:8080 -# ───────────────────────────────────────────── -PROXY_URL= + +# ══════════════════════════════════════════════════════════════════ +# CLASSIC MARKET MAKER (npm run mm-bot) +# Legacy MM strategy using limit sell orders after one leg fills. +# ══════════════════════════════════════════════════════════════════ +MM_ASSETS=btc +MM_DURATION=5m +MM_TRADE_SIZE=5 +MM_SELL_PRICE=0.60 +MM_CUT_LOSS_TIME=60 +MM_MARKET_KEYWORD=Bitcoin Up or Down +MM_ENTRY_WINDOW=45 +MM_POLL_INTERVAL=10 +MM_RECOVERY_BUY=false +MM_RECOVERY_THRESHOLD=0.70 +MM_RECOVERY_SIZE=0 +MM_ADAPTIVE_CL=true +MM_ADAPTIVE_MIN_COMBINED=1.20 +MM_ADAPTIVE_MONITOR_SEC=5 diff --git a/README.md b/README.md index 1e58976..70ba7bd 100644 --- a/README.md +++ b/README.md @@ -1,314 +1,146 @@ # Polymarket Terminal -> An automated trading terminal for [Polymarket](https://polymarket.com) — copy trades, provide liquidity, and snipe low-priced orderbook fills, all from your command line. +An open-source automated trading terminal for [Polymarket](https://polymarket.com) — featuring a high-frequency maker rebate market maker, copy trading, and an orderbook sniper, all runnable from the command line. **Created by [@direkturcrypto](https://twitter.com/direkturcrypto)** +**Repository:** https://github.com/direkturcrypto/polymarket-terminal --- -## Table of Contents +## Strategies -- [Features](#features) -- [Prerequisites](#prerequisites) -- [Installation](#installation) -- [Configuration](#configuration) -- [Usage](#usage) -- [Running on VPS with PM2](#running-on-vps-with-pm2) -- [How It Works](#how-it-works) -- [Project Structure](#project-structure) -- [Important Warnings](#important-warnings) -- [License](#license) +### 1. Maker Rebate MM (`npm run maker-mm-bot`) ⭐ Main Strategy + +High-frequency market-making on Polymarket's 15-minute BTC/ETH/SOL Up-or-Down markets. + +**How it works:** +1. Detects a new 15-minute market as it opens +2. Places maker limit BUY orders on both YES and NO sides simultaneously (combined ≈ $0.98) +3. When both sides fill, merges YES + NO tokens back to USDC via the CTF contract — capturing the spread as profit +4. Re-enters immediately after each successful merge for the duration of the market +5. Automatically queues the next market before the current one closes — zero idle time between markets + +**Key design decisions:** +- **Never aggressive-reprices when one side is filled** — holds the original bid and waits for market reversion, preventing double exposure +- **Stops re-entry after a stuck (one-sided) cycle** — protects against accumulating directional exposure in trending markets +- **Combined cap always enforced** — cost of YES + NO never exceeds `MAKER_MM_MAX_COMBINED`, guaranteeing profitability on every successful merge +- **WebSocket real-time fill detection** — fills detected via RTDS WebSocket for sub-second response, with onchain balance as source of truth +- **Market-neutral** — profits from spread capture only, never depends on price direction + +**Economics per cycle (default $5/side, 5 shares):** +``` +Both sides fill → merge → recover $5.00 from $4.90 cost = +$0.10 profit per cycle +One side stuck → hold original bid → wait for reversion or cut-loss at close +``` + +**Configuration (via `.env`):** +``` +MAKER_MM_ASSETS=btc # Assets: btc, eth, sol, xrp +MAKER_MM_DURATION=15m # Market duration +MAKER_MM_TRADE_SIZE=5 # Shares per side +MAKER_MM_MAX_COMBINED=0.98 # Max combined bid (controls spread profit) +MAKER_MM_REENTRY_DELAY=30 # Seconds between cycles +CURRENT_MARKET_ENABLED=true # Allow entering mid-market +CURRENT_MARKET_MAX_ODDS=0.70 # Skip if market is more skewed than this +``` --- -## Features +### 2. Copy Trader (`npm run bot`) -### Copy Trade Bot -- **Watch Trader** — Monitor any Polymarket wallet address in real time via WebSocket -- **Copy Buy** — Automatically mirror buy orders with configurable position sizing -- **Copy Sell** — Automatically mirror sell orders (market or limit) -- **Auto Sell** — Place a GTC limit sell at a target profit % immediately after a buy fills -- **Auto Redeem** — Periodically check and redeem winning positions on-chain -- **Market Expiry Guard** — Skip buys if market closes within `MIN_MARKET_TIME_LEFT` seconds -- **GTC Fallback** — Falls back to a GTC limit order when copying "next market" trades with no liquidity -- **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 +Mirrors the trades of any target Polymarket wallet in real-time. -### Market Maker Bot -- **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 +- Monitors target wallet for new BUY/SELL activity via the CLOB API +- Replicates trades proportionally using configurable sizing modes (`balance` or `percentage`) +- Supports automatic sell-out when target trader exits (market or limit) +- Auto-redeems resolved positions -### 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 -- **Simulation Mode** — Preview orders without spending funds -- **Session Scheduling** — Per-asset time windows (UTC+8) for selective trading +``` +TRADER_ADDRESS=0xTARGET_WALLET +SIZE_MODE=balance +SIZE_PERCENT=10 +MAX_POSITION_SIZE=10 +``` --- -## Prerequisites +### 3. Orderbook Sniper (`npm run sniper`) -| Requirement | Details | -|---|---| -| Node.js | v18 or higher (ESM support required) | -| Polygon Wallet | An EOA wallet with a private key | -| Polymarket Proxy Wallet | Your proxy wallet address (visible on your Polymarket profile → Deposit) | -| USDC.e on Polygon | Deposited via Polymarket's deposit flow | -| MATIC on Polygon | A small amount for gas fees (redeem & on-chain operations) | -| PM2 *(optional)* | For running on a VPS: `npm install -g pm2` | +Places 3-tier GTC limit BUY orders at deep discount price levels to catch panic dumps. + +- Deploys staggered orders at 3 price tiers (1¢, 2¢, 3¢) with weighted sizing (50% / 30% / 20%) +- Time-based sizing multipliers for peak trading hours +- Per-asset session schedules (UTC+8) +- Auto-pauses an asset after a win to avoid re-entering an already-resolved market + +``` +SNIPER_ASSETS=eth,sol,xrp +SNIPER_MAX_SHARES=15 +SNIPER_MULTIPLIERS=21:00-00:00:1.41,06:00-12:00:0.85 +``` + +--- + +## Requirements + +- Node.js 18+ +- A Polymarket account with a funded proxy wallet (USDC.e on Polygon) +- EOA private key for signing (the signing wallet does not need to hold funds) --- ## Installation ```bash -# 1. Clone the repository git clone https://github.com/direkturcrypto/polymarket-terminal.git cd polymarket-terminal - -# 2. Install dependencies npm install - -# 3. Copy the environment template cp .env.example .env - -# 4. Fill in your credentials -nano .env +# Edit .env with your wallet keys and settings ``` --- -## Configuration +## Quick Start -All settings are controlled via the `.env` file. **Never commit your `.env` file** — it is already listed in `.gitignore`. - -### Wallet Setup - -| Variable | Description | Required | -|---|---|---| -| `PRIVATE_KEY` | Your EOA private key (signing only, does not hold USDC) | Yes | -| `PROXY_WALLET_ADDRESS` | Your Polymarket proxy wallet address | Yes | -| `POLYGON_RPC_URL` | Polygon JSON-RPC endpoint | Yes | - -> **How to find your Proxy Wallet:** Log in to polymarket.com → click your profile → Deposit → copy the wallet address shown. - -### Polymarket API Credentials (Optional) - -Leave these blank to have the client auto-derive credentials from your private key. - -| Variable | Description | -|---|---| -| `CLOB_API_KEY` | CLOB API key | -| `CLOB_API_SECRET` | CLOB API secret | -| `CLOB_API_PASSPHRASE` | CLOB API passphrase | - -### Copy Trade Bot Settings - -| Variable | Description | Default | -|---|---|---| -| `TRADER_ADDRESS` | Proxy wallet address of the trader to copy | (required) | -| `SIZE_MODE` | `percentage` (of `MAX_POSITION_SIZE`) or `balance` (of your USDC balance) | `balance` | -| `SIZE_PERCENT` | Percentage to use per trade | `10` | -| `MIN_TRADE_SIZE` | Minimum trade size in USDC (skip if below) | `1` | -| `MAX_POSITION_SIZE` | Maximum USDC per market position | `10` | -| `AUTO_SELL_ENABLED` | Place a limit sell after each buy fills | `true` | -| `AUTO_SELL_PROFIT_PERCENT` | Target profit % for the auto-sell limit order | `10` | -| `SELL_MODE` | `market` or `limit` when copying a sell | `market` | -| `REDEEM_INTERVAL` | Seconds between redemption checks | `60` | -| `MIN_MARKET_TIME_LEFT` | Skip buy if market closes within this many seconds | `300` | -| `GTC_FALLBACK_TIMEOUT` | Seconds to wait for GTC fill when FAK finds no liquidity | `60` | -| `DRY_RUN` | Simulate without placing real orders | `true` | - -### Market Maker Bot Settings - -| Variable | Description | Default | -|---|---|---| -| `MM_ASSETS` | Comma-separated assets to market-make (e.g. `btc,eth`) | `btc` | -| `MM_DURATION` | Market duration: `5m` or `15m` | `5m` | -| `MM_TRADE_SIZE` | USDC per side (total exposure = 2×) | `5` | -| `MM_SELL_PRICE` | Limit sell price target (e.g. `0.60`) | `0.60` | -| `MM_CUT_LOSS_TIME` | Seconds before close to trigger cut-loss | `60` | -| `MM_MARKET_KEYWORD` | Keyword to filter market questions | `Bitcoin Up or Down` | -| `MM_ENTRY_WINDOW` | Max seconds after open to enter (0 = open only) | `45` | -| `MM_POLL_INTERVAL` | Seconds between new market polls | `10` | -| `MM_RECOVERY_BUY` | Enable recovery buy after cut-loss | `false` | -| `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` | - -### Orderbook Sniper Settings - -**3-Tier Strategy:** Places orders at 3 price levels with weighted sizing - -| Variable | Description | Default | -|---|---|---| -| `SNIPER_ASSETS` | Comma-separated assets to snipe (e.g. `eth,sol,xrp`) | `eth,sol,xrp` | -| `SNIPER_TIER1_PRICE` | Highest price tier (e.g. `0.03` = 3c) | `0.03` | -| `SNIPER_TIER2_PRICE` | Mid price tier (e.g. `0.02` = 2c) | `0.02` | -| `SNIPER_TIER3_PRICE` | Lowest price tier (e.g. `0.01` = 1c) | `0.01` | -| `SNIPER_MAX_SHARES` | Max total shares per side (min 5 per tier) | `15` | - -**Allocation:** -- Tier 1 (3c): 20% of max shares (min 5) -- Tier 2 (2c): 30% of max shares (min 5) -- Tier 3 (1c): 50% of max shares (min 5) - -**Example with `SNIPER_MAX_SHARES=15`:** -- 3 shares @ 3c = $0.09 -- 5 shares @ 2c = $0.10 -- 7 shares @ 1c = $0.07 -- **Total per side:** 15 shares = $0.26 - ---- - -## Usage - -### Terminal UI (local) - -Runs with an interactive split-panel dashboard (blessed TUI). +**Always test with simulation mode first:** ```bash -# Copy Trade Bot -npm start # live trading -npm run dev # live + auto-reload on file changes +# Simulate maker MM — no real orders placed +npm run maker-mm-bot-sim -# Market Maker Bot -npm run mm # live trading -npm run mm-sim # simulation (DRY_RUN=true) -npm run mm-dev # simulation + auto-reload +# Run live maker MM (recommended starting config) +MAKER_MM_TRADE_SIZE=5 MAKER_MM_REENTRY_DELAY=30 npm run maker-mm-bot -# Orderbook Sniper Bot -npm run sniper # live trading -npm run sniper-sim # simulation -npm run sniper-dev # simulation + auto-reload +# Simulate copy trader +npm run bot-sim + +# Run live copy trader +npm run bot + +# Simulate orderbook sniper +npm run sniper-sim + +# Run live sniper +npm run sniper ``` -### Plain Log Mode (no TUI) - -Writes plain timestamped text to stdout — suitable for piping, `tail -f`, or PM2. - -```bash -# Copy Trade Bot -npm run bot # live trading -npm run bot-sim # simulation -npm run bot-dev # simulation + auto-reload - -# Market Maker Bot -npm run mm-bot # live trading -npm run mm-bot-sim # simulation -npm run mm-bot-dev # simulation + auto-reload -``` - -> **Always test with `DRY_RUN=true` (or `*-sim` scripts) first** before committing real funds. - --- -## Running on VPS with PM2 - -Each bot has its own PM2 config file inside the `pm2/` folder. - -### Install PM2 +## Running with PM2 (recommended for VPS) ```bash npm install -g pm2 -``` -### Copy Trade Bot +# Start maker MM +pm2 start src/maker-mm-bot.js --name polymarket-maker-mm --interpreter node -```bash -# Live trading -pm2 start pm2/copy.config.cjs - -# Simulation -pm2 start pm2/copy.config.cjs --env sim +# Start copy trader +pm2 start src/bot.js --name polymarket-bot --interpreter node # View logs -pm2 logs polymarket-copy -tail -f logs/copy-out.log - -# Management -pm2 restart polymarket-copy -pm2 stop polymarket-copy -pm2 delete polymarket-copy -``` - -### Market Maker Bot - -```bash -# Live trading -pm2 start pm2/mm.config.cjs - -# Simulation -pm2 start pm2/mm.config.cjs --env sim - -# View logs -pm2 logs polymarket-mm -tail -f logs/mm-out.log - -# Management -pm2 restart polymarket-mm -pm2 stop polymarket-mm -pm2 delete polymarket-mm -``` - -### Auto-start on reboot - -```bash -pm2 startup # generates a startup command — run the command it prints -pm2 save # saves current process list -``` - ---- - -## How It Works - -### Copy Trade Bot Flow - -``` -WebSocket (RTDS) — real-time trade events from trader - │ - ▼ -Per-market queue (prevents concurrent duplicate buys) - │ - ┌────┴──────┐ - │ │ - BUY SELL - │ │ - ├─ Expiry guard (MIN_MARKET_TIME_LEFT) - ├─ Max position cap ├─ Cancel open orders - ├─ FAK market buy ├─ Reconcile on-chain balance - │ └─ 0 fill? → GTC fallback ├─ FAK market sell / limit sell - ├─ Place auto-sell GTC └─ Remove position - └─ Save position - │ - ▼ -Redeemer loop (every REDEEM_INTERVAL seconds) - → Check on-chain payout → redeemPositions via Gnosis Safe -``` - -### Market Maker Flow - -``` -New Market Detected - │ - ▼ -Split USDC → YES + NO tokens ($0.50 each, zero slippage) - │ - ▼ -Place limit SELL on both sides at MM_SELL_PRICE - │ - ▼ -Monitor fills every few seconds - │ - ┌────┴────┐ - │ │ - Fill Time < MM_CUT_LOSS_TIME - │ │ - ▼ ▼ -Collect Cancel orders → Merge YES+NO back to USDC - profit (recovery buy optional) +pm2 logs polymarket-maker-mm +pm2 logs polymarket-bot ``` --- @@ -316,68 +148,54 @@ Collect Cancel orders → Merge YES+NO back to USDC ## Project Structure ``` -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) -│ ├── sniper.js — Orderbook sniper bot -│ │ -│ ├── config/ -│ │ └── index.js — Environment variable loading & validation -│ │ -│ ├── services/ -│ │ ├── client.js — CLOB client initialization & USDC balance -│ │ ├── watcher.js — Poll-based trader activity detection -│ │ ├── wsWatcher.js — WebSocket real-time trade listener -│ │ ├── executor.js — Buy & sell order execution logic -│ │ ├── position.js — Position state management (CRUD) -│ │ ├── 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 -│ │ ├── sniperDetector.js — Market detection for sniper -│ │ └── sniperExecutor.js — Orderbook sniper order placement -│ │ -│ ├── ui/ -│ │ └── dashboard.js — Terminal UI (blessed) -│ │ -│ └── utils/ -│ ├── logger.js — Timestamped logging (TUI + plain modes) -│ ├── state.js — Atomic JSON state file management -│ └── simStats.js — Simulation P&L statistics -│ -├── pm2/ -│ ├── copy.config.cjs — PM2 config for copy trade bot -│ └── mm.config.cjs — PM2 config for market maker bot -│ -├── data/ — Runtime state files (gitignored) -├── logs/ — PM2 log files (gitignored) -├── .env.example — Configuration template -├── .gitignore -└── package.json +src/ +├── maker-mm-bot.js # Maker Rebate MM — PM2/VPS entry point +├── maker-mm.js # Maker Rebate MM — TUI entry point +├── bot.js # Copy Trader +├── sniper.js # Orderbook Sniper +├── mm-bot.js # Classic MM (legacy) +├── config/ +│ └── index.js # All configuration with env var mapping +└── services/ + ├── makerRebateExecutor.js # Core maker MM logic (orders, fills, merge) + ├── mmDetector.js # Market discovery and scheduling + ├── mmWsFillWatcher.js # WebSocket RTDS real-time fill detection + ├── ctf.js # CTF contract interaction (merge/redeem) + └── client.js # Polymarket CLOB client wrapper ``` --- -## Important Warnings +## How Maker Rebate Works on Polymarket -- **Never commit your `.env` file.** Your private key must remain secret. The `.gitignore` already excludes it. -- **Always start with `DRY_RUN=true`** (or a `*-sim` script) to verify the bot behaves as expected before using real funds. -- **Use a small `SIZE_PERCENT`** for initial live runs to limit exposure. -- **Keep MATIC in your EOA wallet** for gas fees (redeem operations and on-chain CTF calls). -- **This software is provided as-is, with no guarantees.** Prediction market trading carries significant financial risk. You are solely responsible for any losses. +Polymarket's CLOB gives **maker rebates** to traders who post limit orders, while takers pay a fee. This terminal exploits that by: + +1. Simultaneously posting BUY limit orders on both YES and NO of a binary market +2. Since YES + NO always resolve to $1.00 (exactly one wins), buying both at combined cost < $1.00 guarantees a profit on merge +3. The position is closed by merging the token pair back into USDC via Polymarket's CTF contract — not by holding to resolution + +This strategy is **market-neutral** and **direction-agnostic**. Profitability depends on fill rate and spread capture, not on predicting BTC price direction. --- -## Credits +## Risk Management -Built and maintained by **[@direkturcrypto](https://twitter.com/direkturcrypto)**. +- **No aggressive repricing**: after one side fills, the unfilled order stays at its original price — no chasing the market +- **Combined cap enforced**: YES + NO bids always ≤ `MAKER_MM_MAX_COMBINED` — a merge always returns more than it cost +- **One-sided stop**: if a cycle ends with only one side filled, re-entry for that market halts to prevent directional accumulation +- **Cut-loss**: all open orders are cancelled 60 seconds before market close +- **Odds filter**: skips re-entry if market odds exceed the configured threshold (default 70%) --- ## License -ISC License — see [LICENSE](LICENSE) for details. +MIT — free to use, fork, and modify. + +--- + +## Contributing + +Pull requests are welcome. Open an issue for bugs or feature requests. + +Built for the Polymarket ecosystem. Not affiliated with Polymarket. diff --git a/package-lock.json b/package-lock.json index 8b443fb..219e16b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "polymarket-terminal", "version": "1.0.0", + "hasInstallScript": true, "license": "ISC", "dependencies": { "@polymarket/clob-client": "^4.7.3", diff --git a/package.json b/package.json index ef6b850..839fbb1 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,10 @@ "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", + "maker-mm": "DRY_RUN=false node src/maker-mm.js", + "maker-mm-sim": "DRY_RUN=true node src/maker-mm.js", + "maker-mm-bot": "node src/maker-mm-bot.js", + "maker-mm-bot-sim": "DRY_RUN=true node src/maker-mm-bot.js", "postinstall": "node scripts/patch-clob-client.cjs" }, "keywords": [ diff --git a/src/config/index.js b/src/config/index.js index a5e691a..c5feabb 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -82,6 +82,32 @@ const config = { mmRecoveryThreshold: parseFloat(process.env.MM_RECOVERY_THRESHOLD || '0.70'), // min price to qualify mmRecoverySize: parseFloat(process.env.MM_RECOVERY_SIZE || '0'), // 0 = use mmTradeSize + // ── Maker Rebate MM ──────────────────────────────────────────── + // Buy YES+NO at top bid (maker), merge back to USDC ($1.00). + // Profit = spread + maker rebate fees. + makerMmAssets: (process.env.MAKER_MM_ASSETS || process.env.MM_ASSETS || 'btc') + .split(',').map((s) => s.trim().toLowerCase()).filter(Boolean), + makerMmDuration: process.env.MAKER_MM_DURATION || process.env.MM_DURATION || '5m', + makerMmTradeSize: parseFloat(process.env.MAKER_MM_TRADE_SIZE || '5'), // USDC per side + makerMmMaxCombined: parseFloat(process.env.MAKER_MM_MAX_COMBINED || '0.99'), // max bid_YES + bid_NO + makerMmRepriceSec: parseInt(process.env.MAKER_MM_REPRICE_SEC || '3', 10), // orderbook poll interval + makerMmFillTimeout: parseInt(process.env.MAKER_MM_FILL_TIMEOUT || '120', 10), // secs for 2nd fill after 1st + makerMmCutLossTime: parseInt(process.env.MAKER_MM_CUT_LOSS_TIME || '60', 10), // secs before close to force exit + makerMmEntryWindow: parseInt(process.env.MAKER_MM_ENTRY_WINDOW || '45', 10), // max secs after open to enter + makerMmPollInterval: parseInt(process.env.MAKER_MM_POLL_INTERVAL || process.env.MM_POLL_INTERVAL || '5', 10) * 1000, + makerMmReentryDelay: parseInt(process.env.MAKER_MM_REENTRY_DELAY || '30', 10) * 1000, // ms delay between re-entry cycles + makerMmRepriceThreshold: parseFloat(process.env.MAKER_MM_REPRICE_THRESHOLD || '0.02'), // reprice if bid drifts > this (default 2c) + makerMmMinPrice: parseFloat(process.env.MAKER_MM_MIN_PRICE || '0.30'), // min bid for rebate range (both sides) + makerMmMaxPrice: parseFloat(process.env.MAKER_MM_MAX_PRICE || '0.69'), // max bid for rebate range (both sides) + + // ── Current Market Settings ──────────────────────────────────── + // Enable trading on current active market (not just next market) + currentMarketEnabled: process.env.CURRENT_MARKET_ENABLED === 'true', + // Max odds threshold for current market (stop re-entry if odds drop below this) + currentMarketMaxOdds: parseFloat(process.env.CURRENT_MARKET_MAX_ODDS || '0.70'), + // Max odds threshold for next market (only enter if max odds <= this) + nextMarketMaxOdds: parseFloat(process.env.NEXT_MARKET_MAX_ODDS || '0.52'), + // ── Orderbook Sniper ─────────────────────────────────────────── // 3-tier strategy: places GTC limit BUY orders at 3c, 2c, and 1c // Tier 1 (3c): smallest size | Tier 2 (2c): medium size | Tier 3 (1c): largest size @@ -161,4 +187,16 @@ export function validateMMConfig() { throw new Error('MM_SELL_PRICE must be between 0 and 1'); } +// Validation for maker-rebate MM bot +export function validateMakerMMConfig() { + 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.makerMmTradeSize <= 0) throw new Error('MAKER_MM_TRADE_SIZE must be > 0'); + if (config.makerMmMaxCombined <= 0 || config.makerMmMaxCombined >= 1) + throw new Error('MAKER_MM_MAX_COMBINED must be between 0 and 1 exclusive'); +} + export default config; diff --git a/src/maker-mm-bot.js b/src/maker-mm-bot.js new file mode 100644 index 0000000..a34ea27 --- /dev/null +++ b/src/maker-mm-bot.js @@ -0,0 +1,290 @@ +/** + * maker-mm-bot.js — Maker Rebate MM, PM2 / VPS entry point (no TUI) + * + * Plain-text stdout output, compatible with: + * pm2 start pm2/maker-mm.config.cjs + * pm2 logs polymarket-maker-mm + */ + +// Set proxy before any network calls +import './utils/proxy-patch.cjs'; + +import { validateMakerMMConfig } from './config/index.js'; +import config from './config/index.js'; +import logger from './utils/logger.js'; +import { initClient, getUsdcBalance } from './services/client.js'; +import { startMMDetector, stopMMDetector, checkCurrentMarket } from './services/mmDetector.js'; +import { executeMakerRebateStrategy, getActiveMakerPositions, getMarketOdds as getExecutorMarketOdds } from './services/makerRebateExecutor.js'; +import { mmFillWatcher } from './services/mmWsFillWatcher.js'; + +logger.interceptConsole(); + +// ── Validate config ──────────────────────────────────────────────────────────── + +try { + validateMakerMMConfig(); +} 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); +} + +// ── Start WebSocket fill watcher ───────────────────────────────────────────── + +mmFillWatcher.start(); + +// ── Override mmDetector config to use maker-mm settings ────────────────────── + +config.mmAssets = config.makerMmAssets; +config.mmDuration = config.makerMmDuration; +config.mmPollInterval = config.makerMmPollInterval; +config.mmEntryWindow = config.makerMmEntryWindow; + +// ── 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( + `--- MakerMM Status [${mode}] | Balance: ${balanceStr} | Active positions: ${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 left` + : `${secsLeft}s left`; + + const yFill = pos.yes.filled ? `FILLED` : `bid $${pos.yes.buyPrice?.toFixed(3)}`; + const nFill = pos.no.filled ? `FILLED` : `bid $${pos.no.buyPrice?.toFixed(3)}`; + const combined = (pos.yes.buyPrice + pos.no.buyPrice).toFixed(4); + + logger.info( + ` ${assetTag}${label} | ${pos.status} | ${timeStr}` + + ` | combined $${combined}` + + ` | YES ${pos.targetShares}sh → ${yFill}` + + ` | NO ${pos.targetShares}sh → ${nFill}`, + ); + } + } catch (err) { + logger.warn(`Status check error: ${err.message}`); + } +} + +// ── Market handler with per-asset queue ────────────────────────────────────── + +const pendingByAsset = new Map(); +const runningByAsset = new Set(); // tracked from start of runStrategy, not just active positions + +/** + * Check if current market odds allow re-entry + * For current market: max odds must be <= currentMarketMaxOdds (default 70%) + */ +async function isCurrentMarketOddsValidForReentry(yesTokenId, noTokenId) { + if (!config.currentMarketEnabled) return false; + + try { + const odds = await getExecutorMarketOdds(yesTokenId, noTokenId); + if (!odds) { + logger.warn(`MakerMM: cannot determine odds — blocking re-entry`); + return false; + } + + const threshold = config.currentMarketMaxOdds; + const valid = odds.max <= threshold; + + if (!valid) { + logger.warn( + `MakerMM: current market max odds ${(odds.max * 100).toFixed(1)}% > ${(threshold * 100).toFixed(0)}% ` + + `— STOPPING re-entry for this market` + ); + } else { + logger.info( + `MakerMM: current market max odds ${(odds.max * 100).toFixed(1)}% <= ${(threshold * 100).toFixed(0)}% ` + + `— re-entry allowed` + ); + } + + return valid; + } catch (err) { + logger.warn(`MakerMM: odds check error — ${err.message}`); + return false; + } +} + +async function runStrategy(market) { + const isCurrentMarket = market.isCurrentMarket ?? false; + const assetTag = market.asset?.toUpperCase() || ''; + let cycleCount = 0; + + runningByAsset.add(market.asset); + + while (true) { + cycleCount++; + if (cycleCount > 1) { + logger.info(`MakerMM[${assetTag}]: re-entry cycle #${cycleCount}`); + } + + // ── Check if already have active position for this asset ───────────── + // Wait for any existing position to complete before starting new one + const maxWaitMs = 120_000; // Max 2 minutes wait + const pollIntervalMs = 2_000; + const waitStart = Date.now(); + + while (true) { + const activePositions = getActiveMakerPositions(); + const hasActivePosition = activePositions.some(p => p.asset === market.asset); + + if (!hasActivePosition) break; // Safe to proceed + + if (Date.now() - waitStart > maxWaitMs) { + logger.warn(`MakerMM[${assetTag}]: timeout waiting for previous position — skipping cycle`); + return; // Exit this runStrategy entirely + } + + logger.info(`MakerMM[${assetTag}]: waiting for previous position to complete...`); + await new Promise(r => setTimeout(r, pollIntervalMs)); + } + + let cycleResult = { oneSided: false }; + try { + cycleResult = await executeMakerRebateStrategy(market) ?? { oneSided: false }; + } catch (err) { + logger.error(`MakerMM strategy error (${assetTag}): ${err.message}`); + } + + // If cycle ended with one-sided fill (stuck), stop re-entry for this market + if (cycleResult.oneSided) { + logger.warn(`MakerMM[${assetTag}]: cycle ended one-sided — stopping re-entry to avoid accumulating exposure`); + break; + } + + // Check if we can re-enter (market still active with enough time) + const msRemaining = new Date(market.endTime).getTime() - Date.now(); + const secsLeft = Math.round(msRemaining / 1000); + const minTimeForReentry = 180; // 3 minutes minimum + + if (secsLeft > config.makerMmCutLossTime + minTimeForReentry) { + // ── CURRENT MARKET: Check odds before re-entry ────────────────────── + if (isCurrentMarket && config.currentMarketEnabled) { + const oddsValid = await isCurrentMarketOddsValidForReentry( + market.yesTokenId, + market.noTokenId + ); + + if (!oddsValid) { + logger.info( + `MakerMM[${assetTag}]: current market odds exceeded threshold — ` + + `stopping re-entry, will wait for next market` + ); + break; // Exit to next market instead of re-entering + } + } + + const delaySec = config.makerMmReentryDelay / 1000; + logger.info(`MakerMM[${assetTag}]: waiting ${delaySec}s for re-entry (${secsLeft}s remaining)...`); + await new Promise(r => setTimeout(r, config.makerMmReentryDelay)); + continue; // Re-enter same market + } + + // Not enough time for re-entry — check queued market + break; + } + + runningByAsset.delete(market.asset); + + 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.makerMmCutLossTime) { + logger.success( + `MakerMM[${assetTag}]: position cleared — ` + + `executing queued "${queued.question.substring(0, 40)}" (${secsLeft}s left)`, + ); + runStrategy(queued); + } else { + logger.warn( + `MakerMM[${assetTag}]: queued market "${queued.question.substring(0, 40)}" ` + + `expired (${secsLeft}s left) — discarding`, + ); + } + } +} + +async function handleNewMarket(market) { + // Use runningByAsset — tracks from start of runStrategy, not just active positions. + // This prevents race where next market fires before executeMakerRebateStrategy adds to activePositions. + const isAssetBusy = runningByAsset.has(market.asset); + + if (isAssetBusy) { + pendingByAsset.set(market.asset, market); + logger.warn( + `MakerMM[${market.asset?.toUpperCase()}]: queued "${market.question.substring(0, 40)}" — ` + + `will enter after current position clears`, + ); + return; + } + + runStrategy(market); +} + +// ── Timers ──────────────────────────────────────────────────────────────────── + +const statusTimer = setInterval(printStatus, 60_000); + +// ── Graceful shutdown ───────────────────────────────────────────────────────── + +function shutdown() { + logger.warn('MakerMM: shutting down...'); + stopMMDetector(); + mmFillWatcher.stop(); + clearInterval(statusTimer); + setTimeout(() => process.exit(0), 300); +} + +process.on('SIGINT', shutdown); +process.on('SIGTERM', shutdown); + +// ── Start ───────────────────────────────────────────────────────────────────── + +const mode = config.dryRun ? 'SIMULATION' : 'LIVE'; +logger.info(`=== Maker Rebate MM [${mode}] ===`); +logger.info(`Assets : ${config.makerMmAssets.join(', ').toUpperCase()}`); +logger.info(`Duration : ${config.makerMmDuration}`); +logger.info(`Trade size : $${config.makerMmTradeSize} per side`); +logger.info(`Max combined: $${config.makerMmMaxCombined}`); +logger.info(`Reprice : ${config.makerMmRepriceSec}s`); +logger.info(`Fill timeout: ${config.makerMmFillTimeout}s`); +logger.info(`Cut loss : ${config.makerMmCutLossTime}s before close`); +logger.info(`Entry window: ${config.makerMmEntryWindow}s after open`); +logger.info(`Current MM : ${config.currentMarketEnabled ? 'ENABLED' : 'disabled'} (max odds: ${(config.currentMarketMaxOdds * 100).toFixed(0)}%)`); +logger.info(`Next MM : max odds ${(config.nextMarketMaxOdds * 100).toFixed(0)}%`); +logger.info('=========================================='); + +// Check current active market FIRST so it gets priority and marks asset as running +// before the detector polls for the next market. +await checkCurrentMarket((market) => handleNewMarket({ ...market, isCurrentMarket: true })); +startMMDetector(handleNewMarket); +logger.success(`MakerMM bot started — watching for ${config.makerMmDuration} ${config.makerMmAssets.join('/')} markets...`); diff --git a/src/maker-mm.js b/src/maker-mm.js new file mode 100644 index 0000000..58fd878 --- /dev/null +++ b/src/maker-mm.js @@ -0,0 +1,219 @@ +/** + * maker-mm.js + * Entry point for the Maker Rebate MM bot (TUI). + * Buys YES+NO at top bid (maker) → merges → profit from spread + rebates. + * Run with: npm run maker-mm (live) + * npm run maker-mm-sim (simulation / dry-run) + */ + +// Set proxy before any network calls +import './utils/proxy-patch.cjs'; + +import { validateMakerMMConfig } from './config/index.js'; +import config from './config/index.js'; +import logger from './utils/logger.js'; +import { initClient, getClient } from './services/client.js'; +import { initDashboard, appendLog, updateStatus, isDashboardActive } from './ui/dashboard.js'; +import { startMMDetector, stopMMDetector, checkCurrentMarket } from './services/mmDetector.js'; +import { executeMakerRebateStrategy, getActiveMakerPositions } from './services/makerRebateExecutor.js'; +import { mmFillWatcher } from './services/mmWsFillWatcher.js'; +import { getUsdcBalance } from './services/client.js'; + +// ── Validate config ──────────────────────────────────────────────────────────── + +try { + validateMakerMMConfig(); +} 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); +} + +// ── Start WebSocket fill watcher for real-time order detection ──────────────── + +mmFillWatcher.start(); + +// ── Override mmDetector config to use maker-mm settings ────────────────────── + +config.mmAssets = config.makerMmAssets; +config.mmDuration = config.makerMmDuration; +config.mmPollInterval = config.makerMmPollInterval; +config.mmEntryWindow = config.makerMmEntryWindow; + +// ── Status panel refresh ────────────────────────────────────────────────────── + +async function buildStatusContent() { + let 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(` Strategy: {cyan-fg}MAKER REBATE{/cyan-fg}`); + lines.push(''); + + // Config + lines.push(`{bold}MAKER MM CONFIG{/bold}`); + lines.push(` Assets : ${config.makerMmAssets.join(', ').toUpperCase()}`); + lines.push(` Duration : ${config.makerMmDuration}`); + lines.push(` Trade sz : $${config.makerMmTradeSize} per side`); + lines.push(` Max combined: $${config.makerMmMaxCombined}`); + lines.push(` Reprice : ${config.makerMmRepriceSec}s`); + lines.push(` Fill timeout: ${config.makerMmFillTimeout}s`); + lines.push(` Cut loss : ${config.makerMmCutLossTime}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}`; + + const combined = (pos.yes.buyPrice + pos.no.buyPrice).toFixed(4); + const spread = (1 - pos.yes.buyPrice - pos.no.buyPrice).toFixed(4); + + lines.push(` {cyan-fg}${assetTag}${label}{/cyan-fg}`); + lines.push(` Status : ${pos.status} | Time left: ${timeStr}`); + lines.push(` Combined: $${combined} | Spread: $${spread}`); + + // YES side + const yFill = pos.yes.filled + ? `{green-fg}FILLED{/green-fg}` + : `{yellow-fg}bid $${pos.yes.buyPrice?.toFixed(3)}{/yellow-fg}`; + lines.push(` YES ${pos.targetShares?.toFixed(1)} sh @ $${pos.yes.buyPrice?.toFixed(3)} → ${yFill}`); + + // NO side + const nFill = pos.no.filled + ? `{green-fg}FILLED{/green-fg}` + : `{yellow-fg}bid $${pos.no.buyPrice?.toFixed(3)}{/yellow-fg}`; + lines.push(` NO ${pos.targetShares?.toFixed(1)} sh @ $${pos.no.buyPrice?.toFixed(3)} → ${nFill}`); + + if (pos.totalProfit !== 0) { + const sign = pos.totalProfit >= 0 ? '+' : ''; + const color = pos.totalProfit >= 0 ? 'green' : 'red'; + lines.push(` P&L: {${color}-fg}${sign}$${pos.totalProfit.toFixed(2)}{/${color}-fg}`); + } + + lines.push(''); + } + } + + return '\n' + lines.join('\n'); +} + +let refreshTimer = null; + +function startRefresh() { + refreshTimer = setInterval(async () => { + if (!isDashboardActive()) return; + const content = await buildStatusContent(); + updateStatus(content); + }, 3000); + + // Immediate refresh + buildStatusContent().then(updateStatus); +} + +// ── Market handler with per-asset queue ────────────────────────────────────── + +const pendingByAsset = new Map(); + +async function runStrategy(market) { + try { + await executeMakerRebateStrategy(market); + } catch (err) { + logger.error(`MakerMM strategy error (${market.asset?.toUpperCase()}): ${err.message}`); + } + + // After position clears, execute queued market for this asset + 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.makerMmCutLossTime) { + logger.success( + `MakerMM[${market.asset?.toUpperCase()}]: position cleared — ` + + `executing queued "${queued.question.substring(0, 40)}" (${secsLeft}s left)`, + ); + runStrategy(queued); + } else { + logger.warn( + `MakerMM[${market.asset?.toUpperCase()}]: queued market "${queued.question.substring(0, 40)}" ` + + `expired (${secsLeft}s left) — discarding`, + ); + } + } +} + +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( + `MakerMM[${market.asset?.toUpperCase()}]: queued "${market.question.substring(0, 40)}" — ` + + `will enter after current position clears`, + ); + return; + } + + runStrategy(market); +} + +// ── Graceful shutdown ───────────────────────────────────────────────────────── + +function shutdown() { + logger.warn('MakerMM: shutting down...'); + stopMMDetector(); + mmFillWatcher.stop(); + if (refreshTimer) clearInterval(refreshTimer); + process.exit(0); +} + +process.on('SIGINT', shutdown); +process.on('SIGTERM', shutdown); + +// ── Start ───────────────────────────────────────────────────────────────────── + +logger.info(`MakerMM bot starting — ${config.dryRun ? 'SIMULATION MODE' : 'LIVE MODE'} | assets: ${config.makerMmAssets.join(', ').toUpperCase()} | ${config.makerMmDuration}`); +startRefresh(); +startMMDetector(handleNewMarket); +// Immediately check if there's a current active market to enter +checkCurrentMarket(handleNewMarket); diff --git a/src/services/client.js b/src/services/client.js index e5abac8..f7785ae 100644 --- a/src/services/client.js +++ b/src/services/client.js @@ -1,11 +1,12 @@ import { ClobClient } from '@polymarket/clob-client'; -import { Wallet } from 'ethers'; +import { ethers, Wallet } from 'ethers'; import config from '../config/index.js'; import logger from '../utils/logger.js'; import { setupAxiosProxy, testProxy } from '../utils/proxy.js'; let clobClient = null; let signer = null; +let _provider = null; // singleton — reused across all onchain calls /** * Initialize the Polymarket CLOB client @@ -79,20 +80,22 @@ export function getSigner() { } /** - * Get a working Polygon provider using RPC from config + * Get (or create) the singleton Polygon provider. + * A single JsonRpcProvider instance is reused across all onchain calls + * to avoid reconnection overhead on every balance check. */ -export async function getPolygonProvider() { - const { ethers } = await import('ethers'); - const provider = new ethers.providers.JsonRpcProvider(config.polygonRpcUrl); - return provider; +export function getPolygonProvider() { + if (!_provider) { + _provider = new ethers.providers.JsonRpcProvider(config.polygonRpcUrl); + } + return _provider; } /** * Get USDC.e balance of the proxy wallet on Polygon */ export async function getUsdcBalance() { - const { ethers } = await import('ethers'); - const provider = await getPolygonProvider(); + const provider = getPolygonProvider(); const usdcAddress = '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174'; // USDC.e on Polygon const abi = ['function balanceOf(address) view returns (uint256)']; const usdc = new ethers.Contract(usdcAddress, abi, provider); diff --git a/src/services/ctf.js b/src/services/ctf.js index 6fc751f..fa9fbf9 100644 --- a/src/services/ctf.js +++ b/src/services/ctf.js @@ -73,6 +73,10 @@ function parseOnchainError(err) { return 'Priority fee below Polygon minimum (25 Gwei)'; if (msg.includes('UNPREDICTABLE_GAS_LIMIT')) return 'Gas estimation failed — transaction will likely revert'; + if (msg.includes('GS026')) + return 'Safe nonce conflict (GS026) — another transaction consumed this nonce'; + if (msg.includes('GS013')) + return 'Safe execution failed (GS013) — inner transaction reverted'; if (msg.includes('execution reverted') || err?.code === 'CALL_EXCEPTION') return reason ? `Transaction reverted: ${reason}` : 'Transaction reverted by smart contract'; if (msg.includes('timeout') || msg.includes('TIMEOUT')) @@ -117,7 +121,7 @@ let _strategyTxActive = false; * Non-priority calls (redeemer) wait until no strategy tx is active. */ export function execSafeCall(to, data, description = '', opts = {}) { - const { priority = true } = opts; + const { priority = true, gasLimit } = opts; const job = async () => { // Non-priority (redeemer): wait if a strategy tx is active @@ -131,7 +135,7 @@ export function execSafeCall(to, data, description = '', opts = {}) { if (priority) _strategyTxActive = true; try { - return await _doExecSafeCall(to, data, description); + return await _doExecSafeCall(to, data, description, gasLimit); } finally { if (priority) _strategyTxActive = false; } @@ -144,7 +148,7 @@ export function execSafeCall(to, data, description = '', opts = {}) { return result; } -async function _doExecSafeCall(to, data, description = '') { +async function _doExecSafeCall(to, data, description = '', gasLimit = undefined) { if (description) logger.info(`MM: exec safe tx — ${description}`); let lastErr; @@ -203,12 +207,15 @@ async function _doExecSafeCall(to, data, description = '') { ? MAX_FEE_CAP : estimatedMaxFee.mul(Math.ceil(currentMultiplier * 100)).div(100); + const txOpts = { maxPriorityFeePerGas: gasTip, maxFeePerGas: gasFeeCap }; + if (gasLimit) txOpts.gasLimit = gasLimit; + const tx = await safe.execTransaction( to, 0, data, 0, 0, 0, 0, ethers.constants.AddressZero, ethers.constants.AddressZero, signature, - { maxPriorityFeePerGas: gasTip, maxFeePerGas: gasFeeCap }, + txOpts, ); const receipt = await tx.wait(); @@ -287,6 +294,50 @@ export async function ensureExchangeApproval(negRisk = false) { logger.success(`MM: CTF exchange approved as ERC1155 operator`); } +// ── Helper: Redeem after merge ─────────────────────────────────────────────── + +/** + * Redeem positions for a specific conditionId (after successful merge). + * This is a thin wrapper around redeemPositions to support auto-redeem. + * + * @param {string} conditionId - Market conditionId to redeem + * @param {boolean} negRisk - Whether the market uses negRisk exchange + */ +export async function redeemPositions(conditionId, negRisk = false) { + if (config.dryRun) { + logger.info(`MM[SIM]: redeem positions for conditionId=${conditionId?.slice(0, 10)}...`); + return; + } + + // Pre-check: ensure market has resolved before calling redeemPositions. + // If payoutDenominator == 0, the condition is unresolved — redeemPositions will + // revert and the Safe wraps that as GS013. Throw a clear error instead. + try { + const provider = getPolygonProvider(); + const ctf = new ethers.Contract(CTF_ADDRESS, CTF_ABI, provider); + const denominator = await ctf.payoutDenominator(conditionId); + if (denominator.isZero()) { + throw new Error(`Market not resolved yet (payoutDenominator=0) — cannot redeem conditionId=${conditionId?.slice(0, 12)}`); + } + } catch (err) { + if (err.message.includes('payoutDenominator=0') || err.message.includes('not resolved')) throw err; + // RPC error on pre-check — log and proceed anyway (let execSafeCall handle it) + logger.warn(`MM: redeemPositions pre-check failed — ${err.message} — proceeding anyway`); + } + + const ctfIface = new ethers.utils.Interface(CTF_ABI); + const data = ctfIface.encodeFunctionData('redeemPositions', [ + USDC_ADDRESS, + ethers.constants.HashZero, + conditionId, + [1, 2], + ]); + + // gasLimit bypasses eth_estimateGas RPC flakiness (same reason as mergePositions). + // GS013 without gasLimit = inner CTF call reverted, often due to gas estimation failure. + await execSafeCall(CTF_ADDRESS, data, `redeemPositions ${conditionId?.slice(0, 12)}...`, { gasLimit: 500_000 }); +} + // ── Core CTF operations ─────────────────────────────────────────────────────── /** @@ -355,7 +406,13 @@ export async function mergePositions(conditionId, sharesPerSide) { return recovered; } - const amountWei = ethers.utils.parseUnits(sharesPerSide.toFixed(6), 6); + // Floor to exact 6-decimal integer to prevent requesting more units than the Safe holds. + // Floating point round-trip (e.g. 4.910199 → toFixed(4) → 4.9102 → 4910200 wei) + // can exceed actual on-chain balance by 1 unit, causing the CTF merge to revert. + const amountWei = ethers.utils.parseUnits( + (Math.floor(sharesPerSide * 1_000_000) / 1_000_000).toFixed(6), + 6, + ); const ctfIface = new ethers.utils.Interface(CTF_ABI); const data = ctfIface.encodeFunctionData('mergePositions', [ @@ -366,7 +423,10 @@ export async function mergePositions(conditionId, sharesPerSide) { amountWei, ]); - await execSafeCall(CTF_ADDRESS, data, `mergePositions conditionId=${conditionId.slice(0, 10)}...`); + // Pass explicit gasLimit to bypass eth_estimateGas — Polygon RPC instability + // can cause estimateGas to fail even when the tx would succeed onchain. + // 500k gas is well above the ~200-250k typically consumed by a Safe+CTF merge. + await execSafeCall(CTF_ADDRESS, data, `mergePositions conditionId=${conditionId.slice(0, 10)}...`, { gasLimit: 500_000 }); logger.success(`MM: merged — recovered $${sharesPerSide} USDC`); return sharesPerSide; } diff --git a/src/services/makerRebateExecutor.js b/src/services/makerRebateExecutor.js new file mode 100644 index 0000000..8a82de5 --- /dev/null +++ b/src/services/makerRebateExecutor.js @@ -0,0 +1,827 @@ +/** + * makerRebateExecutor.js + * Simplified Maker Rebate MM strategy: + * 1. Fetch YES orderbook + * 2. Deduce NO price from YES (YES + NO ≈ $1.00) + * 3. Place BUY limit once on both sides (NO repricing) + * 4. Wait for 100% fill with SAME share count on both sides + * 5. Merge YES+NO → $1.00 USDC → profit + maker rebates + */ + +import { Side, OrderType } from '@polymarket/clob-client'; +import { ethers } from 'ethers'; +import config from '../config/index.js'; +import { getClient, getUsdcBalance, getPolygonProvider } from './client.js'; +import { mergePositions } from './ctf.js'; +import { mmFillWatcher } from './mmWsFillWatcher.js'; +import logger from '../utils/logger.js'; + +const CTF_ADDRESS = '0x4D97DCd97eC945f40cF65F87097ACe5EA0476045'; +const CTF_BALANCE_ABI = ['function balanceOf(address account, uint256 id) view returns (uint256)']; +const CLOB_MIN_ORDER_SHARES = 5; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// Price range limits — configurable via MAKER_MM_MIN_PRICE / MAKER_MM_MAX_PRICE +// Both sides must be within this range to qualify for maker rebates +const getMinPrice = () => config.makerMmMinPrice; +const getMaxPrice = () => config.makerMmMaxPrice; + +const activePositions = new Map(); +export function getActiveMakerPositions() { + return Array.from(activePositions.values()); +} + +// Export for use in maker-mm-bot.js +export { getMarketOdds }; + +// ── Price helpers ──────────────────────────────────────────────────────────── + +async function getRealPrice(tokenId) { + const client = getClient(); + try { + const result = await client.getPrice(tokenId, 'BUY'); + const price = parseFloat(result?.price ?? result ?? '0'); + if (price > 0 && price < 1) return price; + } catch (err) { + logger.warn(`MakerMM: getPrice error — ${err.message}`); + } + try { + const mp = await client.getMidpoint(tokenId); + const price = parseFloat(mp?.mid ?? mp ?? '0'); + if (price > 0 && price < 1) return price; + } catch {} + return null; +} + +function roundToTick(price, tickSize) { + const ts = parseFloat(tickSize); + const rounded = Math.round(price / ts) * ts; + const decimals = tickSize.toString().split('.')[1]?.length || 2; + return Math.max(0.01, Math.min(0.99, parseFloat(rounded.toFixed(decimals)))); +} + +// ── Get best ask via getPrice(SELL) — the lowest price a seller will accept ──── +// Used as a safety cap to ensure our bid never crosses the ask (taker prevention). +async function getBestAsk(tokenId) { + const client = getClient(); + try { + const result = await client.getPrice(tokenId, 'SELL'); + const price = parseFloat(result?.price ?? result ?? '0'); + return (price > 0 && price < 1) ? price : null; + } catch (err) { + logger.warn(`MakerMM: getBestAsk error — ${err.message}`); + return null; + } +} + +// ── Bid-based repricing ─────────────────────────────────────────────────────── +// Targets top of bid orderbook: newBid = bestBid + 1 tick (become new top bid). +// Safety cap: newBid < bestAsk (guaranteed maker by construction). +// Only reprices when bid drifts > config.makerMmRepriceThreshold (default 2c). +// If one side is already filled, the other side is capped so combined never exceeds target. +async function checkAndReprice(pos, tag) { + const threshold = config.makerMmRepriceThreshold; + const ts = parseFloat(pos.tickSize); + const MIN_PRICE = getMinPrice(); + const MAX_PRICE = getMaxPrice(); + const oneSideFilled = pos.yes.filled !== pos.no.filled; + const timeSinceFirstFill = pos.firstFillTime ? Date.now() - pos.firstFillTime : 0; + if (oneSideFilled) { + logger.info(`MakerMM${tag}: ${pos.yes.filled ? 'YES' : 'NO'} filled — holding bid, waiting for reversion (${Math.round(timeSinceFirstFill / 1000)}s)`); + return; // Never reprice when one side is already filled — prevent double exposure + } + + const repriceSide = async (side) => { + const s = pos[side]; + if (s.filled) return; + + // Bid-based: target = bestBid + 1 tick (top of bid orderbook) + // Fetch both bid and ask in parallel — ask used as safety cap only + const [bestBid, bestAsk] = await Promise.all([ + getRealPrice(s.tokenId), + getBestAsk(s.tokenId), + ]); + if (!bestBid) return; // no bid data — skip + + let newBid = roundToTick(bestBid + ts, pos.tickSize); + + // Safety: never cross the ask (guaranteed maker) + if (bestAsk && newBid >= bestAsk) { + newBid = roundToTick(bestAsk - ts, pos.tickSize); + } + + // Drift check: compare current bestBid vs entryBid (set once on first reprice) + // entryBid = buyPrice - ts (since buyPrice = bestBid + ts at entry) + const entryBid = s.entryBid ?? (s.buyPrice - ts); + const bidDrift = Math.abs(bestBid - entryBid); + if (bidDrift <= threshold) return; + + // Rebate range cap + newBid = Math.min(newBid, MAX_PRICE); + + // Combined cap always enforced — never allow combined to exceed maxCombined + const otherBid = side === 'yes' ? pos.no.buyPrice : pos.yes.buyPrice; + const maxBid = roundToTick(config.makerMmMaxCombined - otherBid, pos.tickSize); + newBid = Math.min(newBid, maxBid); + + if (newBid < MIN_PRICE) { + logger.info(`MakerMM${tag}: ${side.toUpperCase()} new bid $${newBid} < MIN_PRICE — skip reprice`); + return; + } + if (Math.abs(newBid - s.buyPrice) < ts) return; // no meaningful change after caps + + logger.info( + `MakerMM${tag}: repricing ${side.toUpperCase()} $${s.buyPrice} → $${newBid} ` + + `(bid drift ${(bidDrift * 100).toFixed(0)}c > ${(threshold * 100).toFixed(0)}c threshold)` + ); + + // Re-check filled status — WS fill may have arrived during the async API calls above + if (s.filled) { + logger.money(`MakerMM${tag}: ${side.toUpperCase()} filled during reprice check — skipping cancel`); + if (!pos.firstFillTime) pos.firstFillTime = Date.now(); + return; + } + + const oldOrderId = s.orderId; + const cancelled = await cancelOrder(oldOrderId); + + // Wait 1500ms — matching engine can take up to 5s to settle a fill after + // the API acknowledges a cancel. 300ms is too short to catch most races. + await sleep(1500); + const oldStatus = await checkOrderStatus(oldOrderId); + if (oldStatus === 'filled' || oldStatus === 'partial') { + logger.money(`MakerMM${tag}: ${side.toUpperCase()} filled during reprice cancel (status: ${oldStatus}) — skipping new order`); + s.filled = true; + if (!pos.firstFillTime) pos.firstFillTime = Date.now(); + return; + } + if (!cancelled) { + logger.warn(`MakerMM${tag}: reprice ${side.toUpperCase()} — cancel failed (status: ${oldStatus}), skipping to avoid duplicate`); + return; + } + + // Final WS-fill check before placing new order + if (s.filled) { + logger.money(`MakerMM${tag}: ${side.toUpperCase()} filled while verifying cancel — skipping new order`); + if (!pos.firstFillTime) pos.firstFillTime = Date.now(); + return; + } + + const orderShares = pos.targetShares; + + const result = await placeLimitBuy(s.tokenId, orderShares, newBid, pos.tickSize, pos.negRisk); + if (result.success) { + // One last check: if old order filled while we were placing the new one, cancel it immediately + if (s.filled) { + logger.warn(`MakerMM${tag}: ${side.toUpperCase()} old order filled while placing new — cancelling new order to prevent double fill`); + await cancelOrder(result.orderId); + return; + } + const newOrderId = result.orderId; + s.orderId = newOrderId; + s.buyPrice = newBid; + s.cost = orderShares * newBid; + s.orderShares = orderShares; // may differ from targetShares when loss-compensating + // Track entryBid once — never update so drift tracks from original entry + if (!s.entryBid) s.entryBid = entryBid; + + // Background: matching engine may still fill the old order up to ~6s post-cancel. + // If that happens, cancel the new order immediately to prevent double-fill. + setTimeout(async () => { + try { + if (s.orderId !== newOrderId) return; // already repriced again — skip + const delayedStatus = await checkOrderStatus(oldOrderId); + if (delayedStatus === 'filled' || delayedStatus === 'partial') { + logger.warn( + `MakerMM${tag}: delayed fill on cancelled ${side.toUpperCase()} order — ` + + `cancelling new order ${newOrderId.slice(-8)} to prevent double-fill` + ); + if (!s.filled) { + s.filled = true; + if (!pos.firstFillTime) pos.firstFillTime = Date.now(); + } + await cancelOrder(newOrderId); + } + } catch {} + }, 5000); + } else { + logger.warn(`MakerMM${tag}: reprice ${side.toUpperCase()} failed — order not replaced`); + } + }; + + // Sequential: recheck filled status before each side in case WS fill arrived mid-reprice + await repriceSide('yes'); + if (!pos.no.filled) await repriceSide('no'); +} + +// ── Get current market odds ────────────────────────────────────────────────── +async function getMarketOdds(yesTokenId, noTokenId) { + try { + const [yesPrice, noPrice] = await Promise.all([ + getRealPrice(yesTokenId), + getRealPrice(noTokenId), + ]); + + if (yesPrice && noPrice) { + return { yes: yesPrice, no: noPrice, max: Math.max(yesPrice, noPrice) }; + } + } catch (err) { + logger.warn(`MakerMM: getMarketOdds error — ${err.message}`); + } + return null; +} + +// ── Order helpers ──────────────────────────────────────────────────────────── + +/** + * Check order status via CLOB API + * Returns true if order is filled (even if createAndPostOrder returned false) + */ +async function checkOrderStatus(orderId) { + if (!orderId || orderId.startsWith('filled-') || orderId.startsWith('sim-')) return null; + + try { + const client = getClient(); + const order = await client.getOrder(orderId); + + // Order might be: OPEN, FILLED, PARTIAL_FILLED, CANCELLED, etc. + if (order?.status === 'FILLED' || order?.status === 'FILLED_FULLY') { + return 'filled'; + } + if (order?.status === 'PARTIAL_FILLED' || order?.status === 'FILLED_PARTIALLY') { + return 'partial'; + } + if (order?.status === 'CANCELLED' || order?.status === 'CANCELLED_BY_USER' || order?.status === 'EXPIRED') { + return 'cancelled'; + } + if (order?.status === 'OPEN') { + return 'open'; + } + } catch (err) { + // Order not found or API error - consider as unknown + logger.debug(`MakerMM: order status check failed for ${orderId?.slice(-8)} — ${err.message}`); + } + return 'unknown'; +} + +async function placeLimitBuy(tokenId, shares, price, tickSize, negRisk) { + if (config.dryRun) { + return { success: true, orderId: `sim-buy-${Date.now()}-${tokenId.slice(-6)}` }; + } + const client = getClient(); + try { + const res = await client.createAndPostOrder( + { tokenID: tokenId, side: Side.BUY, price, size: shares }, + { tickSize, negRisk }, + OrderType.GTC, + ); + if (!res?.success) { + logger.error(`MakerMM: limit buy failed — response: ${JSON.stringify(res)}`); + return { success: false }; + } + return { success: true, orderId: res.orderID }; + } catch (err) { + logger.error(`MakerMM: limit buy error — ${err.message}`); + return { success: false }; + } +} + +async function cancelOrder(orderId) { + if (config.dryRun || !orderId || orderId.startsWith('sim-')) return true; + try { + const client = getClient(); + await client.cancelOrder({ orderID: orderId }); + return true; + } catch (err) { + logger.warn(`MakerMM: cancel error — ${err.message}`); + return false; + } +} + +// ── Fill detection ─────────────────────────────────────────────────────────── + +async function getTokenBalance(tokenId) { + try { + const provider = getPolygonProvider(); // singleton — no await needed + const ctf = new ethers.Contract(CTF_ADDRESS, CTF_BALANCE_ABI, provider); + const raw = await ctf.balanceOf(config.proxyWallet, tokenId); + return parseFloat(ethers.utils.formatUnits(raw, 6)); + } catch { return null; } +} + +function waitForFillOrTimeout(tokenIds, timeoutMs) { + return new Promise((resolve) => { + let timer; + const onFill = (event) => { + if (tokenIds.includes(event.tokenId)) { + clearTimeout(timer); + mmFillWatcher.removeListener('fill', onFill); + resolve(event); + } + }; + mmFillWatcher.on('fill', onFill); + timer = setTimeout(() => { + mmFillWatcher.removeListener('fill', onFill); + resolve(null); + }, timeoutMs); + }); +} + +// ── Core monitoring ─────────────────────────────────────────────────────────── + +async function monitorUntilFilled(pos, tag, label) { + mmFillWatcher.watch(pos.yes.tokenId); + mmFillWatcher.watch(pos.no.tokenId); + + // WS fill events: early signal only — onchain balance is the source of truth. + // Side filter removed: RTDS may report side from taker perspective (SELL), + // not our maker perspective. We're already gated by proxyWallet + tokenId. + const onWsFill = (event) => { + if (event.tokenId === pos.yes.tokenId) { + pos.yes.filled = true; + logger.money(`MakerMM${tag}: YES fill signal (WS) ${event.size?.toFixed(2) || '?'} @ $${event.price?.toFixed(3) || pos.yes.buyPrice.toFixed(3)}`); + } + if (event.tokenId === pos.no.tokenId) { + pos.no.filled = true; + logger.money(`MakerMM${tag}: NO fill signal (WS) ${event.size?.toFixed(2) || '?'} @ $${event.price?.toFixed(3) || pos.no.buyPrice.toFixed(3)}`); + } + }; + mmFillWatcher.on('fill', onWsFill); + + // Brief pause to let WebSocket register token subscriptions + await sleep(50); + + try { + let fastFillCheckCount = 0; + const maxFastChecks = 10; // 1s polling for first 10s + let lastRepriceCheck = 0; // track last reprice attempt time + + while (true) { + // Safety guard: exit immediately if resolved by any path + if (pos.status === 'done') return; + + // ── Onchain balance — source of truth, checked FIRST ────────────── + const [yesBal, noBal] = await Promise.all([ + getTokenBalance(pos.yes.tokenId), + getTokenBalance(pos.no.tokenId), + ]); + + // NET new shares only — subtract baseline to exclude leftover tokens + // from previous cycles on the same tokenId. Without this, re-entry + // would see old balance >= 0.5x target and trigger a false early merge + // while the new orders are still open in the orderbook. + // Use toFixed(6) — full precision to avoid rounding UP past actual token balance. + // toFixed(4) could round 4.910199 → 4.9102 (4910200 wei) when Safe has 4910199 → revert. + const yesShares = parseFloat(Math.max(0, (yesBal || 0) - pos.yes.baseline).toFixed(6)); + const noShares = parseFloat(Math.max(0, (noBal || 0) - pos.no.baseline).toFixed(6)); + + // Sync fill flags from onchain (overrides any stale WS flag). + // Use s.orderShares if set (loss-compensating reprice may order > targetShares), + // so we wait for the actual order size to fill, not just targetShares. + const yesOrderShares = pos.yes.orderShares ?? pos.targetShares; + const noOrderShares = pos.no.orderShares ?? pos.targetShares; + if (!pos.yes.filled && yesShares >= yesOrderShares * 0.99) { + pos.yes.filled = true; + logger.money(`MakerMM${tag}: YES filled (onchain) ${yesShares.toFixed(4)} shares`); + } + if (!pos.no.filled && noShares >= noOrderShares * 0.99) { + pos.no.filled = true; + logger.money(`MakerMM${tag}: NO filled (onchain) ${noShares.toFixed(4)} shares`); + } + + // ── Over-position safety net ──────────────────────────────────────── + // If one side's balance is > 1.5x target AND the current order is still open, + // a double-fill occurred (old cancelled order + new order both filled). + // Cancel the open order immediately so it doesn't also fill. + if (yesShares > pos.targetShares * 1.5 && pos.yes.orderId && !pos.yes.filled) { + logger.warn(`MakerMM${tag}: YES over-position (${yesShares.toFixed(4)} > 1.5x target=${pos.targetShares}) — cancelling open order to stop double-fill`); + await cancelOrder(pos.yes.orderId); + pos.yes.filled = true; + if (!pos.firstFillTime) pos.firstFillTime = Date.now(); + } + if (noShares > pos.targetShares * 1.5 && pos.no.orderId && !pos.no.filled) { + logger.warn(`MakerMM${tag}: NO over-position (${noShares.toFixed(4)} > 1.5x target=${pos.targetShares}) — cancelling open order to stop double-fill`); + await cancelOrder(pos.no.orderId); + pos.no.filled = true; + if (!pos.firstFillTime) pos.firstFillTime = Date.now(); + } + + // ── WS fallback: both sides WS-confirmed filled but onchain RPC not reflecting ── + // If onchain balance is unavailable (RPC slow/failed) but both filled flags are + // set from WS signals, wait a grace period then merge with targetShares as fallback. + if (pos.yes.filled && pos.no.filled && yesShares < pos.targetShares * 0.5 && noShares < pos.targetShares * 0.5) { + if (!pos.bothFilledSince) pos.bothFilledSince = Date.now(); + const waitedSec = Math.round((Date.now() - pos.bothFilledSince) / 1000); + if (waitedSec >= 15) { + logger.warn( + `MakerMM${tag}: both sides WS-filled but onchain shows YES=${yesShares} NO=${noShares} after ${waitedSec}s ` + + `— RPC may be stale, merging with target ${pos.targetShares} shares` + ); + await executeMerge(pos, pos.targetShares, tag); + if (pos.status === 'done') return; + } else { + logger.info(`MakerMM${tag}: both WS-filled, waiting for onchain confirmation (${waitedSec}s / 15s grace)...`); + } + } + + // Both sides have net balance ≥ 50% target → merge + if (yesShares >= pos.targetShares * 0.5 && noShares >= pos.targetShares * 0.5) { + pos.bothFilledSince = null; // onchain confirmed — clear WS fallback timer + const minShares = Math.min(yesShares, noShares); + const isFull = yesShares >= pos.targetShares * 0.99 && noShares >= pos.targetShares * 0.99; + logger.success( + `MakerMM${tag}: ${isFull ? 'FULL' : 'PARTIAL'} fill — ` + + `YES=${yesShares.toFixed(4)} NO=${noShares.toFixed(4)}, merging ${minShares.toFixed(4)} shares` + ); + pos.yes.filled = true; + pos.no.filled = true; + await executeMerge(pos, minShares, tag); + if (pos.status === 'done') return; + + // Merge call errored — but tx may have confirmed onchain despite the RPC error + // (common: tx.wait() timeout while tx was already included in a block). + // Re-check balance to avoid looping forever on an empty position. + const [yesRecheck, noRecheck] = await Promise.all([ + getTokenBalance(pos.yes.tokenId), + getTokenBalance(pos.no.tokenId), + ]); + const yesNetRecheck = Math.max(0, (yesRecheck || 0) - pos.yes.baseline); + const noNetRecheck = Math.max(0, (noRecheck || 0) - pos.no.baseline); + if (yesNetRecheck < pos.targetShares * 0.1 && noNetRecheck < pos.targetShares * 0.1) { + logger.success(`MakerMM${tag}: merge confirmed onchain (RPC reported error but tx went through)`); + pos.status = 'done'; + pos.totalProfit = minShares - (pos.yes.cost + pos.no.cost); + return; + } + pos.mergeFailCount = (pos.mergeFailCount || 0) + 1; + const backoffSec = Math.min(5 * pos.mergeFailCount, 30); // 5s, 10s, 15s … max 30s + logger.warn(`MakerMM${tag}: merge failed (attempt ${pos.mergeFailCount}) — tokens still present (YES=${yesNetRecheck.toFixed(6)} NO=${noNetRecheck.toFixed(6)}), retrying in ${backoffSec}s`); + await sleep(backoffSec * 1000); + } + + // ── Cut-loss check (AFTER balance check) ────────────────────────── + const msRemaining = new Date(pos.endTime).getTime() - Date.now(); + if (msRemaining <= config.makerMmCutLossTime * 1000) { + logger.warn(`MakerMM${tag}: cut-loss — net YES=${yesShares.toFixed(4)} NO=${noShares.toFixed(4)}`); + + if (yesShares >= 1 && noShares >= 1) { + // Both sides have net fills — emergency merge to recover USDC + const minShares = Math.min(yesShares, noShares); + logger.warn(`MakerMM${tag}: emergency merge ${minShares.toFixed(4)} shares`); + await executeMerge(pos, minShares, tag); + } else { + // One or neither side net-filled — cancel open orders, log held tokens + await Promise.all([ + cancelOrder(pos.yes.orderId), + cancelOrder(pos.no.orderId), + ]); + if (yesShares > 0 || noShares > 0) { + logger.warn(`MakerMM${tag}: tokens held — net YES=${yesShares.toFixed(4)} NO=${noShares.toFixed(4)} (cannot merge)`); + pos.totalProfit = -((yesShares > 0 ? pos.yes.cost : 0) + (noShares > 0 ? pos.no.cost : 0)); + pos.oneSided = true; // flag: cycle ended with one-sided fill + } else { + logger.info(`MakerMM${tag}: no net fills — orders cancelled, zero loss`); + pos.totalProfit = 0; + } + pos.status = 'done'; + } + return; + } + + // ── One side filled — log status and keep waiting ───────────────── + if (pos.yes.filled !== pos.no.filled) { + const filledKey = pos.yes.filled ? 'yes' : 'no'; + const now = Date.now(); + + if (now < pos.marketOpenTime) { + logger.info(`MakerMM${tag}: ${filledKey.toUpperCase()} filled — market not open yet (${Math.round((pos.marketOpenTime - now) / 1000)}s), waiting...`); + } else { + if (!pos.firstFillTime) { + pos.firstFillTime = now; + logger.info(`MakerMM${tag}: ${filledKey.toUpperCase()} filled first — waiting for other side...`); + } else { + const elapsedMin = Math.floor((now - pos.firstFillTime) / 60000); + if (elapsedMin > 0 && elapsedMin % 5 === 0 && pos.lastLogMin !== elapsedMin) { + pos.lastLogMin = elapsedMin; + logger.info(`MakerMM${tag}: still waiting for ${filledKey === 'yes' ? 'NO' : 'YES'} — ${elapsedMin}m elapsed`); + } + } + } + } + + // ── Threshold repricing ─────────────────────────────────────────── + // Reprice unfilled side(s) only when price has drifted > threshold. + const repriceNow = Date.now(); + if ( + !pos.yes.filled || !pos.no.filled + ) { + if (repriceNow - lastRepriceCheck >= config.makerMmRepriceSec * 1000) { + lastRepriceCheck = repriceNow; + await checkAndReprice(pos, tag); + } + } + + // Fast polling first 10s, then event-driven with 5s fallback + fastFillCheckCount++; + if (fastFillCheckCount < maxFastChecks) { + await sleep(1000); + } else { + await waitForFillOrTimeout([pos.yes.tokenId, pos.no.tokenId], 5000); + } + } + } finally { + mmFillWatcher.removeListener('fill', onWsFill); + mmFillWatcher.unwatch(pos.yes.tokenId); + mmFillWatcher.unwatch(pos.no.tokenId); + + // Cancel any residual open orders — can happen when loss-compensating reprice + // placed extra shares (e.g. 6 NO) but merge triggered after 5 filled, + // leaving 1 remaining NO share still open in the orderbook. + await Promise.all([ + cancelOrder(pos.yes.orderId), + cancelOrder(pos.no.orderId), + ]).catch(() => {}); + } +} + +async function executeMerge(pos, shares, tag) { + const totalCost = pos.yes.cost + pos.no.cost; + const recovered = shares; // Merge returns $1 per share + pos.totalProfit = recovered - totalCost; + + try { + await mergePositions(pos.conditionId, shares, pos.negRisk); + + // Orders are already fully filled at this point — no cancel needed + logger.money(`MakerMM${tag}: MERGED ${shares.toFixed(4)} shares → $${recovered.toFixed(2)} | cost $${totalCost.toFixed(2)} | P&L $${pos.totalProfit.toFixed(2)}`); + pos.status = 'done'; + } catch (err) { + logger.error(`MakerMM${tag}: merge failed — ${err.message}`); + // Don't change status — let monitor loop continue + } +} + +// ── Main entry ─────────────────────────────────────────────────────────────── + +export async function executeMakerRebateStrategy(market) { + const { asset, conditionId, question, endTime, eventStartTime, yesTokenId, noTokenId, negRisk, tickSize } = market; + const tag = asset ? `[${asset.toUpperCase()}]` : ''; + const label = question.substring(0, 40); + const sim = config.dryRun ? '[SIM] ' : ''; + + // Market officially opens at eventStartTime (not when we detect it) + const marketOpenTime = eventStartTime ? new Date(eventStartTime).getTime() : Date.now(); + + // Wait until 10 seconds after market open before placing any orders. + // Orders placed too early (pre-open or first few seconds) tend to open at a loss + // due to wide spreads and erratic pricing before liquidity stabilizes. + const ENTRY_DELAY_MS = 10_000; + const entryNotBefore = marketOpenTime + ENTRY_DELAY_MS; + const waitMs = entryNotBefore - Date.now(); + if (waitMs > 0) { + logger.info(`MakerMM${tag}: ${sim}waiting ${Math.round(waitMs / 1000)}s for market to stabilize (open +10s)...`); + await sleep(waitMs); + } + + logger.info(`MakerMM${tag}: ${sim}entering — ${label}`); + + // ── Wait for real YES price ───────────────────────────────── + const POLL_SEC = 3; + const ts = parseFloat(tickSize); + + let yesBid, noBid, combined; + let yesEntryBid, noEntryBid; // best bid at time of entry — stored for drift tracking + const waitStart = Date.now(); + const MIN_PRICE = getMinPrice(); + const MAX_PRICE = getMaxPrice(); + + while (true) { + const msRemaining = new Date(endTime).getTime() - Date.now(); + if (msRemaining <= config.makerMmCutLossTime * 1000) { + logger.warn(`MakerMM${tag}: market closing — aborting`); + return; + } + + // ── Bid-based pricing: bid = bestBid + 1_tick (top of orderbook, guaranteed maker) ── + // We become the new top bid, getting fill priority over existing bids. + // Safety cap: newBid < bestAsk ensures we never accidentally cross and become a taker. + const [yesBestBid, yesAsk, noBestBid, noAsk] = await Promise.all([ + getRealPrice(yesTokenId), + getBestAsk(yesTokenId), + getRealPrice(noTokenId), + getBestAsk(noTokenId), + ]); + + if (!yesBestBid || !noBestBid) { + logger.info(`MakerMM${tag}: waiting — no bid data (YES: ${yesBestBid ?? 'null'}, NO: ${noBestBid ?? 'null'})`); + await sleep(POLL_SEC * 1000); + continue; + } + + // Target = bestBid + 1 tick; safety cap below ask + yesBid = roundToTick(yesBestBid + ts, tickSize); + if (yesAsk && yesBid >= yesAsk) yesBid = roundToTick(yesAsk - ts, tickSize); + + // Range check on YES bid + if (yesBid < MIN_PRICE || yesBid > MAX_PRICE) { + logger.info(`MakerMM${tag}: waiting — YES bid $${yesBid.toFixed(3)} (need ${MIN_PRICE}-${MAX_PRICE})`); + await sleep(POLL_SEC * 1000); + continue; + } + + // NO bid: fill remaining combined budget + noBid = roundToTick(config.makerMmMaxCombined - yesBid, tickSize); + // Safety: ensure NO is also strictly below NO ask (maker) + if (noAsk && noBid >= noAsk) noBid = roundToTick(noAsk - ts, tickSize); + + // Range check on NO bid + if (noBid < MIN_PRICE || noBid > MAX_PRICE) { + logger.info(`MakerMM${tag}: waiting — NO bid $${noBid.toFixed(3)} (need ${MIN_PRICE}-${MAX_PRICE})`); + await sleep(POLL_SEC * 1000); + continue; + } + + combined = yesBid + noBid; + + if (combined > config.makerMmMaxCombined) { + logger.info(`MakerMM${tag}: combined $${combined.toFixed(4)} > max — waiting`); + await sleep(POLL_SEC * 1000); + continue; + } + + // If combined is more than 1 tick below target the market spread is too tight. + // Wait for better conditions instead of entering with lower-than-expected profit. + const minCombined = parseFloat((config.makerMmMaxCombined - ts).toFixed(4)); + if (combined < minCombined) { + logger.info(`MakerMM${tag}: spread too tight — combined $${combined.toFixed(4)} < target $${config.makerMmMaxCombined} — waiting`); + await sleep(POLL_SEC * 1000); + continue; + } + + yesEntryBid = yesBestBid; + noEntryBid = noBestBid; + + const waitSec = ((Date.now() - waitStart) / 1000).toFixed(1); + logger.success(`MakerMM${tag}: ready after ${waitSec}s — YES $${yesBid} + NO $${noBid} = $${combined.toFixed(4)} (topBid YES:$${yesBestBid} NO:$${noBestBid})`); + break; + } + + // ── Calculate shares ────────────────────────────────────────── + const targetShares = config.makerMmTradeSize; + + if (targetShares < CLOB_MIN_ORDER_SHARES) { + logger.warn(`MakerMM${tag}: shares ${targetShares} < min ${CLOB_MIN_ORDER_SHARES} — skipping`); + return; + } + + const yesCost = targetShares * yesBid; + const noCost = targetShares * noBid; + const totalCost = yesCost + noCost; + + if (!config.dryRun) { + const balance = await getUsdcBalance(); + if (balance < totalCost) { + logger.error(`MakerMM${tag}: insufficient balance $${balance.toFixed(2)} (need $${totalCost.toFixed(2)})`); + return; + } + } + + // ── Snapshot balance BEFORE placing orders ──────────────────────────────── + // Critical for re-entry: same tokenIds are reused each cycle, so leftover + // tokens from a previous cycle would otherwise fool the fill-detection logic + // into thinking the new orders filled instantly, causing a new cycle to start + // while the actual new orders remain open in the orderbook. + const [yesBaseline, noBaseline] = await Promise.all([ + getTokenBalance(yesTokenId), + getTokenBalance(noTokenId), + ]); + if ((yesBaseline || 0) > 0 || (noBaseline || 0) > 0) { + logger.info(`MakerMM${tag}: pre-order baseline — YES=${(yesBaseline || 0).toFixed(4)} NO=${(noBaseline || 0).toFixed(4)} (leftover from prior cycle)`); + } + + // ── Place orders ONCE (NO repricing) ────────────────────── + logger.trade(`MakerMM${tag}: placing BUY — YES $${yesBid} × ${targetShares} + NO $${noBid} × ${targetShares} = $${totalCost.toFixed(2)}`); + + const [yesBuy, noBuy] = await Promise.all([ + placeLimitBuy(yesTokenId, targetShares, yesBid, tickSize, negRisk), + placeLimitBuy(noTokenId, targetShares, noBid, tickSize, negRisk), + ]); + + logger.info(`MakerMM${tag}: order results — YES: ${yesBuy.success ? 'OK' : 'FAIL'} (id=${yesBuy.orderId?.slice(-8) || 'none'}), NO: ${noBuy.success ? 'OK' : 'FAIL'} (id=${noBuy.orderId?.slice(-8) || 'none'})`); + + // If one side failed, check if actually filled on-chain OR via order book before retrying + let finalYesBuy = yesBuy; + let finalNoBuy = noBuy; + const maxRetries = 3; + + for (let attempt = 1; attempt <= maxRetries && (!finalYesBuy.success || !finalNoBuy.success); attempt++) { + // Check 1: On-chain balance (most reliable) — compare against baseline + const [yesBalance, noBalance] = await Promise.all([ + getTokenBalance(yesTokenId), + getTokenBalance(noTokenId), + ]); + const yesNet = (yesBalance || 0) - (yesBaseline || 0); + const noNet = (noBalance || 0) - (noBaseline || 0); + + // Check 2: Order status via CLOB API (backup check) + const [yesOrderStatus, noOrderStatus] = await Promise.all([ + finalYesBuy.success ? null : checkOrderStatus(yesBuy.orderId), + finalNoBuy.success ? null : checkOrderStatus(noBuy.orderId), + ]); + + if (yesOrderStatus || noOrderStatus) { + logger.info(`MakerMM${tag}: order status check — YES: ${yesOrderStatus || 'N/A'}, NO: ${noOrderStatus || 'N/A'}`); + } + + // Use net (new) balance to determine if actually filled — not total balance + if (!finalYesBuy.success && ( + yesNet >= targetShares * 0.5 || + yesOrderStatus === 'filled' || + yesOrderStatus === 'partial' + )) { + logger.success(`MakerMM${tag}: YES already filled (net: ${yesNet.toFixed(4)}, order: ${yesOrderStatus}) — no retry`); + finalYesBuy = { success: true, orderId: yesBuy.orderId || `filled-${Date.now()}` }; + } + + if (!finalNoBuy.success && ( + noNet >= targetShares * 0.5 || + noOrderStatus === 'filled' || + noOrderStatus === 'partial' + )) { + logger.success(`MakerMM${tag}: NO already filled (net: ${noNet.toFixed(4)}, order: ${noOrderStatus}) — no retry`); + finalNoBuy = { success: true, orderId: noBuy.orderId || `filled-${Date.now()}` }; + } + + if (finalYesBuy.success && finalNoBuy.success) break; + + // Cancel existing order before retry to avoid duplicate orders + if (!finalYesBuy.success) { + logger.warn(`MakerMM${tag}: retrying YES order (attempt ${attempt}/${maxRetries})...`); + await cancelOrder(yesBuy.orderId); + await sleep(500); + finalYesBuy = await placeLimitBuy(yesTokenId, targetShares, yesBid, tickSize, negRisk); + if (finalYesBuy.success) { + logger.success(`MakerMM${tag}: YES order succeeded on retry ${attempt}`); + } + } + if (!finalNoBuy.success) { + logger.warn(`MakerMM${tag}: retrying NO order (attempt ${attempt}/${maxRetries})...`); + await cancelOrder(noBuy.orderId); + await sleep(500); + finalNoBuy = await placeLimitBuy(noTokenId, targetShares, noBid, tickSize, negRisk); + if (finalNoBuy.success) { + logger.success(`MakerMM${tag}: NO order succeeded on retry ${attempt}`); + } + } + } + + if (!finalYesBuy.success || !finalNoBuy.success) { + logger.error(`MakerMM${tag}: order failed after retries — YES: ${finalYesBuy.success}, NO: ${finalNoBuy.success}`); + await Promise.all([ + finalYesBuy.success ? cancelOrder(finalYesBuy.orderId) : null, + finalNoBuy.success ? cancelOrder(finalNoBuy.orderId) : null, + ]); + return; + } + + // ── Build position and wait ───────────────────────────────── + const pos = { + asset: asset || 'btc', + conditionId, + question, + endTime, + marketOpenTime, + tickSize, + negRisk, + status: 'monitoring', + targetShares, + yes: { + tokenId: yesTokenId, + buyPrice: yesBid, + cost: yesCost, + orderId: finalYesBuy.orderId, + filled: false, + baseline: yesBaseline || 0, // pre-order balance — subtract to get net new fills + entryBid: yesEntryBid, // bestBid at entry — for bid drift tracking + }, + no: { + tokenId: noTokenId, + buyPrice: noBid, + cost: noCost, + orderId: finalNoBuy.orderId, + filled: false, + baseline: noBaseline || 0, // pre-order balance — subtract to get net new fills + entryBid: noEntryBid, // bestBid at entry — for bid drift tracking + }, + totalProfit: 0, + }; + + activePositions.set(conditionId, pos); + await monitorUntilFilled(pos, tag, label); + activePositions.delete(conditionId); + + const sign = pos.totalProfit >= 0 ? '+' : ''; + logger.info(`MakerMM${tag}: done | P&L: ${sign}$${pos.totalProfit.toFixed(2)}`); + + return { oneSided: pos.oneSided ?? false }; +} diff --git a/src/services/mmDetector.js b/src/services/mmDetector.js index 3757b75..ccdce65 100644 --- a/src/services/mmDetector.js +++ b/src/services/mmDetector.js @@ -7,28 +7,31 @@ * e.g. btc-updown-5m-1771755000 * eth-updown-15m-1771754100 * - * NEVER enters the currently active market — always targets the NEXT upcoming slot. + * poll() targets the NEXT upcoming slot. checkCurrentMarket() enters the current slot on startup. */ import config from '../config/index.js'; import logger from '../utils/logger.js'; import { proxyFetch } from '../utils/proxy.js'; -// Slot size in seconds (300 for 5m, 900 for 15m) -const SLOT_SEC = config.mmDuration === '15m' ? 900 : 300; - let pollTimer = null; let onMarketCb = null; const seenKeys = new Set(); // `${asset}-${slotTimestamp}` already scheduled // ── Slot helpers ────────────────────────────────────────────────────────────── +// Computed dynamically so config.mmDuration overrides in maker-mm.js take effect. + +function slotSec() { + return config.mmDuration === '15m' ? 900 : 300; +} function currentSlot() { - return Math.floor(Date.now() / 1000 / SLOT_SEC) * SLOT_SEC; + const s = slotSec(); + return Math.floor(Date.now() / 1000 / s) * s; } function nextSlot() { - return currentSlot() + SLOT_SEC; + return currentSlot() + slotSec(); } // ── Gamma API fetch ─────────────────────────────────────────────────────────── @@ -150,3 +153,48 @@ export function stopMMDetector() { pollTimer = null; } } + +// ── Check current active market on startup ──────────────────────────────────── +// Enters the currently running market slot if enough time remains. +// Enabled unconditionally for the maker rebate bot — call only from maker-mm.js. +export async function checkCurrentMarket(onMarketFound) { + const current = currentSlot(); + const cutLossSec = config.makerMmCutLossTime ?? 60; + const tag = '[CURRENT]'; + + logger.info(`MM${tag}: checking current slot ${current} (${config.mmDuration}) for assets: ${config.mmAssets.join(', ').toUpperCase()}`); + + for (const asset of config.mmAssets) { + const key = `${asset}-${current}`; + if (seenKeys.has(key)) { + logger.info(`MM${tag}: ${asset.toUpperCase()} already seen — skip`); + continue; + } + + const market = await fetchBySlug(asset, current); + if (!market) { + logger.warn(`MM${tag}: ${asset.toUpperCase()} — no market found for slot ${current} (slug: ${asset}-updown-${config.mmDuration}-${current})`); + continue; + } + + const data = extractMarketData(market, asset); + if (!data) { + logger.warn(`MM${tag}: ${asset.toUpperCase()} — market found but missing token IDs, skipping`); + seenKeys.add(key); + continue; + } + + const msRemaining = new Date(data.endTime).getTime() - Date.now(); + const secsRemaining = Math.round(msRemaining / 1000); + + if (isNaN(secsRemaining) || secsRemaining <= cutLossSec) { + logger.info(`MM${tag}: ${asset.toUpperCase()} current market ${secsRemaining}s left (≤ cutLoss ${cutLossSec}s) — skipping`); + seenKeys.add(key); + continue; + } + + seenKeys.add(key); + logger.success(`MM${tag}: ${asset.toUpperCase()} entering current market "${data.question.slice(0, 40)}" (${secsRemaining}s left)`); + onMarketFound(data); + } +}