14 Commits

Author SHA1 Message Date
direkturcrypto 0b28dcc228 fix(oneshot): always clear loss positions from redeem queue regardless of tx result 2026-02-24 16:38:28 +07:00
direkturcrypto 28cba8b0fb fix(oneshot): route redeemPositions through Gnosis Safe with 30gwei gas floor — same as MM 2026-02-24 16:08:37 +07:00
direkturcrypto 0c1e9f114c fix(oneshot): remove gasLimit override in redeemPositions — let ethers auto-estimate gas for Polygon 2026-02-24 15:52:12 +07:00
direkturcrypto bac4e320f5 fix(oneshot): handle null pnl in Telemetry.logExit for expired positions 2026-02-24 15:46:03 +07:00
direkturcrypto dbaba5914f fix(oneshot): emit expired snapshot so positions get queued to RedeemEngine on market close 2026-02-24 15:29:36 +07:00
direkturcrypto 6a2bbfa008 fix(oneshot): remove global position limit — block per-market only via SM state 2026-02-24 15:05:48 +07:00
direkturcrypto 8296c4129b fix(oneshot): add momentum scoring, widen gates to fix rare-entry problem
Root causes identified and fixed:

1. SPREAD_MAX 0.02 → 0.04  (biggest culprit — near-expiry books often have
   0.03 spread, hard gate was blocking all valid entries)

2. tteMax 90s → 150s  (direction is established by TTE=150s on 5m markets;
   previous 90s window was too narrow, skipped the "trend building" phase)

3. Added momentum as scoring factor W_MOMENTUM=0.30  ("follow where odds
   are moving" — midSlope6s from FeatureEngine now drives 30% of entry score)

4. Added SLOPE_CANCEL momentum gate (-0.0020): if dominant side's mid is
   actively falling (reversal risk), block entry regardless of mid level.
   New reason code: SIG_FADING_DOMINANT

5. Revised score weights: MID 45%→35%, IMBALANCE 35%→20%, SPREAD 20%→15%,
   MOMENTUM 0%→30%

6. Score threshold 0.55 → 0.42 (now calibrated for 4-factor scoring)

7. minDominantMid default 0.60 → 0.58, tteMin 20 → 15

8. Spread gate now checks min(up.spread, down.spread) — dominant side only
   needs to be tradeable, not both sides

Updated .env and .env.example defaults to match new parameters.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 14:48:36 +07:00
direkturcrypto 89a7803aa8 feat(oneshot): add auto-redemption via RedeemEngine
When a position's market expires, RedeemEngine automatically polls the
CTF contract and redeems the winning position on-chain — no manual redeem needed.

Flow:
  1. expirePosition() queues the expired position into RedeemEngine
  2. RedeemEngine polls every 30s (ONESHOT_REDEEM_POLL_MS)
  3. Checks Gamma API first, then CTF.payoutDenominator() on-chain
  4. When settled: emits redemption:complete event with final P&L
  5. Orchestrator passes P&L to RiskEngine

DRY_RUN=true: simulates by reading on-chain payouts and logging win/loss
DRY_RUN=false: submits real redeemPositions() tx on Polygon (gasLimit 300k)

Also stores conditionId and negRisk in PositionEngine state so the
expired position has all data needed for redemption without extra lookups.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 14:20:57 +07:00
direkturcrypto 2692694309 fix(oneshot): add global position limit — no new entry while any position is open
Previously the per-market state machine check (sm.is(IDLE)) only blocked
re-entry on the same slug. A fresh market slot (different slug) would get
its own IDLE state machine and could trigger another entry while the previous
market's position was still being held.

Added posEngine.hasAnyPosition() global guard in onSignal so the engine holds
exactly one position at a time across all tracked markets.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 14:07:13 +07:00
direkturcrypto bf09d30376 refactor(oneshot): switch from scalper to Dominant Side Hold strategy
Previous behaviour: enter any side with positive momentum, exit at TP (+1 tick),
cycle back to IDLE — causing rapid buy-sell-buy loops on low-probability tokens.

New strategy:
- Enter ONLY the side the market already prices as probable winner (mid >= MIN_DOMINANT_MID)
- Hold position to market expiry; on-chain redeemer settles at $1.00 win / $0.00 loss
- Emergency stop-loss only (absolute mid floor, e.g. 0.20) for catastrophic reversals
- One entry per market slot — no re-entry while POSITION_OPEN

Key changes:
- SignalEngine: detect dominant side (up.mid vs down.mid), require MIN_DOMINANT_MID
  threshold, new scoring weights (mid 45% / imbalance 35% / spread 20%)
- PositionEngine: remove TP, slope-drop, time-reduce exits; add expired handler;
  stop-loss is now an absolute mid floor instead of relative-to-entry ticks
- oneshot.js: expirePosition() clears state without submitting sell orders;
  flattenPosition() only called for emergency stops; update cfg vars
- constants.js: add SIG_NO_DOMINANT, SIG_LOW_DOMINANT, EXIT_EXPIRED reason codes
- .env.example: replace ONESHOT_TP_TICKS with MIN_DOMINANT_MID, STOP_LOSS_MID,
  TTE_MIN, TTE_MAX

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 14:05:44 +07:00
direkturcrypto 707d654749 fix(oneshot): rewrite MarketFeedService market discovery to match working detectors
- Use /markets/slug/{slug} direct endpoint (not /markets?slug=...&limit=1)
- Extract token IDs from clobTokenIds field with JSON string parsing fallback
- Read tick size from market.orderPriceMinTickSize (no separate API call)
- Prioritise endDate (full datetime) over endDateIso (date-only) to fix false expiry
- Slot formula matches sniperDetector/mmDetector exactly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 13:40:09 +07:00
direkturcrypto acf3943f80 fix(oneshot): use endDate instead of endDateIso for market expiry check
The Gamma API returns two date fields:
  endDateIso = "2026-02-24"           ← date only, no time
  endDate    = "2026-02-24T06:35:00Z" ← correct close datetime

_parseEndTs() was preferring endDateIso, which parsed to midnight UTC
and was already in the past by the time any market opened during the day.
Both current and next-slot markets were therefore rejected as "already expired".

Fix: prioritise endDate (full ISO datetime) over endDateIso (date-only).

Co-Authored-By: direkturcrypto <direkturcrypto.x@mail3.me>
2026-02-24 13:33:48 +07:00
direkturcrypto 202bf98fe7 feat(oneshot): add ONESHOT_DEBUG verbose logging mode
Adds a debug flag (ONESHOT_DEBUG=true / --debug / npm run oneshot-debug)
that surfaces the engine's internal decision process at every key step.

Debug output tags
─────────────────
  [DBG:FEED]   — Market discovery: every slug probed, API response status,
                 token IDs extracted, tick size fetched.
                 Throttled poll summary every 10 ticks per market showing
                 bid/ask/spread/mid/depth for both UP and DOWN sides.

  [DBG:GATE]   — Hard gate result every 5 evaluations per market:
                 TTE range, spread width, depth thinness, stale flag.
                 Shows exact gate fail reason or PASS confirmation.

  [DBG:FEAT]   — Feature breakdown for each side every 5 evals:
                 slope, imbalance, spread, retrace raw values plus
                 per-component scores and weighted total.

  [DBG:SCORE]  — Per-side qualify check: score vs threshold,
                 trend confirm flag, and QUALIFY / skip verdict.

  [DBG:SIGNAL] — Always logged (no throttle) when an ENTER signal fires.

  [DBG:HEART]  — 5-second heartbeat: active markets, per-market SM state,
                 dailyPnl, consecLosses, cooldownLeft, halted flag.

New script
──────────
  npm run oneshot-debug  →  DRY_RUN=true ONESHOT_DEBUG=true node src/oneshot.js

Co-Authored-By: direkturcrypto <direkturcrypto.x@mail3.me>
2026-02-24 13:29:26 +07:00
direkturcrypto f074ca9ecb feat(oneshot): add Anti-Flip 5m microstructure execution engine
Introduces a complete, event-driven execution engine for 5-minute
Polymarket UP/DOWN markets, implementing the Anti-Flip strategy spec.

Architecture
────────────
• EventBus          — central pub/sub bus connecting all services
• MarketFeedService — discovers 5m/15m markets via Gamma API, polls
                      CLOB orderbooks every 200–500ms, emits snapshots
• FeatureEngine     — maintains a 15s rolling buffer per market and
                      computes midSlope6s, retrace3s, imbalance, spread,
                      depthTop3 for both UP and DOWN sides
• SignalEngine      — hard gate checks (TTE, spread, depth, stale) then
                      weighted score (imbalance 35%, slope 35%,
                      spread 20%, retrace 10%) + trend confirmation
• ExecutionEngine   — limit-marketable FOK buy, market-sell FOK exit,
                      GTC limit-sell for TP; dry-run short-circuits
• RiskEngine        — consecutive loss cooldown, daily USDC loss cap,
                      session halt; all via explicit canTrade() gate
• PositionEngine    — per-market position state, TP/adverse/slope/time
                      exit evaluation on every snapshot tick
• StateMachine      — explicit state graph with guarded transitions:
                      IDLE → SETUP_READY → ORDER_PENDING → POSITION_OPEN
                      → REDUCE_ONLY → IDLE | COOLDOWN | HALTED
• Telemetry         — structured JSONL logger (data/oneshot_telemetry.jsonl)
                      recording decisions, orders, exits, and transitions

Runtime sequence (per market, per tick)
────────────────────────────────────────
A  Ingest snapshot (MarketFeedService)
B  Build features — rolling slope, retrace, imbalance (FeatureEngine)
C  Hard gate check — TTE [25,120]s, spread ≤ 0.02, depth ≥ minTopSize
D  Score + trend confirm → emit ENTER_LONG / ENTER_SHORT / NO_TRADE
E  Submit FOK limit-marketable at bestAsk
F  Fill handling — full fill / partial (reduce if TTE ≤ 25s) / timeout
G  Position management — TP, adverse (2-tick), slope drop (4s), time exits
H  Risk enforcement — P&L accounting, cooldown, daily halt

New scripts
───────────
  npm run oneshot      — live trading  (DRY_RUN=false)
  npm run oneshot-sim  — simulation    (DRY_RUN=true)
  npm run oneshot-dev  — sim + nodemon

New .env variables
──────────────────
  ONESHOT_ASSETS, ONESHOT_DURATION, ONESHOT_POLL_INTERVAL_MS,
  ONESHOT_BASE_RISK_USDC, ONESHOT_TP_TICKS, ONESHOT_SCORE_THRESHOLD,
  ONESHOT_MIN_TOP_SIZE, ONESHOT_MAX_CONSEC_LOSSES,
  ONESHOT_COOLDOWN_ROUNDS, ONESHOT_DAILY_LOSS_CAP, ONESHOT_FILL_TIMEOUT_MS

Co-Authored-By: direkturcrypto <direkturcrypto.x@mail3.me>
2026-02-24 13:00:19 +07:00
47 changed files with 3237 additions and 5334 deletions
+149 -154
View File
@@ -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 (200500ms 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.550.65. The momentum gate provides additional conviction filtering.
ONESHOT_MIN_DOMINANT_MID=0.58
# Minimum composite score to trigger entry (01).
# 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 25 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
-2
View File
@@ -1,6 +1,4 @@
node_modules/
.env
data/*.json
data/*.jsonl
.DS_Store
logs/*.log
+226 -137
View File
@@ -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.
View File
+5 -177
View File
@@ -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
View File
@@ -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"
}
}
}
-47
View File
@@ -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],
},
],
};
-47
View File
@@ -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],
},
],
};
-114
View File
@@ -1,114 +0,0 @@
/**
* patch-clob-client.cjs
*
* Patches @polymarket/clob-client to inject proxy support.
* Runs automatically via `npm install` (postinstall hook).
*
* What it does:
* - Adds HttpsProxyAgent import to http-helpers/index.js
* - Registers an axios interceptor that injects the proxy agent
* into every request to polymarket.com
* - Reads PROXY_URL from process.env at runtime
*/
const fs = require('fs');
const path = require('path');
const TARGET = path.join(
__dirname,
'..',
'node_modules',
'@polymarket',
'clob-client',
'dist',
'http-helpers',
'index.js',
);
if (!fs.existsSync(TARGET)) {
console.log('[patch] @polymarket/clob-client not found — skipping');
process.exit(0);
}
let code = fs.readFileSync(TARGET, 'utf8');
// Check if proxy support is already patched
const proxyAlreadyPatched = code.includes('getProxyAgent');
// Check if the JSON.stringify circular-ref fix is already applied
const jsonFixAlreadyPatched = !code.includes('config: (_d = err.response)');
// ── 1. Proxy interceptor ─────────────────────────────────────────────────────
if (!proxyAlreadyPatched) {
const PATCH_CODE = `
// ── Proxy support (auto-patched by scripts/patch-clob-client.cjs) ──────────
const https_proxy_agent_1 = require("https-proxy-agent");
let _cachedProxyAgent = null;
const getProxyAgent = () => {
if (!process.env.PROXY_URL) return undefined;
if (!_cachedProxyAgent) {
_cachedProxyAgent = new https_proxy_agent_1.HttpsProxyAgent(process.env.PROXY_URL);
}
return _cachedProxyAgent;
};
// Intercept all axios requests — inject proxy agent for polymarket.com
axios_1.default.interceptors.request.use(function(cfg) {
if (cfg.url && cfg.url.includes('polymarket.com')) {
var agent = getProxyAgent();
if (agent) {
cfg.httpsAgent = agent;
cfg.httpAgent = agent;
cfg.proxy = false;
}
}
return cfg;
});
// ── End proxy patch ────────────────────────────────────────────────────────
`;
const axiosPatterns = [
/tslib_1\.__importDefault\s*\(\s*require\s*\(\s*["']axios["']\s*\)\s*\)\s*;/,
/require\s*\(\s*["']axios["']\s*\)\s*;/,
];
let injected = false;
for (const pattern of axiosPatterns) {
const match = code.match(pattern);
if (match) {
code = code.replace(match[0], match[0] + PATCH_CODE);
console.log('[patch] Injected proxy interceptor after axios import');
injected = true;
break;
}
}
if (!injected) {
console.error('[patch] Could not find axios import — skipping proxy patch');
}
} else {
console.log('[patch] Proxy support already present — skipping');
}
// ── 2. Fix errorHandling circular JSON ───────────────────────────────────────
// JSON.stringify(err.response.config) includes httpsAgent (from proxy) which
// has circular/deep refs and causes "Maximum call stack size exceeded".
// Replace with a simple log that only serializes the response data.
if (!jsonFixAlreadyPatched) {
const OLD_LOG = `console.error("[CLOB Client] request error", JSON.stringify({
status: (_a = err.response) === null || _a === void 0 ? void 0 : _a.status,
statusText: (_b = err.response) === null || _b === void 0 ? void 0 : _b.statusText,
data: (_c = err.response) === null || _c === void 0 ? void 0 : _c.data,
config: (_d = err.response) === null || _d === void 0 ? void 0 : _d.config,
}));`;
const NEW_LOG = `// config excluded — contains httpsAgent circular refs (stack overflow)
console.error("[CLOB Client] request error:", (_a = err.response) === null || _a === void 0 ? void 0 : _a.status, JSON.stringify((_b = err.response) === null || _b === void 0 ? void 0 : _b.data));`;
if (code.includes(OLD_LOG)) {
code = code.replace(OLD_LOG, NEW_LOG);
console.log('[patch] Fixed errorHandling circular JSON.stringify');
} else {
console.warn('[patch] Could not find errorHandling JSON.stringify — skipping (already fixed or SDK changed)');
}
} else {
console.log('[patch] errorHandling JSON fix already applied — skipping');
}
fs.writeFileSync(TARGET, code, 'utf8');
console.log('[patch] @polymarket/clob-client patched ✅');
-149
View File
@@ -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
View File
@@ -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;
-1
View File
@@ -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) {
-290
View File
@@ -1,290 +0,0 @@
/**
* maker-mm-bot.js — Maker Rebate MM, PM2 / VPS entry point (no TUI)
*
* Plain-text stdout output, compatible with:
* pm2 start pm2/maker-mm.config.cjs
* pm2 logs polymarket-maker-mm
*/
// Set proxy before any network calls
import './utils/proxy-patch.cjs';
import { validateMakerMMConfig } from './config/index.js';
import config from './config/index.js';
import logger from './utils/logger.js';
import { initClient, getUsdcBalance } from './services/client.js';
import { startMMDetector, stopMMDetector, checkCurrentMarket } from './services/mmDetector.js';
import { executeMakerRebateStrategy, getActiveMakerPositions, getMarketOdds as getExecutorMarketOdds } from './services/makerRebateExecutor.js';
import { mmFillWatcher } from './services/mmWsFillWatcher.js';
logger.interceptConsole();
// ── Validate config ────────────────────────────────────────────────────────────
try {
validateMakerMMConfig();
} catch (err) {
logger.error(`Config error: ${err.message}`);
process.exit(1);
}
// ── Init CLOB client ──────────────────────────────────────────────────────────
try {
await initClient();
} catch (err) {
logger.error(`Client init error: ${err.message}`);
process.exit(1);
}
// ── Start WebSocket fill watcher ─────────────────────────────────────────────
mmFillWatcher.start();
// ── Override mmDetector config to use maker-mm settings ──────────────────────
config.mmAssets = config.makerMmAssets;
config.mmDuration = config.makerMmDuration;
config.mmPollInterval = config.makerMmPollInterval;
config.mmEntryWindow = config.makerMmEntryWindow;
// ── Periodic status log ──────────────────────────────────────────────────────
async function printStatus() {
try {
let balanceStr = 'SIM';
if (!config.dryRun) {
try { balanceStr = `$${(await getUsdcBalance()).toFixed(2)} USDC`; } catch { balanceStr = 'N/A'; }
}
const positions = getActiveMakerPositions();
const mode = config.dryRun ? 'SIMULATION' : 'LIVE';
logger.info(
`--- MakerMM Status [${mode}] | Balance: ${balanceStr} | Active positions: ${positions.length} ---`,
);
for (const pos of positions) {
const assetTag = pos.asset ? `[${pos.asset.toUpperCase()}] ` : '';
const label = pos.question.substring(0, 50);
const msLeft = new Date(pos.endTime).getTime() - Date.now();
const secsLeft = Math.max(0, Math.round(msLeft / 1000));
const timeStr = secsLeft > 60
? `${Math.floor(secsLeft / 60)}m${secsLeft % 60}s left`
: `${secsLeft}s left`;
const yFill = pos.yes.filled ? `FILLED` : `bid $${pos.yes.buyPrice?.toFixed(3)}`;
const nFill = pos.no.filled ? `FILLED` : `bid $${pos.no.buyPrice?.toFixed(3)}`;
const combined = (pos.yes.buyPrice + pos.no.buyPrice).toFixed(4);
logger.info(
` ${assetTag}${label} | ${pos.status} | ${timeStr}` +
` | combined $${combined}` +
` | YES ${pos.targetShares}sh → ${yFill}` +
` | NO ${pos.targetShares}sh → ${nFill}`,
);
}
} catch (err) {
logger.warn(`Status check error: ${err.message}`);
}
}
// ── Market handler with per-asset queue ──────────────────────────────────────
const pendingByAsset = new Map();
const runningByAsset = new Set(); // tracked from start of runStrategy, not just active positions
/**
* Check if current market odds allow re-entry
* For current market: max odds must be <= currentMarketMaxOdds (default 70%)
*/
async function isCurrentMarketOddsValidForReentry(yesTokenId, noTokenId) {
if (!config.currentMarketEnabled) return false;
try {
const odds = await getExecutorMarketOdds(yesTokenId, noTokenId);
if (!odds) {
logger.warn(`MakerMM: cannot determine odds — blocking re-entry`);
return false;
}
const threshold = config.currentMarketMaxOdds;
const valid = odds.max <= threshold;
if (!valid) {
logger.warn(
`MakerMM: current market max odds ${(odds.max * 100).toFixed(1)}% > ${(threshold * 100).toFixed(0)}% ` +
`— STOPPING re-entry for this market`
);
} else {
logger.info(
`MakerMM: current market max odds ${(odds.max * 100).toFixed(1)}% <= ${(threshold * 100).toFixed(0)}% ` +
`— re-entry allowed`
);
}
return valid;
} catch (err) {
logger.warn(`MakerMM: odds check error — ${err.message}`);
return false;
}
}
async function runStrategy(market) {
const isCurrentMarket = market.isCurrentMarket ?? false;
const assetTag = market.asset?.toUpperCase() || '';
let cycleCount = 0;
runningByAsset.add(market.asset);
while (true) {
cycleCount++;
if (cycleCount > 1) {
logger.info(`MakerMM[${assetTag}]: re-entry cycle #${cycleCount}`);
}
// ── Check if already have active position for this asset ─────────────
// Wait for any existing position to complete before starting new one
const maxWaitMs = 120_000; // Max 2 minutes wait
const pollIntervalMs = 2_000;
const waitStart = Date.now();
while (true) {
const activePositions = getActiveMakerPositions();
const hasActivePosition = activePositions.some(p => p.asset === market.asset);
if (!hasActivePosition) break; // Safe to proceed
if (Date.now() - waitStart > maxWaitMs) {
logger.warn(`MakerMM[${assetTag}]: timeout waiting for previous position — skipping cycle`);
return; // Exit this runStrategy entirely
}
logger.info(`MakerMM[${assetTag}]: waiting for previous position to complete...`);
await new Promise(r => setTimeout(r, pollIntervalMs));
}
let cycleResult = { oneSided: false };
try {
cycleResult = await executeMakerRebateStrategy(market) ?? { oneSided: false };
} catch (err) {
logger.error(`MakerMM strategy error (${assetTag}): ${err.message}`);
}
// If cycle ended with one-sided fill (stuck), stop re-entry for this market
if (cycleResult.oneSided) {
logger.warn(`MakerMM[${assetTag}]: cycle ended one-sided — stopping re-entry to avoid accumulating exposure`);
break;
}
// Check if we can re-enter (market still active with enough time)
const msRemaining = new Date(market.endTime).getTime() - Date.now();
const secsLeft = Math.round(msRemaining / 1000);
const minTimeForReentry = 180; // 3 minutes minimum
if (config.makerMmReentryEnabled && secsLeft > config.makerMmCutLossTime + minTimeForReentry) {
// ── CURRENT MARKET: Check odds before re-entry ──────────────────────
if (isCurrentMarket && config.currentMarketEnabled) {
const oddsValid = await isCurrentMarketOddsValidForReentry(
market.yesTokenId,
market.noTokenId
);
if (!oddsValid) {
logger.info(
`MakerMM[${assetTag}]: current market odds exceeded threshold — ` +
`stopping re-entry, will wait for next market`
);
break; // Exit to next market instead of re-entering
}
}
const delaySec = config.makerMmReentryDelay / 1000;
logger.info(`MakerMM[${assetTag}]: waiting ${delaySec}s for re-entry (${secsLeft}s remaining)...`);
await new Promise(r => setTimeout(r, config.makerMmReentryDelay));
continue; // Re-enter same market
}
// Not enough time for re-entry — check queued market
break;
}
runningByAsset.delete(market.asset);
const queued = pendingByAsset.get(market.asset);
if (queued) {
pendingByAsset.delete(market.asset);
const endMs = new Date(queued.endTime).getTime();
const secsLeft = Math.round((endMs - Date.now()) / 1000);
if (secsLeft > config.makerMmCutLossTime) {
logger.success(
`MakerMM[${assetTag}]: position cleared — ` +
`executing queued "${queued.question.substring(0, 40)}" (${secsLeft}s left)`,
);
runStrategy(queued);
} else {
logger.warn(
`MakerMM[${assetTag}]: queued market "${queued.question.substring(0, 40)}" ` +
`expired (${secsLeft}s left) — discarding`,
);
}
}
}
async function handleNewMarket(market) {
// Use runningByAsset — tracks from start of runStrategy, not just active positions.
// This prevents race where next market fires before executeMakerRebateStrategy adds to activePositions.
const isAssetBusy = runningByAsset.has(market.asset);
if (isAssetBusy) {
pendingByAsset.set(market.asset, market);
logger.warn(
`MakerMM[${market.asset?.toUpperCase()}]: queued "${market.question.substring(0, 40)}" — ` +
`will enter after current position clears`,
);
return;
}
runStrategy(market);
}
// ── Timers ────────────────────────────────────────────────────────────────────
const statusTimer = setInterval(printStatus, 60_000);
// ── Graceful shutdown ─────────────────────────────────────────────────────────
function shutdown() {
logger.warn('MakerMM: shutting down...');
stopMMDetector();
mmFillWatcher.stop();
clearInterval(statusTimer);
setTimeout(() => process.exit(0), 300);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
// ── Start ─────────────────────────────────────────────────────────────────────
const mode = config.dryRun ? 'SIMULATION' : 'LIVE';
logger.info(`=== Maker Rebate MM [${mode}] ===`);
logger.info(`Assets : ${config.makerMmAssets.join(', ').toUpperCase()}`);
logger.info(`Duration : ${config.makerMmDuration}`);
logger.info(`Trade size : $${config.makerMmTradeSize} per side`);
logger.info(`Max combined: $${config.makerMmMaxCombined}`);
logger.info(`Reprice : ${config.makerMmRepriceSec}s`);
logger.info(`Fill timeout: ${config.makerMmFillTimeout}s`);
logger.info(`Cut loss : ${config.makerMmCutLossTime}s before close`);
logger.info(`Entry window: ${config.makerMmEntryWindow}s after open`);
logger.info(`Current MM : ${config.currentMarketEnabled ? 'ENABLED' : 'disabled'} (max odds: ${(config.currentMarketMaxOdds * 100).toFixed(0)}%)`);
logger.info(`Next MM : max odds ${(config.nextMarketMaxOdds * 100).toFixed(0)}%`);
logger.info('==========================================');
// Check current active market FIRST so it gets priority and marks asset as running
// before the detector polls for the next market.
await checkCurrentMarket((market) => handleNewMarket({ ...market, isCurrentMarket: true }));
startMMDetector(handleNewMarket);
logger.success(`MakerMM bot started — watching for ${config.makerMmDuration} ${config.makerMmAssets.join('/')} markets...`);
-219
View File
@@ -1,219 +0,0 @@
/**
* maker-mm.js
* Entry point for the Maker Rebate MM bot (TUI).
* Buys YES+NO at top bid (maker) → merges → profit from spread + rebates.
* Run with: npm run maker-mm (live)
* npm run maker-mm-sim (simulation / dry-run)
*/
// Set proxy before any network calls
import './utils/proxy-patch.cjs';
import { validateMakerMMConfig } from './config/index.js';
import config from './config/index.js';
import logger from './utils/logger.js';
import { initClient, getClient } from './services/client.js';
import { initDashboard, appendLog, updateStatus, isDashboardActive } from './ui/dashboard.js';
import { startMMDetector, stopMMDetector, checkCurrentMarket } from './services/mmDetector.js';
import { executeMakerRebateStrategy, getActiveMakerPositions } from './services/makerRebateExecutor.js';
import { mmFillWatcher } from './services/mmWsFillWatcher.js';
import { getUsdcBalance } from './services/client.js';
// ── Validate config ────────────────────────────────────────────────────────────
try {
validateMakerMMConfig();
} catch (err) {
console.error(`Config error: ${err.message}`);
process.exit(1);
}
// ── Init TUI ──────────────────────────────────────────────────────────────────
initDashboard();
logger.setOutput(appendLog);
// ── Init CLOB client ──────────────────────────────────────────────────────────
try {
await initClient();
} catch (err) {
logger.error(`Client init error: ${err.message}`);
process.exit(1);
}
// ── Start WebSocket fill watcher for real-time order detection ────────────────
mmFillWatcher.start();
// ── Override mmDetector config to use maker-mm settings ──────────────────────
config.mmAssets = config.makerMmAssets;
config.mmDuration = config.makerMmDuration;
config.mmPollInterval = config.makerMmPollInterval;
config.mmEntryWindow = config.makerMmEntryWindow;
// ── Status panel refresh ──────────────────────────────────────────────────────
async function buildStatusContent() {
let lines = [];
// Balance
let balance = '?';
if (!config.dryRun) {
try { balance = (await getUsdcBalance()).toFixed(2); } catch { /* ignore */ }
} else {
balance = '{yellow-fg}SIM{/yellow-fg}';
}
lines.push(`{bold}BALANCE{/bold}`);
lines.push(` USDC.e: {green-fg}$${balance}{/green-fg}`);
lines.push('');
// Mode
lines.push(`{bold}MODE{/bold}`);
lines.push(` ${config.dryRun ? '{yellow-fg}SIMULATION{/yellow-fg}' : '{green-fg}LIVE{/green-fg}'}`);
lines.push(` Strategy: {cyan-fg}MAKER REBATE{/cyan-fg}`);
lines.push('');
// Config
lines.push(`{bold}MAKER MM CONFIG{/bold}`);
lines.push(` Assets : ${config.makerMmAssets.join(', ').toUpperCase()}`);
lines.push(` Duration : ${config.makerMmDuration}`);
lines.push(` Trade sz : $${config.makerMmTradeSize} per side`);
lines.push(` Max combined: $${config.makerMmMaxCombined}`);
lines.push(` Reprice : ${config.makerMmRepriceSec}s`);
lines.push(` Fill timeout: ${config.makerMmFillTimeout}s`);
lines.push(` Cut loss : ${config.makerMmCutLossTime}s before close`);
lines.push('');
// Active positions
const positions = getActiveMakerPositions();
lines.push(`{bold}ACTIVE POSITIONS (${positions.length}){/bold}`);
if (positions.length === 0) {
lines.push(' {gray-fg}Waiting for market...{/gray-fg}');
} else {
for (const pos of positions) {
const assetTag = pos.asset ? `[${pos.asset.toUpperCase()}] ` : '';
const label = pos.question.substring(0, 32);
const msLeft = new Date(pos.endTime).getTime() - Date.now();
const secsLeft = Math.max(0, Math.round(msLeft / 1000));
const timeStr = secsLeft > 60
? `${Math.floor(secsLeft / 60)}m${secsLeft % 60}s`
: `{red-fg}${secsLeft}s{/red-fg}`;
const combined = (pos.yes.buyPrice + pos.no.buyPrice).toFixed(4);
const spread = (1 - pos.yes.buyPrice - pos.no.buyPrice).toFixed(4);
lines.push(` {cyan-fg}${assetTag}${label}{/cyan-fg}`);
lines.push(` Status : ${pos.status} | Time left: ${timeStr}`);
lines.push(` Combined: $${combined} | Spread: $${spread}`);
// YES side
const yFill = pos.yes.filled
? `{green-fg}FILLED{/green-fg}`
: `{yellow-fg}bid $${pos.yes.buyPrice?.toFixed(3)}{/yellow-fg}`;
lines.push(` YES ${pos.targetShares?.toFixed(1)} sh @ $${pos.yes.buyPrice?.toFixed(3)}${yFill}`);
// NO side
const nFill = pos.no.filled
? `{green-fg}FILLED{/green-fg}`
: `{yellow-fg}bid $${pos.no.buyPrice?.toFixed(3)}{/yellow-fg}`;
lines.push(` NO ${pos.targetShares?.toFixed(1)} sh @ $${pos.no.buyPrice?.toFixed(3)}${nFill}`);
if (pos.totalProfit !== 0) {
const sign = pos.totalProfit >= 0 ? '+' : '';
const color = pos.totalProfit >= 0 ? 'green' : 'red';
lines.push(` P&L: {${color}-fg}${sign}$${pos.totalProfit.toFixed(2)}{/${color}-fg}`);
}
lines.push('');
}
}
return '\n' + lines.join('\n');
}
let refreshTimer = null;
function startRefresh() {
refreshTimer = setInterval(async () => {
if (!isDashboardActive()) return;
const content = await buildStatusContent();
updateStatus(content);
}, 3000);
// Immediate refresh
buildStatusContent().then(updateStatus);
}
// ── Market handler with per-asset queue ──────────────────────────────────────
const pendingByAsset = new Map();
async function runStrategy(market) {
try {
await executeMakerRebateStrategy(market);
} catch (err) {
logger.error(`MakerMM strategy error (${market.asset?.toUpperCase()}): ${err.message}`);
}
// After position clears, execute queued market for this asset
const queued = pendingByAsset.get(market.asset);
if (queued) {
pendingByAsset.delete(market.asset);
const endMs = new Date(queued.endTime).getTime();
const secsLeft = Math.round((endMs - Date.now()) / 1000);
if (secsLeft > config.makerMmCutLossTime) {
logger.success(
`MakerMM[${market.asset?.toUpperCase()}]: position cleared — ` +
`executing queued "${queued.question.substring(0, 40)}" (${secsLeft}s left)`,
);
runStrategy(queued);
} else {
logger.warn(
`MakerMM[${market.asset?.toUpperCase()}]: queued market "${queued.question.substring(0, 40)}" ` +
`expired (${secsLeft}s left) — discarding`,
);
}
}
}
async function handleNewMarket(market) {
const active = getActiveMakerPositions();
const isAssetBusy = active.some((p) => p.asset === market.asset);
if (isAssetBusy) {
pendingByAsset.set(market.asset, market);
logger.warn(
`MakerMM[${market.asset?.toUpperCase()}]: queued "${market.question.substring(0, 40)}" — ` +
`will enter after current position clears`,
);
return;
}
runStrategy(market);
}
// ── Graceful shutdown ─────────────────────────────────────────────────────────
function shutdown() {
logger.warn('MakerMM: shutting down...');
stopMMDetector();
mmFillWatcher.stop();
if (refreshTimer) clearInterval(refreshTimer);
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
// ── Start ─────────────────────────────────────────────────────────────────────
logger.info(`MakerMM bot starting — ${config.dryRun ? 'SIMULATION MODE' : 'LIVE MODE'} | assets: ${config.makerMmAssets.join(', ').toUpperCase()} | ${config.makerMmDuration}`);
startRefresh();
startMMDetector(handleNewMarket);
// Immediately check if there's a current active market to enter
checkCurrentMarket(handleNewMarket);
-197
View File
@@ -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...`);
+1 -10
View File
@@ -6,9 +6,6 @@
* npm run mm-sim (simulation / dry-run)
*/
// Set proxy before any network calls
import './utils/proxy-patch.cjs';
import { validateMMConfig } from './config/index.js';
import config from './config/index.js';
import logger from './utils/logger.js';
@@ -16,7 +13,6 @@ import { initClient, getClient } from './services/client.js';
import { initDashboard, appendLog, updateStatus, isDashboardActive } from './ui/dashboard.js';
import { startMMDetector, stopMMDetector } from './services/mmDetector.js';
import { executeMMStrategy, getActiveMMPositions } from './services/mmExecutor.js';
import { mmFillWatcher } from './services/mmWsFillWatcher.js';
import { getUsdcBalance } from './services/client.js';
import { cleanupOpenPositions, redeemMMPositions, MIN_SHARES_PER_SIDE } from './services/ctf.js';
@@ -53,11 +49,7 @@ if (config.mmTradeSize < MIN_SHARES_PER_SIDE) {
process.exit(1);
}
// ── Start WebSocket fill watcher for real-time order detection ────────────────
mmFillWatcher.start();
// ── Cleanup leftover positions on startup ────────────────────────────────────
// ── Cleanup leftover positions on startup ─────────────────────────────────────
try {
await cleanupOpenPositions(getClient());
@@ -215,7 +207,6 @@ async function handleNewMarket(market) {
function shutdown() {
logger.warn('MM: shutting down...');
stopMMDetector();
mmFillWatcher.stop();
if (refreshTimer) clearInterval(refreshTimer);
if (redeemTimer) clearInterval(redeemTimer);
process.exit(0);
+465
View File
@@ -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);
});
+22
View File
@@ -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;
+221
View File
@@ -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
*/
+174
View File
@@ -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);
}
}
+374
View File
@@ -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 (200500)
* @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,
};
}
}
+153
View File
@@ -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
*/
+280
View File
@@ -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
*/
+138
View File
@@ -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}`,
);
}
}
+304
View File
@@ -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 (01)
* @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.600.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,
});
}
}
+69
View File
@@ -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);
}
}
+111
View File
@@ -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}`);
});
}
}
+70
View File
@@ -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]: [],
});
+28
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
},
{
-935
View File
@@ -1,935 +0,0 @@
/**
* makerRebateExecutor.js
* Simplified Maker Rebate MM strategy:
* 1. Fetch YES orderbook
* 2. Deduce NO price from YES (YES + NO ≈ $1.00)
* 3. Place BUY limit once on both sides (NO repricing)
* 4. Wait for 100% fill with SAME share count on both sides
* 5. Merge YES+NO → $1.00 USDC → profit + maker rebates
*/
import { Side, OrderType } from '@polymarket/clob-client';
import { ethers } from 'ethers';
import config from '../config/index.js';
import { getClient, getUsdcBalance, getPolygonProvider } from './client.js';
import { mergePositions, redeemPositions } from './ctf.js';
import { mmFillWatcher } from './mmWsFillWatcher.js';
import logger from '../utils/logger.js';
const CTF_ADDRESS = '0x4D97DCd97eC945f40cF65F87097ACe5EA0476045';
const CTF_BALANCE_ABI = ['function balanceOf(address account, uint256 id) view returns (uint256)'];
const CLOB_MIN_ORDER_SHARES = 5;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Price range limits — configurable via MAKER_MM_MIN_PRICE / MAKER_MM_MAX_PRICE
// Both sides must be within this range to qualify for maker rebates
const getMinPrice = () => config.makerMmMinPrice;
const getMaxPrice = () => config.makerMmMaxPrice;
const activePositions = new Map();
export function getActiveMakerPositions() {
return Array.from(activePositions.values());
}
// Export for use in maker-mm-bot.js
export { getMarketOdds };
// ── Price helpers ────────────────────────────────────────────────────────────
async function getRealPrice(tokenId) {
const client = getClient();
try {
const result = await client.getPrice(tokenId, 'BUY');
const price = parseFloat(result?.price ?? result ?? '0');
if (price > 0 && price < 1) return price;
} catch (err) {
logger.warn(`MakerMM: getPrice error — ${err.message}`);
}
try {
const mp = await client.getMidpoint(tokenId);
const price = parseFloat(mp?.mid ?? mp ?? '0');
if (price > 0 && price < 1) return price;
} catch {}
return null;
}
function roundToTick(price, tickSize) {
const ts = parseFloat(tickSize);
const rounded = Math.round(price / ts) * ts;
const decimals = tickSize.toString().split('.')[1]?.length || 2;
return Math.max(0.01, Math.min(0.99, parseFloat(rounded.toFixed(decimals))));
}
// ── Get best ask via getPrice(SELL) — the lowest price a seller will accept ────
// Used as a safety cap to ensure our bid never crosses the ask (taker prevention).
async function getBestAsk(tokenId) {
const client = getClient();
try {
const result = await client.getPrice(tokenId, 'SELL');
const price = parseFloat(result?.price ?? result ?? '0');
return (price > 0 && price < 1) ? price : null;
} catch (err) {
logger.warn(`MakerMM: getBestAsk error — ${err.message}`);
return null;
}
}
// ── Bid-based repricing ───────────────────────────────────────────────────────
// ── Get current market odds ──────────────────────────────────────────────────
async function getMarketOdds(yesTokenId, noTokenId) {
try {
const [yesPrice, noPrice] = await Promise.all([
getRealPrice(yesTokenId),
getRealPrice(noTokenId),
]);
if (yesPrice && noPrice) {
return { yes: yesPrice, no: noPrice, max: Math.max(yesPrice, noPrice) };
}
} catch (err) {
logger.warn(`MakerMM: getMarketOdds error — ${err.message}`);
}
return null;
}
// ── Order helpers ────────────────────────────────────────────────────────────
/**
* Check order status via CLOB API
* Returns true if order is filled (even if createAndPostOrder returned false)
*/
async function checkOrderStatus(orderId) {
if (!orderId || orderId.startsWith('filled-') || orderId.startsWith('sim-')) return null;
try {
const client = getClient();
const order = await client.getOrder(orderId);
// Order might be: OPEN, FILLED, PARTIAL_FILLED, CANCELLED, etc.
if (order?.status === 'FILLED' || order?.status === 'FILLED_FULLY') {
return 'filled';
}
if (order?.status === 'PARTIAL_FILLED' || order?.status === 'FILLED_PARTIALLY') {
return 'partial';
}
if (order?.status === 'CANCELLED' || order?.status === 'CANCELLED_BY_USER' || order?.status === 'EXPIRED') {
return 'cancelled';
}
if (order?.status === 'OPEN') {
return 'open';
}
} catch (err) {
// Order not found or API error - consider as unknown
logger.debug(`MakerMM: order status check failed for ${orderId?.slice(-8)}${err.message}`);
}
return 'unknown';
}
// ── Market sell ───────────────────────────────────────────────────────────────
// Verifies onchain balance after each attempt — CLOB fill confirmation alone is
// not enough because sells can also be ghost-filled (CLOB says done, txhash invalid,
// shares still in wallet). Retries up to 3 times with onchain verification.
async function marketSellToken(tokenId, shares, tickSize, negRisk, tag) {
if (config.dryRun) {
logger.info(`MakerMM${tag}: [SIM] would market-sell ${shares.toFixed(4)} shares of token ${tokenId.slice(-8)}`);
return true;
}
const client = getClient();
const maxAttempts = 3;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
// Snapshot balance before sell — source of truth for whether it went through
const balanceBefore = (await getTokenBalance(tokenId)) ?? 0;
if (balanceBefore < 0.01) {
logger.info(`MakerMM${tag}: sell skipped — balance already 0`);
return true;
}
const sharesToSell = Math.min(shares, balanceBefore);
let refPrice = 0.01;
try {
const bidResult = await client.getPrice(tokenId, 'BUY');
const bid = parseFloat(bidResult?.price ?? bidResult ?? '0');
if (bid > 0) refPrice = Math.max(bid * 0.97, 0.01);
} catch {}
try {
const response = await client.createAndPostMarketOrder(
{ tokenID: tokenId, side: Side.SELL, amount: sharesToSell, price: refPrice },
{ tickSize, negRisk },
OrderType.FAK,
);
if (!response?.success || parseFloat(response?.takingAmount || '0') === 0) {
logger.warn(`MakerMM${tag}: sell attempt ${attempt}/${maxAttempts} — CLOB rejected (${response?.errorMsg || 'no liquidity'})`);
await sleep(3000);
continue;
}
// CLOB says filled — wait then verify onchain balance actually decreased
await sleep(8000);
const balanceAfter = (await getTokenBalance(tokenId)) ?? balanceBefore;
const sold = balanceBefore - balanceAfter;
if (sold >= sharesToSell * 0.5) {
logger.money(`MakerMM${tag}: sold ${sold.toFixed(4)} shares @ ~$${refPrice.toFixed(3)} (attempt ${attempt})`);
return true;
}
// Balance unchanged → ghost sell, retry
logger.warn(`MakerMM${tag}: sell attempt ${attempt}/${maxAttempts} ghost — CLOB filled but ${balanceAfter.toFixed(4)} shares still onchain, retrying...`);
await sleep(5000 * attempt);
} catch (err) {
logger.error(`MakerMM${tag}: sell attempt ${attempt}/${maxAttempts} error — ${err.message}`);
await sleep(3000);
}
}
logger.warn(`MakerMM${tag}: sell failed after ${maxAttempts} attempts — shares remain in wallet (will resolve at market close)`);
return false;
}
// ── Ghost fill recovery ───────────────────────────────────────────────────────
// Onchain balance doesn't match what CLOB says was filled (partial or full ghost).
// Strategy: merge whatever paired shares exist, then market-sell any unpaired remainder.
// Handles all partial amounts — caller passes actual onchain balances.
async function recoverFromGhostFill(pos, yesShares, noShares, tag) {
logger.warn(
`MakerMM${tag}: ghost fill recovery — onchain YES=${yesShares.toFixed(4)} NO=${noShares.toFixed(4)} ` +
`(expected ${pos.targetShares} each)`
);
const mergeable = Math.floor(Math.min(yesShares, noShares) * 10000) / 10000;
let mergeRecovered = 0;
if (mergeable >= 1) {
try {
await mergePositions(pos.conditionId, mergeable, pos.negRisk);
mergeRecovered = mergeable;
logger.money(`MakerMM${tag}: ghost recovery merge ${mergeable.toFixed(4)} pairs → $${mergeRecovered.toFixed(2)}`);
} catch (err) {
logger.error(`MakerMM${tag}: ghost recovery merge failed — ${err.message}`);
}
}
const yesRemainder = parseFloat(Math.max(0, yesShares - mergeable).toFixed(6));
const noRemainder = parseFloat(Math.max(0, noShares - mergeable).toFixed(6));
// Only market-sell the CHEAP side remainder — expensive side is too costly to dump at market.
// e.g. YES=83c filled, NO=15c ghost → hold YES (high cost basis, market sell = guaranteed loss).
// NO=15c filled, YES=83c ghost → sell NO (cheap, small loss acceptable).
const expSide = pos.yes.buyPrice >= pos.no.buyPrice ? 'yes' : 'no';
if (yesRemainder >= 1) {
if (expSide === 'yes') {
logger.warn(`MakerMM${tag}: ghost recovery — holding YES remainder ${yesRemainder.toFixed(4)} (expensive $${pos.yes.buyPrice}, scheduling redeem after resolution)`);
pos.holdingSide = 'yes';
} else {
await marketSellToken(pos.yes.tokenId, yesRemainder, pos.tickSize, pos.negRisk, tag);
}
}
if (noRemainder >= 1) {
if (expSide === 'no') {
logger.warn(`MakerMM${tag}: ghost recovery — holding NO remainder ${noRemainder.toFixed(4)} (expensive $${pos.no.buyPrice}, scheduling redeem after resolution)`);
pos.holdingSide = 'no';
} else {
await marketSellToken(pos.no.tokenId, noRemainder, pos.tickSize, pos.negRisk, tag);
}
}
pos.totalProfit = mergeRecovered - (pos.yes.cost + pos.no.cost);
// Don't mark done if holding expensive side — waitAndRedeem will close it out
if (!pos.holdingSide) pos.status = 'done';
}
async function placeLimitBuy(tokenId, shares, price, tickSize, negRisk) {
if (config.dryRun) {
return { success: true, orderId: `sim-buy-${Date.now()}-${tokenId.slice(-6)}` };
}
const client = getClient();
try {
const res = await client.createAndPostOrder(
{ tokenID: tokenId, side: Side.BUY, price, size: shares },
{ tickSize, negRisk },
OrderType.GTC,
);
if (!res?.success) {
// JSON.stringify can throw "Maximum call stack size exceeded" if res
// contains a circular reference (e.g. axios/fetch response object).
// Log only safe primitive fields instead.
const errDetail = res?.errorMsg || res?.error || res?.message || 'unknown';
logger.error(`MakerMM: limit buy failed — response: {"error":"${errDetail}","status":${res?.status ?? 'n/a'}}`);
return { success: false };
}
return { success: true, orderId: res.orderID };
} catch (err) {
logger.error(`MakerMM: limit buy error — ${err.message}`);
return { success: false };
}
}
async function cancelOrder(orderId) {
if (config.dryRun || !orderId || orderId.startsWith('sim-')) return true;
try {
const client = getClient();
await client.cancelOrder({ orderID: orderId });
return true;
} catch (err) {
logger.warn(`MakerMM: cancel error — ${err.message}`);
return false;
}
}
// ── Fill detection ───────────────────────────────────────────────────────────
async function getTokenBalance(tokenId) {
try {
const provider = getPolygonProvider(); // singleton — no await needed
const ctf = new ethers.Contract(CTF_ADDRESS, CTF_BALANCE_ABI, provider);
const raw = await ctf.balanceOf(config.proxyWallet, tokenId);
return parseFloat(ethers.utils.formatUnits(raw, 6));
} catch { return null; }
}
function waitForFillOrTimeout(tokenIds, timeoutMs) {
return new Promise((resolve) => {
let timer;
const onFill = (event) => {
if (tokenIds.includes(event.tokenId)) {
clearTimeout(timer);
mmFillWatcher.removeListener('fill', onFill);
resolve(event);
}
};
mmFillWatcher.on('fill', onFill);
timer = setTimeout(() => {
mmFillWatcher.removeListener('fill', onFill);
resolve(null);
}, timeoutMs);
});
}
// ── Core monitoring ───────────────────────────────────────────────────────────
async function monitorUntilFilled(pos, tag, label) {
mmFillWatcher.watch(pos.yes.tokenId);
mmFillWatcher.watch(pos.no.tokenId);
// WS fill events: early signal only — onchain balance is the source of truth.
// Side filter removed: RTDS may report side from taker perspective (SELL),
// not our maker perspective. We're already gated by proxyWallet + tokenId.
const onWsFill = (event) => {
// WS is used only as a wake-up signal — do NOT set pos.filled here.
// Setting filled=true from WS on a partial fill (e.g. 2 of 5 shares) would
// make the loop think the side is done and skip the onchain balance check,
// leaving the position stuck. Onchain balance is the sole source of truth.
if (event.tokenId === pos.yes.tokenId) {
logger.money(`MakerMM${tag}: YES fill signal (WS) ${event.size?.toFixed(2) || '?'} @ $${event.price?.toFixed(3) || pos.yes.buyPrice.toFixed(3)}`);
}
if (event.tokenId === pos.no.tokenId) {
logger.money(`MakerMM${tag}: NO fill signal (WS) ${event.size?.toFixed(2) || '?'} @ $${event.price?.toFixed(3) || pos.no.buyPrice.toFixed(3)}`);
}
};
mmFillWatcher.on('fill', onWsFill);
// Brief pause to let WebSocket register token subscriptions
await sleep(50);
try {
let fastFillCheckCount = 0;
const maxFastChecks = 10; // 1s polling for first 10s
while (true) {
// Safety guard: exit immediately if resolved by any path
if (pos.status === 'done') return;
// ── Onchain balance — source of truth, checked FIRST ──────────────
const [yesBal, noBal] = await Promise.all([
getTokenBalance(pos.yes.tokenId),
getTokenBalance(pos.no.tokenId),
]);
// NET new shares only — subtract baseline to exclude leftover tokens
// from previous cycles on the same tokenId. Without this, re-entry
// would see old balance >= 0.5x target and trigger a false early merge
// while the new orders are still open in the orderbook.
// Use toFixed(6) — full precision to avoid rounding UP past actual token balance.
// toFixed(4) could round 4.910199 → 4.9102 (4910200 wei) when Safe has 4910199 → revert.
const yesShares = parseFloat(Math.max(0, (yesBal || 0) - pos.yes.baseline).toFixed(6));
const noShares = parseFloat(Math.max(0, (noBal || 0) - pos.no.baseline).toFixed(6));
// Sync fill flags from onchain (source of truth)
if (!pos.yes.filled && yesShares >= pos.targetShares * 0.99) {
pos.yes.filled = true;
logger.money(`MakerMM${tag}: YES filled (onchain) ${yesShares.toFixed(4)} shares`);
}
if (!pos.no.filled && noShares >= pos.targetShares * 0.99) {
pos.no.filled = true;
logger.money(`MakerMM${tag}: NO filled (onchain) ${noShares.toFixed(4)} shares`);
}
// ── Cancel cheap side when expensive fills first ──────────────────────
// When enabled: if the expensive side fills and cheap side hasn't,
// cancel the cheap order and hold the expensive token to redeem at resolution.
if (config.makerMmCancelCheapOnExpFill) {
const expSide = pos.yes.buyPrice >= pos.no.buyPrice ? 'yes' : 'no';
const cheapSide = expSide === 'yes' ? 'no' : 'yes';
if (pos[expSide].filled && !pos[cheapSide].filled) {
logger.info(
`MakerMM${tag}: ${expSide.toUpperCase()} ($${pos[expSide].buyPrice}) filled first — ` +
`cancelling cheap ${cheapSide.toUpperCase()} ($${pos[cheapSide].buyPrice}) order`
);
await cancelOrder(pos[cheapSide].orderId);
pos.holdingSide = expSide;
pos.status = 'holding';
return;
}
}
// ── Over-position safety net ────────────────────────────────────────
// If one side's balance is > 1.5x target AND the current order is still open,
// a double-fill occurred (old cancelled order + new order both filled).
// Cancel the open order immediately so it doesn't also fill.
if (yesShares > pos.targetShares * 1.5 && pos.yes.orderId && !pos.yes.filled) {
logger.warn(`MakerMM${tag}: YES over-position (${yesShares.toFixed(4)} > 1.5x target=${pos.targetShares}) — cancelling open order to stop double-fill`);
await cancelOrder(pos.yes.orderId);
pos.yes.filled = true;
if (!pos.firstFillTime) pos.firstFillTime = Date.now();
}
if (noShares > pos.targetShares * 1.5 && pos.no.orderId && !pos.no.filled) {
logger.warn(`MakerMM${tag}: NO over-position (${noShares.toFixed(4)} > 1.5x target=${pos.targetShares}) — cancelling open order to stop double-fill`);
await cancelOrder(pos.no.orderId);
pos.no.filled = true;
if (!pos.firstFillTime) pos.firstFillTime = Date.now();
}
// ── Ghost fill detection via open orders check ────────────────────────
// More reliable than checkOrderStatus(orderId) which can return 'unknown'
// for ghost fills (invalid txhash → CLOB state is inconsistent).
// If our order is gone from open orders but onchain balance didn't increase
// → order was matched in CLOB but settlement failed (ghost fill).
{
const nowMs = Date.now();
const client = getClient();
if (!pos.yes.filled && !pos.yes.clobFilled && pos.yes.orderId && nowMs - (pos.yes.lastClobCheck || 0) >= 15_000) {
pos.yes.lastClobCheck = nowMs;
try {
const openOrders = await client.getOpenOrders({ asset_id: pos.yes.tokenId });
const stillOpen = Array.isArray(openOrders) && openOrders.some(o => (o.id ?? o.order_id) === pos.yes.orderId);
if (!stillOpen) {
pos.yes.clobFilled = true;
logger.info(`MakerMM${tag}: YES order gone from CLOB open orders (onchain not yet reflected)`);
}
} catch {}
}
if (!pos.no.filled && !pos.no.clobFilled && pos.no.orderId && nowMs - (pos.no.lastClobCheck || 0) >= 15_000) {
pos.no.lastClobCheck = nowMs;
try {
const openOrders = await client.getOpenOrders({ asset_id: pos.no.tokenId });
const stillOpen = Array.isArray(openOrders) && openOrders.some(o => (o.id ?? o.order_id) === pos.no.orderId);
if (!stillOpen) {
pos.no.clobFilled = true;
logger.info(`MakerMM${tag}: NO order gone from CLOB open orders (onchain not yet reflected)`);
}
} catch {}
}
// Ghost fill detection:
// CLOB says order is FILLED but onchain balance < expected after timeout.
// Could be full ghost (0 tokens) or partial (some tokens, but not all).
// Trigger: either side clobFilled AND onchain short of target after 60s.
const yesGhost = pos.yes.clobFilled && yesShares < pos.targetShares * 0.99;
const noGhost = pos.no.clobFilled && noShares < pos.targetShares * 0.99;
if (yesGhost || noGhost) {
if (!pos.ghostFillSince) pos.ghostFillSince = nowMs;
const waitedSec = Math.round((nowMs - pos.ghostFillSince) / 1000);
if (waitedSec >= 30) {
// 30s is enough to distinguish settlement delay from ghost fill.
// Act now while market prices are still fair — don't wait for cut-loss.
await recoverFromGhostFill(pos, yesShares, noShares, tag);
return;
} else {
logger.info(
`MakerMM${tag}: ghost fill suspected ` +
`(YES CLOB=${pos.yes.clobFilled} onchain=${yesShares.toFixed(4)}, ` +
`NO CLOB=${pos.no.clobFilled} onchain=${noShares.toFixed(4)}) ` +
`— waiting ${waitedSec}s / 30s`
);
}
}
}
// ── WS fallback: both sides WS-confirmed filled but onchain RPC not reflecting ──
// If onchain balance is unavailable (RPC slow/failed) but both filled flags are
// set from WS signals, wait a grace period then merge with targetShares as fallback.
if (pos.yes.filled && pos.no.filled && yesShares < pos.targetShares * 0.5 && noShares < pos.targetShares * 0.5) {
if (!pos.bothFilledSince) pos.bothFilledSince = Date.now();
const waitedSec = Math.round((Date.now() - pos.bothFilledSince) / 1000);
if (waitedSec >= 15) {
logger.warn(
`MakerMM${tag}: both sides WS-filled but onchain shows YES=${yesShares} NO=${noShares} after ${waitedSec}s ` +
`— RPC may be stale, merging with target ${pos.targetShares} shares`
);
await executeMerge(pos, pos.targetShares, tag);
if (pos.status === 'done') return;
} else {
logger.info(`MakerMM${tag}: both WS-filled, waiting for onchain confirmation (${waitedSec}s / 15s grace)...`);
}
}
// Both sides have net balance ≥ 50% target → merge
if (yesShares >= pos.targetShares * 0.5 && noShares >= pos.targetShares * 0.5) {
pos.bothFilledSince = null; // onchain confirmed — clear WS fallback timer
const minShares = Math.min(yesShares, noShares);
const isFull = yesShares >= pos.targetShares * 0.99 && noShares >= pos.targetShares * 0.99;
logger.success(
`MakerMM${tag}: ${isFull ? 'FULL' : 'PARTIAL'} fill — ` +
`YES=${yesShares.toFixed(4)} NO=${noShares.toFixed(4)}, merging ${minShares.toFixed(4)} shares`
);
pos.yes.filled = true;
pos.no.filled = true;
await executeMerge(pos, minShares, tag);
if (pos.status === 'done') return;
// Merge call errored — but tx may have confirmed onchain despite the RPC error
// (common: tx.wait() timeout while tx was already included in a block).
// Re-check balance to avoid looping forever on an empty position.
const [yesRecheck, noRecheck] = await Promise.all([
getTokenBalance(pos.yes.tokenId),
getTokenBalance(pos.no.tokenId),
]);
const yesNetRecheck = Math.max(0, (yesRecheck || 0) - pos.yes.baseline);
const noNetRecheck = Math.max(0, (noRecheck || 0) - pos.no.baseline);
if (yesNetRecheck < pos.targetShares * 0.1 && noNetRecheck < pos.targetShares * 0.1) {
logger.success(`MakerMM${tag}: merge confirmed onchain (RPC reported error but tx went through)`);
pos.status = 'done';
pos.totalProfit = minShares - (pos.yes.cost + pos.no.cost);
return;
}
pos.mergeFailCount = (pos.mergeFailCount || 0) + 1;
const backoffSec = Math.min(5 * pos.mergeFailCount, 30); // 5s, 10s, 15s … max 30s
logger.warn(`MakerMM${tag}: merge failed (attempt ${pos.mergeFailCount}) — tokens still present (YES=${yesNetRecheck.toFixed(6)} NO=${noNetRecheck.toFixed(6)}), retrying in ${backoffSec}s`);
await sleep(backoffSec * 1000);
}
// ── Cut-loss check (AFTER balance check) ──────────────────────────
const msRemaining = new Date(pos.endTime).getTime() - Date.now();
if (msRemaining <= config.makerMmCutLossTime * 1000) {
logger.warn(`MakerMM${tag}: cut-loss — net YES=${yesShares.toFixed(4)} NO=${noShares.toFixed(4)}`);
if (yesShares >= 1 && noShares >= 1) {
// Both sides have net fills — emergency merge to recover USDC
const minShares = Math.min(yesShares, noShares);
logger.warn(`MakerMM${tag}: emergency merge ${minShares.toFixed(4)} shares`);
await executeMerge(pos, minShares, tag);
} else {
// One or neither side net-filled — cancel open orders, log held tokens
await Promise.all([
cancelOrder(pos.yes.orderId),
cancelOrder(pos.no.orderId),
]);
if (yesShares > 0 || noShares > 0) {
logger.warn(`MakerMM${tag}: tokens held — net YES=${yesShares.toFixed(4)} NO=${noShares.toFixed(4)} (cannot merge)`);
pos.totalProfit = -((yesShares > 0 ? pos.yes.cost : 0) + (noShares > 0 ? pos.no.cost : 0));
pos.oneSided = true; // flag: cycle ended with one-sided fill
} else {
logger.info(`MakerMM${tag}: no net fills — orders cancelled, zero loss`);
pos.totalProfit = 0;
}
pos.status = 'done';
}
return;
}
// ── One side filled — log status and keep waiting ─────────────────
if (pos.yes.filled !== pos.no.filled) {
const filledKey = pos.yes.filled ? 'yes' : 'no';
const now = Date.now();
if (now < pos.marketOpenTime) {
logger.info(`MakerMM${tag}: ${filledKey.toUpperCase()} filled — market not open yet (${Math.round((pos.marketOpenTime - now) / 1000)}s), waiting...`);
} else {
if (!pos.firstFillTime) {
pos.firstFillTime = now;
logger.info(`MakerMM${tag}: ${filledKey.toUpperCase()} filled first — waiting for other side...`);
} else {
const elapsedMin = Math.floor((now - pos.firstFillTime) / 60000);
if (elapsedMin > 0 && elapsedMin % 5 === 0 && pos.lastLogMin !== elapsedMin) {
pos.lastLogMin = elapsedMin;
logger.info(`MakerMM${tag}: still waiting for ${filledKey === 'yes' ? 'NO' : 'YES'}${elapsedMin}m elapsed`);
}
}
}
}
// Fast polling first 10s, then event-driven with 5s fallback
fastFillCheckCount++;
if (fastFillCheckCount < maxFastChecks) {
await sleep(1000);
} else {
await waitForFillOrTimeout([pos.yes.tokenId, pos.no.tokenId], 5000);
}
}
} finally {
mmFillWatcher.removeListener('fill', onWsFill);
mmFillWatcher.unwatch(pos.yes.tokenId);
mmFillWatcher.unwatch(pos.no.tokenId);
// Cancel any residual open orders — can happen when loss-compensating reprice
// placed extra shares (e.g. 6 NO) but merge triggered after 5 filled,
// leaving 1 remaining NO share still open in the orderbook.
await Promise.all([
cancelOrder(pos.yes.orderId),
cancelOrder(pos.no.orderId),
]).catch(() => {});
}
}
async function executeMerge(pos, shares, tag) {
const totalCost = pos.yes.cost + pos.no.cost;
const recovered = shares; // Merge returns $1 per share
pos.totalProfit = recovered - totalCost;
try {
await mergePositions(pos.conditionId, shares, pos.negRisk);
// Orders are already fully filled at this point — no cancel needed
logger.money(`MakerMM${tag}: MERGED ${shares.toFixed(4)} shares → $${recovered.toFixed(2)} | cost $${totalCost.toFixed(2)} | P&L $${pos.totalProfit.toFixed(2)}`);
pos.status = 'done';
} catch (err) {
logger.error(`MakerMM${tag}: merge failed — ${err.message}`);
// Don't change status — let monitor loop continue
}
}
// ── Auto-redeem after market resolution ──────────────────────────────────────
// Used when holding a single-sided position (expensive side filled, cheap cancelled).
// Polls until the market resolves on-chain, then calls redeemPositions.
async function waitAndRedeem(pos, tag) {
const endMs = new Date(pos.endTime).getTime();
const waitForEndMs = endMs - Date.now();
if (waitForEndMs > 0) {
logger.info(`MakerMM${tag}: holding ${pos.holdingSide.toUpperCase()} — waiting ${Math.round(waitForEndMs / 1000)}s for market to end...`);
await sleep(waitForEndMs);
}
if (config.dryRun) {
logger.info(`MakerMM${tag}: [SIM] would redeem ${pos.holdingSide.toUpperCase()} after resolution`);
return;
}
logger.info(`MakerMM${tag}: market ended — polling for on-chain resolution...`);
const provider = getPolygonProvider();
const ctf = new ethers.Contract(CTF_ADDRESS, ['function payoutDenominator(bytes32 conditionId) view returns (uint256)'], provider);
const maxWaitMs = 10 * 60 * 1000; // 10 minutes max
const pollMs = 15_000;
const start = Date.now();
while (Date.now() - start < maxWaitMs) {
try {
const denom = await ctf.payoutDenominator(pos.conditionId);
if (!denom.isZero()) {
logger.info(`MakerMM${tag}: market resolved — redeeming ${pos.holdingSide.toUpperCase()} tokens...`);
await redeemPositions(pos.conditionId, pos.negRisk);
logger.money(`MakerMM${tag}: redemption complete`);
return;
}
} catch (err) {
logger.warn(`MakerMM${tag}: resolution poll error — ${err.message}`);
}
const elapsedSec = Math.round((Date.now() - start) / 1000);
logger.info(`MakerMM${tag}: not resolved yet (${elapsedSec}s / ${maxWaitMs / 1000}s) — retrying in ${pollMs / 1000}s...`);
await sleep(pollMs);
}
logger.warn(`MakerMM${tag}: market not resolved after ${maxWaitMs / 60000} minutes — skipping auto-redeem (tokens remain in wallet)`);
}
// ── Main entry ───────────────────────────────────────────────────────────────
export async function executeMakerRebateStrategy(market) {
const { asset, conditionId, question, endTime, eventStartTime, yesTokenId, noTokenId, negRisk, tickSize } = market;
const tag = asset ? `[${asset.toUpperCase()}]` : '';
const label = question.substring(0, 40);
const sim = config.dryRun ? '[SIM] ' : '';
// Market officially opens at eventStartTime (not when we detect it)
const marketOpenTime = eventStartTime ? new Date(eventStartTime).getTime() : Date.now();
// Wait until 10 seconds after market open before placing any orders.
// Orders placed too early (pre-open or first few seconds) tend to open at a loss
// due to wide spreads and erratic pricing before liquidity stabilizes.
const ENTRY_DELAY_MS = 10_000;
const entryNotBefore = marketOpenTime + ENTRY_DELAY_MS;
const waitMs = entryNotBefore - Date.now();
if (waitMs > 0) {
logger.info(`MakerMM${tag}: ${sim}waiting ${Math.round(waitMs / 1000)}s for market to stabilize (open +10s)...`);
await sleep(waitMs);
}
logger.info(`MakerMM${tag}: ${sim}entering — ${label}`);
// ── Wait for real YES price ─────────────────────────────────
const POLL_SEC = config.makerMmPollSec;
const ts = parseFloat(tickSize);
let yesBid, noBid, combined;
let yesEntryBid, noEntryBid; // best bid at time of entry — stored for drift tracking
const waitStart = Date.now();
const MIN_PRICE = getMinPrice();
const MAX_PRICE = getMaxPrice();
while (true) {
const msRemaining = new Date(endTime).getTime() - Date.now();
if (msRemaining <= config.makerMmCutLossTime * 1000) {
logger.warn(`MakerMM${tag}: market closing — aborting`);
return;
}
// ── Bid-based pricing: bid = bestBid + 1_tick (top of orderbook, guaranteed maker) ──
// We become the new top bid, getting fill priority over existing bids.
// Safety cap: newBid < bestAsk ensures we never accidentally cross and become a taker.
const [yesBestBid, yesAsk, noBestBid, noAsk] = await Promise.all([
getRealPrice(yesTokenId),
getBestAsk(yesTokenId),
getRealPrice(noTokenId),
getBestAsk(noTokenId),
]);
if (!yesBestBid || !noBestBid) {
logger.info(`MakerMM${tag}: waiting — no bid data (YES: ${yesBestBid ?? 'null'}, NO: ${noBestBid ?? 'null'})`);
await sleep(POLL_SEC * 1000);
continue;
}
// Auto-detect cheap side: whichever of YES/NO has lower bestBid.
// Range filter (MIN_PRICE/MAX_PRICE) applies to the cheap side only.
// The expensive side is derived from: maxCombined - cheapBid.
const cheapSide = yesBestBid <= noBestBid ? 'yes' : 'no';
const cheapBestBid = cheapSide === 'yes' ? yesBestBid : noBestBid;
const cheapAsk = cheapSide === 'yes' ? yesAsk : noAsk;
let cheapBid = roundToTick(cheapBestBid + ts, tickSize);
// Cap to ask - 2 ticks (not 1) to absorb timing race between fetch and place.
// A 1-tick buffer still lets the ask move 1 tick before our order is submitted,
// turning it into a marketable (taker) order and hitting the $1 minimum.
if (cheapAsk && cheapBid >= cheapAsk - ts) cheapBid = roundToTick(cheapAsk - 2 * ts, tickSize);
// Range check on cheap side
if (cheapBid < MIN_PRICE || cheapBid > MAX_PRICE) {
logger.info(`MakerMM${tag}: waiting — ${cheapSide.toUpperCase()} bid $${cheapBid.toFixed(3)} (need ${MIN_PRICE}-${MAX_PRICE})`);
await sleep(POLL_SEC * 1000);
continue;
}
// Expensive side: fill remaining combined budget
const expensiveBid = roundToTick(config.makerMmMaxCombined - cheapBid, tickSize);
const expensiveAsk = cheapSide === 'yes' ? noAsk : yesAsk;
let expBid = expensiveBid;
if (expensiveAsk && expBid >= expensiveAsk - ts) expBid = roundToTick(expensiveAsk - 2 * ts, tickSize);
if (expBid <= 0 || expBid >= 1) {
logger.info(`MakerMM${tag}: waiting — ${cheapSide === 'yes' ? 'NO' : 'YES'} bid $${expBid.toFixed(3)} out of bounds`);
await sleep(POLL_SEC * 1000);
continue;
}
// Map back to yes/no
yesBid = cheapSide === 'yes' ? cheapBid : expBid;
noBid = cheapSide === 'yes' ? expBid : cheapBid;
combined = parseFloat((yesBid + noBid).toFixed(4));
if (combined > config.makerMmMaxCombined) {
logger.info(`MakerMM${tag}: combined $${combined.toFixed(4)} > max — waiting`);
await sleep(POLL_SEC * 1000);
continue;
}
// If combined is more than 1 tick below target the market spread is too tight.
// Wait for better conditions instead of entering with lower-than-expected profit.
const minCombined = parseFloat((config.makerMmMaxCombined - ts).toFixed(4));
if (combined < minCombined) {
logger.info(`MakerMM${tag}: spread too tight — combined $${combined.toFixed(4)} < target $${config.makerMmMaxCombined} — waiting`);
await sleep(POLL_SEC * 1000);
continue;
}
yesEntryBid = yesBestBid;
noEntryBid = noBestBid;
const waitSec = ((Date.now() - waitStart) / 1000).toFixed(1);
logger.success(`MakerMM${tag}: ready after ${waitSec}s — YES $${yesBid} + NO $${noBid} = $${combined.toFixed(4)} (topBid YES:$${yesBestBid} NO:$${noBestBid})`);
break;
}
// ── Calculate shares ──────────────────────────────────────────
const targetShares = config.makerMmTradeSize;
if (targetShares < CLOB_MIN_ORDER_SHARES) {
logger.warn(`MakerMM${tag}: shares ${targetShares} < min ${CLOB_MIN_ORDER_SHARES} — skipping`);
return;
}
const yesCost = targetShares * yesBid;
const noCost = targetShares * noBid;
const totalCost = yesCost + noCost;
if (!config.dryRun) {
const balance = await getUsdcBalance();
if (balance < totalCost) {
logger.error(`MakerMM${tag}: insufficient balance $${balance.toFixed(2)} (need $${totalCost.toFixed(2)})`);
return;
}
}
// ── Snapshot balance BEFORE placing orders ────────────────────────────────
// Critical for re-entry: same tokenIds are reused each cycle, so leftover
// tokens from a previous cycle would otherwise fool the fill-detection logic
// into thinking the new orders filled instantly, causing a new cycle to start
// while the actual new orders remain open in the orderbook.
const [yesBaseline, noBaseline] = await Promise.all([
getTokenBalance(yesTokenId),
getTokenBalance(noTokenId),
]);
if ((yesBaseline || 0) > 0 || (noBaseline || 0) > 0) {
logger.info(`MakerMM${tag}: pre-order baseline — YES=${(yesBaseline || 0).toFixed(4)} NO=${(noBaseline || 0).toFixed(4)} (leftover from prior cycle)`);
}
// ── Place orders ONCE (NO repricing) ──────────────────────
logger.trade(`MakerMM${tag}: placing BUY — YES $${yesBid} × ${targetShares} + NO $${noBid} × ${targetShares} = $${totalCost.toFixed(2)}`);
const [yesBuy, noBuy] = await Promise.all([
placeLimitBuy(yesTokenId, targetShares, yesBid, tickSize, negRisk),
placeLimitBuy(noTokenId, targetShares, noBid, tickSize, negRisk),
]);
logger.info(`MakerMM${tag}: order results — YES: ${yesBuy.success ? 'OK' : 'FAIL'} (id=${yesBuy.orderId?.slice(-8) || 'none'}), NO: ${noBuy.success ? 'OK' : 'FAIL'} (id=${noBuy.orderId?.slice(-8) || 'none'})`);
// If one side failed, check if actually filled on-chain OR via order book before retrying
let finalYesBuy = yesBuy;
let finalNoBuy = noBuy;
const maxRetries = 3;
for (let attempt = 1; attempt <= maxRetries && (!finalYesBuy.success || !finalNoBuy.success); attempt++) {
// Check 1: On-chain balance (most reliable) — compare against baseline
const [yesBalance, noBalance] = await Promise.all([
getTokenBalance(yesTokenId),
getTokenBalance(noTokenId),
]);
const yesNet = (yesBalance || 0) - (yesBaseline || 0);
const noNet = (noBalance || 0) - (noBaseline || 0);
// Check 2: Order status via CLOB API (backup check)
const [yesOrderStatus, noOrderStatus] = await Promise.all([
finalYesBuy.success ? null : checkOrderStatus(yesBuy.orderId),
finalNoBuy.success ? null : checkOrderStatus(noBuy.orderId),
]);
if (yesOrderStatus || noOrderStatus) {
logger.info(`MakerMM${tag}: order status check — YES: ${yesOrderStatus || 'N/A'}, NO: ${noOrderStatus || 'N/A'}`);
}
// Use net (new) balance to determine if actually filled — not total balance
if (!finalYesBuy.success && (
yesNet >= targetShares * 0.5 ||
yesOrderStatus === 'filled' ||
yesOrderStatus === 'partial'
)) {
logger.success(`MakerMM${tag}: YES already filled (net: ${yesNet.toFixed(4)}, order: ${yesOrderStatus}) — no retry`);
finalYesBuy = { success: true, orderId: yesBuy.orderId || `filled-${Date.now()}` };
}
if (!finalNoBuy.success && (
noNet >= targetShares * 0.5 ||
noOrderStatus === 'filled' ||
noOrderStatus === 'partial'
)) {
logger.success(`MakerMM${tag}: NO already filled (net: ${noNet.toFixed(4)}, order: ${noOrderStatus}) — no retry`);
finalNoBuy = { success: true, orderId: noBuy.orderId || `filled-${Date.now()}` };
}
if (finalYesBuy.success && finalNoBuy.success) break;
// Cancel existing order before retry to avoid duplicate orders
if (!finalYesBuy.success) {
logger.warn(`MakerMM${tag}: retrying YES order (attempt ${attempt}/${maxRetries})...`);
await cancelOrder(yesBuy.orderId);
await sleep(500);
finalYesBuy = await placeLimitBuy(yesTokenId, targetShares, yesBid, tickSize, negRisk);
if (finalYesBuy.success) {
logger.success(`MakerMM${tag}: YES order succeeded on retry ${attempt}`);
}
}
if (!finalNoBuy.success) {
logger.warn(`MakerMM${tag}: retrying NO order (attempt ${attempt}/${maxRetries})...`);
await cancelOrder(noBuy.orderId);
await sleep(500);
finalNoBuy = await placeLimitBuy(noTokenId, targetShares, noBid, tickSize, negRisk);
if (finalNoBuy.success) {
logger.success(`MakerMM${tag}: NO order succeeded on retry ${attempt}`);
}
}
}
if (!finalYesBuy.success || !finalNoBuy.success) {
logger.error(`MakerMM${tag}: order failed after retries — YES: ${finalYesBuy.success}, NO: ${finalNoBuy.success}`);
await Promise.all([
finalYesBuy.success ? cancelOrder(finalYesBuy.orderId) : null,
finalNoBuy.success ? cancelOrder(finalNoBuy.orderId) : null,
]);
return;
}
// ── Build position and wait ─────────────────────────────────
const pos = {
asset: asset || 'btc',
conditionId,
question,
endTime,
marketOpenTime,
tickSize,
negRisk,
status: 'monitoring',
targetShares,
yes: {
tokenId: yesTokenId,
buyPrice: yesBid,
cost: yesCost,
orderId: finalYesBuy.orderId,
filled: false,
baseline: yesBaseline || 0,
},
no: {
tokenId: noTokenId,
buyPrice: noBid,
cost: noCost,
orderId: finalNoBuy.orderId,
filled: false,
baseline: noBaseline || 0,
},
totalProfit: 0,
};
activePositions.set(conditionId, pos);
await monitorUntilFilled(pos, tag, label);
activePositions.delete(conditionId);
// If holding a single-sided position (expensive filled, cheap cancelled) — wait and redeem
if (pos.holdingSide) {
await waitAndRedeem(pos, tag);
return { oneSided: false }; // not a stuck one-sided cycle, intentional hold
}
const sign = pos.totalProfit >= 0 ? '+' : '';
logger.info(`MakerMM${tag}: done | P&L: ${sign}$${pos.totalProfit.toFixed(2)}`);
return { oneSided: pos.oneSided ?? false };
}
+18 -67
View File
@@ -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
View File
@@ -14,16 +14,12 @@ import { ethers } from 'ethers';
import config from '../config/index.js';
import { getClient, getUsdcBalance, getPolygonProvider } from './client.js';
import { splitPosition, mergePositions } from './ctf.js';
import { mmFillWatcher } from './mmWsFillWatcher.js';
import logger from '../utils/logger.js';
// CTF contract for on-chain balance queries
const CTF_ADDRESS = '0x4D97DCd97eC945f40cF65F87097ACe5EA0476045';
const CTF_BALANCE_ABI = ['function balanceOf(address account, uint256 id) view returns (uint256)'];
// Polymarket CLOB minimum order size (shares)
const CLOB_MIN_ORDER_SHARES = 5;
/**
* Get actual on-chain ERC1155 token balance for the proxy wallet.
* Used before market-sell to avoid 'not enough balance' errors from partial fills.
@@ -41,37 +37,6 @@ async function getTokenBalance(tokenId) {
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Fallback poll interval — WS handles the fast path, this is the safety net
const POLL_INTERVAL_MS = 30_000;
/**
* Wait for a fill event from WebSocket OR timeout (polling fallback).
* Returns early if WS delivers a fill for any of the watched token IDs.
* @param {string[]} tokenIds - token IDs to listen for
* @param {number} timeoutMs - max wait time before returning for poll check
* @returns {Promise<{tokenId: string, size: number, price: number} | null>}
*/
function waitForFillOrTimeout(tokenIds, timeoutMs) {
return new Promise((resolve) => {
let timer;
const onFill = (event) => {
if (tokenIds.includes(event.tokenId)) {
clearTimeout(timer);
mmFillWatcher.removeListener('fill', onFill);
resolve(event);
}
};
mmFillWatcher.on('fill', onFill);
timer = setTimeout(() => {
mmFillWatcher.removeListener('fill', onFill);
resolve(null); // timeout — caller does poll check
}, timeoutMs);
});
}
// In-memory store of all active MM positions (conditionId → position)
const activePositions = new Map();
@@ -142,89 +107,17 @@ async function marketSell(tokenId, shares, tickSize, negRisk) {
// ── Order status check ────────────────────────────────────────────────────────
async function isOrderFilled(orderId, shares, tokenId = null) {
async function isOrderFilled(orderId, shares) {
if (!orderId || orderId.startsWith('sim-')) return false;
const MAX_FILL_RETRIES = 2;
for (let attempt = 1; attempt <= MAX_FILL_RETRIES; attempt++) {
try {
const client = getClient();
const order = await client.getOrder(orderId);
if (!order) break; // order gone — fall through to balance check
if (order.status === 'MATCHED') return true;
const matched = parseFloat(order.size_matched || '0');
if (matched >= shares * 0.99) return true;
// CLOB says not filled — trust it if we have no tokenId for balance check
if (!tokenId) return false;
// Otherwise fall through to balance check below
break;
} catch (err) {
logger.warn(`MM: isOrderFilled CLOB error (attempt ${attempt}/${MAX_FILL_RETRIES}): ${err.message}`);
if (attempt < MAX_FILL_RETRIES) await sleep(2000);
}
}
// Fallback: check on-chain token balance
// If we placed a SELL and our balance is now ~0, the order was filled
if (tokenId) {
const balance = await getTokenBalance(tokenId);
if (balance !== null && balance < shares * 0.05) {
logger.warn(`MM: CLOB API missed fill — on-chain balance ${balance.toFixed(3)} ≈ 0 (expected ${shares}) → treating as filled`);
return true;
}
}
return false;
}
/**
* Check how many shares of an order have been partially filled.
* Returns { matched, remaining, total }.
*/
async function getPartialFillInfo(orderId, originalShares, tokenId = null) {
let matched = 0;
if (orderId && !orderId.startsWith('sim-')) {
try {
const client = getClient();
const order = await client.getOrder(orderId);
if (order) {
if (order.status === 'MATCHED') {
matched = parseFloat(order.original_size || order.size || String(originalShares));
} else {
matched = parseFloat(order.size_matched || '0');
}
}
} catch { /* ignore */ }
}
// Cross-check with on-chain balance for accuracy
if (tokenId) {
const balance = await getTokenBalance(tokenId);
if (balance !== null) {
const onChainMatched = originalShares - balance;
if (onChainMatched > matched) {
matched = Math.max(0, onChainMatched);
}
return { matched, remaining: balance, total: originalShares };
}
}
return { matched, remaining: originalShares - matched, total: originalShares };
}
/**
* Get partial fill amount for an order (how many shares already matched).
* Returns 0 on error.
*/
async function getOrderMatched(orderId) {
if (!orderId || orderId.startsWith('sim-')) return 0;
try {
const client = getClient();
const order = await client.getOrder(orderId);
if (!order) return 0;
if (order.status === 'MATCHED') return parseFloat(order.original_size || order.size || '0');
return parseFloat(order.size_matched || '0');
if (!order) return false;
if (order.status === 'MATCHED') return true;
const matched = parseFloat(order.size_matched || '0');
return matched >= shares * 0.99;
} catch {
return 0;
return false;
}
}
@@ -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 (90180s): minAdaptivePrice - 0.10
* Phase 3 (3090s): minAdaptivePrice - 0.20
* Phase 4 (< 30s): market sell
*/
function getTieredFloor(msLeft) {
if (!is5m) return minAdaptivePrice; // non-5m: use fixed floor
if (msLeft > 180_000) return minAdaptivePrice;
if (msLeft > 90_000) return Math.max(0.01, minAdaptivePrice - floorDrop);
if (msLeft > 30_000) return Math.max(0.01, minAdaptivePrice - floorDrop * 2);
return 0; // phase 4: market sell
}
logger.warn(`MM: one leg filled — starting adaptive CL for ${unfilledKey.toUpperCase()} | ${label}`);
if (is5m) {
logger.info(`MM adaptive CL: filled @ $${filledLegPrice.toFixed(3)} | floor (minCombined $${config.mmAdaptiveMinCombined.toFixed(2)}): $${minAdaptivePrice.toFixed(3)} | tiered: $${minAdaptivePrice.toFixed(2)}$${Math.max(0.01, minAdaptivePrice - floorDrop).toFixed(2)}$${Math.max(0.01, minAdaptivePrice - floorDrop * 2).toFixed(2)}`);
} else {
logger.info(`MM adaptive CL: filled leg @ $${filledLegPrice.toFixed(3)} | min floor for combined ≥ $${config.mmAdaptiveMinCombined.toFixed(2)}: $${minAdaptivePrice.toFixed(3)}`);
}
// 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`);
-208
View File
@@ -1,208 +0,0 @@
/**
* mmWsFillWatcher.js
* Real-time order fill detection for the Market Maker via Polymarket RTDS WebSocket.
*
* Subscribes to the `activity` topic and filters events by the bot's own proxy wallet.
* When a SELL trade is detected on a token we're watching, emits a 'fill' event
* so mmExecutor can react instantly instead of polling every 10s.
*
* Usage:
* import { mmFillWatcher } from './mmWsFillWatcher.js';
* mmFillWatcher.watch(tokenId); // start watching a token
* mmFillWatcher.unwatch(tokenId); // stop watching
* mmFillWatcher.on('fill', ({ tokenId, size, price }) => { ... });
* mmFillWatcher.start();
* mmFillWatcher.stop();
*/
import WebSocket from 'ws';
import { EventEmitter } from 'events';
import config from '../config/index.js';
import logger from '../utils/logger.js';
const RTDS_WS_URL = 'wss://ws-live-data.polymarket.com';
const PING_INTERVAL_MS = 5000;
const INITIAL_RECONNECT_DELAY = 2000;
const MAX_RECONNECT_DELAY = 30000;
class MMFillWatcher extends EventEmitter {
constructor() {
super();
this._ws = null;
this._pingTimer = null;
this._reconnectTimer = null;
this._reconnectDelay = INITIAL_RECONNECT_DELAY;
this._shuttingDown = false;
this._watchedTokens = new Set(); // token IDs we care about
this._connected = false;
}
/** Register a token ID to watch for fills */
watch(tokenId) {
if (tokenId) this._watchedTokens.add(tokenId);
}
/** Stop watching a token ID */
unwatch(tokenId) {
this._watchedTokens.delete(tokenId);
}
/** Check if currently connected */
get connected() {
return this._connected;
}
/** Start the WebSocket connection */
start() {
this._shuttingDown = false;
this._reconnectDelay = INITIAL_RECONNECT_DELAY;
this._connect();
}
/** Gracefully stop */
stop() {
this._shuttingDown = true;
this._cleanup(false);
this._watchedTokens.clear();
logger.info('MM fill watcher stopped');
}
// ── Internal ─────────────────────────────────────────────────────────────
_connect() {
if (this._shuttingDown) return;
logger.info('MM fill watcher: connecting to RTDS WebSocket...');
this._ws = new WebSocket(RTDS_WS_URL);
this._ws.on('open', () => {
this._connected = true;
this._reconnectDelay = INITIAL_RECONNECT_DELAY;
logger.success('MM fill watcher: WebSocket connected');
this._ws.send(JSON.stringify({
action: 'subscribe',
subscriptions: [{
topic: 'activity',
type: 'trades',
}],
}));
this._startPing();
});
this._ws.on('message', (data) => this._handleMessage(data));
this._ws.on('ping', () => {
this._ws?.pong();
});
this._ws.on('close', (code, reason) => {
this._connected = false;
const reasonStr = reason ? reason.toString() : 'no reason';
logger.warn(`MM fill watcher: WS closed (${code}): ${reasonStr}`);
this._cleanup(true);
});
this._ws.on('error', (err) => {
this._connected = false;
logger.error(`MM fill watcher: WS error: ${err.message}`);
this._cleanup(true);
});
}
_handleMessage(rawData) {
let msg;
try {
msg = JSON.parse(rawData.toString());
} catch {
const text = rawData.toString().trim();
if (text === 'ping') this._ws?.send('pong');
return;
}
if (msg.type === 'ping' || msg === 'ping') {
this._ws?.send('pong');
return;
}
if (msg.topic !== 'activity') return;
const payload = msg.payload;
if (!payload) return;
// Filter: only our own proxy wallet
const ourWallet = config.proxyWallet?.toLowerCase();
if (!ourWallet) return;
const proxyWallet = (payload.proxyWallet || payload.proxy_wallet || '').toLowerCase();
if (proxyWallet !== ourWallet) return;
// Filter: only tokens we're watching
const tokenId = payload.asset || '';
if (!tokenId || !this._watchedTokens.has(tokenId)) return;
const side = (payload.side || '').toUpperCase();
const size = parseFloat(payload.size || '0');
const price = parseFloat(payload.price || '0');
if (size <= 0) return;
logger.info(`MM fill watcher: detected ${side} on token ${tokenId.slice(-8)}${size} shares @ $${price.toFixed(3)}`);
this.emit('fill', {
tokenId,
side,
size,
price,
conditionId: payload.conditionId || payload.condition_id || '',
timestamp: payload.timestamp || new Date().toISOString(),
});
}
_startPing() {
this._stopPing();
this._pingTimer = setInterval(() => {
if (this._ws?.readyState === WebSocket.OPEN) {
this._ws.send('ping');
}
}, PING_INTERVAL_MS);
}
_stopPing() {
if (this._pingTimer) {
clearInterval(this._pingTimer);
this._pingTimer = null;
}
}
_cleanup(reconnect = true) {
this._stopPing();
if (this._reconnectTimer) {
clearTimeout(this._reconnectTimer);
this._reconnectTimer = null;
}
if (this._ws) {
this._ws.removeAllListeners();
if (this._ws.readyState === WebSocket.OPEN || this._ws.readyState === WebSocket.CONNECTING) {
this._ws.terminate();
}
this._ws = null;
}
this._connected = false;
if (reconnect && !this._shuttingDown) {
this._scheduleReconnect();
}
}
_scheduleReconnect() {
logger.info(`MM fill watcher: reconnecting in ${this._reconnectDelay / 1000}s...`);
this._reconnectTimer = setTimeout(() => {
this._reconnectDelay = Math.min(this._reconnectDelay * 2, MAX_RECONNECT_DELAY);
this._connect();
}, this._reconnectDelay);
}
}
// Singleton instance
export const mmFillWatcher = new MMFillWatcher();
+39 -41
View File
@@ -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...`);
}
}
}
-139
View File
@@ -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:0002: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 -34
View File
@@ -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
View File
@@ -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}`);
}
}
}
-50
View File
@@ -1,50 +0,0 @@
/**
* sniperSizing.js
* Time-based multiplier for sniper bet sizing.
* All time windows are specified in UTC+8.
*/
import config from '../config/index.js';
const UTC8_OFFSET = 8;
/**
* Convert HH:MM (UTC+8) to minutes-since-midnight UTC.
*/
function utc8ToUtcMinutes(hhmm) {
const [h, m] = hhmm.split(':').map(Number);
let totalMin = (h * 60 + m) - (UTC8_OFFSET * 60);
if (totalMin < 0) totalMin += 1440;
if (totalMin >= 1440) totalMin -= 1440;
return totalMin;
}
function inRange(nowMin, startMin, endMin) {
if (startMin <= endMin) {
return nowMin >= startMin && nowMin < endMin;
}
// overnight wrap
return nowMin >= startMin || nowMin < endMin;
}
/**
* Get the current time multiplier based on configured SNIPER_MULTIPLIERS windows.
* Returns { multiplier, label } where label describes the active window (or 'default').
*/
export function getTimeMultiplier() {
const windows = config.sniperMultipliers;
if (!windows || windows.length === 0) return { multiplier: 1.0, label: 'default' };
const now = new Date();
const nowMin = now.getUTCHours() * 60 + now.getUTCMinutes();
for (const w of windows) {
const startMin = utc8ToUtcMinutes(w.start);
const endMin = utc8ToUtcMinutes(w.end);
if (inRange(nowMin, startMin, endMin)) {
return { multiplier: w.multiplier, label: `${w.start}-${w.end} UTC+8 → ${w.multiplier}x` };
}
}
return { multiplier: 1.0, label: 'default' };
}
+3 -4
View File
@@ -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;
-227
View File
@@ -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
View File
@@ -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);
-39
View File
@@ -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;
-58
View File
@@ -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');
}
-264
View File
@@ -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)';
}
}