Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b28dcc228 | |||
| 28cba8b0fb | |||
| 0c1e9f114c | |||
| bac4e320f5 | |||
| dbaba5914f | |||
| 6a2bbfa008 | |||
| 8296c4129b | |||
| 89a7803aa8 | |||
| 2692694309 | |||
| bf09d30376 | |||
| 707d654749 | |||
| acf3943f80 | |||
| 202bf98fe7 | |||
| f074ca9ecb |
+149
-154
@@ -1,206 +1,201 @@
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
# polymarket-terminal — Environment Configuration
|
||||
# Copy this file to .env and fill in your values
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# WALLET SETUP (required for all strategies)
|
||||
# WALLET SETUP
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
# EOA private key — used for SIGNING only, does NOT hold funds
|
||||
# Never share this key or commit it to version control
|
||||
# EOA private key — used for SIGNING only, does NOT hold USDC
|
||||
PRIVATE_KEY=0xYOUR_EOA_PRIVATE_KEY_HERE
|
||||
|
||||
# Polymarket Proxy Wallet — the address shown when you click "Deposit" on Polymarket
|
||||
# This is where you deposit USDC.e and where all trades are funded from
|
||||
# How to find: polymarket.com → Profile → Deposit → copy the address
|
||||
# This is where you deposit USDC.e, and where trades are funded from
|
||||
# How to find: Login to polymarket.com → Profile → Deposit → copy the address
|
||||
PROXY_WALLET_ADDRESS=0xYOUR_PROXY_WALLET_ADDRESS_HERE
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# POLYGON RPC
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
# Public RPC (default, no key required)
|
||||
POLYGON_RPC_URL=https://polygon.lava.build
|
||||
|
||||
# Alternative RPCs:
|
||||
# POLYGON_RPC_URL=https://polygon-rpc.com
|
||||
# POLYGON_RPC_URL=https://rpc.ankr.com/polygon
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# POLYMARKET API CREDENTIALS (optional)
|
||||
# Leave blank to auto-derive from your private key on first run
|
||||
# Leave blank to auto-derive from your private key
|
||||
# ─────────────────────────────────────────────
|
||||
CLOB_API_KEY=
|
||||
CLOB_API_SECRET=
|
||||
CLOB_API_PASSPHRASE=
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# PROXY (optional — Polymarket API only, NOT Polygon RPC)
|
||||
# Supports HTTP, HTTPS, and SOCKS5 proxies
|
||||
# Leave empty to connect directly
|
||||
# Example: http://user:pass@proxy.example.com:8080
|
||||
# TRADER TO COPY
|
||||
# Use the proxy wallet address of the trader (visible on their Polymarket profile)
|
||||
# ─────────────────────────────────────────────
|
||||
PROXY_URL=
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# DRY RUN — simulate without placing real orders
|
||||
# Always test with DRY_RUN=true first!
|
||||
# ─────────────────────────────────────────────
|
||||
DRY_RUN=true
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
# MAKER REBATE MM (npm run maker-mm-bot)
|
||||
# High-frequency market-making on 15m BTC Up/Down markets.
|
||||
# Places maker limit orders on both YES and NO sides, merges filled
|
||||
# pairs back to USDC, and captures the bid-ask spread + maker rebate.
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
|
||||
# Assets to market-make (comma-separated slugs: btc, eth, sol, xrp)
|
||||
MAKER_MM_ASSETS=btc
|
||||
|
||||
# Market duration to target: "5m" or "15m"
|
||||
MAKER_MM_DURATION=15m
|
||||
|
||||
# Number of shares per side per cycle (minimum 5)
|
||||
# Total USDC deployed per cycle ≈ MAKER_MM_TRADE_SIZE × combined_price
|
||||
MAKER_MM_TRADE_SIZE=5
|
||||
|
||||
# Maximum combined bid (YES + NO) — controls spread profit
|
||||
# $0.98 combined = $0.02 profit per share when both fill
|
||||
# Lower = more profit per pair but lower fill rate (e.g. 0.95 = $0.05/share)
|
||||
MAKER_MM_MAX_COMBINED=0.98
|
||||
|
||||
# Reprice check interval (seconds) — how often to check for bid drift
|
||||
MAKER_MM_REPRICE_SEC=10
|
||||
|
||||
# Minimum bid drift (in dollars) before repricing — prevents over-trading
|
||||
# Default 0.02 = only reprice if best bid moved more than 2 cents
|
||||
MAKER_MM_REPRICE_THRESHOLD=0.02
|
||||
|
||||
# Seconds before market close to force-exit open positions (cut-loss)
|
||||
MAKER_MM_CUT_LOSS_TIME=60
|
||||
|
||||
# Max seconds after market open to enter (entry window)
|
||||
# After this window, bot waits for the next market
|
||||
MAKER_MM_ENTRY_WINDOW=45
|
||||
|
||||
# How often to poll for new markets (seconds)
|
||||
MAKER_MM_POLL_INTERVAL=5
|
||||
|
||||
# Delay between re-entry cycles within the same market (seconds)
|
||||
MAKER_MM_REENTRY_DELAY=30
|
||||
|
||||
# Set to false to disable re-entry — bot places one order per market then waits for next
|
||||
MAKER_MM_REENTRY_ENABLED=true
|
||||
|
||||
# YES bid price range — bot only enters when YES bid is within this range.
|
||||
# NO bid is derived from MAX_COMBINED - YES bid, and is NOT range-filtered.
|
||||
# Default (balanced market): 0.30-0.69 — enters near 50/50
|
||||
# Skewed market mode: 0.10-0.30 — only enters when YES is cheap (e.g. YES=11c, NO=87c)
|
||||
MAKER_MM_MIN_PRICE=0.30
|
||||
MAKER_MM_MAX_PRICE=0.69
|
||||
|
||||
# Cancel cheap side when expensive side fills first, then hold and auto-redeem at resolution
|
||||
# Example: YES=5c, NO=94c → NO fills first → cancel YES, hold NO, redeem after market ends
|
||||
# Default false (symmetric maker behavior — wait for both sides)
|
||||
MAKER_MM_CANCEL_CHEAP_ON_EXP_FILL=false
|
||||
|
||||
# Price polling interval (seconds) while waiting for entry conditions
|
||||
# Lower = more responsive but more API calls. Default: 3
|
||||
MAKER_MM_POLL_SEC=3
|
||||
|
||||
# ── Current Market Entry (optional) ─────────────────────────────
|
||||
# Allow entering markets that are already in progress
|
||||
# Useful for catching mid-market opportunities
|
||||
CURRENT_MARKET_ENABLED=true
|
||||
|
||||
# Maximum odds (% as decimal) to allow entry into a running market
|
||||
# 0.70 = skip if either YES or NO is above 70%
|
||||
CURRENT_MARKET_MAX_ODDS=0.70
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
# COPY TRADER (npm run bot)
|
||||
# Mirrors trades from a target trader's Polymarket wallet.
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
|
||||
# Proxy wallet address of the trader to copy
|
||||
# Visible on their Polymarket profile URL
|
||||
TRADER_ADDRESS=0xTRADER_PROXY_WALLET_ADDRESS
|
||||
|
||||
# ── Trade Sizing ─────────────────────────────────────────────────
|
||||
# ─────────────────────────────────────────────
|
||||
# TRADE SIZING
|
||||
# ─────────────────────────────────────────────
|
||||
# SIZE_MODE:
|
||||
# "percentage" = SIZE_PERCENT% of MAX_POSITION_SIZE per entry
|
||||
# "percentage" = SIZE_PERCENT% of MAX_POSITION_SIZE per market entry
|
||||
# (e.g. MAX_POSITION_SIZE=$10, SIZE_PERCENT=50 → buy $5 per entry)
|
||||
# "balance" = SIZE_PERCENT% of your current USDC.e balance per entry
|
||||
# (e.g. balance=$100, SIZE_PERCENT=10 → buy $10 per entry)
|
||||
# Note: sizing is independent of the trader's individual fill size.
|
||||
# Limit orders can fill in many small chunks — we always use our own sizing.
|
||||
SIZE_MODE=balance
|
||||
SIZE_PERCENT=10
|
||||
|
||||
# Minimum trade size in USDC (skip if calculated size is below this)
|
||||
MIN_TRADE_SIZE=1
|
||||
|
||||
# Maximum total USDC position per market
|
||||
# Maximum total position per market in USDC (won't buy more once this is reached)
|
||||
MAX_POSITION_SIZE=10
|
||||
|
||||
# ── Auto Sell ────────────────────────────────────────────────────
|
||||
# ─────────────────────────────────────────────
|
||||
# AUTO SELL
|
||||
# ─────────────────────────────────────────────
|
||||
AUTO_SELL_ENABLED=true
|
||||
AUTO_SELL_PROFIT_PERCENT=10
|
||||
|
||||
# "market" = sell immediately at market price
|
||||
# Sell mode when copying trader's sell
|
||||
# "market" = sell at market price immediately
|
||||
# "limit" = place limit order at trader's sell price
|
||||
SELL_MODE=market
|
||||
|
||||
# ── Intervals ────────────────────────────────────────────────────
|
||||
# ─────────────────────────────────────────────
|
||||
# INTERVALS
|
||||
# ─────────────────────────────────────────────
|
||||
# How often (seconds) to check for resolved markets to redeem
|
||||
REDEEM_INTERVAL=60
|
||||
MIN_MARKET_TIME_LEFT=300
|
||||
GTC_FALLBACK_TIMEOUT=60
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# DRY RUN (set true to simulate without real trades)
|
||||
# ─────────────────────────────────────────────
|
||||
DRY_RUN=true
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
# ORDERBOOK SNIPER (npm run sniper)
|
||||
# Places 3-tier GTC limit buy orders at panic-dump price levels.
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
# ─────────────────────────────────────────────
|
||||
# MARKET MAKER (mm.js / npm run mm-sim)
|
||||
# ─────────────────────────────────────────────
|
||||
# Comma-separated assets to market-make (same slug format as sniper)
|
||||
MM_ASSETS=btc
|
||||
|
||||
# Market duration: "5m" (5-minute) or "15m" (15-minute)
|
||||
MM_DURATION=5m
|
||||
|
||||
# USDC amount per side (total exposure = 2x this)
|
||||
MM_TRADE_SIZE=5
|
||||
|
||||
# Limit sell price target (e.g. 0.60 = sell at $0.60)
|
||||
MM_SELL_PRICE=0.60
|
||||
|
||||
# Seconds before market close to trigger cut-loss
|
||||
MM_CUT_LOSS_TIME=60
|
||||
|
||||
# Keyword to match market question (case-insensitive)
|
||||
MM_MARKET_KEYWORD=Bitcoin Up or Down
|
||||
|
||||
# Max seconds after market open to enter (0 = at open only)
|
||||
MM_ENTRY_WINDOW=45
|
||||
|
||||
# How often to poll for new markets (seconds)
|
||||
MM_POLL_INTERVAL=10
|
||||
|
||||
# ── Recovery Buy (after cut-loss) ───────────────────────────
|
||||
# After cut-loss triggers, monitor prices for 10s and market-buy
|
||||
# the dominant side if criteria are met. Does not affect the main
|
||||
# MM flow — purely an opt-in add-on.
|
||||
#
|
||||
# Enable recovery buy
|
||||
MM_RECOVERY_BUY=false
|
||||
|
||||
# Minimum price the dominant side must be at (and rising/stable) to qualify
|
||||
MM_RECOVERY_THRESHOLD=0.70
|
||||
|
||||
# USDC size for the recovery buy (0 = use MM_TRADE_SIZE)
|
||||
MM_RECOVERY_SIZE=0
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# ORDERBOOK SNIPER (sniper.js / npm run sniper-sim)
|
||||
# Places tiny GTC BUY orders at a low price on both sides of
|
||||
# ETH/SOL/XRP 5-minute markets — catches panic dumps near $0.
|
||||
# ─────────────────────────────────────────────
|
||||
# Comma-separated assets to snipe
|
||||
SNIPER_ASSETS=eth,sol,xrp
|
||||
|
||||
# 3-Tier pricing (descending) — orders placed at these prices
|
||||
SNIPER_TIER1_PRICE=0.03
|
||||
SNIPER_TIER2_PRICE=0.02
|
||||
SNIPER_TIER3_PRICE=0.01
|
||||
# Buy price per share (1 cent = $0.01)
|
||||
SNIPER_PRICE=0.01
|
||||
|
||||
# Max total shares to deploy (split across tiers: 20% / 30% / 50%)
|
||||
SNIPER_MAX_SHARES=15
|
||||
# Shares per side — minimum Polymarket order size is 5 shares
|
||||
# At $0.01/share: 5 shares = $0.05 per side, $0.10 per market
|
||||
SNIPER_SHARES=5
|
||||
|
||||
# Time-based sizing multiplier (UTC+8). Format: HH:MM-HH:MM:factor,...
|
||||
SNIPER_MULTIPLIERS=21:00-00:00:1.41,06:00-12:00:0.85
|
||||
# ─────────────────────────────────────────────
|
||||
# ONESHOT ENGINE (oneshot.js / npm run oneshot-sim)
|
||||
# Dominant Side Hold strategy.
|
||||
# Enters ONLY the side that the market already prices as the probable
|
||||
# winner (mid >= ONESHOT_MIN_DOMINANT_MID), then holds the position
|
||||
# to market expiry for on-chain redemption at $1.00.
|
||||
# No take-profit sells. No momentum-based exits.
|
||||
# ALWAYS test with DRY_RUN=true before going live.
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
# Rounds to pause an asset after detecting a win (5-min intervals)
|
||||
SNIPER_PAUSE_ROUNDS_AFTER_WIN=3
|
||||
# Comma-separated assets to monitor (e.g. btc,eth,sol)
|
||||
ONESHOT_ASSETS=btc
|
||||
|
||||
# Active session schedules per asset (UTC+8). Format: HH:MM-HH:MM,...
|
||||
SNIPER_SCHEDULE_BTC=19:40-22:40,03:40-06:10
|
||||
SNIPER_SCHEDULE_ETH=11:40-15:40,16:40-19:40
|
||||
SNIPER_SCHEDULE_SOL=09:40-12:40,21:40-23:40
|
||||
SNIPER_SCHEDULE_XRP=18:40-20:40,08:40-09:50
|
||||
# Market duration: "5m" (5-minute) or "15m" (15-minute)
|
||||
ONESHOT_DURATION=5m
|
||||
|
||||
# Book poll interval in milliseconds (200–500ms recommended)
|
||||
ONESHOT_POLL_INTERVAL_MS=300
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
# CLASSIC MARKET MAKER (npm run mm-bot)
|
||||
# Legacy MM strategy using limit sell orders after one leg fills.
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
MM_ASSETS=btc
|
||||
MM_DURATION=5m
|
||||
MM_TRADE_SIZE=5
|
||||
MM_SELL_PRICE=0.60
|
||||
MM_CUT_LOSS_TIME=60
|
||||
MM_MARKET_KEYWORD=Bitcoin Up or Down
|
||||
MM_ENTRY_WINDOW=45
|
||||
MM_POLL_INTERVAL=10
|
||||
MM_RECOVERY_BUY=false
|
||||
MM_RECOVERY_THRESHOLD=0.70
|
||||
MM_RECOVERY_SIZE=0
|
||||
MM_ADAPTIVE_CL=true
|
||||
MM_ADAPTIVE_MIN_COMBINED=1.20
|
||||
MM_ADAPTIVE_MONITOR_SEC=5
|
||||
# USDC risk per trade — size = floor(ONESHOT_BASE_RISK_USDC / entryPrice), min 5 shares
|
||||
ONESHOT_BASE_RISK_USDC=5
|
||||
|
||||
# ── Entry filters ──────────────────────────────────────────────────────
|
||||
|
||||
# Minimum mid price for the dominant side to qualify as an entry candidate.
|
||||
# Example: 0.58 means the token must be priced at ≥58% probability of winning.
|
||||
# Lower = more trades but more uncertain outcomes. Higher = fewer but more confident.
|
||||
# Recommended range: 0.55–0.65. The momentum gate provides additional conviction filtering.
|
||||
ONESHOT_MIN_DOMINANT_MID=0.58
|
||||
|
||||
# Minimum composite score to trigger entry (0–1).
|
||||
# Score = mid strength (35%) + momentum direction (30%) + book imbalance (20%) + spread (15%).
|
||||
# Lower threshold captures more "decent but not perfect" setups.
|
||||
ONESHOT_SCORE_THRESHOLD=0.42
|
||||
|
||||
# TTE (time-to-expiry) window in seconds for entry.
|
||||
# TTE_MAX=150 captures the "direction establishment" phase (last 2.5 minutes).
|
||||
# TTE_MIN=15 ensures enough time to get a fill before market locks.
|
||||
# Tighter window = higher conviction but fewer entries per session.
|
||||
ONESHOT_TTE_MIN=15
|
||||
ONESHOT_TTE_MAX=150
|
||||
|
||||
# Minimum shares at the best bid AND best ask for the depth hard gate
|
||||
ONESHOT_MIN_TOP_SIZE=10
|
||||
|
||||
# ── Exit settings ──────────────────────────────────────────────────────
|
||||
|
||||
# Emergency stop-loss: exit if the token's mid price drops below this absolute level.
|
||||
# Protects against a complete market reversal (e.g. entered UP at 0.70, price drops to 0.18).
|
||||
# Set to 0 to disable (pure hold-to-expiry — binary win/loss outcome).
|
||||
ONESHOT_STOP_LOSS_MID=0.20
|
||||
|
||||
# ── Risk settings ──────────────────────────────────────────────────────
|
||||
|
||||
# Number of consecutive emergency exits (losses) before entering cooldown
|
||||
ONESHOT_MAX_CONSEC_LOSSES=2
|
||||
|
||||
# Number of market slots to skip during cooldown
|
||||
ONESHOT_COOLDOWN_ROUNDS=3
|
||||
|
||||
# Maximum cumulative daily loss in USDC before halting all trading
|
||||
ONESHOT_DAILY_LOSS_CAP=20
|
||||
|
||||
# Maximum milliseconds to wait for a FOK fill ack (timeout → cancel → IDLE)
|
||||
ONESHOT_FILL_TIMEOUT_MS=800
|
||||
|
||||
# How often (ms) to poll for on-chain redemption after market expiry.
|
||||
# 5m markets typically settle on-chain within 2–5 minutes after close.
|
||||
ONESHOT_REDEEM_POLL_MS=30000
|
||||
|
||||
# Enable verbose debug logging (discovery probes, gate results, scoring, heartbeat)
|
||||
# Can also be enabled with: npm run oneshot-debug
|
||||
# Or on the command line: ONESHOT_DEBUG=true npm run oneshot
|
||||
ONESHOT_DEBUG=false
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
node_modules/
|
||||
.env
|
||||
data/*.json
|
||||
data/*.jsonl
|
||||
.DS_Store
|
||||
logs/*.log
|
||||
|
||||
@@ -1,147 +1,216 @@
|
||||
# Polymarket Terminal
|
||||
|
||||
An open-source automated trading terminal for [Polymarket](https://polymarket.com) — featuring a high-frequency maker rebate market maker, copy trading, and an orderbook sniper, all runnable from the command line.
|
||||
> An automated trading terminal for [Polymarket](https://polymarket.com) — copy trades, provide liquidity, and snipe low-priced orderbook fills, all from your command line.
|
||||
|
||||
**Created by [@direkturcrypto](https://twitter.com/direkturcrypto)**
|
||||
**Repository:** https://github.com/direkturcrypto/polymarket-terminal
|
||||
|
||||
---
|
||||
|
||||
## Strategies
|
||||
## Table of Contents
|
||||
|
||||
### 1. Maker Rebate MM (`npm run maker-mm-bot`) ⭐ Main Strategy
|
||||
|
||||
High-frequency market-making on Polymarket's 15-minute BTC/ETH/SOL Up-or-Down markets.
|
||||
|
||||
**How it works:**
|
||||
1. Detects a new 15-minute market as it opens
|
||||
2. Places maker limit BUY orders on both YES and NO sides simultaneously (combined ≈ $0.98)
|
||||
3. When both sides fill, merges YES + NO tokens back to USDC via the CTF contract — capturing the spread as profit
|
||||
4. Re-enters immediately after each successful merge for the duration of the market
|
||||
5. Automatically queues the next market before the current one closes — zero idle time between markets
|
||||
|
||||
**Key design decisions:**
|
||||
- **No repricing** — orders are placed once and held; no cancel/replace cycles that cause double orders or ghost fills
|
||||
- **Onchain balance as source of truth** — fill detection uses Polygon RPC balance, not CLOB API responses or WebSocket events alone
|
||||
- **Ghost fill recovery** — detects CLOB-matched orders with invalid txhash (order gone from book but tokens never arrived), recovers by merging what settled and selling remainder at market before prices skew
|
||||
- **Stops re-entry after a stuck (one-sided) cycle** — protects against accumulating directional exposure in trending markets
|
||||
- **Combined cap always enforced** — cost of YES + NO never exceeds `MAKER_MM_MAX_COMBINED`, guaranteeing profitability on every successful merge
|
||||
- **Market-neutral** — profits from spread capture only, never depends on price direction
|
||||
|
||||
**Economics per cycle (default $5/side, 5 shares):**
|
||||
```
|
||||
Both sides fill → merge → recover $5.00 from $4.90 cost = +$0.10 profit per cycle
|
||||
One side stuck → hold original bid → wait for reversion or cut-loss at close
|
||||
```
|
||||
|
||||
**Configuration (via `.env`):**
|
||||
```
|
||||
MAKER_MM_ASSETS=btc # Assets: btc, eth, sol, xrp
|
||||
MAKER_MM_DURATION=15m # Market duration
|
||||
MAKER_MM_TRADE_SIZE=5 # Shares per side
|
||||
MAKER_MM_MAX_COMBINED=0.98 # Max combined bid (controls spread profit)
|
||||
MAKER_MM_REENTRY_DELAY=30 # Seconds between cycles
|
||||
CURRENT_MARKET_ENABLED=true # Allow entering mid-market
|
||||
CURRENT_MARKET_MAX_ODDS=0.70 # Skip if market is more skewed than this
|
||||
```
|
||||
- [Features](#features)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Installation](#installation)
|
||||
- [Configuration](#configuration)
|
||||
- [Usage](#usage)
|
||||
- [How It Works](#how-it-works)
|
||||
- [Project Structure](#project-structure)
|
||||
- [Important Warnings](#important-warnings)
|
||||
- [Contributing](#contributing)
|
||||
- [License](#license)
|
||||
|
||||
---
|
||||
|
||||
### 2. Copy Trader (`npm run bot`)
|
||||
## Features
|
||||
|
||||
Mirrors the trades of any target Polymarket wallet in real-time.
|
||||
### Copy Trade Bot (`npm start`)
|
||||
- **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
|
||||
- **Deduplication** — Each market is entered at most once; no double buys
|
||||
- **Balance Guard** — Checks USDC.e balance before every order
|
||||
- **Dry Run Mode** — Simulate the full flow without placing real orders
|
||||
|
||||
- Monitors target wallet for new BUY/SELL activity via the CLOB API
|
||||
- Replicates trades proportionally using configurable sizing modes (`balance` or `percentage`)
|
||||
- Supports automatic sell-out when target trader exits (market or limit)
|
||||
- Auto-redeems resolved positions
|
||||
### Market Maker Bot (`npm run mm`)
|
||||
- **Automated Liquidity** — Splits USDC into YES+NO tokens and places limit sells on both sides at $0.50 entry
|
||||
- **Cut-Loss Protection** — Merges unsold tokens back to USDC before market close
|
||||
- **Recovery Buy** — Optional directional bet after a cut-loss triggers
|
||||
- **Multi-Asset** — Supports BTC, ETH, SOL, and any 5m/15m Polymarket market
|
||||
- **Simulation Mode** — Full dry-run with P&L tracking (`npm run mm-sim`)
|
||||
|
||||
```
|
||||
TRADER_ADDRESS=0xTARGET_WALLET
|
||||
SIZE_MODE=balance
|
||||
SIZE_PERCENT=10
|
||||
MAX_POSITION_SIZE=10
|
||||
```
|
||||
### Orderbook Sniper Bot (`npm run sniper`)
|
||||
- **Low-Price Orders** — Places tiny GTC BUY orders at a configurable price (e.g. $0.01) on both sides
|
||||
- **Multi-Asset** — Targets ETH, SOL, XRP, and more simultaneously
|
||||
- **Simulation Mode** — Preview orders without spending funds (`npm run sniper-sim`)
|
||||
|
||||
---
|
||||
|
||||
### 3. Orderbook Sniper (`npm run sniper`)
|
||||
## Prerequisites
|
||||
|
||||
Places 3-tier GTC limit BUY orders at deep discount price levels to catch panic dumps.
|
||||
|
||||
- Deploys staggered orders at 3 price tiers (1¢, 2¢, 3¢) with weighted sizing (50% / 30% / 20%)
|
||||
- Time-based sizing multipliers for peak trading hours
|
||||
- Per-asset session schedules (UTC+8)
|
||||
- Auto-pauses an asset after a win to avoid re-entering an already-resolved market
|
||||
|
||||
```
|
||||
SNIPER_ASSETS=eth,sol,xrp
|
||||
SNIPER_MAX_SHARES=15
|
||||
SNIPER_MULTIPLIERS=21:00-00:00:1.41,06:00-12:00:0.85
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js 18+
|
||||
- A Polymarket account with a funded proxy wallet (USDC.e on Polygon)
|
||||
- EOA private key for signing (the signing wallet does not need to hold funds)
|
||||
| Requirement | Details |
|
||||
|---|---|
|
||||
| Node.js | v18 or higher (ESM support required) |
|
||||
| Polygon Wallet | An EOA wallet with a private key |
|
||||
| Polymarket Proxy Wallet | Your proxy wallet address (visible on your Polymarket profile → Deposit) |
|
||||
| USDC.e on Polygon | Deposited via Polymarket's deposit flow |
|
||||
| MATIC on Polygon | A small amount for gas fees (redeem & on-chain operations) |
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# 1. Clone the repository
|
||||
git clone https://github.com/direkturcrypto/polymarket-terminal.git
|
||||
cd polymarket-terminal
|
||||
|
||||
# 2. Install dependencies
|
||||
npm install
|
||||
|
||||
# 3. Copy the environment template
|
||||
cp .env.example .env
|
||||
# Edit .env with your wallet keys and settings
|
||||
|
||||
# 4. Fill in your credentials (see Configuration section below)
|
||||
nano .env # or use your preferred editor
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
## Configuration
|
||||
|
||||
**Always test with simulation mode first:**
|
||||
All settings are controlled via the `.env` file. **Never commit your `.env` file** — it is already listed in `.gitignore`.
|
||||
|
||||
```bash
|
||||
# Simulate maker MM — no real orders placed
|
||||
npm run maker-mm-bot-sim
|
||||
### Wallet Setup
|
||||
|
||||
# Run live maker MM (recommended starting config)
|
||||
MAKER_MM_TRADE_SIZE=5 MAKER_MM_REENTRY_DELAY=30 npm run maker-mm-bot
|
||||
| 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 |
|
||||
|
||||
# Simulate copy trader
|
||||
npm run bot-sim
|
||||
> **How to find your Proxy Wallet:** Log in to polymarket.com → click your profile → Deposit → copy the wallet address shown.
|
||||
|
||||
# Run live copy trader
|
||||
npm run bot
|
||||
### Polymarket API Credentials (Optional)
|
||||
|
||||
# Simulate orderbook sniper
|
||||
npm run sniper-sim
|
||||
Leave these blank to have the client auto-derive credentials from your private key.
|
||||
|
||||
# Run live sniper
|
||||
npm run sniper
|
||||
```
|
||||
| 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` |
|
||||
| `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
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
| `SNIPER_ASSETS` | Comma-separated assets to snipe (e.g. `eth,sol,xrp`) | `eth,sol,xrp` |
|
||||
| `SNIPER_PRICE` | Buy price per share (e.g. `0.01` = $0.01) | `0.01` |
|
||||
| `SNIPER_SHARES` | Shares per side (minimum 5 per Polymarket rules) | `5` |
|
||||
|
||||
---
|
||||
|
||||
## Running with PM2 (recommended for VPS)
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
npm install -g pm2
|
||||
# ── Copy Trade Bot ─────────────────────────────────
|
||||
npm start # Production mode
|
||||
npm run dev # Development mode (auto-reload on file changes)
|
||||
|
||||
# Start maker MM
|
||||
pm2 start src/maker-mm-bot.js --name polymarket-maker-mm --interpreter node
|
||||
# ── Market Maker Bot ───────────────────────────────
|
||||
npm run mm # Live trading (DRY_RUN=false)
|
||||
npm run mm-sim # Simulation mode (DRY_RUN=true)
|
||||
npm run mm-dev # Simulation + auto-reload
|
||||
|
||||
# Start copy trader
|
||||
pm2 start src/bot.js --name polymarket-bot --interpreter node
|
||||
# ── Orderbook Sniper Bot ───────────────────────────
|
||||
npm run sniper # Live trading (DRY_RUN=false)
|
||||
npm run sniper-sim # Simulation mode (DRY_RUN=true)
|
||||
npm run sniper-dev # Simulation + auto-reload
|
||||
```
|
||||
|
||||
# View logs
|
||||
pm2 logs polymarket-maker-mm
|
||||
pm2 logs polymarket-bot
|
||||
> **Always test with `DRY_RUN=true` first** before committing real funds.
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
### Copy Trade Bot Flow
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ WATCHER LOOP │
|
||||
│ WebSocket (RTDS) — real-time trade events from trader │
|
||||
│ Fallback: poll Data API every N seconds │
|
||||
├───────────────────────┬──────────────────────────────────┤
|
||||
│ NEW BUY │ NEW SELL │
|
||||
│ │ │
|
||||
│ ✓ Check position │ ✓ Check position exists │
|
||||
│ ✓ Check USDC balance │ ✓ Cancel existing auto-sell │
|
||||
│ ✓ Market buy (FOK) │ ✓ Market / limit sell │
|
||||
│ ✓ Retry on failure │ ✓ Retry on failure │
|
||||
│ ✓ Place auto-sell │ ✓ Remove position from state │
|
||||
│ ✓ Save position │ │
|
||||
├───────────────────────┴──────────────────────────────────┤
|
||||
│ REDEEMER LOOP │
|
||||
│ Periodically checks resolved markets │
|
||||
│ → Redeems winning positions via CTF contract on-chain │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 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)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -149,54 +218,74 @@ pm2 logs polymarket-bot
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── maker-mm-bot.js # Maker Rebate MM — PM2/VPS entry point
|
||||
├── maker-mm.js # Maker Rebate MM — TUI entry point
|
||||
├── bot.js # Copy Trader
|
||||
├── sniper.js # Orderbook Sniper
|
||||
├── mm-bot.js # Classic MM (legacy)
|
||||
├── config/
|
||||
│ └── index.js # All configuration with env var mapping
|
||||
└── services/
|
||||
├── makerRebateExecutor.js # Core maker MM logic (orders, fills, merge)
|
||||
├── mmDetector.js # Market discovery and scheduling
|
||||
├── mmWsFillWatcher.js # WebSocket RTDS real-time fill detection
|
||||
├── ctf.js # CTF contract interaction (merge/redeem)
|
||||
└── client.js # Polymarket CLOB client wrapper
|
||||
polymarket-terminal/
|
||||
├── src/
|
||||
│ ├── index.js — Copy trade bot entry point
|
||||
│ ├── mm.js — Market maker bot entry point
|
||||
│ ├── sniper.js — Orderbook sniper bot entry point
|
||||
│ │
|
||||
│ ├── config/
|
||||
│ │ └── index.js — Environment variable loading & validation
|
||||
│ │
|
||||
│ ├── services/
|
||||
│ │ ├── client.js — CLOB client initialization & USDC balance
|
||||
│ │ ├── watcher.js — Poll-based trader activity detection
|
||||
│ │ ├── wsWatcher.js — WebSocket real-time trade listener
|
||||
│ │ ├── executor.js — Buy & sell order execution logic
|
||||
│ │ ├── position.js — Position state management (CRUD)
|
||||
│ │ ├── autoSell.js — Auto limit-sell placement
|
||||
│ │ ├── redeemer.js — Market resolution check & CTF redemption
|
||||
│ │ ├── ctf.js — On-chain CTF contract interactions (MM bot)
|
||||
│ │ ├── 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 — Color-coded, timestamped logging
|
||||
│ ├── state.js — Atomic JSON state file management
|
||||
│ └── simStats.js — Simulation P&L statistics
|
||||
│
|
||||
├── data/ — Runtime state files (gitignored)
|
||||
├── .env.example — Configuration template
|
||||
├── .gitignore
|
||||
└── package.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How Maker Rebate Works on Polymarket
|
||||
## Important Warnings
|
||||
|
||||
Polymarket's CLOB gives **maker rebates** to traders who post limit orders, while takers pay a fee. This terminal exploits that by:
|
||||
|
||||
1. Simultaneously posting BUY limit orders on both YES and NO of a binary market
|
||||
2. Since YES + NO always resolve to $1.00 (exactly one wins), buying both at combined cost < $1.00 guarantees a profit on merge
|
||||
3. The position is closed by merging the token pair back into USDC via Polymarket's CTF contract — not by holding to resolution
|
||||
|
||||
This strategy is **market-neutral** and **direction-agnostic**. Profitability depends on fill rate and spread capture, not on predicting BTC price direction.
|
||||
|
||||
---
|
||||
|
||||
## Risk Management
|
||||
|
||||
- **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
|
||||
|
||||
MIT — free to use, fork, and modify.
|
||||
- **Never commit your `.env` file.** Your private key must remain secret. The `.gitignore` already excludes it.
|
||||
- **Always start with `DRY_RUN=true`** to verify the bot behaves as expected before using real funds.
|
||||
- **Use a small `SIZE_PERCENT`** for initial live runs to limit exposure.
|
||||
- **Keep MATIC in your EOA wallet** for gas fees (redeem operations and on-chain CTF calls).
|
||||
- **This software is provided as-is, with no guarantees.** Prediction market trading carries significant financial risk. You are solely responsible for any losses.
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Pull requests are welcome. Open an issue for bugs or feature requests.
|
||||
Contributions are welcome! To get started:
|
||||
|
||||
Built for the Polymarket ecosystem. Not affiliated with Polymarket.
|
||||
1. Fork the repository
|
||||
2. Create a feature branch: `git checkout -b feat/your-feature`
|
||||
3. Make your changes and ensure the code is clean and well-documented
|
||||
4. Open a pull request describing what you changed and why
|
||||
|
||||
Please keep pull requests focused and avoid mixing unrelated changes.
|
||||
|
||||
---
|
||||
|
||||
## Credits
|
||||
|
||||
Built and maintained by **[@direkturcrypto](https://twitter.com/direkturcrypto)**.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
ISC License — see [LICENSE](LICENSE) for details.
|
||||
|
||||
Generated
+5
-177
@@ -1,22 +1,18 @@
|
||||
{
|
||||
"name": "polymarket-terminal",
|
||||
"name": "polymarket-copy",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "polymarket-terminal",
|
||||
"name": "polymarket-copy",
|
||||
"version": "1.0.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@polymarket/clob-client": "^4.7.3",
|
||||
"blessed": "^0.1.81",
|
||||
"dotenv": "^16.4.7",
|
||||
"ethers": "^5.8.0",
|
||||
"global-agent": "^4.1.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"undici": "^7.22.0",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -917,15 +913,6 @@
|
||||
"integrity": "sha512-H7wUZRn8WpTq9jocdxQ2c8x2sKo9ZVmzfRE13GiNJXfp7NcKYEdvl3vspKjXox6RIG2VtaRe4JFvxG4rqp2Zuw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/anymatch": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||
@@ -1096,6 +1083,7 @@
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
@@ -1109,40 +1097,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/define-data-property": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
||||
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.0",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/define-properties": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
|
||||
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"define-data-property": "^1.0.1",
|
||||
"has-property-descriptors": "^1.0.0",
|
||||
"object-keys": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
@@ -1244,18 +1198,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/ethereum-cryptography": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ethereum-cryptography/-/ethereum-cryptography-2.2.1.tgz",
|
||||
@@ -1453,37 +1395,6 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/global-agent": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/global-agent/-/global-agent-4.1.2.tgz",
|
||||
"integrity": "sha512-CvUwIRwmDHu33Y71P8v1Pr8yo5/U2inzrdRNEtPdiYKBd8J4oQ6aDkt9WqZhMQDtAcTAl+akDTHssRYaBTTlhQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"globalthis": "^1.0.2",
|
||||
"matcher": "^4.0.0",
|
||||
"semver": "^7.3.5",
|
||||
"serialize-error": "^8.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/globalthis": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
|
||||
"integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"define-properties": "^1.2.1",
|
||||
"gopd": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
@@ -1506,18 +1417,6 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/has-property-descriptors": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
|
||||
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
@@ -1578,19 +1477,6 @@
|
||||
"minimalistic-crypto-utils": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore-by-default": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
|
||||
@@ -1666,21 +1552,6 @@
|
||||
"integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/matcher": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/matcher/-/matcher-4.0.0.tgz",
|
||||
"integrity": "sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"escape-string-regexp": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -1749,6 +1620,7 @@
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemon": {
|
||||
@@ -1790,15 +1662,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-keys": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
|
||||
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
@@ -1848,6 +1711,7 @@
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
@@ -1856,21 +1720,6 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/serialize-error": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-8.1.0.tgz",
|
||||
"integrity": "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"type-fest": "^0.20.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-update-notifier": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
|
||||
@@ -1951,18 +1800,6 @@
|
||||
"integrity": "sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/type-fest": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
|
||||
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/undefsafe": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
||||
@@ -1970,15 +1807,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.22.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz",
|
||||
"integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "5.26.5",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||
|
||||
+5
-18
@@ -7,26 +7,16 @@
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "nodemon --ignore 'data/*.json' src/index.js",
|
||||
"bot": "node src/bot.js",
|
||||
"bot-sim": "DRY_RUN=true node src/bot.js",
|
||||
"bot-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/bot.js",
|
||||
"mm": "DRY_RUN=false node src/mm.js",
|
||||
"mm-sim": "DRY_RUN=true node src/mm.js",
|
||||
"mm-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/mm.js",
|
||||
"mm-bot": "node src/mm-bot.js",
|
||||
"mm-bot-sim": "DRY_RUN=true node src/mm-bot.js",
|
||||
"mm-bot-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/mm-bot.js",
|
||||
"sniper": "DRY_RUN=false node src/sniper.js",
|
||||
"sniper-sim": "DRY_RUN=true node src/sniper.js",
|
||||
"sniper-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/sniper.js",
|
||||
"sniper-tui": "DRY_RUN=false node src/sniper-tui.js",
|
||||
"sniper-tui-sim": "DRY_RUN=true node src/sniper-tui.js",
|
||||
"sniper-tui-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/sniper-tui.js",
|
||||
"maker-mm": "DRY_RUN=false node src/maker-mm.js",
|
||||
"maker-mm-sim": "DRY_RUN=true node src/maker-mm.js",
|
||||
"maker-mm-bot": "node src/maker-mm-bot.js",
|
||||
"maker-mm-bot-sim": "DRY_RUN=true node src/maker-mm-bot.js",
|
||||
"postinstall": "node scripts/patch-clob-client.cjs"
|
||||
"oneshot": "DRY_RUN=false node src/oneshot.js",
|
||||
"oneshot-sim": "DRY_RUN=true node src/oneshot.js",
|
||||
"oneshot-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/oneshot.js",
|
||||
"oneshot-debug": "DRY_RUN=true ONESHOT_DEBUG=true node src/oneshot.js"
|
||||
},
|
||||
"keywords": [
|
||||
"polymarket",
|
||||
@@ -40,12 +30,9 @@
|
||||
"blessed": "^0.1.81",
|
||||
"dotenv": "^16.4.7",
|
||||
"ethers": "^5.8.0",
|
||||
"global-agent": "^4.1.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"undici": "^7.22.0",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.9"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
/**
|
||||
* PM2 config — Copy Trade Bot
|
||||
*
|
||||
* Usage (from project root):
|
||||
* pm2 start pm2/copy.config.cjs # live trading
|
||||
* pm2 start pm2/copy.config.cjs --env sim # simulation / dry-run
|
||||
*
|
||||
* pm2 logs polymarket-copy
|
||||
* pm2 restart polymarket-copy
|
||||
* pm2 stop polymarket-copy
|
||||
* pm2 delete polymarket-copy
|
||||
*/
|
||||
const path = require('path');
|
||||
const root = path.join(__dirname, '..');
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'polymarket-copy',
|
||||
script: path.join(root, 'src/bot.js'),
|
||||
interpreter: 'node',
|
||||
|
||||
// Live trading (default)
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
DRY_RUN: 'false',
|
||||
},
|
||||
|
||||
// Simulation: pm2 start pm2/copy.config.cjs --env sim
|
||||
env_sim: {
|
||||
NODE_ENV: 'production',
|
||||
DRY_RUN: 'true',
|
||||
},
|
||||
|
||||
out_file: path.join(root, 'logs/copy-out.log'),
|
||||
error_file: path.join(root, 'logs/copy-error.log'),
|
||||
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
|
||||
merge_logs: true,
|
||||
|
||||
restart_delay: 5000,
|
||||
max_restarts: 10,
|
||||
min_uptime: '10s',
|
||||
max_memory_restart: '256M',
|
||||
stop_exit_codes: [0],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,47 +0,0 @@
|
||||
/**
|
||||
* PM2 config — Market Maker Bot
|
||||
*
|
||||
* Usage (from project root):
|
||||
* pm2 start pm2/mm.config.cjs # live trading
|
||||
* pm2 start pm2/mm.config.cjs --env sim # simulation / dry-run
|
||||
*
|
||||
* pm2 logs polymarket-mm
|
||||
* pm2 restart polymarket-mm
|
||||
* pm2 stop polymarket-mm
|
||||
* pm2 delete polymarket-mm
|
||||
*/
|
||||
const path = require('path');
|
||||
const root = path.join(__dirname, '..');
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'polymarket-mm',
|
||||
script: path.join(root, 'src/mm-bot.js'),
|
||||
interpreter: 'node',
|
||||
|
||||
// Live trading (default)
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
DRY_RUN: 'false',
|
||||
},
|
||||
|
||||
// Simulation: pm2 start pm2/mm.config.cjs --env sim
|
||||
env_sim: {
|
||||
NODE_ENV: 'production',
|
||||
DRY_RUN: 'true',
|
||||
},
|
||||
|
||||
out_file: path.join(root, 'logs/mm-out.log'),
|
||||
error_file: path.join(root, 'logs/mm-error.log'),
|
||||
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
|
||||
merge_logs: true,
|
||||
|
||||
restart_delay: 5000,
|
||||
max_restarts: 10,
|
||||
min_uptime: '10s',
|
||||
max_memory_restart: '256M',
|
||||
stop_exit_codes: [0],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,114 +0,0 @@
|
||||
/**
|
||||
* patch-clob-client.cjs
|
||||
*
|
||||
* Patches @polymarket/clob-client to inject proxy support.
|
||||
* Runs automatically via `npm install` (postinstall hook).
|
||||
*
|
||||
* What it does:
|
||||
* - Adds HttpsProxyAgent import to http-helpers/index.js
|
||||
* - Registers an axios interceptor that injects the proxy agent
|
||||
* into every request to polymarket.com
|
||||
* - Reads PROXY_URL from process.env at runtime
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const TARGET = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'node_modules',
|
||||
'@polymarket',
|
||||
'clob-client',
|
||||
'dist',
|
||||
'http-helpers',
|
||||
'index.js',
|
||||
);
|
||||
|
||||
if (!fs.existsSync(TARGET)) {
|
||||
console.log('[patch] @polymarket/clob-client not found — skipping');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let code = fs.readFileSync(TARGET, 'utf8');
|
||||
|
||||
// Check if proxy support is already patched
|
||||
const proxyAlreadyPatched = code.includes('getProxyAgent');
|
||||
// Check if the JSON.stringify circular-ref fix is already applied
|
||||
const jsonFixAlreadyPatched = !code.includes('config: (_d = err.response)');
|
||||
|
||||
// ── 1. Proxy interceptor ─────────────────────────────────────────────────────
|
||||
|
||||
if (!proxyAlreadyPatched) {
|
||||
const PATCH_CODE = `
|
||||
// ── Proxy support (auto-patched by scripts/patch-clob-client.cjs) ──────────
|
||||
const https_proxy_agent_1 = require("https-proxy-agent");
|
||||
let _cachedProxyAgent = null;
|
||||
const getProxyAgent = () => {
|
||||
if (!process.env.PROXY_URL) return undefined;
|
||||
if (!_cachedProxyAgent) {
|
||||
_cachedProxyAgent = new https_proxy_agent_1.HttpsProxyAgent(process.env.PROXY_URL);
|
||||
}
|
||||
return _cachedProxyAgent;
|
||||
};
|
||||
// Intercept all axios requests — inject proxy agent for polymarket.com
|
||||
axios_1.default.interceptors.request.use(function(cfg) {
|
||||
if (cfg.url && cfg.url.includes('polymarket.com')) {
|
||||
var agent = getProxyAgent();
|
||||
if (agent) {
|
||||
cfg.httpsAgent = agent;
|
||||
cfg.httpAgent = agent;
|
||||
cfg.proxy = false;
|
||||
}
|
||||
}
|
||||
return cfg;
|
||||
});
|
||||
// ── End proxy patch ────────────────────────────────────────────────────────
|
||||
`;
|
||||
const axiosPatterns = [
|
||||
/tslib_1\.__importDefault\s*\(\s*require\s*\(\s*["']axios["']\s*\)\s*\)\s*;/,
|
||||
/require\s*\(\s*["']axios["']\s*\)\s*;/,
|
||||
];
|
||||
let injected = false;
|
||||
for (const pattern of axiosPatterns) {
|
||||
const match = code.match(pattern);
|
||||
if (match) {
|
||||
code = code.replace(match[0], match[0] + PATCH_CODE);
|
||||
console.log('[patch] Injected proxy interceptor after axios import');
|
||||
injected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!injected) {
|
||||
console.error('[patch] Could not find axios import — skipping proxy patch');
|
||||
}
|
||||
} else {
|
||||
console.log('[patch] Proxy support already present — skipping');
|
||||
}
|
||||
|
||||
// ── 2. Fix errorHandling circular JSON ───────────────────────────────────────
|
||||
// JSON.stringify(err.response.config) includes httpsAgent (from proxy) which
|
||||
// has circular/deep refs and causes "Maximum call stack size exceeded".
|
||||
// Replace with a simple log that only serializes the response data.
|
||||
|
||||
if (!jsonFixAlreadyPatched) {
|
||||
const OLD_LOG = `console.error("[CLOB Client] request error", JSON.stringify({
|
||||
status: (_a = err.response) === null || _a === void 0 ? void 0 : _a.status,
|
||||
statusText: (_b = err.response) === null || _b === void 0 ? void 0 : _b.statusText,
|
||||
data: (_c = err.response) === null || _c === void 0 ? void 0 : _c.data,
|
||||
config: (_d = err.response) === null || _d === void 0 ? void 0 : _d.config,
|
||||
}));`;
|
||||
const NEW_LOG = `// config excluded — contains httpsAgent circular refs (stack overflow)
|
||||
console.error("[CLOB Client] request error:", (_a = err.response) === null || _a === void 0 ? void 0 : _a.status, JSON.stringify((_b = err.response) === null || _b === void 0 ? void 0 : _b.data));`;
|
||||
if (code.includes(OLD_LOG)) {
|
||||
code = code.replace(OLD_LOG, NEW_LOG);
|
||||
console.log('[patch] Fixed errorHandling circular JSON.stringify');
|
||||
} else {
|
||||
console.warn('[patch] Could not find errorHandling JSON.stringify — skipping (already fixed or SDK changed)');
|
||||
}
|
||||
} else {
|
||||
console.log('[patch] errorHandling JSON fix already applied — skipping');
|
||||
}
|
||||
|
||||
fs.writeFileSync(TARGET, code, 'utf8');
|
||||
console.log('[patch] @polymarket/clob-client patched ✅');
|
||||
-149
@@ -1,149 +0,0 @@
|
||||
/**
|
||||
* bot.js — PM2 / VPS entry point (no TUI)
|
||||
*
|
||||
* Plain-text stdout output, compatible with:
|
||||
* pm2 start ecosystem.config.cjs
|
||||
* pm2 logs polymarket-copy
|
||||
*/
|
||||
import config, { validateConfig } from './config/index.js';
|
||||
import { initClient, getUsdcBalance, getClient } from './services/client.js';
|
||||
import { executeBuy, executeSell } from './services/executor.js';
|
||||
import { checkAndRedeemPositions } from './services/redeemer.js';
|
||||
import { getOpenPositions } from './services/position.js';
|
||||
import { startWsWatcher, stopWsWatcher } from './services/wsWatcher.js';
|
||||
import { getSimStats } from './utils/simStats.js';
|
||||
import logger from './utils/logger.js';
|
||||
|
||||
logger.interceptConsole(); // strip auth headers from CLOB axios error dumps
|
||||
|
||||
// ── Handle a trade event from WebSocket ───────────────────────────────────────
|
||||
async function handleTrade(trade) {
|
||||
try {
|
||||
if (trade.type === 'BUY') await executeBuy(trade);
|
||||
if (trade.type === 'SELL') await executeSell(trade);
|
||||
} catch (err) {
|
||||
logger.error(`Error processing trade ${trade.id}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Periodic status log (replaces TUI right panel) ────────────────────────────
|
||||
async function printStatus() {
|
||||
try {
|
||||
const balance = await getUsdcBalance();
|
||||
const positions = getOpenPositions();
|
||||
|
||||
logger.info(`--- Status | Balance: $${balance.toFixed(2)} USDC | Open positions: ${positions.length} ---`);
|
||||
|
||||
for (const pos of positions) {
|
||||
let pnlStr = '';
|
||||
try {
|
||||
const client = getClient();
|
||||
const mp = await client.getMidpoint(pos.tokenId);
|
||||
const mid = parseFloat(mp?.mid ?? mp ?? '0');
|
||||
if (mid > 0) {
|
||||
const pnl = (mid - pos.avgBuyPrice) * pos.shares;
|
||||
const sign = pnl >= 0 ? '+' : '';
|
||||
const pct = pos.totalCost > 0 ? ((pnl / pos.totalCost) * 100).toFixed(1) : '0.0';
|
||||
pnlStr = ` | unrealized ${sign}$${pnl.toFixed(2)} (${sign}${pct}%)`;
|
||||
}
|
||||
} catch { /* price unavailable */ }
|
||||
|
||||
const name = (pos.market || pos.tokenId || '').substring(0, 50);
|
||||
logger.info(
|
||||
` [${pos.outcome || '?'}] ${name}` +
|
||||
` | ${pos.shares.toFixed(4)} sh @ $${pos.avgBuyPrice.toFixed(4)}` +
|
||||
` | spent $${(pos.totalCost || 0).toFixed(2)}${pnlStr}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (config.dryRun) {
|
||||
const s = getSimStats();
|
||||
if (s.totalBuys > 0 || s.totalResolved > 0) {
|
||||
const rate = s.totalResolved > 0
|
||||
? `${((s.wins / s.totalResolved) * 100).toFixed(0)}% win`
|
||||
: 'no resolved yet';
|
||||
logger.info(
|
||||
` [SIM] ${s.totalBuys} buys tracked | ${s.wins}W/${s.losses}L (${rate})` +
|
||||
` | realized P&L: $${(s.closedPnl || 0).toFixed(2)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`Status check error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Redeemer loop ─────────────────────────────────────────────────────────────
|
||||
async function redeemerLoop() {
|
||||
try {
|
||||
await checkAndRedeemPositions();
|
||||
} catch (err) {
|
||||
logger.error('Redeemer loop error:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main ──────────────────────────────────────────────────────────────────────
|
||||
async function main() {
|
||||
try {
|
||||
validateConfig();
|
||||
} catch (err) {
|
||||
logger.error(err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const mode = config.dryRun ? 'SIMULATION' : 'LIVE TRADING';
|
||||
logger.info(`=== Polymarket Copy Trade [${mode}] ===`);
|
||||
logger.info(`Trader : ${config.traderAddress}`);
|
||||
logger.info(`Proxy wallet : ${config.proxyWallet}`);
|
||||
logger.info(`Size mode : ${config.sizeMode} (${config.sizePercent}%)`);
|
||||
logger.info(`Min trade : $${config.minTradeSize}`);
|
||||
logger.info(`Max position : $${config.maxPositionSize} per market`);
|
||||
logger.info(`Auto sell : ${config.autoSellEnabled ? `ON (+${config.autoSellProfitPercent}%)` : 'OFF'}`);
|
||||
logger.info(`Sell mode : ${config.sellMode}`);
|
||||
logger.info(`Min time left: ${config.minMarketTimeLeft}s`);
|
||||
logger.info('==========================================');
|
||||
|
||||
try {
|
||||
await initClient();
|
||||
} catch (err) {
|
||||
logger.error('Failed to initialize CLOB client:', err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const balance = await getUsdcBalance();
|
||||
logger.money(`USDC.e Balance: $${balance.toFixed(2)}`);
|
||||
} catch (err) {
|
||||
logger.warn('Could not fetch balance:', err.message);
|
||||
}
|
||||
|
||||
logger.success(
|
||||
config.dryRun
|
||||
? 'Simulation started — watching trader in real-time...'
|
||||
: 'Bot started — watching trader in real-time...',
|
||||
);
|
||||
|
||||
startWsWatcher(handleTrade);
|
||||
|
||||
await redeemerLoop();
|
||||
const redeemerInterval = setInterval(redeemerLoop, config.redeemInterval);
|
||||
|
||||
// Print status every 60 seconds
|
||||
const statusInterval = setInterval(printStatus, 60_000);
|
||||
|
||||
const shutdown = () => {
|
||||
logger.info('Shutting down...');
|
||||
stopWsWatcher();
|
||||
clearInterval(redeemerInterval);
|
||||
clearInterval(statusInterval);
|
||||
setTimeout(() => process.exit(0), 300);
|
||||
};
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
logger.error('Fatal error:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
+16
-119
@@ -46,122 +46,31 @@ const config = {
|
||||
maxRetries: 5,
|
||||
retryDelay: 3000,
|
||||
|
||||
// Skip buy if market closes within this many seconds (default 5 minutes)
|
||||
minMarketTimeLeft: parseInt(process.env.MIN_MARKET_TIME_LEFT || '300', 10),
|
||||
|
||||
// Seconds to wait for a GTC limit order to fill when FAK finds no liquidity
|
||||
// (happens when copying trades into "next market" before sellers arrive)
|
||||
gtcFallbackTimeout: parseInt(process.env.GTC_FALLBACK_TIMEOUT || '60', 10),
|
||||
|
||||
// ── Market Maker ──────────────────────────────────────────────
|
||||
mmAssets: (process.env.MM_ASSETS || 'btc')
|
||||
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean),
|
||||
mmDuration: process.env.MM_DURATION || '5m', // '5m' or '15m'
|
||||
mmTradeSize: parseFloat(process.env.MM_TRADE_SIZE || '5'), // USDC per side
|
||||
mmSellPrice: parseFloat(process.env.MM_SELL_PRICE || '0.60'), // limit sell target
|
||||
mmCutLossTime: parseInt(process.env.MM_CUT_LOSS_TIME || '60', 10), // seconds before close
|
||||
mmMarketKeyword: process.env.MM_MARKET_KEYWORD || 'Bitcoin Up or Down',
|
||||
mmEntryWindow: parseInt(process.env.MM_ENTRY_WINDOW || '45', 10), // max secs after open
|
||||
mmPollInterval: parseInt(process.env.MM_POLL_INTERVAL || '10', 10) * 1000,
|
||||
mmAdaptiveCL: process.env.MM_ADAPTIVE_CL !== 'false', // true = adaptive, false = legacy immediate market-sell
|
||||
mmAdaptiveMinCombined: parseFloat(process.env.MM_ADAPTIVE_MIN_COMBINED || '1.20'), // min combined sell (both legs) to qualify for limit
|
||||
mmAdaptiveMonitorSec: parseInt(process.env.MM_ADAPTIVE_MONITOR_SEC || '5', 10),
|
||||
|
||||
// ── Defensive Pivot (5m markets only) ─────────────────────────
|
||||
// When NEITHER side fills within timeout, enter defensive mode:
|
||||
// At 30s before close, if worst side < threshold → market sell worst, keep best
|
||||
// Otherwise merge back to USDC (zero P&L)
|
||||
mmDefensiveEnabled: process.env.MM_DEFENSIVE_ENABLED !== 'false', // default on
|
||||
mmDefensiveTimeout: parseInt(process.env.MM_DEFENSIVE_TIMEOUT || '120', 10), // secs without fill → defensive
|
||||
mmDefensiveWorstThreshold: parseFloat(process.env.MM_DEFENSIVE_WORST_THRESHOLD || '0.10'), // sell worst if price < this
|
||||
mmAssets: (process.env.MM_ASSETS || 'btc')
|
||||
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean),
|
||||
mmDuration: process.env.MM_DURATION || '5m', // '5m' or '15m'
|
||||
mmTradeSize: parseFloat(process.env.MM_TRADE_SIZE || '5'), // USDC per side
|
||||
mmSellPrice: parseFloat(process.env.MM_SELL_PRICE || '0.60'), // limit sell target
|
||||
mmCutLossTime: parseInt( process.env.MM_CUT_LOSS_TIME || '60', 10), // seconds before close
|
||||
mmMarketKeyword: process.env.MM_MARKET_KEYWORD || 'Bitcoin Up or Down',
|
||||
mmEntryWindow: parseInt( process.env.MM_ENTRY_WINDOW || '45', 10), // max secs after open
|
||||
mmPollInterval: parseInt( process.env.MM_POLL_INTERVAL || '10', 10) * 1000,
|
||||
|
||||
// ── Recovery Buy (after cut-loss) ─────────────────────────────
|
||||
// When enabled: after cutting loss, monitor prices for 10s and
|
||||
// market-buy the dominant side if it's above threshold and rising/stable.
|
||||
mmRecoveryBuy: process.env.MM_RECOVERY_BUY === 'true',
|
||||
mmRecoveryBuy: process.env.MM_RECOVERY_BUY === 'true',
|
||||
mmRecoveryThreshold: parseFloat(process.env.MM_RECOVERY_THRESHOLD || '0.70'), // min price to qualify
|
||||
mmRecoverySize: parseFloat(process.env.MM_RECOVERY_SIZE || '0'), // 0 = use mmTradeSize
|
||||
|
||||
// ── Maker Rebate MM ────────────────────────────────────────────
|
||||
// Buy YES+NO at top bid (maker), merge back to USDC ($1.00).
|
||||
// Profit = spread + maker rebate fees.
|
||||
makerMmAssets: (process.env.MAKER_MM_ASSETS || process.env.MM_ASSETS || 'btc')
|
||||
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean),
|
||||
makerMmDuration: process.env.MAKER_MM_DURATION || process.env.MM_DURATION || '5m',
|
||||
makerMmTradeSize: parseFloat(process.env.MAKER_MM_TRADE_SIZE || '5'), // USDC per side
|
||||
makerMmMaxCombined: parseFloat(process.env.MAKER_MM_MAX_COMBINED || '0.99'), // max bid_YES + bid_NO
|
||||
makerMmRepriceSec: parseInt(process.env.MAKER_MM_REPRICE_SEC || '3', 10), // orderbook poll interval
|
||||
makerMmFillTimeout: parseInt(process.env.MAKER_MM_FILL_TIMEOUT || '120', 10), // secs for 2nd fill after 1st
|
||||
makerMmCutLossTime: parseInt(process.env.MAKER_MM_CUT_LOSS_TIME || '60', 10), // secs before close to force exit
|
||||
makerMmEntryWindow: parseInt(process.env.MAKER_MM_ENTRY_WINDOW || '45', 10), // max secs after open to enter
|
||||
makerMmPollInterval: parseInt(process.env.MAKER_MM_POLL_INTERVAL || process.env.MM_POLL_INTERVAL || '5', 10) * 1000,
|
||||
makerMmReentryDelay: parseInt(process.env.MAKER_MM_REENTRY_DELAY || '30', 10) * 1000, // ms delay between re-entry cycles
|
||||
makerMmReentryEnabled: process.env.MAKER_MM_REENTRY_ENABLED !== 'false', // set false to disable re-entry (one cycle per market)
|
||||
makerMmRepriceThreshold: parseFloat(process.env.MAKER_MM_REPRICE_THRESHOLD || '0.02'), // reprice if bid drifts > this (default 2c)
|
||||
makerMmMinPrice: parseFloat(process.env.MAKER_MM_MIN_PRICE || '0.30'), // min bid for rebate range (both sides)
|
||||
makerMmMaxPrice: parseFloat(process.env.MAKER_MM_MAX_PRICE || '0.69'), // max bid for rebate range (both sides)
|
||||
// When true: if expensive side fills first, cancel cheap side and hold to redemption
|
||||
makerMmCancelCheapOnExpFill: process.env.MAKER_MM_CANCEL_CHEAP_ON_EXP_FILL === 'true',
|
||||
makerMmPollSec: parseInt(process.env.MAKER_MM_POLL_SEC || '3', 10),
|
||||
|
||||
// ── Current Market Settings ────────────────────────────────────
|
||||
// Enable trading on current active market (not just next market)
|
||||
currentMarketEnabled: process.env.CURRENT_MARKET_ENABLED === 'true',
|
||||
// Max odds threshold for current market (stop re-entry if odds drop below this)
|
||||
currentMarketMaxOdds: parseFloat(process.env.CURRENT_MARKET_MAX_ODDS || '0.70'),
|
||||
// Max odds threshold for next market (only enter if max odds <= this)
|
||||
nextMarketMaxOdds: parseFloat(process.env.NEXT_MARKET_MAX_ODDS || '0.52'),
|
||||
mmRecoverySize: parseFloat(process.env.MM_RECOVERY_SIZE || '0'), // 0 = use mmTradeSize
|
||||
|
||||
// ── Orderbook Sniper ───────────────────────────────────────────
|
||||
// 3-tier strategy: places GTC limit BUY orders at 3c, 2c, and 1c
|
||||
// Tier 1 (3c): smallest size | Tier 2 (2c): medium size | Tier 3 (1c): largest size
|
||||
// Min 5 shares per tier, total = SNIPER_MAX_SHARES_PER_SIDE
|
||||
// Places tiny GTC limit BUY orders at a very low price on each side
|
||||
// of ETH/SOL/XRP 5-minute markets — catches panic dumps near $0.
|
||||
sniperAssets: (process.env.SNIPER_ASSETS || 'eth,sol,xrp')
|
||||
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean),
|
||||
sniperTierPrices: [
|
||||
parseFloat(process.env.SNIPER_TIER1_PRICE || '0.03'), // high price, small size
|
||||
parseFloat(process.env.SNIPER_TIER2_PRICE || '0.02'), // mid price, medium size
|
||||
parseFloat(process.env.SNIPER_TIER3_PRICE || '0.01'), // low price, large size
|
||||
],
|
||||
sniperMaxShares: parseFloat(process.env.SNIPER_MAX_SHARES || '15'), // max total per side
|
||||
sniperMinSharesPerTier: 5, // minimum shares for each tier
|
||||
|
||||
// ── Sniper Sizing Multiplier (UTC+8) ──────────────────────────
|
||||
// Time-based bet sizing multiplier. Format: HH:MM-HH:MM:factor,...
|
||||
// Example: SNIPER_MULTIPLIERS=21:00-00:00:1.41,06:00-12:00:0.85
|
||||
// Default multiplier outside any window = 1.0
|
||||
sniperMultipliers: (() => {
|
||||
const raw = process.env.SNIPER_MULTIPLIERS || '';
|
||||
if (!raw.trim()) return [];
|
||||
return raw.split(',').map((s) => s.trim()).filter(Boolean).map((entry) => {
|
||||
const m = entry.match(/^(\d{1,2}:\d{2})\s*[-–]\s*(\d{1,2}:\d{2}):(\d+\.?\d*)$/);
|
||||
if (!m) return null;
|
||||
return { start: m[1], end: m[2], multiplier: parseFloat(m[3]) };
|
||||
}).filter(Boolean);
|
||||
})(),
|
||||
|
||||
// ── Sniper Pause After Win ───────────────────────────────────
|
||||
// Number of rounds (5-min slots) to pause an asset after a win is detected.
|
||||
sniperPauseRoundsAfterWin: parseInt(process.env.SNIPER_PAUSE_ROUNDS_AFTER_WIN || '3', 10),
|
||||
|
||||
// ── Sniper Schedule (UTC+8) ────────────────────────────────────
|
||||
// Per-asset session windows. Format: SNIPER_SCHEDULE_{ASSET}=HH:MM-HH:MM,HH:MM-HH:MM
|
||||
// Assets without a schedule are always active.
|
||||
sniperSchedule: (() => {
|
||||
const schedule = {};
|
||||
const prefix = 'SNIPER_SCHEDULE_';
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (key.startsWith(prefix) && value) {
|
||||
const asset = key.slice(prefix.length).toLowerCase();
|
||||
schedule[asset] = value;
|
||||
}
|
||||
}
|
||||
return schedule;
|
||||
})(),
|
||||
|
||||
// ── Proxy (Polymarket API only, NOT Polygon RPC) ──────────────
|
||||
// Supports HTTP/HTTPS. Example: http://user:pass@host:port
|
||||
proxyUrl: process.env.PROXY_URL || '',
|
||||
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean),
|
||||
sniperPrice: parseFloat(process.env.SNIPER_PRICE || '0.01'), // $ per share
|
||||
sniperShares: parseFloat(process.env.SNIPER_SHARES || '5'), // shares per side
|
||||
};
|
||||
|
||||
// Validation for copy-trade bot
|
||||
@@ -191,16 +100,4 @@ export function validateMMConfig() {
|
||||
throw new Error('MM_SELL_PRICE must be between 0 and 1');
|
||||
}
|
||||
|
||||
// Validation for maker-rebate MM bot
|
||||
export function validateMakerMMConfig() {
|
||||
const required = ['privateKey', 'proxyWallet'];
|
||||
const missing = required.filter((key) => !config[key]);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Missing required config: ${missing.join(', ')}. Check your .env file.`);
|
||||
}
|
||||
if (config.makerMmTradeSize <= 0) throw new Error('MAKER_MM_TRADE_SIZE must be > 0');
|
||||
if (config.makerMmMaxCombined <= 0 || config.makerMmMaxCombined >= 1)
|
||||
throw new Error('MAKER_MM_MAX_COMBINED must be between 0 and 1 exclusive');
|
||||
}
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -11,7 +11,6 @@ import logger from './utils/logger.js';
|
||||
// ── Dashboard init (before any log output) ────────────────────────────────────
|
||||
initDashboard();
|
||||
logger.setOutput(appendLog);
|
||||
logger.interceptConsole(); // strip auth headers from CLOB client axios error dumps
|
||||
|
||||
// ── Handle a trade event from WebSocket ───────────────────────────────────────
|
||||
async function handleTrade(trade) {
|
||||
|
||||
@@ -1,290 +0,0 @@
|
||||
/**
|
||||
* maker-mm-bot.js — Maker Rebate MM, PM2 / VPS entry point (no TUI)
|
||||
*
|
||||
* Plain-text stdout output, compatible with:
|
||||
* pm2 start pm2/maker-mm.config.cjs
|
||||
* pm2 logs polymarket-maker-mm
|
||||
*/
|
||||
|
||||
// Set proxy before any network calls
|
||||
import './utils/proxy-patch.cjs';
|
||||
|
||||
import { validateMakerMMConfig } from './config/index.js';
|
||||
import config from './config/index.js';
|
||||
import logger from './utils/logger.js';
|
||||
import { initClient, getUsdcBalance } from './services/client.js';
|
||||
import { startMMDetector, stopMMDetector, checkCurrentMarket } from './services/mmDetector.js';
|
||||
import { executeMakerRebateStrategy, getActiveMakerPositions, getMarketOdds as getExecutorMarketOdds } from './services/makerRebateExecutor.js';
|
||||
import { mmFillWatcher } from './services/mmWsFillWatcher.js';
|
||||
|
||||
logger.interceptConsole();
|
||||
|
||||
// ── Validate config ────────────────────────────────────────────────────────────
|
||||
|
||||
try {
|
||||
validateMakerMMConfig();
|
||||
} catch (err) {
|
||||
logger.error(`Config error: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Init CLOB client ──────────────────────────────────────────────────────────
|
||||
|
||||
try {
|
||||
await initClient();
|
||||
} catch (err) {
|
||||
logger.error(`Client init error: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Start WebSocket fill watcher ─────────────────────────────────────────────
|
||||
|
||||
mmFillWatcher.start();
|
||||
|
||||
// ── Override mmDetector config to use maker-mm settings ──────────────────────
|
||||
|
||||
config.mmAssets = config.makerMmAssets;
|
||||
config.mmDuration = config.makerMmDuration;
|
||||
config.mmPollInterval = config.makerMmPollInterval;
|
||||
config.mmEntryWindow = config.makerMmEntryWindow;
|
||||
|
||||
// ── Periodic status log ──────────────────────────────────────────────────────
|
||||
|
||||
async function printStatus() {
|
||||
try {
|
||||
let balanceStr = 'SIM';
|
||||
if (!config.dryRun) {
|
||||
try { balanceStr = `$${(await getUsdcBalance()).toFixed(2)} USDC`; } catch { balanceStr = 'N/A'; }
|
||||
}
|
||||
|
||||
const positions = getActiveMakerPositions();
|
||||
const mode = config.dryRun ? 'SIMULATION' : 'LIVE';
|
||||
|
||||
logger.info(
|
||||
`--- MakerMM Status [${mode}] | Balance: ${balanceStr} | Active positions: ${positions.length} ---`,
|
||||
);
|
||||
|
||||
for (const pos of positions) {
|
||||
const assetTag = pos.asset ? `[${pos.asset.toUpperCase()}] ` : '';
|
||||
const label = pos.question.substring(0, 50);
|
||||
const msLeft = new Date(pos.endTime).getTime() - Date.now();
|
||||
const secsLeft = Math.max(0, Math.round(msLeft / 1000));
|
||||
const timeStr = secsLeft > 60
|
||||
? `${Math.floor(secsLeft / 60)}m${secsLeft % 60}s left`
|
||||
: `${secsLeft}s left`;
|
||||
|
||||
const yFill = pos.yes.filled ? `FILLED` : `bid $${pos.yes.buyPrice?.toFixed(3)}`;
|
||||
const nFill = pos.no.filled ? `FILLED` : `bid $${pos.no.buyPrice?.toFixed(3)}`;
|
||||
const combined = (pos.yes.buyPrice + pos.no.buyPrice).toFixed(4);
|
||||
|
||||
logger.info(
|
||||
` ${assetTag}${label} | ${pos.status} | ${timeStr}` +
|
||||
` | combined $${combined}` +
|
||||
` | YES ${pos.targetShares}sh → ${yFill}` +
|
||||
` | NO ${pos.targetShares}sh → ${nFill}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`Status check error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Market handler with per-asset queue ──────────────────────────────────────
|
||||
|
||||
const pendingByAsset = new Map();
|
||||
const runningByAsset = new Set(); // tracked from start of runStrategy, not just active positions
|
||||
|
||||
/**
|
||||
* Check if current market odds allow re-entry
|
||||
* For current market: max odds must be <= currentMarketMaxOdds (default 70%)
|
||||
*/
|
||||
async function isCurrentMarketOddsValidForReentry(yesTokenId, noTokenId) {
|
||||
if (!config.currentMarketEnabled) return false;
|
||||
|
||||
try {
|
||||
const odds = await getExecutorMarketOdds(yesTokenId, noTokenId);
|
||||
if (!odds) {
|
||||
logger.warn(`MakerMM: cannot determine odds — blocking re-entry`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const threshold = config.currentMarketMaxOdds;
|
||||
const valid = odds.max <= threshold;
|
||||
|
||||
if (!valid) {
|
||||
logger.warn(
|
||||
`MakerMM: current market max odds ${(odds.max * 100).toFixed(1)}% > ${(threshold * 100).toFixed(0)}% ` +
|
||||
`— STOPPING re-entry for this market`
|
||||
);
|
||||
} else {
|
||||
logger.info(
|
||||
`MakerMM: current market max odds ${(odds.max * 100).toFixed(1)}% <= ${(threshold * 100).toFixed(0)}% ` +
|
||||
`— re-entry allowed`
|
||||
);
|
||||
}
|
||||
|
||||
return valid;
|
||||
} catch (err) {
|
||||
logger.warn(`MakerMM: odds check error — ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runStrategy(market) {
|
||||
const isCurrentMarket = market.isCurrentMarket ?? false;
|
||||
const assetTag = market.asset?.toUpperCase() || '';
|
||||
let cycleCount = 0;
|
||||
|
||||
runningByAsset.add(market.asset);
|
||||
|
||||
while (true) {
|
||||
cycleCount++;
|
||||
if (cycleCount > 1) {
|
||||
logger.info(`MakerMM[${assetTag}]: re-entry cycle #${cycleCount}`);
|
||||
}
|
||||
|
||||
// ── Check if already have active position for this asset ─────────────
|
||||
// Wait for any existing position to complete before starting new one
|
||||
const maxWaitMs = 120_000; // Max 2 minutes wait
|
||||
const pollIntervalMs = 2_000;
|
||||
const waitStart = Date.now();
|
||||
|
||||
while (true) {
|
||||
const activePositions = getActiveMakerPositions();
|
||||
const hasActivePosition = activePositions.some(p => p.asset === market.asset);
|
||||
|
||||
if (!hasActivePosition) break; // Safe to proceed
|
||||
|
||||
if (Date.now() - waitStart > maxWaitMs) {
|
||||
logger.warn(`MakerMM[${assetTag}]: timeout waiting for previous position — skipping cycle`);
|
||||
return; // Exit this runStrategy entirely
|
||||
}
|
||||
|
||||
logger.info(`MakerMM[${assetTag}]: waiting for previous position to complete...`);
|
||||
await new Promise(r => setTimeout(r, pollIntervalMs));
|
||||
}
|
||||
|
||||
let cycleResult = { oneSided: false };
|
||||
try {
|
||||
cycleResult = await executeMakerRebateStrategy(market) ?? { oneSided: false };
|
||||
} catch (err) {
|
||||
logger.error(`MakerMM strategy error (${assetTag}): ${err.message}`);
|
||||
}
|
||||
|
||||
// If cycle ended with one-sided fill (stuck), stop re-entry for this market
|
||||
if (cycleResult.oneSided) {
|
||||
logger.warn(`MakerMM[${assetTag}]: cycle ended one-sided — stopping re-entry to avoid accumulating exposure`);
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if we can re-enter (market still active with enough time)
|
||||
const msRemaining = new Date(market.endTime).getTime() - Date.now();
|
||||
const secsLeft = Math.round(msRemaining / 1000);
|
||||
const minTimeForReentry = 180; // 3 minutes minimum
|
||||
|
||||
if (config.makerMmReentryEnabled && secsLeft > config.makerMmCutLossTime + minTimeForReentry) {
|
||||
// ── CURRENT MARKET: Check odds before re-entry ──────────────────────
|
||||
if (isCurrentMarket && config.currentMarketEnabled) {
|
||||
const oddsValid = await isCurrentMarketOddsValidForReentry(
|
||||
market.yesTokenId,
|
||||
market.noTokenId
|
||||
);
|
||||
|
||||
if (!oddsValid) {
|
||||
logger.info(
|
||||
`MakerMM[${assetTag}]: current market odds exceeded threshold — ` +
|
||||
`stopping re-entry, will wait for next market`
|
||||
);
|
||||
break; // Exit to next market instead of re-entering
|
||||
}
|
||||
}
|
||||
|
||||
const delaySec = config.makerMmReentryDelay / 1000;
|
||||
logger.info(`MakerMM[${assetTag}]: waiting ${delaySec}s for re-entry (${secsLeft}s remaining)...`);
|
||||
await new Promise(r => setTimeout(r, config.makerMmReentryDelay));
|
||||
continue; // Re-enter same market
|
||||
}
|
||||
|
||||
// Not enough time for re-entry — check queued market
|
||||
break;
|
||||
}
|
||||
|
||||
runningByAsset.delete(market.asset);
|
||||
|
||||
const queued = pendingByAsset.get(market.asset);
|
||||
if (queued) {
|
||||
pendingByAsset.delete(market.asset);
|
||||
|
||||
const endMs = new Date(queued.endTime).getTime();
|
||||
const secsLeft = Math.round((endMs - Date.now()) / 1000);
|
||||
|
||||
if (secsLeft > config.makerMmCutLossTime) {
|
||||
logger.success(
|
||||
`MakerMM[${assetTag}]: position cleared — ` +
|
||||
`executing queued "${queued.question.substring(0, 40)}" (${secsLeft}s left)`,
|
||||
);
|
||||
runStrategy(queued);
|
||||
} else {
|
||||
logger.warn(
|
||||
`MakerMM[${assetTag}]: queued market "${queued.question.substring(0, 40)}" ` +
|
||||
`expired (${secsLeft}s left) — discarding`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNewMarket(market) {
|
||||
// Use runningByAsset — tracks from start of runStrategy, not just active positions.
|
||||
// This prevents race where next market fires before executeMakerRebateStrategy adds to activePositions.
|
||||
const isAssetBusy = runningByAsset.has(market.asset);
|
||||
|
||||
if (isAssetBusy) {
|
||||
pendingByAsset.set(market.asset, market);
|
||||
logger.warn(
|
||||
`MakerMM[${market.asset?.toUpperCase()}]: queued "${market.question.substring(0, 40)}" — ` +
|
||||
`will enter after current position clears`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
runStrategy(market);
|
||||
}
|
||||
|
||||
// ── Timers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const statusTimer = setInterval(printStatus, 60_000);
|
||||
|
||||
// ── Graceful shutdown ─────────────────────────────────────────────────────────
|
||||
|
||||
function shutdown() {
|
||||
logger.warn('MakerMM: shutting down...');
|
||||
stopMMDetector();
|
||||
mmFillWatcher.stop();
|
||||
clearInterval(statusTimer);
|
||||
setTimeout(() => process.exit(0), 300);
|
||||
}
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
|
||||
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const mode = config.dryRun ? 'SIMULATION' : 'LIVE';
|
||||
logger.info(`=== Maker Rebate MM [${mode}] ===`);
|
||||
logger.info(`Assets : ${config.makerMmAssets.join(', ').toUpperCase()}`);
|
||||
logger.info(`Duration : ${config.makerMmDuration}`);
|
||||
logger.info(`Trade size : $${config.makerMmTradeSize} per side`);
|
||||
logger.info(`Max combined: $${config.makerMmMaxCombined}`);
|
||||
logger.info(`Reprice : ${config.makerMmRepriceSec}s`);
|
||||
logger.info(`Fill timeout: ${config.makerMmFillTimeout}s`);
|
||||
logger.info(`Cut loss : ${config.makerMmCutLossTime}s before close`);
|
||||
logger.info(`Entry window: ${config.makerMmEntryWindow}s after open`);
|
||||
logger.info(`Current MM : ${config.currentMarketEnabled ? 'ENABLED' : 'disabled'} (max odds: ${(config.currentMarketMaxOdds * 100).toFixed(0)}%)`);
|
||||
logger.info(`Next MM : max odds ${(config.nextMarketMaxOdds * 100).toFixed(0)}%`);
|
||||
logger.info('==========================================');
|
||||
|
||||
// Check current active market FIRST so it gets priority and marks asset as running
|
||||
// before the detector polls for the next market.
|
||||
await checkCurrentMarket((market) => handleNewMarket({ ...market, isCurrentMarket: true }));
|
||||
startMMDetector(handleNewMarket);
|
||||
logger.success(`MakerMM bot started — watching for ${config.makerMmDuration} ${config.makerMmAssets.join('/')} markets...`);
|
||||
-219
@@ -1,219 +0,0 @@
|
||||
/**
|
||||
* maker-mm.js
|
||||
* Entry point for the Maker Rebate MM bot (TUI).
|
||||
* Buys YES+NO at top bid (maker) → merges → profit from spread + rebates.
|
||||
* Run with: npm run maker-mm (live)
|
||||
* npm run maker-mm-sim (simulation / dry-run)
|
||||
*/
|
||||
|
||||
// Set proxy before any network calls
|
||||
import './utils/proxy-patch.cjs';
|
||||
|
||||
import { validateMakerMMConfig } from './config/index.js';
|
||||
import config from './config/index.js';
|
||||
import logger from './utils/logger.js';
|
||||
import { initClient, getClient } from './services/client.js';
|
||||
import { initDashboard, appendLog, updateStatus, isDashboardActive } from './ui/dashboard.js';
|
||||
import { startMMDetector, stopMMDetector, checkCurrentMarket } from './services/mmDetector.js';
|
||||
import { executeMakerRebateStrategy, getActiveMakerPositions } from './services/makerRebateExecutor.js';
|
||||
import { mmFillWatcher } from './services/mmWsFillWatcher.js';
|
||||
import { getUsdcBalance } from './services/client.js';
|
||||
|
||||
// ── Validate config ────────────────────────────────────────────────────────────
|
||||
|
||||
try {
|
||||
validateMakerMMConfig();
|
||||
} catch (err) {
|
||||
console.error(`Config error: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Init TUI ──────────────────────────────────────────────────────────────────
|
||||
|
||||
initDashboard();
|
||||
logger.setOutput(appendLog);
|
||||
|
||||
// ── Init CLOB client ──────────────────────────────────────────────────────────
|
||||
|
||||
try {
|
||||
await initClient();
|
||||
} catch (err) {
|
||||
logger.error(`Client init error: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Start WebSocket fill watcher for real-time order detection ────────────────
|
||||
|
||||
mmFillWatcher.start();
|
||||
|
||||
// ── Override mmDetector config to use maker-mm settings ──────────────────────
|
||||
|
||||
config.mmAssets = config.makerMmAssets;
|
||||
config.mmDuration = config.makerMmDuration;
|
||||
config.mmPollInterval = config.makerMmPollInterval;
|
||||
config.mmEntryWindow = config.makerMmEntryWindow;
|
||||
|
||||
// ── Status panel refresh ──────────────────────────────────────────────────────
|
||||
|
||||
async function buildStatusContent() {
|
||||
let lines = [];
|
||||
|
||||
// Balance
|
||||
let balance = '?';
|
||||
if (!config.dryRun) {
|
||||
try { balance = (await getUsdcBalance()).toFixed(2); } catch { /* ignore */ }
|
||||
} else {
|
||||
balance = '{yellow-fg}SIM{/yellow-fg}';
|
||||
}
|
||||
lines.push(`{bold}BALANCE{/bold}`);
|
||||
lines.push(` USDC.e: {green-fg}$${balance}{/green-fg}`);
|
||||
lines.push('');
|
||||
|
||||
// Mode
|
||||
lines.push(`{bold}MODE{/bold}`);
|
||||
lines.push(` ${config.dryRun ? '{yellow-fg}SIMULATION{/yellow-fg}' : '{green-fg}LIVE{/green-fg}'}`);
|
||||
lines.push(` Strategy: {cyan-fg}MAKER REBATE{/cyan-fg}`);
|
||||
lines.push('');
|
||||
|
||||
// Config
|
||||
lines.push(`{bold}MAKER MM CONFIG{/bold}`);
|
||||
lines.push(` Assets : ${config.makerMmAssets.join(', ').toUpperCase()}`);
|
||||
lines.push(` Duration : ${config.makerMmDuration}`);
|
||||
lines.push(` Trade sz : $${config.makerMmTradeSize} per side`);
|
||||
lines.push(` Max combined: $${config.makerMmMaxCombined}`);
|
||||
lines.push(` Reprice : ${config.makerMmRepriceSec}s`);
|
||||
lines.push(` Fill timeout: ${config.makerMmFillTimeout}s`);
|
||||
lines.push(` Cut loss : ${config.makerMmCutLossTime}s before close`);
|
||||
lines.push('');
|
||||
|
||||
// Active positions
|
||||
const positions = getActiveMakerPositions();
|
||||
lines.push(`{bold}ACTIVE POSITIONS (${positions.length}){/bold}`);
|
||||
|
||||
if (positions.length === 0) {
|
||||
lines.push(' {gray-fg}Waiting for market...{/gray-fg}');
|
||||
} else {
|
||||
for (const pos of positions) {
|
||||
const assetTag = pos.asset ? `[${pos.asset.toUpperCase()}] ` : '';
|
||||
const label = pos.question.substring(0, 32);
|
||||
const msLeft = new Date(pos.endTime).getTime() - Date.now();
|
||||
const secsLeft = Math.max(0, Math.round(msLeft / 1000));
|
||||
const timeStr = secsLeft > 60
|
||||
? `${Math.floor(secsLeft / 60)}m${secsLeft % 60}s`
|
||||
: `{red-fg}${secsLeft}s{/red-fg}`;
|
||||
|
||||
const combined = (pos.yes.buyPrice + pos.no.buyPrice).toFixed(4);
|
||||
const spread = (1 - pos.yes.buyPrice - pos.no.buyPrice).toFixed(4);
|
||||
|
||||
lines.push(` {cyan-fg}${assetTag}${label}{/cyan-fg}`);
|
||||
lines.push(` Status : ${pos.status} | Time left: ${timeStr}`);
|
||||
lines.push(` Combined: $${combined} | Spread: $${spread}`);
|
||||
|
||||
// YES side
|
||||
const yFill = pos.yes.filled
|
||||
? `{green-fg}FILLED{/green-fg}`
|
||||
: `{yellow-fg}bid $${pos.yes.buyPrice?.toFixed(3)}{/yellow-fg}`;
|
||||
lines.push(` YES ${pos.targetShares?.toFixed(1)} sh @ $${pos.yes.buyPrice?.toFixed(3)} → ${yFill}`);
|
||||
|
||||
// NO side
|
||||
const nFill = pos.no.filled
|
||||
? `{green-fg}FILLED{/green-fg}`
|
||||
: `{yellow-fg}bid $${pos.no.buyPrice?.toFixed(3)}{/yellow-fg}`;
|
||||
lines.push(` NO ${pos.targetShares?.toFixed(1)} sh @ $${pos.no.buyPrice?.toFixed(3)} → ${nFill}`);
|
||||
|
||||
if (pos.totalProfit !== 0) {
|
||||
const sign = pos.totalProfit >= 0 ? '+' : '';
|
||||
const color = pos.totalProfit >= 0 ? 'green' : 'red';
|
||||
lines.push(` P&L: {${color}-fg}${sign}$${pos.totalProfit.toFixed(2)}{/${color}-fg}`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
return '\n' + lines.join('\n');
|
||||
}
|
||||
|
||||
let refreshTimer = null;
|
||||
|
||||
function startRefresh() {
|
||||
refreshTimer = setInterval(async () => {
|
||||
if (!isDashboardActive()) return;
|
||||
const content = await buildStatusContent();
|
||||
updateStatus(content);
|
||||
}, 3000);
|
||||
|
||||
// Immediate refresh
|
||||
buildStatusContent().then(updateStatus);
|
||||
}
|
||||
|
||||
// ── Market handler with per-asset queue ──────────────────────────────────────
|
||||
|
||||
const pendingByAsset = new Map();
|
||||
|
||||
async function runStrategy(market) {
|
||||
try {
|
||||
await executeMakerRebateStrategy(market);
|
||||
} catch (err) {
|
||||
logger.error(`MakerMM strategy error (${market.asset?.toUpperCase()}): ${err.message}`);
|
||||
}
|
||||
|
||||
// After position clears, execute queued market for this asset
|
||||
const queued = pendingByAsset.get(market.asset);
|
||||
if (queued) {
|
||||
pendingByAsset.delete(market.asset);
|
||||
|
||||
const endMs = new Date(queued.endTime).getTime();
|
||||
const secsLeft = Math.round((endMs - Date.now()) / 1000);
|
||||
|
||||
if (secsLeft > config.makerMmCutLossTime) {
|
||||
logger.success(
|
||||
`MakerMM[${market.asset?.toUpperCase()}]: position cleared — ` +
|
||||
`executing queued "${queued.question.substring(0, 40)}" (${secsLeft}s left)`,
|
||||
);
|
||||
runStrategy(queued);
|
||||
} else {
|
||||
logger.warn(
|
||||
`MakerMM[${market.asset?.toUpperCase()}]: queued market "${queued.question.substring(0, 40)}" ` +
|
||||
`expired (${secsLeft}s left) — discarding`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNewMarket(market) {
|
||||
const active = getActiveMakerPositions();
|
||||
const isAssetBusy = active.some((p) => p.asset === market.asset);
|
||||
|
||||
if (isAssetBusy) {
|
||||
pendingByAsset.set(market.asset, market);
|
||||
logger.warn(
|
||||
`MakerMM[${market.asset?.toUpperCase()}]: queued "${market.question.substring(0, 40)}" — ` +
|
||||
`will enter after current position clears`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
runStrategy(market);
|
||||
}
|
||||
|
||||
// ── Graceful shutdown ─────────────────────────────────────────────────────────
|
||||
|
||||
function shutdown() {
|
||||
logger.warn('MakerMM: shutting down...');
|
||||
stopMMDetector();
|
||||
mmFillWatcher.stop();
|
||||
if (refreshTimer) clearInterval(refreshTimer);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
|
||||
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
logger.info(`MakerMM bot starting — ${config.dryRun ? 'SIMULATION MODE' : 'LIVE MODE'} | assets: ${config.makerMmAssets.join(', ').toUpperCase()} | ${config.makerMmDuration}`);
|
||||
startRefresh();
|
||||
startMMDetector(handleNewMarket);
|
||||
// Immediately check if there's a current active market to enter
|
||||
checkCurrentMarket(handleNewMarket);
|
||||
-197
@@ -1,197 +0,0 @@
|
||||
/**
|
||||
* mm-bot.js — Market Maker, PM2 / VPS entry point (no TUI)
|
||||
*
|
||||
* Plain-text stdout output, compatible with:
|
||||
* pm2 start ecosystem.config.cjs --only polymarket-mm
|
||||
* pm2 logs polymarket-mm
|
||||
*/
|
||||
|
||||
// Set proxy before any network calls
|
||||
import './utils/proxy-patch.cjs';
|
||||
|
||||
import { validateMMConfig } from './config/index.js';
|
||||
import config from './config/index.js';
|
||||
import logger from './utils/logger.js';
|
||||
import { initClient, getClient, getUsdcBalance } from './services/client.js';
|
||||
import { startMMDetector, stopMMDetector } from './services/mmDetector.js';
|
||||
import { executeMMStrategy, getActiveMMPositions } from './services/mmExecutor.js';
|
||||
import { mmFillWatcher } from './services/mmWsFillWatcher.js';
|
||||
import { cleanupOpenPositions, redeemMMPositions, MIN_SHARES_PER_SIDE } from './services/ctf.js';
|
||||
|
||||
logger.interceptConsole();
|
||||
|
||||
// ── Validate config ────────────────────────────────────────────────────────────
|
||||
|
||||
try {
|
||||
validateMMConfig();
|
||||
} 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);
|
||||
}
|
||||
|
||||
// ── Validate MM_TRADE_SIZE minimum ────────────────────────────────────────────
|
||||
|
||||
if (config.mmTradeSize < MIN_SHARES_PER_SIDE) {
|
||||
logger.error(
|
||||
`MM_TRADE_SIZE=${config.mmTradeSize} is below Polymarket minimum of ${MIN_SHARES_PER_SIDE} shares. ` +
|
||||
`Set MM_TRADE_SIZE ≥ ${MIN_SHARES_PER_SIDE} in your .env and restart.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Start WebSocket fill watcher for real-time order detection ────────────────
|
||||
|
||||
mmFillWatcher.start();
|
||||
|
||||
// ── Cleanup leftover positions on startup ─────────────────────────────────────
|
||||
|
||||
try {
|
||||
await cleanupOpenPositions(getClient());
|
||||
} catch (err) {
|
||||
logger.warn(`MM: startup cleanup failed (non-fatal): ${err.message}`);
|
||||
}
|
||||
|
||||
// ── Periodic status log (replaces TUI right panel) ────────────────────────────
|
||||
|
||||
async function printStatus() {
|
||||
try {
|
||||
let balanceStr = 'SIM';
|
||||
if (!config.dryRun) {
|
||||
try { balanceStr = `$${(await getUsdcBalance()).toFixed(2)} USDC`; } catch { balanceStr = 'N/A'; }
|
||||
}
|
||||
|
||||
const positions = getActiveMMPositions();
|
||||
const mode = config.dryRun ? 'SIMULATION' : 'LIVE';
|
||||
|
||||
logger.info(
|
||||
`--- MM 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 @ $${pos.yes.fillPrice?.toFixed(3)}`
|
||||
: `waiting $${config.mmSellPrice}`;
|
||||
const nFill = pos.no.filled
|
||||
? `FILLED @ $${pos.no.fillPrice?.toFixed(3)}`
|
||||
: `waiting $${config.mmSellPrice}`;
|
||||
|
||||
logger.info(
|
||||
` ${assetTag}${label} | ${pos.status} | ${timeStr}` +
|
||||
` | YES ${pos.yes.shares?.toFixed(3)}sh@$${pos.yes.entryPrice?.toFixed(3)} → ${yFill}` +
|
||||
` | NO ${pos.no.shares?.toFixed(3)}sh@$${pos.no.entryPrice?.toFixed(3)} → ${nFill}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`Status check error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Market handler with per-asset queue ───────────────────────────────────────
|
||||
|
||||
// Each asset can hold one pending market while its current position is active.
|
||||
const pendingByAsset = new Map(); // asset → market
|
||||
|
||||
async function runStrategy(market) {
|
||||
try {
|
||||
await executeMMStrategy(market);
|
||||
} catch (err) {
|
||||
logger.error(`MM strategy error (${market.asset?.toUpperCase()}): ${err.message}`);
|
||||
}
|
||||
|
||||
// After position clears, execute the queued market for this asset if still valid
|
||||
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.mmCutLossTime) {
|
||||
logger.success(
|
||||
`MM[${market.asset?.toUpperCase()}]: position cleared — ` +
|
||||
`executing queued "${queued.question.substring(0, 40)}" (${secsLeft}s left)`,
|
||||
);
|
||||
runStrategy(queued); // non-blocking
|
||||
} else {
|
||||
logger.warn(
|
||||
`MM[${market.asset?.toUpperCase()}]: queued market "${queued.question.substring(0, 40)}" ` +
|
||||
`expired (${secsLeft}s left) — discarding`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNewMarket(market) {
|
||||
const active = getActiveMMPositions();
|
||||
const isAssetBusy = active.some((p) => p.asset === market.asset);
|
||||
|
||||
if (isAssetBusy) {
|
||||
pendingByAsset.set(market.asset, market);
|
||||
logger.warn(
|
||||
`MM[${market.asset?.toUpperCase()}]: queued "${market.question.substring(0, 40)}" — ` +
|
||||
`will enter after current ${market.asset?.toUpperCase()} position clears`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
runStrategy(market); // non-blocking
|
||||
}
|
||||
|
||||
// ── Timers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Print status every 60 seconds
|
||||
const statusTimer = setInterval(printStatus, 60_000);
|
||||
|
||||
// Redeemer: run immediately then every redeemInterval
|
||||
redeemMMPositions().catch((err) => logger.error('MM redeemer error:', err.message));
|
||||
const redeemTimer = setInterval(
|
||||
() => redeemMMPositions().catch((err) => logger.error('MM redeemer error:', err.message)),
|
||||
config.redeemInterval,
|
||||
);
|
||||
|
||||
// ── Graceful shutdown ─────────────────────────────────────────────────────────
|
||||
|
||||
function shutdown() {
|
||||
logger.warn('MM: shutting down...');
|
||||
stopMMDetector();
|
||||
mmFillWatcher.stop();
|
||||
clearInterval(statusTimer);
|
||||
clearInterval(redeemTimer);
|
||||
setTimeout(() => process.exit(0), 300);
|
||||
}
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
|
||||
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const mode = config.dryRun ? 'SIMULATION' : 'LIVE';
|
||||
logger.info(`=== Market Maker [${mode}] ===`);
|
||||
logger.info(`Assets : ${config.mmAssets.join(', ').toUpperCase()}`);
|
||||
logger.info(`Duration : ${config.mmDuration}`);
|
||||
logger.info(`Trade size: $${config.mmTradeSize} per side`);
|
||||
logger.info(`Sell @ : $${config.mmSellPrice}`);
|
||||
logger.info(`Cut loss : ${config.mmCutLossTime}s before close`);
|
||||
logger.info(`Keyword : ${config.mmMarketKeyword}`);
|
||||
logger.info(`Entry win : ${config.mmEntryWindow}s after open`);
|
||||
logger.info('==========================================');
|
||||
|
||||
startMMDetector(handleNewMarket);
|
||||
logger.success(`MM bot started — watching for ${config.mmDuration} ${config.mmAssets.join('/')} markets...`);
|
||||
@@ -6,9 +6,6 @@
|
||||
* npm run mm-sim (simulation / dry-run)
|
||||
*/
|
||||
|
||||
// Set proxy before any network calls
|
||||
import './utils/proxy-patch.cjs';
|
||||
|
||||
import { validateMMConfig } from './config/index.js';
|
||||
import config from './config/index.js';
|
||||
import logger from './utils/logger.js';
|
||||
@@ -16,7 +13,6 @@ import { initClient, getClient } from './services/client.js';
|
||||
import { initDashboard, appendLog, updateStatus, isDashboardActive } from './ui/dashboard.js';
|
||||
import { startMMDetector, stopMMDetector } from './services/mmDetector.js';
|
||||
import { executeMMStrategy, getActiveMMPositions } from './services/mmExecutor.js';
|
||||
import { mmFillWatcher } from './services/mmWsFillWatcher.js';
|
||||
import { getUsdcBalance } from './services/client.js';
|
||||
import { cleanupOpenPositions, redeemMMPositions, MIN_SHARES_PER_SIDE } from './services/ctf.js';
|
||||
|
||||
@@ -53,11 +49,7 @@ if (config.mmTradeSize < MIN_SHARES_PER_SIDE) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Start WebSocket fill watcher for real-time order detection ────────────────
|
||||
|
||||
mmFillWatcher.start();
|
||||
|
||||
// ── Cleanup leftover positions on startup ─────────────────────────────────���───
|
||||
// ── Cleanup leftover positions on startup ─────────────────────────────────────
|
||||
|
||||
try {
|
||||
await cleanupOpenPositions(getClient());
|
||||
@@ -215,7 +207,6 @@ async function handleNewMarket(market) {
|
||||
function shutdown() {
|
||||
logger.warn('MM: shutting down...');
|
||||
stopMMDetector();
|
||||
mmFillWatcher.stop();
|
||||
if (refreshTimer) clearInterval(refreshTimer);
|
||||
if (redeemTimer) clearInterval(redeemTimer);
|
||||
process.exit(0);
|
||||
|
||||
+465
@@ -0,0 +1,465 @@
|
||||
/**
|
||||
* src/oneshot.js
|
||||
* Dominant Side Hold Engine — main orchestrator entry point.
|
||||
*
|
||||
* Strategy: enter the probable winner (dominant side, mid >= minDominantMid),
|
||||
* hold the position until the market expires, then let redeemer.js claim
|
||||
* the on-chain payout. There are no take-profit sells or momentum-based exits.
|
||||
*
|
||||
* Runtime sequence (per market, per tick):
|
||||
* A → MarketFeedService emits 'snapshot'
|
||||
* B → FeatureEngine processes snapshot, emits 'features'
|
||||
* C+D → SignalEngine evaluates gates + dominant side, emits 'signal'
|
||||
* E → Orchestrator submits FOK buy on ENTER signal
|
||||
* F → Fill handling (full / partial / timeout)
|
||||
* G → PositionEngine evaluates exit on each snapshot
|
||||
* H → RiskEngine updated on emergency exits only
|
||||
*
|
||||
* State machine (per market):
|
||||
* IDLE → SETUP_READY → ORDER_PENDING → POSITION_OPEN → IDLE (expired)
|
||||
* POSITION_OPEN → IDLE (emergency stop-loss exit)
|
||||
* ANY → COOLDOWN → IDLE
|
||||
* ANY → HALTED (terminal for the session)
|
||||
*/
|
||||
|
||||
import { initClient, getClient } from './services/client.js';
|
||||
import logger from './utils/logger.js';
|
||||
|
||||
import eventBus from './oneshot/EventBus.js';
|
||||
import { StateMachine } from './oneshot/StateMachine.js';
|
||||
import { MarketFeedService } from './oneshot/MarketFeedService.js';
|
||||
import { FeatureEngine } from './oneshot/FeatureEngine.js';
|
||||
import { SignalEngine } from './oneshot/SignalEngine.js';
|
||||
import { ExecutionEngine } from './oneshot/ExecutionEngine.js';
|
||||
import { RiskEngine } from './oneshot/RiskEngine.js';
|
||||
import { PositionEngine } from './oneshot/PositionEngine.js';
|
||||
import { Telemetry } from './oneshot/Telemetry.js';
|
||||
import { RedeemEngine } from './oneshot/RedeemEngine.js';
|
||||
import { State, Signal, ReasonCode } from './oneshot/constants.js';
|
||||
import { DEBUG, dbg } from './oneshot/debug.js';
|
||||
|
||||
// ── Configuration ──────────────────────────────────────────────────────────────
|
||||
|
||||
const cfg = {
|
||||
assets: (process.env.ONESHOT_ASSETS || 'btc').split(',').map((s) => s.trim().toLowerCase()),
|
||||
duration: process.env.ONESHOT_DURATION || '5m',
|
||||
baseRiskUsdc: parseFloat(process.env.ONESHOT_BASE_RISK_USDC || '5'),
|
||||
minDominantMid: parseFloat(process.env.ONESHOT_MIN_DOMINANT_MID || '0.58'),
|
||||
stopLossMid: parseFloat(process.env.ONESHOT_STOP_LOSS_MID || '0.20'),
|
||||
scoreThreshold: parseFloat(process.env.ONESHOT_SCORE_THRESHOLD || '0.42'),
|
||||
pollIntervalMs: parseInt(process.env.ONESHOT_POLL_INTERVAL_MS || '300', 10),
|
||||
minTopSize: parseFloat(process.env.ONESHOT_MIN_TOP_SIZE || '10'),
|
||||
tteMin: parseInt(process.env.ONESHOT_TTE_MIN || '15', 10),
|
||||
tteMax: parseInt(process.env.ONESHOT_TTE_MAX || '150', 10),
|
||||
maxConsecLosses: parseInt(process.env.ONESHOT_MAX_CONSEC_LOSSES || '2', 10),
|
||||
cooldownRounds: parseInt(process.env.ONESHOT_COOLDOWN_ROUNDS || '3', 10),
|
||||
dailyLossCap: parseFloat(process.env.ONESHOT_DAILY_LOSS_CAP || '20'),
|
||||
fillTimeoutMs: parseInt(process.env.ONESHOT_FILL_TIMEOUT_MS || '800', 10),
|
||||
redeemPollMs: parseInt(process.env.ONESHOT_REDEEM_POLL_MS || '30000', 10),
|
||||
dryRun: process.env.DRY_RUN !== 'false',
|
||||
};
|
||||
|
||||
// ── Per-market state ───────────────────────────────────────────────────────────
|
||||
|
||||
/** @type {Map<string, StateMachine>} */
|
||||
const stateMachines = new Map();
|
||||
|
||||
// ── Service instances ─────────────────────────────────────────────────────────
|
||||
|
||||
let feedService;
|
||||
let featureEngine;
|
||||
let signalEngine;
|
||||
let execEngine;
|
||||
let riskEngine;
|
||||
let posEngine;
|
||||
let redeemEngine;
|
||||
let telemetry;
|
||||
|
||||
// ── Entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
logger.success('=== OneShot Dominant Side Hold Engine starting ===');
|
||||
logger.info(`Assets: [${cfg.assets}] | Duration: ${cfg.duration} | DRY_RUN: ${cfg.dryRun}`);
|
||||
logger.info(
|
||||
`Strategy: enter dominant side (mid >= ${cfg.minDominantMid}) | ` +
|
||||
`TTE window: ${cfg.tteMin}–${cfg.tteMax}s | hold to expiry`,
|
||||
);
|
||||
logger.info(
|
||||
`Risk: baseRisk=$${cfg.baseRiskUsdc} | stopLoss=${cfg.stopLossMid > 0 ? cfg.stopLossMid : 'disabled'} | ` +
|
||||
`scoreMin=${cfg.scoreThreshold}`,
|
||||
);
|
||||
|
||||
await initClient();
|
||||
const client = getClient();
|
||||
|
||||
telemetry = new Telemetry();
|
||||
redeemEngine = new RedeemEngine({
|
||||
dryRun: cfg.dryRun,
|
||||
pollIntervalMs: cfg.redeemPollMs,
|
||||
eventBus,
|
||||
});
|
||||
riskEngine = new RiskEngine({
|
||||
maxConsecLosses: cfg.maxConsecLosses,
|
||||
cooldownRounds: cfg.cooldownRounds,
|
||||
dailyLossCap: cfg.dailyLossCap,
|
||||
});
|
||||
posEngine = new PositionEngine({ stopLossMid: cfg.stopLossMid });
|
||||
execEngine = new ExecutionEngine({ client, dryRun: cfg.dryRun, fillTimeoutMs: cfg.fillTimeoutMs });
|
||||
featureEngine = new FeatureEngine({ eventBus });
|
||||
signalEngine = new SignalEngine({
|
||||
eventBus,
|
||||
scoreThreshold: cfg.scoreThreshold,
|
||||
minTopSize: cfg.minTopSize,
|
||||
minDominantMid: cfg.minDominantMid,
|
||||
tteMin: cfg.tteMin,
|
||||
tteMax: cfg.tteMax,
|
||||
});
|
||||
feedService = new MarketFeedService({
|
||||
client,
|
||||
assets: cfg.assets,
|
||||
duration: cfg.duration,
|
||||
pollIntervalMs: cfg.pollIntervalMs,
|
||||
eventBus,
|
||||
});
|
||||
|
||||
// Wire orchestrator handlers
|
||||
eventBus.on('signal', onSignal);
|
||||
eventBus.on('snapshot', onSnapshotForPositionMgmt);
|
||||
eventBus.on('state:transition', onStateTransition);
|
||||
|
||||
redeemEngine.start();
|
||||
await feedService.start();
|
||||
|
||||
// Report final P&L when a redemption settles
|
||||
eventBus.on('redemption:complete', ({ marketSlug, won, pnl }) => {
|
||||
riskEngine.recordResult(pnl);
|
||||
logger.info(`[REDEEM] ${marketSlug} settled | ${won ? 'WIN' : 'LOSS'} | pnl=${won ? '+' : ''}$${pnl.toFixed(4)}`);
|
||||
});
|
||||
|
||||
logger.success('OneShot Engine running — waiting for dominant side signals...');
|
||||
|
||||
if (DEBUG) {
|
||||
logger.info(
|
||||
'[DBG] Debug mode active. Tags: FEED=discovery/poll, GATE=hard gates, ' +
|
||||
'SCORE=dominant side scoring, SIGNAL=entry trigger, SM=state changes, HEART=heartbeat',
|
||||
);
|
||||
setInterval(() => {
|
||||
const markets = feedService.activeMarkets;
|
||||
const states = markets.map((slug) => {
|
||||
const sm = stateMachines.get(slug);
|
||||
return `${slug.split('-')[0]}:${sm?.state ?? 'none'}`;
|
||||
}).join(' | ') || '(none)';
|
||||
const risk = riskEngine.stats();
|
||||
dbg('HEART',
|
||||
`active=${markets.length} | states=[${states}] | ` +
|
||||
`dailyPnl=$${risk.dailyPnl.toFixed(4)} | consec=${risk.consecLosses} | ` +
|
||||
`cooldown=${risk.cooldownLeft} | halted=${risk.halted}`,
|
||||
);
|
||||
}, 5_000);
|
||||
}
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
}
|
||||
|
||||
// ── Signal handler (Steps C/D/E/F) ───────────────────────────────────────────
|
||||
|
||||
async function onSignal(evt) {
|
||||
const { marketSlug, signal, side, score, reason, snapshot, features } = evt;
|
||||
|
||||
const sm = getOrCreateSM(marketSlug);
|
||||
|
||||
// Log every evaluation tick for later analysis
|
||||
const sideFeatures = side ? features[side] : (features.up ?? features.down ?? {});
|
||||
telemetry.logDecision({
|
||||
marketSlug,
|
||||
ts: snapshot.ts,
|
||||
tteSec: snapshot.tteSec,
|
||||
spread: sideFeatures.spread ?? 0,
|
||||
imbalance: sideFeatures.imbalance ?? 0,
|
||||
slope: sideFeatures.midSlope6s ?? 0,
|
||||
retrace: sideFeatures.retrace3s ?? 0,
|
||||
depth: sideFeatures.depthTop3 ?? 0,
|
||||
gatePass: signal !== Signal.NO_TRADE,
|
||||
reasonCode: reason ?? '',
|
||||
score,
|
||||
action: signal,
|
||||
});
|
||||
|
||||
if (signal === Signal.NO_TRADE) return;
|
||||
|
||||
// Only enter from IDLE — one position per market slot
|
||||
if (!sm.is(State.IDLE)) return;
|
||||
|
||||
// ── Risk gate ─────────────────────────────────────────────────────────
|
||||
|
||||
const riskCheck = riskEngine.canTrade();
|
||||
|
||||
if (!riskCheck.ok) {
|
||||
if (riskCheck.halted && sm.canTransitionTo(State.HALTED)) {
|
||||
sm.transition(State.HALTED, ReasonCode.RISK_DAILY_CAP);
|
||||
} else if (riskEngine.isCooldown()) {
|
||||
riskEngine.decrementCooldown();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Step E: order submission ───────────────────────────────────────────
|
||||
|
||||
const bookSide = side === 'up' ? snapshot.up : snapshot.down;
|
||||
const entryPrice = bookSide.bestAsk;
|
||||
|
||||
// Size: floor(baseRiskUSDC / entryPrice), minimum 5 shares
|
||||
const rawSize = cfg.baseRiskUsdc / entryPrice;
|
||||
const size = Math.max(5, Math.floor(rawSize));
|
||||
|
||||
logger.trade(
|
||||
`OneShot ENTER | ${signal} | ${marketSlug} | ` +
|
||||
`mid=${bookSide.mid.toFixed(4)} px=$${entryPrice} | size=${size} | score=${score.toFixed(3)} | tte=${snapshot.tteSec}s`,
|
||||
);
|
||||
|
||||
sm.transition(State.SETUP_READY, 'signal_passed');
|
||||
|
||||
try {
|
||||
sm.transition(State.ORDER_PENDING, 'submitting');
|
||||
|
||||
const result = await execEngine.submitBuy({
|
||||
tokenId: bookSide.tokenId,
|
||||
size,
|
||||
price: entryPrice,
|
||||
marketSlug,
|
||||
});
|
||||
|
||||
telemetry.logOrder({
|
||||
clientOrderId: result.orderId,
|
||||
side: signal,
|
||||
marketSlug,
|
||||
px: entryPrice,
|
||||
qty: size,
|
||||
ackMs: result.ackMs,
|
||||
fillMs: result.fillMs,
|
||||
status: result.status,
|
||||
});
|
||||
|
||||
// ── Step F: fill handling ──────────────────────────────────────────
|
||||
|
||||
if (result.status === 'filled') {
|
||||
posEngine.open(marketSlug, {
|
||||
tokenId: bookSide.tokenId,
|
||||
side,
|
||||
shares: result.filledSize,
|
||||
entryPrice: result.avgFillPrice || entryPrice,
|
||||
tickSize: snapshot.tickSize,
|
||||
conditionId: snapshot.conditionId,
|
||||
negRisk: snapshot.negRisk,
|
||||
});
|
||||
sm.transition(State.POSITION_OPEN, 'fill_confirmed');
|
||||
logger.success(
|
||||
`OneShot: position OPEN | ${marketSlug} | ` +
|
||||
`${result.filledSize} shares @ $${(result.avgFillPrice || entryPrice).toFixed(4)} | ` +
|
||||
`holding to expiry`,
|
||||
);
|
||||
|
||||
} else if (result.status === 'partial' && result.filledSize > 0) {
|
||||
// Accept partial fill and hold to expiry
|
||||
posEngine.open(marketSlug, {
|
||||
tokenId: bookSide.tokenId,
|
||||
side,
|
||||
shares: result.filledSize,
|
||||
entryPrice: result.avgFillPrice || entryPrice,
|
||||
tickSize: snapshot.tickSize,
|
||||
conditionId: snapshot.conditionId,
|
||||
negRisk: snapshot.negRisk,
|
||||
});
|
||||
sm.transition(State.POSITION_OPEN, 'partial_fill_accepted');
|
||||
logger.warn(`OneShot: partial fill accepted | ${result.filledSize}/${size} shares | holding to expiry`);
|
||||
|
||||
} else {
|
||||
logger.warn(`OneShot: no fill on ${marketSlug} — returning to IDLE`);
|
||||
sm.transition(State.IDLE, ReasonCode.EXEC_TIMEOUT_NO_FILL);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
logger.error(`OneShot: order error on ${marketSlug} — ${err.message}`);
|
||||
if (sm.is(State.ORDER_PENDING) || sm.is(State.SETUP_READY)) {
|
||||
sm.transition(State.IDLE, ReasonCode.EXEC_SUBMIT_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Position management handler (Step G) ──────────────────────────────────────
|
||||
|
||||
async function onSnapshotForPositionMgmt(snapshot) {
|
||||
const { marketSlug, tteSec } = snapshot;
|
||||
const sm = stateMachines.get(marketSlug);
|
||||
if (!sm) return;
|
||||
|
||||
// Clean up state machines for fully expired markets with no open position
|
||||
if (tteSec < -10 && sm.is(State.IDLE)) {
|
||||
stateMachines.delete(marketSlug);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sm.is(State.POSITION_OPEN)) return;
|
||||
|
||||
const pos = posEngine.getPosition(marketSlug);
|
||||
if (!pos) {
|
||||
if (sm.canTransitionTo(State.IDLE)) sm.transition(State.IDLE, 'position_missing');
|
||||
return;
|
||||
}
|
||||
|
||||
// Evaluate exit conditions
|
||||
const exitResult = posEngine.evaluateExit(marketSlug, snapshot);
|
||||
|
||||
// Market expired — position goes to on-chain redeemer
|
||||
if (exitResult.isExpired) {
|
||||
await expirePosition(marketSlug, pos);
|
||||
return;
|
||||
}
|
||||
|
||||
// Emergency stop-loss (catastrophic market reversal)
|
||||
if (exitResult.shouldExit) {
|
||||
const bookSide = pos.side === 'up' ? snapshot.up : snapshot.down;
|
||||
await flattenPosition(marketSlug, pos, bookSide, exitResult.reason, snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Expire helper (market closed, pending on-chain redemption) ────────────────
|
||||
|
||||
async function expirePosition(marketSlug, pos) {
|
||||
const sm = stateMachines.get(marketSlug);
|
||||
if (!sm) return;
|
||||
|
||||
logger.success(
|
||||
`OneShot: market EXPIRED | ${marketSlug} | ` +
|
||||
`${pos.shares} shares of ${pos.side.toUpperCase()} @ entry $${pos.entryPrice.toFixed(4)} | ` +
|
||||
`queuing for auto-redemption`,
|
||||
);
|
||||
|
||||
posEngine.closeExpired(marketSlug);
|
||||
|
||||
telemetry.logExit({
|
||||
marketSlug,
|
||||
exitReason: ReasonCode.EXIT_EXPIRED,
|
||||
entryPx: pos.entryPrice,
|
||||
exitPx: null, // settled on-chain — see redemption:complete event
|
||||
pnl: null,
|
||||
shares: pos.shares,
|
||||
});
|
||||
|
||||
// Hand off to RedeemEngine — it will poll until settled and report final P&L
|
||||
redeemEngine.queueRedemption({
|
||||
conditionId: pos.conditionId,
|
||||
marketSlug,
|
||||
side: pos.side,
|
||||
shares: pos.shares,
|
||||
entryPrice: pos.entryPrice,
|
||||
negRisk: pos.negRisk,
|
||||
});
|
||||
|
||||
if (sm.canTransitionTo(State.IDLE)) {
|
||||
sm.transition(State.IDLE, ReasonCode.EXIT_EXPIRED);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Emergency flatten helper (adverse-move stop-loss only) ────────────────────
|
||||
|
||||
async function flattenPosition(marketSlug, pos, bookSide, reason, snapshot) {
|
||||
const sm = stateMachines.get(marketSlug);
|
||||
if (!sm || !sm.is(State.POSITION_OPEN)) return;
|
||||
|
||||
const exitPrice = bookSide.bestBid;
|
||||
|
||||
logger.warn(
|
||||
`OneShot: EMERGENCY EXIT | ${marketSlug} | reason=${reason} | ` +
|
||||
`mid=${bookSide.mid.toFixed(4)} exitPx=$${exitPrice.toFixed(4)}`,
|
||||
);
|
||||
|
||||
try {
|
||||
await execEngine.submitSell({
|
||||
tokenId: pos.tokenId,
|
||||
size: pos.shares,
|
||||
price: exitPrice,
|
||||
marketSlug,
|
||||
});
|
||||
|
||||
const exitData = posEngine.close(marketSlug, exitPrice);
|
||||
riskEngine.recordResult(exitData.pnl);
|
||||
|
||||
telemetry.logExit({
|
||||
marketSlug,
|
||||
exitReason: reason,
|
||||
entryPx: pos.entryPrice,
|
||||
exitPx: exitPrice,
|
||||
pnl: exitData.pnl,
|
||||
shares: pos.shares,
|
||||
});
|
||||
|
||||
const { ok, halted } = riskEngine.canTrade();
|
||||
|
||||
if (halted && sm.canTransitionTo(State.HALTED)) {
|
||||
sm.transition(State.HALTED, ReasonCode.RISK_DAILY_CAP);
|
||||
} else if (!ok && riskEngine.isCooldown() && sm.canTransitionTo(State.COOLDOWN)) {
|
||||
sm.transition(State.COOLDOWN, ReasonCode.RISK_CONSEC_LOSS);
|
||||
} else {
|
||||
sm.transition(State.IDLE, `emergency_exit_${reason}`);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
logger.error(`OneShot: flatten error on ${marketSlug} — ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── State transition logging ──────────────────────────────────────────────────
|
||||
|
||||
function onStateTransition(evt) {
|
||||
telemetry.logTransition(evt);
|
||||
dbg('SM', `${evt.marketSlug}: ${evt.from} → ${evt.to} | ${evt.reason}`);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function getOrCreateSM(marketSlug) {
|
||||
if (!stateMachines.has(marketSlug)) {
|
||||
stateMachines.set(marketSlug, new StateMachine(marketSlug, eventBus));
|
||||
}
|
||||
return stateMachines.get(marketSlug);
|
||||
}
|
||||
|
||||
// ── Graceful shutdown ─────────────────────────────────────────────────────────
|
||||
|
||||
async function shutdown() {
|
||||
logger.warn('OneShot: shutting down...');
|
||||
feedService?.stop();
|
||||
redeemEngine?.stop();
|
||||
|
||||
// Report any positions still open at shutdown
|
||||
const markets = feedService?.activeMarkets ?? [];
|
||||
for (const slug of markets) {
|
||||
const pos = posEngine?.getPosition(slug);
|
||||
if (pos) {
|
||||
logger.warn(
|
||||
`OneShot: position still open at shutdown — ${slug} | ` +
|
||||
`${pos.shares} shares @ $${pos.entryPrice.toFixed(4)} | redeemer.js will settle`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const stats = riskEngine?.stats();
|
||||
if (stats) {
|
||||
const sign = stats.dailyPnl >= 0 ? '+' : '';
|
||||
logger.money(
|
||||
`Session summary | emergencyExitPnl=${sign}$${stats.dailyPnl.toFixed(4)} | ` +
|
||||
`consecLosses=${stats.consecLosses} | halted=${stats.halted}`,
|
||||
);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// ── Bootstrap ─────────────────────────────────────────────────────────────────
|
||||
|
||||
main().catch((err) => {
|
||||
logger.error(`OneShot fatal: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* EventBus.js
|
||||
* Central event bus for the OneShot engine.
|
||||
* All inter-service communication flows through this singleton.
|
||||
*
|
||||
* Event catalogue:
|
||||
* snapshot MarketFeedService → FeatureEngine, orchestrator
|
||||
* features FeatureEngine → SignalEngine, orchestrator
|
||||
* signal SignalEngine → orchestrator
|
||||
* state:transition StateMachine → orchestrator, Telemetry
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
class OneShotEventBus extends EventEmitter {}
|
||||
|
||||
const bus = new OneShotEventBus();
|
||||
|
||||
// Prevent memory-leak warnings for high subscriber counts across many markets
|
||||
bus.setMaxListeners(50);
|
||||
|
||||
export default bus;
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* ExecutionEngine.js
|
||||
* Steps E & F of the runtime sequence.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Submit a limit-marketable FOK BUY order at bestAsk
|
||||
* - Wait up to fillTimeoutMs for an ack/fill response
|
||||
* - Return structured fill result (filled | partial | cancelled)
|
||||
* - Submit market-sell (FOK) for exits and cut-losses
|
||||
* - Place GTC limit-sell for take-profit orders
|
||||
*
|
||||
* In dry-run mode all calls short-circuit with simulated successful results.
|
||||
*/
|
||||
|
||||
import { Side, OrderType } from '@polymarket/clob-client';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
const FILL_TIMEOUT_MS = 800;
|
||||
|
||||
export class ExecutionEngine {
|
||||
/**
|
||||
* @param {Object} opts
|
||||
* @param {import('@polymarket/clob-client').ClobClient} opts.client
|
||||
* @param {boolean} opts.dryRun
|
||||
* @param {number} [opts.fillTimeoutMs=800]
|
||||
*/
|
||||
constructor({ client, dryRun, fillTimeoutMs = FILL_TIMEOUT_MS }) {
|
||||
this._client = client;
|
||||
this._dryRun = dryRun;
|
||||
this._fillTimeoutMs = fillTimeoutMs;
|
||||
|
||||
/** Cache tick sizes to avoid repeated API calls */
|
||||
this._tickCache = new Map();
|
||||
}
|
||||
|
||||
// ── Buy ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Submit a limit-marketable FOK buy and wait for the fill result.
|
||||
*
|
||||
* @param {Object} opts
|
||||
* @param {string} opts.tokenId - ERC1155 token ID (UP or DOWN)
|
||||
* @param {number} opts.size - Number of shares to buy (≥ 5)
|
||||
* @param {number} opts.price - Limit price (bestAsk from snapshot)
|
||||
* @param {string} opts.marketSlug - For logging
|
||||
*
|
||||
* @returns {Promise<FillResult>}
|
||||
*/
|
||||
async submitBuy({ tokenId, size, price, marketSlug }) {
|
||||
if (this._dryRun) {
|
||||
logger.trade(`[SIM] BUY ${marketSlug} | ${size} shares @ $${price}`);
|
||||
return {
|
||||
orderId: `sim_buy_${Date.now()}`,
|
||||
status: 'filled',
|
||||
filledSize: size,
|
||||
avgFillPrice: price,
|
||||
ackMs: 45,
|
||||
fillMs: 90,
|
||||
};
|
||||
}
|
||||
|
||||
const startTs = Date.now();
|
||||
const { tickSize, negRisk } = await this._getMarketOpts(tokenId);
|
||||
|
||||
logger.trade(`BUY ${marketSlug} | ${size} shares @ $${price}`);
|
||||
|
||||
const response = await this._withTimeout(
|
||||
this._client.createAndPostOrder(
|
||||
{ tokenID: tokenId, price: price.toString(), size, side: Side.BUY },
|
||||
{ tickSize, negRisk },
|
||||
OrderType.FOK,
|
||||
),
|
||||
this._fillTimeoutMs,
|
||||
);
|
||||
|
||||
const ackMs = Date.now() - startTs;
|
||||
const fillMs = ackMs;
|
||||
|
||||
if (!response?.success) {
|
||||
logger.warn(`ExecutionEngine: buy not filled — ${response?.errorMsg ?? 'no response'}`);
|
||||
return { orderId: null, status: 'cancelled', filledSize: 0, ackMs, fillMs };
|
||||
}
|
||||
|
||||
const takingAmt = parseFloat(response.takingAmount || '0');
|
||||
const makingAmt = parseFloat(response.makingAmount || '0');
|
||||
|
||||
if (takingAmt > 0) {
|
||||
const avgFillPrice = makingAmt > 0 ? makingAmt / takingAmt : price;
|
||||
logger.success(`ExecutionEngine: filled ${takingAmt.toFixed(2)} shares @ avg $${avgFillPrice.toFixed(4)}`);
|
||||
return { orderId: response.orderID, status: 'filled', filledSize: takingAmt, avgFillPrice, ackMs, fillMs };
|
||||
}
|
||||
|
||||
// Some CLOB responses indicate fill via status string rather than amounts
|
||||
const isMatched = /matched|filled/i.test(response.status ?? '');
|
||||
if (isMatched || response.success) {
|
||||
return { orderId: response.orderID, status: 'filled', filledSize: size, avgFillPrice: price, ackMs, fillMs };
|
||||
}
|
||||
|
||||
return { orderId: response.orderID, status: 'cancelled', filledSize: 0, ackMs, fillMs };
|
||||
}
|
||||
|
||||
// ── Sell (exit / cut-loss) ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Submit a market-sell FOK order to exit a position immediately.
|
||||
*
|
||||
* @param {Object} opts
|
||||
* @param {string} opts.tokenId
|
||||
* @param {number} opts.size - Shares to sell
|
||||
* @param {number} opts.price - Minimum acceptable sell price (5% slippage floor applied internally)
|
||||
* @param {string} opts.marketSlug
|
||||
*/
|
||||
async submitSell({ tokenId, size, price, marketSlug }) {
|
||||
if (this._dryRun) {
|
||||
logger.trade(`[SIM] SELL ${marketSlug} | ${size} shares @ ~$${price}`);
|
||||
return { orderId: `sim_sell_${Date.now()}`, status: 'filled' };
|
||||
}
|
||||
|
||||
const { tickSize, negRisk } = await this._getMarketOpts(tokenId);
|
||||
const minPrice = Math.max(price * 0.95, 0.01);
|
||||
|
||||
logger.trade(`SELL ${marketSlug} | ${size} shares @ min $${minPrice.toFixed(4)}`);
|
||||
|
||||
const response = await this._client.createAndPostMarketOrder(
|
||||
{ tokenID: tokenId, side: Side.SELL, amount: size, price: minPrice },
|
||||
{ tickSize, negRisk },
|
||||
OrderType.FOK,
|
||||
).catch((err) => {
|
||||
logger.warn(`ExecutionEngine: sell error — ${err.message}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
const filled = response?.success ?? false;
|
||||
if (!filled) logger.warn(`ExecutionEngine: sell not filled — ${response?.errorMsg ?? 'unknown'}`);
|
||||
|
||||
return { orderId: response?.orderID ?? null, status: filled ? 'filled' : 'failed' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Place a GTC limit-sell order for take-profit.
|
||||
* Returns the order ID so the caller can cancel it if exit conditions change.
|
||||
*
|
||||
* @param {Object} opts
|
||||
* @param {string} opts.tokenId
|
||||
* @param {number} opts.size - Shares to sell
|
||||
* @param {number} opts.tpPrice - Exact target sell price (aligned to tick size)
|
||||
* @param {string} opts.marketSlug
|
||||
*/
|
||||
async submitTPOrder({ tokenId, size, tpPrice, marketSlug }) {
|
||||
if (this._dryRun) {
|
||||
logger.trade(`[SIM] TP ORDER ${marketSlug} | ${size} shares @ $${tpPrice}`);
|
||||
return { orderId: `sim_tp_${Date.now()}`, status: 'placed' };
|
||||
}
|
||||
|
||||
const { tickSize, negRisk } = await this._getMarketOpts(tokenId);
|
||||
|
||||
const response = await this._client.createAndPostOrder(
|
||||
{ tokenID: tokenId, price: tpPrice.toString(), size, side: Side.SELL },
|
||||
{ tickSize, negRisk },
|
||||
OrderType.GTC,
|
||||
).catch((err) => {
|
||||
logger.warn(`ExecutionEngine: TP order error — ${err.message}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
const placed = response?.success ?? false;
|
||||
logger.info(`ExecutionEngine: TP order ${placed ? 'placed' : 'failed'} | ${marketSlug} @ $${tpPrice}`);
|
||||
|
||||
return { orderId: response?.orderID ?? null, status: placed ? 'placed' : 'failed' };
|
||||
}
|
||||
|
||||
/** Cancel an open order by order ID */
|
||||
async cancelOrder(orderId) {
|
||||
if (this._dryRun || !orderId) return;
|
||||
try {
|
||||
await this._client.cancelOrder({ orderID: orderId });
|
||||
} catch (err) {
|
||||
logger.warn(`ExecutionEngine: cancel failed for ${orderId} — ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
async _getMarketOpts(tokenId) {
|
||||
if (this._tickCache.has(tokenId)) return this._tickCache.get(tokenId);
|
||||
|
||||
let tickSize = '0.01';
|
||||
let negRisk = false;
|
||||
|
||||
try {
|
||||
tickSize = String(await this._client.getTickSize(tokenId) ?? '0.01');
|
||||
negRisk = await this._client.getNegRisk(tokenId).catch(() => false) ?? false;
|
||||
} catch { /* use defaults */ }
|
||||
|
||||
const opts = { tickSize, negRisk };
|
||||
this._tickCache.set(tokenId, opts);
|
||||
return opts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a promise with a hard timeout.
|
||||
* Resolves to null on timeout rather than rejecting — execution layer
|
||||
* treats null as a no-fill and transitions back to IDLE cleanly.
|
||||
*/
|
||||
_withTimeout(promise, ms) {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise((resolve) => setTimeout(() => resolve(null), ms)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} FillResult
|
||||
* @property {string|null} orderId
|
||||
* @property {'filled'|'partial'|'cancelled'} status
|
||||
* @property {number} filledSize
|
||||
* @property {number} avgFillPrice
|
||||
* @property {number} ackMs
|
||||
* @property {number} fillMs
|
||||
*/
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* FeatureEngine.js
|
||||
* Step B of the runtime sequence.
|
||||
*
|
||||
* Maintains a rolling 15-second buffer of market snapshots per market
|
||||
* and computes the following features on each incoming snapshot:
|
||||
*
|
||||
* midSlope6s — Linear regression slope of the mid price over the last 6s
|
||||
* (positive = upward momentum, unit: price change per second)
|
||||
* retrace3s — Fractional pullback from the 6s rolling peak to current mid
|
||||
* (0 = no retrace, 1 = fully retraced to baseline)
|
||||
* imbalance — (depthBid - depthAsk) / (depthBid + depthAsk)
|
||||
* (positive = buyers dominate, negative = sellers dominate)
|
||||
* spread — Current bestAsk - bestBid
|
||||
* depthTop3 — Sum of the top-3 bid levels (buy-side depth at best prices)
|
||||
*
|
||||
* Features are computed independently for both UP and DOWN book sides.
|
||||
*
|
||||
* Emits a 'features' event on the event bus with shape:
|
||||
* { ts, marketSlug, tteSec, up: SideFeatures, down: SideFeatures, snapshot }
|
||||
*/
|
||||
|
||||
const BUFFER_WINDOW_MS = 15_000;
|
||||
const SLOPE_WINDOW_MS = 6_000;
|
||||
const RETRACE_PEAK_MS = 6_000; // Look-back window for peak in retrace calc
|
||||
const DEPTH_TOP_N = 3;
|
||||
|
||||
export class FeatureEngine {
|
||||
/**
|
||||
* @param {Object} opts
|
||||
* @param {import('./EventBus.js').default} opts.eventBus
|
||||
*/
|
||||
constructor({ eventBus }) {
|
||||
this._eventBus = eventBus;
|
||||
|
||||
/** @type {Map<string, Array<{ts, up_mid, down_mid, up_spread, up_depthBid, up_depthAsk, up_bestBidSize, up_bestAskSize, down_spread, down_depthBid, down_depthAsk, down_bestBidSize, down_bestAskSize}>>} */
|
||||
this._buffers = new Map();
|
||||
|
||||
/** @type {Map<string, Object>} Most recent features per market */
|
||||
this._latest = new Map();
|
||||
|
||||
this._eventBus.on('snapshot', (snap) => this._onSnapshot(snap));
|
||||
}
|
||||
|
||||
/** Retrieve the most recently computed features for a given market */
|
||||
getLatest(marketSlug) {
|
||||
return this._latest.get(marketSlug) ?? null;
|
||||
}
|
||||
|
||||
// ── Internal ──────────────────────────────────────────────────────────────
|
||||
|
||||
_onSnapshot(snap) {
|
||||
const { marketSlug, ts, tteSec, up, down } = snap;
|
||||
|
||||
// Add to rolling buffer
|
||||
if (!this._buffers.has(marketSlug)) this._buffers.set(marketSlug, []);
|
||||
const buf = this._buffers.get(marketSlug);
|
||||
|
||||
buf.push({
|
||||
ts,
|
||||
up_mid: up.mid,
|
||||
up_spread: up.spread,
|
||||
up_depthBid: up.depthBid,
|
||||
up_depthAsk: up.depthAsk,
|
||||
up_bestBidSize: up.bestBidSize,
|
||||
up_bestAskSize: up.bestAskSize,
|
||||
down_mid: down.mid,
|
||||
down_spread: down.spread,
|
||||
down_depthBid: down.depthBid,
|
||||
down_depthAsk: down.depthAsk,
|
||||
down_bestBidSize: down.bestBidSize,
|
||||
down_bestAskSize: down.bestAskSize,
|
||||
});
|
||||
|
||||
// Evict entries older than the buffer window
|
||||
const cutoff = ts - BUFFER_WINDOW_MS;
|
||||
while (buf.length > 0 && buf[0].ts < cutoff) buf.shift();
|
||||
|
||||
const features = {
|
||||
ts,
|
||||
marketSlug,
|
||||
tteSec,
|
||||
up: this._computeSideFeatures(buf, 'up', up),
|
||||
down: this._computeSideFeatures(buf, 'down', down),
|
||||
snapshot: snap,
|
||||
};
|
||||
|
||||
this._latest.set(marketSlug, features);
|
||||
this._eventBus.emit('features', features);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute all features for one book side using the rolling buffer.
|
||||
*
|
||||
* @param {Array} buf - Rolling buffer entries (ascending ts)
|
||||
* @param {string} side - 'up' or 'down'
|
||||
* @param {Object} currentBook - Live BookSide from current snapshot
|
||||
*/
|
||||
_computeSideFeatures(buf, side, currentBook) {
|
||||
const now = buf[buf.length - 1]?.ts ?? Date.now();
|
||||
const midKey = `${side}_mid`;
|
||||
|
||||
// Slice for slope window (last 6s)
|
||||
const slopeBuf = buf.filter((e) => e.ts >= now - SLOPE_WINDOW_MS);
|
||||
const mids6s = slopeBuf.map((e) => e[midKey]);
|
||||
|
||||
// Slice for retrace peak look-back (last 6s)
|
||||
const retraceBuf = buf.filter((e) => e.ts >= now - RETRACE_PEAK_MS);
|
||||
const midsRetrace = retraceBuf.map((e) => e[midKey]);
|
||||
|
||||
const midSlope6s = this._linearSlope(mids6s);
|
||||
const retrace3s = this._retrace(midsRetrace, currentBook.mid);
|
||||
|
||||
// Imbalance from depth
|
||||
const totalDepth = currentBook.depthBid + currentBook.depthAsk;
|
||||
const imbalance = totalDepth > 0
|
||||
? (currentBook.depthBid - currentBook.depthAsk) / totalDepth
|
||||
: 0;
|
||||
|
||||
// Top-3 bid depth from current book
|
||||
const depthTop3 = currentBook.bids
|
||||
.slice(0, DEPTH_TOP_N)
|
||||
.reduce((s, l) => s + l.size, 0);
|
||||
|
||||
return {
|
||||
midSlope6s,
|
||||
retrace3s,
|
||||
imbalance,
|
||||
spread: currentBook.spread,
|
||||
depthTop3,
|
||||
bufLen: slopeBuf.length, // diagnostic
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordinary least-squares slope through an array of mid-price values.
|
||||
* Returns slope in units of "price change per sample interval".
|
||||
* Returns 0 if fewer than 2 data points are available.
|
||||
*/
|
||||
_linearSlope(values) {
|
||||
const n = values.length;
|
||||
if (n < 2) return 0;
|
||||
|
||||
const meanX = (n - 1) / 2;
|
||||
const meanY = values.reduce((a, b) => a + b, 0) / n;
|
||||
|
||||
let num = 0;
|
||||
let den = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const dx = i - meanX;
|
||||
num += dx * (values[i] - meanY);
|
||||
den += dx * dx;
|
||||
}
|
||||
return den === 0 ? 0 : num / den;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fractional retrace: how far the current mid has pulled back from
|
||||
* the rolling peak within the look-back window.
|
||||
*
|
||||
* 0 = price is at its peak (no retrace)
|
||||
* 1 = price is at its trough (full retrace)
|
||||
*/
|
||||
_retrace(mids, currentMid) {
|
||||
if (mids.length === 0) return 0;
|
||||
|
||||
const peak = Math.max(...mids, currentMid);
|
||||
const trough = Math.min(...mids, currentMid);
|
||||
const range = peak - trough;
|
||||
|
||||
if (range < 1e-9) return 0;
|
||||
return Math.max(0, (peak - currentMid) / range);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* MarketFeedService.js
|
||||
* Step A of the runtime sequence.
|
||||
*
|
||||
* Responsibilities:
|
||||
* 1. Discover active 5m/15m UP↑DOWN↓ markets for configured assets via Gamma API
|
||||
* 2. Poll the CLOB orderbook for both UP and DOWN tokens every pollIntervalMs
|
||||
* 3. Normalise raw book data into a consistent snapshot format
|
||||
* 4. Detect stale books (no levels, or fetch latency > STALE_THRESHOLD_MS)
|
||||
* 5. Emit 'snapshot' events on the event bus
|
||||
*
|
||||
* Market discovery mirrors the logic in sniperDetector.js / mmDetector.js:
|
||||
* - API endpoint: /markets/slug/{slug} (not /markets?slug=...)
|
||||
* - Token IDs: clobTokenIds[0/1] (JSON string parsed if needed)
|
||||
* - Tick size: market.orderPriceMinTickSize (no separate API call)
|
||||
* - Slot formula: Math.floor(Date.now()/1000/SLOT_SEC) * SLOT_SEC
|
||||
*
|
||||
* Snapshot shape:
|
||||
* { ts, marketSlug, conditionId, tteSec, tickSize, up: BookSide, down: BookSide, stale }
|
||||
*
|
||||
* BookSide shape:
|
||||
* { tokenId, bids, asks, bestBid, bestAsk, mid, spread, depthBid, depthAsk,
|
||||
* bestBidSize, bestAskSize }
|
||||
*/
|
||||
|
||||
import config from '../config/index.js';
|
||||
import logger from '../utils/logger.js';
|
||||
import { dbg, DEBUG } from './debug.js';
|
||||
|
||||
const STALE_THRESHOLD_MS = 1500;
|
||||
const TOP_N_LEVELS = 5; // Levels counted for depth calculation
|
||||
const DISCOVER_INTERVAL = 30_000; // Re-scan for new markets every 30s
|
||||
const DEBUG_POLL_EVERY = 10; // Throttle: log one poll summary every N ticks
|
||||
|
||||
export class MarketFeedService {
|
||||
/**
|
||||
* @param {Object} opts
|
||||
* @param {import('@polymarket/clob-client').ClobClient} opts.client
|
||||
* @param {string[]} opts.assets - e.g. ['btc', 'eth', 'sol']
|
||||
* @param {string} opts.duration - '5m' or '15m'
|
||||
* @param {number} opts.pollIntervalMs - Book poll cadence in ms (200–500)
|
||||
* @param {import('./EventBus.js').default} opts.eventBus
|
||||
*/
|
||||
constructor({ client, assets, duration = '5m', pollIntervalMs = 300, eventBus }) {
|
||||
this._client = client;
|
||||
this._assets = assets;
|
||||
this._duration = duration;
|
||||
this._slotSec = duration === '15m' ? 900 : 300; // same as sniperDetector/mmDetector
|
||||
this._pollMs = pollIntervalMs;
|
||||
this._eventBus = eventBus;
|
||||
|
||||
/** @type {Map<string, MarketRecord>} slug → market record */
|
||||
this._markets = new Map();
|
||||
|
||||
this._pollTimer = null;
|
||||
this._discoverTimer = null;
|
||||
|
||||
/** Per-market tick counter for throttled debug logs */
|
||||
this._pollCount = new Map();
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||
|
||||
async start() {
|
||||
await this._discoverMarkets();
|
||||
this._pollTimer = setInterval(() => this._tick().catch(() => {}), this._pollMs);
|
||||
this._discoverTimer = setInterval(() => this._discoverMarkets().catch(() => {}), DISCOVER_INTERVAL);
|
||||
logger.info(`MarketFeedService: started | assets=[${this._assets}] interval=${this._pollMs}ms`);
|
||||
if (DEBUG) logger.info('[DBG:FEED] Debug mode ON — verbose feed logging enabled');
|
||||
}
|
||||
|
||||
stop() {
|
||||
clearInterval(this._pollTimer);
|
||||
clearInterval(this._discoverTimer);
|
||||
logger.info('MarketFeedService: stopped');
|
||||
}
|
||||
|
||||
/** Active market slugs currently being polled */
|
||||
get activeMarkets() {
|
||||
return [...this._markets.keys()];
|
||||
}
|
||||
|
||||
// ── Market discovery ──────────────────────────────────────────────────────
|
||||
|
||||
async _discoverMarkets() {
|
||||
// Probe current slot AND next upcoming slot (same as sniperDetector)
|
||||
const curr = this._currentSlot();
|
||||
const next = curr + this._slotSec;
|
||||
const slots = [curr, next];
|
||||
|
||||
dbg('FEED', `--- discovery cycle | curr=${curr} next=${next} | probing ${this._assets.length * 2} slug(s) ---`);
|
||||
|
||||
for (const asset of this._assets) {
|
||||
for (const slotTs of slots) {
|
||||
const slug = `${asset}-updown-${this._duration}-${slotTs}`;
|
||||
|
||||
if (this._markets.has(slug)) {
|
||||
dbg('FEED', ` ${slug} → already tracked`);
|
||||
continue;
|
||||
}
|
||||
|
||||
dbg('FEED', ` probing ${slug} ...`);
|
||||
|
||||
try {
|
||||
// ── Use /markets/slug/{slug} — same endpoint as sniperDetector ──
|
||||
const market = await this._fetchBySlug(slug);
|
||||
|
||||
if (!market) {
|
||||
dbg('FEED', ` ${slug} → not found (API returned null)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Extract end time ─────────────────────────────────────────
|
||||
// endDate = "2026-02-24T06:35:00Z" (full datetime — use this)
|
||||
// endDateIso = "2026-02-24" (date only, parses to midnight UTC — skip)
|
||||
const endTs = this._parseEndTs(market);
|
||||
if (!endTs) {
|
||||
dbg('FEED', ` ${slug} → found but endDate unparseable (keys: ${Object.keys(market).slice(0, 8).join(',')})`);
|
||||
continue;
|
||||
}
|
||||
if (Date.now() >= endTs) {
|
||||
dbg('FEED', ` ${slug} → found but expired (endTs=${new Date(endTs).toISOString()})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Extract token IDs — same logic as sniperDetector/mmDetector ──
|
||||
const { upTokenId, downTokenId } = this._extractTokenIds(market);
|
||||
if (!upTokenId || !downTokenId) {
|
||||
logger.warn(`MarketFeedService: missing token IDs for ${slug}`);
|
||||
dbg('FEED', ` clobTokenIds raw: ${JSON.stringify(market.clobTokenIds)}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Tick size from market object — same as mmDetector ────────
|
||||
const tickSize = parseFloat(
|
||||
market.orderPriceMinTickSize ??
|
||||
market.minimum_tick_size ??
|
||||
market.minimumTickSize ??
|
||||
'0.01',
|
||||
) || 0.01;
|
||||
|
||||
const negRisk = market.negRisk ?? market.neg_risk ?? false;
|
||||
|
||||
this._markets.set(slug, {
|
||||
slug,
|
||||
conditionId: market.conditionId || market.condition_id,
|
||||
upTokenId,
|
||||
downTokenId,
|
||||
endTs,
|
||||
tickSize,
|
||||
negRisk,
|
||||
});
|
||||
|
||||
const secLeft = Math.floor((endTs - Date.now()) / 1000);
|
||||
logger.success(`MarketFeedService: tracking ${slug} (closes in ${secLeft}s)`);
|
||||
dbg('FEED',
|
||||
` up=${upTokenId.slice(0, 16)}... ` +
|
||||
`down=${downTokenId.slice(0, 16)}... ` +
|
||||
`tick=${tickSize} negRisk=${negRisk}`,
|
||||
);
|
||||
|
||||
} catch (err) {
|
||||
dbg('FEED', ` ${slug} → error: ${err.message}`);
|
||||
// Network blip — will retry on next cycle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prune markets that have fully expired (5s grace for final snapshots)
|
||||
for (const [slug, mkt] of this._markets) {
|
||||
if (Date.now() > mkt.endTs + 5_000) {
|
||||
this._markets.delete(slug);
|
||||
this._pollCount.delete(slug);
|
||||
logger.info(`MarketFeedService: pruned ${slug}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (this._markets.size === 0) {
|
||||
dbg('FEED', 'No active markets — retrying in 30s');
|
||||
} else {
|
||||
dbg('FEED', `Tracking: [${[...this._markets.keys()].join(', ')}]`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Slot helpers (identical to sniperDetector / mmDetector) ──────────────
|
||||
|
||||
_currentSlot() {
|
||||
return Math.floor(Date.now() / 1000 / this._slotSec) * this._slotSec;
|
||||
}
|
||||
|
||||
// ── Gamma API ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Uses /markets/slug/{slug} — the same direct endpoint as sniperDetector */
|
||||
async _fetchBySlug(slug) {
|
||||
const resp = await fetch(`${config.gammaHost}/markets/slug/${slug}`);
|
||||
if (!resp.ok) return null;
|
||||
const data = await resp.json();
|
||||
// Returns a single object (not an array) when using the slug endpoint
|
||||
return data?.conditionId || data?.condition_id ? data : null;
|
||||
}
|
||||
|
||||
_parseEndTs(market) {
|
||||
// endDate = "2026-02-24T06:35:00Z" → correct full datetime
|
||||
// endDateIso = "2026-02-24" → date-only, parses to midnight UTC (wrong!)
|
||||
const raw = market.endDate || market.end_date || market.endDateIso || market.end_date_iso;
|
||||
if (!raw) return null;
|
||||
const ts = new Date(raw).getTime();
|
||||
return Number.isFinite(ts) ? ts : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract UP/DOWN token IDs using the same logic as sniperDetector / mmDetector.
|
||||
*
|
||||
* clobTokenIds may be:
|
||||
* - a real JS array: ["123...", "456..."]
|
||||
* - a JSON string: '["123...","456..."]'
|
||||
* UP = clobTokenIds[0] (YES / Up)
|
||||
* DOWN = clobTokenIds[1] (NO / Down)
|
||||
*/
|
||||
_extractTokenIds(market) {
|
||||
let tokenIds = market.clobTokenIds ?? market.clob_token_ids;
|
||||
|
||||
// Unwrap JSON string if the API returned it encoded
|
||||
if (typeof tokenIds === 'string') {
|
||||
try { tokenIds = JSON.parse(tokenIds); } catch { tokenIds = null; }
|
||||
}
|
||||
|
||||
let upTokenId = null;
|
||||
let downTokenId = null;
|
||||
|
||||
if (Array.isArray(tokenIds) && tokenIds.length >= 2) {
|
||||
[upTokenId, downTokenId] = tokenIds.map(String);
|
||||
} else if (Array.isArray(market.tokens) && market.tokens.length >= 2) {
|
||||
// Fallback: named tokens array (less common)
|
||||
upTokenId = String(market.tokens[0]?.token_id ?? market.tokens[0]?.tokenId ?? '');
|
||||
downTokenId = String(market.tokens[1]?.token_id ?? market.tokens[1]?.tokenId ?? '');
|
||||
if (!upTokenId || !downTokenId) { upTokenId = null; downTokenId = null; }
|
||||
}
|
||||
|
||||
return { upTokenId, downTokenId };
|
||||
}
|
||||
|
||||
// ── Book polling ──────────────────────────────────────────────────────────
|
||||
|
||||
async _tick() {
|
||||
if (this._markets.size === 0) return;
|
||||
|
||||
for (const [, mkt] of this._markets) {
|
||||
const tteSec = Math.floor((mkt.endTs - Date.now()) / 1000);
|
||||
|
||||
// Market has expired — emit a synthetic snapshot so the position manager
|
||||
// can detect the expiry and queue the position for on-chain redemption.
|
||||
// Without this, positions in expired markets never reach RedeemEngine.
|
||||
if (tteSec < 0) {
|
||||
this._eventBus.emit('snapshot', this._buildExpiredSnapshot(mkt, tteSec));
|
||||
continue;
|
||||
}
|
||||
|
||||
const fetchStart = Date.now();
|
||||
|
||||
try {
|
||||
const [upBook, downBook] = await Promise.all([
|
||||
this._client.getOrderBook(mkt.upTokenId),
|
||||
this._client.getOrderBook(mkt.downTokenId),
|
||||
]);
|
||||
|
||||
const fetchMs = Date.now() - fetchStart;
|
||||
const stale = fetchMs > STALE_THRESHOLD_MS;
|
||||
|
||||
const snapshot = this._buildSnapshot(mkt, upBook, downBook, tteSec, stale);
|
||||
this._eventBus.emit('snapshot', snapshot);
|
||||
|
||||
// ── Throttled debug poll summary ──────────────────────────────
|
||||
if (DEBUG) {
|
||||
const count = (this._pollCount.get(mkt.slug) ?? 0) + 1;
|
||||
this._pollCount.set(mkt.slug, count);
|
||||
|
||||
if (count % DEBUG_POLL_EVERY === 1) {
|
||||
const u = snapshot.up;
|
||||
const d = snapshot.down;
|
||||
dbg('POLL',
|
||||
`${mkt.slug} | tte=${tteSec}s | fetchMs=${fetchMs}ms${stale ? ' [STALE]' : ''}\n` +
|
||||
` UP bid=${u.bestBid.toFixed(4)}/ask=${u.bestAsk.toFixed(4)} ` +
|
||||
`sprd=${u.spread.toFixed(4)} mid=${u.mid.toFixed(4)} ` +
|
||||
`dBid=${u.depthBid.toFixed(1)} dAsk=${u.depthAsk.toFixed(1)}\n` +
|
||||
` DOWN bid=${d.bestBid.toFixed(4)}/ask=${d.bestAsk.toFixed(4)} ` +
|
||||
`sprd=${d.spread.toFixed(4)} mid=${d.mid.toFixed(4)} ` +
|
||||
`dBid=${d.depthBid.toFixed(1)} dAsk=${d.depthAsk.toFixed(1)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
dbg('POLL', `${mkt.slug} → poll error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Snapshot builder ──────────────────────────────────────────────────────
|
||||
|
||||
/** Synthetic snapshot emitted when a market has already closed (tteSec < 0). */
|
||||
_buildExpiredSnapshot(mkt, tteSec) {
|
||||
const emptySide = (tokenId) => ({
|
||||
tokenId,
|
||||
bids: [], asks: [],
|
||||
bestBid: 0, bestAsk: 1, mid: 0.5,
|
||||
spread: 1, depthBid: 0, depthAsk: 0,
|
||||
bestBidSize: 0, bestAskSize: 0,
|
||||
});
|
||||
return {
|
||||
ts: Date.now(),
|
||||
marketSlug: mkt.slug,
|
||||
conditionId: mkt.conditionId,
|
||||
tteSec,
|
||||
tickSize: mkt.tickSize,
|
||||
negRisk: mkt.negRisk,
|
||||
up: emptySide(mkt.upTokenId),
|
||||
down: emptySide(mkt.downTokenId),
|
||||
stale: true, // blocks SignalEngine gates — no new entries on expired book
|
||||
};
|
||||
}
|
||||
|
||||
_buildSnapshot(mkt, upBook, downBook, tteSec, stale) {
|
||||
const up = this._buildSide(mkt.upTokenId, upBook);
|
||||
const down = this._buildSide(mkt.downTokenId, downBook);
|
||||
return {
|
||||
ts: Date.now(),
|
||||
marketSlug: mkt.slug,
|
||||
conditionId: mkt.conditionId,
|
||||
tteSec,
|
||||
tickSize: mkt.tickSize,
|
||||
negRisk: mkt.negRisk,
|
||||
up,
|
||||
down,
|
||||
stale: stale || up.bestBid === 0 || down.bestBid === 0,
|
||||
};
|
||||
}
|
||||
|
||||
_buildSide(tokenId, book) {
|
||||
const parse = (raw = []) =>
|
||||
(Array.isArray(raw) ? raw : [])
|
||||
.filter((l) => l?.price && l?.size)
|
||||
.map((l) => ({ price: parseFloat(l.price), size: parseFloat(l.size) }))
|
||||
.filter((l) => l.price > 0 && l.size > 0);
|
||||
|
||||
const bids = parse(book?.bids).sort((a, b) => b.price - a.price);
|
||||
const asks = parse(book?.asks).sort((a, b) => a.price - b.price);
|
||||
|
||||
const bestBid = bids[0]?.price ?? 0;
|
||||
const bestAsk = asks[0]?.price ?? 1;
|
||||
const mid = bestBid > 0 && bestAsk < 1
|
||||
? (bestBid + bestAsk) / 2
|
||||
: (bestBid || bestAsk || 0.5);
|
||||
const spread = Math.max(0, bestAsk - bestBid);
|
||||
|
||||
const topN = Math.min(TOP_N_LEVELS, Math.max(bids.length, asks.length));
|
||||
const depthBid = bids.slice(0, topN).reduce((s, l) => s + l.size, 0);
|
||||
const depthAsk = asks.slice(0, topN).reduce((s, l) => s + l.size, 0);
|
||||
|
||||
return {
|
||||
tokenId,
|
||||
bids,
|
||||
asks,
|
||||
bestBid,
|
||||
bestAsk,
|
||||
mid,
|
||||
spread,
|
||||
depthBid,
|
||||
depthAsk,
|
||||
bestBidSize: bids[0]?.size ?? 0,
|
||||
bestAskSize: asks[0]?.size ?? 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* PositionEngine.js
|
||||
* Step G of the runtime sequence.
|
||||
*
|
||||
* Maintains position state per market and evaluates exit conditions
|
||||
* on every incoming snapshot tick.
|
||||
*
|
||||
* Strategy: Hold to Expiry (Dominant Side)
|
||||
* ─────────────────────────────────────────
|
||||
* Positions entered on the dominant (probable winner) side are held until
|
||||
* the market expires and the payout is claimed via the on-chain redeemer.
|
||||
* There are no take-profit sells, no momentum-based exits.
|
||||
*
|
||||
* Exit conditions (priority order):
|
||||
* 1. EXIT_EXPIRED — TTE <= 0: market has closed, pending on-chain redemption
|
||||
* 2. EXIT_ADVERSE_MOVE — Token mid has collapsed below the stop-loss floor
|
||||
* (configurable absolute threshold, e.g. 0.20)
|
||||
* Protects against a complete market reversal while still
|
||||
* allowing normal price fluctuations in the dominant range.
|
||||
*/
|
||||
|
||||
import { ReasonCode } from './constants.js';
|
||||
|
||||
export class PositionEngine {
|
||||
/**
|
||||
* @param {Object} opts
|
||||
* @param {number} [opts.stopLossMid=0.20] - Exit if token mid falls below this absolute level.
|
||||
* Set to 0 to disable the stop-loss entirely.
|
||||
*/
|
||||
constructor({ stopLossMid = 0.20 } = {}) {
|
||||
this._stopLossMid = stopLossMid;
|
||||
|
||||
/** @type {Map<string, PositionState>} */
|
||||
this._positions = new Map();
|
||||
}
|
||||
|
||||
// ── Position lifecycle ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Record a newly filled position.
|
||||
*
|
||||
* @param {string} marketSlug
|
||||
* @param {Object} data
|
||||
* @param {string} data.tokenId
|
||||
* @param {'up'|'down'} data.side
|
||||
* @param {number} data.shares
|
||||
* @param {number} data.entryPrice
|
||||
* @param {number} data.tickSize
|
||||
* @param {string} [data.conditionId] - Required for auto-redemption
|
||||
* @param {boolean} [data.negRisk] - Which CTF contract to use for redemption
|
||||
*/
|
||||
open(marketSlug, { tokenId, side, shares, entryPrice, tickSize, conditionId = null, negRisk = false }) {
|
||||
this._positions.set(marketSlug, {
|
||||
marketSlug,
|
||||
tokenId,
|
||||
side,
|
||||
shares,
|
||||
entryPrice,
|
||||
tickSize,
|
||||
conditionId,
|
||||
negRisk,
|
||||
openedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
/** @returns {PositionState|null} */
|
||||
getPosition(marketSlug) {
|
||||
return this._positions.get(marketSlug) ?? null;
|
||||
}
|
||||
|
||||
hasPosition(marketSlug) {
|
||||
return this._positions.has(marketSlug);
|
||||
}
|
||||
|
||||
/** True if ANY position is open across all tracked markets */
|
||||
hasAnyPosition() {
|
||||
return this._positions.size > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the position actively (adverse-move emergency exit) and return exit data.
|
||||
*
|
||||
* @param {string} marketSlug
|
||||
* @param {number} exitPrice - Actual fill price of the sell order
|
||||
* @returns {{ pnl: number, shares: number, entryPrice: number, exitPrice: number }}
|
||||
*/
|
||||
close(marketSlug, exitPrice) {
|
||||
const pos = this._positions.get(marketSlug);
|
||||
if (!pos) return { pnl: 0, shares: 0, entryPrice: 0, exitPrice };
|
||||
|
||||
const pnl = (exitPrice - pos.entryPrice) * pos.shares;
|
||||
this._positions.delete(marketSlug);
|
||||
|
||||
return { pnl, shares: pos.shares, entryPrice: pos.entryPrice, exitPrice };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a position as expired (market closed, pending on-chain redemption).
|
||||
* Does NOT compute final P&L — that is settled by the redeemer service.
|
||||
*
|
||||
* @param {string} marketSlug
|
||||
* @returns {PositionState|null}
|
||||
*/
|
||||
closeExpired(marketSlug) {
|
||||
const pos = this._positions.get(marketSlug) ?? null;
|
||||
if (pos) this._positions.delete(marketSlug);
|
||||
return pos;
|
||||
}
|
||||
|
||||
// ── Exit evaluation ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Evaluate whether the current position should be exited.
|
||||
* Called on every snapshot tick while in POSITION_OPEN state.
|
||||
*
|
||||
* @param {string} marketSlug
|
||||
* @param {Object} snapshot - Current market snapshot
|
||||
* @returns {{ shouldExit: boolean, reason: string|null, isExpired: boolean }}
|
||||
*/
|
||||
evaluateExit(marketSlug, snapshot) {
|
||||
const pos = this._positions.get(marketSlug);
|
||||
if (!pos) return { shouldExit: false, reason: null, isExpired: false };
|
||||
|
||||
const { tteSec } = snapshot;
|
||||
const bookSide = pos.side === 'up' ? snapshot.up : snapshot.down;
|
||||
|
||||
// 1. Market expired — hand off to on-chain redeemer
|
||||
if (tteSec <= 0) {
|
||||
return { shouldExit: false, reason: ReasonCode.EXIT_EXPIRED, isExpired: true };
|
||||
}
|
||||
|
||||
// 2. Catastrophic stop-loss: token has completely collapsed
|
||||
// (market reversed strongly against us — salvage remaining value)
|
||||
if (this._stopLossMid > 0 && bookSide.mid < this._stopLossMid) {
|
||||
return { shouldExit: true, reason: ReasonCode.EXIT_ADVERSE_MOVE, isExpired: false };
|
||||
}
|
||||
|
||||
return { shouldExit: false, reason: null, isExpired: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} PositionState
|
||||
* @property {string} marketSlug
|
||||
* @property {string} tokenId
|
||||
* @property {'up'|'down'} side
|
||||
* @property {number} shares
|
||||
* @property {number} entryPrice
|
||||
* @property {number} tickSize
|
||||
* @property {string|null} conditionId - CTF condition ID for on-chain redemption
|
||||
* @property {boolean} negRisk - Whether to use NegRisk CTF contract
|
||||
* @property {number} openedAt
|
||||
*/
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* RedeemEngine.js
|
||||
* Auto-redemption service for the OneShot Dominant Side Hold engine.
|
||||
*
|
||||
* When a market expires and the position is cleared, this service queues the
|
||||
* position and polls at a regular interval until the CTF contract shows a
|
||||
* non-zero payout denominator (i.e. the market has been resolved on-chain).
|
||||
* It then either:
|
||||
* - DRY_RUN=true → simulates the outcome, logs win/loss P&L
|
||||
* - DRY_RUN=false → submits a real redeemPositions() transaction on Polygon
|
||||
*
|
||||
* Resolution flow:
|
||||
* 1. Gamma API check → market.closed || market.resolved
|
||||
* 2. On-chain check → CTF.payoutDenominator(conditionId) > 0
|
||||
* 3. Compute payout → payouts[0] for UP (YES), payouts[1] for DOWN (NO)
|
||||
* 4. Execute / log
|
||||
* 5. Emit 'redemption:complete' on EventBus with final P&L
|
||||
*
|
||||
* Payout index mapping:
|
||||
* side === 'up' → outcome index 0 (YES / Up token)
|
||||
* side === 'down' → outcome index 1 (NO / Down token)
|
||||
*/
|
||||
|
||||
import { ethers } from 'ethers';
|
||||
import logger from '../utils/logger.js';
|
||||
import { getPolygonProvider } from '../services/client.js';
|
||||
import { redeemPosition, CTF_ADDRESS } from '../services/ctf.js';
|
||||
import { dbg } from './debug.js';
|
||||
|
||||
// ── On-chain constants (read-only — no writes go through EOA) ─────────────────
|
||||
|
||||
const CTF_ABI = [
|
||||
'function payoutNumerators(bytes32 conditionId, uint256 outcomeIndex) view returns (uint256)',
|
||||
'function payoutDenominator(bytes32 conditionId) view returns (uint256)',
|
||||
];
|
||||
|
||||
export class RedeemEngine {
|
||||
/**
|
||||
* @param {Object} opts
|
||||
* @param {boolean} opts.dryRun - If true, simulate instead of real tx
|
||||
* @param {number} [opts.pollIntervalMs] - How often to check pending queue (ms)
|
||||
* @param {import('./EventBus.js').default} opts.eventBus
|
||||
*/
|
||||
constructor({ dryRun, pollIntervalMs = 30_000, eventBus }) {
|
||||
this._dryRun = dryRun;
|
||||
this._pollMs = pollIntervalMs;
|
||||
this._eventBus = eventBus;
|
||||
this._pollTimer = null;
|
||||
|
||||
/**
|
||||
* @type {Map<string, PendingRedemption>}
|
||||
* Key: conditionId
|
||||
*/
|
||||
this._queue = new Map();
|
||||
|
||||
/** Prevent concurrent processing of the same conditionId */
|
||||
this._processing = new Set();
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||
|
||||
start() {
|
||||
this._pollTimer = setInterval(() => this._poll().catch(() => {}), this._pollMs);
|
||||
logger.info(`RedeemEngine: started | poll every ${this._pollMs / 1000}s | dryRun=${this._dryRun}`);
|
||||
}
|
||||
|
||||
stop() {
|
||||
clearInterval(this._pollTimer);
|
||||
if (this._queue.size > 0) {
|
||||
logger.warn(`RedeemEngine: stopped — ${this._queue.size} position(s) still pending redemption:`);
|
||||
for (const [, item] of this._queue) {
|
||||
logger.warn(` → ${item.marketSlug} | ${item.side.toUpperCase()} | ${item.shares} shares @ $${item.entryPrice.toFixed(4)}`);
|
||||
}
|
||||
} else {
|
||||
logger.info('RedeemEngine: stopped — no pending redemptions');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Add an expired position to the redemption queue.
|
||||
* Safe to call multiple times — duplicate conditionIds are ignored.
|
||||
*
|
||||
* @param {Object} data
|
||||
* @param {string} data.conditionId
|
||||
* @param {string} data.marketSlug
|
||||
* @param {'up'|'down'} data.side
|
||||
* @param {number} data.shares
|
||||
* @param {number} data.entryPrice
|
||||
* @param {boolean} data.negRisk
|
||||
*/
|
||||
queueRedemption({ conditionId, marketSlug, side, shares, entryPrice, negRisk }) {
|
||||
if (!conditionId) {
|
||||
logger.warn(`RedeemEngine: missing conditionId for ${marketSlug} — skipping queue`);
|
||||
return;
|
||||
}
|
||||
if (this._queue.has(conditionId)) return;
|
||||
|
||||
this._queue.set(conditionId, {
|
||||
conditionId,
|
||||
marketSlug,
|
||||
side,
|
||||
shares,
|
||||
entryPrice,
|
||||
negRisk: negRisk ?? false,
|
||||
queuedAt: Date.now(),
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`RedeemEngine: queued ${marketSlug} | ${side.toUpperCase()} | ` +
|
||||
`${shares} shares @ $${entryPrice.toFixed(4)} | pending on-chain resolution`,
|
||||
);
|
||||
|
||||
// Trigger an immediate check rather than waiting for the first poll tick
|
||||
this._checkAndRedeem(this._queue.get(conditionId)).catch(() => {});
|
||||
}
|
||||
|
||||
/** Number of positions waiting to be redeemed */
|
||||
get pendingCount() {
|
||||
return this._queue.size;
|
||||
}
|
||||
|
||||
// ── Poll loop ─────────────────────────────────────────────────────────────
|
||||
|
||||
async _poll() {
|
||||
if (this._queue.size === 0) return;
|
||||
dbg('REDEEM', `poll — ${this._queue.size} pending: [${[...this._queue.keys()].map((id) => id.slice(0, 8) + '...').join(', ')}]`);
|
||||
|
||||
for (const [, item] of this._queue) {
|
||||
if (this._processing.has(item.conditionId)) continue;
|
||||
this._processing.add(item.conditionId);
|
||||
this._checkAndRedeem(item)
|
||||
.catch((err) => logger.error(`RedeemEngine: error on ${item.marketSlug} — ${err.message}`))
|
||||
.finally(() => this._processing.delete(item.conditionId));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Resolution check ──────────────────────────────────────────────────────
|
||||
|
||||
async _checkAndRedeem(item) {
|
||||
// Always use on-chain as ground truth for payout data
|
||||
const onChain = await this._checkOnChainPayout(item.conditionId);
|
||||
|
||||
if (!onChain.resolved) {
|
||||
// Gamma API as a secondary status check (informational only)
|
||||
const gammaResolved = await this._checkGammaResolution(item.conditionId);
|
||||
const secWaiting = Math.floor((Date.now() - item.queuedAt) / 1000);
|
||||
|
||||
dbg('REDEEM',
|
||||
`${item.marketSlug} | not yet settled on-chain | ` +
|
||||
`gammaResolved=${gammaResolved} | waited=${secWaiting}s`,
|
||||
);
|
||||
return; // retry on next poll tick
|
||||
}
|
||||
|
||||
await this._settle(item, onChain.payouts);
|
||||
}
|
||||
|
||||
// ── Settlement ────────────────────────────────────────────────────────────
|
||||
|
||||
async _settle(item, payouts) {
|
||||
// UP token = outcome index 0 (YES), DOWN token = outcome index 1 (NO)
|
||||
const outcomeIdx = item.side === 'up' ? 0 : 1;
|
||||
const payoutFraction = payouts[outcomeIdx] ?? 0;
|
||||
const won = payoutFraction > 0;
|
||||
const received = payoutFraction * item.shares; // USDC back from CTF
|
||||
const cost = item.entryPrice * item.shares; // USDC paid at entry
|
||||
const pnl = received - cost;
|
||||
|
||||
if (this._dryRun) {
|
||||
// Simulate: just log the outcome without touching the chain
|
||||
this._logSettlement(item, won, pnl, received, cost);
|
||||
} else {
|
||||
// Always attempt redeemPositions — even for losses (burns the token, cleans wallet)
|
||||
const success = await this._executeRedeem(item);
|
||||
if (!success && won) {
|
||||
// Win but tx failed — USDC unclaimed, keep in queue and retry next poll
|
||||
logger.warn(`RedeemEngine: redemption tx failed for ${item.marketSlug} — will retry`);
|
||||
return;
|
||||
}
|
||||
// Loss: clear from queue regardless of tx result — payout is 0, nothing to collect
|
||||
this._logSettlement(item, won, pnl, received, cost);
|
||||
}
|
||||
|
||||
// Clear from queue and notify orchestrator
|
||||
this._queue.delete(item.conditionId);
|
||||
|
||||
this._eventBus.emit('redemption:complete', {
|
||||
conditionId: item.conditionId,
|
||||
marketSlug: item.marketSlug,
|
||||
side: item.side,
|
||||
won,
|
||||
pnl,
|
||||
shares: item.shares,
|
||||
entryPrice: item.entryPrice,
|
||||
});
|
||||
}
|
||||
|
||||
_logSettlement(item, won, pnl, received, cost) {
|
||||
const tag = this._dryRun ? '[SIM]' : '';
|
||||
|
||||
if (won) {
|
||||
const pct = cost > 0 ? ((pnl / cost) * 100).toFixed(1) : '0.0';
|
||||
logger.money(
|
||||
`${tag} RedeemEngine WIN | ${item.marketSlug} | ${item.side.toUpperCase()} won | ` +
|
||||
`+$${pnl.toFixed(4)} (+${pct}%) | ` +
|
||||
`${item.shares} shares: paid $${cost.toFixed(4)} → received $${received.toFixed(4)}`,
|
||||
);
|
||||
} else {
|
||||
logger.error(
|
||||
`${tag} RedeemEngine LOSS | ${item.marketSlug} | ${item.side.toUpperCase()} lost | ` +
|
||||
`-$${cost.toFixed(4)} (-100%) | ${item.shares} shares @ $${item.entryPrice.toFixed(4)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
async _checkGammaResolution(conditionId) {
|
||||
try {
|
||||
const url = `${config.gammaHost}/markets?condition_id=${conditionId}`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) return false;
|
||||
const markets = await resp.json();
|
||||
if (!Array.isArray(markets) || markets.length === 0) return false;
|
||||
const m = markets[0];
|
||||
return !!(m.closed || m.resolved);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read payoutNumerators and payoutDenominator from the CTF contract.
|
||||
* Returns resolved=true only when denominator > 0 (market has been settled).
|
||||
*/
|
||||
async _checkOnChainPayout(conditionId) {
|
||||
try {
|
||||
const provider = await getPolygonProvider();
|
||||
const ctf = new ethers.Contract(CTF_ADDRESS, CTF_ABI, provider);
|
||||
|
||||
const denom = await ctf.payoutDenominator(conditionId);
|
||||
if (denom.isZero()) return { resolved: false, payouts: [] };
|
||||
|
||||
const payouts = [];
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const num = await ctf.payoutNumerators(conditionId, i);
|
||||
payouts.push(num.toNumber() / denom.toNumber());
|
||||
}
|
||||
return { resolved: true, payouts };
|
||||
} catch {
|
||||
return { resolved: false, payouts: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/** Submit redeemPositions() via Gnosis Safe proxy wallet (same path as MM) */
|
||||
async _executeRedeem(item) {
|
||||
try {
|
||||
logger.info(`RedeemEngine: submitting redeem tx | ${item.marketSlug}...`);
|
||||
await redeemPosition(item.conditionId, item.negRisk);
|
||||
logger.success(`RedeemEngine: redeemed | ${item.marketSlug}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.error(`RedeemEngine: tx error | ${item.marketSlug} — ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} PendingRedemption
|
||||
* @property {string} conditionId
|
||||
* @property {string} marketSlug
|
||||
* @property {'up'|'down'} side
|
||||
* @property {number} shares
|
||||
* @property {number} entryPrice
|
||||
* @property {boolean} negRisk
|
||||
* @property {number} queuedAt - timestamp when queued
|
||||
*/
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* RiskEngine.js
|
||||
* Step H of the runtime sequence.
|
||||
*
|
||||
* Global risk enforcement across all markets in the same session:
|
||||
*
|
||||
* Consecutive loss cap — after N consecutive losses, enter COOLDOWN for
|
||||
* `cooldownRounds` market opportunities
|
||||
* Daily loss cap — if total daily P&L drops below -dailyLossCap,
|
||||
* HALT all trading for the rest of the day
|
||||
*
|
||||
* All policy violations are surfaced via canTrade() so the orchestrator
|
||||
* can gate entries without needing direct access to internal state.
|
||||
*/
|
||||
|
||||
import logger from '../utils/logger.js';
|
||||
import { ReasonCode } from './constants.js';
|
||||
|
||||
export class RiskEngine {
|
||||
/**
|
||||
* @param {Object} opts
|
||||
* @param {number} opts.maxConsecLosses - Consecutive losses before cooldown
|
||||
* @param {number} opts.cooldownRounds - Market slots to skip during cooldown
|
||||
* @param {number} opts.dailyLossCap - Max cumulative daily loss in USDC (positive number)
|
||||
*/
|
||||
constructor({ maxConsecLosses = 2, cooldownRounds = 3, dailyLossCap = 20 }) {
|
||||
this._maxConsecLosses = maxConsecLosses;
|
||||
this._cooldownRounds = cooldownRounds;
|
||||
this._dailyLossCap = dailyLossCap;
|
||||
|
||||
this._dailyPnl = 0;
|
||||
this._consecLosses = 0;
|
||||
this._cooldownLeft = 0;
|
||||
this._halted = false;
|
||||
|
||||
this._sessionStart = Date.now();
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check whether a new entry is allowed.
|
||||
* @returns {{ ok: boolean, reason: string|null, halted: boolean }}
|
||||
*/
|
||||
canTrade() {
|
||||
if (this._halted) {
|
||||
return { ok: false, reason: ReasonCode.RISK_DAILY_CAP, halted: true };
|
||||
}
|
||||
if (this._cooldownLeft > 0) {
|
||||
return { ok: false, reason: ReasonCode.RISK_CONSEC_LOSS, halted: false };
|
||||
}
|
||||
return { ok: true, reason: null, halted: false };
|
||||
}
|
||||
|
||||
/** True if the engine is in cooldown (but not halted) */
|
||||
isCooldown() {
|
||||
return !this._halted && this._cooldownLeft > 0;
|
||||
}
|
||||
|
||||
/** True if trading has been permanently halted for today */
|
||||
isHalted() {
|
||||
return this._halted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the P&L of a closed position and update risk counters.
|
||||
* @param {number} pnl - Realised P&L in USDC (negative = loss)
|
||||
*/
|
||||
recordResult(pnl) {
|
||||
this._dailyPnl += pnl;
|
||||
|
||||
if (pnl < 0) {
|
||||
this._consecLosses++;
|
||||
|
||||
if (this._consecLosses >= this._maxConsecLosses) {
|
||||
this._cooldownLeft = this._cooldownRounds;
|
||||
logger.warn(
|
||||
`RiskEngine: ${this._consecLosses} consecutive losses — ` +
|
||||
`entering cooldown for ${this._cooldownRounds} rounds`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Reset consecutive loss streak on any win
|
||||
this._consecLosses = 0;
|
||||
}
|
||||
|
||||
// Daily cap check
|
||||
if (this._dailyPnl <= -Math.abs(this._dailyLossCap)) {
|
||||
this._halted = true;
|
||||
logger.error(
|
||||
`RiskEngine: daily loss cap hit ($${this._dailyPnl.toFixed(2)}) — ` +
|
||||
`trading HALTED for the rest of the session`,
|
||||
);
|
||||
}
|
||||
|
||||
this._logState(pnl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrement the cooldown counter by one market slot.
|
||||
* Called by the orchestrator each time a new market opportunity is seen
|
||||
* while in cooldown mode.
|
||||
*/
|
||||
decrementCooldown() {
|
||||
if (this._cooldownLeft > 0) {
|
||||
this._cooldownLeft--;
|
||||
logger.info(`RiskEngine: cooldown rounds remaining: ${this._cooldownLeft}`);
|
||||
|
||||
if (this._cooldownLeft === 0) {
|
||||
this._consecLosses = 0;
|
||||
logger.success('RiskEngine: cooldown lifted — resuming normal trading');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Current session statistics snapshot */
|
||||
stats() {
|
||||
return {
|
||||
dailyPnl: this._dailyPnl,
|
||||
consecLosses: this._consecLosses,
|
||||
cooldownLeft: this._cooldownLeft,
|
||||
halted: this._halted,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Internal ──────────────────────────────────────────────────────────────
|
||||
|
||||
_logState(pnl) {
|
||||
const sign = pnl >= 0 ? '+' : '';
|
||||
const stats = this.stats();
|
||||
logger.info(
|
||||
`RiskEngine: pnl=${sign}$${pnl.toFixed(4)} | ` +
|
||||
`daily=$${stats.dailyPnl.toFixed(4)} | ` +
|
||||
`streak=${stats.consecLosses} | ` +
|
||||
`cooldown=${stats.cooldownLeft}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* SignalEngine.js
|
||||
* Steps C & D of the runtime sequence.
|
||||
*
|
||||
* Strategy: Dominant Side Hold — Momentum-Aware Entry
|
||||
* ────────────────────────────────────────────────────
|
||||
* Enters ONLY the side that the market already prices as probable winner
|
||||
* (mid > 50%) AND whose price is either rising or stable.
|
||||
*
|
||||
* "Follow where the odds are moving" — midSlope6s from FeatureEngine is now
|
||||
* a first-class scoring factor. A dominant side that is actively FADING
|
||||
* (slope < SLOPE_CANCEL) is blocked entirely even if its mid is still > 0.60,
|
||||
* because a fading dominant signals a potential reversal.
|
||||
*
|
||||
* Entry pipeline (per 'features' event):
|
||||
* 1. Hard gates — stale, TTE out of [tteMin, tteMax], spread > SPREAD_MAX, depth thin
|
||||
* 2. Dominant side — identify which token the market prices higher; require mid gap >= MIN_MID_GAP
|
||||
* 3. Min probability — dominant mid must be >= minDominantMid (e.g. 0.58)
|
||||
* 4. Momentum gate — dominant midSlope6s must be >= SLOPE_CANCEL (not actively fading)
|
||||
* 5. Score — weighted: mid strength (35%) + momentum (30%) + imbalance (20%) + spread (15%)
|
||||
* 6. Threshold — score >= scoreThreshold
|
||||
*
|
||||
* Key parameter changes vs previous version:
|
||||
* - SPREAD_MAX: 0.02 → 0.04 (near-expiry books often have 0.03 spread)
|
||||
* - tteMax: 90 → 150s (catch direction when it is being established)
|
||||
* - Added W_MOMENTUM = 0.30 (replaces old W_SLOPE/W_RETRACE scalper metrics)
|
||||
* - Added momentum gate (SIG_FADING_DOMINANT) to block reversals
|
||||
*/
|
||||
|
||||
import { Signal, ReasonCode } from './constants.js';
|
||||
import { dbg, DEBUG } from './debug.js';
|
||||
|
||||
// ── Score weights ──────────────────────────────────────────────────────────────
|
||||
const W_MID = 0.35; // How strongly the market prices this side as winner
|
||||
const W_MOMENTUM = 0.30; // Is the dominant odds direction being maintained?
|
||||
const W_IMBALANCE = 0.20; // Order-book depth confirms the direction
|
||||
const W_SPREAD = 0.15; // Execution cost (less critical for hold-to-expiry)
|
||||
|
||||
// ── Gate thresholds ────────────────────────────────────────────────────────────
|
||||
const SPREAD_MAX = 0.04; // Hard gate: spread wider than this → skip
|
||||
const MIN_MID_GAP = 0.08; // Hard gate: |up.mid - down.mid| must exceed this
|
||||
|
||||
// ── Momentum constants ─────────────────────────────────────────────────────────
|
||||
// SLOPE_CANCEL: if dominant side's 6s slope is below this, the market may be
|
||||
// reversing — block entry even if mid is still above threshold.
|
||||
const SLOPE_CANCEL = -0.0020; // Active fade = potential reversal, do not enter
|
||||
const SLOPE_STRONG = 0.0020; // Clearly rising — best signal
|
||||
const SLOPE_MILD = 0.0005; // Gently rising — still good
|
||||
|
||||
// ── Imbalance constants ────────────────────────────────────────────────────────
|
||||
const IMB_STRONG = 0.20;
|
||||
const IMB_WEAK = 0.05;
|
||||
|
||||
/** Throttle debug output: log detail every N evaluations per market */
|
||||
const DEBUG_EVERY = 5;
|
||||
|
||||
export class SignalEngine {
|
||||
/**
|
||||
* @param {Object} opts
|
||||
* @param {import('./EventBus.js').default} opts.eventBus
|
||||
* @param {number} opts.scoreThreshold - Minimum composite score to trigger entry (0–1)
|
||||
* @param {number} opts.minTopSize - Minimum shares at best bid/ask for depth gate
|
||||
* @param {number} opts.minDominantMid - Dominant side mid must be >= this (e.g. 0.58)
|
||||
* @param {number} [opts.tteMin=15] - Minimum TTE in seconds
|
||||
* @param {number} [opts.tteMax=150] - Maximum TTE in seconds
|
||||
*/
|
||||
constructor({ eventBus, scoreThreshold, minTopSize, minDominantMid = 0.58, tteMin = 15, tteMax = 150 }) {
|
||||
this._eventBus = eventBus;
|
||||
this._scoreThreshold = scoreThreshold;
|
||||
this._minTopSize = minTopSize;
|
||||
this._minDominantMid = minDominantMid;
|
||||
this._tteMin = tteMin;
|
||||
this._tteMax = tteMax;
|
||||
|
||||
/** Per-market evaluation counter for throttled debug logs */
|
||||
this._evalCount = new Map();
|
||||
|
||||
this._eventBus.on('features', (feat) => this._onFeatures(feat));
|
||||
}
|
||||
|
||||
// ── Internal ──────────────────────────────────────────────────────────────
|
||||
|
||||
_onFeatures(feat) {
|
||||
const { ts, marketSlug, tteSec, snapshot } = feat;
|
||||
|
||||
const evalN = (this._evalCount.get(marketSlug) ?? 0) + 1;
|
||||
this._evalCount.set(marketSlug, evalN);
|
||||
const logThis = DEBUG && (evalN % DEBUG_EVERY === 1);
|
||||
|
||||
// ── Step C: hard gates ──────────────────────────────────────────────
|
||||
|
||||
const gate = this._hardGates(snapshot, tteSec);
|
||||
|
||||
if (logThis) {
|
||||
if (!gate.pass) {
|
||||
dbg('GATE',
|
||||
`${marketSlug} | tte=${tteSec}s | FAIL → ${gate.reason} | ` +
|
||||
`upSprd=${snapshot.up.spread.toFixed(3)} dnSprd=${snapshot.down.spread.toFixed(3)} ` +
|
||||
`upMid=${snapshot.up.mid.toFixed(3)} dnMid=${snapshot.down.mid.toFixed(3)}`,
|
||||
);
|
||||
} else {
|
||||
dbg('GATE',
|
||||
`${marketSlug} | tte=${tteSec}s | PASS | ` +
|
||||
`upMid=${snapshot.up.mid.toFixed(3)} dnMid=${snapshot.down.mid.toFixed(3)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!gate.pass) {
|
||||
this._emit(marketSlug, Signal.NO_TRADE, null, 0, gate.reason, ts, snapshot, feat);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Step D1: identify dominant side ─────────────────────────────────
|
||||
// The dominant side is whichever token the market prices higher.
|
||||
|
||||
const upMid = snapshot.up.mid;
|
||||
const downMid = snapshot.down.mid;
|
||||
const midGap = Math.abs(upMid - downMid);
|
||||
|
||||
if (midGap < MIN_MID_GAP) {
|
||||
if (logThis) {
|
||||
dbg('SCORE',
|
||||
`${marketSlug} | NO_DOMINANT | upMid=${upMid.toFixed(3)} dnMid=${downMid.toFixed(3)} ` +
|
||||
`gap=${midGap.toFixed(3)} < ${MIN_MID_GAP}`,
|
||||
);
|
||||
}
|
||||
this._emit(marketSlug, Signal.NO_TRADE, null, 0, ReasonCode.SIG_NO_DOMINANT, ts, snapshot, feat);
|
||||
return;
|
||||
}
|
||||
|
||||
const isDominantUp = upMid > downMid;
|
||||
const dominantMid = isDominantUp ? upMid : downMid;
|
||||
const dominantBook = isDominantUp ? snapshot.up : snapshot.down;
|
||||
const dominantFeat = isDominantUp ? feat.up : feat.down;
|
||||
const signal = isDominantUp ? Signal.ENTER_LONG : Signal.ENTER_SHORT;
|
||||
const side = isDominantUp ? 'up' : 'down';
|
||||
const slope = dominantFeat?.midSlope6s ?? 0;
|
||||
|
||||
// ── Step D2: minimum probability gate ───────────────────────────────
|
||||
|
||||
if (dominantMid < this._minDominantMid) {
|
||||
if (logThis) {
|
||||
dbg('SCORE',
|
||||
`${marketSlug} | ${side.toUpperCase()} | LOW_DOMINANT | ` +
|
||||
`mid=${dominantMid.toFixed(3)} < ${this._minDominantMid}`,
|
||||
);
|
||||
}
|
||||
this._emit(marketSlug, Signal.NO_TRADE, null, 0, ReasonCode.SIG_LOW_DOMINANT, ts, snapshot, feat);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Step D3: momentum gate ───────────────────────────────────────────
|
||||
// If the dominant side's price is actively falling, the market may be
|
||||
// reversing. A fading dominant is more dangerous than a weak dominant.
|
||||
|
||||
if (slope < SLOPE_CANCEL) {
|
||||
if (logThis) {
|
||||
dbg('SCORE',
|
||||
`${marketSlug} | ${side.toUpperCase()} | FADING | ` +
|
||||
`slope=${slope.toFixed(5)} < ${SLOPE_CANCEL} (reversal risk)`,
|
||||
);
|
||||
}
|
||||
this._emit(marketSlug, Signal.NO_TRADE, null, 0, ReasonCode.SIG_FADING_DOMINANT, ts, snapshot, feat);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Step D4: composite score ─────────────────────────────────────────
|
||||
|
||||
const midScore = this._scoreMid(dominantMid);
|
||||
const momentumScore = this._scoreMomentum(slope);
|
||||
const imbalanceScore = this._scoreImbalance(dominantFeat?.imbalance ?? 0);
|
||||
const spreadScore = this._scoreSpread(dominantBook.spread);
|
||||
|
||||
const score =
|
||||
W_MID * midScore +
|
||||
W_MOMENTUM * momentumScore +
|
||||
W_IMBALANCE * imbalanceScore +
|
||||
W_SPREAD * spreadScore;
|
||||
|
||||
if (logThis) {
|
||||
dbg('SCORE',
|
||||
`${marketSlug} | ${side.toUpperCase()} dominant | ` +
|
||||
`mid=${dominantMid.toFixed(3)} gap=${midGap.toFixed(3)} slope=${slope.toFixed(5)} | ` +
|
||||
`midS=${midScore.toFixed(2)} momS=${momentumScore.toFixed(2)} ` +
|
||||
`imbS=${imbalanceScore.toFixed(2)} sprdS=${spreadScore.toFixed(2)} ` +
|
||||
`→ score=${score.toFixed(3)} (need ${this._scoreThreshold})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (score < this._scoreThreshold) {
|
||||
this._emit(marketSlug, Signal.NO_TRADE, null, score, ReasonCode.SIG_SCORE_LOW, ts, snapshot, feat);
|
||||
return;
|
||||
}
|
||||
|
||||
// Always log qualifying entries regardless of throttle
|
||||
dbg('SIGNAL',
|
||||
`>>> ${signal} | ${marketSlug} | ` +
|
||||
`mid=${dominantMid.toFixed(3)} slope=${slope.toFixed(5)} ` +
|
||||
`score=${score.toFixed(3)} tte=${tteSec}s`,
|
||||
);
|
||||
|
||||
this._emit(marketSlug, signal, side, score, null, ts, snapshot, feat);
|
||||
}
|
||||
|
||||
// ── Hard gates ────────────────────────────────────────────────────────────
|
||||
|
||||
_hardGates(snapshot, tteSec) {
|
||||
if (snapshot.stale)
|
||||
return { pass: false, reason: ReasonCode.GATE_STALE_BOOK };
|
||||
|
||||
if (tteSec < this._tteMin || tteSec > this._tteMax)
|
||||
return { pass: false, reason: ReasonCode.GATE_TTE_FAIL };
|
||||
|
||||
// Use the dominant side's spread only — underdog's spread is irrelevant
|
||||
// since we never buy the underdog.
|
||||
const dominantSpread = Math.min(snapshot.up.spread, snapshot.down.spread);
|
||||
if (dominantSpread > SPREAD_MAX)
|
||||
return { pass: false, reason: ReasonCode.GATE_SPREAD_WIDE };
|
||||
|
||||
// Require adequate depth on at least one side (dominant side check happens after)
|
||||
const thinUp = snapshot.up.bestBidSize < this._minTopSize
|
||||
|| snapshot.up.bestAskSize < this._minTopSize;
|
||||
const thinDown = snapshot.down.bestBidSize < this._minTopSize
|
||||
|| snapshot.down.bestAskSize < this._minTopSize;
|
||||
|
||||
if (thinUp && thinDown)
|
||||
return { pass: false, reason: ReasonCode.GATE_DEPTH_THIN };
|
||||
|
||||
return { pass: true, reason: null };
|
||||
}
|
||||
|
||||
// ── Scoring helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Score market confidence in the dominant side.
|
||||
* Higher mid price = market is more certain = higher score.
|
||||
* Entry "sweet spot" is 0.60–0.80 (clear direction, still worth holding).
|
||||
*/
|
||||
_scoreMid(mid) {
|
||||
if (mid >= 0.85) return 1.00;
|
||||
if (mid >= 0.75) return 0.85;
|
||||
if (mid >= 0.65) return 0.65;
|
||||
if (mid >= 0.58) return 0.40;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Score the momentum (direction) of the dominant side's price movement.
|
||||
* This is the "follow where the odds are moving" factor.
|
||||
*
|
||||
* Positive slope = dominant side is getting more expensive = conviction increasing.
|
||||
* Flat slope = direction held, acceptable.
|
||||
* Mild negative = slight give-back, cautious but still allowed.
|
||||
* SLOPE_CANCEL = actively fading = blocked by momentum gate before reaching here.
|
||||
*/
|
||||
_scoreMomentum(slope) {
|
||||
if (slope >= SLOPE_STRONG) return 1.00; // Strong, fast move in dominant direction
|
||||
if (slope >= SLOPE_MILD) return 0.75; // Steady climb
|
||||
if (slope >= 0) return 0.50; // Flat / holding
|
||||
if (slope >= -0.0005) return 0.20; // Slight give-back — cautious
|
||||
return 0.05; // Between -0.0005 and SLOPE_CANCEL — marginal
|
||||
}
|
||||
|
||||
/**
|
||||
* Score order-book imbalance for the dominant side.
|
||||
* Positive = more buy depth on dominant side = confirms direction.
|
||||
* Mildly negative = tolerated (sellers exist on winner too, normal).
|
||||
*/
|
||||
_scoreImbalance(imb) {
|
||||
if (imb >= IMB_STRONG) return 1.00;
|
||||
if (imb >= IMB_WEAK) return 0.70;
|
||||
if (imb >= -0.10) return 0.40; // Neutral to slight sell pressure
|
||||
if (imb >= -0.25) return 0.10; // Notable sell pressure
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Score execution cost (spread).
|
||||
* For hold-to-expiry the spread is paid once at entry, so wider spreads
|
||||
* are more tolerated than in a scalping strategy — hence 4 tiers up to SPREAD_MAX.
|
||||
*/
|
||||
_scoreSpread(spread) {
|
||||
if (spread <= 0.01) return 1.00;
|
||||
if (spread <= 0.02) return 0.70;
|
||||
if (spread <= 0.03) return 0.40;
|
||||
if (spread <= 0.04) return 0.10;
|
||||
return 0;
|
||||
}
|
||||
|
||||
_emit(marketSlug, signal, side, score, reason, ts, snapshot, features) {
|
||||
this._eventBus.emit('signal', {
|
||||
ts,
|
||||
marketSlug,
|
||||
tteSec: snapshot.tteSec,
|
||||
signal,
|
||||
side,
|
||||
score,
|
||||
reason,
|
||||
snapshot,
|
||||
features,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* StateMachine.js
|
||||
* Explicit per-market state machine with strict transition guards.
|
||||
*
|
||||
* Rule: never derive state from floating booleans.
|
||||
* Every state change must go through transition() to be validated and logged.
|
||||
*/
|
||||
|
||||
import { State, TRANSITIONS } from './constants.js';
|
||||
|
||||
export class StateMachine {
|
||||
/**
|
||||
* @param {string} marketSlug - Market identifier (used in error messages and logs)
|
||||
* @param {import('./EventBus.js').default} eventBus
|
||||
*/
|
||||
constructor(marketSlug, eventBus) {
|
||||
this._state = State.IDLE;
|
||||
this._slug = marketSlug;
|
||||
this._eventBus = eventBus;
|
||||
}
|
||||
|
||||
/** Current state string */
|
||||
get state() {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt a state transition.
|
||||
* Throws if the transition is not in the allowed graph — this is intentional:
|
||||
* a programming error that bypasses the guard should be loud and traceable.
|
||||
*
|
||||
* @param {string} nextState - One of the State enum values
|
||||
* @param {string} [reason] - Human-readable reason for the transition
|
||||
* @returns {StateMachine} - Returns `this` for chaining
|
||||
*/
|
||||
transition(nextState, reason = '') {
|
||||
const allowed = TRANSITIONS[this._state] ?? [];
|
||||
|
||||
if (!allowed.includes(nextState)) {
|
||||
throw new Error(
|
||||
`[StateMachine] Invalid transition: ${this._state} → ${nextState}` +
|
||||
` (market: ${this._slug}, reason: ${reason})`,
|
||||
);
|
||||
}
|
||||
|
||||
const from = this._state;
|
||||
this._state = nextState;
|
||||
|
||||
this._eventBus.emit('state:transition', {
|
||||
marketSlug: this._slug,
|
||||
from,
|
||||
to: nextState,
|
||||
reason,
|
||||
ts: Date.now(),
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/** @param {string} state */
|
||||
is(state) {
|
||||
return this._state === state;
|
||||
}
|
||||
|
||||
/** @param {string} state */
|
||||
canTransitionTo(state) {
|
||||
return (TRANSITIONS[this._state] ?? []).includes(state);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Telemetry.js
|
||||
* Structured JSONL logger for the OneShot engine.
|
||||
*
|
||||
* Every decision tick, order lifecycle event, position exit, and state
|
||||
* transition is recorded to data/oneshot_telemetry.jsonl — one JSON object
|
||||
* per line — for offline analysis and strategy tuning.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
const DATA_DIR = path.resolve('data');
|
||||
const LOG_FILE = path.join(DATA_DIR, 'oneshot_telemetry.jsonl');
|
||||
|
||||
export class Telemetry {
|
||||
constructor() {
|
||||
// Ensure data/ directory exists
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public log methods ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Log a per-decision-tick evaluation record.
|
||||
* Called for every signal evaluation, whether entry is taken or not.
|
||||
*
|
||||
* @param {Object} d
|
||||
* @param {string} d.marketSlug
|
||||
* @param {number} d.ts
|
||||
* @param {number} d.tteSec
|
||||
* @param {number} d.spread
|
||||
* @param {number} d.imbalance
|
||||
* @param {number} d.slope
|
||||
* @param {number} d.retrace
|
||||
* @param {number} d.depth
|
||||
* @param {boolean} d.gatePass
|
||||
* @param {string} d.reasonCode
|
||||
* @param {number} d.score
|
||||
* @param {string} d.action
|
||||
*/
|
||||
logDecision(d) {
|
||||
this._write({ type: 'decision', ...d });
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an order lifecycle event (submit → ack → fill / cancel).
|
||||
*
|
||||
* @param {Object} d
|
||||
* @param {string} d.clientOrderId
|
||||
* @param {string} d.side
|
||||
* @param {string} d.marketSlug
|
||||
* @param {number} d.px
|
||||
* @param {number} d.qty
|
||||
* @param {number} d.ackMs
|
||||
* @param {number} d.fillMs
|
||||
* @param {string} d.status
|
||||
*/
|
||||
logOrder(d) {
|
||||
this._write({ type: 'order', ...d });
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a position exit event.
|
||||
*
|
||||
* @param {Object} d
|
||||
* @param {string} d.marketSlug
|
||||
* @param {string} d.exitReason
|
||||
* @param {number} d.entryPx
|
||||
* @param {number} d.exitPx
|
||||
* @param {number} d.pnl
|
||||
* @param {number} d.shares
|
||||
*/
|
||||
logExit(d) {
|
||||
this._write({ type: 'exit', ...d });
|
||||
|
||||
const pnlStr = d.pnl == null
|
||||
? 'pending(on-chain)'
|
||||
: d.pnl >= 0
|
||||
? `+$${d.pnl.toFixed(4)}`
|
||||
: `-$${Math.abs(d.pnl).toFixed(4)}`;
|
||||
|
||||
logger.money(`[Telemetry] exit ${d.marketSlug} | ${d.exitReason} | pnl=${pnlStr}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a state machine transition.
|
||||
*
|
||||
* @param {Object} d
|
||||
* @param {string} d.marketSlug
|
||||
* @param {string} d.from
|
||||
* @param {string} d.to
|
||||
* @param {string} d.reason
|
||||
* @param {number} d.ts
|
||||
*/
|
||||
logTransition(d) {
|
||||
this._write({ type: 'transition', ...d });
|
||||
}
|
||||
|
||||
// ── Internal ─────────────────────────────────────────────────────────────
|
||||
|
||||
_write(record) {
|
||||
const line = JSON.stringify({ ...record, ts: record.ts ?? Date.now() }) + '\n';
|
||||
fs.appendFile(LOG_FILE, line, (err) => {
|
||||
if (err) logger.warn(`[Telemetry] write error: ${err.message}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* constants.js
|
||||
* Shared enums and reason codes for the Anti-Flip 5m OneShot Engine.
|
||||
* All objects are frozen to prevent accidental mutation at runtime.
|
||||
*/
|
||||
|
||||
// ── State machine states ───────────────────────────────────────────────────────
|
||||
|
||||
export const State = Object.freeze({
|
||||
IDLE: 'IDLE', // Waiting for a qualifying signal
|
||||
SETUP_READY: 'SETUP_READY', // Signal passed — about to submit order
|
||||
ORDER_PENDING: 'ORDER_PENDING', // Order submitted, awaiting fill ack
|
||||
POSITION_OPEN: 'POSITION_OPEN', // Filled — actively managing position
|
||||
REDUCE_ONLY: 'REDUCE_ONLY', // Time threshold reached — exit only, no new entry
|
||||
COOLDOWN: 'COOLDOWN', // Short suspension after consecutive losses
|
||||
HALTED: 'HALTED', // Daily stop-loss hit — no more trading today
|
||||
});
|
||||
|
||||
// ── Reason / decision codes ────────────────────────────────────────────────────
|
||||
|
||||
export const ReasonCode = Object.freeze({
|
||||
// Hard gate failures
|
||||
GATE_TTE_FAIL: 'GATE_TTE_FAIL', // TTE outside [25, 120] range
|
||||
GATE_SPREAD_WIDE: 'GATE_SPREAD_WIDE', // Spread exceeds maximum threshold
|
||||
GATE_DEPTH_THIN: 'GATE_DEPTH_THIN', // Best bid/ask size below minimum
|
||||
GATE_STALE_BOOK: 'GATE_STALE_BOOK', // Book snapshot is stale or empty
|
||||
|
||||
// Signal evaluation failures
|
||||
SIG_SCORE_LOW: 'SIG_SCORE_LOW', // Composite score below threshold
|
||||
SIG_NO_CONFIRM: 'SIG_NO_CONFIRM', // Trend confirmation failed (legacy)
|
||||
SIG_NO_DOMINANT: 'SIG_NO_DOMINANT', // Neither side is clearly dominant (mid gap too small)
|
||||
SIG_LOW_DOMINANT: 'SIG_LOW_DOMINANT', // Dominant side mid below minimum threshold
|
||||
SIG_FADING_DOMINANT: 'SIG_FADING_DOMINANT', // Dominant side mid is actively falling — reversal risk
|
||||
|
||||
// Execution failures
|
||||
EXEC_TIMEOUT_NO_FILL: 'EXEC_TIMEOUT_NO_FILL', // FOK timed out without fill
|
||||
EXEC_PARTIAL_REDUCE: 'EXEC_PARTIAL_REDUCE', // Partial fill reduced & closed
|
||||
EXEC_SUBMIT_ERROR: 'EXEC_SUBMIT_ERROR', // Order submission threw error
|
||||
|
||||
// Risk policy
|
||||
RISK_CONSEC_LOSS: 'RISK_CONSEC_LOSS', // Consecutive loss limit triggered cooldown
|
||||
RISK_DAILY_CAP: 'RISK_DAILY_CAP', // Daily loss cap reached — halted
|
||||
RISK_STATE_BLOCK: 'RISK_STATE_BLOCK', // Risk engine blocked entry (cooldown/halted)
|
||||
|
||||
// Exit reasons
|
||||
EXIT_ADVERSE_MOVE: 'EXIT_ADVERSE_MOVE', // Token mid collapsed below stop-loss floor
|
||||
EXIT_EXPIRED: 'EXIT_EXPIRED', // Market expired — position pending on-chain redemption
|
||||
EXIT_RISK_FORCED: 'EXIT_RISK_FORCED', // Risk engine forced exit
|
||||
});
|
||||
|
||||
// ── Signal directions ──────────────────────────────────────────────────────────
|
||||
|
||||
export const Signal = Object.freeze({
|
||||
NO_TRADE: 'NO_TRADE', // Conditions not met — skip
|
||||
ENTER_LONG: 'ENTER_LONG', // Buy UP token
|
||||
ENTER_SHORT: 'ENTER_SHORT', // Buy DOWN token
|
||||
});
|
||||
|
||||
// ── Valid state transitions ────────────────────────────────────────────────────
|
||||
// Used by StateMachine to enforce the explicit transition graph.
|
||||
|
||||
export const TRANSITIONS = Object.freeze({
|
||||
[State.IDLE]: [State.SETUP_READY, State.COOLDOWN, State.HALTED],
|
||||
[State.SETUP_READY]: [State.ORDER_PENDING, State.IDLE, State.COOLDOWN, State.HALTED],
|
||||
[State.ORDER_PENDING]: [State.POSITION_OPEN, State.IDLE, State.COOLDOWN, State.HALTED],
|
||||
[State.POSITION_OPEN]: [State.REDUCE_ONLY, State.IDLE, State.COOLDOWN, State.HALTED],
|
||||
[State.REDUCE_ONLY]: [State.IDLE, State.COOLDOWN, State.HALTED],
|
||||
[State.COOLDOWN]: [State.IDLE, State.HALTED],
|
||||
[State.HALTED]: [],
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* debug.js
|
||||
* Lightweight debug helper for the OneShot engine.
|
||||
*
|
||||
* Enable by setting ONESHOT_DEBUG=true in your .env or environment,
|
||||
* or by passing --debug on the command line:
|
||||
*
|
||||
* ONESHOT_DEBUG=true npm run oneshot
|
||||
* npm run oneshot -- --debug
|
||||
* npm run oneshot-debug (shorthand script)
|
||||
*/
|
||||
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
export const DEBUG = process.env.ONESHOT_DEBUG === 'true'
|
||||
|| process.argv.includes('--debug');
|
||||
|
||||
/**
|
||||
* Log a debug message — no-op when DEBUG is false.
|
||||
* Prefixes every line with a [DBG <tag>] marker so you can grep by component.
|
||||
*
|
||||
* @param {string} tag - Component name, e.g. 'FEED', 'GATE', 'SCORE'
|
||||
* @param {string} msg - Message string
|
||||
*/
|
||||
export function dbg(tag, msg) {
|
||||
if (!DEBUG) return;
|
||||
logger.info(`[DBG:${tag}] ${msg}`);
|
||||
}
|
||||
+8
-22
@@ -1,28 +1,16 @@
|
||||
import { ClobClient } from '@polymarket/clob-client';
|
||||
import { ethers, Wallet } from 'ethers';
|
||||
import { Wallet } from 'ethers';
|
||||
import config from '../config/index.js';
|
||||
import logger from '../utils/logger.js';
|
||||
import { setupAxiosProxy, testProxy } from '../utils/proxy.js';
|
||||
|
||||
let clobClient = null;
|
||||
let signer = null;
|
||||
let _provider = null; // singleton — reused across all onchain calls
|
||||
|
||||
/**
|
||||
* Initialize the Polymarket CLOB client
|
||||
* Auto-derives API credentials if not provided in .env
|
||||
*/
|
||||
export async function initClient() {
|
||||
// ── Set up proxy (if configured) BEFORE any Polymarket API calls ──
|
||||
await setupAxiosProxy();
|
||||
|
||||
// Test proxy connectivity
|
||||
const proxyOk = await testProxy();
|
||||
if (!proxyOk) {
|
||||
logger.error('Proxy test failed — cannot reach Polymarket. Exiting.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
logger.info('Initializing Polymarket CLOB client...');
|
||||
|
||||
signer = new Wallet(config.privateKey);
|
||||
@@ -80,22 +68,20 @@ export function getSigner() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get (or create) the singleton Polygon provider.
|
||||
* A single JsonRpcProvider instance is reused across all onchain calls
|
||||
* to avoid reconnection overhead on every balance check.
|
||||
* Get a working Polygon provider using RPC from config
|
||||
*/
|
||||
export function getPolygonProvider() {
|
||||
if (!_provider) {
|
||||
_provider = new ethers.providers.JsonRpcProvider(config.polygonRpcUrl);
|
||||
}
|
||||
return _provider;
|
||||
export async function getPolygonProvider() {
|
||||
const { ethers } = await import('ethers');
|
||||
const provider = new ethers.providers.JsonRpcProvider(config.polygonRpcUrl);
|
||||
return provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get USDC.e balance of the proxy wallet on Polygon
|
||||
*/
|
||||
export async function getUsdcBalance() {
|
||||
const provider = getPolygonProvider();
|
||||
const { ethers } = await import('ethers');
|
||||
const provider = await getPolygonProvider();
|
||||
const usdcAddress = '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174'; // USDC.e on Polygon
|
||||
const abi = ['function balanceOf(address) view returns (uint256)'];
|
||||
const usdc = new ethers.Contract(usdcAddress, abi, provider);
|
||||
|
||||
+48
-348
@@ -12,7 +12,6 @@ import { ethers } from 'ethers';
|
||||
import config from '../config/index.js';
|
||||
import { getSigner, getPolygonProvider } from './client.js';
|
||||
import logger from '../utils/logger.js';
|
||||
import { proxyFetch } from '../utils/proxy.js';
|
||||
|
||||
// ── Contract addresses (Polygon mainnet) ──────────────────────────────────────
|
||||
|
||||
@@ -60,8 +59,8 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
* Strips the lengthy internal stack info that ethers appends.
|
||||
*/
|
||||
function parseOnchainError(err) {
|
||||
const msg = err?.message || String(err);
|
||||
const reason = err?.reason || err?.error?.reason || '';
|
||||
const msg = err?.message || String(err);
|
||||
const reason = err?.reason || err?.error?.reason || '';
|
||||
|
||||
if (msg.includes('insufficient funds') || msg.includes('insufficient balance'))
|
||||
return 'Insufficient MATIC balance for gas fees';
|
||||
@@ -73,10 +72,6 @@ function parseOnchainError(err) {
|
||||
return 'Priority fee below Polygon minimum (25 Gwei)';
|
||||
if (msg.includes('UNPREDICTABLE_GAS_LIMIT'))
|
||||
return 'Gas estimation failed — transaction will likely revert';
|
||||
if (msg.includes('GS026'))
|
||||
return 'Safe nonce conflict (GS026) — another transaction consumed this nonce';
|
||||
if (msg.includes('GS013'))
|
||||
return 'Safe execution failed (GS013) — inner transaction reverted';
|
||||
if (msg.includes('execution reverted') || err?.code === 'CALL_EXCEPTION')
|
||||
return reason ? `Transaction reverted: ${reason}` : 'Transaction reverted by smart contract';
|
||||
if (msg.includes('timeout') || msg.includes('TIMEOUT'))
|
||||
@@ -105,61 +100,28 @@ const RETRY_DELAY = 3000; // ms
|
||||
// tx waits for the previous one to fully confirm before starting.
|
||||
let _txQueue = Promise.resolve();
|
||||
|
||||
// Track whether a strategy (split/merge) tx is in progress so the redeemer can defer
|
||||
let _strategyTxActive = false;
|
||||
|
||||
/**
|
||||
* Execute an arbitrary call through the Gnosis Safe proxy wallet.
|
||||
* Calls are serialized via an internal queue so nonces never collide.
|
||||
* Retries up to MAX_RETRIES times on transient errors.
|
||||
*
|
||||
* @param {string} to - Contract address
|
||||
* @param {string} data - Encoded calldata
|
||||
* @param {string} description - Human-readable label for logging
|
||||
* @param {object} [opts] - Options
|
||||
* @param {boolean} [opts.priority=true] - Priority calls (strategy split/merge) run immediately.
|
||||
* Non-priority calls (redeemer) wait until no strategy tx is active.
|
||||
*/
|
||||
export function execSafeCall(to, data, description = '', opts = {}) {
|
||||
const { priority = true, gasLimit } = opts;
|
||||
|
||||
const job = async () => {
|
||||
// Non-priority (redeemer): wait if a strategy tx is active
|
||||
if (!priority && _strategyTxActive) {
|
||||
logger.info(`MM: deferring non-priority tx (${description}) — strategy tx in progress`);
|
||||
// Wait until strategy tx finishes (poll every 1s, max 60s)
|
||||
for (let i = 0; i < 60 && _strategyTxActive; i++) {
|
||||
await sleep(1000);
|
||||
}
|
||||
}
|
||||
|
||||
if (priority) _strategyTxActive = true;
|
||||
try {
|
||||
return await _doExecSafeCall(to, data, description, gasLimit);
|
||||
} finally {
|
||||
if (priority) _strategyTxActive = false;
|
||||
}
|
||||
};
|
||||
|
||||
function execSafeCall(to, data, description = '') {
|
||||
// Enqueue: this call will only start after the previous one resolves/rejects
|
||||
const result = _txQueue.then(job);
|
||||
const result = _txQueue.then(() => _doExecSafeCall(to, data, description));
|
||||
// Don't let a failure poison the queue for subsequent calls
|
||||
_txQueue = result.catch(() => { });
|
||||
_txQueue = result.catch(() => {});
|
||||
return result;
|
||||
}
|
||||
|
||||
async function _doExecSafeCall(to, data, description = '', gasLimit = undefined) {
|
||||
async function _doExecSafeCall(to, data, description = '') {
|
||||
if (description) logger.info(`MM: exec safe tx — ${description}`);
|
||||
|
||||
let lastErr;
|
||||
// Track gas price multiplier for replacement transactions
|
||||
let gasMultiplier = 1;
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const provider = await getPolygonProvider();
|
||||
const wallet = getSigner().connect(provider);
|
||||
const safe = new ethers.Contract(config.proxyWallet, SAFE_ABI, wallet);
|
||||
const wallet = getSigner().connect(provider);
|
||||
const safe = new ethers.Contract(config.proxyWallet, SAFE_ABI, wallet);
|
||||
|
||||
const nonce = await safe.nonce();
|
||||
|
||||
@@ -180,42 +142,22 @@ async function _doExecSafeCall(to, data, description = '', gasLimit = undefined)
|
||||
// Sign the raw hash with the EOA signing key (no EIP-191 prefix)
|
||||
// Gnosis Safe v1.3.0 treats plain ECDSA signatures (v=27/28) on the tx hash directly
|
||||
const signingKey = new ethers.utils.SigningKey(config.privateKey);
|
||||
const rawSig = signingKey.signDigest(txHash);
|
||||
const signature = ethers.utils.joinSignature(rawSig);
|
||||
const rawSig = signingKey.signDigest(txHash);
|
||||
const signature = ethers.utils.joinSignature(rawSig);
|
||||
|
||||
// Polygon requires maxPriorityFeePerGas ≥ 25 Gwei.
|
||||
// Use HIGH gas prices for fast inclusion — especially important for redeem.
|
||||
const feeData = await provider.getFeeData();
|
||||
|
||||
// Increase gas price on retry to replace pending transaction
|
||||
// Base: use 150% of estimated fees for fast inclusion
|
||||
// Multiplier on retry: 1.5x → 3x → 6x
|
||||
const BASE_MULTIPLIER = 1.5;
|
||||
const currentMultiplier = BASE_MULTIPLIER * gasMultiplier;
|
||||
|
||||
// Priority fee: minimum 50 Gwei, or 150%+ of estimate
|
||||
const MIN_TIP = ethers.utils.parseUnits('50', 'gwei');
|
||||
const estimatedTip = feeData.maxPriorityFeePerGas || MIN_TIP;
|
||||
const gasTip = estimatedTip.mul(Math.ceil(currentMultiplier * 100)).div(100).gt(MIN_TIP)
|
||||
? estimatedTip.mul(Math.ceil(currentMultiplier * 100)).div(100)
|
||||
: MIN_TIP;
|
||||
|
||||
// Max fee: use high ceiling to ensure inclusion
|
||||
const MAX_FEE_CAP = ethers.utils.parseUnits('1000', 'gwei');
|
||||
const estimatedMaxFee = feeData.maxFeePerGas || ethers.utils.parseUnits('500', 'gwei');
|
||||
const gasFeeCap = estimatedMaxFee.mul(Math.ceil(currentMultiplier * 100)).div(100).gt(MAX_FEE_CAP)
|
||||
? MAX_FEE_CAP
|
||||
: estimatedMaxFee.mul(Math.ceil(currentMultiplier * 100)).div(100);
|
||||
|
||||
const txOpts = { maxPriorityFeePerGas: gasTip, maxFeePerGas: gasFeeCap };
|
||||
if (gasLimit) txOpts.gasLimit = gasLimit;
|
||||
// Some RPC nodes (e.g. lava.build) return a stale low estimate, so we enforce a floor.
|
||||
const feeData = await provider.getFeeData();
|
||||
const MIN_TIP = ethers.utils.parseUnits('30', 'gwei');
|
||||
const gasTip = feeData.maxPriorityFeePerGas?.gt(MIN_TIP) ? feeData.maxPriorityFeePerGas : MIN_TIP;
|
||||
const gasFeeCap = feeData.maxFeePerGas ?? ethers.utils.parseUnits('500', 'gwei');
|
||||
|
||||
const tx = await safe.execTransaction(
|
||||
to, 0, data, 0, 0, 0, 0,
|
||||
ethers.constants.AddressZero,
|
||||
ethers.constants.AddressZero,
|
||||
signature,
|
||||
txOpts,
|
||||
{ maxPriorityFeePerGas: gasTip, maxFeePerGas: gasFeeCap },
|
||||
);
|
||||
|
||||
const receipt = await tx.wait();
|
||||
@@ -226,14 +168,7 @@ async function _doExecSafeCall(to, data, description = '', gasLimit = undefined)
|
||||
const friendly = parseOnchainError(err);
|
||||
|
||||
if (attempt < MAX_RETRIES) {
|
||||
// Increase gas multiplier for replacement transaction
|
||||
if (err?.message?.includes('replacement transaction underpriced') ||
|
||||
err?.message?.includes('Gas price too low to replace')) {
|
||||
gasMultiplier *= 2;
|
||||
logger.warn(`MM: transaction failed (attempt ${attempt}/${MAX_RETRIES}): ${friendly} — increasing gas ${gasMultiplier}x and retrying...`);
|
||||
} else {
|
||||
logger.warn(`MM: transaction failed (attempt ${attempt}/${MAX_RETRIES}): ${friendly} — retrying in ${RETRY_DELAY / 1000}s...`);
|
||||
}
|
||||
logger.warn(`MM: transaction failed (attempt ${attempt}/${MAX_RETRIES}): ${friendly} — retrying in ${RETRY_DELAY / 1000}s...`);
|
||||
await sleep(RETRY_DELAY);
|
||||
}
|
||||
}
|
||||
@@ -245,28 +180,18 @@ async function _doExecSafeCall(to, data, description = '', gasLimit = undefined)
|
||||
|
||||
// ── Approval helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
// In-memory approval cache — avoids redundant on-chain reads after first approval
|
||||
let _usdcApproved = false;
|
||||
const _exchangeApproved = new Set(); // exchange addresses already confirmed
|
||||
|
||||
/**
|
||||
* Ensure the CTF contract can spend USDC from the proxy wallet.
|
||||
*/
|
||||
async function ensureUsdcApproval(amountWei) {
|
||||
if (_usdcApproved) return;
|
||||
|
||||
const provider = await getPolygonProvider();
|
||||
const usdc = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, provider);
|
||||
const allowance = await usdc.allowance(config.proxyWallet, CTF_ADDRESS);
|
||||
if (allowance.gte(amountWei)) {
|
||||
_usdcApproved = true;
|
||||
return;
|
||||
}
|
||||
if (allowance.gte(amountWei)) return;
|
||||
|
||||
const iface = new ethers.utils.Interface(ERC20_ABI);
|
||||
const data = iface.encodeFunctionData('approve', [CTF_ADDRESS, ethers.constants.MaxUint256]);
|
||||
await execSafeCall(USDC_ADDRESS, data, 'approve USDC → CTF');
|
||||
_usdcApproved = true;
|
||||
logger.success('MM: USDC approved to CTF contract');
|
||||
}
|
||||
|
||||
@@ -276,68 +201,18 @@ async function ensureUsdcApproval(amountWei) {
|
||||
*/
|
||||
export async function ensureExchangeApproval(negRisk = false) {
|
||||
const exchange = negRisk ? NEG_RISK_EXCHANGE : CTF_EXCHANGE;
|
||||
if (_exchangeApproved.has(exchange)) return;
|
||||
|
||||
const provider = await getPolygonProvider();
|
||||
const ctf = new ethers.Contract(CTF_ADDRESS, ERC1155_ABI, provider);
|
||||
|
||||
const approved = await ctf.isApprovedForAll(config.proxyWallet, exchange);
|
||||
if (approved) {
|
||||
_exchangeApproved.add(exchange);
|
||||
return;
|
||||
}
|
||||
if (approved) return;
|
||||
|
||||
const iface = new ethers.utils.Interface(ERC1155_ABI);
|
||||
const data = iface.encodeFunctionData('setApprovalForAll', [exchange, true]);
|
||||
await execSafeCall(CTF_ADDRESS, data, 'setApprovalForAll → CTF Exchange');
|
||||
_exchangeApproved.add(exchange);
|
||||
logger.success(`MM: CTF exchange approved as ERC1155 operator`);
|
||||
}
|
||||
|
||||
// ── Helper: Redeem after merge ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Redeem positions for a specific conditionId (after successful merge).
|
||||
* This is a thin wrapper around redeemPositions to support auto-redeem.
|
||||
*
|
||||
* @param {string} conditionId - Market conditionId to redeem
|
||||
* @param {boolean} negRisk - Whether the market uses negRisk exchange
|
||||
*/
|
||||
export async function redeemPositions(conditionId, negRisk = false) {
|
||||
if (config.dryRun) {
|
||||
logger.info(`MM[SIM]: redeem positions for conditionId=${conditionId?.slice(0, 10)}...`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Pre-check: ensure market has resolved before calling redeemPositions.
|
||||
// If payoutDenominator == 0, the condition is unresolved — redeemPositions will
|
||||
// revert and the Safe wraps that as GS013. Throw a clear error instead.
|
||||
try {
|
||||
const provider = getPolygonProvider();
|
||||
const ctf = new ethers.Contract(CTF_ADDRESS, CTF_ABI, provider);
|
||||
const denominator = await ctf.payoutDenominator(conditionId);
|
||||
if (denominator.isZero()) {
|
||||
throw new Error(`Market not resolved yet (payoutDenominator=0) — cannot redeem conditionId=${conditionId?.slice(0, 12)}`);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.message.includes('payoutDenominator=0') || err.message.includes('not resolved')) throw err;
|
||||
// RPC error on pre-check — log and proceed anyway (let execSafeCall handle it)
|
||||
logger.warn(`MM: redeemPositions pre-check failed — ${err.message} — proceeding anyway`);
|
||||
}
|
||||
|
||||
const ctfIface = new ethers.utils.Interface(CTF_ABI);
|
||||
const data = ctfIface.encodeFunctionData('redeemPositions', [
|
||||
USDC_ADDRESS,
|
||||
ethers.constants.HashZero,
|
||||
conditionId,
|
||||
[1, 2],
|
||||
]);
|
||||
|
||||
// gasLimit bypasses eth_estimateGas RPC flakiness (same reason as mergePositions).
|
||||
// GS013 without gasLimit = inner CTF call reverted, often due to gas estimation failure.
|
||||
await execSafeCall(CTF_ADDRESS, data, `redeemPositions ${conditionId?.slice(0, 12)}...`, { gasLimit: 500_000 });
|
||||
}
|
||||
|
||||
// ── Core CTF operations ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -406,13 +281,7 @@ export async function mergePositions(conditionId, sharesPerSide) {
|
||||
return recovered;
|
||||
}
|
||||
|
||||
// Floor to exact 6-decimal integer to prevent requesting more units than the Safe holds.
|
||||
// Floating point round-trip (e.g. 4.910199 → toFixed(4) → 4.9102 → 4910200 wei)
|
||||
// can exceed actual on-chain balance by 1 unit, causing the CTF merge to revert.
|
||||
const amountWei = ethers.utils.parseUnits(
|
||||
(Math.floor(sharesPerSide * 1_000_000) / 1_000_000).toFixed(6),
|
||||
6,
|
||||
);
|
||||
const amountWei = ethers.utils.parseUnits(sharesPerSide.toFixed(6), 6);
|
||||
|
||||
const ctfIface = new ethers.utils.Interface(CTF_ABI);
|
||||
const data = ctfIface.encodeFunctionData('mergePositions', [
|
||||
@@ -423,10 +292,7 @@ export async function mergePositions(conditionId, sharesPerSide) {
|
||||
amountWei,
|
||||
]);
|
||||
|
||||
// Pass explicit gasLimit to bypass eth_estimateGas — Polygon RPC instability
|
||||
// can cause estimateGas to fail even when the tx would succeed onchain.
|
||||
// 500k gas is well above the ~200-250k typically consumed by a Safe+CTF merge.
|
||||
await execSafeCall(CTF_ADDRESS, data, `mergePositions conditionId=${conditionId.slice(0, 10)}...`, { gasLimit: 500_000 });
|
||||
await execSafeCall(CTF_ADDRESS, data, `mergePositions conditionId=${conditionId.slice(0, 10)}...`);
|
||||
logger.success(`MM: merged — recovered $${sharesPerSide} USDC`);
|
||||
return sharesPerSide;
|
||||
}
|
||||
@@ -466,7 +332,7 @@ export async function cleanupOpenPositions(clobClient) {
|
||||
let dataPositions = [];
|
||||
try {
|
||||
const url = `https://data-api.polymarket.com/positions?user=${config.proxyWallet}`;
|
||||
const resp = await proxyFetch(url);
|
||||
const resp = await fetch(url);
|
||||
if (resp.ok) dataPositions = await resp.json();
|
||||
if (!Array.isArray(dataPositions)) dataPositions = [];
|
||||
} catch (err) {
|
||||
@@ -543,6 +409,28 @@ export async function cleanupOpenPositions(clobClient) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Single-position redeemer (used by OneShot RedeemEngine) ──────────────────
|
||||
|
||||
/**
|
||||
* Redeem a single resolved position through the Gnosis Safe proxy wallet.
|
||||
* Uses the same execSafeCall path as all other MM on-chain operations,
|
||||
* enforcing the 30 Gwei priority fee floor required by Polygon.
|
||||
*
|
||||
* @param {string} conditionId - bytes32 condition ID
|
||||
* @param {boolean} negRisk - use NegRisk CTF address if true
|
||||
*/
|
||||
export async function redeemPosition(conditionId, negRisk = false) {
|
||||
const ctfAddress = negRisk ? NEG_RISK_EXCHANGE : CTF_ADDRESS;
|
||||
const ctfIface = new ethers.utils.Interface(CTF_ABI);
|
||||
const data = ctfIface.encodeFunctionData('redeemPositions', [
|
||||
USDC_ADDRESS,
|
||||
ethers.constants.HashZero,
|
||||
conditionId,
|
||||
[1, 2],
|
||||
]);
|
||||
await execSafeCall(ctfAddress, data, `redeemPositions ${conditionId.slice(0, 12)}...`);
|
||||
}
|
||||
|
||||
// ── Periodic redeemer ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -558,7 +446,7 @@ export async function redeemMMPositions() {
|
||||
// 1. Query Data API for all positions held by the proxy wallet
|
||||
let dataPositions = [];
|
||||
try {
|
||||
const resp = await proxyFetch(`${config.dataHost}/positions?user=${config.proxyWallet}`);
|
||||
const resp = await fetch(`${config.dataHost}/positions?user=${config.proxyWallet}`);
|
||||
if (resp.ok) dataPositions = await resp.json();
|
||||
if (!Array.isArray(dataPositions)) dataPositions = [];
|
||||
} catch {
|
||||
@@ -575,12 +463,12 @@ export async function redeemMMPositions() {
|
||||
const byCondition = new Map();
|
||||
for (const pos of dataPositions) {
|
||||
const cid = pos.conditionId || pos.condition_id;
|
||||
const tid = pos.asset || pos.tokenId || pos.token_id;
|
||||
const tid = pos.asset || pos.tokenId || pos.token_id;
|
||||
if (!cid || !tid) continue;
|
||||
if (!byCondition.has(cid)) byCondition.set(cid, []);
|
||||
byCondition.get(cid).push({
|
||||
tokenId: String(tid),
|
||||
size: parseFloat(pos.size || pos.currentValue || '0'),
|
||||
size: parseFloat(pos.size || pos.currentValue || '0'),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -629,7 +517,7 @@ export async function redeemMMPositions() {
|
||||
conditionId,
|
||||
[1, 2],
|
||||
]);
|
||||
await execSafeCall(CTF_ADDRESS, data, `redeemPositions ${label}`, { priority: false });
|
||||
await execSafeCall(CTF_ADDRESS, data, `redeemPositions ${label}`);
|
||||
logger.money(`MM redeemer: redeemed ${label} → ~$${expectedUsdc.toFixed(2)} USDC`);
|
||||
redeemed++;
|
||||
} catch (err) {
|
||||
@@ -641,191 +529,3 @@ export async function redeemMMPositions() {
|
||||
logger.success(`MM redeemer: collected ${redeemed} resolved position(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sniper-specific redeemer ──────────────────────────────────────────────────
|
||||
|
||||
// Track conditionIds that repeatedly fail or are losses to avoid retrying every cycle
|
||||
const _failedConditions = new Set();
|
||||
const _skippedLosses = new Set();
|
||||
|
||||
// Callback invoked when a win is detected — receives conditionId
|
||||
let _onWinCallback = null;
|
||||
|
||||
// Function to look up conditionId → { asset, yesTokenId, noTokenId }
|
||||
// Injected from sniper entry point to avoid circular imports
|
||||
let _getConditionInfo = null;
|
||||
|
||||
/**
|
||||
* Register a callback to be called when a sniper win is detected.
|
||||
* Callback signature: (conditionId: string) => void
|
||||
*/
|
||||
export function onSniperWin(cb) {
|
||||
_onWinCallback = cb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a function to look up sniper condition info (token mapping).
|
||||
* Used to correctly map token balances to outcome indices.
|
||||
*/
|
||||
export function setSniperConditionLookup(fn) {
|
||||
_getConditionInfo = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeem sniper positions via Gnosis Safe.
|
||||
* Only redeems WINNING positions — skip losses (they can be manually cleared).
|
||||
* Runs on interval only (no startup check) to catch new winners.
|
||||
*/
|
||||
export async function redeemSniperPositions() {
|
||||
// 1. Query Data API for all positions held by the proxy wallet
|
||||
let dataPositions = [];
|
||||
try {
|
||||
const resp = await proxyFetch(`${config.dataHost}/positions?user=${config.proxyWallet}`);
|
||||
if (!resp.ok) {
|
||||
logger.warn(`SNIPER redeemer: Data API returned ${resp.status} — will retry`);
|
||||
return;
|
||||
}
|
||||
dataPositions = await resp.json();
|
||||
if (!Array.isArray(dataPositions)) dataPositions = [];
|
||||
} catch (err) {
|
||||
logger.warn(`SNIPER redeemer: Data API fetch failed — ${err.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (dataPositions.length === 0) return;
|
||||
|
||||
const provider = await getPolygonProvider();
|
||||
const ctf = new ethers.Contract(CTF_ADDRESS, CTF_ABI, provider);
|
||||
const ctfIface = new ethers.utils.Interface(CTF_ABI);
|
||||
|
||||
// Group tokens by conditionId
|
||||
const byCondition = new Map();
|
||||
for (const pos of dataPositions) {
|
||||
const cid = pos.conditionId || pos.condition_id;
|
||||
const tid = pos.asset || pos.tokenId || pos.token_id;
|
||||
if (!cid || !tid) continue;
|
||||
if (!byCondition.has(cid)) byCondition.set(cid, []);
|
||||
byCondition.get(cid).push({
|
||||
tokenId: String(tid),
|
||||
size: parseFloat(pos.size || pos.currentValue || '0'),
|
||||
});
|
||||
}
|
||||
|
||||
let redeemed = 0;
|
||||
let skippedUnresolved = 0;
|
||||
let skippedLosses = 0;
|
||||
let skippedNoBalance = 0;
|
||||
|
||||
for (const [conditionId, tokens] of byCondition) {
|
||||
// Fast skip: conditionIds that previously failed on-chain or confirmed losses
|
||||
if (_failedConditions.has(conditionId)) continue;
|
||||
if (_skippedLosses.has(conditionId)) {
|
||||
skippedLosses++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Skip unresolved markets (fast check)
|
||||
const denominator = await ctf.payoutDenominator(conditionId);
|
||||
if (denominator.isZero()) {
|
||||
skippedUnresolved++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check actual on-chain token balances (positions API can lag)
|
||||
const balances = await Promise.all(
|
||||
tokens.map(({ tokenId }) =>
|
||||
ctf.balanceOf(config.proxyWallet, tokenId)
|
||||
.then((b) => parseFloat(ethers.utils.formatUnits(b, 6)))
|
||||
)
|
||||
);
|
||||
const totalShares = balances.reduce((a, b) => a + b, 0);
|
||||
if (totalShares < 0.001) {
|
||||
skippedNoBalance++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check outcome via payoutNumerators — which outcome index won?
|
||||
const payoutNums = await Promise.all(
|
||||
[0, 1].map((i) =>
|
||||
ctf.payoutNumerators(conditionId, i).then((n) => n.toNumber())
|
||||
)
|
||||
);
|
||||
const denom = denominator.toNumber();
|
||||
const payoutFractions = payoutNums.map((n) => n / denom);
|
||||
|
||||
// Determine winning outcome index (the one with payoutFraction > 0)
|
||||
const winningOutcome = payoutFractions[0] > 0 ? 0 : payoutFractions[1] > 0 ? 1 : -1;
|
||||
|
||||
const label = conditionId.slice(0, 12) + '...';
|
||||
|
||||
// Map token balances to outcome indices using sniper's token mapping.
|
||||
// yesTokenId = outcome 0 (clobTokenIds[0]), noTokenId = outcome 1
|
||||
const sniperInfo = _getConditionInfo ? _getConditionInfo(conditionId) : null;
|
||||
|
||||
// Build outcome→balance mapping (keyed by outcome index, not array index)
|
||||
const outcomeBalances = [0, 0];
|
||||
if (sniperInfo) {
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
if (tokens[i].tokenId === sniperInfo.yesTokenId) outcomeBalances[0] = balances[i];
|
||||
else if (tokens[i].tokenId === sniperInfo.noTokenId) outcomeBalances[1] = balances[i];
|
||||
}
|
||||
} else {
|
||||
// No sniper mapping — skip (not a sniper position)
|
||||
continue;
|
||||
}
|
||||
|
||||
// Win = we hold shares on the winning outcome side
|
||||
const winShares = winningOutcome >= 0 ? outcomeBalances[winningOutcome] : 0;
|
||||
const isWin = winShares > 0;
|
||||
const expectedUsdc = outcomeBalances.reduce(
|
||||
(sum, shares, i) => sum + shares * (payoutFractions[i] ?? 0), 0
|
||||
);
|
||||
|
||||
// SNIPER: only redeem WINNERS — cache losses to skip next time
|
||||
if (!isWin) {
|
||||
_skippedLosses.add(conditionId);
|
||||
if (config.dryRun) {
|
||||
logger.info(`SNIPER[SIM] skip loss: ${label} — outcome=${winningOutcome}, win_shares=0 (cached)`);
|
||||
} else {
|
||||
logger.info(`SNIPER redeemer: skip loss ${label} — outcome=${winningOutcome}, no shares on winner`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Track win for pause-after-win (notify via callback)
|
||||
if (_onWinCallback) _onWinCallback(conditionId);
|
||||
|
||||
if (config.dryRun) {
|
||||
logger.money(`SNIPER[SIM] redeem: ${label} — ${winShares.toFixed(3)} shares on outcome ${winningOutcome} → ~$${expectedUsdc.toFixed(2)} USDC (WIN)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.info(`SNIPER redeemer: ${label} resolved WIN — outcome ${winningOutcome}, ${winShares.toFixed(3)} shares → ~$${expectedUsdc.toFixed(2)} USDC`);
|
||||
|
||||
// Call redeemPositions through Safe — winners only
|
||||
const data = ctfIface.encodeFunctionData('redeemPositions', [
|
||||
USDC_ADDRESS,
|
||||
ethers.constants.HashZero,
|
||||
conditionId,
|
||||
[1, 2],
|
||||
]);
|
||||
const receipt = await execSafeCall(CTF_ADDRESS, data, `redeemPositions ${label}`, { priority: false });
|
||||
|
||||
logger.money(`SNIPER redeemer: redeemed ${label} → ~$${expectedUsdc.toFixed(2)} USDC ✅ | tx: ${receipt.transactionHash}`);
|
||||
redeemed++;
|
||||
} catch (err) {
|
||||
const friendly = parseOnchainError(err);
|
||||
logger.error(`SNIPER redeemer: failed ${conditionId.slice(0, 12)}... — ${friendly}`);
|
||||
// Don't retry this conditionId next cycle — it will keep failing
|
||||
_failedConditions.add(conditionId);
|
||||
logger.warn(`SNIPER redeemer: skipping ${conditionId.slice(0, 12)}... in future cycles`);
|
||||
}
|
||||
}
|
||||
|
||||
// Summary log
|
||||
const totalSkipped = skippedUnresolved + skippedLosses + skippedNoBalance + _skippedLosses.size + _failedConditions.size;
|
||||
if (redeemed > 0 || totalSkipped > 0) {
|
||||
logger.info(`SNIPER redeemer: ${redeemed} redeemed, ${_skippedLosses.size} losses cached, ${skippedUnresolved} unresolved skipped`);
|
||||
}
|
||||
}
|
||||
|
||||
+55
-248
@@ -1,37 +1,12 @@
|
||||
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 { getClient, getUsdcBalance } from './client.js';
|
||||
import { hasPosition, addPosition, getPosition, updatePosition, removePosition } from './position.js';
|
||||
import { fetchMarketByTokenId } from './watcher.js';
|
||||
import { placeAutoSell } from './autoSell.js';
|
||||
import { ensureExchangeApproval, CTF_ADDRESS } from './ctf.js';
|
||||
import { recordSimBuy } from '../utils/simStats.js';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
const CTF_ABI_BALANCE = ['function balanceOf(address account, uint256 id) view returns (uint256)'];
|
||||
|
||||
// Per-market buy queue: prevents concurrent buys for the same market.
|
||||
// Each conditionId maps to the Promise tail of its queue so calls are
|
||||
// chained — the next buy only starts after the previous one finishes.
|
||||
const _buyQueue = new Map();
|
||||
|
||||
/**
|
||||
* Fetch the actual on-chain ERC-1155 balance for a conditional token.
|
||||
* Returns shares as a plain float (6-decimal conversion).
|
||||
*/
|
||||
async function getOnChainTokenBalance(tokenId) {
|
||||
try {
|
||||
const provider = await getPolygonProvider();
|
||||
const ctf = new ethers.Contract(CTF_ADDRESS, CTF_ABI_BALANCE, provider);
|
||||
const raw = await ctf.balanceOf(config.proxyWallet, tokenId);
|
||||
return parseFloat(ethers.utils.formatUnits(raw, 6));
|
||||
} catch (err) {
|
||||
logger.warn(`Could not fetch on-chain token balance: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate trade size for our entry — independent of the individual fill event.
|
||||
*
|
||||
@@ -61,13 +36,10 @@ async function getMarketOptions(tokenId) {
|
||||
const marketInfo = await fetchMarketByTokenId(tokenId);
|
||||
if (marketInfo) {
|
||||
return {
|
||||
tickSize: String(marketInfo.orderPriceMinTickSize || '0.01'),
|
||||
negRisk: marketInfo.negRisk || false,
|
||||
conditionId: marketInfo.conditionId || '',
|
||||
question: marketInfo.question || '',
|
||||
endDateIso: marketInfo.endDate || null,
|
||||
active: marketInfo.active !== false,
|
||||
acceptingOrders: marketInfo.acceptingOrders !== false,
|
||||
tickSize: String(marketInfo.minimum_tick_size || '0.01'),
|
||||
negRisk: marketInfo.neg_risk || false,
|
||||
conditionId: marketInfo.condition_id || '',
|
||||
question: marketInfo.question || '',
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -78,128 +50,23 @@ async function getMarketOptions(tokenId) {
|
||||
try {
|
||||
const tickSize = await client.getTickSize(tokenId);
|
||||
const negRisk = await client.getNegRisk(tokenId);
|
||||
return { tickSize: String(tickSize), negRisk, conditionId: '', question: '', endDateIso: null, active: true, acceptingOrders: true };
|
||||
return { tickSize: String(tickSize), negRisk, conditionId: '', question: '' };
|
||||
} catch (err) {
|
||||
logger.warn('Failed to get tick size from SDK, using default 0.01');
|
||||
return { tickSize: '0.01', negRisk: false, conditionId: '', question: '', endDateIso: null, active: true, acceptingOrders: true };
|
||||
return { tickSize: '0.01', negRisk: false, conditionId: '', question: '' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a BUY trade (copy trader's buy).
|
||||
* Calls are serialized per market — concurrent events for the same market
|
||||
* are queued and processed one at a time to prevent duplicate positions.
|
||||
* Execute a BUY trade (copy trader's buy)
|
||||
* @param {Object} trade - Trade info from watcher
|
||||
*/
|
||||
export function executeBuy(trade) {
|
||||
const { tokenId, conditionId } = trade;
|
||||
|
||||
// Resolve conditionId to use as queue key.
|
||||
// getMarketOptions is a read-only fetch — safe to run outside the queue.
|
||||
const queued = getMarketOptions(tokenId).then((marketOpts) => {
|
||||
const effectiveConditionId = conditionId || marketOpts.conditionId;
|
||||
|
||||
// Chain this buy after the previous one for the same market
|
||||
const prev = _buyQueue.get(effectiveConditionId) ?? Promise.resolve();
|
||||
const current = prev
|
||||
.then(() => _doExecuteBuy(trade, marketOpts, effectiveConditionId))
|
||||
.finally(() => {
|
||||
// Remove from map only if we're still the tail (no newer call queued)
|
||||
if (_buyQueue.get(effectiveConditionId) === current) {
|
||||
_buyQueue.delete(effectiveConditionId);
|
||||
}
|
||||
});
|
||||
_buyQueue.set(effectiveConditionId, current);
|
||||
return current;
|
||||
});
|
||||
|
||||
return queued;
|
||||
}
|
||||
|
||||
/**
|
||||
* GTC fallback for when FAK finds no liquidity (e.g. trader buys into "next market"
|
||||
* before any sellers exist). Places a GTC limit order and polls until filled or timeout.
|
||||
*
|
||||
* Returns { sharesFilled, costFilled } on success, or null on failure/timeout.
|
||||
*/
|
||||
async function _tryGtcFallback(client, tokenId, tradeSize, price, marketOpts) {
|
||||
const gtcPrice = parseFloat(Math.min(price * 1.02, 0.99).toFixed(4));
|
||||
const shares = parseFloat((tradeSize / gtcPrice).toFixed(4));
|
||||
|
||||
logger.info(`No liquidity via FAK — placing GTC limit buy: ${shares} shares @ $${gtcPrice}`);
|
||||
|
||||
let orderId;
|
||||
try {
|
||||
const resp = await client.createAndPostOrder(
|
||||
{ tokenID: tokenId, side: Side.BUY, price: gtcPrice, size: shares },
|
||||
{ tickSize: marketOpts.tickSize, negRisk: marketOpts.negRisk },
|
||||
OrderType.GTC,
|
||||
);
|
||||
if (!resp?.success) {
|
||||
logger.warn(`GTC fallback rejected: ${resp?.errorMsg || 'unknown'}`);
|
||||
return null;
|
||||
}
|
||||
orderId = resp.orderID;
|
||||
logger.info(`GTC order placed: ${orderId} — waiting for fill (up to ${config.gtcFallbackTimeout}s)...`);
|
||||
} catch (err) {
|
||||
logger.warn(`GTC fallback order failed: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const deadline = Date.now() + config.gtcFallbackTimeout * 1000;
|
||||
const pollMs = 3000;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, pollMs));
|
||||
try {
|
||||
const order = await client.getOrder(orderId);
|
||||
const matched = parseFloat(order?.size_matched ?? order?.matched_amount ?? '0');
|
||||
const status = (order?.status ?? order?.order_status ?? '').toLowerCase();
|
||||
|
||||
if (matched > 0 || status === 'matched' || status === 'filled') {
|
||||
const sharesFilled = matched > 0 ? matched : shares;
|
||||
const costFilled = sharesFilled * gtcPrice;
|
||||
logger.success(`GTC filled: ${sharesFilled.toFixed(4)} shares @ $${gtcPrice} | orderID: ${orderId}`);
|
||||
return { sharesFilled, costFilled };
|
||||
}
|
||||
|
||||
// Order gone from open orders also means it was matched
|
||||
if (status === 'cancelled') {
|
||||
logger.warn(`GTC order ${orderId} was cancelled externally`);
|
||||
return null;
|
||||
}
|
||||
} catch { /* getOrder can 404 briefly — keep polling */ }
|
||||
}
|
||||
|
||||
// Timed out — cancel the GTC
|
||||
logger.warn(`GTC order ${orderId} not filled in ${config.gtcFallbackTimeout}s — cancelling`);
|
||||
try { await client.cancelOrder({ orderID: orderId }); } catch { /* ignore */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal: the actual buy logic, guaranteed to run serially per market.
|
||||
*/
|
||||
async function _doExecuteBuy(trade, marketOpts, effectiveConditionId) {
|
||||
export async function executeBuy(trade) {
|
||||
const { tokenId, conditionId, market, price, size } = trade;
|
||||
|
||||
// ── Market expiry guard ────────────────────────────────────────────────────
|
||||
if (!marketOpts.active || !marketOpts.acceptingOrders) {
|
||||
logger.warn(`Market closed/not accepting orders: ${market || effectiveConditionId} — skipping buy`);
|
||||
return;
|
||||
}
|
||||
if (marketOpts.endDateIso) {
|
||||
const secsLeft = (new Date(marketOpts.endDateIso).getTime() - Date.now()) / 1000;
|
||||
if (secsLeft < config.minMarketTimeLeft) {
|
||||
const minsLeft = Math.max(0, Math.floor(secsLeft / 60));
|
||||
const sLeft = Math.max(0, Math.floor(secsLeft % 60));
|
||||
logger.warn(
|
||||
`Market expires in ${minsLeft}m ${sLeft}s — below MIN_MARKET_TIME_LEFT ` +
|
||||
`(${config.minMarketTimeLeft}s). Skipping buy: ${market || effectiveConditionId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Get market options first to resolve conditionId
|
||||
const marketOpts = await getMarketOptions(tokenId);
|
||||
const effectiveConditionId = conditionId || marketOpts.conditionId;
|
||||
|
||||
// Check existing position and max position size cap
|
||||
const existingPos = getPosition(effectiveConditionId);
|
||||
@@ -223,11 +90,8 @@ async function _doExecuteBuy(trade, marketOpts, effectiveConditionId) {
|
||||
tradeSize = Math.min(tradeSize, config.maxPositionSize);
|
||||
}
|
||||
|
||||
// Polymarket enforces a hard $1 minimum per market order.
|
||||
const CLOB_MIN_ORDER_USDC = 1;
|
||||
const effectiveMin = Math.max(config.minTradeSize, CLOB_MIN_ORDER_USDC);
|
||||
if (tradeSize < effectiveMin) {
|
||||
logger.warn(`Trade size $${tradeSize.toFixed(2)} below $${effectiveMin} minimum — skipping buy`);
|
||||
if (tradeSize < config.minTradeSize) {
|
||||
logger.warn(`Trade size $${tradeSize.toFixed(2)} below minimum $${config.minTradeSize}. Skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -267,7 +131,7 @@ async function _doExecuteBuy(trade, marketOpts, effectiveConditionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Place market order (FAK) with retries
|
||||
// Place market order with retries
|
||||
const client = getClient();
|
||||
let filled = false;
|
||||
let totalSharesFilled = 0;
|
||||
@@ -276,63 +140,56 @@ async function _doExecuteBuy(trade, marketOpts, effectiveConditionId) {
|
||||
for (let attempt = 1; attempt <= config.maxRetries; attempt++) {
|
||||
try {
|
||||
const remainingAmount = tradeSize - totalCostFilled;
|
||||
if (remainingAmount < effectiveMin) {
|
||||
if (remainingAmount > 0) logger.info(`Remaining $${remainingAmount.toFixed(2)} below $${effectiveMin} minimum — stopping`);
|
||||
break;
|
||||
}
|
||||
if (remainingAmount < config.minTradeSize) break;
|
||||
|
||||
logger.info(`Buy attempt ${attempt}/${config.maxRetries} | Amount: $${remainingAmount.toFixed(2)}`);
|
||||
|
||||
// Use FAK (fill-and-kill) to get what's available, then retry remainder
|
||||
const response = await client.createAndPostMarketOrder(
|
||||
{
|
||||
tokenID: tokenId,
|
||||
side: Side.BUY,
|
||||
amount: remainingAmount,
|
||||
price: Math.min(price * 1.02, 0.99), // 2% slippage, max 0.99
|
||||
price: Math.min(price * 1.05, 0.99), // 5% slippage allowance, max 0.99
|
||||
},
|
||||
{
|
||||
tickSize: marketOpts.tickSize,
|
||||
negRisk: marketOpts.negRisk,
|
||||
},
|
||||
OrderType.FAK, // Fill-and-Kill: takes what's available, no full-fill requirement
|
||||
OrderType.FOK,
|
||||
);
|
||||
|
||||
if (response && response.success) {
|
||||
const sharesFilled = parseFloat(response.takingAmount || '0');
|
||||
const costFilled = parseFloat(response.makingAmount || '0');
|
||||
logger.success(`Order placed: ${response.orderID} | Status: ${response.status}`);
|
||||
|
||||
if (sharesFilled > 0) {
|
||||
logger.success(`Order filled: ${response.orderID} | ${sharesFilled.toFixed(4)} shares @ ~$${(costFilled / sharesFilled).toFixed(4)}`);
|
||||
totalSharesFilled += sharesFilled;
|
||||
totalCostFilled += costFilled || (sharesFilled * price);
|
||||
// Check if fully filled by trying to get trade info
|
||||
const takingAmount = parseFloat(response.takingAmount || '0');
|
||||
const makingAmount = parseFloat(response.makingAmount || '0');
|
||||
|
||||
if (takingAmount > 0 || makingAmount > 0) {
|
||||
totalSharesFilled += takingAmount || (remainingAmount / price);
|
||||
totalCostFilled += makingAmount || remainingAmount;
|
||||
filled = true;
|
||||
// If remainder is below $1 minimum, stop; otherwise loop for partial fill
|
||||
if (tradeSize - totalCostFilled < effectiveMin) break;
|
||||
break; // FOK either fills fully or cancels
|
||||
} else {
|
||||
logger.warn(`No liquidity — FAK filled 0 shares (attempt ${attempt})`);
|
||||
filled = true;
|
||||
totalSharesFilled = tradeSize / price;
|
||||
totalCostFilled = tradeSize;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
logger.warn(`Order rejected: ${response?.errorMsg || 'unknown'}`);
|
||||
logger.warn(`Order not filled. Error: ${response?.errorMsg || 'Unknown'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Buy attempt ${attempt} failed: ${err.message}`);
|
||||
logger.error(`Buy attempt ${attempt} failed:`, err.message);
|
||||
}
|
||||
|
||||
// Wait before retry
|
||||
if (attempt < config.maxRetries) {
|
||||
await new Promise((r) => setTimeout(r, config.retryDelay));
|
||||
}
|
||||
}
|
||||
|
||||
// FAK found no liquidity — fall back to GTC limit order and wait for fill
|
||||
if (!filled && config.gtcFallbackTimeout > 0) {
|
||||
const gtcResult = await _tryGtcFallback(client, tokenId, tradeSize, price, marketOpts);
|
||||
if (gtcResult) {
|
||||
totalSharesFilled = gtcResult.sharesFilled;
|
||||
totalCostFilled = gtcResult.costFilled;
|
||||
filled = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!filled || totalCostFilled === 0) {
|
||||
logger.error(`Failed to fill buy order for ${market || tokenId} after ${config.maxRetries} attempts`);
|
||||
return;
|
||||
@@ -364,13 +221,6 @@ async function _doExecuteBuy(trade, marketOpts, effectiveConditionId) {
|
||||
outcome: trade.outcome,
|
||||
});
|
||||
|
||||
// Ensure the CTF Exchange is approved to move our ERC-1155 tokens (needed for future sells)
|
||||
try {
|
||||
await ensureExchangeApproval(marketOpts.negRisk);
|
||||
} catch (err) {
|
||||
logger.warn(`Could not verify ERC-1155 approval: ${err.message}`);
|
||||
}
|
||||
|
||||
// Auto-sell only on initial entry, not on accumulation
|
||||
if (config.autoSellEnabled) {
|
||||
await placeAutoSell(effectiveConditionId, tokenId, totalSharesFilled, fillAvgPrice, marketOpts);
|
||||
@@ -413,29 +263,15 @@ export async function executeSell(trade) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel ALL open orders for this token so the CLOB frees up locked balance.
|
||||
// Only cancelling by sellOrderId is not enough — the cancel can fail silently
|
||||
// and locked tokens cause "not enough balance" on the subsequent sell.
|
||||
const client = getClient();
|
||||
try {
|
||||
const openOrders = await client.getOpenOrders({ asset_id: tokenId });
|
||||
if (Array.isArray(openOrders) && openOrders.length > 0) {
|
||||
logger.info(`Cancelling ${openOrders.length} open order(s) for token before sell`);
|
||||
await Promise.allSettled(
|
||||
openOrders.map((o) => client.cancelOrder({ orderID: o.id ?? o.order_id })),
|
||||
);
|
||||
// Brief pause so the CLOB can update the locked-balance ledger
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
// Cancel existing auto-sell order if any
|
||||
if (position.sellOrderId) {
|
||||
try {
|
||||
const client = getClient();
|
||||
await client.cancelOrder(position.sellOrderId);
|
||||
logger.info(`Cancelled auto-sell order: ${position.sellOrderId}`);
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to cancel auto-sell: ${err.message}`);
|
||||
}
|
||||
} catch (err) {
|
||||
// Fallback: try to cancel just the tracked auto-sell order ID
|
||||
if (position.sellOrderId) {
|
||||
try {
|
||||
await client.cancelOrder({ orderID: position.sellOrderId });
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
logger.warn(`Could not fetch open orders to cancel: ${err.message}`);
|
||||
}
|
||||
|
||||
updatePosition(effectiveConditionId, { status: 'selling' });
|
||||
@@ -444,64 +280,35 @@ export async function executeSell(trade) {
|
||||
marketOpts = await getMarketOptions(tokenId);
|
||||
}
|
||||
|
||||
// Ensure ERC-1155 approval so the exchange can transfer our tokens
|
||||
try {
|
||||
await ensureExchangeApproval(marketOpts.negRisk);
|
||||
} catch (err) {
|
||||
logger.warn(`Could not verify ERC-1155 approval: ${err.message}`);
|
||||
}
|
||||
|
||||
// Reconcile stored shares with actual on-chain balance to prevent "not enough balance" errors.
|
||||
// The stored amount can be higher than on-chain due to fee deductions or precision drift.
|
||||
const onChain = await getOnChainTokenBalance(tokenId);
|
||||
let sharesToSell = position.shares;
|
||||
if (onChain !== null) {
|
||||
if (onChain < 0.0001) {
|
||||
logger.warn(`On-chain balance is 0 for ${position.market} — position already sold or redeemed`);
|
||||
removePosition(effectiveConditionId);
|
||||
return;
|
||||
}
|
||||
if (onChain < sharesToSell) {
|
||||
logger.info(`Adjusting sell amount: stored ${sharesToSell.toFixed(6)} → on-chain ${onChain.toFixed(6)} shares`);
|
||||
sharesToSell = onChain;
|
||||
}
|
||||
}
|
||||
// Round down to 4 decimal places to avoid sub-unit precision errors
|
||||
sharesToSell = Math.floor(sharesToSell * 10000) / 10000;
|
||||
|
||||
const client = getClient();
|
||||
let filled = false;
|
||||
|
||||
for (let attempt = 1; attempt <= config.maxRetries; attempt++) {
|
||||
try {
|
||||
if (config.sellMode === 'market') {
|
||||
// Market sell (FAK) — takes what's available at 2% slippage
|
||||
logger.info(`Sell attempt ${attempt}/${config.maxRetries} (market) | Shares: ${sharesToSell}`);
|
||||
// Market sell (FOK)
|
||||
logger.info(`Sell attempt ${attempt}/${config.maxRetries} (market) | Shares: ${position.shares}`);
|
||||
|
||||
const response = await client.createAndPostMarketOrder(
|
||||
{
|
||||
tokenID: tokenId,
|
||||
side: Side.SELL,
|
||||
amount: sharesToSell,
|
||||
price: Math.max(price * 0.98, 0.01), // 2% slippage, min 0.01
|
||||
amount: position.shares,
|
||||
price: Math.max(price * 0.95, 0.01), // 5% slippage, min 0.01
|
||||
},
|
||||
{
|
||||
tickSize: marketOpts.tickSize,
|
||||
negRisk: marketOpts.negRisk,
|
||||
},
|
||||
OrderType.FAK, // Fill-and-Kill: takes what's available
|
||||
OrderType.FOK,
|
||||
);
|
||||
|
||||
if (response && response.success) {
|
||||
const sharesFilled = parseFloat(response.takingAmount || '0');
|
||||
if (sharesFilled > 0) {
|
||||
logger.success(`Sell filled: ${response.orderID} | ${sharesFilled.toFixed(4)} shares`);
|
||||
filled = true;
|
||||
break;
|
||||
} else {
|
||||
logger.warn(`No bid liquidity — FAK filled 0 shares (attempt ${attempt})`);
|
||||
}
|
||||
logger.success(`Sell order placed: ${response.orderID}`);
|
||||
filled = true;
|
||||
break;
|
||||
} else {
|
||||
logger.warn(`Sell rejected: ${response?.errorMsg || 'unknown'}`);
|
||||
logger.warn(`Sell not filled: ${response?.errorMsg || 'Unknown'}`);
|
||||
}
|
||||
} else {
|
||||
// Limit sell at trader's sell price
|
||||
@@ -511,7 +318,7 @@ export async function executeSell(trade) {
|
||||
{
|
||||
tokenID: tokenId,
|
||||
price: price,
|
||||
size: sharesToSell,
|
||||
size: position.shares,
|
||||
side: Side.SELL,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,935 +0,0 @@
|
||||
/**
|
||||
* makerRebateExecutor.js
|
||||
* Simplified Maker Rebate MM strategy:
|
||||
* 1. Fetch YES orderbook
|
||||
* 2. Deduce NO price from YES (YES + NO ≈ $1.00)
|
||||
* 3. Place BUY limit once on both sides (NO repricing)
|
||||
* 4. Wait for 100% fill with SAME share count on both sides
|
||||
* 5. Merge YES+NO → $1.00 USDC → profit + maker rebates
|
||||
*/
|
||||
|
||||
import { Side, OrderType } from '@polymarket/clob-client';
|
||||
import { ethers } from 'ethers';
|
||||
import config from '../config/index.js';
|
||||
import { getClient, getUsdcBalance, getPolygonProvider } from './client.js';
|
||||
import { mergePositions, redeemPositions } from './ctf.js';
|
||||
import { mmFillWatcher } from './mmWsFillWatcher.js';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
const CTF_ADDRESS = '0x4D97DCd97eC945f40cF65F87097ACe5EA0476045';
|
||||
const CTF_BALANCE_ABI = ['function balanceOf(address account, uint256 id) view returns (uint256)'];
|
||||
const CLOB_MIN_ORDER_SHARES = 5;
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// Price range limits — configurable via MAKER_MM_MIN_PRICE / MAKER_MM_MAX_PRICE
|
||||
// Both sides must be within this range to qualify for maker rebates
|
||||
const getMinPrice = () => config.makerMmMinPrice;
|
||||
const getMaxPrice = () => config.makerMmMaxPrice;
|
||||
|
||||
const activePositions = new Map();
|
||||
export function getActiveMakerPositions() {
|
||||
return Array.from(activePositions.values());
|
||||
}
|
||||
|
||||
// Export for use in maker-mm-bot.js
|
||||
export { getMarketOdds };
|
||||
|
||||
// ── Price helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
async function getRealPrice(tokenId) {
|
||||
const client = getClient();
|
||||
try {
|
||||
const result = await client.getPrice(tokenId, 'BUY');
|
||||
const price = parseFloat(result?.price ?? result ?? '0');
|
||||
if (price > 0 && price < 1) return price;
|
||||
} catch (err) {
|
||||
logger.warn(`MakerMM: getPrice error — ${err.message}`);
|
||||
}
|
||||
try {
|
||||
const mp = await client.getMidpoint(tokenId);
|
||||
const price = parseFloat(mp?.mid ?? mp ?? '0');
|
||||
if (price > 0 && price < 1) return price;
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
function roundToTick(price, tickSize) {
|
||||
const ts = parseFloat(tickSize);
|
||||
const rounded = Math.round(price / ts) * ts;
|
||||
const decimals = tickSize.toString().split('.')[1]?.length || 2;
|
||||
return Math.max(0.01, Math.min(0.99, parseFloat(rounded.toFixed(decimals))));
|
||||
}
|
||||
|
||||
// ── Get best ask via getPrice(SELL) — the lowest price a seller will accept ────
|
||||
// Used as a safety cap to ensure our bid never crosses the ask (taker prevention).
|
||||
async function getBestAsk(tokenId) {
|
||||
const client = getClient();
|
||||
try {
|
||||
const result = await client.getPrice(tokenId, 'SELL');
|
||||
const price = parseFloat(result?.price ?? result ?? '0');
|
||||
return (price > 0 && price < 1) ? price : null;
|
||||
} catch (err) {
|
||||
logger.warn(`MakerMM: getBestAsk error — ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bid-based repricing ───────────────────────────────────────────────────────
|
||||
// ── Get current market odds ──────────────────────────────────────────────────
|
||||
async function getMarketOdds(yesTokenId, noTokenId) {
|
||||
try {
|
||||
const [yesPrice, noPrice] = await Promise.all([
|
||||
getRealPrice(yesTokenId),
|
||||
getRealPrice(noTokenId),
|
||||
]);
|
||||
|
||||
if (yesPrice && noPrice) {
|
||||
return { yes: yesPrice, no: noPrice, max: Math.max(yesPrice, noPrice) };
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`MakerMM: getMarketOdds error — ${err.message}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Order helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check order status via CLOB API
|
||||
* Returns true if order is filled (even if createAndPostOrder returned false)
|
||||
*/
|
||||
async function checkOrderStatus(orderId) {
|
||||
if (!orderId || orderId.startsWith('filled-') || orderId.startsWith('sim-')) return null;
|
||||
|
||||
try {
|
||||
const client = getClient();
|
||||
const order = await client.getOrder(orderId);
|
||||
|
||||
// Order might be: OPEN, FILLED, PARTIAL_FILLED, CANCELLED, etc.
|
||||
if (order?.status === 'FILLED' || order?.status === 'FILLED_FULLY') {
|
||||
return 'filled';
|
||||
}
|
||||
if (order?.status === 'PARTIAL_FILLED' || order?.status === 'FILLED_PARTIALLY') {
|
||||
return 'partial';
|
||||
}
|
||||
if (order?.status === 'CANCELLED' || order?.status === 'CANCELLED_BY_USER' || order?.status === 'EXPIRED') {
|
||||
return 'cancelled';
|
||||
}
|
||||
if (order?.status === 'OPEN') {
|
||||
return 'open';
|
||||
}
|
||||
} catch (err) {
|
||||
// Order not found or API error - consider as unknown
|
||||
logger.debug(`MakerMM: order status check failed for ${orderId?.slice(-8)} — ${err.message}`);
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
// ── Market sell ───────────────────────────────────────────────────────────────
|
||||
// Verifies onchain balance after each attempt — CLOB fill confirmation alone is
|
||||
// not enough because sells can also be ghost-filled (CLOB says done, txhash invalid,
|
||||
// shares still in wallet). Retries up to 3 times with onchain verification.
|
||||
async function marketSellToken(tokenId, shares, tickSize, negRisk, tag) {
|
||||
if (config.dryRun) {
|
||||
logger.info(`MakerMM${tag}: [SIM] would market-sell ${shares.toFixed(4)} shares of token ${tokenId.slice(-8)}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
const client = getClient();
|
||||
const maxAttempts = 3;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
// Snapshot balance before sell — source of truth for whether it went through
|
||||
const balanceBefore = (await getTokenBalance(tokenId)) ?? 0;
|
||||
if (balanceBefore < 0.01) {
|
||||
logger.info(`MakerMM${tag}: sell skipped — balance already 0`);
|
||||
return true;
|
||||
}
|
||||
|
||||
const sharesToSell = Math.min(shares, balanceBefore);
|
||||
|
||||
let refPrice = 0.01;
|
||||
try {
|
||||
const bidResult = await client.getPrice(tokenId, 'BUY');
|
||||
const bid = parseFloat(bidResult?.price ?? bidResult ?? '0');
|
||||
if (bid > 0) refPrice = Math.max(bid * 0.97, 0.01);
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const response = await client.createAndPostMarketOrder(
|
||||
{ tokenID: tokenId, side: Side.SELL, amount: sharesToSell, price: refPrice },
|
||||
{ tickSize, negRisk },
|
||||
OrderType.FAK,
|
||||
);
|
||||
|
||||
if (!response?.success || parseFloat(response?.takingAmount || '0') === 0) {
|
||||
logger.warn(`MakerMM${tag}: sell attempt ${attempt}/${maxAttempts} — CLOB rejected (${response?.errorMsg || 'no liquidity'})`);
|
||||
await sleep(3000);
|
||||
continue;
|
||||
}
|
||||
|
||||
// CLOB says filled — wait then verify onchain balance actually decreased
|
||||
await sleep(8000);
|
||||
const balanceAfter = (await getTokenBalance(tokenId)) ?? balanceBefore;
|
||||
const sold = balanceBefore - balanceAfter;
|
||||
|
||||
if (sold >= sharesToSell * 0.5) {
|
||||
logger.money(`MakerMM${tag}: sold ${sold.toFixed(4)} shares @ ~$${refPrice.toFixed(3)} (attempt ${attempt})`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Balance unchanged → ghost sell, retry
|
||||
logger.warn(`MakerMM${tag}: sell attempt ${attempt}/${maxAttempts} ghost — CLOB filled but ${balanceAfter.toFixed(4)} shares still onchain, retrying...`);
|
||||
await sleep(5000 * attempt);
|
||||
|
||||
} catch (err) {
|
||||
logger.error(`MakerMM${tag}: sell attempt ${attempt}/${maxAttempts} error — ${err.message}`);
|
||||
await sleep(3000);
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn(`MakerMM${tag}: sell failed after ${maxAttempts} attempts — shares remain in wallet (will resolve at market close)`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Ghost fill recovery ───────────────────────────────────────────────────────
|
||||
// Onchain balance doesn't match what CLOB says was filled (partial or full ghost).
|
||||
// Strategy: merge whatever paired shares exist, then market-sell any unpaired remainder.
|
||||
// Handles all partial amounts — caller passes actual onchain balances.
|
||||
async function recoverFromGhostFill(pos, yesShares, noShares, tag) {
|
||||
logger.warn(
|
||||
`MakerMM${tag}: ghost fill recovery — onchain YES=${yesShares.toFixed(4)} NO=${noShares.toFixed(4)} ` +
|
||||
`(expected ${pos.targetShares} each)`
|
||||
);
|
||||
|
||||
const mergeable = Math.floor(Math.min(yesShares, noShares) * 10000) / 10000;
|
||||
let mergeRecovered = 0;
|
||||
|
||||
if (mergeable >= 1) {
|
||||
try {
|
||||
await mergePositions(pos.conditionId, mergeable, pos.negRisk);
|
||||
mergeRecovered = mergeable;
|
||||
logger.money(`MakerMM${tag}: ghost recovery merge ${mergeable.toFixed(4)} pairs → $${mergeRecovered.toFixed(2)}`);
|
||||
} catch (err) {
|
||||
logger.error(`MakerMM${tag}: ghost recovery merge failed — ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const yesRemainder = parseFloat(Math.max(0, yesShares - mergeable).toFixed(6));
|
||||
const noRemainder = parseFloat(Math.max(0, noShares - mergeable).toFixed(6));
|
||||
|
||||
// Only market-sell the CHEAP side remainder — expensive side is too costly to dump at market.
|
||||
// e.g. YES=83c filled, NO=15c ghost → hold YES (high cost basis, market sell = guaranteed loss).
|
||||
// NO=15c filled, YES=83c ghost → sell NO (cheap, small loss acceptable).
|
||||
const expSide = pos.yes.buyPrice >= pos.no.buyPrice ? 'yes' : 'no';
|
||||
|
||||
if (yesRemainder >= 1) {
|
||||
if (expSide === 'yes') {
|
||||
logger.warn(`MakerMM${tag}: ghost recovery — holding YES remainder ${yesRemainder.toFixed(4)} (expensive $${pos.yes.buyPrice}, scheduling redeem after resolution)`);
|
||||
pos.holdingSide = 'yes';
|
||||
} else {
|
||||
await marketSellToken(pos.yes.tokenId, yesRemainder, pos.tickSize, pos.negRisk, tag);
|
||||
}
|
||||
}
|
||||
if (noRemainder >= 1) {
|
||||
if (expSide === 'no') {
|
||||
logger.warn(`MakerMM${tag}: ghost recovery — holding NO remainder ${noRemainder.toFixed(4)} (expensive $${pos.no.buyPrice}, scheduling redeem after resolution)`);
|
||||
pos.holdingSide = 'no';
|
||||
} else {
|
||||
await marketSellToken(pos.no.tokenId, noRemainder, pos.tickSize, pos.negRisk, tag);
|
||||
}
|
||||
}
|
||||
|
||||
pos.totalProfit = mergeRecovered - (pos.yes.cost + pos.no.cost);
|
||||
// Don't mark done if holding expensive side — waitAndRedeem will close it out
|
||||
if (!pos.holdingSide) pos.status = 'done';
|
||||
}
|
||||
|
||||
async function placeLimitBuy(tokenId, shares, price, tickSize, negRisk) {
|
||||
if (config.dryRun) {
|
||||
return { success: true, orderId: `sim-buy-${Date.now()}-${tokenId.slice(-6)}` };
|
||||
}
|
||||
const client = getClient();
|
||||
try {
|
||||
const res = await client.createAndPostOrder(
|
||||
{ tokenID: tokenId, side: Side.BUY, price, size: shares },
|
||||
{ tickSize, negRisk },
|
||||
OrderType.GTC,
|
||||
);
|
||||
if (!res?.success) {
|
||||
// JSON.stringify can throw "Maximum call stack size exceeded" if res
|
||||
// contains a circular reference (e.g. axios/fetch response object).
|
||||
// Log only safe primitive fields instead.
|
||||
const errDetail = res?.errorMsg || res?.error || res?.message || 'unknown';
|
||||
logger.error(`MakerMM: limit buy failed — response: {"error":"${errDetail}","status":${res?.status ?? 'n/a'}}`);
|
||||
return { success: false };
|
||||
}
|
||||
return { success: true, orderId: res.orderID };
|
||||
} catch (err) {
|
||||
logger.error(`MakerMM: limit buy error — ${err.message}`);
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelOrder(orderId) {
|
||||
if (config.dryRun || !orderId || orderId.startsWith('sim-')) return true;
|
||||
try {
|
||||
const client = getClient();
|
||||
await client.cancelOrder({ orderID: orderId });
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.warn(`MakerMM: cancel error — ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fill detection ───────────────────────────────────────────────────────────
|
||||
|
||||
async function getTokenBalance(tokenId) {
|
||||
try {
|
||||
const provider = getPolygonProvider(); // singleton — no await needed
|
||||
const ctf = new ethers.Contract(CTF_ADDRESS, CTF_BALANCE_ABI, provider);
|
||||
const raw = await ctf.balanceOf(config.proxyWallet, tokenId);
|
||||
return parseFloat(ethers.utils.formatUnits(raw, 6));
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function waitForFillOrTimeout(tokenIds, timeoutMs) {
|
||||
return new Promise((resolve) => {
|
||||
let timer;
|
||||
const onFill = (event) => {
|
||||
if (tokenIds.includes(event.tokenId)) {
|
||||
clearTimeout(timer);
|
||||
mmFillWatcher.removeListener('fill', onFill);
|
||||
resolve(event);
|
||||
}
|
||||
};
|
||||
mmFillWatcher.on('fill', onFill);
|
||||
timer = setTimeout(() => {
|
||||
mmFillWatcher.removeListener('fill', onFill);
|
||||
resolve(null);
|
||||
}, timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Core monitoring ───────────────────────────────────────────────────────────
|
||||
|
||||
async function monitorUntilFilled(pos, tag, label) {
|
||||
mmFillWatcher.watch(pos.yes.tokenId);
|
||||
mmFillWatcher.watch(pos.no.tokenId);
|
||||
|
||||
// WS fill events: early signal only — onchain balance is the source of truth.
|
||||
// Side filter removed: RTDS may report side from taker perspective (SELL),
|
||||
// not our maker perspective. We're already gated by proxyWallet + tokenId.
|
||||
const onWsFill = (event) => {
|
||||
// WS is used only as a wake-up signal — do NOT set pos.filled here.
|
||||
// Setting filled=true from WS on a partial fill (e.g. 2 of 5 shares) would
|
||||
// make the loop think the side is done and skip the onchain balance check,
|
||||
// leaving the position stuck. Onchain balance is the sole source of truth.
|
||||
if (event.tokenId === pos.yes.tokenId) {
|
||||
logger.money(`MakerMM${tag}: YES fill signal (WS) ${event.size?.toFixed(2) || '?'} @ $${event.price?.toFixed(3) || pos.yes.buyPrice.toFixed(3)}`);
|
||||
}
|
||||
if (event.tokenId === pos.no.tokenId) {
|
||||
logger.money(`MakerMM${tag}: NO fill signal (WS) ${event.size?.toFixed(2) || '?'} @ $${event.price?.toFixed(3) || pos.no.buyPrice.toFixed(3)}`);
|
||||
}
|
||||
};
|
||||
mmFillWatcher.on('fill', onWsFill);
|
||||
|
||||
// Brief pause to let WebSocket register token subscriptions
|
||||
await sleep(50);
|
||||
|
||||
try {
|
||||
let fastFillCheckCount = 0;
|
||||
const maxFastChecks = 10; // 1s polling for first 10s
|
||||
|
||||
while (true) {
|
||||
// Safety guard: exit immediately if resolved by any path
|
||||
if (pos.status === 'done') return;
|
||||
|
||||
// ── Onchain balance — source of truth, checked FIRST ──────────────
|
||||
const [yesBal, noBal] = await Promise.all([
|
||||
getTokenBalance(pos.yes.tokenId),
|
||||
getTokenBalance(pos.no.tokenId),
|
||||
]);
|
||||
|
||||
// NET new shares only — subtract baseline to exclude leftover tokens
|
||||
// from previous cycles on the same tokenId. Without this, re-entry
|
||||
// would see old balance >= 0.5x target and trigger a false early merge
|
||||
// while the new orders are still open in the orderbook.
|
||||
// Use toFixed(6) — full precision to avoid rounding UP past actual token balance.
|
||||
// toFixed(4) could round 4.910199 → 4.9102 (4910200 wei) when Safe has 4910199 → revert.
|
||||
const yesShares = parseFloat(Math.max(0, (yesBal || 0) - pos.yes.baseline).toFixed(6));
|
||||
const noShares = parseFloat(Math.max(0, (noBal || 0) - pos.no.baseline).toFixed(6));
|
||||
|
||||
// Sync fill flags from onchain (source of truth)
|
||||
if (!pos.yes.filled && yesShares >= pos.targetShares * 0.99) {
|
||||
pos.yes.filled = true;
|
||||
logger.money(`MakerMM${tag}: YES filled (onchain) ${yesShares.toFixed(4)} shares`);
|
||||
}
|
||||
if (!pos.no.filled && noShares >= pos.targetShares * 0.99) {
|
||||
pos.no.filled = true;
|
||||
logger.money(`MakerMM${tag}: NO filled (onchain) ${noShares.toFixed(4)} shares`);
|
||||
}
|
||||
|
||||
// ── Cancel cheap side when expensive fills first ──────────────────────
|
||||
// When enabled: if the expensive side fills and cheap side hasn't,
|
||||
// cancel the cheap order and hold the expensive token to redeem at resolution.
|
||||
if (config.makerMmCancelCheapOnExpFill) {
|
||||
const expSide = pos.yes.buyPrice >= pos.no.buyPrice ? 'yes' : 'no';
|
||||
const cheapSide = expSide === 'yes' ? 'no' : 'yes';
|
||||
if (pos[expSide].filled && !pos[cheapSide].filled) {
|
||||
logger.info(
|
||||
`MakerMM${tag}: ${expSide.toUpperCase()} ($${pos[expSide].buyPrice}) filled first — ` +
|
||||
`cancelling cheap ${cheapSide.toUpperCase()} ($${pos[cheapSide].buyPrice}) order`
|
||||
);
|
||||
await cancelOrder(pos[cheapSide].orderId);
|
||||
pos.holdingSide = expSide;
|
||||
pos.status = 'holding';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Over-position safety net ────────────────────────────────────────
|
||||
// If one side's balance is > 1.5x target AND the current order is still open,
|
||||
// a double-fill occurred (old cancelled order + new order both filled).
|
||||
// Cancel the open order immediately so it doesn't also fill.
|
||||
if (yesShares > pos.targetShares * 1.5 && pos.yes.orderId && !pos.yes.filled) {
|
||||
logger.warn(`MakerMM${tag}: YES over-position (${yesShares.toFixed(4)} > 1.5x target=${pos.targetShares}) — cancelling open order to stop double-fill`);
|
||||
await cancelOrder(pos.yes.orderId);
|
||||
pos.yes.filled = true;
|
||||
if (!pos.firstFillTime) pos.firstFillTime = Date.now();
|
||||
}
|
||||
if (noShares > pos.targetShares * 1.5 && pos.no.orderId && !pos.no.filled) {
|
||||
logger.warn(`MakerMM${tag}: NO over-position (${noShares.toFixed(4)} > 1.5x target=${pos.targetShares}) — cancelling open order to stop double-fill`);
|
||||
await cancelOrder(pos.no.orderId);
|
||||
pos.no.filled = true;
|
||||
if (!pos.firstFillTime) pos.firstFillTime = Date.now();
|
||||
}
|
||||
|
||||
// ── Ghost fill detection via open orders check ────────────────────────
|
||||
// More reliable than checkOrderStatus(orderId) which can return 'unknown'
|
||||
// for ghost fills (invalid txhash → CLOB state is inconsistent).
|
||||
// If our order is gone from open orders but onchain balance didn't increase
|
||||
// → order was matched in CLOB but settlement failed (ghost fill).
|
||||
{
|
||||
const nowMs = Date.now();
|
||||
const client = getClient();
|
||||
|
||||
if (!pos.yes.filled && !pos.yes.clobFilled && pos.yes.orderId && nowMs - (pos.yes.lastClobCheck || 0) >= 15_000) {
|
||||
pos.yes.lastClobCheck = nowMs;
|
||||
try {
|
||||
const openOrders = await client.getOpenOrders({ asset_id: pos.yes.tokenId });
|
||||
const stillOpen = Array.isArray(openOrders) && openOrders.some(o => (o.id ?? o.order_id) === pos.yes.orderId);
|
||||
if (!stillOpen) {
|
||||
pos.yes.clobFilled = true;
|
||||
logger.info(`MakerMM${tag}: YES order gone from CLOB open orders (onchain not yet reflected)`);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
if (!pos.no.filled && !pos.no.clobFilled && pos.no.orderId && nowMs - (pos.no.lastClobCheck || 0) >= 15_000) {
|
||||
pos.no.lastClobCheck = nowMs;
|
||||
try {
|
||||
const openOrders = await client.getOpenOrders({ asset_id: pos.no.tokenId });
|
||||
const stillOpen = Array.isArray(openOrders) && openOrders.some(o => (o.id ?? o.order_id) === pos.no.orderId);
|
||||
if (!stillOpen) {
|
||||
pos.no.clobFilled = true;
|
||||
logger.info(`MakerMM${tag}: NO order gone from CLOB open orders (onchain not yet reflected)`);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Ghost fill detection:
|
||||
// CLOB says order is FILLED but onchain balance < expected after timeout.
|
||||
// Could be full ghost (0 tokens) or partial (some tokens, but not all).
|
||||
// Trigger: either side clobFilled AND onchain short of target after 60s.
|
||||
const yesGhost = pos.yes.clobFilled && yesShares < pos.targetShares * 0.99;
|
||||
const noGhost = pos.no.clobFilled && noShares < pos.targetShares * 0.99;
|
||||
|
||||
if (yesGhost || noGhost) {
|
||||
if (!pos.ghostFillSince) pos.ghostFillSince = nowMs;
|
||||
const waitedSec = Math.round((nowMs - pos.ghostFillSince) / 1000);
|
||||
if (waitedSec >= 30) {
|
||||
// 30s is enough to distinguish settlement delay from ghost fill.
|
||||
// Act now while market prices are still fair — don't wait for cut-loss.
|
||||
await recoverFromGhostFill(pos, yesShares, noShares, tag);
|
||||
return;
|
||||
} else {
|
||||
logger.info(
|
||||
`MakerMM${tag}: ghost fill suspected ` +
|
||||
`(YES CLOB=${pos.yes.clobFilled} onchain=${yesShares.toFixed(4)}, ` +
|
||||
`NO CLOB=${pos.no.clobFilled} onchain=${noShares.toFixed(4)}) ` +
|
||||
`— waiting ${waitedSec}s / 30s`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── WS fallback: both sides WS-confirmed filled but onchain RPC not reflecting ──
|
||||
// If onchain balance is unavailable (RPC slow/failed) but both filled flags are
|
||||
// set from WS signals, wait a grace period then merge with targetShares as fallback.
|
||||
if (pos.yes.filled && pos.no.filled && yesShares < pos.targetShares * 0.5 && noShares < pos.targetShares * 0.5) {
|
||||
if (!pos.bothFilledSince) pos.bothFilledSince = Date.now();
|
||||
const waitedSec = Math.round((Date.now() - pos.bothFilledSince) / 1000);
|
||||
if (waitedSec >= 15) {
|
||||
logger.warn(
|
||||
`MakerMM${tag}: both sides WS-filled but onchain shows YES=${yesShares} NO=${noShares} after ${waitedSec}s ` +
|
||||
`— RPC may be stale, merging with target ${pos.targetShares} shares`
|
||||
);
|
||||
await executeMerge(pos, pos.targetShares, tag);
|
||||
if (pos.status === 'done') return;
|
||||
} else {
|
||||
logger.info(`MakerMM${tag}: both WS-filled, waiting for onchain confirmation (${waitedSec}s / 15s grace)...`);
|
||||
}
|
||||
}
|
||||
|
||||
// Both sides have net balance ≥ 50% target → merge
|
||||
if (yesShares >= pos.targetShares * 0.5 && noShares >= pos.targetShares * 0.5) {
|
||||
pos.bothFilledSince = null; // onchain confirmed — clear WS fallback timer
|
||||
const minShares = Math.min(yesShares, noShares);
|
||||
const isFull = yesShares >= pos.targetShares * 0.99 && noShares >= pos.targetShares * 0.99;
|
||||
logger.success(
|
||||
`MakerMM${tag}: ${isFull ? 'FULL' : 'PARTIAL'} fill — ` +
|
||||
`YES=${yesShares.toFixed(4)} NO=${noShares.toFixed(4)}, merging ${minShares.toFixed(4)} shares`
|
||||
);
|
||||
pos.yes.filled = true;
|
||||
pos.no.filled = true;
|
||||
await executeMerge(pos, minShares, tag);
|
||||
if (pos.status === 'done') return;
|
||||
|
||||
// Merge call errored — but tx may have confirmed onchain despite the RPC error
|
||||
// (common: tx.wait() timeout while tx was already included in a block).
|
||||
// Re-check balance to avoid looping forever on an empty position.
|
||||
const [yesRecheck, noRecheck] = await Promise.all([
|
||||
getTokenBalance(pos.yes.tokenId),
|
||||
getTokenBalance(pos.no.tokenId),
|
||||
]);
|
||||
const yesNetRecheck = Math.max(0, (yesRecheck || 0) - pos.yes.baseline);
|
||||
const noNetRecheck = Math.max(0, (noRecheck || 0) - pos.no.baseline);
|
||||
if (yesNetRecheck < pos.targetShares * 0.1 && noNetRecheck < pos.targetShares * 0.1) {
|
||||
logger.success(`MakerMM${tag}: merge confirmed onchain (RPC reported error but tx went through)`);
|
||||
pos.status = 'done';
|
||||
pos.totalProfit = minShares - (pos.yes.cost + pos.no.cost);
|
||||
return;
|
||||
}
|
||||
pos.mergeFailCount = (pos.mergeFailCount || 0) + 1;
|
||||
const backoffSec = Math.min(5 * pos.mergeFailCount, 30); // 5s, 10s, 15s … max 30s
|
||||
logger.warn(`MakerMM${tag}: merge failed (attempt ${pos.mergeFailCount}) — tokens still present (YES=${yesNetRecheck.toFixed(6)} NO=${noNetRecheck.toFixed(6)}), retrying in ${backoffSec}s`);
|
||||
await sleep(backoffSec * 1000);
|
||||
}
|
||||
|
||||
// ── Cut-loss check (AFTER balance check) ──────────────────────────
|
||||
const msRemaining = new Date(pos.endTime).getTime() - Date.now();
|
||||
if (msRemaining <= config.makerMmCutLossTime * 1000) {
|
||||
logger.warn(`MakerMM${tag}: cut-loss — net YES=${yesShares.toFixed(4)} NO=${noShares.toFixed(4)}`);
|
||||
|
||||
if (yesShares >= 1 && noShares >= 1) {
|
||||
// Both sides have net fills — emergency merge to recover USDC
|
||||
const minShares = Math.min(yesShares, noShares);
|
||||
logger.warn(`MakerMM${tag}: emergency merge ${minShares.toFixed(4)} shares`);
|
||||
await executeMerge(pos, minShares, tag);
|
||||
} else {
|
||||
// One or neither side net-filled — cancel open orders, log held tokens
|
||||
await Promise.all([
|
||||
cancelOrder(pos.yes.orderId),
|
||||
cancelOrder(pos.no.orderId),
|
||||
]);
|
||||
if (yesShares > 0 || noShares > 0) {
|
||||
logger.warn(`MakerMM${tag}: tokens held — net YES=${yesShares.toFixed(4)} NO=${noShares.toFixed(4)} (cannot merge)`);
|
||||
pos.totalProfit = -((yesShares > 0 ? pos.yes.cost : 0) + (noShares > 0 ? pos.no.cost : 0));
|
||||
pos.oneSided = true; // flag: cycle ended with one-sided fill
|
||||
} else {
|
||||
logger.info(`MakerMM${tag}: no net fills — orders cancelled, zero loss`);
|
||||
pos.totalProfit = 0;
|
||||
}
|
||||
pos.status = 'done';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ── One side filled — log status and keep waiting ─────────────────
|
||||
if (pos.yes.filled !== pos.no.filled) {
|
||||
const filledKey = pos.yes.filled ? 'yes' : 'no';
|
||||
const now = Date.now();
|
||||
|
||||
if (now < pos.marketOpenTime) {
|
||||
logger.info(`MakerMM${tag}: ${filledKey.toUpperCase()} filled — market not open yet (${Math.round((pos.marketOpenTime - now) / 1000)}s), waiting...`);
|
||||
} else {
|
||||
if (!pos.firstFillTime) {
|
||||
pos.firstFillTime = now;
|
||||
logger.info(`MakerMM${tag}: ${filledKey.toUpperCase()} filled first — waiting for other side...`);
|
||||
} else {
|
||||
const elapsedMin = Math.floor((now - pos.firstFillTime) / 60000);
|
||||
if (elapsedMin > 0 && elapsedMin % 5 === 0 && pos.lastLogMin !== elapsedMin) {
|
||||
pos.lastLogMin = elapsedMin;
|
||||
logger.info(`MakerMM${tag}: still waiting for ${filledKey === 'yes' ? 'NO' : 'YES'} — ${elapsedMin}m elapsed`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fast polling first 10s, then event-driven with 5s fallback
|
||||
fastFillCheckCount++;
|
||||
if (fastFillCheckCount < maxFastChecks) {
|
||||
await sleep(1000);
|
||||
} else {
|
||||
await waitForFillOrTimeout([pos.yes.tokenId, pos.no.tokenId], 5000);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
mmFillWatcher.removeListener('fill', onWsFill);
|
||||
mmFillWatcher.unwatch(pos.yes.tokenId);
|
||||
mmFillWatcher.unwatch(pos.no.tokenId);
|
||||
|
||||
// Cancel any residual open orders — can happen when loss-compensating reprice
|
||||
// placed extra shares (e.g. 6 NO) but merge triggered after 5 filled,
|
||||
// leaving 1 remaining NO share still open in the orderbook.
|
||||
await Promise.all([
|
||||
cancelOrder(pos.yes.orderId),
|
||||
cancelOrder(pos.no.orderId),
|
||||
]).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function executeMerge(pos, shares, tag) {
|
||||
const totalCost = pos.yes.cost + pos.no.cost;
|
||||
const recovered = shares; // Merge returns $1 per share
|
||||
pos.totalProfit = recovered - totalCost;
|
||||
|
||||
try {
|
||||
await mergePositions(pos.conditionId, shares, pos.negRisk);
|
||||
|
||||
// Orders are already fully filled at this point — no cancel needed
|
||||
logger.money(`MakerMM${tag}: MERGED ${shares.toFixed(4)} shares → $${recovered.toFixed(2)} | cost $${totalCost.toFixed(2)} | P&L $${pos.totalProfit.toFixed(2)}`);
|
||||
pos.status = 'done';
|
||||
} catch (err) {
|
||||
logger.error(`MakerMM${tag}: merge failed — ${err.message}`);
|
||||
// Don't change status — let monitor loop continue
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auto-redeem after market resolution ──────────────────────────────────────
|
||||
// Used when holding a single-sided position (expensive side filled, cheap cancelled).
|
||||
// Polls until the market resolves on-chain, then calls redeemPositions.
|
||||
|
||||
async function waitAndRedeem(pos, tag) {
|
||||
const endMs = new Date(pos.endTime).getTime();
|
||||
const waitForEndMs = endMs - Date.now();
|
||||
|
||||
if (waitForEndMs > 0) {
|
||||
logger.info(`MakerMM${tag}: holding ${pos.holdingSide.toUpperCase()} — waiting ${Math.round(waitForEndMs / 1000)}s for market to end...`);
|
||||
await sleep(waitForEndMs);
|
||||
}
|
||||
|
||||
if (config.dryRun) {
|
||||
logger.info(`MakerMM${tag}: [SIM] would redeem ${pos.holdingSide.toUpperCase()} after resolution`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`MakerMM${tag}: market ended — polling for on-chain resolution...`);
|
||||
const provider = getPolygonProvider();
|
||||
const ctf = new ethers.Contract(CTF_ADDRESS, ['function payoutDenominator(bytes32 conditionId) view returns (uint256)'], provider);
|
||||
const maxWaitMs = 10 * 60 * 1000; // 10 minutes max
|
||||
const pollMs = 15_000;
|
||||
const start = Date.now();
|
||||
|
||||
while (Date.now() - start < maxWaitMs) {
|
||||
try {
|
||||
const denom = await ctf.payoutDenominator(pos.conditionId);
|
||||
if (!denom.isZero()) {
|
||||
logger.info(`MakerMM${tag}: market resolved — redeeming ${pos.holdingSide.toUpperCase()} tokens...`);
|
||||
await redeemPositions(pos.conditionId, pos.negRisk);
|
||||
logger.money(`MakerMM${tag}: redemption complete`);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`MakerMM${tag}: resolution poll error — ${err.message}`);
|
||||
}
|
||||
const elapsedSec = Math.round((Date.now() - start) / 1000);
|
||||
logger.info(`MakerMM${tag}: not resolved yet (${elapsedSec}s / ${maxWaitMs / 1000}s) — retrying in ${pollMs / 1000}s...`);
|
||||
await sleep(pollMs);
|
||||
}
|
||||
|
||||
logger.warn(`MakerMM${tag}: market not resolved after ${maxWaitMs / 60000} minutes — skipping auto-redeem (tokens remain in wallet)`);
|
||||
}
|
||||
|
||||
// ── Main entry ───────────────────────────────────────────────────────────────
|
||||
|
||||
export async function executeMakerRebateStrategy(market) {
|
||||
const { asset, conditionId, question, endTime, eventStartTime, yesTokenId, noTokenId, negRisk, tickSize } = market;
|
||||
const tag = asset ? `[${asset.toUpperCase()}]` : '';
|
||||
const label = question.substring(0, 40);
|
||||
const sim = config.dryRun ? '[SIM] ' : '';
|
||||
|
||||
// Market officially opens at eventStartTime (not when we detect it)
|
||||
const marketOpenTime = eventStartTime ? new Date(eventStartTime).getTime() : Date.now();
|
||||
|
||||
// Wait until 10 seconds after market open before placing any orders.
|
||||
// Orders placed too early (pre-open or first few seconds) tend to open at a loss
|
||||
// due to wide spreads and erratic pricing before liquidity stabilizes.
|
||||
const ENTRY_DELAY_MS = 10_000;
|
||||
const entryNotBefore = marketOpenTime + ENTRY_DELAY_MS;
|
||||
const waitMs = entryNotBefore - Date.now();
|
||||
if (waitMs > 0) {
|
||||
logger.info(`MakerMM${tag}: ${sim}waiting ${Math.round(waitMs / 1000)}s for market to stabilize (open +10s)...`);
|
||||
await sleep(waitMs);
|
||||
}
|
||||
|
||||
logger.info(`MakerMM${tag}: ${sim}entering — ${label}`);
|
||||
|
||||
// ── Wait for real YES price ─────────────────────────────────
|
||||
const POLL_SEC = config.makerMmPollSec;
|
||||
const ts = parseFloat(tickSize);
|
||||
|
||||
let yesBid, noBid, combined;
|
||||
let yesEntryBid, noEntryBid; // best bid at time of entry — stored for drift tracking
|
||||
const waitStart = Date.now();
|
||||
const MIN_PRICE = getMinPrice();
|
||||
const MAX_PRICE = getMaxPrice();
|
||||
|
||||
while (true) {
|
||||
const msRemaining = new Date(endTime).getTime() - Date.now();
|
||||
if (msRemaining <= config.makerMmCutLossTime * 1000) {
|
||||
logger.warn(`MakerMM${tag}: market closing — aborting`);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Bid-based pricing: bid = bestBid + 1_tick (top of orderbook, guaranteed maker) ──
|
||||
// We become the new top bid, getting fill priority over existing bids.
|
||||
// Safety cap: newBid < bestAsk ensures we never accidentally cross and become a taker.
|
||||
const [yesBestBid, yesAsk, noBestBid, noAsk] = await Promise.all([
|
||||
getRealPrice(yesTokenId),
|
||||
getBestAsk(yesTokenId),
|
||||
getRealPrice(noTokenId),
|
||||
getBestAsk(noTokenId),
|
||||
]);
|
||||
|
||||
if (!yesBestBid || !noBestBid) {
|
||||
logger.info(`MakerMM${tag}: waiting — no bid data (YES: ${yesBestBid ?? 'null'}, NO: ${noBestBid ?? 'null'})`);
|
||||
await sleep(POLL_SEC * 1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Auto-detect cheap side: whichever of YES/NO has lower bestBid.
|
||||
// Range filter (MIN_PRICE/MAX_PRICE) applies to the cheap side only.
|
||||
// The expensive side is derived from: maxCombined - cheapBid.
|
||||
const cheapSide = yesBestBid <= noBestBid ? 'yes' : 'no';
|
||||
const cheapBestBid = cheapSide === 'yes' ? yesBestBid : noBestBid;
|
||||
const cheapAsk = cheapSide === 'yes' ? yesAsk : noAsk;
|
||||
|
||||
let cheapBid = roundToTick(cheapBestBid + ts, tickSize);
|
||||
// Cap to ask - 2 ticks (not 1) to absorb timing race between fetch and place.
|
||||
// A 1-tick buffer still lets the ask move 1 tick before our order is submitted,
|
||||
// turning it into a marketable (taker) order and hitting the $1 minimum.
|
||||
if (cheapAsk && cheapBid >= cheapAsk - ts) cheapBid = roundToTick(cheapAsk - 2 * ts, tickSize);
|
||||
|
||||
// Range check on cheap side
|
||||
if (cheapBid < MIN_PRICE || cheapBid > MAX_PRICE) {
|
||||
logger.info(`MakerMM${tag}: waiting — ${cheapSide.toUpperCase()} bid $${cheapBid.toFixed(3)} (need ${MIN_PRICE}-${MAX_PRICE})`);
|
||||
await sleep(POLL_SEC * 1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Expensive side: fill remaining combined budget
|
||||
const expensiveBid = roundToTick(config.makerMmMaxCombined - cheapBid, tickSize);
|
||||
const expensiveAsk = cheapSide === 'yes' ? noAsk : yesAsk;
|
||||
let expBid = expensiveBid;
|
||||
if (expensiveAsk && expBid >= expensiveAsk - ts) expBid = roundToTick(expensiveAsk - 2 * ts, tickSize);
|
||||
|
||||
if (expBid <= 0 || expBid >= 1) {
|
||||
logger.info(`MakerMM${tag}: waiting — ${cheapSide === 'yes' ? 'NO' : 'YES'} bid $${expBid.toFixed(3)} out of bounds`);
|
||||
await sleep(POLL_SEC * 1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Map back to yes/no
|
||||
yesBid = cheapSide === 'yes' ? cheapBid : expBid;
|
||||
noBid = cheapSide === 'yes' ? expBid : cheapBid;
|
||||
|
||||
combined = parseFloat((yesBid + noBid).toFixed(4));
|
||||
|
||||
if (combined > config.makerMmMaxCombined) {
|
||||
logger.info(`MakerMM${tag}: combined $${combined.toFixed(4)} > max — waiting`);
|
||||
await sleep(POLL_SEC * 1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
// If combined is more than 1 tick below target the market spread is too tight.
|
||||
// Wait for better conditions instead of entering with lower-than-expected profit.
|
||||
const minCombined = parseFloat((config.makerMmMaxCombined - ts).toFixed(4));
|
||||
if (combined < minCombined) {
|
||||
logger.info(`MakerMM${tag}: spread too tight — combined $${combined.toFixed(4)} < target $${config.makerMmMaxCombined} — waiting`);
|
||||
await sleep(POLL_SEC * 1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
yesEntryBid = yesBestBid;
|
||||
noEntryBid = noBestBid;
|
||||
|
||||
const waitSec = ((Date.now() - waitStart) / 1000).toFixed(1);
|
||||
logger.success(`MakerMM${tag}: ready after ${waitSec}s — YES $${yesBid} + NO $${noBid} = $${combined.toFixed(4)} (topBid YES:$${yesBestBid} NO:$${noBestBid})`);
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Calculate shares ──────────────────────────────────────────
|
||||
const targetShares = config.makerMmTradeSize;
|
||||
|
||||
if (targetShares < CLOB_MIN_ORDER_SHARES) {
|
||||
logger.warn(`MakerMM${tag}: shares ${targetShares} < min ${CLOB_MIN_ORDER_SHARES} — skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
const yesCost = targetShares * yesBid;
|
||||
const noCost = targetShares * noBid;
|
||||
const totalCost = yesCost + noCost;
|
||||
|
||||
if (!config.dryRun) {
|
||||
const balance = await getUsdcBalance();
|
||||
if (balance < totalCost) {
|
||||
logger.error(`MakerMM${tag}: insufficient balance $${balance.toFixed(2)} (need $${totalCost.toFixed(2)})`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Snapshot balance BEFORE placing orders ────────────────────────────────
|
||||
// Critical for re-entry: same tokenIds are reused each cycle, so leftover
|
||||
// tokens from a previous cycle would otherwise fool the fill-detection logic
|
||||
// into thinking the new orders filled instantly, causing a new cycle to start
|
||||
// while the actual new orders remain open in the orderbook.
|
||||
const [yesBaseline, noBaseline] = await Promise.all([
|
||||
getTokenBalance(yesTokenId),
|
||||
getTokenBalance(noTokenId),
|
||||
]);
|
||||
if ((yesBaseline || 0) > 0 || (noBaseline || 0) > 0) {
|
||||
logger.info(`MakerMM${tag}: pre-order baseline — YES=${(yesBaseline || 0).toFixed(4)} NO=${(noBaseline || 0).toFixed(4)} (leftover from prior cycle)`);
|
||||
}
|
||||
|
||||
// ── Place orders ONCE (NO repricing) ──────────────────────
|
||||
logger.trade(`MakerMM${tag}: placing BUY — YES $${yesBid} × ${targetShares} + NO $${noBid} × ${targetShares} = $${totalCost.toFixed(2)}`);
|
||||
|
||||
const [yesBuy, noBuy] = await Promise.all([
|
||||
placeLimitBuy(yesTokenId, targetShares, yesBid, tickSize, negRisk),
|
||||
placeLimitBuy(noTokenId, targetShares, noBid, tickSize, negRisk),
|
||||
]);
|
||||
|
||||
logger.info(`MakerMM${tag}: order results — YES: ${yesBuy.success ? 'OK' : 'FAIL'} (id=${yesBuy.orderId?.slice(-8) || 'none'}), NO: ${noBuy.success ? 'OK' : 'FAIL'} (id=${noBuy.orderId?.slice(-8) || 'none'})`);
|
||||
|
||||
// If one side failed, check if actually filled on-chain OR via order book before retrying
|
||||
let finalYesBuy = yesBuy;
|
||||
let finalNoBuy = noBuy;
|
||||
const maxRetries = 3;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries && (!finalYesBuy.success || !finalNoBuy.success); attempt++) {
|
||||
// Check 1: On-chain balance (most reliable) — compare against baseline
|
||||
const [yesBalance, noBalance] = await Promise.all([
|
||||
getTokenBalance(yesTokenId),
|
||||
getTokenBalance(noTokenId),
|
||||
]);
|
||||
const yesNet = (yesBalance || 0) - (yesBaseline || 0);
|
||||
const noNet = (noBalance || 0) - (noBaseline || 0);
|
||||
|
||||
// Check 2: Order status via CLOB API (backup check)
|
||||
const [yesOrderStatus, noOrderStatus] = await Promise.all([
|
||||
finalYesBuy.success ? null : checkOrderStatus(yesBuy.orderId),
|
||||
finalNoBuy.success ? null : checkOrderStatus(noBuy.orderId),
|
||||
]);
|
||||
|
||||
if (yesOrderStatus || noOrderStatus) {
|
||||
logger.info(`MakerMM${tag}: order status check — YES: ${yesOrderStatus || 'N/A'}, NO: ${noOrderStatus || 'N/A'}`);
|
||||
}
|
||||
|
||||
// Use net (new) balance to determine if actually filled — not total balance
|
||||
if (!finalYesBuy.success && (
|
||||
yesNet >= targetShares * 0.5 ||
|
||||
yesOrderStatus === 'filled' ||
|
||||
yesOrderStatus === 'partial'
|
||||
)) {
|
||||
logger.success(`MakerMM${tag}: YES already filled (net: ${yesNet.toFixed(4)}, order: ${yesOrderStatus}) — no retry`);
|
||||
finalYesBuy = { success: true, orderId: yesBuy.orderId || `filled-${Date.now()}` };
|
||||
}
|
||||
|
||||
if (!finalNoBuy.success && (
|
||||
noNet >= targetShares * 0.5 ||
|
||||
noOrderStatus === 'filled' ||
|
||||
noOrderStatus === 'partial'
|
||||
)) {
|
||||
logger.success(`MakerMM${tag}: NO already filled (net: ${noNet.toFixed(4)}, order: ${noOrderStatus}) — no retry`);
|
||||
finalNoBuy = { success: true, orderId: noBuy.orderId || `filled-${Date.now()}` };
|
||||
}
|
||||
|
||||
if (finalYesBuy.success && finalNoBuy.success) break;
|
||||
|
||||
// Cancel existing order before retry to avoid duplicate orders
|
||||
if (!finalYesBuy.success) {
|
||||
logger.warn(`MakerMM${tag}: retrying YES order (attempt ${attempt}/${maxRetries})...`);
|
||||
await cancelOrder(yesBuy.orderId);
|
||||
await sleep(500);
|
||||
finalYesBuy = await placeLimitBuy(yesTokenId, targetShares, yesBid, tickSize, negRisk);
|
||||
if (finalYesBuy.success) {
|
||||
logger.success(`MakerMM${tag}: YES order succeeded on retry ${attempt}`);
|
||||
}
|
||||
}
|
||||
if (!finalNoBuy.success) {
|
||||
logger.warn(`MakerMM${tag}: retrying NO order (attempt ${attempt}/${maxRetries})...`);
|
||||
await cancelOrder(noBuy.orderId);
|
||||
await sleep(500);
|
||||
finalNoBuy = await placeLimitBuy(noTokenId, targetShares, noBid, tickSize, negRisk);
|
||||
if (finalNoBuy.success) {
|
||||
logger.success(`MakerMM${tag}: NO order succeeded on retry ${attempt}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!finalYesBuy.success || !finalNoBuy.success) {
|
||||
logger.error(`MakerMM${tag}: order failed after retries — YES: ${finalYesBuy.success}, NO: ${finalNoBuy.success}`);
|
||||
await Promise.all([
|
||||
finalYesBuy.success ? cancelOrder(finalYesBuy.orderId) : null,
|
||||
finalNoBuy.success ? cancelOrder(finalNoBuy.orderId) : null,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Build position and wait ─────────────────────────────────
|
||||
const pos = {
|
||||
asset: asset || 'btc',
|
||||
conditionId,
|
||||
question,
|
||||
endTime,
|
||||
marketOpenTime,
|
||||
tickSize,
|
||||
negRisk,
|
||||
status: 'monitoring',
|
||||
targetShares,
|
||||
yes: {
|
||||
tokenId: yesTokenId,
|
||||
buyPrice: yesBid,
|
||||
cost: yesCost,
|
||||
orderId: finalYesBuy.orderId,
|
||||
filled: false,
|
||||
baseline: yesBaseline || 0,
|
||||
},
|
||||
no: {
|
||||
tokenId: noTokenId,
|
||||
buyPrice: noBid,
|
||||
cost: noCost,
|
||||
orderId: finalNoBuy.orderId,
|
||||
filled: false,
|
||||
baseline: noBaseline || 0,
|
||||
},
|
||||
totalProfit: 0,
|
||||
};
|
||||
|
||||
activePositions.set(conditionId, pos);
|
||||
await monitorUntilFilled(pos, tag, label);
|
||||
activePositions.delete(conditionId);
|
||||
|
||||
// If holding a single-sided position (expensive filled, cheap cancelled) — wait and redeem
|
||||
if (pos.holdingSide) {
|
||||
await waitAndRedeem(pos, tag);
|
||||
return { oneSided: false }; // not a stuck one-sided cycle, intentional hold
|
||||
}
|
||||
|
||||
const sign = pos.totalProfit >= 0 ? '+' : '';
|
||||
logger.info(`MakerMM${tag}: done | P&L: ${sign}$${pos.totalProfit.toFixed(2)}`);
|
||||
|
||||
return { oneSided: pos.oneSided ?? false };
|
||||
}
|
||||
+18
-67
@@ -7,31 +7,27 @@
|
||||
* e.g. btc-updown-5m-1771755000
|
||||
* eth-updown-15m-1771754100
|
||||
*
|
||||
* poll() targets the NEXT upcoming slot. checkCurrentMarket() enters the current slot on startup.
|
||||
* NEVER enters the currently active market — always targets the NEXT upcoming slot.
|
||||
*/
|
||||
|
||||
import config from '../config/index.js';
|
||||
import logger from '../utils/logger.js';
|
||||
import { proxyFetch } from '../utils/proxy.js';
|
||||
|
||||
let pollTimer = null;
|
||||
let onMarketCb = null;
|
||||
const seenKeys = new Set(); // `${asset}-${slotTimestamp}` already scheduled
|
||||
// Slot size in seconds (300 for 5m, 900 for 15m)
|
||||
const SLOT_SEC = config.mmDuration === '15m' ? 900 : 300;
|
||||
|
||||
let pollTimer = null;
|
||||
let onMarketCb = null;
|
||||
const seenKeys = new Set(); // `${asset}-${slotTimestamp}` already scheduled
|
||||
|
||||
// ── Slot helpers ──────────────────────────────────────────────────────────────
|
||||
// Computed dynamically so config.mmDuration overrides in maker-mm.js take effect.
|
||||
|
||||
function slotSec() {
|
||||
return config.mmDuration === '15m' ? 900 : 300;
|
||||
}
|
||||
|
||||
function currentSlot() {
|
||||
const s = slotSec();
|
||||
return Math.floor(Date.now() / 1000 / s) * s;
|
||||
return Math.floor(Date.now() / 1000 / SLOT_SEC) * SLOT_SEC;
|
||||
}
|
||||
|
||||
function nextSlot() {
|
||||
return currentSlot() + slotSec();
|
||||
return currentSlot() + SLOT_SEC;
|
||||
}
|
||||
|
||||
// ── Gamma API fetch ───────────────────────────────────────────────────────────
|
||||
@@ -39,7 +35,7 @@ function nextSlot() {
|
||||
async function fetchBySlug(asset, slotTimestamp) {
|
||||
const slug = `${asset}-updown-${config.mmDuration}-${slotTimestamp}`;
|
||||
try {
|
||||
const resp = await proxyFetch(`${config.gammaHost}/markets/slug/${slug}`);
|
||||
const resp = await fetch(`${config.gammaHost}/markets/slug/${slug}`);
|
||||
if (!resp.ok) return null;
|
||||
const data = await resp.json();
|
||||
return data?.conditionId ? data : null;
|
||||
@@ -65,7 +61,7 @@ function extractMarketData(market, asset) {
|
||||
[yesTokenId, noTokenId] = tokenIds;
|
||||
} else if (Array.isArray(market.tokens) && market.tokens.length >= 2) {
|
||||
yesTokenId = market.tokens[0]?.token_id ?? market.tokens[0]?.tokenId;
|
||||
noTokenId = market.tokens[1]?.token_id ?? market.tokens[1]?.tokenId;
|
||||
noTokenId = market.tokens[1]?.token_id ?? market.tokens[1]?.tokenId;
|
||||
}
|
||||
|
||||
if (!yesTokenId || !noTokenId) return null;
|
||||
@@ -73,13 +69,13 @@ function extractMarketData(market, asset) {
|
||||
return {
|
||||
asset,
|
||||
conditionId,
|
||||
question: market.question || market.title || '',
|
||||
endTime: market.endDate || market.end_date_iso || market.endDateIso,
|
||||
question: market.question || market.title || '',
|
||||
endTime: market.endDate || market.end_date_iso || market.endDateIso,
|
||||
eventStartTime: market.eventStartTime || market.event_start_time,
|
||||
yesTokenId: String(yesTokenId),
|
||||
noTokenId: String(noTokenId),
|
||||
negRisk: market.negRisk ?? market.neg_risk ?? false,
|
||||
tickSize: String(market.orderPriceMinTickSize ?? market.minimum_tick_size ?? market.minimumTickSize ?? '0.01'),
|
||||
yesTokenId: String(yesTokenId),
|
||||
noTokenId: String(noTokenId),
|
||||
negRisk: market.negRisk ?? market.neg_risk ?? false,
|
||||
tickSize: String(market.orderPriceMinTickSize ?? market.minimum_tick_size ?? market.minimumTickSize ?? '0.01'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -102,7 +98,7 @@ async function scheduleAsset(asset, slotTimestamp) {
|
||||
seenKeys.add(key);
|
||||
|
||||
// Refuse to enter a market already well into its window (e.g., bot restart mid-slot)
|
||||
const openAt = data.eventStartTime ? new Date(data.eventStartTime).getTime() : slotTimestamp * 1000;
|
||||
const openAt = data.eventStartTime ? new Date(data.eventStartTime).getTime() : slotTimestamp * 1000;
|
||||
const elapsedSec = Math.round((Date.now() - openAt) / 1000);
|
||||
if (elapsedSec > 15) {
|
||||
logger.info(`MM: ${asset.toUpperCase()} next slot already ${elapsedSec}s old — skipping, will catch next`);
|
||||
@@ -153,48 +149,3 @@ export function stopMMDetector() {
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Check current active market on startup ────────────────────────────────────
|
||||
// Enters the currently running market slot if enough time remains.
|
||||
// Enabled unconditionally for the maker rebate bot — call only from maker-mm.js.
|
||||
export async function checkCurrentMarket(onMarketFound) {
|
||||
const current = currentSlot();
|
||||
const cutLossSec = config.makerMmCutLossTime ?? 60;
|
||||
const tag = '[CURRENT]';
|
||||
|
||||
logger.info(`MM${tag}: checking current slot ${current} (${config.mmDuration}) for assets: ${config.mmAssets.join(', ').toUpperCase()}`);
|
||||
|
||||
for (const asset of config.mmAssets) {
|
||||
const key = `${asset}-${current}`;
|
||||
if (seenKeys.has(key)) {
|
||||
logger.info(`MM${tag}: ${asset.toUpperCase()} already seen — skip`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const market = await fetchBySlug(asset, current);
|
||||
if (!market) {
|
||||
logger.warn(`MM${tag}: ${asset.toUpperCase()} — no market found for slot ${current} (slug: ${asset}-updown-${config.mmDuration}-${current})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = extractMarketData(market, asset);
|
||||
if (!data) {
|
||||
logger.warn(`MM${tag}: ${asset.toUpperCase()} — market found but missing token IDs, skipping`);
|
||||
seenKeys.add(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
const msRemaining = new Date(data.endTime).getTime() - Date.now();
|
||||
const secsRemaining = Math.round(msRemaining / 1000);
|
||||
|
||||
if (isNaN(secsRemaining) || secsRemaining <= cutLossSec) {
|
||||
logger.info(`MM${tag}: ${asset.toUpperCase()} current market ${secsRemaining}s left (≤ cutLoss ${cutLossSec}s) — skipping`);
|
||||
seenKeys.add(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
seenKeys.add(key);
|
||||
logger.success(`MM${tag}: ${asset.toUpperCase()} entering current market "${data.question.slice(0, 40)}" (${secsRemaining}s left)`);
|
||||
onMarketFound(data);
|
||||
}
|
||||
}
|
||||
|
||||
+121
-740
@@ -14,16 +14,12 @@ import { ethers } from 'ethers';
|
||||
import config from '../config/index.js';
|
||||
import { getClient, getUsdcBalance, getPolygonProvider } from './client.js';
|
||||
import { splitPosition, mergePositions } from './ctf.js';
|
||||
import { mmFillWatcher } from './mmWsFillWatcher.js';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
// CTF contract for on-chain balance queries
|
||||
const CTF_ADDRESS = '0x4D97DCd97eC945f40cF65F87097ACe5EA0476045';
|
||||
const CTF_BALANCE_ABI = ['function balanceOf(address account, uint256 id) view returns (uint256)'];
|
||||
|
||||
// Polymarket CLOB minimum order size (shares)
|
||||
const CLOB_MIN_ORDER_SHARES = 5;
|
||||
|
||||
/**
|
||||
* Get actual on-chain ERC1155 token balance for the proxy wallet.
|
||||
* Used before market-sell to avoid 'not enough balance' errors from partial fills.
|
||||
@@ -41,37 +37,6 @@ async function getTokenBalance(tokenId) {
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// Fallback poll interval — WS handles the fast path, this is the safety net
|
||||
const POLL_INTERVAL_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Wait for a fill event from WebSocket OR timeout (polling fallback).
|
||||
* Returns early if WS delivers a fill for any of the watched token IDs.
|
||||
* @param {string[]} tokenIds - token IDs to listen for
|
||||
* @param {number} timeoutMs - max wait time before returning for poll check
|
||||
* @returns {Promise<{tokenId: string, size: number, price: number} | null>}
|
||||
*/
|
||||
function waitForFillOrTimeout(tokenIds, timeoutMs) {
|
||||
return new Promise((resolve) => {
|
||||
let timer;
|
||||
|
||||
const onFill = (event) => {
|
||||
if (tokenIds.includes(event.tokenId)) {
|
||||
clearTimeout(timer);
|
||||
mmFillWatcher.removeListener('fill', onFill);
|
||||
resolve(event);
|
||||
}
|
||||
};
|
||||
|
||||
mmFillWatcher.on('fill', onFill);
|
||||
|
||||
timer = setTimeout(() => {
|
||||
mmFillWatcher.removeListener('fill', onFill);
|
||||
resolve(null); // timeout — caller does poll check
|
||||
}, timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
// In-memory store of all active MM positions (conditionId → position)
|
||||
const activePositions = new Map();
|
||||
|
||||
@@ -142,89 +107,17 @@ async function marketSell(tokenId, shares, tickSize, negRisk) {
|
||||
|
||||
// ── Order status check ────────────────────────────────────────────────────────
|
||||
|
||||
async function isOrderFilled(orderId, shares, tokenId = null) {
|
||||
async function isOrderFilled(orderId, shares) {
|
||||
if (!orderId || orderId.startsWith('sim-')) return false;
|
||||
const MAX_FILL_RETRIES = 2;
|
||||
for (let attempt = 1; attempt <= MAX_FILL_RETRIES; attempt++) {
|
||||
try {
|
||||
const client = getClient();
|
||||
const order = await client.getOrder(orderId);
|
||||
if (!order) break; // order gone — fall through to balance check
|
||||
if (order.status === 'MATCHED') return true;
|
||||
const matched = parseFloat(order.size_matched || '0');
|
||||
if (matched >= shares * 0.99) return true;
|
||||
// CLOB says not filled — trust it if we have no tokenId for balance check
|
||||
if (!tokenId) return false;
|
||||
// Otherwise fall through to balance check below
|
||||
break;
|
||||
} catch (err) {
|
||||
logger.warn(`MM: isOrderFilled CLOB error (attempt ${attempt}/${MAX_FILL_RETRIES}): ${err.message}`);
|
||||
if (attempt < MAX_FILL_RETRIES) await sleep(2000);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: check on-chain token balance
|
||||
// If we placed a SELL and our balance is now ~0, the order was filled
|
||||
if (tokenId) {
|
||||
const balance = await getTokenBalance(tokenId);
|
||||
if (balance !== null && balance < shares * 0.05) {
|
||||
logger.warn(`MM: CLOB API missed fill — on-chain balance ${balance.toFixed(3)} ≈ 0 (expected ${shares}) → treating as filled`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check how many shares of an order have been partially filled.
|
||||
* Returns { matched, remaining, total }.
|
||||
*/
|
||||
async function getPartialFillInfo(orderId, originalShares, tokenId = null) {
|
||||
let matched = 0;
|
||||
if (orderId && !orderId.startsWith('sim-')) {
|
||||
try {
|
||||
const client = getClient();
|
||||
const order = await client.getOrder(orderId);
|
||||
if (order) {
|
||||
if (order.status === 'MATCHED') {
|
||||
matched = parseFloat(order.original_size || order.size || String(originalShares));
|
||||
} else {
|
||||
matched = parseFloat(order.size_matched || '0');
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Cross-check with on-chain balance for accuracy
|
||||
if (tokenId) {
|
||||
const balance = await getTokenBalance(tokenId);
|
||||
if (balance !== null) {
|
||||
const onChainMatched = originalShares - balance;
|
||||
if (onChainMatched > matched) {
|
||||
matched = Math.max(0, onChainMatched);
|
||||
}
|
||||
return { matched, remaining: balance, total: originalShares };
|
||||
}
|
||||
}
|
||||
|
||||
return { matched, remaining: originalShares - matched, total: originalShares };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get partial fill amount for an order (how many shares already matched).
|
||||
* Returns 0 on error.
|
||||
*/
|
||||
async function getOrderMatched(orderId) {
|
||||
if (!orderId || orderId.startsWith('sim-')) return 0;
|
||||
try {
|
||||
const client = getClient();
|
||||
const order = await client.getOrder(orderId);
|
||||
if (!order) return 0;
|
||||
if (order.status === 'MATCHED') return parseFloat(order.original_size || order.size || '0');
|
||||
return parseFloat(order.size_matched || '0');
|
||||
if (!order) return false;
|
||||
if (order.status === 'MATCHED') return true;
|
||||
const matched = parseFloat(order.size_matched || '0');
|
||||
return matched >= shares * 0.99;
|
||||
} catch {
|
||||
return 0;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,103 +133,71 @@ async function simPriceHitTarget(tokenId) {
|
||||
}
|
||||
}
|
||||
|
||||
// Get current mid price for a token (0 on error)
|
||||
async function getMidprice(tokenId) {
|
||||
try {
|
||||
const mp = await getClient().getMidpoint(tokenId);
|
||||
return parseFloat(mp?.mid ?? mp ?? '0') || 0;
|
||||
} catch { return 0; }
|
||||
}
|
||||
|
||||
// ── Per-side fill check (parallel-safe) ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check one side (yes/no) for fills and partial fills.
|
||||
* Returns true if this side became fully filled during this check.
|
||||
* Safe to run in parallel for both sides.
|
||||
*/
|
||||
async function checkSideFill(pos, key) {
|
||||
const s = pos[key];
|
||||
if (s.filled) return false;
|
||||
|
||||
const label = key.toUpperCase();
|
||||
let filled = false;
|
||||
|
||||
if (config.dryRun) {
|
||||
const hitPrice = await simPriceHitTarget(s.tokenId);
|
||||
if (hitPrice) { filled = true; s.fillPrice = hitPrice; }
|
||||
} else {
|
||||
filled = await isOrderFilled(s.orderId, s.shares, s.tokenId);
|
||||
if (filled) s.fillPrice = config.mmSellPrice;
|
||||
}
|
||||
|
||||
if (filled) {
|
||||
s.filled = true;
|
||||
const pnl = (s.fillPrice - s.entryPrice) * s.shares;
|
||||
logger.money(`MM${config.dryRun ? '[SIM]' : ''}: ${label} filled @ $${s.fillPrice.toFixed(3)} | P&L $${pnl.toFixed(2)}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Partial fill handling (live only)
|
||||
if (config.dryRun) return false;
|
||||
|
||||
const info = await getPartialFillInfo(s.orderId, s.shares, s.tokenId);
|
||||
if (info.matched > 0 && info.remaining > 0 && info.remaining < s.shares * 0.90) {
|
||||
logger.warn(`MM: ${label} partially filled — ${info.matched.toFixed(3)}/${info.total.toFixed(3)} matched, ${info.remaining.toFixed(3)} remaining`);
|
||||
await cancelOrder(s.orderId);
|
||||
s.orderId = null;
|
||||
s.shares = info.remaining;
|
||||
s._partialRevenue = (s._partialRevenue || 0) + info.matched * config.mmSellPrice;
|
||||
|
||||
if (info.remaining < CLOB_MIN_ORDER_SHARES) {
|
||||
logger.warn(`MM: ${label} remaining ${info.remaining.toFixed(3)} < ${CLOB_MIN_ORDER_SHARES} min — market selling remainder`);
|
||||
const result = await marketSell(s.tokenId, info.remaining, pos.tickSize, pos.negRisk);
|
||||
s.fillPrice = config.mmSellPrice;
|
||||
s.filled = true;
|
||||
const pnl = (s._partialRevenue + result.fillPrice * info.remaining) - s.entryPrice * info.total;
|
||||
logger.money(`MM: ${label} fully sold (partial+market) | P&L $${pnl.toFixed(2)}`);
|
||||
return true;
|
||||
} else {
|
||||
const res = await placeLimitSell(s.tokenId, info.remaining, config.mmSellPrice, pos.tickSize, pos.negRisk);
|
||||
if (res.success) {
|
||||
s.orderId = res.orderId;
|
||||
logger.info(`MM: ${label} re-placed limit sell for ${info.remaining.toFixed(3)} shares @ $${config.mmSellPrice}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Core monitoring loop (event-driven + parallel) ───────────────────────────
|
||||
// ── Core monitoring loop ──────────────────────────────────────────────────────
|
||||
|
||||
async function monitorAndManage(pos) {
|
||||
const label = pos.question.substring(0, 40);
|
||||
|
||||
// Register tokens with WS fill watcher for instant fill detection
|
||||
mmFillWatcher.watch(pos.yes.tokenId);
|
||||
mmFillWatcher.watch(pos.no.tokenId);
|
||||
while (true) {
|
||||
const msRemaining = new Date(pos.endTime).getTime() - Date.now();
|
||||
|
||||
// Handle WS fill events — mark side as filled immediately
|
||||
const onWsFill = (event) => {
|
||||
for (const key of ['yes', 'no']) {
|
||||
if (!pos[key].filled && event.tokenId === pos[key].tokenId && event.side === 'SELL') {
|
||||
pos[key].filled = true;
|
||||
pos[key].fillPrice = event.price || config.mmSellPrice;
|
||||
const pnl = (pos[key].fillPrice - pos[key].entryPrice) * pos[key].shares;
|
||||
logger.money(`MM: ${key.toUpperCase()} filled (WS realtime) @ $${pos[key].fillPrice.toFixed(3)} | P&L $${pnl.toFixed(2)}`);
|
||||
if (msRemaining <= 0) {
|
||||
logger.warn(`MM: market expired — ${label}`);
|
||||
pos.status = 'expired';
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Check YES side ──────────────────────────────────────
|
||||
if (!pos.yes.filled) {
|
||||
let filled = false;
|
||||
if (config.dryRun) {
|
||||
const hitPrice = await simPriceHitTarget(pos.yes.tokenId);
|
||||
if (hitPrice) { filled = true; pos.yes.fillPrice = hitPrice; }
|
||||
} else {
|
||||
filled = await isOrderFilled(pos.yes.orderId, pos.yes.shares);
|
||||
if (filled) pos.yes.fillPrice = config.mmSellPrice;
|
||||
}
|
||||
if (filled) {
|
||||
pos.yes.filled = true;
|
||||
const pnl = (pos.yes.fillPrice - pos.yes.entryPrice) * pos.yes.shares;
|
||||
logger.money(`MM${config.dryRun ? '[SIM]' : ''}: YES filled @ $${pos.yes.fillPrice.toFixed(3)} | P&L $${pnl.toFixed(2)}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
mmFillWatcher.on('fill', onWsFill);
|
||||
|
||||
try {
|
||||
await _monitorLoop(pos, label);
|
||||
} finally {
|
||||
// Cleanup WS listeners
|
||||
mmFillWatcher.removeListener('fill', onWsFill);
|
||||
mmFillWatcher.unwatch(pos.yes.tokenId);
|
||||
mmFillWatcher.unwatch(pos.no.tokenId);
|
||||
// ── Check NO side ───────────────────────────────────────
|
||||
if (!pos.no.filled) {
|
||||
let filled = false;
|
||||
if (config.dryRun) {
|
||||
const hitPrice = await simPriceHitTarget(pos.no.tokenId);
|
||||
if (hitPrice) { filled = true; pos.no.fillPrice = hitPrice; }
|
||||
} else {
|
||||
filled = await isOrderFilled(pos.no.orderId, pos.no.shares);
|
||||
if (filled) pos.no.fillPrice = config.mmSellPrice;
|
||||
}
|
||||
if (filled) {
|
||||
pos.no.filled = true;
|
||||
const pnl = (pos.no.fillPrice - pos.no.entryPrice) * pos.no.shares;
|
||||
logger.money(`MM${config.dryRun ? '[SIM]' : ''}: NO filled @ $${pos.no.fillPrice.toFixed(3)} | P&L $${pnl.toFixed(2)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Both filled → done ──────────────────────────────────
|
||||
if (pos.yes.filled && pos.no.filled) {
|
||||
pos.status = 'done';
|
||||
const totalPnl = calcPnl(pos);
|
||||
logger.money(`MM: BOTH sides filled! Total P&L: $${totalPnl.toFixed(2)} | ${label}`);
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Cut-loss time ───────────────────────────────────────
|
||||
if (msRemaining <= config.mmCutLossTime * 1000) {
|
||||
logger.warn(`MM: cut-loss triggered (${Math.round(msRemaining / 1000)}s left) — ${label}`);
|
||||
pos.status = 'cutting';
|
||||
await cutLoss(pos);
|
||||
break;
|
||||
}
|
||||
|
||||
await sleep(10_000);
|
||||
}
|
||||
|
||||
// Final P&L log
|
||||
@@ -347,183 +208,70 @@ async function monitorAndManage(pos) {
|
||||
}
|
||||
}
|
||||
|
||||
async function _monitorLoop(pos, label) {
|
||||
while (true) {
|
||||
const msRemaining = new Date(pos.endTime).getTime() - Date.now();
|
||||
async function cutLoss(pos) {
|
||||
const { conditionId, tickSize, negRisk } = pos;
|
||||
const neitherFilled = !pos.yes.filled && !pos.no.filled;
|
||||
|
||||
if (msRemaining <= 0) {
|
||||
logger.warn(`MM: market expired — ${label}`);
|
||||
pos.status = 'expired';
|
||||
break;
|
||||
}
|
||||
if (neitherFilled) {
|
||||
// ── Best case: neither side sold → cancel both, merge back to USDC ──
|
||||
logger.warn('MM: neither side filled — cancelling orders and merging back to USDC...');
|
||||
await cancelOrder(pos.yes.orderId);
|
||||
await cancelOrder(pos.no.orderId);
|
||||
|
||||
// ── Check YES + NO sides in parallel ────────────────────
|
||||
await Promise.all([
|
||||
checkSideFill(pos, 'yes'),
|
||||
checkSideFill(pos, 'no'),
|
||||
// Read actual on-chain balances (may differ from original if partially consumed)
|
||||
const [yesActual, noActual] = await Promise.all([
|
||||
getTokenBalance(pos.yes.tokenId),
|
||||
getTokenBalance(pos.no.tokenId),
|
||||
]);
|
||||
|
||||
// ── Both filled → done ──────────────────────────────────
|
||||
if (pos.yes.filled && pos.no.filled) {
|
||||
pos.status = 'done';
|
||||
const totalPnl = calcPnl(pos);
|
||||
logger.money(`MM: BOTH sides filled! Total P&L: $${totalPnl.toFixed(2)} | ${label}`);
|
||||
break;
|
||||
// mergePositions needs equal amounts — use the minimum actual balance
|
||||
const yesShares = yesActual ?? pos.yes.shares;
|
||||
const noShares = noActual ?? pos.no.shares;
|
||||
const mergeAmt = Math.min(yesShares, noShares);
|
||||
|
||||
if (mergeAmt < 0.001) {
|
||||
logger.warn('MM: balances too low to merge — nothing to recover');
|
||||
} else {
|
||||
const recovered = await mergePositions(conditionId, mergeAmt);
|
||||
logger.money(`MM: merge complete — recovered ~$${recovered.toFixed ? recovered.toFixed(2) : recovered} USDC (P&L ≈ $0)`);
|
||||
}
|
||||
|
||||
// ── Exactly one leg filled → adaptive cut-loss (if enabled) ────────
|
||||
if (config.mmAdaptiveCL && pos.yes.filled !== pos.no.filled) {
|
||||
const unfilledKey = pos.yes.filled ? 'no' : 'yes';
|
||||
await adaptiveLegCL(pos, unfilledKey);
|
||||
break;
|
||||
}
|
||||
// Mark both sides closed at entry price
|
||||
pos.yes.fillPrice = pos.yes.entryPrice;
|
||||
pos.yes.filled = true;
|
||||
pos.no.fillPrice = pos.no.entryPrice;
|
||||
pos.no.filled = true;
|
||||
|
||||
// ── Defensive pivot: neither filled after timeout (5m markets only) ──
|
||||
if (config.mmDefensiveEnabled && config.mmDuration === '5m'
|
||||
&& !pos.yes.filled && !pos.no.filled && !pos._defensiveActive) {
|
||||
const marketDurationMs = 5 * 60 * 1000;
|
||||
const marketStartMs = new Date(pos.endTime).getTime() - marketDurationMs;
|
||||
const elapsed = (Date.now() - marketStartMs) / 1000;
|
||||
if (elapsed >= config.mmDefensiveTimeout) {
|
||||
// Cancel both orders FIRST so they can't fill while we wait
|
||||
logger.warn(`MM: neither side filled after ${Math.round(elapsed)}s since market open — cancelling orders | ${label}`);
|
||||
await Promise.all([
|
||||
cancelOrder(pos.yes.orderId),
|
||||
cancelOrder(pos.no.orderId),
|
||||
]);
|
||||
pos.yes.orderId = null;
|
||||
pos.no.orderId = null;
|
||||
|
||||
// Re-check fills after cancellation — CLOB may have filled one side
|
||||
// between our last check and the cancel (race condition)
|
||||
await sleep(2000);
|
||||
const [yesBalance, noBalance] = await Promise.all([
|
||||
getTokenBalance(pos.yes.tokenId),
|
||||
getTokenBalance(pos.no.tokenId),
|
||||
]);
|
||||
for (const [key, balance] of [['yes', yesBalance], ['no', noBalance]]) {
|
||||
if (!pos[key].filled && balance !== null && balance < pos[key].shares * 0.05) {
|
||||
logger.warn(`MM: ${key.toUpperCase()} actually filled (on-chain balance ${balance.toFixed(3)} ≈ 0) — detected after cancel`);
|
||||
pos[key].filled = true;
|
||||
pos[key].fillPrice = config.mmSellPrice;
|
||||
const pnl = (pos[key].fillPrice - pos[key].entryPrice) * pos[key].shares;
|
||||
logger.money(`MM: ${key.toUpperCase()} filled @ $${pos[key].fillPrice.toFixed(3)} | P&L $${pnl.toFixed(2)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// If one side is now filled, go to adaptive CL instead of defensive pivot
|
||||
if (pos.yes.filled !== pos.no.filled) {
|
||||
const unfilledKey = pos.yes.filled ? 'no' : 'yes';
|
||||
logger.warn(`MM: one side filled after cancel — switching to adaptive CL for ${unfilledKey.toUpperCase()} instead of defensive pivot`);
|
||||
await adaptiveLegCL(pos, unfilledKey);
|
||||
break;
|
||||
}
|
||||
|
||||
// If both filled (unlikely but possible), we're done
|
||||
if (pos.yes.filled && pos.no.filled) {
|
||||
pos.status = 'done';
|
||||
const totalPnl = calcPnl(pos);
|
||||
logger.money(`MM: BOTH sides filled! Total P&L: $${totalPnl.toFixed(2)} | ${label}`);
|
||||
break;
|
||||
}
|
||||
|
||||
// Neither filled — proceed with defensive pivot
|
||||
pos._defensiveActive = true;
|
||||
await defensivePivot(pos);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cut-loss time ────────────────────────────────────────────────────
|
||||
if (msRemaining <= config.mmCutLossTime * 1000) {
|
||||
logger.warn(`MM: cut-loss triggered (${Math.round(msRemaining / 1000)}s left) — ${label}`);
|
||||
pos.status = 'cutting';
|
||||
const oneLegFilled = pos.yes.filled !== pos.no.filled;
|
||||
if (!config.mmAdaptiveCL && oneLegFilled) {
|
||||
const unfilledKey = pos.yes.filled ? 'no' : 'yes';
|
||||
await cutLossOneLegFilled(pos, unfilledKey);
|
||||
pos.status = 'done';
|
||||
} else {
|
||||
await cutLossNeitherFilled(pos);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Wait for WS fill event or polling fallback ───────────────────────
|
||||
// WS gives us instant fill detection; polling at 30s is just a safety net
|
||||
const watchTokens = [];
|
||||
if (!pos.yes.filled) watchTokens.push(pos.yes.tokenId);
|
||||
if (!pos.no.filled) watchTokens.push(pos.no.tokenId);
|
||||
|
||||
const wsEvent = await waitForFillOrTimeout(watchTokens, POLL_INTERVAL_MS);
|
||||
|
||||
if (wsEvent) {
|
||||
// WS detected a fill — the onWsFill listener already updated pos,
|
||||
// but loop back immediately to run the decision logic
|
||||
logger.info(`MM: WS fill event received — re-checking immediately`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy one-leg CL: cancel unfilled order, immediate market sell (no patience)
|
||||
async function cutLossOneLegFilled(pos, unfilledKey) {
|
||||
const s = pos[unfilledKey];
|
||||
const { tickSize, negRisk } = pos;
|
||||
|
||||
logger.warn(`MM: cancelling ${unfilledKey.toUpperCase()} limit order and market-selling...`);
|
||||
await cancelOrder(s.orderId);
|
||||
|
||||
const actualShares = await getTokenBalance(s.tokenId);
|
||||
const sellShares = actualShares !== null ? actualShares : s.shares;
|
||||
|
||||
if (sellShares < 0.001) {
|
||||
logger.warn(`MM: ${unfilledKey.toUpperCase()} balance is 0 — already fully sold via partial fills`);
|
||||
s.fillPrice = config.mmSellPrice;
|
||||
s.filled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn(`MM: ${unfilledKey.toUpperCase()} actual balance: ${sellShares.toFixed(3)} shares (original: ${s.shares})`);
|
||||
const result = await marketSell(s.tokenId, sellShares, tickSize, negRisk);
|
||||
s.fillPrice = result.fillPrice;
|
||||
s.filled = true;
|
||||
const pnl = (s.fillPrice - s.entryPrice) * sellShares;
|
||||
logger.warn(`MM: ${unfilledKey.toUpperCase()} cut @ $${s.fillPrice.toFixed(3)} | sold ${sellShares.toFixed(3)} sh | P&L $${pnl.toFixed(2)}`);
|
||||
}
|
||||
|
||||
async function cutLossNeitherFilled(pos) {
|
||||
const { conditionId } = pos;
|
||||
|
||||
// ── Best case: neither side sold → cancel both, merge back to USDC ──
|
||||
logger.warn('MM: neither side filled — cancelling orders and merging back to USDC...');
|
||||
await Promise.all([
|
||||
cancelOrder(pos.yes.orderId),
|
||||
cancelOrder(pos.no.orderId),
|
||||
]);
|
||||
|
||||
// Read actual on-chain balances (may differ from original if partially consumed)
|
||||
const [yesActual, noActual] = await Promise.all([
|
||||
getTokenBalance(pos.yes.tokenId),
|
||||
getTokenBalance(pos.no.tokenId),
|
||||
]);
|
||||
|
||||
// mergePositions needs equal amounts — use the minimum actual balance
|
||||
const yesShares = yesActual ?? pos.yes.shares;
|
||||
const noShares = noActual ?? pos.no.shares;
|
||||
const mergeAmt = Math.min(yesShares, noShares);
|
||||
|
||||
if (mergeAmt < 0.001) {
|
||||
logger.warn('MM: balances too low to merge — nothing to recover');
|
||||
} else {
|
||||
const recovered = await mergePositions(conditionId, mergeAmt);
|
||||
logger.money(`MM: merge complete — recovered ~$${recovered.toFixed ? recovered.toFixed(2) : recovered} USDC (P&L ≈ $0)`);
|
||||
}
|
||||
// ── One side already (partly) sold → market-sell the unfilled side ──
|
||||
for (const side of ['yes', 'no']) {
|
||||
const s = pos[side];
|
||||
if (s.filled) continue;
|
||||
|
||||
// Mark both sides closed at entry price
|
||||
pos.yes.fillPrice = pos.yes.entryPrice;
|
||||
pos.yes.filled = true;
|
||||
pos.no.fillPrice = pos.no.entryPrice;
|
||||
pos.no.filled = true;
|
||||
logger.warn(`MM: cancelling ${side.toUpperCase()} limit order and market-selling...`);
|
||||
await cancelOrder(s.orderId);
|
||||
|
||||
// Fetch actual on-chain balance — partial fills reduce this below s.shares
|
||||
const actualShares = await getTokenBalance(s.tokenId);
|
||||
const sellShares = actualShares !== null ? actualShares : s.shares;
|
||||
|
||||
if (sellShares < 0.001) {
|
||||
logger.warn(`MM: ${side.toUpperCase()} balance is 0 — already fully sold via partial fills`);
|
||||
s.fillPrice = config.mmSellPrice; // assume sold at target
|
||||
s.filled = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.warn(`MM: ${side.toUpperCase()} actual balance: ${sellShares.toFixed(3)} shares (original: ${s.shares})`);
|
||||
|
||||
const result = await marketSell(s.tokenId, sellShares, tickSize, negRisk);
|
||||
s.fillPrice = result.fillPrice;
|
||||
s.filled = true;
|
||||
// PnL uses actual sold amount (not original pos.shares)
|
||||
const pnl = (s.fillPrice - s.entryPrice) * sellShares;
|
||||
logger.warn(`MM: ${side.toUpperCase()} cut @ $${s.fillPrice.toFixed(3)} | sold ${sellShares.toFixed(3)} sh | P&L $${pnl.toFixed(2)}`);
|
||||
}
|
||||
}
|
||||
|
||||
pos.status = 'done';
|
||||
|
||||
@@ -531,371 +279,6 @@ async function cutLossNeitherFilled(pos) {
|
||||
await attemptRecoveryBuy(pos);
|
||||
}
|
||||
|
||||
// ── Defensive Pivot (5m markets, neither side filled) ────────────────────────
|
||||
|
||||
/**
|
||||
* Defensive pivot: neither side has filled after MM_DEFENSIVE_TIMEOUT.
|
||||
*
|
||||
* Strategy:
|
||||
* 1. Orders already cancelled by caller (monitorAndManage)
|
||||
* 2. Wait until 45s before close
|
||||
* 3. Check prices: identify worst (lower price) and best (higher price) side
|
||||
* 4. If worst < MM_DEFENSIVE_WORST_THRESHOLD (default 10c):
|
||||
* → market sell worst side, keep best side (let it resolve at close)
|
||||
* → since YES+NO ≈ $1, best side is ~90c+ → profit potential
|
||||
* 5. If worst ≥ threshold: market is still uncertain → merge back ($0 P&L)
|
||||
*/
|
||||
async function defensivePivot(pos) {
|
||||
const { conditionId, tickSize, negRisk } = pos;
|
||||
const label = pos.question.substring(0, 40);
|
||||
const threshold = config.mmDefensiveWorstThreshold;
|
||||
|
||||
// Orders already cancelled by monitorAndManage before entering here
|
||||
logger.info(`MM defensive: waiting for 45s before close | ${label}`);
|
||||
|
||||
// Wait until 45s before close, checking every 5s
|
||||
while (true) {
|
||||
const msLeft = new Date(pos.endTime).getTime() - Date.now();
|
||||
|
||||
if (msLeft <= 45_000) break; // 45s mark reached
|
||||
if (msLeft <= 0) {
|
||||
pos.status = 'expired';
|
||||
return;
|
||||
}
|
||||
|
||||
await sleep(5000);
|
||||
}
|
||||
|
||||
// Read current prices for both sides
|
||||
const [yesPrice, noPrice] = await Promise.all([
|
||||
getMidprice(pos.yes.tokenId),
|
||||
getMidprice(pos.no.tokenId),
|
||||
]);
|
||||
|
||||
logger.info(`MM defensive: 45s mark — YES=$${yesPrice.toFixed(3)}, NO=$${noPrice.toFixed(3)} | threshold=$${threshold} | ${label}`);
|
||||
|
||||
// Determine worst and best sides
|
||||
const worstKey = yesPrice <= noPrice ? 'yes' : 'no';
|
||||
const bestKey = worstKey === 'yes' ? 'no' : 'yes';
|
||||
const worstPrice = Math.min(yesPrice, noPrice);
|
||||
const bestPrice = Math.max(yesPrice, noPrice);
|
||||
|
||||
// ── Decision: pivot or merge? ─────────────────────────────────────────
|
||||
if (worstPrice < threshold) {
|
||||
// Worst side < 10c → market is decisive, pivot!
|
||||
logger.trade(`MM defensive: worst side ${worstKey.toUpperCase()} @ $${worstPrice.toFixed(3)} < $${threshold} — selling worst, keeping ${bestKey.toUpperCase()} @ $${bestPrice.toFixed(3)}`);
|
||||
|
||||
const worstSide = pos[worstKey];
|
||||
const bestSide = pos[bestKey];
|
||||
|
||||
// Get actual on-chain balances
|
||||
const [worstBalance, bestBalance] = await Promise.all([
|
||||
getTokenBalance(worstSide.tokenId),
|
||||
getTokenBalance(bestSide.tokenId),
|
||||
]);
|
||||
const worstShares = worstBalance !== null ? worstBalance : worstSide.shares;
|
||||
const bestShares = bestBalance !== null ? bestBalance : bestSide.shares;
|
||||
|
||||
// Market sell worst side
|
||||
if (worstShares >= 0.001) {
|
||||
const result = await marketSell(worstSide.tokenId, worstShares, tickSize, negRisk);
|
||||
worstSide.fillPrice = result.fillPrice;
|
||||
worstSide.filled = true;
|
||||
logger.warn(`MM defensive: sold ${worstKey.toUpperCase()} ${worstShares.toFixed(3)} sh @ $${result.fillPrice.toFixed(3)}`);
|
||||
} else {
|
||||
worstSide.fillPrice = 0;
|
||||
worstSide.filled = true;
|
||||
}
|
||||
|
||||
// Best side: let it resolve at market close (hold the tokens)
|
||||
// The market will resolve and we can redeem via the redeemer
|
||||
// Best side price is ~90c+ so payout ≈ $1 per share if it wins
|
||||
logger.money(`MM defensive: holding ${bestKey.toUpperCase()} ${bestShares.toFixed(3)} sh @ ~$${bestPrice.toFixed(3)} — waiting for resolution`);
|
||||
logger.info(`MM defensive: expected payout if ${bestKey.toUpperCase()} wins: ~$${bestShares.toFixed(2)} | cost was $${(bestSide.entryPrice * bestShares).toFixed(2)}`);
|
||||
|
||||
// Mark best side as filled at entry price for now — actual payout handled by redeemer
|
||||
bestSide.fillPrice = bestSide.entryPrice;
|
||||
bestSide.filled = true;
|
||||
pos.status = 'done';
|
||||
|
||||
const worstPnl = worstSide.fillPrice
|
||||
? (worstSide.fillPrice - worstSide.entryPrice) * worstShares
|
||||
: 0;
|
||||
logger.info(`MM defensive: worst side P&L: $${worstPnl.toFixed(2)} | best side will be redeemed after resolution`);
|
||||
} else {
|
||||
// Worst side ≥ 10c → market uncertain, safer to merge
|
||||
logger.info(`MM defensive: worst side ${worstKey.toUpperCase()} @ $${worstPrice.toFixed(3)} ≥ $${threshold} — market uncertain, merging back to USDC`);
|
||||
await cutLossNeitherFilled(pos);
|
||||
}
|
||||
}
|
||||
|
||||
async function adaptiveLegCL(pos, unfilledKey) {
|
||||
const s = pos[unfilledKey];
|
||||
const { tickSize, negRisk } = pos;
|
||||
const label = pos.question.substring(0, 40);
|
||||
const pollMs = config.mmAdaptiveMonitorSec * 1000;
|
||||
|
||||
// ── Minimum floor: unfilled leg must sell at least this price ──────────────
|
||||
// Ensures: filledLegPrice + unfilledLegPrice >= mmAdaptiveMinCombined
|
||||
// Example: filledLeg=0.60, minCombined=1.20 → floor=0.60
|
||||
// filledLeg=0.55, minCombined=1.20 → floor=0.65
|
||||
const filledKey = unfilledKey === 'yes' ? 'no' : 'yes';
|
||||
const filledLegPrice = pos[filledKey].fillPrice ?? config.mmSellPrice;
|
||||
const minAdaptivePrice = Math.max(0, config.mmAdaptiveMinCombined - filledLegPrice);
|
||||
|
||||
// ── Tiered floors (5m markets): progressively lower floor over time ────
|
||||
// Start from minAdaptivePrice (MM_ADAPTIVE_MIN_COMBINED - filledPrice), then drop per phase
|
||||
const floorDrop = config.mmDefensiveEnabled ? 0.10 : 0;
|
||||
const emergencyPrice = config.mmDefensiveWorstThreshold; // default 0.10
|
||||
|
||||
const is5m = config.mmDuration === '5m';
|
||||
|
||||
/**
|
||||
* Get the current floor based on time remaining (5m markets only).
|
||||
* Other durations use the fixed mmAdaptiveMinCombined floor.
|
||||
*
|
||||
* Phase 1 (> 180s left): minAdaptivePrice (from MM_ADAPTIVE_MIN_COMBINED)
|
||||
* Phase 2 (90–180s): minAdaptivePrice - 0.10
|
||||
* Phase 3 (30–90s): minAdaptivePrice - 0.20
|
||||
* Phase 4 (< 30s): market sell
|
||||
*/
|
||||
function getTieredFloor(msLeft) {
|
||||
if (!is5m) return minAdaptivePrice; // non-5m: use fixed floor
|
||||
if (msLeft > 180_000) return minAdaptivePrice;
|
||||
if (msLeft > 90_000) return Math.max(0.01, minAdaptivePrice - floorDrop);
|
||||
if (msLeft > 30_000) return Math.max(0.01, minAdaptivePrice - floorDrop * 2);
|
||||
return 0; // phase 4: market sell
|
||||
}
|
||||
|
||||
logger.warn(`MM: one leg filled — starting adaptive CL for ${unfilledKey.toUpperCase()} | ${label}`);
|
||||
if (is5m) {
|
||||
logger.info(`MM adaptive CL: filled @ $${filledLegPrice.toFixed(3)} | floor (minCombined $${config.mmAdaptiveMinCombined.toFixed(2)}): $${minAdaptivePrice.toFixed(3)} | tiered: $${minAdaptivePrice.toFixed(2)} → $${Math.max(0.01, minAdaptivePrice - floorDrop).toFixed(2)} → $${Math.max(0.01, minAdaptivePrice - floorDrop * 2).toFixed(2)}`);
|
||||
} else {
|
||||
logger.info(`MM adaptive CL: filled leg @ $${filledLegPrice.toFixed(3)} | min floor for combined ≥ $${config.mmAdaptiveMinCombined.toFixed(2)}: $${minAdaptivePrice.toFixed(3)}`);
|
||||
}
|
||||
|
||||
// Cancel the unfilled leg's old GTC order immediately
|
||||
await cancelOrder(s.orderId);
|
||||
s.orderId = null;
|
||||
|
||||
// Read actual on-chain balance once — reused for all subsequent sell orders
|
||||
const actualShares = await getTokenBalance(s.tokenId);
|
||||
const sellShares = actualShares !== null ? actualShares : s.shares;
|
||||
|
||||
if (sellShares < 0.001) {
|
||||
logger.warn(`MM adaptive CL: ${unfilledKey.toUpperCase()} balance is 0 — already fully sold`);
|
||||
s.fillPrice = config.mmSellPrice;
|
||||
s.filled = true;
|
||||
pos.status = 'done';
|
||||
return;
|
||||
}
|
||||
|
||||
// If remaining shares below CLOB minimum, market sell immediately instead of trying limit
|
||||
if (sellShares < CLOB_MIN_ORDER_SHARES) {
|
||||
logger.warn(`MM adaptive CL: ${unfilledKey.toUpperCase()} remaining ${sellShares.toFixed(3)} shares < ${CLOB_MIN_ORDER_SHARES} minimum — market selling immediately`);
|
||||
const result = await marketSell(s.tokenId, sellShares, tickSize, negRisk);
|
||||
s.fillPrice = result.fillPrice;
|
||||
s.filled = true;
|
||||
pos.status = 'done';
|
||||
const pnl = (s.fillPrice - s.entryPrice) * sellShares;
|
||||
const combined = filledLegPrice + s.fillPrice;
|
||||
logger.warn(`MM adaptive CL: ${unfilledKey.toUpperCase()} market-sold ${sellShares.toFixed(3)} sh @ $${s.fillPrice.toFixed(3)} | combined $${combined.toFixed(3)} | P&L $${pnl.toFixed(2)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Place standing order at breakeven floor immediately (5m) so brief bounces get caught
|
||||
let activeOrderId = null;
|
||||
let activeLimitPrice = 0;
|
||||
let currentFloor = minAdaptivePrice;
|
||||
|
||||
if (is5m && sellShares >= CLOB_MIN_ORDER_SHARES) {
|
||||
// Check mid price first — place at market price (not just breakeven floor)
|
||||
const initMid = await getMidprice(s.tokenId);
|
||||
// Use mid price if above floor, otherwise use floor as safety net
|
||||
const initSellPrice = initMid >= currentFloor
|
||||
? Math.min(initMid, config.mmSellPrice)
|
||||
: currentFloor;
|
||||
logger.info(`MM adaptive CL: mid=$${initMid.toFixed(3)}, placing initial limit sell @ $${initSellPrice.toFixed(3)} (floor=$${currentFloor.toFixed(3)})`);
|
||||
const standing = await placeLimitSell(s.tokenId, sellShares, initSellPrice, tickSize, negRisk);
|
||||
if (standing.success) {
|
||||
activeOrderId = standing.orderId;
|
||||
activeLimitPrice = initSellPrice;
|
||||
}
|
||||
} else {
|
||||
logger.info(`MM adaptive CL: monitoring ${unfilledKey.toUpperCase()} — floor $${currentFloor.toFixed(3)}, market-sell at CL time`);
|
||||
}
|
||||
|
||||
// ── Continuous monitoring loop ─────────────────────────────────────────────
|
||||
let lastPhaseLog = '';
|
||||
|
||||
while (true) {
|
||||
const msLeft = new Date(pos.endTime).getTime() - Date.now();
|
||||
|
||||
// ── Phase 4 / CL time: force market sell ────────────────────────────
|
||||
if (msLeft <= (is5m ? 30_000 : config.mmCutLossTime * 1000)) {
|
||||
if (activeOrderId) {
|
||||
await cancelOrder(activeOrderId);
|
||||
activeOrderId = null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Update tiered floor ─────────────────────────────────────────────
|
||||
const newFloor = getTieredFloor(msLeft);
|
||||
if (newFloor !== currentFloor) {
|
||||
const phase = msLeft > 180_000 ? '1-breakeven' : msLeft > 90_000 ? '2-controlled' : '3-emergency';
|
||||
if (phase !== lastPhaseLog) {
|
||||
logger.info(`MM adaptive CL: phase ${phase} — floor $${currentFloor.toFixed(3)} → $${newFloor.toFixed(3)} (${Math.round(msLeft / 1000)}s left)`);
|
||||
lastPhaseLog = phase;
|
||||
}
|
||||
// If floor lowered and we have an active order above new floor, keep it
|
||||
// Only cancel+re-place if the floor dropped below our current limit
|
||||
if (activeOrderId && activeLimitPrice > newFloor) {
|
||||
// Current limit is above new floor — that's fine, keep it
|
||||
} else if (activeOrderId && activeLimitPrice < newFloor) {
|
||||
// Floor raised (shouldn't happen in tiered, but safety)
|
||||
await cancelOrder(activeOrderId);
|
||||
activeOrderId = null;
|
||||
activeLimitPrice = 0;
|
||||
}
|
||||
currentFloor = newFloor;
|
||||
}
|
||||
|
||||
// ── Check fill ──────────────────────────────────────────────────────
|
||||
if (activeOrderId) {
|
||||
let filled = false;
|
||||
if (config.dryRun) {
|
||||
const hitPrice = await simPriceHitTarget(s.tokenId);
|
||||
if (hitPrice) { filled = true; s.fillPrice = hitPrice; }
|
||||
} else {
|
||||
filled = await isOrderFilled(activeOrderId, sellShares, s.tokenId);
|
||||
if (filled) s.fillPrice = activeLimitPrice;
|
||||
}
|
||||
|
||||
if (filled) {
|
||||
const pnl = (s.fillPrice - s.entryPrice) * sellShares;
|
||||
const combined = filledLegPrice + s.fillPrice;
|
||||
logger.money(`MM adaptive CL: ${unfilledKey.toUpperCase()} limit filled @ $${s.fillPrice.toFixed(3)} | combined $${combined.toFixed(3)} | P&L $${pnl.toFixed(2)}`);
|
||||
s.filled = true;
|
||||
pos.status = 'done';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Read current price ──────────────────────────────────────────────
|
||||
const currentPrice = await getMidprice(s.tokenId);
|
||||
if (currentPrice <= 0) {
|
||||
await sleep(pollMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Emergency cut: price < 10c in phase 3 → market sell immediately ─
|
||||
if (is5m && msLeft <= 90_000 && currentPrice < emergencyPrice) {
|
||||
logger.warn(`MM adaptive CL: EMERGENCY — price $${currentPrice.toFixed(3)} < $${emergencyPrice} with ${Math.round(msLeft / 1000)}s left — market selling now`);
|
||||
if (activeOrderId) {
|
||||
await cancelOrder(activeOrderId);
|
||||
activeOrderId = null;
|
||||
}
|
||||
break; // fall through to market sell below
|
||||
}
|
||||
|
||||
const targetPrice = Math.min(currentPrice, config.mmSellPrice);
|
||||
|
||||
// ── Adjust or cancel active limit ───────────────────────────────────
|
||||
if (activeOrderId) {
|
||||
const belowFloor = currentPrice < currentFloor;
|
||||
const droppedHard = currentPrice < activeLimitPrice * 0.95;
|
||||
const priceImproved = targetPrice > activeLimitPrice * 1.02;
|
||||
|
||||
if (belowFloor || droppedHard) {
|
||||
const reason = belowFloor
|
||||
? `below floor $${currentFloor.toFixed(3)}`
|
||||
: `dropped >5% from limit $${activeLimitPrice.toFixed(3)}`;
|
||||
logger.info(`MM adaptive CL: price $${currentPrice.toFixed(3)} ${reason} — cancelling limit, watching for recovery`);
|
||||
await cancelOrder(activeOrderId);
|
||||
activeOrderId = null;
|
||||
activeLimitPrice = 0;
|
||||
|
||||
} else if (priceImproved) {
|
||||
logger.info(`MM adaptive CL: price improved $${activeLimitPrice.toFixed(3)} → $${currentPrice.toFixed(3)} — raising limit to $${targetPrice.toFixed(3)}`);
|
||||
await cancelOrder(activeOrderId);
|
||||
activeOrderId = null;
|
||||
activeLimitPrice = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Place limit at floor or above ───────────────────────────────────
|
||||
if (!activeOrderId) {
|
||||
// Re-check actual balance — partial fills may have reduced it
|
||||
const currentBalance = await getTokenBalance(s.tokenId);
|
||||
const remainingShares = currentBalance !== null ? currentBalance : sellShares;
|
||||
|
||||
if (remainingShares < 0.001) {
|
||||
logger.warn(`MM adaptive CL: ${unfilledKey.toUpperCase()} balance is 0 — fully sold via partial fills`);
|
||||
s.fillPrice = config.mmSellPrice;
|
||||
s.filled = true;
|
||||
pos.status = 'done';
|
||||
return;
|
||||
}
|
||||
|
||||
if (remainingShares < CLOB_MIN_ORDER_SHARES) {
|
||||
logger.warn(`MM adaptive CL: ${unfilledKey.toUpperCase()} remaining ${remainingShares.toFixed(3)} shares < ${CLOB_MIN_ORDER_SHARES} minimum — market selling`);
|
||||
const result = await marketSell(s.tokenId, remainingShares, tickSize, negRisk);
|
||||
s.fillPrice = result.fillPrice;
|
||||
s.filled = true;
|
||||
pos.status = 'done';
|
||||
const pnl = (s.fillPrice - s.entryPrice) * remainingShares;
|
||||
const combined = filledLegPrice + s.fillPrice;
|
||||
logger.warn(`MM adaptive CL: ${unfilledKey.toUpperCase()} market-sold ${remainingShares.toFixed(3)} sh @ $${s.fillPrice.toFixed(3)} | combined $${combined.toFixed(3)} | P&L $${pnl.toFixed(2)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Place at max(currentPrice, floor) — standing order strategy
|
||||
const sellPrice = Math.max(currentPrice, currentFloor);
|
||||
const limitPrice = Math.min(sellPrice, config.mmSellPrice);
|
||||
|
||||
if (currentPrice >= currentFloor || is5m) {
|
||||
// 5m: always place at floor or above (standing order catches bounces)
|
||||
// non-5m: only place when price >= floor
|
||||
logger.info(`MM adaptive CL: placing limit sell @ $${limitPrice.toFixed(3)} (mid: $${currentPrice.toFixed(3)}, floor: $${currentFloor.toFixed(3)}, ${Math.round(msLeft / 1000)}s left)`);
|
||||
const result = await placeLimitSell(s.tokenId, remainingShares, limitPrice, tickSize, negRisk);
|
||||
if (result.success) {
|
||||
activeOrderId = result.orderId;
|
||||
activeLimitPrice = limitPrice;
|
||||
}
|
||||
} else {
|
||||
logger.info(`MM adaptive CL: price $${currentPrice.toFixed(3)} below floor $${currentFloor.toFixed(3)} — waiting for recovery (${Math.round(msLeft / 1000)}s left)`);
|
||||
}
|
||||
}
|
||||
|
||||
await sleep(pollMs);
|
||||
}
|
||||
|
||||
// ── Fallback: market sell at CL time ───────────────────────────────────────
|
||||
// Re-check actual balance before market sell (partial fills may have occurred)
|
||||
const finalBalance = await getTokenBalance(s.tokenId);
|
||||
const finalShares = finalBalance !== null ? finalBalance : sellShares;
|
||||
|
||||
if (finalShares < 0.001) {
|
||||
logger.warn(`MM adaptive CL: ${unfilledKey.toUpperCase()} balance is 0 at CL time — already fully sold`);
|
||||
s.fillPrice = config.mmSellPrice;
|
||||
s.filled = true;
|
||||
pos.status = 'done';
|
||||
return;
|
||||
}
|
||||
|
||||
const exitReason = is5m ? 'phase 4 force exit (<30s)' : 'CL time reached';
|
||||
logger.warn(`MM adaptive CL: ${exitReason} — market-selling ${finalShares.toFixed(3)} ${unfilledKey.toUpperCase()} shares`);
|
||||
const result = await marketSell(s.tokenId, finalShares, tickSize, negRisk);
|
||||
s.fillPrice = result.fillPrice;
|
||||
const pnl = (s.fillPrice - s.entryPrice) * finalShares;
|
||||
const combined = filledLegPrice + s.fillPrice;
|
||||
logger.warn(`MM adaptive CL: ${unfilledKey.toUpperCase()} market-sold @ $${s.fillPrice.toFixed(3)} | combined $${combined.toFixed(3)} | sold ${finalShares.toFixed(3)} sh | P&L $${pnl.toFixed(2)}`);
|
||||
|
||||
s.filled = true;
|
||||
pos.status = 'done';
|
||||
}
|
||||
|
||||
// ── Recovery buy ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -1085,12 +468,10 @@ export async function executeMMStrategy(market) {
|
||||
const entryPrice = 0.50;
|
||||
logger.info(`MM${tag}: split done — ${shares} YES + ${shares} NO @ $${entryPrice}`);
|
||||
|
||||
// ── Place limit sells (parallel) ────────────────────────────
|
||||
// ── Place limit sells ───────────────────────────────────────
|
||||
logger.info(`MM${tag}: ${sim}placing limit sells @ $${config.mmSellPrice}`);
|
||||
const [yesSell, noSell] = await Promise.all([
|
||||
placeLimitSell(yesTokenId, shares, config.mmSellPrice, tickSize, negRisk),
|
||||
placeLimitSell(noTokenId, shares, config.mmSellPrice, tickSize, negRisk),
|
||||
]);
|
||||
const yesSell = await placeLimitSell(yesTokenId, shares, config.mmSellPrice, tickSize, negRisk);
|
||||
const noSell = await placeLimitSell(noTokenId, shares, config.mmSellPrice, tickSize, negRisk);
|
||||
|
||||
if (!yesSell.success || !noSell.success) {
|
||||
logger.error(`MM${tag}: failed to place limit sells — cutting immediately`);
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
/**
|
||||
* mmWsFillWatcher.js
|
||||
* Real-time order fill detection for the Market Maker via Polymarket RTDS WebSocket.
|
||||
*
|
||||
* Subscribes to the `activity` topic and filters events by the bot's own proxy wallet.
|
||||
* When a SELL trade is detected on a token we're watching, emits a 'fill' event
|
||||
* so mmExecutor can react instantly instead of polling every 10s.
|
||||
*
|
||||
* Usage:
|
||||
* import { mmFillWatcher } from './mmWsFillWatcher.js';
|
||||
* mmFillWatcher.watch(tokenId); // start watching a token
|
||||
* mmFillWatcher.unwatch(tokenId); // stop watching
|
||||
* mmFillWatcher.on('fill', ({ tokenId, size, price }) => { ... });
|
||||
* mmFillWatcher.start();
|
||||
* mmFillWatcher.stop();
|
||||
*/
|
||||
|
||||
import WebSocket from 'ws';
|
||||
import { EventEmitter } from 'events';
|
||||
import config from '../config/index.js';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
const RTDS_WS_URL = 'wss://ws-live-data.polymarket.com';
|
||||
const PING_INTERVAL_MS = 5000;
|
||||
const INITIAL_RECONNECT_DELAY = 2000;
|
||||
const MAX_RECONNECT_DELAY = 30000;
|
||||
|
||||
class MMFillWatcher extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this._ws = null;
|
||||
this._pingTimer = null;
|
||||
this._reconnectTimer = null;
|
||||
this._reconnectDelay = INITIAL_RECONNECT_DELAY;
|
||||
this._shuttingDown = false;
|
||||
this._watchedTokens = new Set(); // token IDs we care about
|
||||
this._connected = false;
|
||||
}
|
||||
|
||||
/** Register a token ID to watch for fills */
|
||||
watch(tokenId) {
|
||||
if (tokenId) this._watchedTokens.add(tokenId);
|
||||
}
|
||||
|
||||
/** Stop watching a token ID */
|
||||
unwatch(tokenId) {
|
||||
this._watchedTokens.delete(tokenId);
|
||||
}
|
||||
|
||||
/** Check if currently connected */
|
||||
get connected() {
|
||||
return this._connected;
|
||||
}
|
||||
|
||||
/** Start the WebSocket connection */
|
||||
start() {
|
||||
this._shuttingDown = false;
|
||||
this._reconnectDelay = INITIAL_RECONNECT_DELAY;
|
||||
this._connect();
|
||||
}
|
||||
|
||||
/** Gracefully stop */
|
||||
stop() {
|
||||
this._shuttingDown = true;
|
||||
this._cleanup(false);
|
||||
this._watchedTokens.clear();
|
||||
logger.info('MM fill watcher stopped');
|
||||
}
|
||||
|
||||
// ── Internal ─────────────────────────────────────────────────────────────
|
||||
|
||||
_connect() {
|
||||
if (this._shuttingDown) return;
|
||||
|
||||
logger.info('MM fill watcher: connecting to RTDS WebSocket...');
|
||||
this._ws = new WebSocket(RTDS_WS_URL);
|
||||
|
||||
this._ws.on('open', () => {
|
||||
this._connected = true;
|
||||
this._reconnectDelay = INITIAL_RECONNECT_DELAY;
|
||||
logger.success('MM fill watcher: WebSocket connected');
|
||||
|
||||
this._ws.send(JSON.stringify({
|
||||
action: 'subscribe',
|
||||
subscriptions: [{
|
||||
topic: 'activity',
|
||||
type: 'trades',
|
||||
}],
|
||||
}));
|
||||
|
||||
this._startPing();
|
||||
});
|
||||
|
||||
this._ws.on('message', (data) => this._handleMessage(data));
|
||||
|
||||
this._ws.on('ping', () => {
|
||||
this._ws?.pong();
|
||||
});
|
||||
|
||||
this._ws.on('close', (code, reason) => {
|
||||
this._connected = false;
|
||||
const reasonStr = reason ? reason.toString() : 'no reason';
|
||||
logger.warn(`MM fill watcher: WS closed (${code}): ${reasonStr}`);
|
||||
this._cleanup(true);
|
||||
});
|
||||
|
||||
this._ws.on('error', (err) => {
|
||||
this._connected = false;
|
||||
logger.error(`MM fill watcher: WS error: ${err.message}`);
|
||||
this._cleanup(true);
|
||||
});
|
||||
}
|
||||
|
||||
_handleMessage(rawData) {
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(rawData.toString());
|
||||
} catch {
|
||||
const text = rawData.toString().trim();
|
||||
if (text === 'ping') this._ws?.send('pong');
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'ping' || msg === 'ping') {
|
||||
this._ws?.send('pong');
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.topic !== 'activity') return;
|
||||
|
||||
const payload = msg.payload;
|
||||
if (!payload) return;
|
||||
|
||||
// Filter: only our own proxy wallet
|
||||
const ourWallet = config.proxyWallet?.toLowerCase();
|
||||
if (!ourWallet) return;
|
||||
|
||||
const proxyWallet = (payload.proxyWallet || payload.proxy_wallet || '').toLowerCase();
|
||||
if (proxyWallet !== ourWallet) return;
|
||||
|
||||
// Filter: only tokens we're watching
|
||||
const tokenId = payload.asset || '';
|
||||
if (!tokenId || !this._watchedTokens.has(tokenId)) return;
|
||||
|
||||
const side = (payload.side || '').toUpperCase();
|
||||
const size = parseFloat(payload.size || '0');
|
||||
const price = parseFloat(payload.price || '0');
|
||||
|
||||
if (size <= 0) return;
|
||||
|
||||
logger.info(`MM fill watcher: detected ${side} on token ${tokenId.slice(-8)} — ${size} shares @ $${price.toFixed(3)}`);
|
||||
|
||||
this.emit('fill', {
|
||||
tokenId,
|
||||
side,
|
||||
size,
|
||||
price,
|
||||
conditionId: payload.conditionId || payload.condition_id || '',
|
||||
timestamp: payload.timestamp || new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
_startPing() {
|
||||
this._stopPing();
|
||||
this._pingTimer = setInterval(() => {
|
||||
if (this._ws?.readyState === WebSocket.OPEN) {
|
||||
this._ws.send('ping');
|
||||
}
|
||||
}, PING_INTERVAL_MS);
|
||||
}
|
||||
|
||||
_stopPing() {
|
||||
if (this._pingTimer) {
|
||||
clearInterval(this._pingTimer);
|
||||
this._pingTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
_cleanup(reconnect = true) {
|
||||
this._stopPing();
|
||||
if (this._reconnectTimer) {
|
||||
clearTimeout(this._reconnectTimer);
|
||||
this._reconnectTimer = null;
|
||||
}
|
||||
if (this._ws) {
|
||||
this._ws.removeAllListeners();
|
||||
if (this._ws.readyState === WebSocket.OPEN || this._ws.readyState === WebSocket.CONNECTING) {
|
||||
this._ws.terminate();
|
||||
}
|
||||
this._ws = null;
|
||||
}
|
||||
this._connected = false;
|
||||
if (reconnect && !this._shuttingDown) {
|
||||
this._scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
_scheduleReconnect() {
|
||||
logger.info(`MM fill watcher: reconnecting in ${this._reconnectDelay / 1000}s...`);
|
||||
this._reconnectTimer = setTimeout(() => {
|
||||
this._reconnectDelay = Math.min(this._reconnectDelay * 2, MAX_RECONNECT_DELAY);
|
||||
this._connect();
|
||||
}, this._reconnectDelay);
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const mmFillWatcher = new MMFillWatcher();
|
||||
+39
-41
@@ -1,13 +1,16 @@
|
||||
import { ethers } from 'ethers';
|
||||
import config from '../config/index.js';
|
||||
import { getPolygonProvider } from './client.js';
|
||||
import { execSafeCall, CTF_ADDRESS, USDC_ADDRESS } from './ctf.js';
|
||||
import { getOpenPositions, removePosition } from './position.js';
|
||||
import { recordSimResult } from '../utils/simStats.js';
|
||||
import logger from '../utils/logger.js';
|
||||
import { proxyFetch } from '../utils/proxy.js';
|
||||
|
||||
// CTF ABI (minimal — read-only calls only; writes go through execSafeCall)
|
||||
// Contract addresses on Polygon
|
||||
const CTF_ADDRESS = '0x4D97DCd97eC945f40cF65F87097ACe5EA0476045';
|
||||
const NEG_RISK_CTF_ADDRESS = '0xC5d563A36AE78145C45a50134d48A1215220f80a';
|
||||
const USDC_ADDRESS = '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174';
|
||||
|
||||
// CTF ABI (minimal for redeemPositions & balanceOf)
|
||||
const CTF_ABI = [
|
||||
'function redeemPositions(address collateralToken, bytes32 parentCollectionId, bytes32 conditionId, uint256[] indexSets)',
|
||||
'function balanceOf(address owner, uint256 tokenId) view returns (uint256)',
|
||||
@@ -21,7 +24,7 @@ const CTF_ABI = [
|
||||
async function checkMarketResolution(conditionId) {
|
||||
try {
|
||||
const url = `${config.gammaHost}/markets?condition_id=${conditionId}`;
|
||||
const response = await proxyFetch(url);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) return null;
|
||||
|
||||
const markets = await response.json();
|
||||
@@ -64,30 +67,33 @@ async function checkOnChainPayout(conditionId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeem winning position on-chain via the Gnosis Safe proxy wallet.
|
||||
* Uses execSafeCall (same as MM bot) so:
|
||||
* - tx is signed by the EOA but executed FROM the proxy wallet
|
||||
* - Polygon 30 Gwei minimum tip is enforced
|
||||
* - automatic retry on transient errors
|
||||
* Redeem winning position on-chain (real mode only)
|
||||
*/
|
||||
async function redeemPosition(conditionId) {
|
||||
async function redeemPosition(conditionId, isNegRisk = false) {
|
||||
try {
|
||||
const ctfIface = new ethers.utils.Interface(CTF_ABI);
|
||||
const data = ctfIface.encodeFunctionData('redeemPositions', [
|
||||
USDC_ADDRESS,
|
||||
ethers.constants.HashZero,
|
||||
conditionId,
|
||||
[1, 2],
|
||||
]);
|
||||
const provider = await getPolygonProvider();
|
||||
const wallet = new ethers.Wallet(config.privateKey, provider);
|
||||
const ctfAddress = isNegRisk ? NEG_RISK_CTF_ADDRESS : CTF_ADDRESS;
|
||||
const ctf = new ethers.Contract(ctfAddress, CTF_ABI, wallet);
|
||||
|
||||
const label = conditionId.slice(0, 12) + '...';
|
||||
logger.info(`Redeeming position: ${label}`);
|
||||
const receipt = await execSafeCall(CTF_ADDRESS, data, `redeemPositions ${label}`);
|
||||
const txHash = receipt.transactionHash;
|
||||
logger.success(`Redeemed in block ${receipt.blockNumber} | tx: ${txHash}`);
|
||||
const parentCollectionId = ethers.constants.HashZero;
|
||||
const indexSets = [1, 2];
|
||||
|
||||
logger.info(`Redeeming position: ${conditionId}`);
|
||||
const tx = await ctf.redeemPositions(
|
||||
USDC_ADDRESS,
|
||||
parentCollectionId,
|
||||
conditionId,
|
||||
indexSets,
|
||||
{ gasLimit: 300000 },
|
||||
);
|
||||
|
||||
logger.info(`Redeem tx: ${tx.hash}`);
|
||||
const receipt = await tx.wait();
|
||||
logger.success(`Redeemed in block ${receipt.blockNumber}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.error(`Failed to redeem: ${err.message}`);
|
||||
logger.error('Failed to redeem:', err.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -142,22 +148,17 @@ export async function checkAndRedeemPositions() {
|
||||
|
||||
for (const position of positions) {
|
||||
try {
|
||||
// 1. Quick check via Gamma API (low cost)
|
||||
// 1. Check via Gamma API
|
||||
const resolution = await checkMarketResolution(position.conditionId);
|
||||
const apiResolved = resolution?.resolved;
|
||||
const isResolved = resolution?.resolved;
|
||||
|
||||
if (!apiResolved) continue; // Not resolved yet — check again next interval
|
||||
|
||||
logger.info(`Market resolved via API: ${position.market}`);
|
||||
|
||||
// 2. ALWAYS verify on-chain payout before calling redeemPositions.
|
||||
// Gamma API can report "resolved" before payoutDenominator is written
|
||||
// on-chain. Calling redeemPositions with payoutDenominator == 0 causes
|
||||
// the contract to revert → gas estimation failure.
|
||||
const onChain = await checkOnChainPayout(position.conditionId);
|
||||
if (!onChain.resolved) {
|
||||
logger.info(`On-chain payout not set yet for ${position.market} — will retry next interval`);
|
||||
continue;
|
||||
if (!isResolved) {
|
||||
// 2. Fallback: on-chain check
|
||||
const onChain = await checkOnChainPayout(position.conditionId);
|
||||
if (!onChain.resolved) continue;
|
||||
logger.info(`Market resolved on-chain: ${position.market}`);
|
||||
} else {
|
||||
logger.info(`Market resolved: ${position.market}`);
|
||||
}
|
||||
|
||||
// 3. Simulate or execute real redeem
|
||||
@@ -167,14 +168,11 @@ export async function checkAndRedeemPositions() {
|
||||
const success = await redeemPosition(position.conditionId);
|
||||
if (success) {
|
||||
removePosition(position.conditionId);
|
||||
logger.money(`Redeemed: ${position.market} → USDC recovered`);
|
||||
} else {
|
||||
logger.warn(`Redeem failed for ${position.market}, will retry next interval — continuing to next position...`);
|
||||
logger.money(`Redeemed: ${position.market}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Error checking ${position.market}:`, err.message);
|
||||
logger.info(`Continuing to next position...`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
/**
|
||||
* schedule.js
|
||||
* Trading session schedule for the sniper bot.
|
||||
* Reads schedule from .env via config (SNIPER_SCHEDULE_*).
|
||||
*
|
||||
* All times are in UTC+8 and converted to UTC internally.
|
||||
* Assets outside their session window are skipped by the sniper detector.
|
||||
*
|
||||
* .env format per asset:
|
||||
* SNIPER_SCHEDULE_BTC=19:40-22:40,03:40-06:10
|
||||
* SNIPER_SCHEDULE_ETH=11:40-15:40,16:40-19:40
|
||||
*/
|
||||
|
||||
import config from '../config/index.js';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
// UTC+8 offset in hours
|
||||
const UTC8_OFFSET = 8;
|
||||
|
||||
/**
|
||||
* Parse schedule string from .env.
|
||||
* Format: "HH:MM-HH:MM,HH:MM-HH:MM"
|
||||
* Returns: [{ startUtc8, endUtc8, startMin, endMin }]
|
||||
*/
|
||||
function parseScheduleString(str) {
|
||||
if (!str || !str.trim()) return null;
|
||||
|
||||
const sessions = [];
|
||||
const parts = str.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
|
||||
for (const part of parts) {
|
||||
const match = part.match(/^(\d{1,2}:\d{2})\s*[-–]\s*(\d{1,2}:\d{2})$/);
|
||||
if (!match) {
|
||||
logger.warn(`SCHEDULE: invalid session format "${part}" — expected HH:MM-HH:MM`);
|
||||
continue;
|
||||
}
|
||||
const [, startUtc8, endUtc8] = match;
|
||||
sessions.push({
|
||||
startUtc8,
|
||||
endUtc8,
|
||||
startMin: utc8ToUtcMinutes(startUtc8),
|
||||
endMin: utc8ToUtcMinutes(endUtc8),
|
||||
});
|
||||
}
|
||||
|
||||
return sessions.length > 0 ? sessions : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert HH:MM in UTC+8 to minutes-since-midnight in UTC.
|
||||
* Result is always in [0, 1440).
|
||||
*/
|
||||
function utc8ToUtcMinutes(hhmm) {
|
||||
const [h, m] = hhmm.split(':').map(Number);
|
||||
let totalMin = (h * 60 + m) - (UTC8_OFFSET * 60);
|
||||
if (totalMin < 0) totalMin += 1440;
|
||||
if (totalMin >= 1440) totalMin -= 1440;
|
||||
return totalMin;
|
||||
}
|
||||
|
||||
// Build schedule from config (reads SNIPER_SCHEDULE_* from .env)
|
||||
const SCHEDULE = {};
|
||||
const SCHEDULE_DISPLAY = {};
|
||||
|
||||
for (const [asset, raw] of Object.entries(config.sniperSchedule || {})) {
|
||||
const sessions = parseScheduleString(raw);
|
||||
if (sessions) {
|
||||
SCHEDULE[asset] = sessions;
|
||||
SCHEDULE_DISPLAY[asset] = sessions.map((s) => ({
|
||||
startUtc8: s.startUtc8,
|
||||
endUtc8: s.endUtc8,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current time as minutes since midnight UTC.
|
||||
*/
|
||||
function nowMinutesUTC() {
|
||||
const d = new Date();
|
||||
return d.getUTCHours() * 60 + d.getUTCMinutes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if `nowMin` falls within range [start, end).
|
||||
* Handles overnight wrap (e.g. 22:00–02:00).
|
||||
*/
|
||||
function inRange(nowMin, startMin, endMin) {
|
||||
if (startMin <= endMin) {
|
||||
return nowMin >= startMin && nowMin < endMin;
|
||||
} else {
|
||||
return nowMin >= startMin || nowMin < endMin;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an asset is currently within its trading session.
|
||||
* Returns true if asset has no schedule (always active).
|
||||
*/
|
||||
export function isAssetInSession(asset) {
|
||||
const sessions = SCHEDULE[asset.toLowerCase()];
|
||||
if (!sessions) return true; // no schedule = always active
|
||||
|
||||
const now = nowMinutesUTC();
|
||||
return sessions.some((s) => inRange(now, s.startMin, s.endMin));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable time until the next session opens.
|
||||
* Returns string like "2h 15m" or null if currently in session.
|
||||
*/
|
||||
export function getNextSessionInfo(asset) {
|
||||
const sessions = SCHEDULE[asset.toLowerCase()];
|
||||
if (!sessions) return null;
|
||||
|
||||
const now = nowMinutesUTC();
|
||||
if (sessions.some((s) => inRange(now, s.startMin, s.endMin))) return null;
|
||||
|
||||
let minWait = Infinity;
|
||||
for (const s of sessions) {
|
||||
let wait = s.startMin - now;
|
||||
if (wait <= 0) wait += 1440;
|
||||
if (wait < minWait) minWait = wait;
|
||||
}
|
||||
|
||||
if (minWait === Infinity) return null;
|
||||
|
||||
const hours = Math.floor(minWait / 60);
|
||||
const mins = minWait % 60;
|
||||
if (hours > 0) return `${hours}h ${mins}m`;
|
||||
return `${mins}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the schedule for display (UTC+8 strings).
|
||||
*/
|
||||
export function getSchedule() {
|
||||
return SCHEDULE_DISPLAY;
|
||||
}
|
||||
@@ -14,12 +14,10 @@
|
||||
|
||||
import config from '../config/index.js';
|
||||
import logger from '../utils/logger.js';
|
||||
import { isAssetInSession, getNextSessionInfo } from './schedule.js';
|
||||
import { proxyFetch } from '../utils/proxy.js';
|
||||
|
||||
const SLOT_SEC = 5 * 60; // 300 seconds
|
||||
|
||||
let pollTimer = null;
|
||||
let pollTimer = null;
|
||||
let onMarketCb = null;
|
||||
const seenKeys = new Set(); // `${asset}-${slotTimestamp}` already handled
|
||||
|
||||
@@ -38,7 +36,7 @@ function nextSlot() {
|
||||
async function fetchBySlug(asset, slotTimestamp) {
|
||||
const slug = `${asset}-updown-5m-${slotTimestamp}`;
|
||||
try {
|
||||
const resp = await proxyFetch(`${config.gammaHost}/markets/slug/${slug}`);
|
||||
const resp = await fetch(`${config.gammaHost}/markets/slug/${slug}`);
|
||||
if (!resp.ok) return null;
|
||||
const data = await resp.json();
|
||||
return data?.conditionId ? data : null;
|
||||
@@ -63,7 +61,7 @@ function extractMarketData(market, asset) {
|
||||
[yesTokenId, noTokenId] = tokenIds;
|
||||
} else if (Array.isArray(market.tokens) && market.tokens.length >= 2) {
|
||||
yesTokenId = market.tokens[0]?.token_id ?? market.tokens[0]?.tokenId;
|
||||
noTokenId = market.tokens[1]?.token_id ?? market.tokens[1]?.tokenId;
|
||||
noTokenId = market.tokens[1]?.token_id ?? market.tokens[1]?.tokenId;
|
||||
}
|
||||
|
||||
if (!yesTokenId || !noTokenId) return null;
|
||||
@@ -71,13 +69,13 @@ function extractMarketData(market, asset) {
|
||||
return {
|
||||
asset,
|
||||
conditionId,
|
||||
question: market.question || market.title || '',
|
||||
endTime: market.endDate || market.end_date_iso || market.endDateIso,
|
||||
question: market.question || market.title || '',
|
||||
endTime: market.endDate || market.end_date_iso || market.endDateIso,
|
||||
eventStartTime: market.eventStartTime || market.event_start_time,
|
||||
yesTokenId: String(yesTokenId),
|
||||
noTokenId: String(noTokenId),
|
||||
negRisk: market.negRisk ?? market.neg_risk ?? false,
|
||||
tickSize: String(market.orderPriceMinTickSize ?? market.minimum_tick_size ?? '0.01'),
|
||||
yesTokenId: String(yesTokenId),
|
||||
noTokenId: String(noTokenId),
|
||||
negRisk: market.negRisk ?? market.neg_risk ?? false,
|
||||
tickSize: String(market.orderPriceMinTickSize ?? market.minimum_tick_size ?? '0.01'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -101,8 +99,8 @@ async function scheduleAsset(asset, slotTimestamp, isCurrent = false) {
|
||||
|
||||
if (isCurrent) {
|
||||
// Current slot: only place orders if there's at least 30 seconds of market left
|
||||
const endAt = data.endTime ? new Date(data.endTime).getTime() : (slotTimestamp + SLOT_SEC) * 1000;
|
||||
const secsLeft = Math.round((endAt - Date.now()) / 1000);
|
||||
const endAt = data.endTime ? new Date(data.endTime).getTime() : (slotTimestamp + SLOT_SEC) * 1000;
|
||||
const secsLeft = Math.round((endAt - Date.now()) / 1000);
|
||||
if (secsLeft < 30) {
|
||||
logger.info(`SNIPER: ${asset.toUpperCase()} current market closing soon (${secsLeft}s) — skipping`);
|
||||
return;
|
||||
@@ -110,7 +108,7 @@ async function scheduleAsset(asset, slotTimestamp, isCurrent = false) {
|
||||
logger.success(`SNIPER: ${asset.toUpperCase()} current market active (${secsLeft}s left) — placing orders now`);
|
||||
} else {
|
||||
// Next slot: market hasn't opened yet
|
||||
const openAt = data.eventStartTime ? new Date(data.eventStartTime).getTime() : slotTimestamp * 1000;
|
||||
const openAt = data.eventStartTime ? new Date(data.eventStartTime).getTime() : slotTimestamp * 1000;
|
||||
const secsUntilOpen = Math.round((openAt - Date.now()) / 1000);
|
||||
logger.success(`SNIPER: ${asset.toUpperCase()} found "${data.question.slice(0, 40)}"${secsUntilOpen > 0 ? ` — ${secsUntilOpen}s before open` : ''}`);
|
||||
}
|
||||
@@ -124,25 +122,8 @@ async function poll() {
|
||||
try {
|
||||
const curr = currentSlot();
|
||||
const next = nextSlot();
|
||||
|
||||
// Filter assets by trading session schedule
|
||||
const activeAssets = config.sniperAssets.filter((asset) => {
|
||||
if (!isAssetInSession(asset)) {
|
||||
const nextInfo = getNextSessionInfo(asset);
|
||||
const key = `skip-${asset}-${Math.floor(Date.now() / 60000)}`; // log once per minute
|
||||
if (!seenKeys.has(key)) {
|
||||
seenKeys.add(key);
|
||||
logger.info(`SNIPER: ${asset.toUpperCase()} outside session window${nextInfo ? ` — next in ${nextInfo}` : ''}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (activeAssets.length === 0) return;
|
||||
|
||||
// Check current active market AND the upcoming next one, in parallel for each asset
|
||||
await Promise.all(activeAssets.flatMap((asset) => [
|
||||
await Promise.all(config.sniperAssets.flatMap((asset) => [
|
||||
scheduleAsset(asset, curr, true), // current market (if still has time left)
|
||||
scheduleAsset(asset, next, false), // next upcoming market
|
||||
]));
|
||||
@@ -164,8 +145,7 @@ export function startSniperDetector(onNewMarket) {
|
||||
const secsUntil = ns - Math.floor(Date.now() / 1000);
|
||||
logger.info(`SNIPER detector started — assets: ${config.sniperAssets.join(', ').toUpperCase()}`);
|
||||
logger.info(`Next slot: *-updown-5m-${ns} (opens in ${secsUntil}s)`);
|
||||
const prices = config.sniperTierPrices;
|
||||
logger.info(`Order: 3-tier ${prices[0]}c/${prices[1]}c/${prices[2]}c (max ${config.sniperMaxShares} shares per side)`);
|
||||
logger.info(`Order: $${config.sniperPrice} × ${config.sniperShares} shares per side`);
|
||||
}
|
||||
|
||||
export function stopSniperDetector() {
|
||||
|
||||
+54
-111
@@ -1,148 +1,91 @@
|
||||
/**
|
||||
* sniperExecutor.js
|
||||
* 3-Tier Sniper Strategy:
|
||||
* - Tier 1: 3c price, smallest size (20% of max)
|
||||
* - Tier 2: 2c price, medium size (30% of max)
|
||||
* - Tier 3: 1c price, largest size (50% of max)
|
||||
* Min 5 shares per tier, total = SNIPER_MAX_SHARES × timeMultiplier
|
||||
* Places GTC limit BUY orders at a very low price on both sides of a market.
|
||||
*
|
||||
* Strategy:
|
||||
* - For each market detected by sniperDetector, place two GTC BUY orders:
|
||||
* UP token at $SNIPER_PRICE × SNIPER_SHARES shares
|
||||
* DOWN token at $SNIPER_PRICE × SNIPER_SHARES shares
|
||||
* - Orders sit in the orderbook. If someone panic-dumps below the price,
|
||||
* the order fills and becomes redeemable if that side wins.
|
||||
* - GTC orders expire automatically when the market closes — no cleanup needed.
|
||||
*
|
||||
* Cost per market: SNIPER_PRICE × SNIPER_SHARES × 2 sides
|
||||
* e.g. $0.01 × 5 × 2 = $0.10 per market, $0.30 for 3 assets per 5-min slot
|
||||
*/
|
||||
|
||||
import { Side, OrderType } from '@polymarket/clob-client';
|
||||
import config from '../config/index.js';
|
||||
import { getClient } from './client.js';
|
||||
import logger from '../utils/logger.js';
|
||||
import { getTimeMultiplier } from './sniperSizing.js';
|
||||
|
||||
// In-memory tracking of placed snipe orders
|
||||
const activeSnipes = [];
|
||||
|
||||
// conditionId → { asset, yesTokenId, noTokenId } mapping
|
||||
// yesTokenId = outcome 0 (clobTokenIds[0]), noTokenId = outcome 1 (clobTokenIds[1])
|
||||
const conditionInfoMap = new Map();
|
||||
// In-memory tracking of placed snipe orders (for TUI status panel)
|
||||
const activeSnipes = []; // { asset, side, question, orderId, price, shares, cost, potentialPayout }
|
||||
|
||||
export function getActiveSnipes() {
|
||||
return [...activeSnipes];
|
||||
}
|
||||
|
||||
export function getConditionAsset(conditionId) {
|
||||
return conditionInfoMap.get(conditionId)?.asset || null;
|
||||
}
|
||||
|
||||
export function getConditionInfo(conditionId) {
|
||||
return conditionInfoMap.get(conditionId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate tier sizes based on max shares.
|
||||
* Distribution: 20% | 30% | 50% (high→low price)
|
||||
* Minimum 5 shares per tier.
|
||||
*/
|
||||
function calculateTierSizes(maxShares, minPerTier) {
|
||||
// Distribution percentages
|
||||
const ratios = [0.20, 0.30, 0.50]; // Tier 1, 2, 3
|
||||
|
||||
const sizes = ratios.map(ratio => {
|
||||
const size = Math.floor(maxShares * ratio);
|
||||
return Math.max(size, minPerTier);
|
||||
});
|
||||
|
||||
// Ensure we don't exceed maxShares after rounding up to minimums
|
||||
const total = sizes.reduce((a, b) => a + b, 0);
|
||||
if (total > maxShares) {
|
||||
// Adjust tier 3 (largest) down if needed
|
||||
sizes[2] = Math.max(minPerTier, sizes[2] - (total - maxShares));
|
||||
}
|
||||
|
||||
return sizes;
|
||||
}
|
||||
|
||||
export async function executeSnipe(market) {
|
||||
const { asset, conditionId, question, yesTokenId, noTokenId, tickSize, negRisk } = market;
|
||||
const label = question.slice(0, 40);
|
||||
const sim = config.dryRun ? '[SIM] ' : '';
|
||||
|
||||
// Track conditionId → { asset, token IDs } for win detection
|
||||
// yesTokenId = outcome 0 (clobTokenIds[0]), noTokenId = outcome 1 (clobTokenIds[1])
|
||||
conditionInfoMap.set(conditionId, {
|
||||
asset: asset.toLowerCase(),
|
||||
yesTokenId,
|
||||
noTokenId,
|
||||
});
|
||||
|
||||
const sides = [
|
||||
{ name: 'UP', tokenId: yesTokenId },
|
||||
{ name: 'DOWN', tokenId: noTokenId },
|
||||
];
|
||||
|
||||
// Apply time-based multiplier
|
||||
const { multiplier, label: mulLabel } = getTimeMultiplier();
|
||||
const effectiveMaxShares = Math.max(
|
||||
config.sniperMinSharesPerTier * 3,
|
||||
Math.round(config.sniperMaxShares * multiplier),
|
||||
);
|
||||
|
||||
const prices = config.sniperTierPrices;
|
||||
const sizes = calculateTierSizes(effectiveMaxShares, config.sniperMinSharesPerTier);
|
||||
|
||||
const mulInfo = multiplier !== 1.0 ? ` | mul ${mulLabel}` : '';
|
||||
logger.info(`SNIPER: ${sim}${asset.toUpperCase()} — "${label}" | 3-tier: 3c×${sizes[0]} | 2c×${sizes[1]} | 1c×${sizes[2]}${mulInfo}`);
|
||||
logger.info(`SNIPER: ${sim}${asset.toUpperCase()} — "${label}" | $${config.sniperPrice} × ${config.sniperShares}sh each side`);
|
||||
|
||||
for (const { name, tokenId } of sides) {
|
||||
// Place 3 orders per side
|
||||
for (let tier = 0; tier < 3; tier++) {
|
||||
const price = prices[tier];
|
||||
const size = sizes[tier];
|
||||
if (config.dryRun) {
|
||||
const cost = config.sniperPrice * config.sniperShares;
|
||||
logger.trade(`SNIPER[SIM]: ${asset.toUpperCase()} ${name} @ $${config.sniperPrice} × ${config.sniperShares}sh | cost $${cost.toFixed(3)} | payout $${config.sniperShares} if wins`);
|
||||
activeSnipes.push({
|
||||
asset: asset.toUpperCase(),
|
||||
side: name,
|
||||
question: label,
|
||||
orderId: `sim-${Date.now()}-${tokenId.slice(-6)}`,
|
||||
price: config.sniperPrice,
|
||||
shares: config.sniperShares,
|
||||
cost,
|
||||
potentialPayout: config.sniperShares,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (config.dryRun) {
|
||||
const cost = price * size;
|
||||
logger.trade(`SNIPER[SIM]: ${asset.toUpperCase()} ${name} T${tier+1} @ $${price.toFixed(2)} × ${size}sh | cost $${cost.toFixed(3)}`);
|
||||
const client = getClient();
|
||||
try {
|
||||
const res = await client.createAndPostOrder(
|
||||
{
|
||||
tokenID: tokenId,
|
||||
side: Side.BUY,
|
||||
price: config.sniperPrice,
|
||||
size: config.sniperShares,
|
||||
},
|
||||
{ tickSize, negRisk },
|
||||
OrderType.GTC,
|
||||
);
|
||||
|
||||
if (res?.success) {
|
||||
const cost = config.sniperPrice * config.sniperShares;
|
||||
logger.trade(`SNIPER: ${asset.toUpperCase()} ${name} @ $${config.sniperPrice} × ${config.sniperShares}sh | cost $${cost.toFixed(3)} | order ${res.orderID}`);
|
||||
activeSnipes.push({
|
||||
asset: asset.toUpperCase(),
|
||||
side: name,
|
||||
tier: tier + 1,
|
||||
question: label,
|
||||
orderId: `sim-${Date.now()}-${tier}-${tokenId.slice(-6)}`,
|
||||
price,
|
||||
shares: size,
|
||||
orderId: res.orderID,
|
||||
price: config.sniperPrice,
|
||||
shares: config.sniperShares,
|
||||
cost,
|
||||
potentialPayout: size,
|
||||
potentialPayout: config.sniperShares,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const client = getClient();
|
||||
try {
|
||||
const res = await client.createAndPostOrder(
|
||||
{
|
||||
tokenID: tokenId,
|
||||
side: Side.BUY,
|
||||
price: price,
|
||||
size: size,
|
||||
},
|
||||
{ tickSize, negRisk },
|
||||
OrderType.GTC,
|
||||
);
|
||||
|
||||
if (res?.success) {
|
||||
const cost = price * size;
|
||||
logger.trade(`SNIPER: ${asset.toUpperCase()} ${name} T${tier+1} @ $${price.toFixed(2)} × ${size}sh | cost $${cost.toFixed(3)} | order ${res.orderID}`);
|
||||
activeSnipes.push({
|
||||
asset: asset.toUpperCase(),
|
||||
side: name,
|
||||
tier: tier + 1,
|
||||
question: label,
|
||||
orderId: res.orderID,
|
||||
price,
|
||||
shares: size,
|
||||
cost,
|
||||
potentialPayout: size,
|
||||
});
|
||||
} else {
|
||||
logger.warn(`SNIPER: ${asset.toUpperCase()} ${name} T${tier+1} failed — ${res?.errorMsg || 'unknown'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`SNIPER: ${asset.toUpperCase()} ${name} T${tier+1} error — ${err.message}`);
|
||||
} else {
|
||||
logger.warn(`SNIPER: ${asset.toUpperCase()} ${name} order failed — ${res?.errorMsg || 'unknown'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`SNIPER: ${asset.toUpperCase()} ${name} error — ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* sniperSizing.js
|
||||
* Time-based multiplier for sniper bet sizing.
|
||||
* All time windows are specified in UTC+8.
|
||||
*/
|
||||
|
||||
import config from '../config/index.js';
|
||||
|
||||
const UTC8_OFFSET = 8;
|
||||
|
||||
/**
|
||||
* Convert HH:MM (UTC+8) to minutes-since-midnight UTC.
|
||||
*/
|
||||
function utc8ToUtcMinutes(hhmm) {
|
||||
const [h, m] = hhmm.split(':').map(Number);
|
||||
let totalMin = (h * 60 + m) - (UTC8_OFFSET * 60);
|
||||
if (totalMin < 0) totalMin += 1440;
|
||||
if (totalMin >= 1440) totalMin -= 1440;
|
||||
return totalMin;
|
||||
}
|
||||
|
||||
function inRange(nowMin, startMin, endMin) {
|
||||
if (startMin <= endMin) {
|
||||
return nowMin >= startMin && nowMin < endMin;
|
||||
}
|
||||
// overnight wrap
|
||||
return nowMin >= startMin || nowMin < endMin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current time multiplier based on configured SNIPER_MULTIPLIERS windows.
|
||||
* Returns { multiplier, label } where label describes the active window (or 'default').
|
||||
*/
|
||||
export function getTimeMultiplier() {
|
||||
const windows = config.sniperMultipliers;
|
||||
if (!windows || windows.length === 0) return { multiplier: 1.0, label: 'default' };
|
||||
|
||||
const now = new Date();
|
||||
const nowMin = now.getUTCHours() * 60 + now.getUTCMinutes();
|
||||
|
||||
for (const w of windows) {
|
||||
const startMin = utc8ToUtcMinutes(w.start);
|
||||
const endMin = utc8ToUtcMinutes(w.end);
|
||||
if (inRange(nowMin, startMin, endMin)) {
|
||||
return { multiplier: w.multiplier, label: `${w.start}-${w.end} UTC+8 → ${w.multiplier}x` };
|
||||
}
|
||||
}
|
||||
|
||||
return { multiplier: 1.0, label: 'default' };
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import config from '../config/index.js';
|
||||
import logger from '../utils/logger.js';
|
||||
import { readState, writeState } from '../utils/state.js';
|
||||
import { proxyFetch } from '../utils/proxy.js';
|
||||
|
||||
const PROCESSED_FILE = 'processed_trades.json';
|
||||
|
||||
@@ -12,7 +11,7 @@ const PROCESSED_FILE = 'processed_trades.json';
|
||||
async function fetchTraderActivity() {
|
||||
const url = `${config.dataHost}/activity?user=${config.traderAddress}`;
|
||||
try {
|
||||
const response = await proxyFetch(url);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Data API returned ${response.status}`);
|
||||
}
|
||||
@@ -117,7 +116,7 @@ export { markTradeProcessed };
|
||||
export async function fetchMarketInfo(conditionId) {
|
||||
try {
|
||||
const url = `${config.gammaHost}/markets?condition_id=${conditionId}`;
|
||||
const response = await proxyFetch(url);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) return null;
|
||||
const markets = await response.json();
|
||||
return markets && markets.length > 0 ? markets[0] : null;
|
||||
@@ -133,7 +132,7 @@ export async function fetchMarketInfo(conditionId) {
|
||||
export async function fetchMarketByTokenId(tokenId) {
|
||||
try {
|
||||
const url = `${config.gammaHost}/markets?clob_token_ids=${tokenId}`;
|
||||
const response = await proxyFetch(url);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) return null;
|
||||
const markets = await response.json();
|
||||
return markets && markets.length > 0 ? markets[0] : null;
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
/**
|
||||
* sniper-tui.js
|
||||
* TUI version of the Orderbook Sniper bot (blessed dashboard).
|
||||
* Places tiny GTC BUY orders at a low price on both sides of 5-min markets.
|
||||
*
|
||||
* Run with: npm run sniper-tui (live)
|
||||
* npm run sniper-tui-sim (simulation)
|
||||
*/
|
||||
|
||||
// Load proxy patch BEFORE any other imports (must patch https before axios is loaded)
|
||||
import './utils/proxy-patch.cjs';
|
||||
|
||||
import { validateMMConfig } from './config/index.js';
|
||||
import config from './config/index.js';
|
||||
import logger from './utils/logger.js';
|
||||
import { initClient } from './services/client.js';
|
||||
import { getUsdcBalance } from './services/client.js';
|
||||
import { initDashboard, appendLog, updateStatus, isDashboardActive } from './ui/dashboard.js';
|
||||
import { startSniperDetector, stopSniperDetector } from './services/sniperDetector.js';
|
||||
import { executeSnipe, getActiveSnipes, getConditionAsset, getConditionInfo } from './services/sniperExecutor.js';
|
||||
import { redeemSniperPositions, onSniperWin, setSniperConditionLookup } from './services/ctf.js';
|
||||
import { getSchedule, isAssetInSession, getNextSessionInfo } from './services/schedule.js';
|
||||
import { getTimeMultiplier } from './services/sniperSizing.js';
|
||||
|
||||
// ── Validate config ────────────────────────────────────────────────────────────
|
||||
|
||||
try {
|
||||
validateMMConfig();
|
||||
} catch (err) {
|
||||
console.error(`Config error: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (config.sniperAssets.length === 0) {
|
||||
console.error('SNIPER_ASSETS is empty. Set e.g. SNIPER_ASSETS=eth,sol,xrp in .env');
|
||||
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);
|
||||
}
|
||||
|
||||
// ── Status panel ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function buildStatusContent() {
|
||||
const 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('');
|
||||
|
||||
lines.push('{bold}MODE{/bold}');
|
||||
lines.push(` ${config.dryRun ? '{yellow-fg}SIMULATION{/yellow-fg}' : '{green-fg}LIVE{/green-fg}'}`);
|
||||
lines.push('');
|
||||
|
||||
lines.push('{bold}SNIPER CONFIG{/bold}');
|
||||
const prices = config.sniperTierPrices;
|
||||
const sizes = [Math.floor(config.sniperMaxShares * 0.20), Math.floor(config.sniperMaxShares * 0.30), Math.floor(config.sniperMaxShares * 0.50)];
|
||||
lines.push(` Assets : ${config.sniperAssets.join(', ').toUpperCase()}`);
|
||||
lines.push(` 3-Tier : ${prices[0]}c/${prices[1]}c/${prices[2]}c`);
|
||||
lines.push(` Sizes : ${sizes[0]}/${sizes[1]}/${sizes[2]} shares`);
|
||||
const costPerSide = (sizes[0] * prices[0]) + (sizes[1] * prices[1]) + (sizes[2] * prices[2]);
|
||||
lines.push(` Cost : $${(costPerSide * 2 * config.sniperAssets.length).toFixed(3)} per slot (base)`);
|
||||
const { multiplier, label: mulLabel } = getTimeMultiplier();
|
||||
if (config.sniperMultipliers.length > 0) {
|
||||
lines.push(` Mul : ${mulLabel}`);
|
||||
}
|
||||
if (config.sniperPauseRoundsAfterWin > 0) {
|
||||
lines.push(` Pause : ${config.sniperPauseRoundsAfterWin} rounds after win`);
|
||||
}
|
||||
// Show per-asset pause status
|
||||
for (const a of config.sniperAssets) {
|
||||
if (pauseCounters[a] > 0) {
|
||||
lines.push(` {yellow-fg}${a.toUpperCase()} paused (${pauseCounters[a]} rounds){/yellow-fg}`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
// Session schedule
|
||||
lines.push('{bold}SESSION SCHEDULE (UTC+8){/bold}');
|
||||
const schedule = getSchedule();
|
||||
for (const asset of config.sniperAssets) {
|
||||
const sessions = schedule[asset];
|
||||
const active = isAssetInSession(asset);
|
||||
const statusTag = active
|
||||
? '{green-fg}● ACTIVE{/green-fg}'
|
||||
: '{red-fg}○ IDLE{/red-fg}';
|
||||
if (sessions) {
|
||||
const sessionStr = sessions.map(s => `${s.startUtc8}–${s.endUtc8}`).join(', ');
|
||||
lines.push(` ${asset.toUpperCase()} ${statusTag} ${sessionStr}`);
|
||||
if (!active) {
|
||||
const next = getNextSessionInfo(asset);
|
||||
if (next) lines.push(` {gray-fg}Next in ${next}{/gray-fg}`);
|
||||
}
|
||||
} else {
|
||||
lines.push(` ${asset.toUpperCase()} {yellow-fg}NO SCHEDULE{/yellow-fg} (always active)`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
// Recent snipe orders
|
||||
const snipes = getActiveSnipes();
|
||||
lines.push(`{bold}SNIPE ORDERS (${snipes.length} total){/bold}`);
|
||||
|
||||
if (snipes.length === 0) {
|
||||
lines.push(' {gray-fg}Waiting for next slot...{/gray-fg}');
|
||||
} else {
|
||||
// Show last 10 orders (most recent first)
|
||||
const recent = snipes.slice(-10).reverse();
|
||||
for (const s of recent) {
|
||||
const payout = s.potentialPayout.toFixed(2);
|
||||
lines.push(` {cyan-fg}${s.asset}{/cyan-fg} ${s.side} @ $${s.price} × ${s.shares}sh | pay $${payout} if win`);
|
||||
}
|
||||
}
|
||||
|
||||
return '\n' + lines.join('\n');
|
||||
}
|
||||
|
||||
let refreshTimer = null;
|
||||
let redeemTimer = null;
|
||||
|
||||
function startRefresh() {
|
||||
refreshTimer = setInterval(async () => {
|
||||
if (!isDashboardActive()) return;
|
||||
updateStatus(await buildStatusContent());
|
||||
}, 3000);
|
||||
buildStatusContent().then(updateStatus);
|
||||
}
|
||||
|
||||
function startRedeemer() {
|
||||
redeemSniperPositions().catch((err) => logger.error('Sniper redeemer error:', err.message));
|
||||
redeemTimer = setInterval(
|
||||
() => redeemSniperPositions().catch((err) => logger.error('Sniper redeemer error:', err.message)),
|
||||
config.redeemInterval,
|
||||
);
|
||||
logger.info(`Sniper redeemer started — checking every ${config.redeemInterval / 1000}s`);
|
||||
}
|
||||
|
||||
// ── Pause-after-win tracking ─────────────────────────────────────────────────
|
||||
|
||||
const pauseCounters = {};
|
||||
|
||||
function handleWin(conditionId) {
|
||||
const asset = getConditionAsset(conditionId);
|
||||
if (!asset) return;
|
||||
const rounds = config.sniperPauseRoundsAfterWin;
|
||||
pauseCounters[asset] = rounds;
|
||||
logger.success(`SNIPER: WIN on ${asset.toUpperCase()} — pausing ${rounds} rounds`);
|
||||
}
|
||||
|
||||
function isAssetPaused(asset) {
|
||||
const key = asset.toLowerCase();
|
||||
return pauseCounters[key] > 0;
|
||||
}
|
||||
|
||||
function tickPause(asset) {
|
||||
const key = asset.toLowerCase();
|
||||
if (pauseCounters[key] > 0) {
|
||||
pauseCounters[key]--;
|
||||
if (pauseCounters[key] <= 0) {
|
||||
logger.info(`SNIPER: ${asset.toUpperCase()} pause ended — resuming`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onSniperWin(handleWin);
|
||||
setSniperConditionLookup(getConditionInfo);
|
||||
|
||||
// ── Market handler ────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleNewMarket(market) {
|
||||
const asset = market.asset.toLowerCase();
|
||||
|
||||
tickPause(asset);
|
||||
|
||||
if (isAssetPaused(asset)) {
|
||||
logger.info(`SNIPER: ${asset.toUpperCase()} paused (${pauseCounters[asset]} rounds left) — skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
executeSnipe(market).catch((err) =>
|
||||
logger.error(`SNIPER execute error (${market.asset}): ${err.message}`)
|
||||
);
|
||||
}
|
||||
|
||||
// ── Graceful shutdown ─────────────────────────────────────────────────────────
|
||||
|
||||
function shutdown() {
|
||||
logger.warn('SNIPER: shutting down...');
|
||||
stopSniperDetector();
|
||||
if (refreshTimer) clearInterval(refreshTimer);
|
||||
if (redeemTimer) clearInterval(redeemTimer);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
|
||||
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const prices = config.sniperTierPrices;
|
||||
const sizes = [Math.floor(config.sniperMaxShares * 0.20), Math.floor(config.sniperMaxShares * 0.30), Math.floor(config.sniperMaxShares * 0.50)];
|
||||
const costPerSide = (sizes[0] * prices[0]) + (sizes[1] * prices[1]) + (sizes[2] * prices[2]);
|
||||
const costPerSlot = (costPerSide * 2 * config.sniperAssets.length).toFixed(3);
|
||||
logger.info(`SNIPER starting — ${config.dryRun ? 'SIMULATION' : 'LIVE'}`);
|
||||
logger.info(`Assets: ${config.sniperAssets.join(', ').toUpperCase()} | 3-tier: 3c×${sizes[0]}+2c×${sizes[1]}+1c×${sizes[2]} = $${costPerSlot}/slot`);
|
||||
|
||||
startRefresh();
|
||||
startRedeemer();
|
||||
startSniperDetector(handleNewMarket);
|
||||
+66
-118
@@ -1,31 +1,21 @@
|
||||
/**
|
||||
* sniper.js
|
||||
* Console-only entry point for the Orderbook Sniper bot.
|
||||
* Places tiny GTC BUY orders at a low price on both sides of 5-min markets.
|
||||
* Entry point for the Orderbook Sniper bot.
|
||||
* Places tiny GTC BUY orders at $0.01 on both sides of ETH/SOL/XRP 5-min markets.
|
||||
*
|
||||
* Features:
|
||||
* - Time-based multiplier sizing (SNIPER_MULTIPLIERS, UTC+8)
|
||||
* - Pause N rounds per asset after a win (SNIPER_PAUSE_ROUNDS_AFTER_WIN)
|
||||
* - Win detection via outcome (payoutNumerators), not redeem value
|
||||
*
|
||||
* Run with: npm run sniper (live, console)
|
||||
* npm run sniper-sim (simulation, console)
|
||||
*
|
||||
* For the TUI dashboard version, use: npm run sniper-tui
|
||||
* Run with: npm run sniper (live)
|
||||
* npm run sniper-sim (simulation)
|
||||
*/
|
||||
|
||||
import { validateMMConfig } from './config/index.js';
|
||||
import config from './config/index.js';
|
||||
import logger from './utils/logger.js';
|
||||
import { initClient } from './services/client.js';
|
||||
import { getUsdcBalance } from './services/client.js';
|
||||
import { initDashboard, appendLog, updateStatus, isDashboardActive } from './ui/dashboard.js';
|
||||
import { startSniperDetector, stopSniperDetector } from './services/sniperDetector.js';
|
||||
import { executeSnipe, getConditionAsset, getConditionInfo } from './services/sniperExecutor.js';
|
||||
import { redeemSniperPositions, onSniperWin, setSniperConditionLookup } from './services/ctf.js';
|
||||
import { getSchedule, isAssetInSession, getNextSessionInfo } from './services/schedule.js';
|
||||
import { getTimeMultiplier } from './services/sniperSizing.js';
|
||||
|
||||
// Set proxy before any network calls
|
||||
import './utils/proxy-patch.cjs';
|
||||
import { executeSnipe, getActiveSnipes } from './services/sniperExecutor.js';
|
||||
import { redeemMMPositions } from './services/ctf.js';
|
||||
|
||||
// ── Validate config ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -41,6 +31,11 @@ if (config.sniperAssets.length === 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Init TUI ──────────────────────────────────────────────────────────────────
|
||||
|
||||
initDashboard();
|
||||
logger.setOutput(appendLog);
|
||||
|
||||
// ── Init CLOB client ──────────────────────────────────────────────────────────
|
||||
|
||||
try {
|
||||
@@ -50,103 +45,74 @@ try {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Pause-after-win tracking ─────────────────────────────────────────────────
|
||||
// ── Status panel ──────────────────────────────────────────────────────────────
|
||||
|
||||
// pauseCounters[asset] = number of rounds remaining to skip
|
||||
const pauseCounters = {};
|
||||
async function buildStatusContent() {
|
||||
const lines = [];
|
||||
|
||||
/**
|
||||
* Called by the redeemer when a win is detected.
|
||||
* Looks up the asset from the conditionId mapping and sets the pause counter.
|
||||
*/
|
||||
function handleWin(conditionId) {
|
||||
const asset = getConditionAsset(conditionId);
|
||||
if (!asset) {
|
||||
logger.info(`SNIPER: win detected for ${conditionId.slice(0, 12)}... but no asset mapping found`);
|
||||
return;
|
||||
// Balance
|
||||
let balance = '?';
|
||||
if (!config.dryRun) {
|
||||
try { balance = (await getUsdcBalance()).toFixed(2); } catch { /* ignore */ }
|
||||
} else {
|
||||
balance = '{yellow-fg}SIM{/yellow-fg}';
|
||||
}
|
||||
const rounds = config.sniperPauseRoundsAfterWin;
|
||||
pauseCounters[asset] = rounds;
|
||||
logger.success(`SNIPER: WIN on ${asset.toUpperCase()} — pausing ${rounds} rounds`);
|
||||
}
|
||||
lines.push('{bold}BALANCE{/bold}');
|
||||
lines.push(` USDC.e: {green-fg}$${balance}{/green-fg}`);
|
||||
lines.push('');
|
||||
|
||||
/**
|
||||
* Check if an asset is currently paused due to a recent win.
|
||||
* Each call with decrement=true counts as one round passing.
|
||||
*/
|
||||
function isAssetPaused(asset) {
|
||||
const key = asset.toLowerCase();
|
||||
if (!pauseCounters[key] || pauseCounters[key] <= 0) return false;
|
||||
return true;
|
||||
}
|
||||
lines.push('{bold}MODE{/bold}');
|
||||
lines.push(` ${config.dryRun ? '{yellow-fg}SIMULATION{/yellow-fg}' : '{green-fg}LIVE{/green-fg}'}`);
|
||||
lines.push('');
|
||||
|
||||
/**
|
||||
* Decrement pause counter for an asset (called once per round/slot).
|
||||
*/
|
||||
function tickPause(asset) {
|
||||
const key = asset.toLowerCase();
|
||||
if (pauseCounters[key] && pauseCounters[key] > 0) {
|
||||
pauseCounters[key]--;
|
||||
if (pauseCounters[key] <= 0) {
|
||||
logger.info(`SNIPER: ${asset.toUpperCase()} pause ended — resuming`);
|
||||
lines.push('{bold}SNIPER CONFIG{/bold}');
|
||||
lines.push(` Assets : ${config.sniperAssets.join(', ').toUpperCase()}`);
|
||||
lines.push(` Price : $${config.sniperPrice} per share`);
|
||||
lines.push(` Shares : ${config.sniperShares} per side`);
|
||||
lines.push(` Cost : $${(config.sniperPrice * config.sniperShares * 2 * config.sniperAssets.length).toFixed(3)} per slot`);
|
||||
lines.push('');
|
||||
|
||||
// Recent snipe orders
|
||||
const snipes = getActiveSnipes();
|
||||
lines.push(`{bold}SNIPE ORDERS (${snipes.length} total){/bold}`);
|
||||
|
||||
if (snipes.length === 0) {
|
||||
lines.push(' {gray-fg}Waiting for next slot...{/gray-fg}');
|
||||
} else {
|
||||
// Show last 10 orders (most recent first)
|
||||
const recent = snipes.slice(-10).reverse();
|
||||
for (const s of recent) {
|
||||
const payout = s.potentialPayout.toFixed(2);
|
||||
lines.push(` {cyan-fg}${s.asset}{/cyan-fg} ${s.side} @ $${s.price} × ${s.shares}sh | pay $${payout} if win`);
|
||||
}
|
||||
}
|
||||
|
||||
return '\n' + lines.join('\n');
|
||||
}
|
||||
|
||||
// Register win callback and token lookup for correct outcome mapping
|
||||
onSniperWin(handleWin);
|
||||
setSniperConditionLookup(getConditionInfo);
|
||||
let refreshTimer = null;
|
||||
let redeemTimer = null;
|
||||
|
||||
// ── Log session schedule ──────────────────────────────────────────────────────
|
||||
|
||||
function logSchedule() {
|
||||
const schedule = getSchedule();
|
||||
logger.info('─── Session Schedule (UTC+8) ───');
|
||||
for (const asset of config.sniperAssets) {
|
||||
const sessions = schedule[asset];
|
||||
const active = isAssetInSession(asset);
|
||||
const status = active ? '● ACTIVE' : '○ IDLE';
|
||||
if (sessions) {
|
||||
const sessionStr = sessions.map(s => `${s.startUtc8}–${s.endUtc8}`).join(', ');
|
||||
logger.info(` ${asset.toUpperCase()} [${status}] ${sessionStr}`);
|
||||
if (!active) {
|
||||
const next = getNextSessionInfo(asset);
|
||||
if (next) logger.info(` → Next in ${next}`);
|
||||
}
|
||||
} else {
|
||||
logger.info(` ${asset.toUpperCase()} [NO SCHEDULE] (always active)`);
|
||||
}
|
||||
}
|
||||
logger.info('────────────────────────────────');
|
||||
function startRefresh() {
|
||||
refreshTimer = setInterval(async () => {
|
||||
if (!isDashboardActive()) return;
|
||||
updateStatus(await buildStatusContent());
|
||||
}, 3000);
|
||||
buildStatusContent().then(updateStatus);
|
||||
}
|
||||
|
||||
// ── Redeemer ──────────────────────────────────────────────────────────────────
|
||||
|
||||
let redeemTimer = null;
|
||||
|
||||
function startRedeemer() {
|
||||
// Only run on interval, NOT on startup (we only want to redeem NEW winning positions)
|
||||
redeemMMPositions().catch((err) => logger.error('Sniper redeemer error:', err.message));
|
||||
redeemTimer = setInterval(
|
||||
() => redeemSniperPositions().catch((err) => logger.error('Sniper redeemer error:', err.message)),
|
||||
() => redeemMMPositions().catch((err) => logger.error('Sniper redeemer error:', err.message)),
|
||||
config.redeemInterval,
|
||||
);
|
||||
logger.info(`Sniper redeemer started — checking every ${config.redeemInterval / 1000}s (winners only, no startup check)`);
|
||||
logger.info(`Sniper redeemer started — checking every ${config.redeemInterval / 1000}s`);
|
||||
}
|
||||
|
||||
// ── Market handler ────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleNewMarket(market) {
|
||||
const asset = market.asset.toLowerCase();
|
||||
|
||||
// Tick pause counter for this asset (each new market = 1 round)
|
||||
tickPause(asset);
|
||||
|
||||
// Check if asset is paused after a recent win
|
||||
if (isAssetPaused(asset)) {
|
||||
logger.info(`SNIPER: ${asset.toUpperCase()} paused (${pauseCounters[asset]} rounds left) — skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
executeSnipe(market).catch((err) =>
|
||||
logger.error(`SNIPER execute error (${market.asset}): ${err.message}`)
|
||||
);
|
||||
@@ -157,38 +123,20 @@ async function handleNewMarket(market) {
|
||||
function shutdown() {
|
||||
logger.warn('SNIPER: shutting down...');
|
||||
stopSniperDetector();
|
||||
if (redeemTimer) clearInterval(redeemTimer);
|
||||
if (refreshTimer) clearInterval(refreshTimer);
|
||||
if (redeemTimer) clearInterval(redeemTimer);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
|
||||
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Calculate cost for 3-tier strategy
|
||||
const prices = config.sniperTierPrices;
|
||||
const sizes = [Math.floor(config.sniperMaxShares * 0.20), Math.floor(config.sniperMaxShares * 0.30), Math.floor(config.sniperMaxShares * 0.50)];
|
||||
const costPerSide = (sizes[0] * prices[0]) + (sizes[1] * prices[1]) + (sizes[2] * prices[2]);
|
||||
const costPerSlot = (costPerSide * 2 * config.sniperAssets.length).toFixed(3);
|
||||
const costPerSlot = (config.sniperPrice * config.sniperShares * 2 * config.sniperAssets.length).toFixed(3);
|
||||
logger.info(`SNIPER starting — ${config.dryRun ? 'SIMULATION' : 'LIVE'}`);
|
||||
logger.info(`Assets: ${config.sniperAssets.join(', ').toUpperCase()} | 3-tier: 3c×${sizes[0]}+2c×${sizes[1]}+1c×${sizes[2]} = $${costPerSlot}/slot (base)`);
|
||||
logger.info(`Assets: ${config.sniperAssets.join(', ').toUpperCase()} | $${config.sniperPrice} × ${config.sniperShares}sh = $${costPerSlot}/slot`);
|
||||
|
||||
// Log multiplier config
|
||||
if (config.sniperMultipliers.length > 0) {
|
||||
logger.info('─── Sizing Multipliers (UTC+8) ───');
|
||||
for (const w of config.sniperMultipliers) {
|
||||
logger.info(` ${w.start}–${w.end} → ${w.multiplier}x`);
|
||||
}
|
||||
const { multiplier, label } = getTimeMultiplier();
|
||||
logger.info(` Current: ${label}`);
|
||||
logger.info('──────────────────────────────────');
|
||||
}
|
||||
|
||||
if (config.sniperPauseRoundsAfterWin > 0) {
|
||||
logger.info(`Pause after win: ${config.sniperPauseRoundsAfterWin} rounds per asset`);
|
||||
}
|
||||
|
||||
logSchedule();
|
||||
startRefresh();
|
||||
startRedeemer();
|
||||
startSniperDetector(handleNewMarket);
|
||||
|
||||
@@ -22,26 +22,6 @@ const B = {
|
||||
|
||||
let outputFn = null; // When set, all log goes here (blessed dashboard mode)
|
||||
|
||||
/**
|
||||
* Sanitize a CLOB client console message.
|
||||
* Strips the full axios config (which may contain auth headers) and returns
|
||||
* only the HTTP status code + API error message.
|
||||
*/
|
||||
function sanitizeClobMessage(raw) {
|
||||
if (!raw.includes('[CLOB Client]')) return raw;
|
||||
try {
|
||||
const jsonStart = raw.indexOf('{');
|
||||
if (jsonStart === -1) return raw;
|
||||
const parsed = JSON.parse(raw.slice(jsonStart));
|
||||
const status = parsed.status || '';
|
||||
const errMsg = parsed.data?.error || parsed.statusText || 'unknown error';
|
||||
const prefix = raw.slice(0, jsonStart).trim();
|
||||
return `${prefix}: ${status} — ${errMsg}`;
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function ts() {
|
||||
return new Date().toISOString().replace('T', ' ').substring(0, 19);
|
||||
}
|
||||
@@ -75,25 +55,6 @@ const logger = {
|
||||
setOutput(fn) {
|
||||
outputFn = fn;
|
||||
},
|
||||
|
||||
/**
|
||||
* Override console.error and console.log globally so that the CLOB client's
|
||||
* internal axios error dumps are sanitized (no auth headers / full config).
|
||||
* Call this once at startup, before any CLOB requests.
|
||||
*/
|
||||
interceptConsole() {
|
||||
const handle = (originalFn, logFn) => (...args) => {
|
||||
const raw = args.map((a) => (a && typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ');
|
||||
const sanitized = sanitizeClobMessage(raw);
|
||||
if (sanitized !== raw || raw.includes('[CLOB Client]')) {
|
||||
logFn(sanitized);
|
||||
} else {
|
||||
originalFn(...args);
|
||||
}
|
||||
};
|
||||
console.error = handle(console.error.bind(console), logger.error);
|
||||
console.warn = handle(console.warn.bind(console), logger.warn);
|
||||
},
|
||||
};
|
||||
|
||||
export default logger;
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
/**
|
||||
* proxy-patch.cjs
|
||||
*
|
||||
* Patches the Node.js https module to route Polymarket traffic through a proxy.
|
||||
* This needs to be the VERY FIRST import in the application.
|
||||
*/
|
||||
|
||||
const PROXY_URL = process.env.PROXY_URL || '';
|
||||
|
||||
if (PROXY_URL) {
|
||||
const https = require('https');
|
||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||
|
||||
const agent = new HttpsProxyAgent(PROXY_URL);
|
||||
|
||||
const POLY_DOMAINS = [
|
||||
'polymarket.com',
|
||||
'clob.polymarket.com',
|
||||
'gamma-api.polymarket.com',
|
||||
'data-api.polymarket.com',
|
||||
];
|
||||
|
||||
const shouldProxy = (hostname) => {
|
||||
if (!hostname) return false;
|
||||
return POLY_DOMAINS.some(d => hostname === d || hostname.endsWith('.' + d));
|
||||
};
|
||||
|
||||
const originalRequest = https.request;
|
||||
|
||||
https.request = function(...args) {
|
||||
let url;
|
||||
if (typeof args[0] === 'string') {
|
||||
url = args[0];
|
||||
} else if (args[0] && args[0].hostname) {
|
||||
url = args[0].hostname;
|
||||
}
|
||||
|
||||
if (url && shouldProxy(url)) {
|
||||
if (typeof args[0] === 'object') {
|
||||
args[0].agent = agent;
|
||||
} else if (typeof args[0] === 'string') {
|
||||
// Axios might pass string, convert to options object
|
||||
const parsed = new URL(args[0]);
|
||||
args[0] = {
|
||||
protocol: parsed.protocol,
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port,
|
||||
path: parsed.pathname + parsed.search,
|
||||
agent: agent,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return originalRequest.apply(this, args);
|
||||
};
|
||||
|
||||
console.log('[proxy-patch] HTTPS patched for Polymarket');
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
/**
|
||||
* proxy.js
|
||||
* Proxy support for Polymarket API calls only.
|
||||
*
|
||||
* - CLOB API: uses axios internally (via @polymarket/clob-client) →
|
||||
* we set axios.defaults.httpAgent/httpsAgent via https-proxy-agent.
|
||||
* - Gamma / Data API: uses native fetch (undici) →
|
||||
* we use undici.ProxyAgent with the `dispatcher` option.
|
||||
* - Polygon RPC: NOT proxied (separate ethers.js provider).
|
||||
*
|
||||
* Set PROXY_URL in .env to enable. Supports HTTP/HTTPS proxies.
|
||||
* Example: PROXY_URL=http://user:pass@proxy.example.com:8080
|
||||
*/
|
||||
|
||||
import config from '../config/index.js';
|
||||
import logger from './logger.js';
|
||||
|
||||
let axiosAgent = null; // https-proxy-agent for axios (CLOB client)
|
||||
let fetchDispatcher = null; // undici ProxyAgent for native fetch
|
||||
|
||||
/**
|
||||
* Set up axios defaults so that the @polymarket/clob-client's
|
||||
* internal axios calls go through the proxy.
|
||||
* Call this BEFORE creating ClobClient.
|
||||
*/
|
||||
export async function setupAxiosProxy() {
|
||||
if (!config.proxyUrl) {
|
||||
logger.info('No PROXY_URL set — Polymarket API calls will be direct');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Setup axios proxy (for CLOB client)
|
||||
const { HttpsProxyAgent } = await import('https-proxy-agent');
|
||||
axiosAgent = new HttpsProxyAgent(config.proxyUrl);
|
||||
|
||||
const axiosModule = await import('axios');
|
||||
const axios = axiosModule.default || axiosModule;
|
||||
axios.defaults.proxy = false;
|
||||
axios.defaults.httpAgent = axiosAgent;
|
||||
axios.defaults.httpsAgent = axiosAgent;
|
||||
|
||||
// Add request interceptor to force proxy agent on every request
|
||||
// This catches cases where axios.create() instances ignore defaults
|
||||
axios.interceptors.request.use((cfg) => {
|
||||
if (cfg.url && cfg.url.includes('polymarket.com')) {
|
||||
cfg.httpsAgent = axiosAgent;
|
||||
cfg.httpAgent = axiosAgent;
|
||||
cfg.proxy = false;
|
||||
}
|
||||
return cfg;
|
||||
});
|
||||
|
||||
logger.info(`Axios proxy configured → ${maskProxyUrl(config.proxyUrl)}`);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to configure axios proxy: ${err.message}`);
|
||||
logger.error('Make sure https-proxy-agent is installed: npm i https-proxy-agent');
|
||||
}
|
||||
|
||||
try {
|
||||
// 2. Setup undici ProxyAgent (for native fetch)
|
||||
const undici = await import('undici');
|
||||
fetchDispatcher = new undici.ProxyAgent(config.proxyUrl);
|
||||
logger.info(`Fetch proxy configured → ${maskProxyUrl(config.proxyUrl)}`);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to configure fetch proxy: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy-aware fetch wrapper.
|
||||
* Drop-in replacement for global fetch() — uses undici ProxyAgent
|
||||
* as dispatcher when PROXY_URL is configured.
|
||||
* Use this for Gamma API and Data API calls.
|
||||
*/
|
||||
export async function proxyFetch(url, opts = {}) {
|
||||
if (fetchDispatcher) {
|
||||
opts.dispatcher = fetchDispatcher;
|
||||
}
|
||||
return fetch(url, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the outbound IP address that Polymarket sees.
|
||||
* Uses both direct and proxied requests so you can compare.
|
||||
*/
|
||||
async function checkOutboundIP() {
|
||||
const IP_SERVICE = 'https://api.ipify.org?format=json';
|
||||
const GEOBLOCK_API = 'https://polymarket.com/api/geoblock';
|
||||
|
||||
// 1. Check VPS direct IP
|
||||
try {
|
||||
const directResp = await fetch(IP_SERVICE, { signal: AbortSignal.timeout(10000) });
|
||||
if (directResp.ok) {
|
||||
const data = await directResp.json();
|
||||
logger.info(`VPS direct IP : ${data.ip}`);
|
||||
}
|
||||
} catch {
|
||||
logger.warn('Could not detect VPS direct IP');
|
||||
}
|
||||
|
||||
// 2. Check if VPS direct IP is geoblocked
|
||||
try {
|
||||
const geoResp = await fetch(GEOBLOCK_API, { signal: AbortSignal.timeout(10000) });
|
||||
if (geoResp.ok) {
|
||||
const geo = await geoResp.json();
|
||||
if (geo.blocked) {
|
||||
logger.warn(`VPS direct IP GEOBLOCKED — country: ${geo.country}, region: ${geo.region}`);
|
||||
} else {
|
||||
logger.info(`VPS direct IP NOT geoblocked — country: ${geo.country}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
logger.warn('Could not check VPS geoblock status');
|
||||
}
|
||||
|
||||
// 3. Check proxied IP (what Polymarket will see)
|
||||
if (fetchDispatcher) {
|
||||
try {
|
||||
const proxyResp = await fetch(IP_SERVICE, {
|
||||
dispatcher: fetchDispatcher,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (proxyResp.ok) {
|
||||
const data = await proxyResp.json();
|
||||
logger.info(`Proxy IP : ${data.ip} ← Polymarket sees this`);
|
||||
}
|
||||
} catch {
|
||||
logger.warn('Could not detect proxy IP — proxy may not be working');
|
||||
}
|
||||
|
||||
// 4. Check if proxy IP is geoblocked
|
||||
try {
|
||||
const geoResp = await fetch(GEOBLOCK_API, {
|
||||
dispatcher: fetchDispatcher,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (geoResp.ok) {
|
||||
const geo = await geoResp.json();
|
||||
if (geo.blocked) {
|
||||
logger.error('═══════════════════════════════════════════════════');
|
||||
logger.error(`PROXY IP GEOBLOCKED by Polymarket!`);
|
||||
logger.error(`IP: ${geo.ip} | Country: ${geo.country} | Region: ${geo.region}`);
|
||||
logger.error('Change PROXY_URL in .env to a proxy in an allowed region.');
|
||||
logger.error('═══════════════════════════════════════════════════');
|
||||
} else {
|
||||
logger.success(`Proxy IP NOT geoblocked — country: ${geo.country} ✓`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
logger.warn('Could not check proxy geoblock status');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that the proxy works and is not geoblocked by Polymarket CLOB.
|
||||
* Call this at startup to fail fast if the proxy is misconfigured.
|
||||
*/
|
||||
export async function testProxy() {
|
||||
if (!config.proxyUrl) return true; // no proxy = nothing to test
|
||||
|
||||
logger.info(`Testing proxy connection → ${maskProxyUrl(config.proxyUrl)} ...`);
|
||||
|
||||
// Show both IPs so user can verify which IP Polymarket sees
|
||||
await checkOutboundIP();
|
||||
|
||||
// ── Test 1: fetch (undici) ──────────────────────────────────────────
|
||||
try {
|
||||
if (!fetchDispatcher) {
|
||||
throw new Error('Proxy dispatcher not initialized');
|
||||
}
|
||||
|
||||
const resp = await fetch(`${config.clobHost}/time`, {
|
||||
dispatcher: fetchDispatcher,
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
|
||||
if (resp.status === 403) {
|
||||
const body = await resp.text().catch(() => '');
|
||||
const isGeoblock = body.includes('restricted') || body.includes('region') || body.includes('geoblock');
|
||||
if (isGeoblock) {
|
||||
logger.error('═══════════════════════════════════════════════════');
|
||||
logger.error('GEOBLOCKED — Polymarket CLOB rejected your proxy IP!');
|
||||
logger.error('Your proxy IP is in a restricted region.');
|
||||
logger.error('Change PROXY_URL in .env to a proxy in an allowed region.');
|
||||
logger.error('Allowed regions: https://docs.polymarket.com/developers/CLOB/geoblock');
|
||||
logger.error('═══════════════════════════════════════════════════');
|
||||
} else {
|
||||
logger.error(`CLOB returned 403 Forbidden: ${body.substring(0, 200)}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
|
||||
}
|
||||
|
||||
logger.success(`Proxy test (fetch) passed`);
|
||||
} catch (err) {
|
||||
logger.error(`Proxy test (fetch) FAILED: ${err.message}`);
|
||||
logger.error('Check PROXY_URL in .env. Bot cannot reach Polymarket without a working proxy.');
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Test 2: axios (same transport as CLOB client) ────────────────────
|
||||
try {
|
||||
if (!axiosAgent) {
|
||||
throw new Error('Axios proxy agent not initialized');
|
||||
}
|
||||
|
||||
const axiosModule = await import('axios');
|
||||
const axios = axiosModule.default || axiosModule;
|
||||
const axiosResp = await axios.get(`${config.clobHost}/time`, {
|
||||
httpsAgent: axiosAgent,
|
||||
proxy: false,
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
logger.success(`Proxy test (axios) passed`);
|
||||
} catch (err) {
|
||||
const status = err?.response?.status;
|
||||
const data = err?.response?.data;
|
||||
|
||||
if (status === 403) {
|
||||
const body = typeof data === 'string' ? data : JSON.stringify(data || '');
|
||||
const isGeoblock = body.includes('restricted') || body.includes('region') || body.includes('geoblock');
|
||||
if (isGeoblock) {
|
||||
logger.error('═══════════════════════════════════════════════════');
|
||||
logger.error('GEOBLOCKED (axios) — Polymarket CLOB rejected your proxy IP!');
|
||||
logger.error('Your proxy IP is in a restricted region.');
|
||||
logger.error('The CLOB client uses axios — this test confirms proxy routing.');
|
||||
logger.error('Change PROXY_URL in .env to a proxy in an allowed region.');
|
||||
logger.error('═══════════════════════════════════════════════════');
|
||||
} else {
|
||||
logger.error(`CLOB returned 403 via axios: ${body.substring(0, 200)}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.error(`Proxy test (axios) FAILED: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.success(`All proxy tests passed — connected via ${maskProxyUrl(config.proxyUrl)}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask credentials in proxy URL for safe logging.
|
||||
* http://user:pass@host:port → http://***:***@host:port
|
||||
*/
|
||||
function maskProxyUrl(url) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if (u.username || u.password) {
|
||||
u.username = '***';
|
||||
u.password = '***';
|
||||
}
|
||||
return u.toString();
|
||||
} catch {
|
||||
return '(invalid URL)';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user