Merge feat/maker-rebate-mm into main

Adds high-frequency maker rebate market-making strategy:
- Simultaneous YES+NO maker limit orders on 15m binary markets
- CTF merge for guaranteed spread capture (market-neutral)
- WebSocket fill detection, combined cap enforcement, one-sided stop
This commit is contained in:
direkturcrypto
2026-03-30 21:01:19 +07:00
11 changed files with 1773 additions and 454 deletions
+131 -120
View File
@@ -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 PRIVATE_KEY=0xYOUR_EOA_PRIVATE_KEY_HERE
# Polymarket Proxy Wallet — the address shown when you click "Deposit" on Polymarket # 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 # This is where you deposit USDC.e and where all trades are funded from
# How to find: Login to polymarket.com → Profile → Deposit → copy the address # How to find: polymarket.com → Profile → Deposit → copy the address
PROXY_WALLET_ADDRESS=0xYOUR_PROXY_WALLET_ADDRESS_HERE PROXY_WALLET_ADDRESS=0xYOUR_PROXY_WALLET_ADDRESS_HERE
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# POLYGON RPC # POLYGON RPC
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# Public RPC (default, no key required)
POLYGON_RPC_URL=https://polygon.lava.build 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) # 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_KEY=
CLOB_API_SECRET= CLOB_API_SECRET=
CLOB_API_PASSPHRASE= CLOB_API_PASSPHRASE=
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# TRADER TO COPY # PROXY (optional — Polymarket API only, NOT Polygon RPC)
# Use the proxy wallet address of the trader (visible on their Polymarket profile) # 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: # SIZE_MODE:
# "percentage" = SIZE_PERCENT% of MAX_POSITION_SIZE per market entry # "percentage" = SIZE_PERCENT% of MAX_POSITION_SIZE per entry
# (e.g. MAX_POSITION_SIZE=$10, SIZE_PERCENT=50 → buy $5 per entry)
# "balance" = SIZE_PERCENT% of your current USDC.e balance 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_MODE=balance
SIZE_PERCENT=10 SIZE_PERCENT=10
# Minimum trade size in USDC (skip if calculated size is below this) # Minimum trade size in USDC (skip if calculated size is below this)
MIN_TRADE_SIZE=1 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 MAX_POSITION_SIZE=10
# ───────────────────────────────────────────── # ── Auto Sell ────────────────────────────────────────────────────
# AUTO SELL
# ─────────────────────────────────────────────
AUTO_SELL_ENABLED=true AUTO_SELL_ENABLED=true
AUTO_SELL_PROFIT_PERCENT=10 AUTO_SELL_PROFIT_PERCENT=10
# Sell mode when copying trader's sell # "market" = sell immediately at market price
# "market" = sell at market price immediately
# "limit" = place limit order at trader's sell price # "limit" = place limit order at trader's sell price
SELL_MODE=market SELL_MODE=market
# ───────────────────────────────────────────── # ── Intervals ────────────────────────────────────────────────────
# INTERVALS
# ─────────────────────────────────────────────
# How often (seconds) to check for resolved markets to redeem
REDEEM_INTERVAL=60 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 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 GTC_FALLBACK_TIMEOUT=60
# ─────────────────────────────────────────────
# DRY RUN (set true to simulate without real trades)
# ─────────────────────────────────────────────
DRY_RUN=true
# ───────────────────────────────────────────── # ══════════════════════════════════════════════════════════════════
# MARKET MAKER (mm.js / npm run mm-sim) # ORDERBOOK SNIPER (npm run sniper)
# ───────────────────────────────────────────── # Places 3-tier GTC limit buy orders at panic-dump price levels.
# Comma-separated assets to market-make (same slug format as sniper) # ══════════════════════════════════════════════════════════════════
MM_ASSETS=btc
# 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 # Comma-separated assets to snipe
SNIPER_ASSETS=eth,sol,xrp SNIPER_ASSETS=eth,sol,xrp
# 3-Tier pricing (high to low) # 3-Tier pricing (descending) — orders placed at these prices
SNIPER_TIER1_PRICE=0.03 # Highest price, smallest allocation (20%) SNIPER_TIER1_PRICE=0.03
SNIPER_TIER2_PRICE=0.02 # Mid price, medium allocation (30%) SNIPER_TIER2_PRICE=0.02
SNIPER_TIER3_PRICE=0.01 # Lowest price, largest allocation (50%) SNIPER_TIER3_PRICE=0.01
# Max total shares per side (min 5 shares per tier) # Max total shares to deploy (split across tiers: 20% / 30% / 50%)
# Example: 15 shares → 3@3c + 5@2c + 7@1c
# Example: 30 shares → 6@3c + 9@2c + 15@1c
SNIPER_MAX_SHARES=15 SNIPER_MAX_SHARES=15
# ── Sniper Sizing Multiplier (UTC+8) ─────────────────────── # Time-based sizing multiplier (UTC+8). Format: HH:MM-HH:MM:factor,...
# 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
SNIPER_MULTIPLIERS=21:00-00:00:1.41,06:00-12:00:0.85 SNIPER_MULTIPLIERS=21:00-00:00:1.41,06:00-12:00:0.85
# ── Sniper Pause After Win ───────────────────────────────── # Rounds to pause an asset after detecting a win (5-min intervals)
# Number of 5-min rounds to pause an asset after a win is detected.
# Set to 0 to disable.
SNIPER_PAUSE_ROUNDS_AFTER_WIN=3 SNIPER_PAUSE_ROUNDS_AFTER_WIN=3
# ── Sniper Session Schedule (all times UTC+8) ────────────── # Active session schedules per asset (UTC+8). Format: HH:MM-HH:MM,...
# Format: HH:MM-HH:MM,HH:MM-HH:MM (comma-separated sessions)
# Assets without a schedule entry are always active.
SNIPER_SCHEDULE_BTC=19:40-22:40,03:40-06:10 SNIPER_SCHEDULE_BTC=19:40-22:40,03:40-06:10
SNIPER_SCHEDULE_ETH=11:40-15:40,16:40-19:40 SNIPER_SCHEDULE_ETH=11:40-15:40,16:40-19:40
SNIPER_SCHEDULE_SOL=09:40-12:40,21:40-23:40 SNIPER_SCHEDULE_SOL=09:40-12:40,21:40-23:40
SNIPER_SCHEDULE_XRP=18:40-20:40,08:40-09:50 SNIPER_SCHEDULE_XRP=18:40-20:40,08:40-09:50
# ─────────────────────────────────────────────
# PROXY (Polymarket API only, NOT Polygon RPC) # ══════════════════════════════════════════════════════════════════
# Supports HTTP/HTTPS/SOCKS5 proxies # CLASSIC MARKET MAKER (npm run mm-bot)
# Leave empty to connect directly (no proxy) # Legacy MM strategy using limit sell orders after one leg fills.
# Example: http://user:pass@proxy.example.com:8080 # ══════════════════════════════════════════════════════════════════
# ───────────────────────────────────────────── MM_ASSETS=btc
PROXY_URL= 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
+132 -314
View File
@@ -1,314 +1,146 @@
# Polymarket Terminal # 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)** **Created by [@direkturcrypto](https://twitter.com/direkturcrypto)**
**Repository:** https://github.com/direkturcrypto/polymarket-terminal
--- ---
## Table of Contents ## Strategies
- [Features](#features) ### 1. Maker Rebate MM (`npm run maker-mm-bot`) ⭐ Main Strategy
- [Prerequisites](#prerequisites)
- [Installation](#installation) High-frequency market-making on Polymarket's 15-minute BTC/ETH/SOL Up-or-Down markets.
- [Configuration](#configuration)
- [Usage](#usage) **How it works:**
- [Running on VPS with PM2](#running-on-vps-with-pm2) 1. Detects a new 15-minute market as it opens
- [How It Works](#how-it-works) 2. Places maker limit BUY orders on both YES and NO sides simultaneously (combined ≈ $0.98)
- [Project Structure](#project-structure) 3. When both sides fill, merges YES + NO tokens back to USDC via the CTF contract — capturing the spread as profit
- [Important Warnings](#important-warnings) 4. Re-enters immediately after each successful merge for the duration of the market
- [License](#license) 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 Mirrors the trades of any target Polymarket wallet in real-time.
- **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
### Market Maker Bot - Monitors target wallet for new BUY/SELL activity via the CLOB API
- **Automated Liquidity** — Splits USDC into YES+NO tokens and places limit sells on both sides at $0.50 entry - Replicates trades proportionally using configurable sizing modes (`balance` or `percentage`)
- **Cut-Loss Protection** — Merges unsold tokens back to USDC before market close - Supports automatic sell-out when target trader exits (market or limit)
- **Recovery Buy** — Optional directional bet after a cut-loss triggers - Auto-redeems resolved positions
- **Multi-Asset** — Supports BTC, ETH, SOL, and any 5m/15m Polymarket market
- **Simulation Mode** — Full dry-run with P&L tracking
### Orderbook Sniper Bot ```
- **3-Tier Strategy** — Places GTC BUY orders at 3c, 2c, and 1c with weighted sizing (20%/30%/50%) TRADER_ADDRESS=0xTARGET_WALLET
- **Multi-Asset** — Targets ETH, SOL, XRP, and more simultaneously SIZE_MODE=balance
- **Simulation Mode** — Preview orders without spending funds SIZE_PERCENT=10
- **Session Scheduling** — Per-asset time windows (UTC+8) for selective trading MAX_POSITION_SIZE=10
```
--- ---
## Prerequisites ### 3. Orderbook Sniper (`npm run sniper`)
| Requirement | Details | Places 3-tier GTC limit BUY orders at deep discount price levels to catch panic dumps.
|---|---|
| Node.js | v18 or higher (ESM support required) | - Deploys staggered orders at 3 price tiers (1¢, 2¢, 3¢) with weighted sizing (50% / 30% / 20%)
| Polygon Wallet | An EOA wallet with a private key | - Time-based sizing multipliers for peak trading hours
| Polymarket Proxy Wallet | Your proxy wallet address (visible on your Polymarket profile → Deposit) | - Per-asset session schedules (UTC+8)
| USDC.e on Polygon | Deposited via Polymarket's deposit flow | - Auto-pauses an asset after a win to avoid re-entering an already-resolved market
| MATIC on Polygon | A small amount for gas fees (redeem & on-chain operations) |
| PM2 *(optional)* | For running on a VPS: `npm install -g pm2` | ```
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 ## Installation
```bash ```bash
# 1. Clone the repository
git clone https://github.com/direkturcrypto/polymarket-terminal.git git clone https://github.com/direkturcrypto/polymarket-terminal.git
cd polymarket-terminal cd polymarket-terminal
# 2. Install dependencies
npm install npm install
# 3. Copy the environment template
cp .env.example .env cp .env.example .env
# Edit .env with your wallet keys and settings
# 4. Fill in your credentials
nano .env
``` ```
--- ---
## Configuration ## Quick Start
All settings are controlled via the `.env` file. **Never commit your `.env` file** — it is already listed in `.gitignore`. **Always test with simulation mode first:**
### 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).
```bash ```bash
# Copy Trade Bot # Simulate maker MM — no real orders placed
npm start # live trading npm run maker-mm-bot-sim
npm run dev # live + auto-reload on file changes
# Market Maker Bot # Run live maker MM (recommended starting config)
npm run mm # live trading MAKER_MM_TRADE_SIZE=5 MAKER_MM_REENTRY_DELAY=30 npm run maker-mm-bot
npm run mm-sim # simulation (DRY_RUN=true)
npm run mm-dev # simulation + auto-reload
# Orderbook Sniper Bot # Simulate copy trader
npm run sniper # live trading npm run bot-sim
npm run sniper-sim # simulation
npm run sniper-dev # simulation + auto-reload # 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 ## Running with PM2 (recommended for VPS)
Each bot has its own PM2 config file inside the `pm2/` folder.
### Install PM2
```bash ```bash
npm install -g pm2 npm install -g pm2
```
### Copy Trade Bot # Start maker MM
pm2 start src/maker-mm-bot.js --name polymarket-maker-mm --interpreter node
```bash # Start copy trader
# Live trading pm2 start src/bot.js --name polymarket-bot --interpreter node
pm2 start pm2/copy.config.cjs
# Simulation
pm2 start pm2/copy.config.cjs --env sim
# View logs # View logs
pm2 logs polymarket-copy pm2 logs polymarket-maker-mm
tail -f logs/copy-out.log pm2 logs polymarket-bot
# 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)
``` ```
--- ---
@@ -316,68 +148,54 @@ Collect Cancel orders → Merge YES+NO back to USDC
## Project Structure ## Project Structure
``` ```
polymarket-terminal/ src/
├── src/ ├── maker-mm-bot.js # Maker Rebate MM — PM2/VPS entry point
│ ├── index.js — Copy trade bot (TUI) ├── maker-mm.js # Maker Rebate MM — TUI entry point
├── bot.js Copy trade bot (plain log / PM2) ├── bot.js # Copy Trader
│ ├── mm.js — Market maker bot (TUI) ├── sniper.js # Orderbook Sniper
├── mm-bot.js — Market maker bot (plain log / PM2) ├── mm-bot.js # Classic MM (legacy)
│ ├── sniper.js — Orderbook sniper bot ├── config/
└── index.js # All configuration with env var mapping
│ ├── config/ └── services/
│ │ └── index.js — Environment variable loading & validation ├── makerRebateExecutor.js # Core maker MM logic (orders, fills, merge)
│ │ ├── mmDetector.js # Market discovery and scheduling
├── services/ ├── mmWsFillWatcher.js # WebSocket RTDS real-time fill detection
│ │ ├── client.js — CLOB client initialization & USDC balance ├── ctf.js # CTF contract interaction (merge/redeem)
│ ├── watcher.js — Poll-based trader activity detection └── client.js # Polymarket CLOB client wrapper
│ │ ├── 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
``` ```
--- ---
## Important Warnings ## How Maker Rebate Works on Polymarket
- **Never commit your `.env` file.** Your private key must remain secret. The `.gitignore` already excludes it. Polymarket's CLOB gives **maker rebates** to traders who post limit orders, while takers pay a fee. This terminal exploits that by:
- **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. 1. Simultaneously posting BUY limit orders on both YES and NO of a binary market
- **Keep MATIC in your EOA wallet** for gas fees (redeem operations and on-chain CTF calls). 2. Since YES + NO always resolve to $1.00 (exactly one wins), buying both at combined cost < $1.00 guarantees a profit on merge
- **This software is provided as-is, with no guarantees.** Prediction market trading carries significant financial risk. You are solely responsible for any losses. 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 ## 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.
+1
View File
@@ -7,6 +7,7 @@
"": { "": {
"name": "polymarket-terminal", "name": "polymarket-terminal",
"version": "1.0.0", "version": "1.0.0",
"hasInstallScript": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@polymarket/clob-client": "^4.7.3", "@polymarket/clob-client": "^4.7.3",
+4
View File
@@ -22,6 +22,10 @@
"sniper-tui": "DRY_RUN=false node src/sniper-tui.js", "sniper-tui": "DRY_RUN=false node src/sniper-tui.js",
"sniper-tui-sim": "DRY_RUN=true node src/sniper-tui.js", "sniper-tui-sim": "DRY_RUN=true node src/sniper-tui.js",
"sniper-tui-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/sniper-tui.js", "sniper-tui-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/sniper-tui.js",
"maker-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" "postinstall": "node scripts/patch-clob-client.cjs"
}, },
"keywords": [ "keywords": [
+38
View File
@@ -82,6 +82,32 @@ const config = {
mmRecoveryThreshold: parseFloat(process.env.MM_RECOVERY_THRESHOLD || '0.70'), // min price to qualify mmRecoveryThreshold: parseFloat(process.env.MM_RECOVERY_THRESHOLD || '0.70'), // min price to qualify
mmRecoverySize: parseFloat(process.env.MM_RECOVERY_SIZE || '0'), // 0 = use mmTradeSize 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 ─────────────────────────────────────────── // ── Orderbook Sniper ───────────────────────────────────────────
// 3-tier strategy: places GTC limit BUY orders at 3c, 2c, and 1c // 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 // 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'); 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; export default config;
+290
View File
@@ -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...`);
+219
View File
@@ -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);
+11 -8
View File
@@ -1,11 +1,12 @@
import { ClobClient } from '@polymarket/clob-client'; import { ClobClient } from '@polymarket/clob-client';
import { Wallet } from 'ethers'; import { ethers, Wallet } from 'ethers';
import config from '../config/index.js'; import config from '../config/index.js';
import logger from '../utils/logger.js'; import logger from '../utils/logger.js';
import { setupAxiosProxy, testProxy } from '../utils/proxy.js'; import { setupAxiosProxy, testProxy } from '../utils/proxy.js';
let clobClient = null; let clobClient = null;
let signer = null; let signer = null;
let _provider = null; // singleton — reused across all onchain calls
/** /**
* Initialize the Polymarket CLOB client * 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() { export function getPolygonProvider() {
const { ethers } = await import('ethers'); if (!_provider) {
const provider = new ethers.providers.JsonRpcProvider(config.polygonRpcUrl); _provider = new ethers.providers.JsonRpcProvider(config.polygonRpcUrl);
return provider; }
return _provider;
} }
/** /**
* Get USDC.e balance of the proxy wallet on Polygon * Get USDC.e balance of the proxy wallet on Polygon
*/ */
export async function getUsdcBalance() { export async function getUsdcBalance() {
const { ethers } = await import('ethers'); const provider = getPolygonProvider();
const provider = await getPolygonProvider();
const usdcAddress = '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174'; // USDC.e on Polygon const usdcAddress = '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174'; // USDC.e on Polygon
const abi = ['function balanceOf(address) view returns (uint256)']; const abi = ['function balanceOf(address) view returns (uint256)'];
const usdc = new ethers.Contract(usdcAddress, abi, provider); const usdc = new ethers.Contract(usdcAddress, abi, provider);
+66 -6
View File
@@ -73,6 +73,10 @@ function parseOnchainError(err) {
return 'Priority fee below Polygon minimum (25 Gwei)'; return 'Priority fee below Polygon minimum (25 Gwei)';
if (msg.includes('UNPREDICTABLE_GAS_LIMIT')) if (msg.includes('UNPREDICTABLE_GAS_LIMIT'))
return 'Gas estimation failed — transaction will likely revert'; 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') if (msg.includes('execution reverted') || err?.code === 'CALL_EXCEPTION')
return reason ? `Transaction reverted: ${reason}` : 'Transaction reverted by smart contract'; return reason ? `Transaction reverted: ${reason}` : 'Transaction reverted by smart contract';
if (msg.includes('timeout') || msg.includes('TIMEOUT')) 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. * Non-priority calls (redeemer) wait until no strategy tx is active.
*/ */
export function execSafeCall(to, data, description = '', opts = {}) { export function execSafeCall(to, data, description = '', opts = {}) {
const { priority = true } = opts; const { priority = true, gasLimit } = opts;
const job = async () => { const job = async () => {
// Non-priority (redeemer): wait if a strategy tx is active // 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; if (priority) _strategyTxActive = true;
try { try {
return await _doExecSafeCall(to, data, description); return await _doExecSafeCall(to, data, description, gasLimit);
} finally { } finally {
if (priority) _strategyTxActive = false; if (priority) _strategyTxActive = false;
} }
@@ -144,7 +148,7 @@ export function execSafeCall(to, data, description = '', opts = {}) {
return result; 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}`); if (description) logger.info(`MM: exec safe tx — ${description}`);
let lastErr; let lastErr;
@@ -203,12 +207,15 @@ async function _doExecSafeCall(to, data, description = '') {
? MAX_FEE_CAP ? MAX_FEE_CAP
: estimatedMaxFee.mul(Math.ceil(currentMultiplier * 100)).div(100); : estimatedMaxFee.mul(Math.ceil(currentMultiplier * 100)).div(100);
const txOpts = { maxPriorityFeePerGas: gasTip, maxFeePerGas: gasFeeCap };
if (gasLimit) txOpts.gasLimit = gasLimit;
const tx = await safe.execTransaction( const tx = await safe.execTransaction(
to, 0, data, 0, 0, 0, 0, to, 0, data, 0, 0, 0, 0,
ethers.constants.AddressZero, ethers.constants.AddressZero,
ethers.constants.AddressZero, ethers.constants.AddressZero,
signature, signature,
{ maxPriorityFeePerGas: gasTip, maxFeePerGas: gasFeeCap }, txOpts,
); );
const receipt = await tx.wait(); const receipt = await tx.wait();
@@ -287,6 +294,50 @@ export async function ensureExchangeApproval(negRisk = false) {
logger.success(`MM: CTF exchange approved as ERC1155 operator`); 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 ─────────────────────────────────────────────────────── // ── Core CTF operations ───────────────────────────────────────────────────────
/** /**
@@ -355,7 +406,13 @@ export async function mergePositions(conditionId, sharesPerSide) {
return recovered; 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 ctfIface = new ethers.utils.Interface(CTF_ABI);
const data = ctfIface.encodeFunctionData('mergePositions', [ const data = ctfIface.encodeFunctionData('mergePositions', [
@@ -366,7 +423,10 @@ export async function mergePositions(conditionId, sharesPerSide) {
amountWei, 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`); logger.success(`MM: merged — recovered $${sharesPerSide} USDC`);
return sharesPerSide; return sharesPerSide;
} }
+827
View File
@@ -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 };
}
+54 -6
View File
@@ -7,28 +7,31 @@
* e.g. btc-updown-5m-1771755000 * e.g. btc-updown-5m-1771755000
* eth-updown-15m-1771754100 * 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 config from '../config/index.js';
import logger from '../utils/logger.js'; import logger from '../utils/logger.js';
import { proxyFetch } from '../utils/proxy.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 pollTimer = null;
let onMarketCb = null; let onMarketCb = null;
const seenKeys = new Set(); // `${asset}-${slotTimestamp}` already scheduled const seenKeys = new Set(); // `${asset}-${slotTimestamp}` already scheduled
// ── Slot helpers ────────────────────────────────────────────────────────────── // ── Slot helpers ──────────────────────────────────────────────────────────────
// Computed dynamically so config.mmDuration overrides in maker-mm.js take effect.
function slotSec() {
return config.mmDuration === '15m' ? 900 : 300;
}
function currentSlot() { 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() { function nextSlot() {
return currentSlot() + SLOT_SEC; return currentSlot() + slotSec();
} }
// ── Gamma API fetch ─────────────────────────────────────────────────────────── // ── Gamma API fetch ───────────────────────────────────────────────────────────
@@ -150,3 +153,48 @@ export function stopMMDetector() {
pollTimer = null; 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);
}
}