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