6 Commits

Author SHA1 Message Date
direkturcrypto c156a68fdc fix: enforce minimum 5 shares per order (Polymarket minimum)
- Add MIN_ORDER_SIZE=5 constant, apply Math.max + Math.floor to buy/sell sizes
- Skip sell placement if unsold shares < 5 (wait for more fills)
- Config validation: MAKER_TRADE_SIZE must be >= 5

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:10:04 +07:00
direkturcrypto 5f4aa04d13 feat: add buy CL at 10s, multi-duration support, and multi-asset concurrent maker
- CL at 10s before close: cancel unfilled buy orders, place sells for filled
  positions with 3x retry for on-chain settlement delay
- Sell orders stay live until market close (no early cancellation)
- Support multiple durations simultaneously (MAKER_DURATION=5m,15m)
- Queue key changed to asset+duration so BTC/5m and BTC/15m run concurrently
- Update AGENT.MD and README.md with Maker v2 documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:58:35 +07:00
direkturcrypto 930dffafbd fix: add exchange approval, sell retry with settlement delay, CL at 10s
Three fixes for live trading:
1. Call ensureExchangeApproval before selling — CLOB buy gives tokens
   but exchange needs ERC1155 operator approval to place sell orders
2. Wait 2s after buy fill for on-chain settlement before placing sell,
   retry up to 3x with increasing delay if balance not yet available
3. CL at 10s before market close — cancel unfilled sell orders and let
   tokens resolve on-chain naturally

Also adds simulation stats tracking:
- Starting balance $50, tracks cumulative P&L
- Win/Loss/Skip counters with win rate %
- Trade history displayed in TUI and bot status

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 18:58:11 +07:00
direkturcrypto 9a89efb071 fix: remove cut-loss from maker strategy — hold to resolution
No cut-loss needed: worst case is losing buy cost (2c/share) which
resolves on-chain. If on winning side, payout is $1/share.
Removed marketSell, cutLossSells, and makerCutLossTime config.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:59:58 +07:00
direkturcrypto 23bb74127c fix: rewrite makerWs to handle CLOB WebSocket message formats correctly
- Handle initial book snapshot (arrives as array without event_type)
- Handle price_change deltas (incremental updates to bid/ask levels)
- Use Maps for O(1) price level updates instead of full array replace
- Use proper WebSocket ping frames instead of string "ping"
- Subscribe all assets in single message
- Add getBestBid/getBestAsk methods using cached values from events
- Improve TUI orderbook display with depth bars and spread indicator

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:53:04 +07:00
direkturcrypto 991fd6caa4 feat: implement buy-low-sell-high market maker (no splitPosition)
New market maker strategy that places limit BUY on UP+DOWN at low price
(default 2c), then sells at target price (default 3c) when filled.

Key features:
- Concurrent limit BUY on both sides (multi-thread style via Promise.all)
- Immediate partial sell placement on partial fill detection
- Cancel opposite side when one fills completely
- WebSocket orderbook client for real-time simulation visualization
- Both TUI (maker.js) and bot (maker-bot.js) entry points

Files:
- services/makerDetector.js: market detection (reuses slug pattern)
- services/makerExecutor.js: core buy-low-sell-high strategy
- services/makerWs.js: WebSocket orderbook for live data
- maker.js: TUI version with blessed dashboard
- maker-bot.js: PM2/VPS plain-text version

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:47:26 +07:00
27 changed files with 2198 additions and 3287 deletions
+139 -149
View File
@@ -1,206 +1,196 @@
# ══════════════════════════════════════════════════════════════════
# polymarket-terminal — Environment Configuration
# Copy this file to .env and fill in your values
# ══════════════════════════════════════════════════════════════════
# ─────────────────────────────────────────────
# WALLET SETUP (required for all strategies)
# WALLET SETUP
# ─────────────────────────────────────────────
# EOA private key — used for SIGNING only, does NOT hold funds
# Never share this key or commit it to version control
# EOA private key — used for SIGNING only, does NOT hold USDC
PRIVATE_KEY=0xYOUR_EOA_PRIVATE_KEY_HERE
# Polymarket Proxy Wallet — the address shown when you click "Deposit" on Polymarket
# This is where you deposit USDC.e and where all trades are funded from
# How to find: polymarket.com → Profile → Deposit → copy the address
# This is where you deposit USDC.e, and where trades are funded from
# How to find: Login to polymarket.com → Profile → Deposit → copy the address
PROXY_WALLET_ADDRESS=0xYOUR_PROXY_WALLET_ADDRESS_HERE
# ─────────────────────────────────────────────
# POLYGON RPC
# ─────────────────────────────────────────────
# Public RPC (default, no key required)
POLYGON_RPC_URL=https://polygon.lava.build
# Alternative RPCs:
# POLYGON_RPC_URL=https://polygon-rpc.com
# POLYGON_RPC_URL=https://rpc.ankr.com/polygon
# ─────────────────────────────────────────────
# POLYMARKET API CREDENTIALS (optional)
# Leave blank to auto-derive from your private key on first run
# Leave blank to auto-derive from your private key
# ─────────────────────────────────────────────
CLOB_API_KEY=
CLOB_API_SECRET=
CLOB_API_PASSPHRASE=
# ─────────────────────────────────────────────
# PROXY (optional — Polymarket API only, NOT Polygon RPC)
# Supports HTTP, HTTPS, and SOCKS5 proxies
# Leave empty to connect directly
# Example: http://user:pass@proxy.example.com:8080
# TRADER TO COPY
# Use the proxy wallet address of the trader (visible on their Polymarket profile)
# ─────────────────────────────────────────────
PROXY_URL=
# ─────────────────────────────────────────────
# 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
# Set to false to disable re-entry — bot places one order per market then waits for next
MAKER_MM_REENTRY_ENABLED=true
# YES bid price range — bot only enters when YES bid is within this range.
# NO bid is derived from MAX_COMBINED - YES bid, and is NOT range-filtered.
# Default (balanced market): 0.30-0.69 — enters near 50/50
# Skewed market mode: 0.10-0.30 — only enters when YES is cheap (e.g. YES=11c, NO=87c)
MAKER_MM_MIN_PRICE=0.30
MAKER_MM_MAX_PRICE=0.69
# Cancel cheap side when expensive side fills first, then hold and auto-redeem at resolution
# Example: YES=5c, NO=94c → NO fills first → cancel YES, hold NO, redeem after market ends
# Default false (symmetric maker behavior — wait for both sides)
MAKER_MM_CANCEL_CHEAP_ON_EXP_FILL=false
# Price polling interval (seconds) while waiting for entry conditions
# Lower = more responsive but more API calls. Default: 3
MAKER_MM_POLL_SEC=3
# ── 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 ─────────────────────────────────────────────────
# ─────────────────────────────────────────────
# TRADE SIZING
# ─────────────────────────────────────────────
# SIZE_MODE:
# "percentage" = SIZE_PERCENT% of MAX_POSITION_SIZE per entry
# "percentage" = SIZE_PERCENT% of MAX_POSITION_SIZE per market 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
# (e.g. balance=$100, SIZE_PERCENT=10 → buy $10 per entry)
# Note: sizing is independent of the trader's individual fill size.
# Limit orders can fill in many small chunks — we always use our own sizing.
SIZE_MODE=balance
SIZE_PERCENT=10
# Minimum trade size in USDC (skip if calculated size is below this)
MIN_TRADE_SIZE=1
# Maximum total USDC position per market
# Maximum total position per market in USDC (won't buy more once this is reached)
MAX_POSITION_SIZE=10
# ── Auto Sell ────────────────────────────────────────────────────
# ─────────────────────────────────────────────
# AUTO SELL
# ─────────────────────────────────────────────
AUTO_SELL_ENABLED=true
AUTO_SELL_PROFIT_PERCENT=10
# "market" = sell immediately at market price
# Sell mode when copying trader's sell
# "market" = sell at market price immediately
# "limit" = place limit order at trader's sell price
SELL_MODE=market
# ── Intervals ────────────────────────────────────────────────────
# ─────────────────────────────────────────────
# INTERVALS
# ─────────────────────────────────────────────
# How often (seconds) to check for resolved markets to redeem
REDEEM_INTERVAL=60
# Skip BUY if the market closes within this many seconds from now
# Default 300 = skip if market ends in less than 5 minutes
MIN_MARKET_TIME_LEFT=300
# When FAK finds no liquidity (e.g. copying into "next market" before it opens),
# fall back to a GTC limit order and wait this many seconds for it to fill.
# Set to 0 to disable the GTC fallback entirely.
GTC_FALLBACK_TIMEOUT=60
# ─────────────────────────────────────────────
# DRY RUN (set true to simulate without real trades)
# ─────────────────────────────────────────────
DRY_RUN=true
# ══════════════════════════════════════════════════════════════════
# ORDERBOOK SNIPER (npm run sniper)
# Places 3-tier GTC limit buy orders at panic-dump price levels.
# ══════════════════════════════════════════════════════════════════
# ─────────────────────────────────────────────
# MARKET MAKER (mm.js / npm run mm-sim)
# ─────────────────────────────────────────────
# 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
SNIPER_ASSETS=eth,sol,xrp
# 3-Tier pricing (descending) — orders placed at these prices
SNIPER_TIER1_PRICE=0.03
SNIPER_TIER2_PRICE=0.02
SNIPER_TIER3_PRICE=0.01
# 3-Tier pricing (high to low)
SNIPER_TIER1_PRICE=0.03 # Highest price, smallest allocation (20%)
SNIPER_TIER2_PRICE=0.02 # Mid price, medium allocation (30%)
SNIPER_TIER3_PRICE=0.01 # Lowest price, largest allocation (50%)
# Max total shares to deploy (split across tiers: 20% / 30% / 50%)
# Max total shares per side (min 5 shares per tier)
# Example: 15 shares → 3@3c + 5@2c + 7@1c
# Example: 30 shares → 6@3c + 9@2c + 15@1c
SNIPER_MAX_SHARES=15
# Time-based sizing multiplier (UTC+8). Format: HH:MM-HH:MM:factor,...
SNIPER_MULTIPLIERS=21:00-00:00:1.41,06:00-12:00:0.85
# Rounds to pause an asset after detecting a win (5-min intervals)
SNIPER_PAUSE_ROUNDS_AFTER_WIN=3
# Active session schedules per asset (UTC+8). Format: HH:MM-HH:MM,...
# ── Sniper Session Schedule (all times UTC+8) ──────────────
# Format: HH:MM-HH:MM,HH:MM-HH:MM (comma-separated sessions)
# Assets without a schedule entry are always active.
SNIPER_SCHEDULE_BTC=19:40-22:40,03:40-06:10
SNIPER_SCHEDULE_ETH=11:40-15:40,16:40-19:40
SNIPER_SCHEDULE_SOL=09:40-12:40,21:40-23:40
SNIPER_SCHEDULE_XRP=18:40-20:40,08:40-09:50
# ─────────────────────────────────────────────
# MARKET MAKER v2 (maker.js / npm run maker-sim)
# Buy Low, Sell High — no splitPosition.
# Places limit BUY on UP+DOWN at low price, sells at target when filled.
# Only one side will fill — the other gets cancelled.
# ─────────────────────────────────────────────
# Comma-separated assets
MAKER_ASSETS=btc,eth,sol,xrp
# ══════════════════════════════════════════════════════════════════
# CLASSIC MARKET MAKER (npm run mm-bot)
# Legacy MM strategy using limit sell orders after one leg fills.
# ══════════════════════════════════════════════════════════════════
MM_ASSETS=btc
MM_DURATION=5m
MM_TRADE_SIZE=5
MM_SELL_PRICE=0.60
MM_CUT_LOSS_TIME=60
MM_MARKET_KEYWORD=Bitcoin Up or Down
MM_ENTRY_WINDOW=45
MM_POLL_INTERVAL=10
MM_RECOVERY_BUY=false
MM_RECOVERY_THRESHOLD=0.70
MM_RECOVERY_SIZE=0
MM_ADAPTIVE_CL=true
MM_ADAPTIVE_MIN_COMBINED=1.20
MM_ADAPTIVE_MONITOR_SEC=5
# Market duration: comma-separated, e.g. "5m" or "5m,15m" for both
MAKER_DURATION=5m,15m
# Limit BUY price (e.g. 0.02 = 2 cents per share)
MAKER_BUY_PRICE=0.02
# Limit SELL price (e.g. 0.03 = 3 cents per share)
MAKER_SELL_PRICE=0.03
# Shares per side (e.g. 50 shares × 0.02 = $1.00 cost per side)
MAKER_TRADE_SIZE=50
# How often to poll for new markets (seconds)
MAKER_POLL_INTERVAL=10
# How fast to check order fill status (milliseconds)
MAKER_MONITOR_MS=2000
# ─────────────────────────────────────────────
# PROXY (Polymarket API only, NOT Polygon RPC)
# Supports HTTP/HTTPS/SOCKS5 proxies
# Leave empty to connect directly (no proxy)
# Example: http://user:pass@proxy.example.com:8080
# ─────────────────────────────────────────────
PROXY_URL=
+21 -2
View File
@@ -11,7 +11,8 @@ Developer and AI agent reference for understanding, extending, and maintaining t
| Bot | Entry Point | Command | Purpose |
|---|---|---|---|
| Copy Trade | `src/index.js` | `npm start` | Mirror trades from a target trader wallet |
| Market Maker | `src/mm.js` | `npm run mm` | Provide liquidity on 5m/15m binary markets |
| Market Maker v1 | `src/mm.js` | `npm run mm` | Provide liquidity via splitPosition on 5m/15m markets |
| Market Maker v2 | `src/maker.js` | `npm run maker` | Buy low, sell high — pure orderbook maker (no splitPosition) |
| Orderbook Sniper | `src/sniper.js` | `npm run sniper` | Place low-price GTC orders to catch panic dumps |
---
@@ -91,9 +92,10 @@ All files use **ES Modules** (`import`/`export`). Do not use `require()`.
- Loads all settings from `.env` via `dotenv`
- Parses and validates required fields per bot type
- Exports two validator functions:
- Exports three validator functions:
- `validateCopyTradeConfig()` — for `src/index.js`
- `validateMMConfig()` — for `src/mm.js` and `src/sniper.js`
- `validateMakerConfig()` — for `src/maker.js`
- Key exported fields: `privateKey`, `proxyWallet`, `traderAddress`, `dryRun`, `mmTradeSize`, etc.
### Client (`src/services/client.js`)
@@ -171,6 +173,23 @@ Used exclusively by the Market Maker bot. Interacts with the Gnosis Safe proxy w
- `cutLoss(...)` — Cancels remaining orders; merges back or market-sells residual tokens
- `attemptRecoveryBuy(...)` — Optional directional bet post cut-loss (controlled by `MM_RECOVERY_BUY`)
### Maker Detector (`src/services/makerDetector.js`)
- Slot-based detection for the Maker v2 bot (buy low, sell high)
- Supports **multiple durations** simultaneously (e.g. `MAKER_DURATION=5m,15m`)
- Polls all `asset × duration` combinations in parallel
- Each detected market includes a `duration` field for concurrent queue management
- Same slug format as other bots: `{asset}-updown-{duration}-{timestamp}`
### Maker Executor (`src/services/makerExecutor.js`)
- `executeMakerStrategy(market)` — Full flow: place limit BUY on UP+DOWN → monitor fills → place limit SELL → monitor sells
- **CL at 10s before close:** Cancels unfilled buy orders, places sells for any filled positions (retry 3x for settlement delays)
- **Sell phase:** Sell orders stay live until market close (no early cancellation)
- **Multi-market concurrent:** Each position keyed by `conditionId`, queue keyed by `${asset}-${duration}`
- Simulation mode with full P&L tracking (win/loss/skip stats)
- `placeLimitSellWithRetry()` — Retries sell placement 3x with increasing delay for on-chain settlement
### Sniper Detector (`src/services/sniperDetector.js`)
- Scans for 5-minute markets for a configurable list of assets
+383 -132
View File
@@ -1,147 +1,379 @@
# Polymarket Terminal
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.
> An automated trading terminal for [Polymarket](https://polymarket.com) — copy trades, provide liquidity, and snipe low-priced orderbook fills, all from your command line.
**Created by [@direkturcrypto](https://twitter.com/direkturcrypto)**
**Repository:** https://github.com/direkturcrypto/polymarket-terminal
---
## Strategies
## Table of Contents
### 1. Maker Rebate MM (`npm run maker-mm-bot`) ⭐ Main Strategy
High-frequency market-making on Polymarket's 15-minute BTC/ETH/SOL Up-or-Down markets.
**How it works:**
1. Detects a new 15-minute market as it opens
2. Places maker limit BUY orders on both YES and NO sides simultaneously (combined ≈ $0.98)
3. When both sides fill, merges YES + NO tokens back to USDC via the CTF contract — capturing the spread as profit
4. Re-enters immediately after each successful merge for the duration of the market
5. Automatically queues the next market before the current one closes — zero idle time between markets
**Key design decisions:**
- **No repricing** — orders are placed once and held; no cancel/replace cycles that cause double orders or ghost fills
- **Onchain balance as source of truth** — fill detection uses Polygon RPC balance, not CLOB API responses or WebSocket events alone
- **Ghost fill recovery** — detects CLOB-matched orders with invalid txhash (order gone from book but tokens never arrived), recovers by merging what settled and selling remainder at market before prices skew
- **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
- **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](#features)
- [Prerequisites](#prerequisites)
- [Installation](#installation)
- [Configuration](#configuration)
- [Usage](#usage)
- [Running on VPS with PM2](#running-on-vps-with-pm2)
- [How It Works](#how-it-works)
- [Project Structure](#project-structure)
- [Important Warnings](#important-warnings)
- [License](#license)
---
### 2. Copy Trader (`npm run bot`)
## Features
Mirrors the trades of any target Polymarket wallet in real-time.
### Copy Trade Bot
- **Watch Trader** — Monitor any Polymarket wallet address in real time via WebSocket
- **Copy Buy** — Automatically mirror buy orders with configurable position sizing
- **Copy Sell** — Automatically mirror sell orders (market or limit)
- **Auto Sell** — Place a GTC limit sell at a target profit % immediately after a buy fills
- **Auto Redeem** — Periodically check and redeem winning positions on-chain
- **Market Expiry Guard** — Skip buys if market closes within `MIN_MARKET_TIME_LEFT` seconds
- **GTC Fallback** — Falls back to a GTC limit order when copying "next market" trades with no liquidity
- **Per-Market Queue** — Concurrent events for the same market are serialized to prevent duplicate buys
- **Dry Run Mode** — Simulate the full flow without placing real orders
- Monitors target wallet for new BUY/SELL activity via the CLOB API
- Replicates trades proportionally using configurable sizing modes (`balance` or `percentage`)
- Supports automatic sell-out when target trader exits (market or limit)
- Auto-redeems resolved positions
### Market Maker v1 Bot (Split Position)
- **Automated Liquidity** — Splits USDC into YES+NO tokens and places limit sells on both sides at $0.50 entry
- **Cut-Loss Protection** — Merges unsold tokens back to USDC before market close
- **Recovery Buy** — Optional directional bet after a cut-loss triggers
- **Multi-Asset** — Supports BTC, ETH, SOL, and any 5m/15m Polymarket market
- **Simulation Mode** — Full dry-run with P&L tracking
```
TRADER_ADDRESS=0xTARGET_WALLET
SIZE_MODE=balance
SIZE_PERCENT=10
MAX_POSITION_SIZE=10
```
### Market Maker v2 Bot (Buy Low, Sell High)
- **Pure Orderbook** — Places limit BUY on both UP+DOWN at low price (e.g. 2c), sells at higher price (e.g. 3c) when filled. No splitPosition needed
- **Cut-Loss at 10s** — Cancels unfilled buy orders 10 seconds before market close; sells positions as much as possible
- **Sell Retry 3x** — Retries sell placement with delay for on-chain token settlement
- **Multi-Asset** — Supports BTC, ETH, SOL, XRP simultaneously
- **Multi-Duration** — Run 5m and 15m markets concurrently (`MAKER_DURATION=5m,15m`)
- **Concurrent Markets** — Each asset+duration combination runs independently (e.g. BTC/5m and BTC/15m don't block each other)
- **Simulation Mode** — Full dry-run with win/loss/skip tracking and P&L stats
- **Proxy Support** — All API calls go through `PROXY_URL` if configured
### Orderbook Sniper Bot
- **3-Tier Strategy** — Places GTC BUY orders at 3c, 2c, and 1c with weighted sizing (20%/30%/50%)
- **Multi-Asset** — Targets ETH, SOL, XRP, and more simultaneously
- **Simulation Mode** — Preview orders without spending funds
- **Session Scheduling** — Per-asset time windows (UTC+8) for selective trading
---
### 3. Orderbook Sniper (`npm run sniper`)
## Prerequisites
Places 3-tier GTC limit BUY orders at deep discount price levels to catch panic dumps.
- Deploys staggered orders at 3 price tiers (1¢, 2¢, 3¢) with weighted sizing (50% / 30% / 20%)
- Time-based sizing multipliers for peak trading hours
- Per-asset session schedules (UTC+8)
- Auto-pauses an asset after a win to avoid re-entering an already-resolved market
```
SNIPER_ASSETS=eth,sol,xrp
SNIPER_MAX_SHARES=15
SNIPER_MULTIPLIERS=21:00-00:00:1.41,06:00-12:00:0.85
```
---
## Requirements
- Node.js 18+
- A Polymarket account with a funded proxy wallet (USDC.e on Polygon)
- EOA private key for signing (the signing wallet does not need to hold funds)
| Requirement | Details |
|---|---|
| Node.js | v18 or higher (ESM support required) |
| Polygon Wallet | An EOA wallet with a private key |
| Polymarket Proxy Wallet | Your proxy wallet address (visible on your Polymarket profile → Deposit) |
| USDC.e on Polygon | Deposited via Polymarket's deposit flow |
| MATIC on Polygon | A small amount for gas fees (redeem & on-chain operations) |
| PM2 *(optional)* | For running on a VPS: `npm install -g pm2` |
---
## Installation
```bash
# 1. Clone the repository
git clone https://github.com/direkturcrypto/polymarket-terminal.git
cd polymarket-terminal
# 2. Install dependencies
npm install
# 3. Copy the environment template
cp .env.example .env
# Edit .env with your wallet keys and settings
# 4. Fill in your credentials
nano .env
```
---
## Quick Start
## Configuration
**Always test with simulation mode first:**
All settings are controlled via the `.env` file. **Never commit your `.env` file** — it is already listed in `.gitignore`.
### Wallet Setup
| Variable | Description | Required |
|---|---|---|
| `PRIVATE_KEY` | Your EOA private key (signing only, does not hold USDC) | Yes |
| `PROXY_WALLET_ADDRESS` | Your Polymarket proxy wallet address | Yes |
| `POLYGON_RPC_URL` | Polygon JSON-RPC endpoint | Yes |
> **How to find your Proxy Wallet:** Log in to polymarket.com → click your profile → Deposit → copy the wallet address shown.
### Polymarket API Credentials (Optional)
Leave these blank to have the client auto-derive credentials from your private key.
| Variable | Description |
|---|---|
| `CLOB_API_KEY` | CLOB API key |
| `CLOB_API_SECRET` | CLOB API secret |
| `CLOB_API_PASSPHRASE` | CLOB API passphrase |
### Copy Trade Bot Settings
| Variable | Description | Default |
|---|---|---|
| `TRADER_ADDRESS` | Proxy wallet address of the trader to copy | (required) |
| `SIZE_MODE` | `percentage` (of `MAX_POSITION_SIZE`) or `balance` (of your USDC balance) | `balance` |
| `SIZE_PERCENT` | Percentage to use per trade | `10` |
| `MIN_TRADE_SIZE` | Minimum trade size in USDC (skip if below) | `1` |
| `MAX_POSITION_SIZE` | Maximum USDC per market position | `10` |
| `AUTO_SELL_ENABLED` | Place a limit sell after each buy fills | `true` |
| `AUTO_SELL_PROFIT_PERCENT` | Target profit % for the auto-sell limit order | `10` |
| `SELL_MODE` | `market` or `limit` when copying a sell | `market` |
| `REDEEM_INTERVAL` | Seconds between redemption checks | `60` |
| `MIN_MARKET_TIME_LEFT` | Skip buy if market closes within this many seconds | `300` |
| `GTC_FALLBACK_TIMEOUT` | Seconds to wait for GTC fill when FAK finds no liquidity | `60` |
| `DRY_RUN` | Simulate without placing real orders | `true` |
### Market Maker Bot Settings
| Variable | Description | Default |
|---|---|---|
| `MM_ASSETS` | Comma-separated assets to market-make (e.g. `btc,eth`) | `btc` |
| `MM_DURATION` | Market duration: `5m` or `15m` | `5m` |
| `MM_TRADE_SIZE` | USDC per side (total exposure = 2×) | `5` |
| `MM_SELL_PRICE` | Limit sell price target (e.g. `0.60`) | `0.60` |
| `MM_CUT_LOSS_TIME` | Seconds before close to trigger cut-loss | `60` |
| `MM_MARKET_KEYWORD` | Keyword to filter market questions | `Bitcoin Up or Down` |
| `MM_ENTRY_WINDOW` | Max seconds after open to enter (0 = open only) | `45` |
| `MM_POLL_INTERVAL` | Seconds between new market polls | `10` |
| `MM_RECOVERY_BUY` | Enable recovery buy after cut-loss | `false` |
| `MM_RECOVERY_THRESHOLD` | Minimum dominant-side price to qualify for recovery | `0.70` |
| `MM_RECOVERY_SIZE` | USDC for recovery buy (0 = use `MM_TRADE_SIZE`) | `0` |
### Market Maker v2 (Maker) Settings
| Variable | Description | Default |
|---|---|---|
| `MAKER_ASSETS` | Comma-separated assets (e.g. `btc,eth,sol,xrp`) | `btc` |
| `MAKER_DURATION` | Comma-separated durations (e.g. `5m` or `5m,15m`) | `5m` |
| `MAKER_BUY_PRICE` | Limit BUY price per share (e.g. `0.02` = 2c) | `0.02` |
| `MAKER_SELL_PRICE` | Limit SELL price per share (e.g. `0.03` = 3c) | `0.03` |
| `MAKER_TRADE_SIZE` | Shares per side (e.g. `50` × $0.02 = $1.00/side) | `50` |
| `MAKER_POLL_INTERVAL` | Seconds between new market polls | `10` |
| `MAKER_MONITOR_MS` | Milliseconds between order fill checks | `2000` |
**Cut-Loss:** At 10 seconds before market close, unfilled buy orders are cancelled. Any filled positions get sell orders placed (retry 3x). Sell orders stay live until market close.
### Orderbook Sniper Settings
**3-Tier Strategy:** Places orders at 3 price levels with weighted sizing
| 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
# Simulate maker MM — no real orders placed
npm run maker-mm-bot-sim
# Copy Trade Bot
npm start # live trading
npm run dev # live + auto-reload on file changes
# Run live maker MM (recommended starting config)
MAKER_MM_TRADE_SIZE=5 MAKER_MM_REENTRY_DELAY=30 npm run maker-mm-bot
# Market Maker Bot
npm run mm # live trading
npm run mm-sim # simulation (DRY_RUN=true)
npm run mm-dev # simulation + auto-reload
# Simulate copy trader
npm run bot-sim
# Market Maker v2 (Maker) Bot
npm run maker # live trading
npm run maker-sim # simulation
npm run maker-dev # simulation + auto-reload
# Run live copy trader
npm run bot
# Simulate orderbook sniper
npm run sniper-sim
# Run live sniper
npm run sniper
# Orderbook Sniper Bot
npm run sniper # live trading
npm run sniper-sim # simulation
npm run sniper-dev # simulation + auto-reload
```
### 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 v1 Bot
npm run mm-bot # live trading
npm run mm-bot-sim # simulation
npm run mm-bot-dev # simulation + auto-reload
# Market Maker v2 (Maker) Bot
npm run maker-bot # live trading
npm run maker-bot-sim # simulation
npm run maker-bot-dev # simulation + auto-reload
```
> **Always test with `DRY_RUN=true` (or `*-sim` scripts) first** before committing real funds.
---
## Running with PM2 (recommended for VPS)
## Running on VPS with PM2
Each bot has its own PM2 config file inside the `pm2/` folder.
### Install PM2
```bash
npm install -g pm2
```
# Start maker MM
pm2 start src/maker-mm-bot.js --name polymarket-maker-mm --interpreter node
### Copy Trade Bot
# Start copy trader
pm2 start src/bot.js --name polymarket-bot --interpreter node
```bash
# Live trading
pm2 start pm2/copy.config.cjs
# Simulation
pm2 start pm2/copy.config.cjs --env sim
# View logs
pm2 logs polymarket-maker-mm
pm2 logs polymarket-bot
pm2 logs polymarket-copy
tail -f logs/copy-out.log
# Management
pm2 restart polymarket-copy
pm2 stop polymarket-copy
pm2 delete polymarket-copy
```
### Market Maker Bot
```bash
# Live trading
pm2 start pm2/mm.config.cjs
# Simulation
pm2 start pm2/mm.config.cjs --env sim
# View logs
pm2 logs polymarket-mm
tail -f logs/mm-out.log
# Management
pm2 restart polymarket-mm
pm2 stop polymarket-mm
pm2 delete polymarket-mm
```
### Auto-start on reboot
```bash
pm2 startup # generates a startup command — run the command it prints
pm2 save # saves current process list
```
---
## How It Works
### Copy Trade Bot Flow
```
WebSocket (RTDS) — real-time trade events from trader
Per-market queue (prevents concurrent duplicate buys)
┌────┴──────┐
│ │
BUY SELL
│ │
├─ Expiry guard (MIN_MARKET_TIME_LEFT)
├─ Max position cap ├─ Cancel open orders
├─ FAK market buy ├─ Reconcile on-chain balance
│ └─ 0 fill? → GTC fallback ├─ FAK market sell / limit sell
├─ Place auto-sell GTC └─ Remove position
└─ Save position
Redeemer loop (every REDEEM_INTERVAL seconds)
→ Check on-chain payout → redeemPositions via Gnosis Safe
```
### Market Maker v1 Flow (Split Position)
```
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)
```
### Market Maker v2 Flow (Buy Low, Sell High)
```
Detector polls all asset × duration combos (e.g. BTC/5m, ETH/15m)
Place limit BUY on UP + DOWN @ MAKER_BUY_PRICE (e.g. $0.02)
Monitor both sides concurrently
┌────┴─────────────────┐
│ │
One side fills CL at 10s before close
│ │
▼ ▼
Cancel other buy Cancel unfilled buys
Place SELL @ 3c Place SELL for any fills (retry 3x)
│ │
└──────┬───────────────┘
Monitor sells until market close
┌────┴────┐
│ │
Sell fills Market closes
│ │
▼ ▼
WIN $ Tokens resolve on-chain
```
---
@@ -149,54 +381,73 @@ pm2 logs polymarket-bot
## Project Structure
```
src/
├── maker-mm-bot.js # Maker Rebate MM — PM2/VPS entry point
├── maker-mm.js # Maker Rebate MM — TUI entry point
├── bot.js # Copy Trader
├── sniper.js # Orderbook Sniper
├── mm-bot.js # Classic MM (legacy)
├── config/
── index.js # All configuration with env var mapping
└── services/
├── makerRebateExecutor.js # Core maker MM logic (orders, fills, merge)
├── mmDetector.js # Market discovery and scheduling
├── mmWsFillWatcher.js # WebSocket RTDS real-time fill detection
├── ctf.js # CTF contract interaction (merge/redeem)
── client.js # Polymarket CLOB client wrapper
polymarket-terminal/
├── src/
│ ├── index.js — Copy trade bot (TUI)
├── bot.js Copy trade bot (plain log / PM2)
│ ├── mm.js — Market maker v1 bot (TUI)
├── mm-bot.js — Market maker v1 bot (plain log / PM2)
│ ├── maker.js — Market maker v2 bot (TUI)
── maker-bot.js — Market maker v2 bot (plain log / PM2)
│ ├── sniper.js — Orderbook sniper bot
│ │
├── config/
│ └── index.js — Environment variable loading & validation
│ │
── services/
│ │ ├── client.js — CLOB client initialization & USDC balance
│ │ ├── watcher.js — Poll-based trader activity detection
│ │ ├── wsWatcher.js — WebSocket real-time trade listener
│ │ ├── executor.js — Buy & sell order execution logic
│ │ ├── position.js — Position state management (CRUD)
│ │ ├── autoSell.js — Auto limit-sell placement
│ │ ├── redeemer.js — Market resolution check & CTF redemption
│ │ ├── ctf.js — On-chain CTF contract interactions
│ │ ├── mmDetector.js — Market detection for MM v1
│ │ ├── mmExecutor.js — MM v1 strategy execution
│ │ ├── makerDetector.js — Market detection for Maker v2 (multi-duration)
│ │ ├── makerExecutor.js — Maker v2 strategy execution
│ │ ├── makerWs.js — Orderbook WebSocket for Maker v2
│ │ ├── sniperDetector.js — Market detection for sniper
│ │ └── sniperExecutor.js — Orderbook sniper order placement
│ │
│ ├── 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
```
---
## How Maker Rebate Works on Polymarket
## Important Warnings
Polymarket's CLOB gives **maker rebates** to traders who post limit orders, while takers pay a fee. This terminal exploits that by:
1. Simultaneously posting BUY limit orders on both YES and NO of a binary market
2. Since YES + NO always resolve to $1.00 (exactly one wins), buying both at combined cost < $1.00 guarantees a profit on merge
3. The position is closed by merging the token pair back into USDC via Polymarket's CTF contract — not by holding to resolution
This strategy is **market-neutral** and **direction-agnostic**. Profitability depends on fill rate and spread capture, not on predicting BTC price direction.
- **Never commit your `.env` file.** Your private key must remain secret. The `.gitignore` already excludes it.
- **Always start with `DRY_RUN=true`** (or a `*-sim` script) to verify the bot behaves as expected before using real funds.
- **Use a small `SIZE_PERCENT`** for initial live runs to limit exposure.
- **Keep MATIC in your EOA wallet** for gas fees (redeem operations and on-chain CTF calls).
- **This software is provided as-is, with no guarantees.** Prediction market trading carries significant financial risk. You are solely responsible for any losses.
---
## Risk Management
## Credits
- **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%)
Built and maintained by **[@direkturcrypto](https://twitter.com/direkturcrypto)**.
---
## License
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.
ISC License — see [LICENSE](LICENSE) for details.
-1
View File
@@ -7,7 +7,6 @@
"": {
"name": "polymarket-terminal",
"version": "1.0.0",
"hasInstallScript": true,
"license": "ISC",
"dependencies": {
"@polymarket/clob-client": "^4.7.3",
+6 -5
View File
@@ -22,11 +22,12 @@
"sniper-tui": "DRY_RUN=false node src/sniper-tui.js",
"sniper-tui-sim": "DRY_RUN=true node src/sniper-tui.js",
"sniper-tui-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/sniper-tui.js",
"maker-mm": "DRY_RUN=false node src/maker-mm.js",
"maker-mm-sim": "DRY_RUN=true node src/maker-mm.js",
"maker-mm-bot": "node src/maker-mm-bot.js",
"maker-mm-bot-sim": "DRY_RUN=true node src/maker-mm-bot.js",
"postinstall": "node scripts/patch-clob-client.cjs"
"maker": "DRY_RUN=false node src/maker.js",
"maker-sim": "DRY_RUN=true node src/maker.js",
"maker-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/maker.js",
"maker-bot": "node src/maker-bot.js",
"maker-bot-sim": "DRY_RUN=true node src/maker-bot.js",
"maker-bot-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/maker-bot.js"
},
"keywords": [
"polymarket",
-114
View File
@@ -1,114 +0,0 @@
/**
* patch-clob-client.cjs
*
* Patches @polymarket/clob-client to inject proxy support.
* Runs automatically via `npm install` (postinstall hook).
*
* What it does:
* - Adds HttpsProxyAgent import to http-helpers/index.js
* - Registers an axios interceptor that injects the proxy agent
* into every request to polymarket.com
* - Reads PROXY_URL from process.env at runtime
*/
const fs = require('fs');
const path = require('path');
const TARGET = path.join(
__dirname,
'..',
'node_modules',
'@polymarket',
'clob-client',
'dist',
'http-helpers',
'index.js',
);
if (!fs.existsSync(TARGET)) {
console.log('[patch] @polymarket/clob-client not found — skipping');
process.exit(0);
}
let code = fs.readFileSync(TARGET, 'utf8');
// Check if proxy support is already patched
const proxyAlreadyPatched = code.includes('getProxyAgent');
// Check if the JSON.stringify circular-ref fix is already applied
const jsonFixAlreadyPatched = !code.includes('config: (_d = err.response)');
// ── 1. Proxy interceptor ─────────────────────────────────────────────────────
if (!proxyAlreadyPatched) {
const PATCH_CODE = `
// ── Proxy support (auto-patched by scripts/patch-clob-client.cjs) ──────────
const https_proxy_agent_1 = require("https-proxy-agent");
let _cachedProxyAgent = null;
const getProxyAgent = () => {
if (!process.env.PROXY_URL) return undefined;
if (!_cachedProxyAgent) {
_cachedProxyAgent = new https_proxy_agent_1.HttpsProxyAgent(process.env.PROXY_URL);
}
return _cachedProxyAgent;
};
// Intercept all axios requests — inject proxy agent for polymarket.com
axios_1.default.interceptors.request.use(function(cfg) {
if (cfg.url && cfg.url.includes('polymarket.com')) {
var agent = getProxyAgent();
if (agent) {
cfg.httpsAgent = agent;
cfg.httpAgent = agent;
cfg.proxy = false;
}
}
return cfg;
});
// ── End proxy patch ────────────────────────────────────────────────────────
`;
const axiosPatterns = [
/tslib_1\.__importDefault\s*\(\s*require\s*\(\s*["']axios["']\s*\)\s*\)\s*;/,
/require\s*\(\s*["']axios["']\s*\)\s*;/,
];
let injected = false;
for (const pattern of axiosPatterns) {
const match = code.match(pattern);
if (match) {
code = code.replace(match[0], match[0] + PATCH_CODE);
console.log('[patch] Injected proxy interceptor after axios import');
injected = true;
break;
}
}
if (!injected) {
console.error('[patch] Could not find axios import — skipping proxy patch');
}
} else {
console.log('[patch] Proxy support already present — skipping');
}
// ── 2. Fix errorHandling circular JSON ───────────────────────────────────────
// JSON.stringify(err.response.config) includes httpsAgent (from proxy) which
// has circular/deep refs and causes "Maximum call stack size exceeded".
// Replace with a simple log that only serializes the response data.
if (!jsonFixAlreadyPatched) {
const OLD_LOG = `console.error("[CLOB Client] request error", JSON.stringify({
status: (_a = err.response) === null || _a === void 0 ? void 0 : _a.status,
statusText: (_b = err.response) === null || _b === void 0 ? void 0 : _b.statusText,
data: (_c = err.response) === null || _c === void 0 ? void 0 : _c.data,
config: (_d = err.response) === null || _d === void 0 ? void 0 : _d.config,
}));`;
const NEW_LOG = `// config excluded — contains httpsAgent circular refs (stack overflow)
console.error("[CLOB Client] request error:", (_a = err.response) === null || _a === void 0 ? void 0 : _a.status, JSON.stringify((_b = err.response) === null || _b === void 0 ? void 0 : _b.data));`;
if (code.includes(OLD_LOG)) {
code = code.replace(OLD_LOG, NEW_LOG);
console.log('[patch] Fixed errorHandling circular JSON.stringify');
} else {
console.warn('[patch] Could not find errorHandling JSON.stringify — skipping (already fixed or SDK changed)');
}
} else {
console.log('[patch] errorHandling JSON fix already applied — skipping');
}
fs.writeFileSync(TARGET, code, 'utf8');
console.log('[patch] @polymarket/clob-client patched ✅');
+22 -61
View File
@@ -67,14 +67,6 @@ const config = {
mmAdaptiveMinCombined: parseFloat(process.env.MM_ADAPTIVE_MIN_COMBINED || '1.20'), // min combined sell (both legs) to qualify for limit
mmAdaptiveMonitorSec: parseInt(process.env.MM_ADAPTIVE_MONITOR_SEC || '5', 10),
// ── Defensive Pivot (5m markets only) ─────────────────────────
// When NEITHER side fills within timeout, enter defensive mode:
// At 30s before close, if worst side < threshold → market sell worst, keep best
// Otherwise merge back to USDC (zero P&L)
mmDefensiveEnabled: process.env.MM_DEFENSIVE_ENABLED !== 'false', // default on
mmDefensiveTimeout: parseInt(process.env.MM_DEFENSIVE_TIMEOUT || '120', 10), // secs without fill → defensive
mmDefensiveWorstThreshold: parseFloat(process.env.MM_DEFENSIVE_WORST_THRESHOLD || '0.10'), // sell worst if price < this
// ── Recovery Buy (after cut-loss) ─────────────────────────────
// When enabled: after cutting loss, monitor prices for 10s and
// market-buy the dominant side if it's above threshold and rising/stable.
@@ -82,36 +74,6 @@ const config = {
mmRecoveryThreshold: parseFloat(process.env.MM_RECOVERY_THRESHOLD || '0.70'), // min price to qualify
mmRecoverySize: parseFloat(process.env.MM_RECOVERY_SIZE || '0'), // 0 = use mmTradeSize
// ── Maker Rebate MM ────────────────────────────────────────────
// Buy YES+NO at top bid (maker), merge back to USDC ($1.00).
// Profit = spread + maker rebate fees.
makerMmAssets: (process.env.MAKER_MM_ASSETS || process.env.MM_ASSETS || 'btc')
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean),
makerMmDuration: process.env.MAKER_MM_DURATION || process.env.MM_DURATION || '5m',
makerMmTradeSize: parseFloat(process.env.MAKER_MM_TRADE_SIZE || '5'), // USDC per side
makerMmMaxCombined: parseFloat(process.env.MAKER_MM_MAX_COMBINED || '0.99'), // max bid_YES + bid_NO
makerMmRepriceSec: parseInt(process.env.MAKER_MM_REPRICE_SEC || '3', 10), // orderbook poll interval
makerMmFillTimeout: parseInt(process.env.MAKER_MM_FILL_TIMEOUT || '120', 10), // secs for 2nd fill after 1st
makerMmCutLossTime: parseInt(process.env.MAKER_MM_CUT_LOSS_TIME || '60', 10), // secs before close to force exit
makerMmEntryWindow: parseInt(process.env.MAKER_MM_ENTRY_WINDOW || '45', 10), // max secs after open to enter
makerMmPollInterval: parseInt(process.env.MAKER_MM_POLL_INTERVAL || process.env.MM_POLL_INTERVAL || '5', 10) * 1000,
makerMmReentryDelay: parseInt(process.env.MAKER_MM_REENTRY_DELAY || '30', 10) * 1000, // ms delay between re-entry cycles
makerMmReentryEnabled: process.env.MAKER_MM_REENTRY_ENABLED !== 'false', // set false to disable re-entry (one cycle per market)
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)
// When true: if expensive side fills first, cancel cheap side and hold to redemption
makerMmCancelCheapOnExpFill: process.env.MAKER_MM_CANCEL_CHEAP_ON_EXP_FILL === 'true',
makerMmPollSec: parseInt(process.env.MAKER_MM_POLL_SEC || '3', 10),
// ── Current Market Settings ────────────────────────────────────
// Enable trading on current active market (not just next market)
currentMarketEnabled: process.env.CURRENT_MARKET_ENABLED === 'true',
// Max odds threshold for current market (stop re-entry if odds drop below this)
currentMarketMaxOdds: parseFloat(process.env.CURRENT_MARKET_MAX_ODDS || '0.70'),
// Max odds threshold for next market (only enter if max odds <= this)
nextMarketMaxOdds: parseFloat(process.env.NEXT_MARKET_MAX_ODDS || '0.52'),
// ── Orderbook Sniper ───────────────────────────────────────────
// 3-tier strategy: places GTC limit BUY orders at 3c, 2c, and 1c
// Tier 1 (3c): smallest size | Tier 2 (2c): medium size | Tier 3 (1c): largest size
@@ -126,24 +88,6 @@ const config = {
sniperMaxShares: parseFloat(process.env.SNIPER_MAX_SHARES || '15'), // max total per side
sniperMinSharesPerTier: 5, // minimum shares for each tier
// ── Sniper Sizing Multiplier (UTC+8) ──────────────────────────
// Time-based bet sizing multiplier. Format: HH:MM-HH:MM:factor,...
// Example: SNIPER_MULTIPLIERS=21:00-00:00:1.41,06:00-12:00:0.85
// Default multiplier outside any window = 1.0
sniperMultipliers: (() => {
const raw = process.env.SNIPER_MULTIPLIERS || '';
if (!raw.trim()) return [];
return raw.split(',').map((s) => s.trim()).filter(Boolean).map((entry) => {
const m = entry.match(/^(\d{1,2}:\d{2})\s*[-]\s*(\d{1,2}:\d{2}):(\d+\.?\d*)$/);
if (!m) return null;
return { start: m[1], end: m[2], multiplier: parseFloat(m[3]) };
}).filter(Boolean);
})(),
// ── Sniper Pause After Win ───────────────────────────────────
// Number of rounds (5-min slots) to pause an asset after a win is detected.
sniperPauseRoundsAfterWin: parseInt(process.env.SNIPER_PAUSE_ROUNDS_AFTER_WIN || '3', 10),
// ── Sniper Schedule (UTC+8) ────────────────────────────────────
// Per-asset session windows. Format: SNIPER_SCHEDULE_{ASSET}=HH:MM-HH:MM,HH:MM-HH:MM
// Assets without a schedule are always active.
@@ -159,6 +103,20 @@ const config = {
return schedule;
})(),
// ── Market Maker v2 (Buy Low, Sell High) ──────────────────────
// Places limit BUY on UP+DOWN at low price, sells at higher price when filled.
// No splitPosition — pure orderbook strategy.
makerAssets: (process.env.MAKER_ASSETS || 'btc')
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean),
makerDurations: (process.env.MAKER_DURATION || '5m')
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean),
makerBuyPrice: parseFloat(process.env.MAKER_BUY_PRICE || '0.02'),
makerSellPrice: parseFloat(process.env.MAKER_SELL_PRICE || '0.03'),
makerTradeSize: parseFloat(process.env.MAKER_TRADE_SIZE || '50'), // shares per side
makerSimBalance: parseFloat(process.env.MAKER_SIM_BALANCE || '50'), // starting sim balance
makerPollInterval: parseInt(process.env.MAKER_POLL_INTERVAL || '10', 10) * 1000,
makerMonitorMs: parseInt(process.env.MAKER_MONITOR_MS || '2000', 10), // how fast to poll order status
// ── Proxy (Polymarket API only, NOT Polygon RPC) ──────────────
// Supports HTTP/HTTPS. Example: http://user:pass@host:port
proxyUrl: process.env.PROXY_URL || '',
@@ -191,16 +149,19 @@ export function validateMMConfig() {
throw new Error('MM_SELL_PRICE must be between 0 and 1');
}
// Validation for maker-rebate MM bot
export function validateMakerMMConfig() {
// Validation for maker bot (buy low, sell high)
export function validateMakerConfig() {
const required = ['privateKey', 'proxyWallet'];
const missing = required.filter((key) => !config[key]);
if (missing.length > 0) {
throw new Error(`Missing required config: ${missing.join(', ')}. Check your .env file.`);
}
if (config.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');
if (config.makerBuyPrice <= 0 || config.makerBuyPrice >= 1)
throw new Error('MAKER_BUY_PRICE must be between 0 and 1');
if (config.makerSellPrice <= config.makerBuyPrice)
throw new Error('MAKER_SELL_PRICE must be greater than MAKER_BUY_PRICE');
if (config.makerTradeSize < 5)
throw new Error('MAKER_TRADE_SIZE must be >= 5 (Polymarket minimum order size)');
}
export default config;
+183
View File
@@ -0,0 +1,183 @@
/**
* maker-bot.js — Buy Low, Sell High Market Maker, PM2 / VPS entry point (no TUI)
*
* Plain-text stdout output, compatible with:
* pm2 start ecosystem.config.cjs --only polymarket-maker
* pm2 logs polymarket-maker
*/
import './utils/proxy-patch.cjs';
import { validateMakerConfig } from './config/index.js';
import config from './config/index.js';
import logger from './utils/logger.js';
import { initClient, getUsdcBalance } from './services/client.js';
import { startMakerDetector, stopMakerDetector } from './services/makerDetector.js';
import { executeMakerStrategy, getActiveMakerPositions, getSimStats } from './services/makerExecutor.js';
import { OrderbookWs } from './services/makerWs.js';
logger.interceptConsole();
// ── Validate config ────────────────────────────────────────────────────────────
try {
validateMakerConfig();
} catch (err) {
logger.error(`Config error: ${err.message}`);
process.exit(1);
}
// ── Init CLOB client ──────────────────────────────────────────────────────────
try {
await initClient();
} catch (err) {
logger.error(`Client init error: ${err.message}`);
process.exit(1);
}
// ── WebSocket orderbook (for sim mode) ───────────────────────────────────────
const orderbookWs = new OrderbookWs();
// ── Periodic status log ──────────────────────────────────────────────────────
async function printStatus() {
try {
let balanceStr = 'SIM';
if (!config.dryRun) {
try { balanceStr = `$${(await getUsdcBalance()).toFixed(2)} USDC`; } catch { balanceStr = 'N/A'; }
}
const positions = getActiveMakerPositions();
const mode = config.dryRun ? 'SIMULATION' : 'LIVE';
if (config.dryRun) {
const s = getSimStats();
const winRate = s.wins + s.losses > 0 ? ((s.wins / (s.wins + s.losses)) * 100).toFixed(1) : '0.0';
const pnlSign = s.cumulativePnl >= 0 ? '+' : '';
logger.info(`--- MAKER [SIM] | $${s.balance.toFixed(2)} (${pnlSign}$${s.cumulativePnl.toFixed(4)}) | W:${s.wins} L:${s.losses} S:${s.skips} Win%:${winRate}% | Active: ${positions.length} ---`);
} else {
logger.info(`--- MAKER Status [${mode}] | Balance: ${balanceStr} | Active: ${positions.length} ---`);
}
for (const pos of positions) {
const assetTag = pos.asset ? `[${pos.asset.toUpperCase()}] ` : '';
const label = pos.question.substring(0, 50);
const msLeft = new Date(pos.endTime).getTime() - Date.now();
const secsLeft = Math.max(0, Math.round(msLeft / 1000));
const timeStr = secsLeft > 60 ? `${Math.floor(secsLeft / 60)}m${secsLeft % 60}s` : `${secsLeft}s`;
const pnl = pos.totalRevenue - pos.totalCost;
const sign = pnl >= 0 ? '+' : '';
logger.info(
` ${assetTag}${label} | ${pos.status} | ${timeStr} left` +
` | UP: ${pos.up.buyFilled.toFixed(1)}sh bought, ${pos.up.totalSellFilled.toFixed(1)}sh sold` +
` | DOWN: ${pos.down.buyFilled.toFixed(1)}sh bought, ${pos.down.totalSellFilled.toFixed(1)}sh sold` +
` | P&L: ${sign}$${pnl.toFixed(4)}`,
);
// Orderbook snapshot
for (const [label, tokenId] of [['UP', pos.up.tokenId], ['DOWN', pos.down.tokenId]]) {
const bestBid = orderbookWs.getBestBid(tokenId);
const bestAsk = orderbookWs.getBestAsk(tokenId);
if (bestBid || bestAsk) {
const book = orderbookWs.getBook(tokenId);
const bidDepth = book.bids.slice(0, 3).map(b => `${b.price.toFixed(2)}×${b.size.toFixed(0)}`).join(' ');
const askDepth = book.asks.slice(0, 3).map(a => `${a.price.toFixed(2)}×${a.size.toFixed(0)}`).join(' ');
logger.info(` ${label}: [${bidDepth}] | [${askDepth}]`);
}
}
}
} catch (err) {
logger.warn(`Status check error: ${err.message}`);
}
}
// ── Market handler with per-asset queue ──────────────────────────────────────
const pendingByAsset = new Map();
function slotKey(market) {
return `${market.asset}-${market.duration || '5m'}`;
}
async function runStrategy(market) {
const key = slotKey(market);
const tag = `${market.asset?.toUpperCase()}/${market.duration || '5m'}`;
if (config.dryRun) {
orderbookWs.subscribe(market.conditionId, [market.yesTokenId, market.noTokenId]);
}
try {
await executeMakerStrategy(market);
} catch (err) {
logger.error(`MAKER strategy error (${tag}): ${err.message}`);
}
const queued = pendingByAsset.get(key);
if (queued) {
pendingByAsset.delete(key);
const secsLeft = Math.round((new Date(queued.endTime).getTime() - Date.now()) / 1000);
if (secsLeft > 30) {
logger.success(`MAKER[${tag}]: executing queued market (${secsLeft}s left)`);
runStrategy(queued);
} else {
logger.warn(`MAKER[${tag}]: queued market expired (${secsLeft}s left)`);
}
}
}
async function handleNewMarket(market) {
const key = slotKey(market);
const tag = `${market.asset?.toUpperCase()}/${market.duration || '5m'}`;
const active = getActiveMakerPositions();
const isSlotBusy = active.some((p) => p.asset === market.asset && p.duration === (market.duration || '5m'));
if (isSlotBusy) {
pendingByAsset.set(key, market);
logger.warn(`MAKER[${tag}]: queued — will enter after current position clears`);
return;
}
runStrategy(market);
}
// ── Timers ───────────────────────────────────────────────────────────────────
const statusTimer = setInterval(printStatus, 60_000);
// ── Graceful shutdown ─────────────────────────────────────────────────────────
function shutdown() {
logger.warn('MAKER: shutting down...');
stopMakerDetector();
orderbookWs.shutdown();
clearInterval(statusTimer);
setTimeout(() => process.exit(0), 300);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
// ── Start ─────────────────────────────────────────────────────────────────────
const mode = config.dryRun ? 'SIMULATION' : 'LIVE';
const costPerSide = config.makerTradeSize * config.makerBuyPrice;
const profitPerCycle = (config.makerSellPrice - config.makerBuyPrice) * config.makerTradeSize;
logger.info(`=== Market Maker v2 [${mode}] ===`);
logger.info(`Assets : ${config.makerAssets.join(', ').toUpperCase()}`);
logger.info(`Duration : ${config.makerDurations.join(', ')}`);
logger.info(`Buy @ : $${config.makerBuyPrice} per share`);
logger.info(`Sell @ : $${config.makerSellPrice} per share`);
logger.info(`Size : ${config.makerTradeSize} shares/side`);
logger.info(`Cost/side : $${costPerSide.toFixed(2)}`);
logger.info(`Profit : $${profitPerCycle.toFixed(2)} per cycle`);
logger.info(`CL : cancel buys at 10s, sell until close`);
logger.info('==========================================');
startMakerDetector(handleNewMarket);
logger.success(`MAKER bot started — watching for ${config.makerDurations.join('+')} ${config.makerAssets.join('/')} markets...`);
-290
View File
@@ -1,290 +0,0 @@
/**
* 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 (config.makerMmReentryEnabled && 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
@@ -1,219 +0,0 @@
/**
* 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);
+281
View File
@@ -0,0 +1,281 @@
/**
* maker.js
* TUI version — Buy Low, Sell High Market Maker (blessed dashboard).
*
* Strategy: Place limit BUY on UP+DOWN at low price, sell at target when filled.
* No splitPosition — pure orderbook-based market making.
*
* Run with: npm run maker (live)
* npm run maker-sim (simulation with real orderbook via WebSocket)
*/
import './utils/proxy-patch.cjs';
import { validateMakerConfig } from './config/index.js';
import config from './config/index.js';
import logger from './utils/logger.js';
import { initClient, getUsdcBalance } from './services/client.js';
import { initDashboard, appendLog, updateStatus, isDashboardActive } from './ui/dashboard.js';
import { startMakerDetector, stopMakerDetector } from './services/makerDetector.js';
import { executeMakerStrategy, getActiveMakerPositions, getSimStats } from './services/makerExecutor.js';
import { OrderbookWs } from './services/makerWs.js';
// ── Validate config ────────────────────────────────────────────────────────────
try {
validateMakerConfig();
} catch (err) {
console.error(`Config error: ${err.message}`);
process.exit(1);
}
// ── Init TUI ──────────────────────────────────────────────────────────────────
initDashboard();
logger.setOutput(appendLog);
// ── Init CLOB client ──────────────────────────────────────────────────────────
try {
await initClient();
} catch (err) {
logger.error(`Client init error: ${err.message}`);
process.exit(1);
}
// ── WebSocket orderbook (for sim visualization) ──────────────────────────────
const orderbookWs = new OrderbookWs();
let activeWsTokens = { up: null, down: null };
// ── Status panel refresh ──────────────────────────────────────────────────────
async function buildStatusContent() {
const lines = [];
// Balance + Sim Stats
if (config.dryRun) {
const s = getSimStats();
const pnlColor = s.cumulativePnl >= 0 ? 'green' : 'red';
const winRate = s.wins + s.losses > 0 ? ((s.wins / (s.wins + s.losses)) * 100).toFixed(1) : '0.0';
const pnlSign = s.cumulativePnl >= 0 ? '+' : '';
lines.push('{bold}SIMULATION{/bold}');
lines.push(` Balance : {green-fg}$${s.balance.toFixed(2)}{/green-fg} (start: $${s.startBalance.toFixed(2)})`);
lines.push(` PnL : {${pnlColor}-fg}${pnlSign}$${s.cumulativePnl.toFixed(4)}{/${pnlColor}-fg}`);
lines.push(` Trades : ${s.totalTrades} total`);
lines.push(` {green-fg}WIN ${s.wins}x{/green-fg} | {red-fg}LOSS ${s.losses}x{/red-fg} | {gray-fg}SKIP ${s.skips}x{/gray-fg}`);
lines.push(` Win% : ${winRate}%`);
lines.push('');
// Recent trade history
if (s.history.length > 0) {
lines.push('{bold}TRADE HISTORY{/bold}');
const recent = s.history.slice(-8);
for (const h of recent) {
const rColor = h.result === 'win' ? 'green' : h.result === 'loss' ? 'red' : 'gray';
const pSign = h.pnl >= 0 ? '+' : '';
lines.push(` {gray-fg}${h.time}{/gray-fg} {${rColor}-fg}${h.result.toUpperCase().padEnd(4)}{/${rColor}-fg} ${(h.side || '-').padEnd(4)} {${rColor}-fg}${pSign}$${h.pnl.toFixed(4)}{/${rColor}-fg} → $${h.balance.toFixed(2)}`);
}
lines.push('');
}
} else {
let balance = '?';
try { balance = (await getUsdcBalance()).toFixed(2); } catch { /* ignore */ }
lines.push('{bold}BALANCE{/bold}');
lines.push(` USDC.e: {green-fg}$${balance}{/green-fg}`);
lines.push('');
lines.push('{bold}MODE{/bold}');
lines.push(' {green-fg}LIVE{/green-fg}');
lines.push('');
}
// Maker Config
lines.push('{bold}MAKER CONFIG{/bold}');
lines.push(` Assets : ${config.makerAssets.join(', ').toUpperCase()}`);
lines.push(` Duration : ${config.makerDurations.join(', ')}`);
lines.push(` Buy @ : $${config.makerBuyPrice} per share`);
lines.push(` Sell @ : $${config.makerSellPrice} per share`);
lines.push(` Size : ${config.makerTradeSize} shares/side`);
lines.push(` Cost/side: $${(config.makerTradeSize * config.makerBuyPrice).toFixed(2)}`);
lines.push(` Profit : $${((config.makerSellPrice - config.makerBuyPrice) * config.makerTradeSize).toFixed(2)}/cycle`);
lines.push(` CL : cancel buys at ${config.makerCLSeconds || 10}s, sell until 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()}/${pos.duration || '5m'}] ` : '';
const label = pos.question.substring(0, 32);
const msLeft = new Date(pos.endTime).getTime() - Date.now();
const secsLeft = Math.max(0, Math.round(msLeft / 1000));
const timeStr = secsLeft > 60
? `${Math.floor(secsLeft / 60)}m${secsLeft % 60}s`
: `{red-fg}${secsLeft}s{/red-fg}`;
lines.push(` {cyan-fg}${assetTag}${label}{/cyan-fg}`);
lines.push(` Status : ${pos.status} | Time left: ${timeStr}`);
// UP side
const upFill = pos.up.buyFilled > 0
? `{green-fg}BOUGHT ${pos.up.buyFilled.toFixed(1)}sh{/green-fg}`
: '{gray-fg}waiting...{/gray-fg}';
const upSold = pos.up.totalSellFilled > 0
? ` → {green-fg}SOLD ${pos.up.totalSellFilled.toFixed(1)}sh{/green-fg}`
: pos.up.sellOrders.length > 0 ? ' → {yellow-fg}selling...{/yellow-fg}' : '';
lines.push(` UP ${upFill}${upSold}`);
// DOWN side
const downFill = pos.down.buyFilled > 0
? `{green-fg}BOUGHT ${pos.down.buyFilled.toFixed(1)}sh{/green-fg}`
: '{gray-fg}waiting...{/gray-fg}';
const downSold = pos.down.totalSellFilled > 0
? ` → {green-fg}SOLD ${pos.down.totalSellFilled.toFixed(1)}sh{/green-fg}`
: pos.down.sellOrders.length > 0 ? ' → {yellow-fg}selling...{/yellow-fg}' : '';
lines.push(` DOWN ${downFill}${downSold}`);
// P&L
const pnl = pos.totalRevenue - pos.totalCost;
const pnlColor = pnl >= 0 ? 'green' : 'red';
lines.push(` P&L: {${pnlColor}-fg}$${pnl.toFixed(4)}{/${pnlColor}-fg}`);
lines.push('');
}
}
// Orderbook display (always show when tokens are active)
if (activeWsTokens.up) {
lines.push('{bold}LIVE ORDERBOOK{/bold}');
for (const [label, tokenId] of [['UP', activeWsTokens.up], ['DOWN', activeWsTokens.down]]) {
if (!tokenId) continue;
const book = orderbookWs.getBook(tokenId);
const bestBid = orderbookWs.getBestBid(tokenId);
const bestAsk = orderbookWs.getBestAsk(tokenId);
const mid = bestBid && bestAsk ? ((bestBid + bestAsk) / 2) : 0;
lines.push(` {cyan-fg}${label}{/cyan-fg} mid: $${mid.toFixed(3)} | bid: $${bestBid.toFixed(2)} ask: $${bestAsk.toFixed(2)}`);
// Top 5 asks (reversed so lowest is closest to spread)
const topAsks = book.asks.slice(0, 5).reverse();
for (const ask of topAsks) {
const bar = '█'.repeat(Math.min(10, Math.round(ask.size / 100)));
lines.push(` {red-fg}$${ask.price.toFixed(2)} ${ask.size.toFixed(0).padStart(7)} ${bar}{/red-fg}`);
}
// Spread line
if (bestBid && bestAsk) {
const spread = bestAsk - bestBid;
lines.push(` {yellow-fg}── spread $${spread.toFixed(2)} ──{/yellow-fg}`);
}
// Top 5 bids
const topBids = book.bids.slice(0, 5);
for (const bid of topBids) {
const bar = '█'.repeat(Math.min(10, Math.round(bid.size / 100)));
lines.push(` {green-fg}$${bid.price.toFixed(2)} ${bid.size.toFixed(0).padStart(7)} ${bar}{/green-fg}`);
}
lines.push('');
}
}
return '\n' + lines.join('\n');
}
let refreshTimer = null;
function startRefresh() {
refreshTimer = setInterval(async () => {
if (!isDashboardActive()) return;
updateStatus(await buildStatusContent());
}, 2000);
buildStatusContent().then(updateStatus);
}
// ── Market handler with per-asset queue ──────────────────────────────────────
const pendingByAsset = new Map();
function slotKey(market) {
return `${market.asset}-${market.duration || '5m'}`;
}
async function runStrategy(market) {
const key = slotKey(market);
const tag = `${market.asset?.toUpperCase()}/${market.duration || '5m'}`;
// Connect WebSocket for orderbook visualization in sim mode
if (config.dryRun) {
activeWsTokens = { up: market.yesTokenId, down: market.noTokenId };
orderbookWs.subscribe(market.conditionId, [market.yesTokenId, market.noTokenId]);
}
try {
await executeMakerStrategy(market);
} catch (err) {
logger.error(`MAKER strategy error (${tag}): ${err.message}`);
}
// Disconnect WS after strategy ends
if (config.dryRun) {
activeWsTokens = { up: null, down: null };
}
// Process queued market
const queued = pendingByAsset.get(key);
if (queued) {
pendingByAsset.delete(key);
const endMs = new Date(queued.endTime).getTime();
const secsLeft = Math.round((endMs - Date.now()) / 1000);
if (secsLeft > 30) {
logger.success(`MAKER[${tag}]: executing queued market (${secsLeft}s left)`);
runStrategy(queued);
} else {
logger.warn(`MAKER[${tag}]: queued market expired (${secsLeft}s left)`);
}
}
}
async function handleNewMarket(market) {
const key = slotKey(market);
const tag = `${market.asset?.toUpperCase()}/${market.duration || '5m'}`;
const active = getActiveMakerPositions();
const isSlotBusy = active.some((p) => p.asset === market.asset && p.duration === (market.duration || '5m'));
if (isSlotBusy) {
pendingByAsset.set(key, market);
logger.warn(`MAKER[${tag}]: queued — will enter after current position clears`);
return;
}
runStrategy(market);
}
// ── Graceful shutdown ─────────────────────────────────────────────────────────
function shutdown() {
logger.warn('MAKER: shutting down...');
stopMakerDetector();
orderbookWs.shutdown();
if (refreshTimer) clearInterval(refreshTimer);
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
// ── Start ─────────────────────────────────────────────────────────────────────
const costPerSide = config.makerTradeSize * config.makerBuyPrice;
const profitPerCycle = (config.makerSellPrice - config.makerBuyPrice) * config.makerTradeSize;
logger.info(`MAKER starting — ${config.dryRun ? 'SIMULATION' : 'LIVE'}`);
logger.info(`Assets: ${config.makerAssets.join(', ').toUpperCase()} | Durations: ${config.makerDurations.join(', ')} | BUY @ $${config.makerBuyPrice} → SELL @ $${config.makerSellPrice}`);
logger.info(`Size: ${config.makerTradeSize} sh/side | Cost: $${costPerSide.toFixed(2)}/side | Profit: $${profitPerCycle.toFixed(2)}/cycle`);
startRefresh();
startMakerDetector(handleNewMarket);
-9
View File
@@ -6,16 +6,12 @@
* pm2 logs polymarket-mm
*/
// Set proxy before any network calls
import './utils/proxy-patch.cjs';
import { validateMMConfig } from './config/index.js';
import config from './config/index.js';
import logger from './utils/logger.js';
import { initClient, getClient, getUsdcBalance } from './services/client.js';
import { startMMDetector, stopMMDetector } from './services/mmDetector.js';
import { executeMMStrategy, getActiveMMPositions } from './services/mmExecutor.js';
import { mmFillWatcher } from './services/mmWsFillWatcher.js';
import { cleanupOpenPositions, redeemMMPositions, MIN_SHARES_PER_SIDE } from './services/ctf.js';
logger.interceptConsole();
@@ -48,10 +44,6 @@ if (config.mmTradeSize < MIN_SHARES_PER_SIDE) {
process.exit(1);
}
// ── Start WebSocket fill watcher for real-time order detection ────────────────
mmFillWatcher.start();
// ── Cleanup leftover positions on startup ─────────────────────────────────────
try {
@@ -171,7 +163,6 @@ const redeemTimer = setInterval(
function shutdown() {
logger.warn('MM: shutting down...');
stopMMDetector();
mmFillWatcher.stop();
clearInterval(statusTimer);
clearInterval(redeemTimer);
setTimeout(() => process.exit(0), 300);
+1 -10
View File
@@ -6,9 +6,6 @@
* npm run mm-sim (simulation / dry-run)
*/
// Set proxy before any network calls
import './utils/proxy-patch.cjs';
import { validateMMConfig } from './config/index.js';
import config from './config/index.js';
import logger from './utils/logger.js';
@@ -16,7 +13,6 @@ import { initClient, getClient } from './services/client.js';
import { initDashboard, appendLog, updateStatus, isDashboardActive } from './ui/dashboard.js';
import { startMMDetector, stopMMDetector } from './services/mmDetector.js';
import { executeMMStrategy, getActiveMMPositions } from './services/mmExecutor.js';
import { mmFillWatcher } from './services/mmWsFillWatcher.js';
import { getUsdcBalance } from './services/client.js';
import { cleanupOpenPositions, redeemMMPositions, MIN_SHARES_PER_SIDE } from './services/ctf.js';
@@ -53,11 +49,7 @@ if (config.mmTradeSize < MIN_SHARES_PER_SIDE) {
process.exit(1);
}
// ── Start WebSocket fill watcher for real-time order detection ────────────────
mmFillWatcher.start();
// ── Cleanup leftover positions on startup ────────────────────────────────────
// ── Cleanup leftover positions on startup ─────────────────────────────────────
try {
await cleanupOpenPositions(getClient());
@@ -215,7 +207,6 @@ async function handleNewMarket(market) {
function shutdown() {
logger.warn('MM: shutting down...');
stopMMDetector();
mmFillWatcher.stop();
if (refreshTimer) clearInterval(refreshTimer);
if (redeemTimer) clearInterval(redeemTimer);
process.exit(0);
+8 -11
View File
@@ -1,12 +1,11 @@
import { ClobClient } from '@polymarket/clob-client';
import { ethers, Wallet } from 'ethers';
import { Wallet } from 'ethers';
import config from '../config/index.js';
import logger from '../utils/logger.js';
import { setupAxiosProxy, testProxy } from '../utils/proxy.js';
let clobClient = null;
let signer = null;
let _provider = null; // singleton — reused across all onchain calls
/**
* Initialize the Polymarket CLOB client
@@ -80,22 +79,20 @@ export function getSigner() {
}
/**
* 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.
* Get a working Polygon provider using RPC from config
*/
export function getPolygonProvider() {
if (!_provider) {
_provider = new ethers.providers.JsonRpcProvider(config.polygonRpcUrl);
}
return _provider;
export async function getPolygonProvider() {
const { ethers } = await import('ethers');
const provider = new ethers.providers.JsonRpcProvider(config.polygonRpcUrl);
return provider;
}
/**
* Get USDC.e balance of the proxy wallet on Polygon
*/
export async function getUsdcBalance() {
const provider = getPolygonProvider();
const { ethers } = await import('ethers');
const provider = await getPolygonProvider();
const usdcAddress = '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174'; // USDC.e on Polygon
const abi = ['function balanceOf(address) view returns (uint256)'];
const usdc = new ethers.Contract(usdcAddress, abi, provider);
+22 -177
View File
@@ -73,10 +73,6 @@ function parseOnchainError(err) {
return 'Priority fee below Polygon minimum (25 Gwei)';
if (msg.includes('UNPREDICTABLE_GAS_LIMIT'))
return 'Gas estimation failed — transaction will likely revert';
if (msg.includes('GS026'))
return 'Safe nonce conflict (GS026) — another transaction consumed this nonce';
if (msg.includes('GS013'))
return 'Safe execution failed (GS013) — inner transaction reverted';
if (msg.includes('execution reverted') || err?.code === 'CALL_EXCEPTION')
return reason ? `Transaction reverted: ${reason}` : 'Transaction reverted by smart contract';
if (msg.includes('timeout') || msg.includes('TIMEOUT'))
@@ -105,50 +101,20 @@ const RETRY_DELAY = 3000; // ms
// tx waits for the previous one to fully confirm before starting.
let _txQueue = Promise.resolve();
// Track whether a strategy (split/merge) tx is in progress so the redeemer can defer
let _strategyTxActive = false;
/**
* Execute an arbitrary call through the Gnosis Safe proxy wallet.
* Calls are serialized via an internal queue so nonces never collide.
* Retries up to MAX_RETRIES times on transient errors.
*
* @param {string} to - Contract address
* @param {string} data - Encoded calldata
* @param {string} description - Human-readable label for logging
* @param {object} [opts] - Options
* @param {boolean} [opts.priority=true] - Priority calls (strategy split/merge) run immediately.
* Non-priority calls (redeemer) wait until no strategy tx is active.
*/
export function execSafeCall(to, data, description = '', opts = {}) {
const { priority = true, gasLimit } = opts;
const job = async () => {
// Non-priority (redeemer): wait if a strategy tx is active
if (!priority && _strategyTxActive) {
logger.info(`MM: deferring non-priority tx (${description}) — strategy tx in progress`);
// Wait until strategy tx finishes (poll every 1s, max 60s)
for (let i = 0; i < 60 && _strategyTxActive; i++) {
await sleep(1000);
}
}
if (priority) _strategyTxActive = true;
try {
return await _doExecSafeCall(to, data, description, gasLimit);
} finally {
if (priority) _strategyTxActive = false;
}
};
export function execSafeCall(to, data, description = '') {
// Enqueue: this call will only start after the previous one resolves/rejects
const result = _txQueue.then(job);
const result = _txQueue.then(() => _doExecSafeCall(to, data, description));
// Don't let a failure poison the queue for subsequent calls
_txQueue = result.catch(() => { });
return result;
}
async function _doExecSafeCall(to, data, description = '', gasLimit = undefined) {
async function _doExecSafeCall(to, data, description = '') {
if (description) logger.info(`MM: exec safe tx — ${description}`);
let lastErr;
@@ -207,15 +173,12 @@ async function _doExecSafeCall(to, data, description = '', gasLimit = undefined)
? MAX_FEE_CAP
: estimatedMaxFee.mul(Math.ceil(currentMultiplier * 100)).div(100);
const txOpts = { maxPriorityFeePerGas: gasTip, maxFeePerGas: gasFeeCap };
if (gasLimit) txOpts.gasLimit = gasLimit;
const tx = await safe.execTransaction(
to, 0, data, 0, 0, 0, 0,
ethers.constants.AddressZero,
ethers.constants.AddressZero,
signature,
txOpts,
{ maxPriorityFeePerGas: gasTip, maxFeePerGas: gasFeeCap },
);
const receipt = await tx.wait();
@@ -245,28 +208,18 @@ async function _doExecSafeCall(to, data, description = '', gasLimit = undefined)
// ── Approval helpers ──────────────────────────────────────────────────────────
// In-memory approval cache — avoids redundant on-chain reads after first approval
let _usdcApproved = false;
const _exchangeApproved = new Set(); // exchange addresses already confirmed
/**
* Ensure the CTF contract can spend USDC from the proxy wallet.
*/
async function ensureUsdcApproval(amountWei) {
if (_usdcApproved) return;
const provider = await getPolygonProvider();
const usdc = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, provider);
const allowance = await usdc.allowance(config.proxyWallet, CTF_ADDRESS);
if (allowance.gte(amountWei)) {
_usdcApproved = true;
return;
}
if (allowance.gte(amountWei)) return;
const iface = new ethers.utils.Interface(ERC20_ABI);
const data = iface.encodeFunctionData('approve', [CTF_ADDRESS, ethers.constants.MaxUint256]);
await execSafeCall(USDC_ADDRESS, data, 'approve USDC → CTF');
_usdcApproved = true;
logger.success('MM: USDC approved to CTF contract');
}
@@ -276,68 +229,18 @@ async function ensureUsdcApproval(amountWei) {
*/
export async function ensureExchangeApproval(negRisk = false) {
const exchange = negRisk ? NEG_RISK_EXCHANGE : CTF_EXCHANGE;
if (_exchangeApproved.has(exchange)) return;
const provider = await getPolygonProvider();
const ctf = new ethers.Contract(CTF_ADDRESS, ERC1155_ABI, provider);
const approved = await ctf.isApprovedForAll(config.proxyWallet, exchange);
if (approved) {
_exchangeApproved.add(exchange);
return;
}
if (approved) return;
const iface = new ethers.utils.Interface(ERC1155_ABI);
const data = iface.encodeFunctionData('setApprovalForAll', [exchange, true]);
await execSafeCall(CTF_ADDRESS, data, 'setApprovalForAll → CTF Exchange');
_exchangeApproved.add(exchange);
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 ───────────────────────────────────────────────────────
/**
@@ -406,13 +309,7 @@ export async function mergePositions(conditionId, sharesPerSide) {
return recovered;
}
// 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 amountWei = ethers.utils.parseUnits(sharesPerSide.toFixed(6), 6);
const ctfIface = new ethers.utils.Interface(CTF_ABI);
const data = ctfIface.encodeFunctionData('mergePositions', [
@@ -423,10 +320,7 @@ export async function mergePositions(conditionId, sharesPerSide) {
amountWei,
]);
// 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 });
await execSafeCall(CTF_ADDRESS, data, `mergePositions conditionId=${conditionId.slice(0, 10)}...`);
logger.success(`MM: merged — recovered $${sharesPerSide} USDC`);
return sharesPerSide;
}
@@ -629,7 +523,7 @@ export async function redeemMMPositions() {
conditionId,
[1, 2],
]);
await execSafeCall(CTF_ADDRESS, data, `redeemPositions ${label}`, { priority: false });
await execSafeCall(CTF_ADDRESS, data, `redeemPositions ${label}`);
logger.money(`MM redeemer: redeemed ${label} → ~$${expectedUsdc.toFixed(2)} USDC`);
redeemed++;
} catch (err) {
@@ -648,29 +542,6 @@ export async function redeemMMPositions() {
const _failedConditions = new Set();
const _skippedLosses = new Set();
// Callback invoked when a win is detected — receives conditionId
let _onWinCallback = null;
// Function to look up conditionId → { asset, yesTokenId, noTokenId }
// Injected from sniper entry point to avoid circular imports
let _getConditionInfo = null;
/**
* Register a callback to be called when a sniper win is detected.
* Callback signature: (conditionId: string) => void
*/
export function onSniperWin(cb) {
_onWinCallback = cb;
}
/**
* Register a function to look up sniper condition info (token mapping).
* Used to correctly map token balances to outcome indices.
*/
export function setSniperConditionLookup(fn) {
_getConditionInfo = fn;
}
/**
* Redeem sniper positions via Gnosis Safe.
* Only redeems WINNING positions — skip losses (they can be manually cleared).
@@ -745,63 +616,37 @@ export async function redeemSniperPositions() {
continue;
}
// Check outcome via payoutNumerators — which outcome index won?
const payoutNums = await Promise.all(
// Estimate payout from numerators (for logging)
const payoutFractions = await Promise.all(
[0, 1].map((i) =>
ctf.payoutNumerators(conditionId, i).then((n) => n.toNumber())
ctf.payoutNumerators(conditionId, i)
.then((n) => n.toNumber() / denominator.toNumber())
)
);
const denom = denominator.toNumber();
const payoutFractions = payoutNums.map((n) => n / denom);
// Determine winning outcome index (the one with payoutFraction > 0)
const winningOutcome = payoutFractions[0] > 0 ? 0 : payoutFractions[1] > 0 ? 1 : -1;
const label = conditionId.slice(0, 12) + '...';
// Map token balances to outcome indices using sniper's token mapping.
// yesTokenId = outcome 0 (clobTokenIds[0]), noTokenId = outcome 1
const sniperInfo = _getConditionInfo ? _getConditionInfo(conditionId) : null;
// Build outcome→balance mapping (keyed by outcome index, not array index)
const outcomeBalances = [0, 0];
if (sniperInfo) {
for (let i = 0; i < tokens.length; i++) {
if (tokens[i].tokenId === sniperInfo.yesTokenId) outcomeBalances[0] = balances[i];
else if (tokens[i].tokenId === sniperInfo.noTokenId) outcomeBalances[1] = balances[i];
}
} else {
// No sniper mapping — skip (not a sniper position)
continue;
}
// Win = we hold shares on the winning outcome side
const winShares = winningOutcome >= 0 ? outcomeBalances[winningOutcome] : 0;
const isWin = winShares > 0;
const expectedUsdc = outcomeBalances.reduce(
const expectedUsdc = balances.reduce(
(sum, shares, i) => sum + shares * (payoutFractions[i] ?? 0), 0
);
const label = conditionId.slice(0, 12) + '...';
const isWin = expectedUsdc >= 0.01;
// SNIPER: only redeem WINNERS — cache losses to skip next time
if (!isWin) {
_skippedLosses.add(conditionId);
if (config.dryRun) {
logger.info(`SNIPER[SIM] skip loss: ${label}outcome=${winningOutcome}, win_shares=0 (cached)`);
logger.info(`SNIPER[SIM] skip loss: ${label}${totalShares.toFixed(3)} shares (cached, no future checks)`);
} else {
logger.info(`SNIPER redeemer: skip loss ${label}outcome=${winningOutcome}, no shares on winner`);
logger.info(`SNIPER redeemer: skip loss ${label}cached for future cycles`);
}
continue;
}
// Track win for pause-after-win (notify via callback)
if (_onWinCallback) _onWinCallback(conditionId);
if (config.dryRun) {
logger.money(`SNIPER[SIM] redeem: ${label}${winShares.toFixed(3)} shares on outcome ${winningOutcome} → ~$${expectedUsdc.toFixed(2)} USDC (WIN)`);
logger.money(`SNIPER[SIM] redeem: ${label}${totalShares.toFixed(3)} shares → ~$${expectedUsdc.toFixed(2)} USDC (WIN)`);
continue;
}
logger.info(`SNIPER redeemer: ${label} resolved WIN — outcome ${winningOutcome}, ${winShares.toFixed(3)} shares → ~$${expectedUsdc.toFixed(2)} USDC`);
logger.info(`SNIPER redeemer: ${label} resolved WIN — ${totalShares.toFixed(3)} shares → ~$${expectedUsdc.toFixed(2)} USDC`);
// Call redeemPositions through Safe — winners only
const data = ctfIface.encodeFunctionData('redeemPositions', [
@@ -810,7 +655,7 @@ export async function redeemSniperPositions() {
conditionId,
[1, 2],
]);
const receipt = await execSafeCall(CTF_ADDRESS, data, `redeemPositions ${label}`, { priority: false });
const receipt = await execSafeCall(CTF_ADDRESS, data, `redeemPositions ${label}`);
logger.money(`SNIPER redeemer: redeemed ${label} → ~$${expectedUsdc.toFixed(2)} USDC ✅ | tx: ${receipt.transactionHash}`);
redeemed++;
+145
View File
@@ -0,0 +1,145 @@
/**
* makerDetector.js
* Detects upcoming markets for the Maker strategy (buy low, sell high).
* Supports multiple assets AND multiple durations (e.g. 5m,15m).
*/
import config from '../config/index.js';
import logger from '../utils/logger.js';
import { proxyFetch } from '../utils/proxy.js';
const DURATION_SECS = { '5m': 300, '15m': 900 };
let pollTimer = null;
let onMarketCb = null;
const seenKeys = new Set();
function slotSec(duration) {
return DURATION_SECS[duration] || 300;
}
function currentSlot(duration) {
const sec = slotSec(duration);
return Math.floor(Date.now() / 1000 / sec) * sec;
}
function nextSlot(duration) {
return currentSlot(duration) + slotSec(duration);
}
async function fetchBySlug(asset, duration, slotTimestamp) {
const slug = `${asset}-updown-${duration}-${slotTimestamp}`;
try {
const resp = await proxyFetch(`${config.gammaHost}/markets/slug/${slug}`);
if (!resp.ok) return null;
const data = await resp.json();
return data?.conditionId ? data : null;
} catch {
return null;
}
}
function extractMarketData(market, asset, duration) {
const conditionId = market.conditionId || market.condition_id || '';
if (!conditionId) return null;
let tokenIds = market.clobTokenIds ?? market.clob_token_ids;
if (typeof tokenIds === 'string') {
try { tokenIds = JSON.parse(tokenIds); } catch { tokenIds = null; }
}
let yesTokenId, noTokenId;
if (Array.isArray(tokenIds) && tokenIds.length >= 2) {
[yesTokenId, noTokenId] = tokenIds;
} else if (Array.isArray(market.tokens) && market.tokens.length >= 2) {
yesTokenId = market.tokens[0]?.token_id ?? market.tokens[0]?.tokenId;
noTokenId = market.tokens[1]?.token_id ?? market.tokens[1]?.tokenId;
}
if (!yesTokenId || !noTokenId) return null;
return {
asset,
duration,
conditionId,
question: market.question || market.title || '',
endTime: market.endDate || market.end_date_iso || market.endDateIso,
eventStartTime: market.eventStartTime || market.event_start_time,
yesTokenId: String(yesTokenId),
noTokenId: String(noTokenId),
negRisk: market.negRisk ?? market.neg_risk ?? false,
tickSize: String(market.orderPriceMinTickSize ?? market.minimum_tick_size ?? market.minimumTickSize ?? '0.01'),
};
}
async function scheduleAsset(asset, duration, slotTimestamp) {
const key = `${asset}-${duration}-${slotTimestamp}`;
if (seenKeys.has(key)) return;
const market = await fetchBySlug(asset, duration, slotTimestamp);
if (!market) return;
const data = extractMarketData(market, asset, duration);
if (!data) {
logger.warn(`MAKER: skipping ${asset.toUpperCase()} ${duration} slot ${slotTimestamp} — missing token IDs`);
seenKeys.add(key);
return;
}
seenKeys.add(key);
const openAt = data.eventStartTime ? new Date(data.eventStartTime).getTime() : slotTimestamp * 1000;
const elapsedSec = Math.round((Date.now() - openAt) / 1000);
if (elapsedSec > 15) {
logger.info(`MAKER: ${asset.toUpperCase()} ${duration} next slot already ${elapsedSec}s old — skipping`);
return;
}
const secsUntilOpen = Math.round((openAt - Date.now()) / 1000);
if (secsUntilOpen > 0) {
logger.success(`MAKER: ${asset.toUpperCase()} ${duration} found "${data.question.slice(0, 40)}" — placing orders (${secsUntilOpen}s before open)`);
} else {
logger.success(`MAKER: ${asset.toUpperCase()} ${duration} found "${data.question.slice(0, 40)}" — placing orders now`);
}
if (onMarketCb) onMarketCb(data);
}
async function poll() {
try {
const tasks = [];
for (const duration of config.makerDurations) {
const next = nextSlot(duration);
for (const asset of config.makerAssets) {
tasks.push(scheduleAsset(asset, duration, next));
}
}
await Promise.all(tasks);
} catch (err) {
logger.error('MAKER detector poll error:', err.message);
}
}
export function startMakerDetector(onNewMarket) {
onMarketCb = onNewMarket;
seenKeys.clear();
poll();
pollTimer = setInterval(poll, config.makerPollInterval);
const durStr = config.makerDurations.join(', ');
for (const duration of config.makerDurations) {
const ns = nextSlot(duration);
const secsUntil = ns - Math.floor(Date.now() / 1000);
logger.info(`MAKER detector — ${duration}: next slot *-updown-${duration}-${ns} (opens in ${secsUntil}s)`);
}
logger.info(`MAKER detector started — assets: ${config.makerAssets.join(', ').toUpperCase()} | durations: ${durStr}`);
logger.info(`Strategy: BUY @ $${config.makerBuyPrice} → SELL @ $${config.makerSellPrice} | ${config.makerTradeSize} shares/side`);
}
export function stopMakerDetector() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
+525
View File
@@ -0,0 +1,525 @@
/**
* makerExecutor.js
* Buy Low, Sell High Market Maker no splitPosition.
*
* Flow:
* 1. Ensure ERC1155 exchange approval (one-time, needed to sell tokens)
* 2. Place concurrent limit BUY on UP + DOWN at makerBuyPrice (e.g. 2c)
* 3. Monitor both orders in parallel (multi-thread style)
* 4. When one side fills (even partial):
* a. Wait briefly for on-chain settlement
* b. Place limit SELL for filled shares at makerSellPrice (e.g. 3c)
* c. Cancel the other side's buy order
* 5. Partial fills partial sells placed immediately
* 6. CL at 10s before close: cancel unfilled buy orders
* 7. Place sells for any filled positions (retry 3x if settlement pending)
* 8. Monitor sell orders until filled or market close
*/
import { Side, OrderType } from '@polymarket/clob-client';
import config from '../config/index.js';
import { getClient } from './client.js';
import { ensureExchangeApproval } from './ctf.js';
import logger from '../utils/logger.js';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const CL_SECONDS = 10; // cancel unfilled sells 10s before market close
const SELL_DELAY_MS = 2000; // wait for on-chain settlement before placing sell
const MAX_SELL_RETRIES = 3;
const MIN_ORDER_SIZE = 5; // Polymarket minimum order size
// In-memory store of active maker positions
const activePositions = new Map();
export function getActiveMakerPositions() {
return Array.from(activePositions.values());
}
// ── Simulation stats ─────────────────────────────────────────────────────────
const simStats = {
startBalance: config.makerSimBalance,
balance: config.makerSimBalance,
wins: 0, // sell filled → realized profit
losses: 0, // buy filled, sell NOT filled → loss = buy cost
skips: 0, // no fills at all → $0
totalTrades: 0,
cumulativePnl: 0,
history: [], // [{ time, side, result, pnl, balance }]
};
export function getSimStats() {
return { ...simStats, history: [...simStats.history] };
}
function recordTrade(result, side, pnl) {
simStats.totalTrades++;
simStats.cumulativePnl += pnl;
simStats.balance += pnl;
if (result === 'win') simStats.wins++;
else if (result === 'loss') simStats.losses++;
else simStats.skips++;
simStats.history.push({
time: new Date().toISOString().replace('T', ' ').substring(11, 19),
side: side || '-',
result,
pnl,
balance: simStats.balance,
});
if (simStats.history.length > 50) simStats.history.splice(0, simStats.history.length - 50);
}
// ── Approval tracking ────────────────────────────────────────────────────────
let approvalChecked = false;
async function ensureApproval(negRisk) {
if (config.dryRun || approvalChecked) return;
try {
await ensureExchangeApproval(negRisk);
approvalChecked = true;
} catch (err) {
logger.error(`MAKER: exchange approval failed — ${err.message}`);
}
}
// ── Order helpers ─────────────────────────────────────────────────────────────
async function placeLimitBuy(tokenId, shares, price, tickSize, negRisk) {
const size = Math.max(MIN_ORDER_SIZE, Math.floor(shares));
if (config.dryRun) {
return { success: true, orderId: `sim-buy-${Date.now()}-${tokenId.slice(-6)}`, size };
}
const client = getClient();
try {
const res = await client.createAndPostOrder(
{ tokenID: tokenId, side: Side.BUY, price, size },
{ tickSize, negRisk },
OrderType.GTC,
);
if (!res?.success) return { success: false };
return { success: true, orderId: res.orderID, size };
} catch (err) {
logger.error('MAKER limit buy error:', err.message);
return { success: false };
}
}
async function placeLimitSellWithRetry(tokenId, shares, price, tickSize, negRisk, tag) {
const size = Math.max(MIN_ORDER_SIZE, Math.floor(shares));
if (config.dryRun) {
return { success: true, orderId: `sim-sell-${Date.now()}-${tokenId.slice(-6)}` };
}
const client = getClient();
for (let attempt = 1; attempt <= MAX_SELL_RETRIES; attempt++) {
try {
const res = await client.createAndPostOrder(
{ tokenID: tokenId, side: Side.SELL, price, size },
{ tickSize, negRisk },
OrderType.GTC,
);
if (res?.success) {
return { success: true, orderId: res.orderID };
}
const errMsg = res?.errorMsg || 'unknown';
logger.warn(`MAKER${tag}: sell attempt ${attempt}/${MAX_SELL_RETRIES} failed: ${errMsg}`);
} catch (err) {
logger.warn(`MAKER${tag}: sell attempt ${attempt}/${MAX_SELL_RETRIES} error: ${err.message}`);
}
if (attempt < MAX_SELL_RETRIES) {
// Wait longer each retry — tokens might not have settled yet
const delay = SELL_DELAY_MS * attempt;
logger.info(`MAKER${tag}: waiting ${delay / 1000}s for on-chain settlement before retry...`);
await sleep(delay);
}
}
return { success: false };
}
async function cancelOrder(orderId) {
if (config.dryRun || !orderId || orderId.startsWith('sim-')) return true;
try {
await getClient().cancelOrder({ orderID: orderId });
return true;
} catch (err) {
logger.warn('MAKER cancel order error:', err.message);
return false;
}
}
// ── Order status ──────────────────────────────────────────────────────────────
async function getOrderFill(orderId) {
if (!orderId || orderId.startsWith('sim-')) {
return { matched: 0, status: 'LIVE', fullyFilled: false };
}
try {
const order = await getClient().getOrder(orderId);
if (!order) return { matched: 0, status: 'UNKNOWN', fullyFilled: false };
const matched = parseFloat(order.size_matched || '0');
return {
matched,
status: order.status,
fullyFilled: order.status === 'MATCHED',
};
} catch {
return { matched: 0, status: 'ERROR', fullyFilled: false };
}
}
// Simulation: check if market price would fill our order
async function simCheckFill(tokenId, side, price) {
try {
const mp = await getClient().getMidpoint(tokenId);
const midPrice = parseFloat(mp?.mid ?? mp ?? '0') || 0;
if (side === 'buy' && midPrice <= price) return midPrice;
if (side === 'sell' && midPrice >= price) return midPrice;
return null;
} catch {
return null;
}
}
// ── Core strategy ─────────────────────────────────────────────────────────────
export async function executeMakerStrategy(market) {
const { asset, duration, conditionId, question, endTime, yesTokenId, noTokenId, negRisk, tickSize } = market;
const tag = asset ? `[${asset.toUpperCase()}/${duration || '5m'}]` : '';
const label = question.substring(0, 40);
const sim = config.dryRun ? '[SIM] ' : '';
const { makerBuyPrice, makerSellPrice, makerTradeSize, makerMonitorMs } = config;
// Check sim balance
const costPerSide = makerTradeSize * makerBuyPrice;
if (config.dryRun && simStats.balance < costPerSide) {
logger.warn(`MAKER${tag}: ${sim}insufficient sim balance $${simStats.balance.toFixed(2)} (need $${costPerSide.toFixed(2)}) — skipping`);
recordTrade('skip', null, 0);
return;
}
logger.info(`MAKER${tag}: ${sim}entering — ${label}`);
logger.info(`MAKER${tag}: BUY @ $${makerBuyPrice} → SELL @ $${makerSellPrice} | ${makerTradeSize} shares/side | cost $${costPerSide.toFixed(2)}`);
// ── 0. Ensure ERC1155 exchange approval (one-time) ───────────
await ensureApproval(negRisk);
// ── 1. Place BUY UP + DOWN concurrently ──────────────────────
logger.trade(`MAKER${tag}: ${sim}placing BUY UP + DOWN @ $${makerBuyPrice}`);
const [upBuy, downBuy] = await Promise.all([
placeLimitBuy(yesTokenId, makerTradeSize, makerBuyPrice, tickSize, negRisk),
placeLimitBuy(noTokenId, makerTradeSize, makerBuyPrice, tickSize, negRisk),
]);
if (!upBuy.success && !downBuy.success) {
logger.error(`MAKER${tag}: both buy orders failed — aborting`);
recordTrade('skip', null, 0);
return;
}
if (upBuy.success) logger.trade(`MAKER${tag}: ${sim}UP BUY placed | order ${upBuy.orderId}`);
if (downBuy.success) logger.trade(`MAKER${tag}: ${sim}DOWN BUY placed | order ${downBuy.orderId}`);
// ── 2. Build position state ──────────────────────────────────
const pos = {
asset: asset || 'btc',
duration: duration || '5m',
conditionId,
question,
endTime,
tickSize,
negRisk,
status: 'buying',
enteredAt: new Date().toISOString(),
up: {
tokenId: yesTokenId,
buyOrderId: upBuy.success ? upBuy.orderId : null,
buyFilled: 0,
sellOrders: [],
totalSellFilled: 0,
cancelled: !upBuy.success,
},
down: {
tokenId: noTokenId,
buyOrderId: downBuy.success ? downBuy.orderId : null,
buyFilled: 0,
sellOrders: [],
totalSellFilled: 0,
cancelled: !downBuy.success,
},
winner: null,
totalCost: 0,
totalRevenue: 0,
};
activePositions.set(conditionId, pos);
// ── 3. Monitor buy → sell (concurrent) ───────────────────────
try {
await monitorBuyPhase(pos, tag, sim);
await monitorSellPhase(pos, tag, sim);
} catch (err) {
logger.error(`MAKER${tag}: strategy error — ${err.message}`);
}
// ── Final result + sim stats ─────────────────────────────────
const pnl = pos.totalRevenue - pos.totalCost;
const winnerSide = pos.winner?.toUpperCase() || '-';
if (pos.status === 'done' && pos.totalRevenue > 0) {
// WIN: sell filled
recordTrade('win', winnerSide, pnl);
logger.money(`MAKER${tag}: ${sim}WIN | ${winnerSide} | cost $${pos.totalCost.toFixed(4)} → revenue $${pos.totalRevenue.toFixed(4)} | P&L +$${pnl.toFixed(4)}`);
} else if (pos.totalCost > 0) {
// LOSS: buy filled but sell didn't fill (expired-holding)
recordTrade('loss', winnerSide, -pos.totalCost);
logger.warn(`MAKER${tag}: ${sim}LOSS | ${winnerSide} | cost $${pos.totalCost.toFixed(4)} (sell not filled, held to expiry)`);
} else {
// SKIP: nothing filled
recordTrade('skip', null, 0);
logger.info(`MAKER${tag}: ${sim}SKIP | no fills, $0 cost`);
}
// Log running stats
const s = simStats;
const winRate = s.wins + s.losses > 0 ? ((s.wins / (s.wins + s.losses)) * 100).toFixed(1) : '0.0';
logger.info(`MAKER${tag}: ${sim}STATS | W:${s.wins} L:${s.losses} S:${s.skips} | Win%: ${winRate}% | PnL: $${s.cumulativePnl.toFixed(4)} | Balance: $${s.balance.toFixed(2)}`);
activePositions.delete(conditionId);
}
// ── Buy phase: monitor both sides concurrently ───────────────────────────────
async function monitorBuyPhase(pos, tag, sim) {
const { makerBuyPrice, makerSellPrice, makerMonitorMs } = config;
const monitorSide = async (sideKey) => {
const side = pos[sideKey];
const otherKey = sideKey === 'up' ? 'down' : 'up';
const sideName = sideKey.toUpperCase();
if (!side.buyOrderId) return;
while (!pos.winner) {
const msLeft = new Date(pos.endTime).getTime() - Date.now();
// CL: cancel unfilled buy at 10s before market close
if (msLeft <= CL_SECONDS * 1000) {
if (side.buyOrderId && !side.cancelled) {
logger.warn(`MAKER${tag}: CL ${CL_SECONDS}s — cancelling ${sideName} buy`);
await cancelOrder(side.buyOrderId);
side.cancelled = true;
}
break;
}
// Check fill
let fill;
if (config.dryRun) {
const hitPrice = await simCheckFill(side.tokenId, 'buy', makerBuyPrice);
if (hitPrice !== null) {
fill = { matched: config.makerTradeSize, fullyFilled: true };
} else {
fill = { matched: 0, fullyFilled: false };
}
} else {
fill = await getOrderFill(side.buyOrderId);
}
// New fills detected → wait for settlement, then place sell
const newFill = fill.matched - side.buyFilled;
if (newFill > 0) {
side.buyFilled = fill.matched;
pos.totalCost += newFill * makerBuyPrice;
logger.money(`MAKER${tag}: ${sim}${sideName} BUY filled ${newFill.toFixed(2)} shares @ $${makerBuyPrice} (total: ${side.buyFilled.toFixed(2)}/${config.makerTradeSize})`);
// Check total unsold shares — only place sell if >= MIN_ORDER_SIZE
const soldShares = side.sellOrders.reduce((sum, so) => sum + so.shares, 0);
const unsold = side.buyFilled - soldShares;
if (unsold >= MIN_ORDER_SIZE) {
// Wait for on-chain token settlement before placing sell
if (!config.dryRun) {
logger.info(`MAKER${tag}: waiting ${SELL_DELAY_MS / 1000}s for on-chain settlement...`);
await sleep(SELL_DELAY_MS);
}
const sellResult = await placeLimitSellWithRetry(
side.tokenId, unsold, makerSellPrice,
pos.tickSize, pos.negRisk, tag,
);
if (sellResult.success) {
side.sellOrders.push({
orderId: sellResult.orderId,
shares: unsold,
filled: false,
fillPrice: null,
});
logger.trade(`MAKER${tag}: ${sim}${sideName} SELL placed ${unsold.toFixed(2)} shares @ $${makerSellPrice}`);
} else {
logger.error(`MAKER${tag}: ${sideName} SELL failed after ${MAX_SELL_RETRIES} retries — tokens held to resolution`);
}
} else {
logger.info(`MAKER${tag}: ${sideName} unsold ${unsold.toFixed(2)} shares < ${MIN_ORDER_SIZE} min — waiting for more fills`);
}
}
// Fully filled → winner
if (fill.fullyFilled) {
pos.winner = sideKey;
pos.status = 'selling';
logger.success(`MAKER${tag}: ${sim}${sideName} fully filled! Cancelling ${otherKey.toUpperCase()} buy...`);
const other = pos[otherKey];
if (other.buyOrderId && !other.cancelled) {
await cancelOrder(other.buyOrderId);
other.cancelled = true;
logger.info(`MAKER${tag}: ${otherKey.toUpperCase()} buy cancelled`);
}
return;
}
await sleep(makerMonitorMs);
}
};
await Promise.race([
monitorSide('up'),
monitorSide('down'),
]);
// Cleanup: cancel any remaining unfilled buy orders
for (const key of ['up', 'down']) {
const s = pos[key];
if (s.buyOrderId && !s.cancelled) {
await cancelOrder(s.buyOrderId);
s.cancelled = true;
logger.info(`MAKER${tag}: cancelled ${key.toUpperCase()} buy order`);
}
}
// Place sells for filled buys that don't have sell orders yet
for (const key of ['up', 'down']) {
const s = pos[key];
const soldShares = s.sellOrders.reduce((sum, so) => sum + so.shares, 0);
const unsold = s.buyFilled - soldShares;
if (unsold >= MIN_ORDER_SIZE) {
logger.info(`MAKER${tag}: placing sell for ${key.toUpperCase()} ${unsold.toFixed(2)} unsold shares`);
if (!config.dryRun) {
logger.info(`MAKER${tag}: waiting ${SELL_DELAY_MS / 1000}s for on-chain settlement...`);
await sleep(SELL_DELAY_MS);
}
const sellResult = await placeLimitSellWithRetry(
s.tokenId, unsold, makerSellPrice,
pos.tickSize, pos.negRisk, tag,
);
if (sellResult.success) {
s.sellOrders.push({
orderId: sellResult.orderId,
shares: unsold,
filled: false,
fillPrice: null,
});
logger.trade(`MAKER${tag}: ${sim}${key.toUpperCase()} SELL placed ${unsold.toFixed(2)} shares @ $${makerSellPrice}`);
} else {
logger.error(`MAKER${tag}: ${key.toUpperCase()} SELL failed after ${MAX_SELL_RETRIES} retries — tokens held to resolution`);
}
}
}
if (!pos.winner) {
const anyFill = pos.up.buyFilled > 0 || pos.down.buyFilled > 0;
if (anyFill) {
pos.winner = pos.up.buyFilled >= pos.down.buyFilled ? 'up' : 'down';
pos.status = 'selling';
logger.info(`MAKER${tag}: partial fill — monitoring sell for ${pos.winner.toUpperCase()} ${pos[pos.winner].buyFilled.toFixed(2)} shares`);
} else {
pos.status = 'done';
logger.info(`MAKER${tag}: no fills — buy orders expired naturally, $0 loss`);
}
}
}
// ── Sell phase: monitor sell orders until market close ────────────────────────
async function monitorSellPhase(pos, tag, sim) {
if (pos.status === 'done') return;
const { makerSellPrice, makerMonitorMs } = config;
const winnerKey = pos.winner;
if (!winnerKey) return;
const side = pos[winnerKey];
const sideName = winnerKey.toUpperCase();
if (side.sellOrders.length === 0) {
// Sell placement failed — tokens held to resolution
pos.status = 'expired-holding';
logger.warn(`MAKER${tag}: no sell orders placed — held to resolution`);
return;
}
logger.info(`MAKER${tag}: monitoring ${side.sellOrders.length} sell order(s) for ${sideName}`);
while (true) {
const msLeft = new Date(pos.endTime).getTime() - Date.now();
// Market closed — unfilled sells resolve on-chain
if (msLeft <= 0) {
pos.status = side.totalSellFilled > 0 ? 'done' : 'expired-holding';
if (pos.status === 'expired-holding') {
logger.warn(`MAKER${tag}: market closed — unfilled sells held to resolution`);
}
break;
}
// Check all sell orders concurrently
const checks = await Promise.all(
side.sellOrders.map(async (so) => {
if (so.filled) return true;
let filled = false;
if (config.dryRun) {
const hitPrice = await simCheckFill(side.tokenId, 'sell', makerSellPrice);
if (hitPrice !== null) {
filled = true;
so.fillPrice = hitPrice;
}
} else {
const fill = await getOrderFill(so.orderId);
if (fill.fullyFilled || fill.matched >= so.shares * 0.99) {
filled = true;
so.fillPrice = makerSellPrice;
}
}
if (filled) {
so.filled = true;
side.totalSellFilled += so.shares;
pos.totalRevenue += so.shares * (so.fillPrice || makerSellPrice);
logger.money(`MAKER${tag}: ${sim}${sideName} SELL filled ${so.shares.toFixed(2)} shares @ $${(so.fillPrice || makerSellPrice).toFixed(3)}`);
}
return so.filled;
})
);
// All sells filled → done
if (checks.every(Boolean) && side.sellOrders.length > 0) {
pos.status = 'done';
logger.success(`MAKER${tag}: ${sim}all sells filled!`);
break;
}
await sleep(makerMonitorMs);
}
}
-935
View File
@@ -1,935 +0,0 @@
/**
* 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, redeemPositions } 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 ───────────────────────────────────────────────────────
// ── 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';
}
// ── Market sell ───────────────────────────────────────────────────────────────
// Verifies onchain balance after each attempt — CLOB fill confirmation alone is
// not enough because sells can also be ghost-filled (CLOB says done, txhash invalid,
// shares still in wallet). Retries up to 3 times with onchain verification.
async function marketSellToken(tokenId, shares, tickSize, negRisk, tag) {
if (config.dryRun) {
logger.info(`MakerMM${tag}: [SIM] would market-sell ${shares.toFixed(4)} shares of token ${tokenId.slice(-8)}`);
return true;
}
const client = getClient();
const maxAttempts = 3;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
// Snapshot balance before sell — source of truth for whether it went through
const balanceBefore = (await getTokenBalance(tokenId)) ?? 0;
if (balanceBefore < 0.01) {
logger.info(`MakerMM${tag}: sell skipped — balance already 0`);
return true;
}
const sharesToSell = Math.min(shares, balanceBefore);
let refPrice = 0.01;
try {
const bidResult = await client.getPrice(tokenId, 'BUY');
const bid = parseFloat(bidResult?.price ?? bidResult ?? '0');
if (bid > 0) refPrice = Math.max(bid * 0.97, 0.01);
} catch {}
try {
const response = await client.createAndPostMarketOrder(
{ tokenID: tokenId, side: Side.SELL, amount: sharesToSell, price: refPrice },
{ tickSize, negRisk },
OrderType.FAK,
);
if (!response?.success || parseFloat(response?.takingAmount || '0') === 0) {
logger.warn(`MakerMM${tag}: sell attempt ${attempt}/${maxAttempts} — CLOB rejected (${response?.errorMsg || 'no liquidity'})`);
await sleep(3000);
continue;
}
// CLOB says filled — wait then verify onchain balance actually decreased
await sleep(8000);
const balanceAfter = (await getTokenBalance(tokenId)) ?? balanceBefore;
const sold = balanceBefore - balanceAfter;
if (sold >= sharesToSell * 0.5) {
logger.money(`MakerMM${tag}: sold ${sold.toFixed(4)} shares @ ~$${refPrice.toFixed(3)} (attempt ${attempt})`);
return true;
}
// Balance unchanged → ghost sell, retry
logger.warn(`MakerMM${tag}: sell attempt ${attempt}/${maxAttempts} ghost — CLOB filled but ${balanceAfter.toFixed(4)} shares still onchain, retrying...`);
await sleep(5000 * attempt);
} catch (err) {
logger.error(`MakerMM${tag}: sell attempt ${attempt}/${maxAttempts} error — ${err.message}`);
await sleep(3000);
}
}
logger.warn(`MakerMM${tag}: sell failed after ${maxAttempts} attempts — shares remain in wallet (will resolve at market close)`);
return false;
}
// ── Ghost fill recovery ───────────────────────────────────────────────────────
// Onchain balance doesn't match what CLOB says was filled (partial or full ghost).
// Strategy: merge whatever paired shares exist, then market-sell any unpaired remainder.
// Handles all partial amounts — caller passes actual onchain balances.
async function recoverFromGhostFill(pos, yesShares, noShares, tag) {
logger.warn(
`MakerMM${tag}: ghost fill recovery — onchain YES=${yesShares.toFixed(4)} NO=${noShares.toFixed(4)} ` +
`(expected ${pos.targetShares} each)`
);
const mergeable = Math.floor(Math.min(yesShares, noShares) * 10000) / 10000;
let mergeRecovered = 0;
if (mergeable >= 1) {
try {
await mergePositions(pos.conditionId, mergeable, pos.negRisk);
mergeRecovered = mergeable;
logger.money(`MakerMM${tag}: ghost recovery merge ${mergeable.toFixed(4)} pairs → $${mergeRecovered.toFixed(2)}`);
} catch (err) {
logger.error(`MakerMM${tag}: ghost recovery merge failed — ${err.message}`);
}
}
const yesRemainder = parseFloat(Math.max(0, yesShares - mergeable).toFixed(6));
const noRemainder = parseFloat(Math.max(0, noShares - mergeable).toFixed(6));
// Only market-sell the CHEAP side remainder — expensive side is too costly to dump at market.
// e.g. YES=83c filled, NO=15c ghost → hold YES (high cost basis, market sell = guaranteed loss).
// NO=15c filled, YES=83c ghost → sell NO (cheap, small loss acceptable).
const expSide = pos.yes.buyPrice >= pos.no.buyPrice ? 'yes' : 'no';
if (yesRemainder >= 1) {
if (expSide === 'yes') {
logger.warn(`MakerMM${tag}: ghost recovery — holding YES remainder ${yesRemainder.toFixed(4)} (expensive $${pos.yes.buyPrice}, scheduling redeem after resolution)`);
pos.holdingSide = 'yes';
} else {
await marketSellToken(pos.yes.tokenId, yesRemainder, pos.tickSize, pos.negRisk, tag);
}
}
if (noRemainder >= 1) {
if (expSide === 'no') {
logger.warn(`MakerMM${tag}: ghost recovery — holding NO remainder ${noRemainder.toFixed(4)} (expensive $${pos.no.buyPrice}, scheduling redeem after resolution)`);
pos.holdingSide = 'no';
} else {
await marketSellToken(pos.no.tokenId, noRemainder, pos.tickSize, pos.negRisk, tag);
}
}
pos.totalProfit = mergeRecovered - (pos.yes.cost + pos.no.cost);
// Don't mark done if holding expensive side — waitAndRedeem will close it out
if (!pos.holdingSide) pos.status = 'done';
}
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) {
// JSON.stringify can throw "Maximum call stack size exceeded" if res
// contains a circular reference (e.g. axios/fetch response object).
// Log only safe primitive fields instead.
const errDetail = res?.errorMsg || res?.error || res?.message || 'unknown';
logger.error(`MakerMM: limit buy failed — response: {"error":"${errDetail}","status":${res?.status ?? 'n/a'}}`);
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) => {
// WS is used only as a wake-up signal — do NOT set pos.filled here.
// Setting filled=true from WS on a partial fill (e.g. 2 of 5 shares) would
// make the loop think the side is done and skip the onchain balance check,
// leaving the position stuck. Onchain balance is the sole source of truth.
if (event.tokenId === pos.yes.tokenId) {
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) {
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
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 (source of truth)
if (!pos.yes.filled && yesShares >= pos.targetShares * 0.99) {
pos.yes.filled = true;
logger.money(`MakerMM${tag}: YES filled (onchain) ${yesShares.toFixed(4)} shares`);
}
if (!pos.no.filled && noShares >= pos.targetShares * 0.99) {
pos.no.filled = true;
logger.money(`MakerMM${tag}: NO filled (onchain) ${noShares.toFixed(4)} shares`);
}
// ── Cancel cheap side when expensive fills first ──────────────────────
// When enabled: if the expensive side fills and cheap side hasn't,
// cancel the cheap order and hold the expensive token to redeem at resolution.
if (config.makerMmCancelCheapOnExpFill) {
const expSide = pos.yes.buyPrice >= pos.no.buyPrice ? 'yes' : 'no';
const cheapSide = expSide === 'yes' ? 'no' : 'yes';
if (pos[expSide].filled && !pos[cheapSide].filled) {
logger.info(
`MakerMM${tag}: ${expSide.toUpperCase()} ($${pos[expSide].buyPrice}) filled first — ` +
`cancelling cheap ${cheapSide.toUpperCase()} ($${pos[cheapSide].buyPrice}) order`
);
await cancelOrder(pos[cheapSide].orderId);
pos.holdingSide = expSide;
pos.status = 'holding';
return;
}
}
// ── 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();
}
// ── Ghost fill detection via open orders check ────────────────────────
// More reliable than checkOrderStatus(orderId) which can return 'unknown'
// for ghost fills (invalid txhash → CLOB state is inconsistent).
// If our order is gone from open orders but onchain balance didn't increase
// → order was matched in CLOB but settlement failed (ghost fill).
{
const nowMs = Date.now();
const client = getClient();
if (!pos.yes.filled && !pos.yes.clobFilled && pos.yes.orderId && nowMs - (pos.yes.lastClobCheck || 0) >= 15_000) {
pos.yes.lastClobCheck = nowMs;
try {
const openOrders = await client.getOpenOrders({ asset_id: pos.yes.tokenId });
const stillOpen = Array.isArray(openOrders) && openOrders.some(o => (o.id ?? o.order_id) === pos.yes.orderId);
if (!stillOpen) {
pos.yes.clobFilled = true;
logger.info(`MakerMM${tag}: YES order gone from CLOB open orders (onchain not yet reflected)`);
}
} catch {}
}
if (!pos.no.filled && !pos.no.clobFilled && pos.no.orderId && nowMs - (pos.no.lastClobCheck || 0) >= 15_000) {
pos.no.lastClobCheck = nowMs;
try {
const openOrders = await client.getOpenOrders({ asset_id: pos.no.tokenId });
const stillOpen = Array.isArray(openOrders) && openOrders.some(o => (o.id ?? o.order_id) === pos.no.orderId);
if (!stillOpen) {
pos.no.clobFilled = true;
logger.info(`MakerMM${tag}: NO order gone from CLOB open orders (onchain not yet reflected)`);
}
} catch {}
}
// Ghost fill detection:
// CLOB says order is FILLED but onchain balance < expected after timeout.
// Could be full ghost (0 tokens) or partial (some tokens, but not all).
// Trigger: either side clobFilled AND onchain short of target after 60s.
const yesGhost = pos.yes.clobFilled && yesShares < pos.targetShares * 0.99;
const noGhost = pos.no.clobFilled && noShares < pos.targetShares * 0.99;
if (yesGhost || noGhost) {
if (!pos.ghostFillSince) pos.ghostFillSince = nowMs;
const waitedSec = Math.round((nowMs - pos.ghostFillSince) / 1000);
if (waitedSec >= 30) {
// 30s is enough to distinguish settlement delay from ghost fill.
// Act now while market prices are still fair — don't wait for cut-loss.
await recoverFromGhostFill(pos, yesShares, noShares, tag);
return;
} else {
logger.info(
`MakerMM${tag}: ghost fill suspected ` +
`(YES CLOB=${pos.yes.clobFilled} onchain=${yesShares.toFixed(4)}, ` +
`NO CLOB=${pos.no.clobFilled} onchain=${noShares.toFixed(4)}) ` +
`— waiting ${waitedSec}s / 30s`
);
}
}
}
// ── 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`);
}
}
}
}
// 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
}
}
// ── Auto-redeem after market resolution ──────────────────────────────────────
// Used when holding a single-sided position (expensive side filled, cheap cancelled).
// Polls until the market resolves on-chain, then calls redeemPositions.
async function waitAndRedeem(pos, tag) {
const endMs = new Date(pos.endTime).getTime();
const waitForEndMs = endMs - Date.now();
if (waitForEndMs > 0) {
logger.info(`MakerMM${tag}: holding ${pos.holdingSide.toUpperCase()} — waiting ${Math.round(waitForEndMs / 1000)}s for market to end...`);
await sleep(waitForEndMs);
}
if (config.dryRun) {
logger.info(`MakerMM${tag}: [SIM] would redeem ${pos.holdingSide.toUpperCase()} after resolution`);
return;
}
logger.info(`MakerMM${tag}: market ended — polling for on-chain resolution...`);
const provider = getPolygonProvider();
const ctf = new ethers.Contract(CTF_ADDRESS, ['function payoutDenominator(bytes32 conditionId) view returns (uint256)'], provider);
const maxWaitMs = 10 * 60 * 1000; // 10 minutes max
const pollMs = 15_000;
const start = Date.now();
while (Date.now() - start < maxWaitMs) {
try {
const denom = await ctf.payoutDenominator(pos.conditionId);
if (!denom.isZero()) {
logger.info(`MakerMM${tag}: market resolved — redeeming ${pos.holdingSide.toUpperCase()} tokens...`);
await redeemPositions(pos.conditionId, pos.negRisk);
logger.money(`MakerMM${tag}: redemption complete`);
return;
}
} catch (err) {
logger.warn(`MakerMM${tag}: resolution poll error — ${err.message}`);
}
const elapsedSec = Math.round((Date.now() - start) / 1000);
logger.info(`MakerMM${tag}: not resolved yet (${elapsedSec}s / ${maxWaitMs / 1000}s) — retrying in ${pollMs / 1000}s...`);
await sleep(pollMs);
}
logger.warn(`MakerMM${tag}: market not resolved after ${maxWaitMs / 60000} minutes — skipping auto-redeem (tokens remain in wallet)`);
}
// ── 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 = config.makerMmPollSec;
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;
}
// Auto-detect cheap side: whichever of YES/NO has lower bestBid.
// Range filter (MIN_PRICE/MAX_PRICE) applies to the cheap side only.
// The expensive side is derived from: maxCombined - cheapBid.
const cheapSide = yesBestBid <= noBestBid ? 'yes' : 'no';
const cheapBestBid = cheapSide === 'yes' ? yesBestBid : noBestBid;
const cheapAsk = cheapSide === 'yes' ? yesAsk : noAsk;
let cheapBid = roundToTick(cheapBestBid + ts, tickSize);
// Cap to ask - 2 ticks (not 1) to absorb timing race between fetch and place.
// A 1-tick buffer still lets the ask move 1 tick before our order is submitted,
// turning it into a marketable (taker) order and hitting the $1 minimum.
if (cheapAsk && cheapBid >= cheapAsk - ts) cheapBid = roundToTick(cheapAsk - 2 * ts, tickSize);
// Range check on cheap side
if (cheapBid < MIN_PRICE || cheapBid > MAX_PRICE) {
logger.info(`MakerMM${tag}: waiting — ${cheapSide.toUpperCase()} bid $${cheapBid.toFixed(3)} (need ${MIN_PRICE}-${MAX_PRICE})`);
await sleep(POLL_SEC * 1000);
continue;
}
// Expensive side: fill remaining combined budget
const expensiveBid = roundToTick(config.makerMmMaxCombined - cheapBid, tickSize);
const expensiveAsk = cheapSide === 'yes' ? noAsk : yesAsk;
let expBid = expensiveBid;
if (expensiveAsk && expBid >= expensiveAsk - ts) expBid = roundToTick(expensiveAsk - 2 * ts, tickSize);
if (expBid <= 0 || expBid >= 1) {
logger.info(`MakerMM${tag}: waiting — ${cheapSide === 'yes' ? 'NO' : 'YES'} bid $${expBid.toFixed(3)} out of bounds`);
await sleep(POLL_SEC * 1000);
continue;
}
// Map back to yes/no
yesBid = cheapSide === 'yes' ? cheapBid : expBid;
noBid = cheapSide === 'yes' ? expBid : cheapBid;
combined = parseFloat((yesBid + noBid).toFixed(4));
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,
},
no: {
tokenId: noTokenId,
buyPrice: noBid,
cost: noCost,
orderId: finalNoBuy.orderId,
filled: false,
baseline: noBaseline || 0,
},
totalProfit: 0,
};
activePositions.set(conditionId, pos);
await monitorUntilFilled(pos, tag, label);
activePositions.delete(conditionId);
// If holding a single-sided position (expensive filled, cheap cancelled) — wait and redeem
if (pos.holdingSide) {
await waitAndRedeem(pos, tag);
return { oneSided: false }; // not a stuck one-sided cycle, intentional hold
}
const sign = pos.totalProfit >= 0 ? '+' : '';
logger.info(`MakerMM${tag}: done | P&L: ${sign}$${pos.totalProfit.toFixed(2)}`);
return { oneSided: pos.oneSided ?? false };
}
+363
View File
@@ -0,0 +1,363 @@
/**
* makerWs.js
* WebSocket client for Polymarket CLOB orderbook + trade data.
* Used by the simulation to display real-time orderbook and simulate fills.
*
* Message formats from CLOB WS:
* 1. Book snapshot (initial): [{asset_id, bids, asks, timestamp, hash}] (array, no event_type)
* 2. price_change: {event_type:"price_change", price_changes:[{asset_id, price, size, side, best_bid, best_ask}]}
* 3. last_trade_price: {event_type:"last_trade_price", asset_id, price}
*/
import WebSocket from 'ws';
import logger from '../utils/logger.js';
const WS_URL = 'wss://ws-subscriptions-clob.polymarket.com/ws/market';
const PING_INTERVAL = 30_000;
const RECONNECT_DELAY = 3000;
const MAX_RECONNECT_DELAY = 30_000;
export class OrderbookWs {
constructor() {
this.ws = null;
this.pingTimer = null;
this.reconnectTimer = null;
this.reconnectDelay = RECONNECT_DELAY;
this.isShutdown = false;
// Subscribed assets
this.assetIds = [];
this.conditionId = null;
// Orderbook state per asset: Map<price, size>
this.bids = new Map(); // assetId → Map<price, size>
this.asks = new Map(); // assetId → Map<price, size>
// Best bid/ask per asset (from price_change events)
this.bestBid = new Map(); // assetId → number
this.bestAsk = new Map(); // assetId → number
// Recent trades per asset
this.trades = new Map(); // assetId → [{ price, side, size, timestamp }]
// Last trade price per asset
this.lastPrice = new Map(); // assetId → number
// Callbacks
this.onBookUpdate = null;
this.onTradeUpdate = null;
this.onPriceUpdate = null;
}
subscribe(conditionId, assetIds) {
// Shutdown existing connection if any
if (this.ws) {
this.cleanup(false);
}
this.conditionId = conditionId;
this.assetIds = assetIds;
this.isShutdown = false;
for (const id of assetIds) {
this.bids.set(id, new Map());
this.asks.set(id, new Map());
this.trades.set(id, []);
}
this.connect();
}
connect() {
if (this.isShutdown) return;
this.ws = new WebSocket(WS_URL);
this.ws.on('open', () => {
logger.info('MAKER WS: connected to orderbook feed');
this.reconnectDelay = RECONNECT_DELAY;
// Subscribe to all assets in one message
const msg = {
auth: {},
type: 'subscribe',
markets: [],
assets_ids: this.assetIds,
channels: ['book'],
};
this.ws.send(JSON.stringify(msg));
this.startPing();
});
this.ws.on('message', (raw) => {
this.handleMessage(raw);
});
this.ws.on('ping', () => {
this.ws?.pong();
});
this.ws.on('close', (code, reason) => {
logger.warn(`MAKER WS: disconnected (${code})`);
this.cleanup(true);
});
this.ws.on('error', (err) => {
logger.warn(`MAKER WS error: ${err.message}`);
this.cleanup(true);
});
}
handleMessage(raw) {
let msg;
try {
msg = JSON.parse(raw.toString());
} catch {
return;
}
// Initial book snapshot comes as an array [{...}]
if (Array.isArray(msg)) {
for (const evt of msg) {
if (evt.asset_id && evt.bids) {
this.handleBookSnapshot(evt.asset_id, evt);
}
}
return;
}
// Subsequent messages are objects with event_type
switch (msg.event_type) {
case 'book':
if (msg.asset_id) {
this.handleBookSnapshot(msg.asset_id, msg);
}
break;
case 'price_change':
this.handlePriceChange(msg);
break;
case 'last_trade_price':
if (msg.asset_id) {
this.handleLastPrice(msg.asset_id, msg);
}
break;
default:
// Full book updates without event_type (non-array single object)
if (msg.asset_id && msg.bids) {
this.handleBookSnapshot(msg.asset_id, msg);
}
break;
}
}
handleBookSnapshot(assetId, evt) {
if (!this.assetIds.includes(assetId)) return;
// Replace entire book for this asset
const bidMap = new Map();
for (const b of (evt.bids || [])) {
const price = parseFloat(b.price);
const size = parseFloat(b.size);
if (size > 0) bidMap.set(price, size);
}
this.bids.set(assetId, bidMap);
const askMap = new Map();
for (const a of (evt.asks || [])) {
const price = parseFloat(a.price);
const size = parseFloat(a.size);
if (size > 0) askMap.set(price, size);
}
this.asks.set(assetId, askMap);
if (this.onBookUpdate) {
this.onBookUpdate(assetId, this.getBook(assetId));
}
}
handlePriceChange(msg) {
const changes = msg.price_changes || [];
for (const change of changes) {
const assetId = change.asset_id;
if (!assetId || !this.assetIds.includes(assetId)) continue;
const price = parseFloat(change.price);
const size = parseFloat(change.size);
const side = change.side; // "BUY" = bid, "SELL" = ask
if (side === 'BUY') {
const bidMap = this.bids.get(assetId);
if (bidMap) {
if (size > 0) {
bidMap.set(price, size);
} else {
bidMap.delete(price); // size 0 = remove level
}
}
} else if (side === 'SELL') {
const askMap = this.asks.get(assetId);
if (askMap) {
if (size > 0) {
askMap.set(price, size);
} else {
askMap.delete(price);
}
}
}
// Update best bid/ask from the event
if (change.best_bid) this.bestBid.set(assetId, parseFloat(change.best_bid));
if (change.best_ask) this.bestAsk.set(assetId, parseFloat(change.best_ask));
}
// Notify for each affected asset
const affectedAssets = new Set(changes.map(c => c.asset_id).filter(id => this.assetIds.includes(id)));
for (const assetId of affectedAssets) {
if (this.onBookUpdate) {
this.onBookUpdate(assetId, this.getBook(assetId));
}
}
}
handleLastPrice(assetId, evt) {
if (!this.assetIds.includes(assetId)) return;
const price = parseFloat(evt.price || '0');
this.lastPrice.set(assetId, price);
if (this.onPriceUpdate) {
this.onPriceUpdate(assetId, price);
}
}
/**
* Get sorted orderbook for an asset
*/
getBook(assetId) {
const bidMap = this.bids.get(assetId) || new Map();
const askMap = this.asks.get(assetId) || new Map();
const bids = Array.from(bidMap.entries())
.map(([price, size]) => ({ price, size }))
.sort((a, b) => b.price - a.price);
const asks = Array.from(askMap.entries())
.map(([price, size]) => ({ price, size }))
.sort((a, b) => a.price - b.price);
return { bids, asks };
}
/**
* Check if a simulated order would fill based on current orderbook.
*/
checkSimFill(assetId, side, price, size) {
const book = this.getBook(assetId);
if (side === 'buy') {
const eligible = book.asks.filter((a) => a.price <= price);
if (eligible.length === 0) return null;
let filled = 0;
let totalCost = 0;
for (const ask of eligible) {
const take = Math.min(ask.size, size - filled);
filled += take;
totalCost += take * ask.price;
if (filled >= size) break;
}
if (filled > 0) {
return { filled: Math.min(filled, size), avgPrice: totalCost / filled };
}
} else {
const eligible = book.bids.filter((b) => b.price >= price);
if (eligible.length === 0) return null;
let filled = 0;
let totalRevenue = 0;
for (const bid of eligible) {
const take = Math.min(bid.size, size - filled);
filled += take;
totalRevenue += take * bid.price;
if (filled >= size) break;
}
if (filled > 0) {
return { filled: Math.min(filled, size), avgPrice: totalRevenue / filled };
}
}
return null;
}
getLastPrice(assetId) {
return this.lastPrice.get(assetId) || 0;
}
getBestBid(assetId) {
// Try from price_change data first, fallback to computed from book
const cached = this.bestBid.get(assetId);
if (cached) return cached;
const book = this.getBook(assetId);
return book.bids[0]?.price || 0;
}
getBestAsk(assetId) {
const cached = this.bestAsk.get(assetId);
if (cached) return cached;
const book = this.getBook(assetId);
return book.asks[0]?.price || 0;
}
getRecentTrades(assetId, limit = 10) {
const trades = this.trades.get(assetId) || [];
return trades.slice(-limit);
}
startPing() {
this.stopPing();
this.pingTimer = setInterval(() => {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.ping(); // Use proper WebSocket ping frames
}
}, PING_INTERVAL);
}
stopPing() {
if (this.pingTimer) {
clearInterval(this.pingTimer);
this.pingTimer = null;
}
}
cleanup(reconnect = true) {
this.stopPing();
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
this.ws.removeAllListeners();
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
this.ws.terminate();
}
this.ws = null;
}
if (reconnect && !this.isShutdown) {
this.reconnectTimer = setTimeout(() => {
this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_RECONNECT_DELAY);
this.connect();
}, this.reconnectDelay);
}
}
shutdown() {
this.isShutdown = true;
this.cleanup(false);
}
}
+6 -54
View File
@@ -7,31 +7,28 @@
* e.g. btc-updown-5m-1771755000
* eth-updown-15m-1771754100
*
* poll() targets the NEXT upcoming slot. checkCurrentMarket() enters the current slot on startup.
* NEVER enters the currently active market always targets the NEXT upcoming slot.
*/
import config from '../config/index.js';
import logger from '../utils/logger.js';
import { proxyFetch } from '../utils/proxy.js';
// Slot size in seconds (300 for 5m, 900 for 15m)
const SLOT_SEC = config.mmDuration === '15m' ? 900 : 300;
let pollTimer = null;
let onMarketCb = null;
const seenKeys = new Set(); // `${asset}-${slotTimestamp}` already scheduled
// ── Slot helpers ──────────────────────────────────────────────────────────────
// Computed dynamically so config.mmDuration overrides in maker-mm.js take effect.
function slotSec() {
return config.mmDuration === '15m' ? 900 : 300;
}
function currentSlot() {
const s = slotSec();
return Math.floor(Date.now() / 1000 / s) * s;
return Math.floor(Date.now() / 1000 / SLOT_SEC) * SLOT_SEC;
}
function nextSlot() {
return currentSlot() + slotSec();
return currentSlot() + SLOT_SEC;
}
// ── Gamma API fetch ───────────────────────────────────────────────────────────
@@ -153,48 +150,3 @@ export function stopMMDetector() {
pollTimer = null;
}
}
// ── Check current active market on startup ────────────────────────────────────
// Enters the currently running market slot if enough time remains.
// Enabled unconditionally for the maker rebate bot — call only from maker-mm.js.
export async function checkCurrentMarket(onMarketFound) {
const current = currentSlot();
const cutLossSec = config.makerMmCutLossTime ?? 60;
const tag = '[CURRENT]';
logger.info(`MM${tag}: checking current slot ${current} (${config.mmDuration}) for assets: ${config.mmAssets.join(', ').toUpperCase()}`);
for (const asset of config.mmAssets) {
const key = `${asset}-${current}`;
if (seenKeys.has(key)) {
logger.info(`MM${tag}: ${asset.toUpperCase()} already seen — skip`);
continue;
}
const market = await fetchBySlug(asset, current);
if (!market) {
logger.warn(`MM${tag}: ${asset.toUpperCase()} — no market found for slot ${current} (slug: ${asset}-updown-${config.mmDuration}-${current})`);
continue;
}
const data = extractMarketData(market, asset);
if (!data) {
logger.warn(`MM${tag}: ${asset.toUpperCase()} — market found but missing token IDs, skipping`);
seenKeys.add(key);
continue;
}
const msRemaining = new Date(data.endTime).getTime() - Date.now();
const secsRemaining = Math.round(msRemaining / 1000);
if (isNaN(secsRemaining) || secsRemaining <= cutLossSec) {
logger.info(`MM${tag}: ${asset.toUpperCase()} current market ${secsRemaining}s left (≤ cutLoss ${cutLossSec}s) — skipping`);
seenKeys.add(key);
continue;
}
seenKeys.add(key);
logger.success(`MM${tag}: ${asset.toUpperCase()} entering current market "${data.question.slice(0, 40)}" (${secsRemaining}s left)`);
onMarketFound(data);
}
}
+79 -540
View File
@@ -14,16 +14,12 @@ import { ethers } from 'ethers';
import config from '../config/index.js';
import { getClient, getUsdcBalance, getPolygonProvider } from './client.js';
import { splitPosition, mergePositions } from './ctf.js';
import { mmFillWatcher } from './mmWsFillWatcher.js';
import logger from '../utils/logger.js';
// CTF contract for on-chain balance queries
const CTF_ADDRESS = '0x4D97DCd97eC945f40cF65F87097ACe5EA0476045';
const CTF_BALANCE_ABI = ['function balanceOf(address account, uint256 id) view returns (uint256)'];
// Polymarket CLOB minimum order size (shares)
const CLOB_MIN_ORDER_SHARES = 5;
/**
* Get actual on-chain ERC1155 token balance for the proxy wallet.
* Used before market-sell to avoid 'not enough balance' errors from partial fills.
@@ -41,37 +37,6 @@ async function getTokenBalance(tokenId) {
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Fallback poll interval — WS handles the fast path, this is the safety net
const POLL_INTERVAL_MS = 30_000;
/**
* Wait for a fill event from WebSocket OR timeout (polling fallback).
* Returns early if WS delivers a fill for any of the watched token IDs.
* @param {string[]} tokenIds - token IDs to listen for
* @param {number} timeoutMs - max wait time before returning for poll check
* @returns {Promise<{tokenId: string, size: number, price: number} | 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); // timeout — caller does poll check
}, timeoutMs);
});
}
// In-memory store of all active MM positions (conditionId → position)
const activePositions = new Map();
@@ -142,89 +107,17 @@ async function marketSell(tokenId, shares, tickSize, negRisk) {
// ── Order status check ────────────────────────────────────────────────────────
async function isOrderFilled(orderId, shares, tokenId = null) {
async function isOrderFilled(orderId, shares) {
if (!orderId || orderId.startsWith('sim-')) return false;
const MAX_FILL_RETRIES = 2;
for (let attempt = 1; attempt <= MAX_FILL_RETRIES; attempt++) {
try {
const client = getClient();
const order = await client.getOrder(orderId);
if (!order) break; // order gone — fall through to balance check
if (order.status === 'MATCHED') return true;
const matched = parseFloat(order.size_matched || '0');
if (matched >= shares * 0.99) return true;
// CLOB says not filled — trust it if we have no tokenId for balance check
if (!tokenId) return false;
// Otherwise fall through to balance check below
break;
} catch (err) {
logger.warn(`MM: isOrderFilled CLOB error (attempt ${attempt}/${MAX_FILL_RETRIES}): ${err.message}`);
if (attempt < MAX_FILL_RETRIES) await sleep(2000);
}
}
// Fallback: check on-chain token balance
// If we placed a SELL and our balance is now ~0, the order was filled
if (tokenId) {
const balance = await getTokenBalance(tokenId);
if (balance !== null && balance < shares * 0.05) {
logger.warn(`MM: CLOB API missed fill — on-chain balance ${balance.toFixed(3)} ≈ 0 (expected ${shares}) → treating as filled`);
return true;
}
}
return false;
}
/**
* Check how many shares of an order have been partially filled.
* Returns { matched, remaining, total }.
*/
async function getPartialFillInfo(orderId, originalShares, tokenId = null) {
let matched = 0;
if (orderId && !orderId.startsWith('sim-')) {
try {
const client = getClient();
const order = await client.getOrder(orderId);
if (order) {
if (order.status === 'MATCHED') {
matched = parseFloat(order.original_size || order.size || String(originalShares));
} else {
matched = parseFloat(order.size_matched || '0');
}
}
} catch { /* ignore */ }
}
// Cross-check with on-chain balance for accuracy
if (tokenId) {
const balance = await getTokenBalance(tokenId);
if (balance !== null) {
const onChainMatched = originalShares - balance;
if (onChainMatched > matched) {
matched = Math.max(0, onChainMatched);
}
return { matched, remaining: balance, total: originalShares };
}
}
return { matched, remaining: originalShares - matched, total: originalShares };
}
/**
* Get partial fill amount for an order (how many shares already matched).
* Returns 0 on error.
*/
async function getOrderMatched(orderId) {
if (!orderId || orderId.startsWith('sim-')) return 0;
try {
const client = getClient();
const order = await client.getOrder(orderId);
if (!order) return 0;
if (order.status === 'MATCHED') return parseFloat(order.original_size || order.size || '0');
return parseFloat(order.size_matched || '0');
if (!order) return false;
if (order.status === 'MATCHED') return true;
const matched = parseFloat(order.size_matched || '0');
return matched >= shares * 0.99;
} catch {
return 0;
return false;
}
}
@@ -248,106 +141,11 @@ async function getMidprice(tokenId) {
} catch { return 0; }
}
// ── Per-side fill check (parallel-safe) ──────────────────────────────────────
/**
* Check one side (yes/no) for fills and partial fills.
* Returns true if this side became fully filled during this check.
* Safe to run in parallel for both sides.
*/
async function checkSideFill(pos, key) {
const s = pos[key];
if (s.filled) return false;
const label = key.toUpperCase();
let filled = false;
if (config.dryRun) {
const hitPrice = await simPriceHitTarget(s.tokenId);
if (hitPrice) { filled = true; s.fillPrice = hitPrice; }
} else {
filled = await isOrderFilled(s.orderId, s.shares, s.tokenId);
if (filled) s.fillPrice = config.mmSellPrice;
}
if (filled) {
s.filled = true;
const pnl = (s.fillPrice - s.entryPrice) * s.shares;
logger.money(`MM${config.dryRun ? '[SIM]' : ''}: ${label} filled @ $${s.fillPrice.toFixed(3)} | P&L $${pnl.toFixed(2)}`);
return true;
}
// Partial fill handling (live only)
if (config.dryRun) return false;
const info = await getPartialFillInfo(s.orderId, s.shares, s.tokenId);
if (info.matched > 0 && info.remaining > 0 && info.remaining < s.shares * 0.90) {
logger.warn(`MM: ${label} partially filled — ${info.matched.toFixed(3)}/${info.total.toFixed(3)} matched, ${info.remaining.toFixed(3)} remaining`);
await cancelOrder(s.orderId);
s.orderId = null;
s.shares = info.remaining;
s._partialRevenue = (s._partialRevenue || 0) + info.matched * config.mmSellPrice;
if (info.remaining < CLOB_MIN_ORDER_SHARES) {
logger.warn(`MM: ${label} remaining ${info.remaining.toFixed(3)} < ${CLOB_MIN_ORDER_SHARES} min — market selling remainder`);
const result = await marketSell(s.tokenId, info.remaining, pos.tickSize, pos.negRisk);
s.fillPrice = config.mmSellPrice;
s.filled = true;
const pnl = (s._partialRevenue + result.fillPrice * info.remaining) - s.entryPrice * info.total;
logger.money(`MM: ${label} fully sold (partial+market) | P&L $${pnl.toFixed(2)}`);
return true;
} else {
const res = await placeLimitSell(s.tokenId, info.remaining, config.mmSellPrice, pos.tickSize, pos.negRisk);
if (res.success) {
s.orderId = res.orderId;
logger.info(`MM: ${label} re-placed limit sell for ${info.remaining.toFixed(3)} shares @ $${config.mmSellPrice}`);
}
}
}
return false;
}
// ── Core monitoring loop (event-driven + parallel) ───────────────────────────
// ── Core monitoring loop ──────────────────────────────────────────────────────
async function monitorAndManage(pos) {
const label = pos.question.substring(0, 40);
// Register tokens with WS fill watcher for instant fill detection
mmFillWatcher.watch(pos.yes.tokenId);
mmFillWatcher.watch(pos.no.tokenId);
// Handle WS fill events — mark side as filled immediately
const onWsFill = (event) => {
for (const key of ['yes', 'no']) {
if (!pos[key].filled && event.tokenId === pos[key].tokenId && event.side === 'SELL') {
pos[key].filled = true;
pos[key].fillPrice = event.price || config.mmSellPrice;
const pnl = (pos[key].fillPrice - pos[key].entryPrice) * pos[key].shares;
logger.money(`MM: ${key.toUpperCase()} filled (WS realtime) @ $${pos[key].fillPrice.toFixed(3)} | P&L $${pnl.toFixed(2)}`);
}
}
};
mmFillWatcher.on('fill', onWsFill);
try {
await _monitorLoop(pos, label);
} finally {
// Cleanup WS listeners
mmFillWatcher.removeListener('fill', onWsFill);
mmFillWatcher.unwatch(pos.yes.tokenId);
mmFillWatcher.unwatch(pos.no.tokenId);
}
// Final P&L log
const totalPnl = calcPnl(pos);
const sign = totalPnl >= 0 ? '+' : '';
if (pos.status !== 'done') {
logger.info(`MM: strategy ended (${pos.status}) | P&L: ${sign}$${totalPnl.toFixed(2)} | ${label}`);
}
}
async function _monitorLoop(pos, label) {
while (true) {
const msRemaining = new Date(pos.endTime).getTime() - Date.now();
@@ -357,11 +155,39 @@ async function _monitorLoop(pos, label) {
break;
}
// ── Check YES + NO sides in parallel ────────────────────
await Promise.all([
checkSideFill(pos, 'yes'),
checkSideFill(pos, 'no'),
]);
// ── Check YES side ──────────────────────────────────────
if (!pos.yes.filled) {
let filled = false;
if (config.dryRun) {
const hitPrice = await simPriceHitTarget(pos.yes.tokenId);
if (hitPrice) { filled = true; pos.yes.fillPrice = hitPrice; }
} else {
filled = await isOrderFilled(pos.yes.orderId, pos.yes.shares);
if (filled) pos.yes.fillPrice = config.mmSellPrice;
}
if (filled) {
pos.yes.filled = true;
const pnl = (pos.yes.fillPrice - pos.yes.entryPrice) * pos.yes.shares;
logger.money(`MM${config.dryRun ? '[SIM]' : ''}: YES filled @ $${pos.yes.fillPrice.toFixed(3)} | P&L $${pnl.toFixed(2)}`);
}
}
// ── Check NO side ───────────────────────────────────────
if (!pos.no.filled) {
let filled = false;
if (config.dryRun) {
const hitPrice = await simPriceHitTarget(pos.no.tokenId);
if (hitPrice) { filled = true; pos.no.fillPrice = hitPrice; }
} else {
filled = await isOrderFilled(pos.no.orderId, pos.no.shares);
if (filled) pos.no.fillPrice = config.mmSellPrice;
}
if (filled) {
pos.no.filled = true;
const pnl = (pos.no.fillPrice - pos.no.entryPrice) * pos.no.shares;
logger.money(`MM${config.dryRun ? '[SIM]' : ''}: NO filled @ $${pos.no.fillPrice.toFixed(3)} | P&L $${pnl.toFixed(2)}`);
}
}
// ── Both filled → done ──────────────────────────────────
if (pos.yes.filled && pos.no.filled) {
@@ -378,90 +204,31 @@ async function _monitorLoop(pos, label) {
break;
}
// ── Defensive pivot: neither filled after timeout (5m markets only) ──
if (config.mmDefensiveEnabled && config.mmDuration === '5m'
&& !pos.yes.filled && !pos.no.filled && !pos._defensiveActive) {
const marketDurationMs = 5 * 60 * 1000;
const marketStartMs = new Date(pos.endTime).getTime() - marketDurationMs;
const elapsed = (Date.now() - marketStartMs) / 1000;
if (elapsed >= config.mmDefensiveTimeout) {
// Cancel both orders FIRST so they can't fill while we wait
logger.warn(`MM: neither side filled after ${Math.round(elapsed)}s since market open — cancelling orders | ${label}`);
await Promise.all([
cancelOrder(pos.yes.orderId),
cancelOrder(pos.no.orderId),
]);
pos.yes.orderId = null;
pos.no.orderId = null;
// Re-check fills after cancellation — CLOB may have filled one side
// between our last check and the cancel (race condition)
await sleep(2000);
const [yesBalance, noBalance] = await Promise.all([
getTokenBalance(pos.yes.tokenId),
getTokenBalance(pos.no.tokenId),
]);
for (const [key, balance] of [['yes', yesBalance], ['no', noBalance]]) {
if (!pos[key].filled && balance !== null && balance < pos[key].shares * 0.05) {
logger.warn(`MM: ${key.toUpperCase()} actually filled (on-chain balance ${balance.toFixed(3)} ≈ 0) — detected after cancel`);
pos[key].filled = true;
pos[key].fillPrice = config.mmSellPrice;
const pnl = (pos[key].fillPrice - pos[key].entryPrice) * pos[key].shares;
logger.money(`MM: ${key.toUpperCase()} filled @ $${pos[key].fillPrice.toFixed(3)} | P&L $${pnl.toFixed(2)}`);
}
}
// If one side is now filled, go to adaptive CL instead of defensive pivot
if (pos.yes.filled !== pos.no.filled) {
const unfilledKey = pos.yes.filled ? 'no' : 'yes';
logger.warn(`MM: one side filled after cancel — switching to adaptive CL for ${unfilledKey.toUpperCase()} instead of defensive pivot`);
await adaptiveLegCL(pos, unfilledKey);
break;
}
// If both filled (unlikely but possible), we're done
if (pos.yes.filled && pos.no.filled) {
pos.status = 'done';
const totalPnl = calcPnl(pos);
logger.money(`MM: BOTH sides filled! Total P&L: $${totalPnl.toFixed(2)} | ${label}`);
break;
}
// Neither filled — proceed with defensive pivot
pos._defensiveActive = true;
await defensivePivot(pos);
break;
}
}
// ── Cut-loss time ────────────────────────────────────────────────────
if (msRemaining <= config.mmCutLossTime * 1000) {
logger.warn(`MM: cut-loss triggered (${Math.round(msRemaining / 1000)}s left) — ${label}`);
pos.status = 'cutting';
const oneLegFilled = pos.yes.filled !== pos.no.filled;
if (!config.mmAdaptiveCL && oneLegFilled) {
// Legacy: one side sold → immediate market sell on the other
const unfilledKey = pos.yes.filled ? 'no' : 'yes';
await cutLossOneLegFilled(pos, unfilledKey);
pos.status = 'done';
} else {
// Neither filled → cancel both + merge back to USDC
await cutLossNeitherFilled(pos);
}
break;
}
// ── Wait for WS fill event or polling fallback ───────────────────────
// WS gives us instant fill detection; polling at 30s is just a safety net
const watchTokens = [];
if (!pos.yes.filled) watchTokens.push(pos.yes.tokenId);
if (!pos.no.filled) watchTokens.push(pos.no.tokenId);
await sleep(10_000);
}
const wsEvent = await waitForFillOrTimeout(watchTokens, POLL_INTERVAL_MS);
if (wsEvent) {
// WS detected a fill — the onWsFill listener already updated pos,
// but loop back immediately to run the decision logic
logger.info(`MM: WS fill event received — re-checking immediately`);
}
// Final P&L log
const totalPnl = calcPnl(pos);
const sign = totalPnl >= 0 ? '+' : '';
if (pos.status !== 'done') {
logger.info(`MM: strategy ended (${pos.status}) | P&L: ${sign}$${totalPnl.toFixed(2)} | ${label}`);
}
}
@@ -496,10 +263,8 @@ async function cutLossNeitherFilled(pos) {
// ── Best case: neither side sold → cancel both, merge back to USDC ──
logger.warn('MM: neither side filled — cancelling orders and merging back to USDC...');
await Promise.all([
cancelOrder(pos.yes.orderId),
cancelOrder(pos.no.orderId),
]);
await cancelOrder(pos.yes.orderId);
await cancelOrder(pos.no.orderId);
// Read actual on-chain balances (may differ from original if partially consumed)
const [yesActual, noActual] = await Promise.all([
@@ -531,104 +296,6 @@ async function cutLossNeitherFilled(pos) {
await attemptRecoveryBuy(pos);
}
// ── Defensive Pivot (5m markets, neither side filled) ────────────────────────
/**
* Defensive pivot: neither side has filled after MM_DEFENSIVE_TIMEOUT.
*
* Strategy:
* 1. Orders already cancelled by caller (monitorAndManage)
* 2. Wait until 45s before close
* 3. Check prices: identify worst (lower price) and best (higher price) side
* 4. If worst < MM_DEFENSIVE_WORST_THRESHOLD (default 10c):
* market sell worst side, keep best side (let it resolve at close)
* since YES+NO $1, best side is ~90c+ profit potential
* 5. If worst threshold: market is still uncertain merge back ($0 P&L)
*/
async function defensivePivot(pos) {
const { conditionId, tickSize, negRisk } = pos;
const label = pos.question.substring(0, 40);
const threshold = config.mmDefensiveWorstThreshold;
// Orders already cancelled by monitorAndManage before entering here
logger.info(`MM defensive: waiting for 45s before close | ${label}`);
// Wait until 45s before close, checking every 5s
while (true) {
const msLeft = new Date(pos.endTime).getTime() - Date.now();
if (msLeft <= 45_000) break; // 45s mark reached
if (msLeft <= 0) {
pos.status = 'expired';
return;
}
await sleep(5000);
}
// Read current prices for both sides
const [yesPrice, noPrice] = await Promise.all([
getMidprice(pos.yes.tokenId),
getMidprice(pos.no.tokenId),
]);
logger.info(`MM defensive: 45s mark — YES=$${yesPrice.toFixed(3)}, NO=$${noPrice.toFixed(3)} | threshold=$${threshold} | ${label}`);
// Determine worst and best sides
const worstKey = yesPrice <= noPrice ? 'yes' : 'no';
const bestKey = worstKey === 'yes' ? 'no' : 'yes';
const worstPrice = Math.min(yesPrice, noPrice);
const bestPrice = Math.max(yesPrice, noPrice);
// ── Decision: pivot or merge? ─────────────────────────────────────────
if (worstPrice < threshold) {
// Worst side < 10c → market is decisive, pivot!
logger.trade(`MM defensive: worst side ${worstKey.toUpperCase()} @ $${worstPrice.toFixed(3)} < $${threshold} — selling worst, keeping ${bestKey.toUpperCase()} @ $${bestPrice.toFixed(3)}`);
const worstSide = pos[worstKey];
const bestSide = pos[bestKey];
// Get actual on-chain balances
const [worstBalance, bestBalance] = await Promise.all([
getTokenBalance(worstSide.tokenId),
getTokenBalance(bestSide.tokenId),
]);
const worstShares = worstBalance !== null ? worstBalance : worstSide.shares;
const bestShares = bestBalance !== null ? bestBalance : bestSide.shares;
// Market sell worst side
if (worstShares >= 0.001) {
const result = await marketSell(worstSide.tokenId, worstShares, tickSize, negRisk);
worstSide.fillPrice = result.fillPrice;
worstSide.filled = true;
logger.warn(`MM defensive: sold ${worstKey.toUpperCase()} ${worstShares.toFixed(3)} sh @ $${result.fillPrice.toFixed(3)}`);
} else {
worstSide.fillPrice = 0;
worstSide.filled = true;
}
// Best side: let it resolve at market close (hold the tokens)
// The market will resolve and we can redeem via the redeemer
// Best side price is ~90c+ so payout ≈ $1 per share if it wins
logger.money(`MM defensive: holding ${bestKey.toUpperCase()} ${bestShares.toFixed(3)} sh @ ~$${bestPrice.toFixed(3)} — waiting for resolution`);
logger.info(`MM defensive: expected payout if ${bestKey.toUpperCase()} wins: ~$${bestShares.toFixed(2)} | cost was $${(bestSide.entryPrice * bestShares).toFixed(2)}`);
// Mark best side as filled at entry price for now — actual payout handled by redeemer
bestSide.fillPrice = bestSide.entryPrice;
bestSide.filled = true;
pos.status = 'done';
const worstPnl = worstSide.fillPrice
? (worstSide.fillPrice - worstSide.entryPrice) * worstShares
: 0;
logger.info(`MM defensive: worst side P&L: $${worstPnl.toFixed(2)} | best side will be redeemed after resolution`);
} else {
// Worst side ≥ 10c → market uncertain, safer to merge
logger.info(`MM defensive: worst side ${worstKey.toUpperCase()} @ $${worstPrice.toFixed(3)}$${threshold} — market uncertain, merging back to USDC`);
await cutLossNeitherFilled(pos);
}
}
async function adaptiveLegCL(pos, unfilledKey) {
const s = pos[unfilledKey];
const { tickSize, negRisk } = pos;
@@ -643,36 +310,8 @@ async function adaptiveLegCL(pos, unfilledKey) {
const filledLegPrice = pos[filledKey].fillPrice ?? config.mmSellPrice;
const minAdaptivePrice = Math.max(0, config.mmAdaptiveMinCombined - filledLegPrice);
// ── Tiered floors (5m markets): progressively lower floor over time ────
// Start from minAdaptivePrice (MM_ADAPTIVE_MIN_COMBINED - filledPrice), then drop per phase
const floorDrop = config.mmDefensiveEnabled ? 0.10 : 0;
const emergencyPrice = config.mmDefensiveWorstThreshold; // default 0.10
const is5m = config.mmDuration === '5m';
/**
* Get the current floor based on time remaining (5m markets only).
* Other durations use the fixed mmAdaptiveMinCombined floor.
*
* Phase 1 (> 180s left): minAdaptivePrice (from MM_ADAPTIVE_MIN_COMBINED)
* Phase 2 (90180s): minAdaptivePrice - 0.10
* Phase 3 (3090s): minAdaptivePrice - 0.20
* Phase 4 (< 30s): market sell
*/
function getTieredFloor(msLeft) {
if (!is5m) return minAdaptivePrice; // non-5m: use fixed floor
if (msLeft > 180_000) return minAdaptivePrice;
if (msLeft > 90_000) return Math.max(0.01, minAdaptivePrice - floorDrop);
if (msLeft > 30_000) return Math.max(0.01, minAdaptivePrice - floorDrop * 2);
return 0; // phase 4: market sell
}
logger.warn(`MM: one leg filled — starting adaptive CL for ${unfilledKey.toUpperCase()} | ${label}`);
if (is5m) {
logger.info(`MM adaptive CL: filled @ $${filledLegPrice.toFixed(3)} | floor (minCombined $${config.mmAdaptiveMinCombined.toFixed(2)}): $${minAdaptivePrice.toFixed(3)} | tiered: $${minAdaptivePrice.toFixed(2)}$${Math.max(0.01, minAdaptivePrice - floorDrop).toFixed(2)}$${Math.max(0.01, minAdaptivePrice - floorDrop * 2).toFixed(2)}`);
} else {
logger.info(`MM adaptive CL: filled leg @ $${filledLegPrice.toFixed(3)} | min floor for combined ≥ $${config.mmAdaptiveMinCombined.toFixed(2)}: $${minAdaptivePrice.toFixed(3)}`);
}
logger.info(`MM adaptive CL: filled leg @ $${filledLegPrice.toFixed(3)} | min floor for combined ≥ $${config.mmAdaptiveMinCombined.toFixed(2)}: $${minAdaptivePrice.toFixed(3)}`);
// Cancel the unfilled leg's old GTC order immediately
await cancelOrder(s.orderId);
@@ -690,49 +329,25 @@ async function adaptiveLegCL(pos, unfilledKey) {
return;
}
// If remaining shares below CLOB minimum, market sell immediately instead of trying limit
if (sellShares < CLOB_MIN_ORDER_SHARES) {
logger.warn(`MM adaptive CL: ${unfilledKey.toUpperCase()} remaining ${sellShares.toFixed(3)} shares < ${CLOB_MIN_ORDER_SHARES} minimum — market selling immediately`);
const result = await marketSell(s.tokenId, sellShares, tickSize, negRisk);
s.fillPrice = result.fillPrice;
s.filled = true;
pos.status = 'done';
const pnl = (s.fillPrice - s.entryPrice) * sellShares;
const combined = filledLegPrice + s.fillPrice;
logger.warn(`MM adaptive CL: ${unfilledKey.toUpperCase()} market-sold ${sellShares.toFixed(3)} sh @ $${s.fillPrice.toFixed(3)} | combined $${combined.toFixed(3)} | P&L $${pnl.toFixed(2)}`);
return;
}
logger.info(`MM adaptive CL: monitoring ${unfilledKey.toUpperCase()} — limit only when price ≥ $${minAdaptivePrice.toFixed(3)}, market-sell only at CL time`);
// Place standing order at breakeven floor immediately (5m) so brief bounces get caught
let activeOrderId = null;
let activeLimitPrice = 0;
let currentFloor = minAdaptivePrice;
if (is5m && sellShares >= CLOB_MIN_ORDER_SHARES) {
// Check mid price first — place at market price (not just breakeven floor)
const initMid = await getMidprice(s.tokenId);
// Use mid price if above floor, otherwise use floor as safety net
const initSellPrice = initMid >= currentFloor
? Math.min(initMid, config.mmSellPrice)
: currentFloor;
logger.info(`MM adaptive CL: mid=$${initMid.toFixed(3)}, placing initial limit sell @ $${initSellPrice.toFixed(3)} (floor=$${currentFloor.toFixed(3)})`);
const standing = await placeLimitSell(s.tokenId, sellShares, initSellPrice, tickSize, negRisk);
if (standing.success) {
activeOrderId = standing.orderId;
activeLimitPrice = initSellPrice;
}
} else {
logger.info(`MM adaptive CL: monitoring ${unfilledKey.toUpperCase()} — floor $${currentFloor.toFixed(3)}, market-sell at CL time`);
}
// ── Continuous monitoring loop ─────────────────────────────────────────────
let lastPhaseLog = '';
// Every poll cycle:
// 1. CL time → cancel limit, market sell (last resort)
// 2. Check fill → done
// 3. Read current price
// 4a. Price < floor OR dropped >5% → cancel limit, keep watching
// 4b. Price improved >2% → cancel and re-place higher
// 5a. price >= floor → place/maintain limit at min(currentPrice, mmSellPrice)
// 5b. price < floor → no limit placed, log & wait (never sell below profit floor)
while (true) {
const msLeft = new Date(pos.endTime).getTime() - Date.now();
// ── Phase 4 / CL time: force market sell ────────────────────────────
if (msLeft <= (is5m ? 30_000 : config.mmCutLossTime * 1000)) {
// ── CL time: last resort market sell ───────────────────────────────
if (msLeft <= config.mmCutLossTime * 1000) {
if (activeOrderId) {
await cancelOrder(activeOrderId);
activeOrderId = null;
@@ -740,27 +355,6 @@ async function adaptiveLegCL(pos, unfilledKey) {
break;
}
// ── Update tiered floor ─────────────────────────────────────────────
const newFloor = getTieredFloor(msLeft);
if (newFloor !== currentFloor) {
const phase = msLeft > 180_000 ? '1-breakeven' : msLeft > 90_000 ? '2-controlled' : '3-emergency';
if (phase !== lastPhaseLog) {
logger.info(`MM adaptive CL: phase ${phase} — floor $${currentFloor.toFixed(3)}$${newFloor.toFixed(3)} (${Math.round(msLeft / 1000)}s left)`);
lastPhaseLog = phase;
}
// If floor lowered and we have an active order above new floor, keep it
// Only cancel+re-place if the floor dropped below our current limit
if (activeOrderId && activeLimitPrice > newFloor) {
// Current limit is above new floor — that's fine, keep it
} else if (activeOrderId && activeLimitPrice < newFloor) {
// Floor raised (shouldn't happen in tiered, but safety)
await cancelOrder(activeOrderId);
activeOrderId = null;
activeLimitPrice = 0;
}
currentFloor = newFloor;
}
// ── Check fill ──────────────────────────────────────────────────────
if (activeOrderId) {
let filled = false;
@@ -768,7 +362,7 @@ async function adaptiveLegCL(pos, unfilledKey) {
const hitPrice = await simPriceHitTarget(s.tokenId);
if (hitPrice) { filled = true; s.fillPrice = hitPrice; }
} else {
filled = await isOrderFilled(activeOrderId, sellShares, s.tokenId);
filled = await isOrderFilled(activeOrderId, sellShares);
if (filled) s.fillPrice = activeLimitPrice;
}
@@ -789,27 +383,17 @@ async function adaptiveLegCL(pos, unfilledKey) {
continue;
}
// ── Emergency cut: price < 10c in phase 3 → market sell immediately ─
if (is5m && msLeft <= 90_000 && currentPrice < emergencyPrice) {
logger.warn(`MM adaptive CL: EMERGENCY — price $${currentPrice.toFixed(3)} < $${emergencyPrice} with ${Math.round(msLeft / 1000)}s left — market selling now`);
if (activeOrderId) {
await cancelOrder(activeOrderId);
activeOrderId = null;
}
break; // fall through to market sell below
}
const targetPrice = Math.min(currentPrice, config.mmSellPrice);
// ── Adjust or cancel active limit ───────────────────────────────────
if (activeOrderId) {
const belowFloor = currentPrice < currentFloor;
const belowFloor = currentPrice < minAdaptivePrice;
const droppedHard = currentPrice < activeLimitPrice * 0.95;
const priceImproved = targetPrice > activeLimitPrice * 1.02;
if (belowFloor || droppedHard) {
const reason = belowFloor
? `below floor $${currentFloor.toFixed(3)}`
? `below floor $${minAdaptivePrice.toFixed(3)} (combined $${(filledLegPrice + currentPrice).toFixed(3)} < $${config.mmAdaptiveMinCombined.toFixed(2)})`
: `dropped >5% from limit $${activeLimitPrice.toFixed(3)}`;
logger.info(`MM adaptive CL: price $${currentPrice.toFixed(3)} ${reason} — cancelling limit, watching for recovery`);
await cancelOrder(activeOrderId);
@@ -824,47 +408,17 @@ async function adaptiveLegCL(pos, unfilledKey) {
}
}
// ── Place limit at floor or above ───────────────────────────────────
// ── Place limit only above the profitable floor ─────────────────────
if (!activeOrderId) {
// Re-check actual balance — partial fills may have reduced it
const currentBalance = await getTokenBalance(s.tokenId);
const remainingShares = currentBalance !== null ? currentBalance : sellShares;
if (remainingShares < 0.001) {
logger.warn(`MM adaptive CL: ${unfilledKey.toUpperCase()} balance is 0 — fully sold via partial fills`);
s.fillPrice = config.mmSellPrice;
s.filled = true;
pos.status = 'done';
return;
}
if (remainingShares < CLOB_MIN_ORDER_SHARES) {
logger.warn(`MM adaptive CL: ${unfilledKey.toUpperCase()} remaining ${remainingShares.toFixed(3)} shares < ${CLOB_MIN_ORDER_SHARES} minimum — market selling`);
const result = await marketSell(s.tokenId, remainingShares, tickSize, negRisk);
s.fillPrice = result.fillPrice;
s.filled = true;
pos.status = 'done';
const pnl = (s.fillPrice - s.entryPrice) * remainingShares;
const combined = filledLegPrice + s.fillPrice;
logger.warn(`MM adaptive CL: ${unfilledKey.toUpperCase()} market-sold ${remainingShares.toFixed(3)} sh @ $${s.fillPrice.toFixed(3)} | combined $${combined.toFixed(3)} | P&L $${pnl.toFixed(2)}`);
return;
}
// Place at max(currentPrice, floor) — standing order strategy
const sellPrice = Math.max(currentPrice, currentFloor);
const limitPrice = Math.min(sellPrice, config.mmSellPrice);
if (currentPrice >= currentFloor || is5m) {
// 5m: always place at floor or above (standing order catches bounces)
// non-5m: only place when price >= floor
logger.info(`MM adaptive CL: placing limit sell @ $${limitPrice.toFixed(3)} (mid: $${currentPrice.toFixed(3)}, floor: $${currentFloor.toFixed(3)}, ${Math.round(msLeft / 1000)}s left)`);
const result = await placeLimitSell(s.tokenId, remainingShares, limitPrice, tickSize, negRisk);
if (currentPrice >= minAdaptivePrice) {
logger.info(`MM adaptive CL: placing limit sell @ $${targetPrice.toFixed(3)} (mid: $${currentPrice.toFixed(3)}, combined: $${(filledLegPrice + targetPrice).toFixed(3)}, ${Math.round(msLeft / 1000)}s left)`);
const result = await placeLimitSell(s.tokenId, sellShares, targetPrice, tickSize, negRisk);
if (result.success) {
activeOrderId = result.orderId;
activeLimitPrice = limitPrice;
activeLimitPrice = targetPrice;
}
} else {
logger.info(`MM adaptive CL: price $${currentPrice.toFixed(3)} below floor $${currentFloor.toFixed(3)} — waiting for recovery (${Math.round(msLeft / 1000)}s left)`);
logger.info(`MM adaptive CL: price $${currentPrice.toFixed(3)} below floor $${minAdaptivePrice.toFixed(3)} (combined $${(filledLegPrice + currentPrice).toFixed(3)}) — waiting for recovery (${Math.round(msLeft / 1000)}s left)`);
}
}
@@ -872,25 +426,12 @@ async function adaptiveLegCL(pos, unfilledKey) {
}
// ── Fallback: market sell at CL time ───────────────────────────────────────
// Re-check actual balance before market sell (partial fills may have occurred)
const finalBalance = await getTokenBalance(s.tokenId);
const finalShares = finalBalance !== null ? finalBalance : sellShares;
if (finalShares < 0.001) {
logger.warn(`MM adaptive CL: ${unfilledKey.toUpperCase()} balance is 0 at CL time — already fully sold`);
s.fillPrice = config.mmSellPrice;
s.filled = true;
pos.status = 'done';
return;
}
const exitReason = is5m ? 'phase 4 force exit (<30s)' : 'CL time reached';
logger.warn(`MM adaptive CL: ${exitReason} — market-selling ${finalShares.toFixed(3)} ${unfilledKey.toUpperCase()} shares`);
const result = await marketSell(s.tokenId, finalShares, tickSize, negRisk);
logger.warn(`MM adaptive CL: CL time reached — market-selling ${sellShares.toFixed(3)} ${unfilledKey.toUpperCase()} shares`);
const result = await marketSell(s.tokenId, sellShares, tickSize, negRisk);
s.fillPrice = result.fillPrice;
const pnl = (s.fillPrice - s.entryPrice) * finalShares;
const pnl = (s.fillPrice - s.entryPrice) * sellShares;
const combined = filledLegPrice + s.fillPrice;
logger.warn(`MM adaptive CL: ${unfilledKey.toUpperCase()} market-sold @ $${s.fillPrice.toFixed(3)} | combined $${combined.toFixed(3)} | sold ${finalShares.toFixed(3)} sh | P&L $${pnl.toFixed(2)}`);
logger.warn(`MM adaptive CL: ${unfilledKey.toUpperCase()} market-sold @ $${s.fillPrice.toFixed(3)} | combined $${combined.toFixed(3)} | sold ${sellShares.toFixed(3)} sh | P&L $${pnl.toFixed(2)}`);
s.filled = true;
pos.status = 'done';
@@ -1085,12 +626,10 @@ export async function executeMMStrategy(market) {
const entryPrice = 0.50;
logger.info(`MM${tag}: split done — ${shares} YES + ${shares} NO @ $${entryPrice}`);
// ── Place limit sells (parallel) ────────────────────────────
// ── Place limit sells ───────────────────────────────────────
logger.info(`MM${tag}: ${sim}placing limit sells @ $${config.mmSellPrice}`);
const [yesSell, noSell] = await Promise.all([
placeLimitSell(yesTokenId, shares, config.mmSellPrice, tickSize, negRisk),
placeLimitSell(noTokenId, shares, config.mmSellPrice, tickSize, negRisk),
]);
const yesSell = await placeLimitSell(yesTokenId, shares, config.mmSellPrice, tickSize, negRisk);
const noSell = await placeLimitSell(noTokenId, shares, config.mmSellPrice, tickSize, negRisk);
if (!yesSell.success || !noSell.success) {
logger.error(`MM${tag}: failed to place limit sells — cutting immediately`);
-208
View File
@@ -1,208 +0,0 @@
/**
* mmWsFillWatcher.js
* Real-time order fill detection for the Market Maker via Polymarket RTDS WebSocket.
*
* Subscribes to the `activity` topic and filters events by the bot's own proxy wallet.
* When a SELL trade is detected on a token we're watching, emits a 'fill' event
* so mmExecutor can react instantly instead of polling every 10s.
*
* Usage:
* import { mmFillWatcher } from './mmWsFillWatcher.js';
* mmFillWatcher.watch(tokenId); // start watching a token
* mmFillWatcher.unwatch(tokenId); // stop watching
* mmFillWatcher.on('fill', ({ tokenId, size, price }) => { ... });
* mmFillWatcher.start();
* mmFillWatcher.stop();
*/
import WebSocket from 'ws';
import { EventEmitter } from 'events';
import config from '../config/index.js';
import logger from '../utils/logger.js';
const RTDS_WS_URL = 'wss://ws-live-data.polymarket.com';
const PING_INTERVAL_MS = 5000;
const INITIAL_RECONNECT_DELAY = 2000;
const MAX_RECONNECT_DELAY = 30000;
class MMFillWatcher extends EventEmitter {
constructor() {
super();
this._ws = null;
this._pingTimer = null;
this._reconnectTimer = null;
this._reconnectDelay = INITIAL_RECONNECT_DELAY;
this._shuttingDown = false;
this._watchedTokens = new Set(); // token IDs we care about
this._connected = false;
}
/** Register a token ID to watch for fills */
watch(tokenId) {
if (tokenId) this._watchedTokens.add(tokenId);
}
/** Stop watching a token ID */
unwatch(tokenId) {
this._watchedTokens.delete(tokenId);
}
/** Check if currently connected */
get connected() {
return this._connected;
}
/** Start the WebSocket connection */
start() {
this._shuttingDown = false;
this._reconnectDelay = INITIAL_RECONNECT_DELAY;
this._connect();
}
/** Gracefully stop */
stop() {
this._shuttingDown = true;
this._cleanup(false);
this._watchedTokens.clear();
logger.info('MM fill watcher stopped');
}
// ── Internal ─────────────────────────────────────────────────────────────
_connect() {
if (this._shuttingDown) return;
logger.info('MM fill watcher: connecting to RTDS WebSocket...');
this._ws = new WebSocket(RTDS_WS_URL);
this._ws.on('open', () => {
this._connected = true;
this._reconnectDelay = INITIAL_RECONNECT_DELAY;
logger.success('MM fill watcher: WebSocket connected');
this._ws.send(JSON.stringify({
action: 'subscribe',
subscriptions: [{
topic: 'activity',
type: 'trades',
}],
}));
this._startPing();
});
this._ws.on('message', (data) => this._handleMessage(data));
this._ws.on('ping', () => {
this._ws?.pong();
});
this._ws.on('close', (code, reason) => {
this._connected = false;
const reasonStr = reason ? reason.toString() : 'no reason';
logger.warn(`MM fill watcher: WS closed (${code}): ${reasonStr}`);
this._cleanup(true);
});
this._ws.on('error', (err) => {
this._connected = false;
logger.error(`MM fill watcher: WS error: ${err.message}`);
this._cleanup(true);
});
}
_handleMessage(rawData) {
let msg;
try {
msg = JSON.parse(rawData.toString());
} catch {
const text = rawData.toString().trim();
if (text === 'ping') this._ws?.send('pong');
return;
}
if (msg.type === 'ping' || msg === 'ping') {
this._ws?.send('pong');
return;
}
if (msg.topic !== 'activity') return;
const payload = msg.payload;
if (!payload) return;
// Filter: only our own proxy wallet
const ourWallet = config.proxyWallet?.toLowerCase();
if (!ourWallet) return;
const proxyWallet = (payload.proxyWallet || payload.proxy_wallet || '').toLowerCase();
if (proxyWallet !== ourWallet) return;
// Filter: only tokens we're watching
const tokenId = payload.asset || '';
if (!tokenId || !this._watchedTokens.has(tokenId)) return;
const side = (payload.side || '').toUpperCase();
const size = parseFloat(payload.size || '0');
const price = parseFloat(payload.price || '0');
if (size <= 0) return;
logger.info(`MM fill watcher: detected ${side} on token ${tokenId.slice(-8)}${size} shares @ $${price.toFixed(3)}`);
this.emit('fill', {
tokenId,
side,
size,
price,
conditionId: payload.conditionId || payload.condition_id || '',
timestamp: payload.timestamp || new Date().toISOString(),
});
}
_startPing() {
this._stopPing();
this._pingTimer = setInterval(() => {
if (this._ws?.readyState === WebSocket.OPEN) {
this._ws.send('ping');
}
}, PING_INTERVAL_MS);
}
_stopPing() {
if (this._pingTimer) {
clearInterval(this._pingTimer);
this._pingTimer = null;
}
}
_cleanup(reconnect = true) {
this._stopPing();
if (this._reconnectTimer) {
clearTimeout(this._reconnectTimer);
this._reconnectTimer = null;
}
if (this._ws) {
this._ws.removeAllListeners();
if (this._ws.readyState === WebSocket.OPEN || this._ws.readyState === WebSocket.CONNECTING) {
this._ws.terminate();
}
this._ws = null;
}
this._connected = false;
if (reconnect && !this._shuttingDown) {
this._scheduleReconnect();
}
}
_scheduleReconnect() {
logger.info(`MM fill watcher: reconnecting in ${this._reconnectDelay / 1000}s...`);
this._reconnectTimer = setTimeout(() => {
this._reconnectDelay = Math.min(this._reconnectDelay * 2, MAX_RECONNECT_DELAY);
this._connect();
}, this._reconnectDelay);
}
}
// Singleton instance
export const mmFillWatcher = new MMFillWatcher();
+3 -32
View File
@@ -4,34 +4,21 @@
* - Tier 1: 3c price, smallest size (20% of max)
* - Tier 2: 2c price, medium size (30% of max)
* - Tier 3: 1c price, largest size (50% of max)
* Min 5 shares per tier, total = SNIPER_MAX_SHARES × timeMultiplier
* Min 5 shares per tier, total = SNIPER_MAX_SHARES
*/
import { Side, OrderType } from '@polymarket/clob-client';
import config from '../config/index.js';
import { getClient } from './client.js';
import logger from '../utils/logger.js';
import { getTimeMultiplier } from './sniperSizing.js';
// In-memory tracking of placed snipe orders
const activeSnipes = [];
// conditionId → { asset, yesTokenId, noTokenId } mapping
// yesTokenId = outcome 0 (clobTokenIds[0]), noTokenId = outcome 1 (clobTokenIds[1])
const conditionInfoMap = new Map();
export function getActiveSnipes() {
return [...activeSnipes];
}
export function getConditionAsset(conditionId) {
return conditionInfoMap.get(conditionId)?.asset || null;
}
export function getConditionInfo(conditionId) {
return conditionInfoMap.get(conditionId) || null;
}
/**
* Calculate tier sizes based on max shares.
* Distribution: 20% | 30% | 50% (highlow price)
@@ -61,31 +48,15 @@ export async function executeSnipe(market) {
const label = question.slice(0, 40);
const sim = config.dryRun ? '[SIM] ' : '';
// Track conditionId → { asset, token IDs } for win detection
// yesTokenId = outcome 0 (clobTokenIds[0]), noTokenId = outcome 1 (clobTokenIds[1])
conditionInfoMap.set(conditionId, {
asset: asset.toLowerCase(),
yesTokenId,
noTokenId,
});
const sides = [
{ name: 'UP', tokenId: yesTokenId },
{ name: 'DOWN', tokenId: noTokenId },
];
// Apply time-based multiplier
const { multiplier, label: mulLabel } = getTimeMultiplier();
const effectiveMaxShares = Math.max(
config.sniperMinSharesPerTier * 3,
Math.round(config.sniperMaxShares * multiplier),
);
const prices = config.sniperTierPrices;
const sizes = calculateTierSizes(effectiveMaxShares, config.sniperMinSharesPerTier);
const sizes = calculateTierSizes(config.sniperMaxShares, config.sniperMinSharesPerTier);
const mulInfo = multiplier !== 1.0 ? ` | mul ${mulLabel}` : '';
logger.info(`SNIPER: ${sim}${asset.toUpperCase()} — "${label}" | 3-tier: 3c×${sizes[0]} | 2c×${sizes[1]} | 1c×${sizes[2]}${mulInfo}`);
logger.info(`SNIPER: ${sim}${asset.toUpperCase()} — "${label}" | 3-tier: 3c×${sizes[0]} | 2c×${sizes[1]} | 1c×${sizes[2]}`);
for (const { name, tokenId } of sides) {
// Place 3 orders per side
-50
View File
@@ -1,50 +0,0 @@
/**
* sniperSizing.js
* Time-based multiplier for sniper bet sizing.
* All time windows are specified in UTC+8.
*/
import config from '../config/index.js';
const UTC8_OFFSET = 8;
/**
* Convert HH:MM (UTC+8) to minutes-since-midnight UTC.
*/
function utc8ToUtcMinutes(hhmm) {
const [h, m] = hhmm.split(':').map(Number);
let totalMin = (h * 60 + m) - (UTC8_OFFSET * 60);
if (totalMin < 0) totalMin += 1440;
if (totalMin >= 1440) totalMin -= 1440;
return totalMin;
}
function inRange(nowMin, startMin, endMin) {
if (startMin <= endMin) {
return nowMin >= startMin && nowMin < endMin;
}
// overnight wrap
return nowMin >= startMin || nowMin < endMin;
}
/**
* Get the current time multiplier based on configured SNIPER_MULTIPLIERS windows.
* Returns { multiplier, label } where label describes the active window (or 'default').
*/
export function getTimeMultiplier() {
const windows = config.sniperMultipliers;
if (!windows || windows.length === 0) return { multiplier: 1.0, label: 'default' };
const now = new Date();
const nowMin = now.getUTCHours() * 60 + now.getUTCMinutes();
for (const w of windows) {
const startMin = utc8ToUtcMinutes(w.start);
const endMin = utc8ToUtcMinutes(w.end);
if (inRange(nowMin, startMin, endMin)) {
return { multiplier: w.multiplier, label: `${w.start}-${w.end} UTC+8 → ${w.multiplier}x` };
}
}
return { multiplier: 1.0, label: 'default' };
}
+3 -56
View File
@@ -17,10 +17,9 @@ import { initClient } from './services/client.js';
import { getUsdcBalance } from './services/client.js';
import { initDashboard, appendLog, updateStatus, isDashboardActive } from './ui/dashboard.js';
import { startSniperDetector, stopSniperDetector } from './services/sniperDetector.js';
import { executeSnipe, getActiveSnipes, getConditionAsset, getConditionInfo } from './services/sniperExecutor.js';
import { redeemSniperPositions, onSniperWin, setSniperConditionLookup } from './services/ctf.js';
import { executeSnipe, getActiveSnipes } from './services/sniperExecutor.js';
import { redeemSniperPositions } from './services/ctf.js';
import { getSchedule, isAssetInSession, getNextSessionInfo } from './services/schedule.js';
import { getTimeMultiplier } from './services/sniperSizing.js';
// ── Validate config ────────────────────────────────────────────────────────────
@@ -77,20 +76,7 @@ async function buildStatusContent() {
lines.push(` 3-Tier : ${prices[0]}c/${prices[1]}c/${prices[2]}c`);
lines.push(` Sizes : ${sizes[0]}/${sizes[1]}/${sizes[2]} shares`);
const costPerSide = (sizes[0] * prices[0]) + (sizes[1] * prices[1]) + (sizes[2] * prices[2]);
lines.push(` Cost : $${(costPerSide * 2 * config.sniperAssets.length).toFixed(3)} per slot (base)`);
const { multiplier, label: mulLabel } = getTimeMultiplier();
if (config.sniperMultipliers.length > 0) {
lines.push(` Mul : ${mulLabel}`);
}
if (config.sniperPauseRoundsAfterWin > 0) {
lines.push(` Pause : ${config.sniperPauseRoundsAfterWin} rounds after win`);
}
// Show per-asset pause status
for (const a of config.sniperAssets) {
if (pauseCounters[a] > 0) {
lines.push(` {yellow-fg}${a.toUpperCase()} paused (${pauseCounters[a]} rounds){/yellow-fg}`);
}
}
lines.push(` Cost : $${(costPerSide * 2 * config.sniperAssets.length).toFixed(3)} per slot`);
lines.push('');
// Session schedule
@@ -153,48 +139,9 @@ function startRedeemer() {
logger.info(`Sniper redeemer started — checking every ${config.redeemInterval / 1000}s`);
}
// ── Pause-after-win tracking ─────────────────────────────────────────────────
const pauseCounters = {};
function handleWin(conditionId) {
const asset = getConditionAsset(conditionId);
if (!asset) return;
const rounds = config.sniperPauseRoundsAfterWin;
pauseCounters[asset] = rounds;
logger.success(`SNIPER: WIN on ${asset.toUpperCase()} — pausing ${rounds} rounds`);
}
function isAssetPaused(asset) {
const key = asset.toLowerCase();
return pauseCounters[key] > 0;
}
function tickPause(asset) {
const key = asset.toLowerCase();
if (pauseCounters[key] > 0) {
pauseCounters[key]--;
if (pauseCounters[key] <= 0) {
logger.info(`SNIPER: ${asset.toUpperCase()} pause ended — resuming`);
}
}
}
onSniperWin(handleWin);
setSniperConditionLookup(getConditionInfo);
// ── Market handler ────────────────────────────────────────────────────────────
async function handleNewMarket(market) {
const asset = market.asset.toLowerCase();
tickPause(asset);
if (isAssetPaused(asset)) {
logger.info(`SNIPER: ${asset.toUpperCase()} paused (${pauseCounters[asset]} rounds left) — skipping`);
return;
}
executeSnipe(market).catch((err) =>
logger.error(`SNIPER execute error (${market.asset}): ${err.message}`)
);
+3 -82
View File
@@ -3,11 +3,6 @@
* Console-only entry point for the Orderbook Sniper bot.
* Places tiny GTC BUY orders at a low price on both sides of 5-min markets.
*
* Features:
* - Time-based multiplier sizing (SNIPER_MULTIPLIERS, UTC+8)
* - Pause N rounds per asset after a win (SNIPER_PAUSE_ROUNDS_AFTER_WIN)
* - Win detection via outcome (payoutNumerators), not redeem value
*
* Run with: npm run sniper (live, console)
* npm run sniper-sim (simulation, console)
*
@@ -19,10 +14,9 @@ import config from './config/index.js';
import logger from './utils/logger.js';
import { initClient } from './services/client.js';
import { startSniperDetector, stopSniperDetector } from './services/sniperDetector.js';
import { executeSnipe, getConditionAsset, getConditionInfo } from './services/sniperExecutor.js';
import { redeemSniperPositions, onSniperWin, setSniperConditionLookup } from './services/ctf.js';
import { executeSnipe } from './services/sniperExecutor.js';
import { redeemSniperPositions } from './services/ctf.js';
import { getSchedule, isAssetInSession, getNextSessionInfo } from './services/schedule.js';
import { getTimeMultiplier } from './services/sniperSizing.js';
// Set proxy before any network calls
import './utils/proxy-patch.cjs';
@@ -50,53 +44,6 @@ try {
process.exit(1);
}
// ── Pause-after-win tracking ─────────────────────────────────────────────────
// pauseCounters[asset] = number of rounds remaining to skip
const pauseCounters = {};
/**
* Called by the redeemer when a win is detected.
* Looks up the asset from the conditionId mapping and sets the pause counter.
*/
function handleWin(conditionId) {
const asset = getConditionAsset(conditionId);
if (!asset) {
logger.info(`SNIPER: win detected for ${conditionId.slice(0, 12)}... but no asset mapping found`);
return;
}
const rounds = config.sniperPauseRoundsAfterWin;
pauseCounters[asset] = rounds;
logger.success(`SNIPER: WIN on ${asset.toUpperCase()} — pausing ${rounds} rounds`);
}
/**
* Check if an asset is currently paused due to a recent win.
* Each call with decrement=true counts as one round passing.
*/
function isAssetPaused(asset) {
const key = asset.toLowerCase();
if (!pauseCounters[key] || pauseCounters[key] <= 0) return false;
return true;
}
/**
* Decrement pause counter for an asset (called once per round/slot).
*/
function tickPause(asset) {
const key = asset.toLowerCase();
if (pauseCounters[key] && pauseCounters[key] > 0) {
pauseCounters[key]--;
if (pauseCounters[key] <= 0) {
logger.info(`SNIPER: ${asset.toUpperCase()} pause ended — resuming`);
}
}
}
// Register win callback and token lookup for correct outcome mapping
onSniperWin(handleWin);
setSniperConditionLookup(getConditionInfo);
// ── Log session schedule ──────────────────────────────────────────────────────
function logSchedule() {
@@ -136,17 +83,6 @@ function startRedeemer() {
// ── Market handler ────────────────────────────────────────────────────────────
async function handleNewMarket(market) {
const asset = market.asset.toLowerCase();
// Tick pause counter for this asset (each new market = 1 round)
tickPause(asset);
// Check if asset is paused after a recent win
if (isAssetPaused(asset)) {
logger.info(`SNIPER: ${asset.toUpperCase()} paused (${pauseCounters[asset]} rounds left) — skipping`);
return;
}
executeSnipe(market).catch((err) =>
logger.error(`SNIPER execute error (${market.asset}): ${err.message}`)
);
@@ -172,22 +108,7 @@ const sizes = [Math.floor(config.sniperMaxShares * 0.20), Math.floor(config.snip
const costPerSide = (sizes[0] * prices[0]) + (sizes[1] * prices[1]) + (sizes[2] * prices[2]);
const costPerSlot = (costPerSide * 2 * config.sniperAssets.length).toFixed(3);
logger.info(`SNIPER starting — ${config.dryRun ? 'SIMULATION' : 'LIVE'}`);
logger.info(`Assets: ${config.sniperAssets.join(', ').toUpperCase()} | 3-tier: 3c×${sizes[0]}+2c×${sizes[1]}+1c×${sizes[2]} = $${costPerSlot}/slot (base)`);
// Log multiplier config
if (config.sniperMultipliers.length > 0) {
logger.info('─── Sizing Multipliers (UTC+8) ───');
for (const w of config.sniperMultipliers) {
logger.info(` ${w.start}${w.end}${w.multiplier}x`);
}
const { multiplier, label } = getTimeMultiplier();
logger.info(` Current: ${label}`);
logger.info('──────────────────────────────────');
}
if (config.sniperPauseRoundsAfterWin > 0) {
logger.info(`Pause after win: ${config.sniperPauseRoundsAfterWin} rounds per asset`);
}
logger.info(`Assets: ${config.sniperAssets.join(', ').toUpperCase()} | 3-tier: 3c×${sizes[0]}+2c×${sizes[1]}+1c×${sizes[2]} = $${costPerSlot}/slot`);
logSchedule();
startRedeemer();
+5 -150
View File
@@ -39,18 +39,6 @@ export async function setupAxiosProxy() {
axios.defaults.proxy = false;
axios.defaults.httpAgent = axiosAgent;
axios.defaults.httpsAgent = axiosAgent;
// Add request interceptor to force proxy agent on every request
// This catches cases where axios.create() instances ignore defaults
axios.interceptors.request.use((cfg) => {
if (cfg.url && cfg.url.includes('polymarket.com')) {
cfg.httpsAgent = axiosAgent;
cfg.httpAgent = axiosAgent;
cfg.proxy = false;
}
return cfg;
});
logger.info(`Axios proxy configured → ${maskProxyUrl(config.proxyUrl)}`);
} catch (err) {
logger.error(`Failed to configure axios proxy: ${err.message}`);
@@ -81,80 +69,7 @@ export async function proxyFetch(url, opts = {}) {
}
/**
* Check the outbound IP address that Polymarket sees.
* Uses both direct and proxied requests so you can compare.
*/
async function checkOutboundIP() {
const IP_SERVICE = 'https://api.ipify.org?format=json';
const GEOBLOCK_API = 'https://polymarket.com/api/geoblock';
// 1. Check VPS direct IP
try {
const directResp = await fetch(IP_SERVICE, { signal: AbortSignal.timeout(10000) });
if (directResp.ok) {
const data = await directResp.json();
logger.info(`VPS direct IP : ${data.ip}`);
}
} catch {
logger.warn('Could not detect VPS direct IP');
}
// 2. Check if VPS direct IP is geoblocked
try {
const geoResp = await fetch(GEOBLOCK_API, { signal: AbortSignal.timeout(10000) });
if (geoResp.ok) {
const geo = await geoResp.json();
if (geo.blocked) {
logger.warn(`VPS direct IP GEOBLOCKED — country: ${geo.country}, region: ${geo.region}`);
} else {
logger.info(`VPS direct IP NOT geoblocked — country: ${geo.country}`);
}
}
} catch {
logger.warn('Could not check VPS geoblock status');
}
// 3. Check proxied IP (what Polymarket will see)
if (fetchDispatcher) {
try {
const proxyResp = await fetch(IP_SERVICE, {
dispatcher: fetchDispatcher,
signal: AbortSignal.timeout(10000),
});
if (proxyResp.ok) {
const data = await proxyResp.json();
logger.info(`Proxy IP : ${data.ip} ← Polymarket sees this`);
}
} catch {
logger.warn('Could not detect proxy IP — proxy may not be working');
}
// 4. Check if proxy IP is geoblocked
try {
const geoResp = await fetch(GEOBLOCK_API, {
dispatcher: fetchDispatcher,
signal: AbortSignal.timeout(10000),
});
if (geoResp.ok) {
const geo = await geoResp.json();
if (geo.blocked) {
logger.error('═══════════════════════════════════════════════════');
logger.error(`PROXY IP GEOBLOCKED by Polymarket!`);
logger.error(`IP: ${geo.ip} | Country: ${geo.country} | Region: ${geo.region}`);
logger.error('Change PROXY_URL in .env to a proxy in an allowed region.');
logger.error('═══════════════════════════════════════════════════');
} else {
logger.success(`Proxy IP NOT geoblocked — country: ${geo.country}`);
}
}
} catch {
logger.warn('Could not check proxy geoblock status');
}
}
}
/**
* Test that the proxy works and is not geoblocked by Polymarket CLOB.
* Test that the proxy works by making a simple request to the CLOB API.
* Call this at startup to fail fast if the proxy is misconfigured.
*/
export async function testProxy() {
@@ -162,88 +77,28 @@ export async function testProxy() {
logger.info(`Testing proxy connection → ${maskProxyUrl(config.proxyUrl)} ...`);
// Show both IPs so user can verify which IP Polymarket sees
await checkOutboundIP();
// ── Test 1: fetch (undici) ──────────────────────────────────────────
try {
if (!fetchDispatcher) {
throw new Error('Proxy dispatcher not initialized');
}
// Test with a simple GET to the CLOB time endpoint via proxied fetch
const resp = await fetch(`${config.clobHost}/time`, {
dispatcher: fetchDispatcher,
signal: AbortSignal.timeout(15000),
});
if (resp.status === 403) {
const body = await resp.text().catch(() => '');
const isGeoblock = body.includes('restricted') || body.includes('region') || body.includes('geoblock');
if (isGeoblock) {
logger.error('═══════════════════════════════════════════════════');
logger.error('GEOBLOCKED — Polymarket CLOB rejected your proxy IP!');
logger.error('Your proxy IP is in a restricted region.');
logger.error('Change PROXY_URL in .env to a proxy in an allowed region.');
logger.error('Allowed regions: https://docs.polymarket.com/developers/CLOB/geoblock');
logger.error('═══════════════════════════════════════════════════');
} else {
logger.error(`CLOB returned 403 Forbidden: ${body.substring(0, 200)}`);
}
return false;
}
if (!resp.ok) {
throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
}
logger.success(`Proxy test (fetch) passed`);
logger.success(`Proxy test passed — connected via ${maskProxyUrl(config.proxyUrl)}`);
return true;
} catch (err) {
logger.error(`Proxy test (fetch) FAILED: ${err.message}`);
logger.error(`Proxy test FAILED: ${err.message}`);
logger.error('Check PROXY_URL in .env. Bot cannot reach Polymarket without a working proxy.');
return false;
}
// ── Test 2: axios (same transport as CLOB client) ────────────────────
try {
if (!axiosAgent) {
throw new Error('Axios proxy agent not initialized');
}
const axiosModule = await import('axios');
const axios = axiosModule.default || axiosModule;
const axiosResp = await axios.get(`${config.clobHost}/time`, {
httpsAgent: axiosAgent,
proxy: false,
timeout: 15000,
});
logger.success(`Proxy test (axios) passed`);
} catch (err) {
const status = err?.response?.status;
const data = err?.response?.data;
if (status === 403) {
const body = typeof data === 'string' ? data : JSON.stringify(data || '');
const isGeoblock = body.includes('restricted') || body.includes('region') || body.includes('geoblock');
if (isGeoblock) {
logger.error('═══════════════════════════════════════════════════');
logger.error('GEOBLOCKED (axios) — Polymarket CLOB rejected your proxy IP!');
logger.error('Your proxy IP is in a restricted region.');
logger.error('The CLOB client uses axios — this test confirms proxy routing.');
logger.error('Change PROXY_URL in .env to a proxy in an allowed region.');
logger.error('═══════════════════════════════════════════════════');
} else {
logger.error(`CLOB returned 403 via axios: ${body.substring(0, 200)}`);
}
return false;
}
logger.error(`Proxy test (axios) FAILED: ${err.message}`);
return false;
}
logger.success(`All proxy tests passed — connected via ${maskProxyUrl(config.proxyUrl)}`);
return true;
}
/**