40 Commits

Author SHA1 Message Date
direkturcrypto e4e8d1f62c feat: configurable price polling interval via MAKER_MM_POLL_SEC
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 13:34:55 +07:00
direkturcrypto f376b439a5 fix: patch errorHandling to prevent circular JSON stack overflow
JSON.stringify(err.response.config) in clob-client's errorHandling
includes the httpsAgent (HttpsProxyAgent) object which has circular/deep
refs, causing "Maximum call stack size exceeded" on every API error.

- Fixed live node_modules file to log only status + data (no config)
- Updated patch script to apply this fix idempotently after npm install,
  independent of proxy patch (two separate checks/markers)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 13:32:23 +07:00
direkturcrypto 49de5c60e9 fix: auto-redeem after ghost recovery when holding expensive side
When ghost fill recovery decides to hold the expensive side (not sell),
set pos.holdingSide so the existing waitAndRedeem flow picks it up after
executeMakerRebateStrategy — same path as CANCEL_CHEAP_ON_EXP_FILL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 13:22:08 +07:00
direkturcrypto 3d2a6e7c4a fix: skip market-sell for expensive side in ghost fill recovery
When expensive side (e.g. YES at 83c) is filled but cheap side (NO at 15c)
is ghost, do NOT market-sell the YES tokens — the cost basis is too high
and a market dump guarantees a loss. Only CL (market-sell) the cheap side
remainder, where the cost basis is low enough to absorb the slippage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 13:15:25 +07:00
direkturcrypto 9a5073dd20 fix: avoid JSON.stringify on circular response object in placeLimitBuy
When the CLOB client returns a failure response that contains a circular
reference (e.g. axios/fetch internals), JSON.stringify throws
"Maximum call stack size exceeded" which is caught and swallows the real
error. Log only safe primitive fields (errorMsg, error, status) instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 00:04:22 +07:00
direkturcrypto 78c66a9b1e feat: cancel cheap side on expensive fill + auto-redeem after resolution
- MAKER_MM_CANCEL_CHEAP_ON_EXP_FILL=true: when the expensive side fills
  first, immediately cancel the cheap side order and hold the token.
  After market ends, polls on-chain payoutDenominator until resolved,
  then calls redeemPositions to recover USDC.
- Default false — existing symmetric maker behavior unchanged.
- Redeem polls every 15s for up to 10 minutes after market close.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 14:19:11 +07:00
direkturcrypto ececad8574 fix: use 2-tick ask buffer to prevent bid crossing ask (taker race)
Between fetching the best ask and placing the GTC order, the ask can
move down 1 tick — turning our bid into a marketable (taker) order,
which hits Polymarket's $1 minimum notional error. Cap bid to
ask - 2*tickSize instead of ask - 1*tickSize to absorb the timing race.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 10:55:19 +07:00
direkturcrypto f2f2d48ada Revert "fix: auto-scale shares to meet $1 minimum notional for cheap-side orders"
This reverts commit fdb613e966.
2026-04-01 10:54:59 +07:00
direkturcrypto fdb613e966 fix: auto-scale shares to meet $1 minimum notional for cheap-side orders
When cheap side price is very low (e.g. NO at 5c), MAKER_MM_TRADE_SIZE=5
produces $0.25 notional which fails Polymarket's $1 minimum for marketable
BUY orders. Auto-scale both sides to ceil($1/price) to meet the minimum,
keeping YES+NO pair counts equal for clean merge.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 10:52:17 +07:00
direkturcrypto 03d3f25d09 fix: auto-detect cheap side for price range filter
MIN/MAX_PRICE was always checked against YES bid, blocking entry when
NO is the cheap side (e.g. YES=96c, NO=4c with range 0.03-0.05).

Now the range filter applies to whichever side has the lower bestBid.
The expensive side is derived from maxCombined - cheapBid as before.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 10:45:42 +07:00
direkturcrypto eec97eb1e5 fix: apply price range filter to YES only, not NO
MIN/MAX_PRICE was filtering both YES and NO bids, blocking valid
skewed-market entries like YES=11c + NO=87c (NO fails MAX_PRICE=0.30).

Now only YES is range-checked. NO only needs basic sanity bounds (0 < noBid < 1).
This enables targeting cheap YES markets while NO bid can be any value.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 14:27:55 +07:00
direkturcrypto 3a4dbf2b02 feat: add MAKER_MM_REENTRY_ENABLED config + fix float combined check
- MAKER_MM_REENTRY_ENABLED=false disables re-entry (one cycle per market)
- Fix floating point bug: 0.1+0.5=0.6000000000000001 caused "combined > max"
  loop when MAKER_MM_MAX_COMBINED=0.60 — now rounds combined to 4dp before compare

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 14:19:25 +07:00
direkturcrypto 0ee19ce48a fix: remove repricing, add ghost fill recovery with onchain verification
Repricing was the root cause of double orders and ghost fills:
- cancel/replace race caused duplicate active orders in CLOB
- cancel API returning OK did not guarantee order was actually gone
- ghost fills (CLOB matched, invalid txhash) went undetected

Changes:
- Remove checkAndReprice entirely — orders placed once and held
- Detect ghost fills via getOpenOrders (reliable) instead of checkOrderStatus
- recoverFromGhostFill: merge paired shares + market-sell remainder
- marketSellToken: verify onchain balance after each attempt, retry up to 3x
  (sells can also be ghost-filled — shares still in wallet despite CLOB saying done)
- Recovery triggers at 30s after detection, not at cut-loss time (prices still fair)
- Update README to reflect no-reprice design

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 10:51:23 +07:00
direkturcrypto 4ea59c8f06 fix: WS fill signal no longer sets pos.filled — prevents partial fill false-positive
WS handler was marking a side as 'filled' for any fill event regardless of
share size. A 2-share partial fill (target: 5) would set pos.no.filled=true,
causing the monitoring loop to enter limbo: both sides 'filled' but onchain
balance below the 0.5x merge threshold, so no merge ever triggered.

WS events now only serve as a wake-up signal for waitForFillOrTimeout.
Onchain balance (getTokenBalance) is the sole source of truth for filled flags.
2026-03-30 23:57:27 +07:00
direkturcrypto 187765f4ac Merge feat/maker-rebate-mm into main
Adds high-frequency maker rebate market-making strategy:
- Simultaneous YES+NO maker limit orders on 15m binary markets
- CTF merge for guaranteed spread capture (market-neutral)
- WebSocket fill detection, combined cap enforcement, one-sided stop
2026-03-30 21:01:19 +07:00
direkturcrypto ef249ad971 feat: maker rebate MM — HFT market-making with spread capture on 15m markets
- Place simultaneous maker limit BUY on YES+NO sides (combined ≈ $0.98)
- Merge filled pairs via CTF contract → capture spread as profit
- WebSocket RTDS real-time fill detection + onchain balance as source of truth
- No aggressive repricing when one side is filled (prevents double exposure)
- Stop re-entry after one-sided stuck cycle (prevents directional accumulation)
- Combined cap always enforced — profitable merge guaranteed
- Auto-queue next market before current closes (zero idle time)
- Remove loss-compensating martingale logic from reprice flow
- Add .env.example with full documentation for all strategies
- Update README with grant-ready project description
2026-03-30 21:00:43 +07:00
direkturcrypto 455f265ed0 feat: WebSocket realtime fill detection + parallel API calls for MM
Replace 10s polling loop with RTDS WebSocket event-driven fill detection
(~100ms latency). Parallelize YES/NO side checks and cancel operations.
Polling kept as 30s safety net fallback.

- Add mmWsFillWatcher.js: subscribes to RTDS activity feed, filters by
  own proxy wallet, emits 'fill' events for watched tokens
- Refactor monitorAndManage(): event-driven loop with parallel checks
- Extract checkSideFill() for parallel-safe per-side fill detection
- Wire up WS watcher lifecycle in mm.js and mm-bot.js

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 22:21:50 +07:00
direkturcrypto d48749db40 fix: handle partial fills and defensive/adaptive CL race condition
Bug 1 (partial fill stuck): When an order is partially filled (e.g. 4/20
shares), the bot would get stuck — isOrderFilled returns false, but the
order is dead on the book. Now detect partial fills via getPartialFillInfo,
cancel the old order, and either re-place a limit for the remaining shares
or market-sell if below CLOB minimum.

Bug 2 (defensive overrides fill): When defensive timeout fires, it cancels
both orders — but one side may have actually filled between the last poll
and the cancel. Now re-check on-chain balances after cancellation. If one
side is filled, route to adaptive CL instead of defensive pivot.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 22:00:28 +07:00
direkturcrypto e9e5d66ac9 fix: patch CLOB client JSON.stringify to handle circular proxy agent
The CLOB client errorHandling function does JSON.stringify on
err.response.config which includes httpsAgent (HttpsProxyAgent with
circular references). Inject a safe serializer that strips agent
properties before serializing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 12:16:46 +07:00
direkturcrypto 0886938694 fix: use axios interceptor instead of inline config injection
The IIFE approach that injected httpsAgent into the request config
caused "Converting circular structure to JSON" when the CLOB client
serialized the config object (HttpsProxyAgent has circular refs).

New approach: register an axios request interceptor after the axios
import. This is simpler (no need to find request config patterns),
works with any code format, and avoids serialization issues since
axios handles httpsAgent internally after interceptors run.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 14:36:21 +07:00
direkturcrypto ae523c4f27 fix: breakevenFloor is not defined in adaptive CL
Replace undefined `breakevenFloor` variable with `minAdaptivePrice`
which is the actual breakeven floor calculated from
mmAdaptiveMinCombined - filledLegPrice.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 14:08:03 +07:00
direkturcrypto f0295818e2 fix: handle inline axios call pattern in patch script
The VPS fresh install uses `(0, axios_1.default)({ method, url: endpoint, ... })`
instead of a separate `config` variable. Add Format B regex to match this
inline pattern and wrap it in an IIFE that injects the proxy agent.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 13:58:27 +07:00
direkturcrypto 85f19d0bd6 fix: use regex matching in patch script + add geoblock IP check
- Rewrite patch-clob-client.cjs to use regex instead of exact string
  matching, so it works regardless of code formatting differences
  between npm versions (fixes "Could not find request config line" on
  fresh VPS install)
- Add multiple fallback patterns (config regex → fallback regex →
  axios call injection) with debug output if all fail
- Add Polymarket geoblock API check (polymarket.com/api/geoblock)
  for both VPS direct IP and proxy IP at startup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 13:55:35 +07:00
direkturcrypto b4d6d648bd feat: auto-patch clob-client proxy support via postinstall script
The proxy patch in node_modules is lost on every fresh npm install.
This postinstall script automatically patches @polymarket/clob-client
http-helpers to inject HttpsProxyAgent for all polymarket.com requests.

Runs automatically after npm install — no manual patching needed on VPS deploy.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 13:49:52 +07:00
direkturcrypto d82c5fd14e fix: add axios interceptor for proxy + dual proxy test (fetch + axios)
The CLOB client uses axios internally. Previous setup only set
axios.defaults which doesn't work if the CLOB client or axios
creates its own instance.

Fix: add axios request interceptor that forces proxy agent on every
request to polymarket.com domains.

Also add separate axios-based proxy test alongside fetch test to catch
cases where fetch works but axios doesn't route through proxy.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 13:47:22 +07:00
direkturcrypto efe0a077e5 feat: add IP check and geoblock detection at startup
Shows both VPS direct IP and proxy IP so user can verify which IP
Polymarket sees. If CLOB returns 403 geoblock, shows clear error
with link to allowed regions instead of proceeding.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 13:42:40 +07:00
direkturcrypto 3078ab9790 fix: tiered floor must start from MM_ADAPTIVE_MIN_COMBINED, not hardcoded $1.00
Was using breakevenFloor (1.00 - filledPrice) which ignored the user's
MM_ADAPTIVE_MIN_COMBINED setting. This caused sells below the configured
minimum (e.g. selling at 28c when min combined was 1.01).

Now: filled @ 70c, MM_ADAPTIVE_MIN_COMBINED=1.01 → floor starts at 31c
  Phase 1 (>180s): 31c
  Phase 2 (90-180s): 21c
  Phase 3 (30-90s): 11c
  Phase 4 (<30s): market sell

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 13:36:20 +07:00
direkturcrypto 6e3d484728 merge: defensive pivot, tiered CL, on-chain fill detection, proxy patch for MM 2026-03-27 13:27:50 +07:00
direkturcrypto 565f98571a fix: add proxy-patch import to mm-bot and mm entry points
Both sniper and dumper had proxy-patch.cjs imported but market maker
entry points (mm-bot.js, mm.js) were missing it, causing direct
connections without proxy.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 13:26:23 +07:00
direkturcrypto b05fed9207 fix: add on-chain balance fallback for fill detection
CLOB API (client.getOrder) can be stale — order filled on-chain but API
still shows unfilled. This caused the bot to miss fills and incorrectly
enter defensive mode or keep monitoring dead orders.

Now isOrderFilled does:
1. Check CLOB API (existing) with retry
2. Fallback: check on-chain token balance via CTF contract
   If balance < 5% of original shares → treat as filled

Fixes issue where Polymarket UI shows filled but bot doesn't detect it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 13:24:33 +07:00
direkturcrypto ba250572b9 fix: cancel orders immediately when defensive timeout reached
Orders must be cancelled BEFORE the monitoring loop can detect fills,
otherwise the limit sell at 70c can still get filled at minute 3
and trigger adaptive CL instead of defensive pivot.

Now: timeout reached → cancel both orders → enter defensivePivot

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 05:25:11 +07:00
direkturcrypto 1da662313d fix: use mid price for initial adaptive CL sell instead of fixed breakeven floor
Check mid price first, place limit at market price (capped at mmSellPrice).
Only falls back to breakeven floor if mid price is below it.

Example: YES filled @ 70c, NO mid = 38c → sell @ 38c (not 30c breakeven)
This gives profit potential instead of always targeting breakeven.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 05:15:01 +07:00
direkturcrypto df09403f18 fix: change defensive pivot trigger from 30s to 45s before close
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 05:09:02 +07:00
direkturcrypto cc90176861 feat: implement tiered floor + standing order for adaptive CL (5m markets)
Adaptive CL now uses progressive floor lowering for 5-minute markets:

  Phase 1 (>180s left): breakeven floor (e.g. 40c for 60c fill)
  Phase 2 (90-180s):    floor - 10c (accept small loss to escape)
  Phase 3 (30-90s):     floor - 20c + emergency cut if price < 10c
  Phase 4 (<30s):       force market sell

Key improvements:
- Standing order: immediately place limit at breakeven floor so brief
  bounces get caught (don't wait for polling to detect price >= floor)
- Emergency cut: market sell immediately if price < 10c in phase 3
  (market is decisive, bounce unlikely)
- Non-5m markets still use the existing fixed floor (mmAdaptiveMinCombined)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 05:01:38 +07:00
direkturcrypto 8a5962d0a5 fix: measure defensive timeout from market open time, not bot entry time
Market start = endTime - 5 minutes, so defensive triggers at exactly
120s after the market opens regardless of when the bot entered.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 04:54:42 +07:00
direkturcrypto e19278c665 feat: add defensive pivot mode for 5m markets when neither side fills
When neither limit sell fills within MM_DEFENSIVE_TIMEOUT (default 120s),
the bot enters defensive mode:
- Cancel both limit sells
- Wait until 30s before market close
- If worst side price < MM_DEFENSIVE_WORST_THRESHOLD (default 10c):
  market sell worst side, hold best side for resolution (best ≈ 90c+)
- If worst side ≥ threshold: merge back to USDC (safe $0 P&L)

Only active for 5-minute markets (MM_DURATION=5m).

New config:
  MM_DEFENSIVE_ENABLED (default true)
  MM_DEFENSIVE_TIMEOUT (default 120s)
  MM_DEFENSIVE_WORST_THRESHOLD (default 0.10)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 04:43:56 +07:00
direkturcrypto a73cf22aa5 merge: mm-bot improvements - retry fill detection, parallel limit sells, priority safe queue 2026-03-27 04:18:55 +07:00
direkturcrypto 0da77f78fd fix: improve mm-bot reliability - retry fills, parallel sells, smart small remainder handling
- isOrderFilled: retry 2x with logging instead of silent error swallow
- Place YES/NO limit sells in parallel via Promise.all
- Market sell immediately when remaining shares < CLOB minimum (5 shares)
- Re-check on-chain balance before every limit/market sell in adaptive CL
- Separate redeemer Safe queue priority from strategy (split/merge) to reduce cross-market delays
- Cache USDC and exchange approvals in-memory to skip redundant on-chain reads

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 04:18:51 +07:00
direkturcrypto d89b8ff54d fix: correct win detection by mapping tokenId→outcome instead of array index
Problem: redeemer used array index to match balances to payoutFractions,
but Data API token order ≠ on-chain outcome index. This caused false wins
(shares on losing side counted as winning).

Fix:
- Store yesTokenId/noTokenId per conditionId in sniperExecutor
- Match tokens by ID (yes=outcome0, no=outcome1) instead of array position
- Only process positions tracked by sniper (skip MM/other positions)
- Inject token lookup via setSniperConditionLookup to avoid circular imports

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 13:59:48 +07:00
direkturcrypto a8f28c961b feat: add time-based multiplier sizing, pause-after-win, and outcome-based win detection
- Time-based bet sizing multiplier (SNIPER_MULTIPLIERS, UTC+8 windows)
- Pause N rounds per asset after win (SNIPER_PAUSE_ROUNDS_AFTER_WIN)
- Win detection via payoutNumerators outcome check instead of redeem value threshold

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 12:53:48 +07:00
21 changed files with 3295 additions and 558 deletions
+149 -112
View File
@@ -1,169 +1,206 @@
# ══════════════════════════════════════════════════════════════════
# polymarket-terminal — Environment Configuration
# Copy this file to .env and fill in your values
# ══════════════════════════════════════════════════════════════════
# ─────────────────────────────────────────────
# WALLET SETUP
# WALLET SETUP (required for all strategies)
# ─────────────────────────────────────────────
# EOA private key — used for SIGNING only, does NOT hold USDC
# EOA private key — used for SIGNING only, does NOT hold funds
# Never share this key or commit it to version control
PRIVATE_KEY=0xYOUR_EOA_PRIVATE_KEY_HERE
# Polymarket Proxy Wallet — the address shown when you click "Deposit" on Polymarket
# This is where you deposit USDC.e, and where trades are funded from
# How to find: Login to polymarket.com → Profile → Deposit → copy the address
# This is where you deposit USDC.e and where all trades are funded from
# How to find: 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
# Leave blank to auto-derive from your private key on first run
# ─────────────────────────────────────────────
CLOB_API_KEY=
CLOB_API_SECRET=
CLOB_API_PASSPHRASE=
# ─────────────────────────────────────────────
# TRADER TO COPY
# Use the proxy wallet address of the trader (visible on their Polymarket profile)
# 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_ADDRESS=0xTRADER_PROXY_WALLET_ADDRESS
PROXY_URL=
# ─────────────────────────────────────────────
# TRADE SIZING
# DRY RUN — simulate without placing real orders
# Always test with DRY_RUN=true first!
# ─────────────────────────────────────────────
DRY_RUN=true
# ══════════════════════════════════════════════════════════════════
# MAKER REBATE MM (npm run maker-mm-bot)
# High-frequency market-making on 15m BTC Up/Down markets.
# Places maker limit orders on both YES and NO sides, merges filled
# pairs back to USDC, and captures the bid-ask spread + maker rebate.
# ══════════════════════════════════════════════════════════════════
# Assets to market-make (comma-separated slugs: btc, eth, sol, xrp)
MAKER_MM_ASSETS=btc
# Market duration to target: "5m" or "15m"
MAKER_MM_DURATION=15m
# Number of shares per side per cycle (minimum 5)
# Total USDC deployed per cycle ≈ MAKER_MM_TRADE_SIZE × combined_price
MAKER_MM_TRADE_SIZE=5
# Maximum combined bid (YES + NO) — controls spread profit
# $0.98 combined = $0.02 profit per share when both fill
# Lower = more profit per pair but lower fill rate (e.g. 0.95 = $0.05/share)
MAKER_MM_MAX_COMBINED=0.98
# Reprice check interval (seconds) — how often to check for bid drift
MAKER_MM_REPRICE_SEC=10
# Minimum bid drift (in dollars) before repricing — prevents over-trading
# Default 0.02 = only reprice if best bid moved more than 2 cents
MAKER_MM_REPRICE_THRESHOLD=0.02
# Seconds before market close to force-exit open positions (cut-loss)
MAKER_MM_CUT_LOSS_TIME=60
# Max seconds after market open to enter (entry window)
# After this window, bot waits for the next market
MAKER_MM_ENTRY_WINDOW=45
# How often to poll for new markets (seconds)
MAKER_MM_POLL_INTERVAL=5
# Delay between re-entry cycles within the same market (seconds)
MAKER_MM_REENTRY_DELAY=30
# 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 ─────────────────────────────────────────────────
# SIZE_MODE:
# "percentage" = SIZE_PERCENT% of MAX_POSITION_SIZE per market entry
# (e.g. MAX_POSITION_SIZE=$10, SIZE_PERCENT=50 → buy $5 per entry)
# "percentage" = SIZE_PERCENT% of MAX_POSITION_SIZE 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 position per market in USDC (won't buy more once this is reached)
# Maximum total USDC position per market
MAX_POSITION_SIZE=10
# ─────────────────────────────────────────────
# AUTO SELL
# ─────────────────────────────────────────────
# ── Auto Sell ────────────────────────────────────────────────────
AUTO_SELL_ENABLED=true
AUTO_SELL_PROFIT_PERCENT=10
# Sell mode when copying trader's sell
# "market" = sell at market price immediately
# "market" = sell immediately at market price
# "limit" = place limit order at trader's sell price
SELL_MODE=market
# ─────────────────────────────────────────────
# INTERVALS
# ─────────────────────────────────────────────
# How often (seconds) to check for resolved markets to redeem
# ── Intervals ────────────────────────────────────────────────────
REDEEM_INTERVAL=60
# Skip BUY if the market closes within this many seconds from now
# Default 300 = skip if market ends in less than 5 minutes
MIN_MARKET_TIME_LEFT=300
# When FAK finds no liquidity (e.g. copying into "next market" before it opens),
# fall back to a GTC limit order and wait this many seconds for it to fill.
# Set to 0 to disable the GTC fallback entirely.
GTC_FALLBACK_TIMEOUT=60
# ─────────────────────────────────────────────
# DRY RUN (set true to simulate without real trades)
# ─────────────────────────────────────────────
DRY_RUN=true
# ─────────────────────────────────────────────
# MARKET MAKER (mm.js / npm run mm-sim)
# ─────────────────────────────────────────────
# Comma-separated assets to market-make (same slug format as sniper)
MM_ASSETS=btc
# ══════════════════════════════════════════════════════════════════
# ORDERBOOK SNIPER (npm run sniper)
# Places 3-tier GTC limit buy orders at panic-dump price levels.
# ══════════════════════════════════════════════════════════════════
# Market duration: "5m" (5-minute) or "15m" (15-minute)
MM_DURATION=5m
# USDC amount per side (total exposure = 2x this)
MM_TRADE_SIZE=5
# Limit sell price target (e.g. 0.60 = sell at $0.60)
MM_SELL_PRICE=0.60
# Seconds before market close to trigger cut-loss
MM_CUT_LOSS_TIME=60
# Keyword to match market question (case-insensitive)
MM_MARKET_KEYWORD=Bitcoin Up or Down
# Max seconds after market open to enter (0 = at open only)
MM_ENTRY_WINDOW=45
# How often to poll for new markets (seconds)
MM_POLL_INTERVAL=10
# ── Recovery Buy (after cut-loss) ───────────────────────────
# After cut-loss triggers, monitor prices for 10s and market-buy
# the dominant side if criteria are met. Does not affect the main
# MM flow — purely an opt-in add-on.
#
# Enable recovery buy
MM_RECOVERY_BUY=false
# Minimum price the dominant side must be at (and rising/stable) to qualify
MM_RECOVERY_THRESHOLD=0.70
# USDC size for the recovery buy (0 = use MM_TRADE_SIZE)
MM_RECOVERY_SIZE=0
# Enable adaptive CL when one leg fills (true = patient limit orders, false = immediate market sell)
MM_ADAPTIVE_CL=true
# Minimum combined sell price (both legs) required to place a limit order.
# Formula: filledLegPrice + unfilledLegPrice >= MM_ADAPTIVE_MIN_COMBINED
# Example: filledLeg=0.60, MM_ADAPTIVE_MIN_COMBINED=1.20 → floor=0.60 (won't limit-sell below $0.60)
# filledLeg=0.55, MM_ADAPTIVE_MIN_COMBINED=1.20 → floor=0.65
# If price is below floor, bot waits for recovery. Market-sell only at CL time as last resort.
MM_ADAPTIVE_MIN_COMBINED=1.20
# Poll interval (seconds) for the adaptive CL loop after one leg fills.
# Smaller = more responsive to price changes, more API calls.
MM_ADAPTIVE_MONITOR_SEC=5
# ─────────────────────────────────────────────
# ORDERBOOK SNIPER (sniper.js / npm run sniper-sim)
# 3-Tier Strategy: Places GTC BUY orders at 3 prices with weighted sizing
# Tier 1 (3c): smallest size | Tier 2 (2c): medium | Tier 3 (1c): largest
# Catches panic dumps at multiple price levels with optimal capital allocation
# ─────────────────────────────────────────────
# Comma-separated assets to snipe
SNIPER_ASSETS=eth,sol,xrp
# 3-Tier pricing (high to low)
SNIPER_TIER1_PRICE=0.03 # Highest price, smallest allocation (20%)
SNIPER_TIER2_PRICE=0.02 # Mid price, medium allocation (30%)
SNIPER_TIER3_PRICE=0.01 # Lowest price, largest allocation (50%)
# 3-Tier pricing (descending) — orders placed at these prices
SNIPER_TIER1_PRICE=0.03
SNIPER_TIER2_PRICE=0.02
SNIPER_TIER3_PRICE=0.01
# Max total shares per side (min 5 shares per tier)
# Example: 15 shares → 3@3c + 5@2c + 7@1c
# Example: 30 shares → 6@3c + 9@2c + 15@1c
# Max total shares to deploy (split across tiers: 20% / 30% / 50%)
SNIPER_MAX_SHARES=15
# ── Sniper Session Schedule (all times UTC+8) ──────────────
# Format: HH:MM-HH:MM,HH:MM-HH:MM (comma-separated sessions)
# Assets without a schedule entry are always active.
# Time-based sizing multiplier (UTC+8). Format: HH:MM-HH:MM:factor,...
SNIPER_MULTIPLIERS=21:00-00:00:1.41,06:00-12:00:0.85
# Rounds to pause an asset after detecting a win (5-min intervals)
SNIPER_PAUSE_ROUNDS_AFTER_WIN=3
# Active session schedules per asset (UTC+8). Format: HH:MM-HH:MM,...
SNIPER_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
# ─────────────────────────────────────────────
# PROXY (Polymarket API only, NOT Polygon RPC)
# Supports HTTP/HTTPS/SOCKS5 proxies
# Leave empty to connect directly (no proxy)
# Example: http://user:pass@proxy.example.com:8080
# ─────────────────────────────────────────────
PROXY_URL=
# ══════════════════════════════════════════════════════════════════
# 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
+133 -314
View File
@@ -1,314 +1,147 @@
# Polymarket Terminal
> An automated trading terminal for [Polymarket](https://polymarket.com) — copy trades, provide liquidity, and snipe low-priced orderbook fills, all from your command line.
An open-source automated trading terminal for [Polymarket](https://polymarket.com) — featuring a high-frequency maker rebate market maker, copy trading, and an orderbook sniper, all runnable from the command line.
**Created by [@direkturcrypto](https://twitter.com/direkturcrypto)**
**Repository:** https://github.com/direkturcrypto/polymarket-terminal
---
## Table of Contents
## Strategies
- [Features](#features)
- [Prerequisites](#prerequisites)
- [Installation](#installation)
- [Configuration](#configuration)
- [Usage](#usage)
- [Running on VPS with PM2](#running-on-vps-with-pm2)
- [How It Works](#how-it-works)
- [Project Structure](#project-structure)
- [Important Warnings](#important-warnings)
- [License](#license)
### 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
### 2. Copy Trader (`npm run bot`)
### Copy Trade Bot
- **Watch Trader** — Monitor any Polymarket wallet address in real time via WebSocket
- **Copy Buy** — Automatically mirror buy orders with configurable position sizing
- **Copy Sell** — Automatically mirror sell orders (market or limit)
- **Auto Sell** — Place a GTC limit sell at a target profit % immediately after a buy fills
- **Auto Redeem** — Periodically check and redeem winning positions on-chain
- **Market Expiry Guard** — Skip buys if market closes within `MIN_MARKET_TIME_LEFT` seconds
- **GTC Fallback** — Falls back to a GTC limit order when copying "next market" trades with no liquidity
- **Per-Market Queue** — Concurrent events for the same market are serialized to prevent duplicate buys
- **Dry Run Mode** — Simulate the full flow without placing real orders
Mirrors the trades of any target Polymarket wallet in real-time.
### Market Maker Bot
- **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
- 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
### Orderbook Sniper Bot
- **3-Tier Strategy** — Places GTC BUY orders at 3c, 2c, and 1c with weighted sizing (20%/30%/50%)
- **Multi-Asset** — Targets ETH, SOL, XRP, and more simultaneously
- **Simulation Mode** — Preview orders without spending funds
- **Session Scheduling** — Per-asset time windows (UTC+8) for selective trading
```
TRADER_ADDRESS=0xTARGET_WALLET
SIZE_MODE=balance
SIZE_PERCENT=10
MAX_POSITION_SIZE=10
```
---
## Prerequisites
### 3. Orderbook Sniper (`npm run sniper`)
| Requirement | Details |
|---|---|
| Node.js | v18 or higher (ESM support required) |
| Polygon Wallet | An EOA wallet with a private key |
| Polymarket Proxy Wallet | Your proxy wallet address (visible on your Polymarket profile → Deposit) |
| USDC.e on Polygon | Deposited via Polymarket's deposit flow |
| MATIC on Polygon | A small amount for gas fees (redeem & on-chain operations) |
| PM2 *(optional)* | For running on a VPS: `npm install -g pm2` |
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)
---
## 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
# 4. Fill in your credentials
nano .env
# Edit .env with your wallet keys and settings
```
---
## Configuration
## Quick Start
All settings are controlled via the `.env` file. **Never commit your `.env` file** — it is already listed in `.gitignore`.
### Wallet Setup
| Variable | Description | Required |
|---|---|---|
| `PRIVATE_KEY` | Your EOA private key (signing only, does not hold USDC) | Yes |
| `PROXY_WALLET_ADDRESS` | Your Polymarket proxy wallet address | Yes |
| `POLYGON_RPC_URL` | Polygon JSON-RPC endpoint | Yes |
> **How to find your Proxy Wallet:** Log in to polymarket.com → click your profile → Deposit → copy the wallet address shown.
### Polymarket API Credentials (Optional)
Leave these blank to have the client auto-derive credentials from your private key.
| Variable | Description |
|---|---|
| `CLOB_API_KEY` | CLOB API key |
| `CLOB_API_SECRET` | CLOB API secret |
| `CLOB_API_PASSPHRASE` | CLOB API passphrase |
### Copy Trade Bot Settings
| Variable | Description | Default |
|---|---|---|
| `TRADER_ADDRESS` | Proxy wallet address of the trader to copy | (required) |
| `SIZE_MODE` | `percentage` (of `MAX_POSITION_SIZE`) or `balance` (of your USDC balance) | `balance` |
| `SIZE_PERCENT` | Percentage to use per trade | `10` |
| `MIN_TRADE_SIZE` | Minimum trade size in USDC (skip if below) | `1` |
| `MAX_POSITION_SIZE` | Maximum USDC per market position | `10` |
| `AUTO_SELL_ENABLED` | Place a limit sell after each buy fills | `true` |
| `AUTO_SELL_PROFIT_PERCENT` | Target profit % for the auto-sell limit order | `10` |
| `SELL_MODE` | `market` or `limit` when copying a sell | `market` |
| `REDEEM_INTERVAL` | Seconds between redemption checks | `60` |
| `MIN_MARKET_TIME_LEFT` | Skip buy if market closes within this many seconds | `300` |
| `GTC_FALLBACK_TIMEOUT` | Seconds to wait for GTC fill when FAK finds no liquidity | `60` |
| `DRY_RUN` | Simulate without placing real orders | `true` |
### Market Maker Bot Settings
| Variable | Description | Default |
|---|---|---|
| `MM_ASSETS` | Comma-separated assets to market-make (e.g. `btc,eth`) | `btc` |
| `MM_DURATION` | Market duration: `5m` or `15m` | `5m` |
| `MM_TRADE_SIZE` | USDC per side (total exposure = 2×) | `5` |
| `MM_SELL_PRICE` | Limit sell price target (e.g. `0.60`) | `0.60` |
| `MM_CUT_LOSS_TIME` | Seconds before close to trigger cut-loss | `60` |
| `MM_MARKET_KEYWORD` | Keyword to filter market questions | `Bitcoin Up or Down` |
| `MM_ENTRY_WINDOW` | Max seconds after open to enter (0 = open only) | `45` |
| `MM_POLL_INTERVAL` | Seconds between new market polls | `10` |
| `MM_RECOVERY_BUY` | Enable recovery buy after cut-loss | `false` |
| `MM_RECOVERY_THRESHOLD` | Minimum dominant-side price to qualify for recovery | `0.70` |
| `MM_RECOVERY_SIZE` | USDC for recovery buy (0 = use `MM_TRADE_SIZE`) | `0` |
### Orderbook Sniper Settings
**3-Tier Strategy:** Places orders at 3 price levels with weighted sizing
| Variable | Description | Default |
|---|---|---|
| `SNIPER_ASSETS` | Comma-separated assets to snipe (e.g. `eth,sol,xrp`) | `eth,sol,xrp` |
| `SNIPER_TIER1_PRICE` | Highest price tier (e.g. `0.03` = 3c) | `0.03` |
| `SNIPER_TIER2_PRICE` | Mid price tier (e.g. `0.02` = 2c) | `0.02` |
| `SNIPER_TIER3_PRICE` | Lowest price tier (e.g. `0.01` = 1c) | `0.01` |
| `SNIPER_MAX_SHARES` | Max total shares per side (min 5 per tier) | `15` |
**Allocation:**
- Tier 1 (3c): 20% of max shares (min 5)
- Tier 2 (2c): 30% of max shares (min 5)
- Tier 3 (1c): 50% of max shares (min 5)
**Example with `SNIPER_MAX_SHARES=15`:**
- 3 shares @ 3c = $0.09
- 5 shares @ 2c = $0.10
- 7 shares @ 1c = $0.07
- **Total per side:** 15 shares = $0.26
---
## Usage
### Terminal UI (local)
Runs with an interactive split-panel dashboard (blessed TUI).
**Always test with simulation mode first:**
```bash
# Copy Trade Bot
npm start # live trading
npm run dev # live + auto-reload on file changes
# Simulate maker MM — no real orders placed
npm run maker-mm-bot-sim
# Market Maker Bot
npm run mm # live trading
npm run mm-sim # simulation (DRY_RUN=true)
npm run mm-dev # simulation + auto-reload
# Run live maker MM (recommended starting config)
MAKER_MM_TRADE_SIZE=5 MAKER_MM_REENTRY_DELAY=30 npm run maker-mm-bot
# Orderbook Sniper Bot
npm run sniper # live trading
npm run sniper-sim # simulation
npm run sniper-dev # simulation + auto-reload
# Simulate copy trader
npm run bot-sim
# Run live copy trader
npm run bot
# Simulate orderbook sniper
npm run sniper-sim
# Run live sniper
npm run sniper
```
### Plain Log Mode (no TUI)
Writes plain timestamped text to stdout — suitable for piping, `tail -f`, or PM2.
```bash
# Copy Trade Bot
npm run bot # live trading
npm run bot-sim # simulation
npm run bot-dev # simulation + auto-reload
# Market Maker Bot
npm run mm-bot # live trading
npm run mm-bot-sim # simulation
npm run mm-bot-dev # simulation + auto-reload
```
> **Always test with `DRY_RUN=true` (or `*-sim` scripts) first** before committing real funds.
---
## Running on VPS with PM2
Each bot has its own PM2 config file inside the `pm2/` folder.
### Install PM2
## Running with PM2 (recommended for VPS)
```bash
npm install -g pm2
```
### Copy Trade Bot
# Start maker MM
pm2 start src/maker-mm-bot.js --name polymarket-maker-mm --interpreter node
```bash
# Live trading
pm2 start pm2/copy.config.cjs
# Simulation
pm2 start pm2/copy.config.cjs --env sim
# Start copy trader
pm2 start src/bot.js --name polymarket-bot --interpreter node
# View logs
pm2 logs polymarket-copy
tail -f logs/copy-out.log
# Management
pm2 restart polymarket-copy
pm2 stop polymarket-copy
pm2 delete polymarket-copy
```
### Market Maker Bot
```bash
# Live trading
pm2 start pm2/mm.config.cjs
# Simulation
pm2 start pm2/mm.config.cjs --env sim
# View logs
pm2 logs polymarket-mm
tail -f logs/mm-out.log
# Management
pm2 restart polymarket-mm
pm2 stop polymarket-mm
pm2 delete polymarket-mm
```
### Auto-start on reboot
```bash
pm2 startup # generates a startup command — run the command it prints
pm2 save # saves current process list
```
---
## How It Works
### Copy Trade Bot Flow
```
WebSocket (RTDS) — real-time trade events from trader
Per-market queue (prevents concurrent duplicate buys)
┌────┴──────┐
│ │
BUY SELL
│ │
├─ Expiry guard (MIN_MARKET_TIME_LEFT)
├─ Max position cap ├─ Cancel open orders
├─ FAK market buy ├─ Reconcile on-chain balance
│ └─ 0 fill? → GTC fallback ├─ FAK market sell / limit sell
├─ Place auto-sell GTC └─ Remove position
└─ Save position
Redeemer loop (every REDEEM_INTERVAL seconds)
→ Check on-chain payout → redeemPositions via Gnosis Safe
```
### Market Maker 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)
pm2 logs polymarket-maker-mm
pm2 logs polymarket-bot
```
---
@@ -316,68 +149,54 @@ Collect Cancel orders → Merge YES+NO back to USDC
## Project Structure
```
polymarket-terminal/
├── src/
│ ├── index.js — Copy trade bot (TUI)
├── bot.js Copy trade bot (plain log / PM2)
│ ├── mm.js — Market maker bot (TUI)
├── mm-bot.js — Market maker bot (plain log / PM2)
│ ├── sniper.js — Orderbook sniper bot
│ ├── config/
│ │ └── index.js — Environment variable loading & validation
│ │
├── services/
│ │ ├── client.js — CLOB client initialization & USDC balance
│ ├── watcher.js — Poll-based trader activity detection
│ │ ├── wsWatcher.js — WebSocket real-time trade listener
│ │ ├── executor.js — Buy & sell order execution logic
│ │ ├── position.js — Position state management (CRUD)
│ │ ├── autoSell.js — Auto limit-sell placement
│ │ ├── redeemer.js — Market resolution check & CTF redemption
│ │ ├── ctf.js — On-chain CTF contract interactions
│ │ ├── mmDetector.js — Market detection for market maker
│ │ ├── mmExecutor.js — Market maker strategy execution
│ │ ├── sniperDetector.js — Market detection for sniper
│ │ └── sniperExecutor.js — Orderbook sniper order placement
│ │
│ ├── ui/
│ │ └── dashboard.js — Terminal UI (blessed)
│ │
│ └── utils/
│ ├── logger.js — Timestamped logging (TUI + plain modes)
│ ├── state.js — Atomic JSON state file management
│ └── simStats.js — Simulation P&L statistics
├── pm2/
│ ├── copy.config.cjs — PM2 config for copy trade bot
│ └── mm.config.cjs — PM2 config for market maker bot
├── data/ — Runtime state files (gitignored)
├── logs/ — PM2 log files (gitignored)
├── .env.example — Configuration template
├── .gitignore
└── package.json
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
```
---
## Important Warnings
## How Maker Rebate Works on Polymarket
- **Never commit your `.env` file.** Your private key must remain secret. The `.gitignore` already excludes it.
- **Always start with `DRY_RUN=true`** (or a `*-sim` script) to verify the bot behaves as expected before using real funds.
- **Use a small `SIZE_PERCENT`** for initial live runs to limit exposure.
- **Keep MATIC in your EOA wallet** for gas fees (redeem operations and on-chain CTF calls).
- **This software is provided as-is, with no guarantees.** Prediction market trading carries significant financial risk. You are solely responsible for any losses.
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.
---
## Credits
## Risk Management
Built and maintained by **[@direkturcrypto](https://twitter.com/direkturcrypto)**.
- **No aggressive repricing**: after one side fills, the unfilled order stays at its original price — no chasing the market
- **Combined cap enforced**: YES + NO bids always ≤ `MAKER_MM_MAX_COMBINED` — a merge always returns more than it cost
- **One-sided stop**: if a cycle ends with only one side filled, re-entry for that market halts to prevent directional accumulation
- **Cut-loss**: all open orders are cancelled 60 seconds before market close
- **Odds filter**: skips re-entry if market odds exceed the configured threshold (default 70%)
---
## License
ISC License — see [LICENSE](LICENSE) for details.
MIT — free to use, fork, and modify.
---
## Contributing
Pull requests are welcome. Open an issue for bugs or feature requests.
Built for the Polymarket ecosystem. Not affiliated with Polymarket.
+1
View File
@@ -7,6 +7,7 @@
"": {
"name": "polymarket-terminal",
"version": "1.0.0",
"hasInstallScript": true,
"license": "ISC",
"dependencies": {
"@polymarket/clob-client": "^4.7.3",
+6 -1
View File
@@ -21,7 +21,12 @@
"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"
"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"
},
"keywords": [
"polymarket",
+114
View File
@@ -0,0 +1,114 @@
/**
* 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 ✅');
+68
View File
@@ -67,6 +67,14 @@ const config = {
mmAdaptiveMinCombined: parseFloat(process.env.MM_ADAPTIVE_MIN_COMBINED || '1.20'), // min combined sell (both legs) to qualify for limit
mmAdaptiveMonitorSec: parseInt(process.env.MM_ADAPTIVE_MONITOR_SEC || '5', 10),
// ── Defensive Pivot (5m markets only) ─────────────────────────
// When NEITHER side fills within timeout, enter defensive mode:
// At 30s before close, if worst side < threshold → market sell worst, keep best
// Otherwise merge back to USDC (zero P&L)
mmDefensiveEnabled: process.env.MM_DEFENSIVE_ENABLED !== 'false', // default on
mmDefensiveTimeout: parseInt(process.env.MM_DEFENSIVE_TIMEOUT || '120', 10), // secs without fill → defensive
mmDefensiveWorstThreshold: parseFloat(process.env.MM_DEFENSIVE_WORST_THRESHOLD || '0.10'), // sell worst if price < this
// ── Recovery Buy (after cut-loss) ─────────────────────────────
// When enabled: after cutting loss, monitor prices for 10s and
// market-buy the dominant side if it's above threshold and rising/stable.
@@ -74,6 +82,36 @@ const config = {
mmRecoveryThreshold: parseFloat(process.env.MM_RECOVERY_THRESHOLD || '0.70'), // min price to qualify
mmRecoverySize: parseFloat(process.env.MM_RECOVERY_SIZE || '0'), // 0 = use mmTradeSize
// ── Maker Rebate MM ────────────────────────────────────────────
// Buy YES+NO at top bid (maker), merge back to USDC ($1.00).
// Profit = spread + maker rebate fees.
makerMmAssets: (process.env.MAKER_MM_ASSETS || process.env.MM_ASSETS || 'btc')
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean),
makerMmDuration: process.env.MAKER_MM_DURATION || process.env.MM_DURATION || '5m',
makerMmTradeSize: parseFloat(process.env.MAKER_MM_TRADE_SIZE || '5'), // USDC per side
makerMmMaxCombined: parseFloat(process.env.MAKER_MM_MAX_COMBINED || '0.99'), // max bid_YES + bid_NO
makerMmRepriceSec: parseInt(process.env.MAKER_MM_REPRICE_SEC || '3', 10), // orderbook poll interval
makerMmFillTimeout: parseInt(process.env.MAKER_MM_FILL_TIMEOUT || '120', 10), // secs for 2nd fill after 1st
makerMmCutLossTime: parseInt(process.env.MAKER_MM_CUT_LOSS_TIME || '60', 10), // secs before close to force exit
makerMmEntryWindow: parseInt(process.env.MAKER_MM_ENTRY_WINDOW || '45', 10), // max secs after open to enter
makerMmPollInterval: parseInt(process.env.MAKER_MM_POLL_INTERVAL || process.env.MM_POLL_INTERVAL || '5', 10) * 1000,
makerMmReentryDelay: parseInt(process.env.MAKER_MM_REENTRY_DELAY || '30', 10) * 1000, // ms delay between re-entry cycles
makerMmReentryEnabled: process.env.MAKER_MM_REENTRY_ENABLED !== 'false', // set false to disable re-entry (one cycle per market)
makerMmRepriceThreshold: parseFloat(process.env.MAKER_MM_REPRICE_THRESHOLD || '0.02'), // reprice if bid drifts > this (default 2c)
makerMmMinPrice: parseFloat(process.env.MAKER_MM_MIN_PRICE || '0.30'), // min bid for rebate range (both sides)
makerMmMaxPrice: parseFloat(process.env.MAKER_MM_MAX_PRICE || '0.69'), // max bid for rebate range (both sides)
// When true: if expensive side fills first, cancel cheap side and hold to redemption
makerMmCancelCheapOnExpFill: process.env.MAKER_MM_CANCEL_CHEAP_ON_EXP_FILL === 'true',
makerMmPollSec: parseInt(process.env.MAKER_MM_POLL_SEC || '3', 10),
// ── Current Market Settings ────────────────────────────────────
// Enable trading on current active market (not just next market)
currentMarketEnabled: process.env.CURRENT_MARKET_ENABLED === 'true',
// Max odds threshold for current market (stop re-entry if odds drop below this)
currentMarketMaxOdds: parseFloat(process.env.CURRENT_MARKET_MAX_ODDS || '0.70'),
// Max odds threshold for next market (only enter if max odds <= this)
nextMarketMaxOdds: parseFloat(process.env.NEXT_MARKET_MAX_ODDS || '0.52'),
// ── Orderbook Sniper ───────────────────────────────────────────
// 3-tier strategy: places GTC limit BUY orders at 3c, 2c, and 1c
// Tier 1 (3c): smallest size | Tier 2 (2c): medium size | Tier 3 (1c): largest size
@@ -88,6 +126,24 @@ const config = {
sniperMaxShares: parseFloat(process.env.SNIPER_MAX_SHARES || '15'), // max total per side
sniperMinSharesPerTier: 5, // minimum shares for each tier
// ── Sniper Sizing Multiplier (UTC+8) ──────────────────────────
// Time-based bet sizing multiplier. Format: HH:MM-HH:MM:factor,...
// Example: SNIPER_MULTIPLIERS=21:00-00:00:1.41,06:00-12:00:0.85
// Default multiplier outside any window = 1.0
sniperMultipliers: (() => {
const raw = process.env.SNIPER_MULTIPLIERS || '';
if (!raw.trim()) return [];
return raw.split(',').map((s) => s.trim()).filter(Boolean).map((entry) => {
const m = entry.match(/^(\d{1,2}:\d{2})\s*[-]\s*(\d{1,2}:\d{2}):(\d+\.?\d*)$/);
if (!m) return null;
return { start: m[1], end: m[2], multiplier: parseFloat(m[3]) };
}).filter(Boolean);
})(),
// ── Sniper Pause After Win ───────────────────────────────────
// Number of rounds (5-min slots) to pause an asset after a win is detected.
sniperPauseRoundsAfterWin: parseInt(process.env.SNIPER_PAUSE_ROUNDS_AFTER_WIN || '3', 10),
// ── Sniper Schedule (UTC+8) ────────────────────────────────────
// Per-asset session windows. Format: SNIPER_SCHEDULE_{ASSET}=HH:MM-HH:MM,HH:MM-HH:MM
// Assets without a schedule are always active.
@@ -135,4 +191,16 @@ 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;
+290
View File
@@ -0,0 +1,290 @@
/**
* maker-mm-bot.js — Maker Rebate MM, PM2 / VPS entry point (no TUI)
*
* Plain-text stdout output, compatible with:
* pm2 start pm2/maker-mm.config.cjs
* pm2 logs polymarket-maker-mm
*/
// Set proxy before any network calls
import './utils/proxy-patch.cjs';
import { validateMakerMMConfig } from './config/index.js';
import config from './config/index.js';
import logger from './utils/logger.js';
import { initClient, getUsdcBalance } from './services/client.js';
import { startMMDetector, stopMMDetector, checkCurrentMarket } from './services/mmDetector.js';
import { executeMakerRebateStrategy, getActiveMakerPositions, getMarketOdds as getExecutorMarketOdds } from './services/makerRebateExecutor.js';
import { mmFillWatcher } from './services/mmWsFillWatcher.js';
logger.interceptConsole();
// ── Validate config ────────────────────────────────────────────────────────────
try {
validateMakerMMConfig();
} catch (err) {
logger.error(`Config error: ${err.message}`);
process.exit(1);
}
// ── Init CLOB client ──────────────────────────────────────────────────────────
try {
await initClient();
} catch (err) {
logger.error(`Client init error: ${err.message}`);
process.exit(1);
}
// ── Start WebSocket fill watcher ─────────────────────────────────────────────
mmFillWatcher.start();
// ── Override mmDetector config to use maker-mm settings ──────────────────────
config.mmAssets = config.makerMmAssets;
config.mmDuration = config.makerMmDuration;
config.mmPollInterval = config.makerMmPollInterval;
config.mmEntryWindow = config.makerMmEntryWindow;
// ── Periodic status log ──────────────────────────────────────────────────────
async function printStatus() {
try {
let balanceStr = 'SIM';
if (!config.dryRun) {
try { balanceStr = `$${(await getUsdcBalance()).toFixed(2)} USDC`; } catch { balanceStr = 'N/A'; }
}
const positions = getActiveMakerPositions();
const mode = config.dryRun ? 'SIMULATION' : 'LIVE';
logger.info(
`--- MakerMM Status [${mode}] | Balance: ${balanceStr} | Active positions: ${positions.length} ---`,
);
for (const pos of positions) {
const assetTag = pos.asset ? `[${pos.asset.toUpperCase()}] ` : '';
const label = pos.question.substring(0, 50);
const msLeft = new Date(pos.endTime).getTime() - Date.now();
const secsLeft = Math.max(0, Math.round(msLeft / 1000));
const timeStr = secsLeft > 60
? `${Math.floor(secsLeft / 60)}m${secsLeft % 60}s left`
: `${secsLeft}s left`;
const yFill = pos.yes.filled ? `FILLED` : `bid $${pos.yes.buyPrice?.toFixed(3)}`;
const nFill = pos.no.filled ? `FILLED` : `bid $${pos.no.buyPrice?.toFixed(3)}`;
const combined = (pos.yes.buyPrice + pos.no.buyPrice).toFixed(4);
logger.info(
` ${assetTag}${label} | ${pos.status} | ${timeStr}` +
` | combined $${combined}` +
` | YES ${pos.targetShares}sh → ${yFill}` +
` | NO ${pos.targetShares}sh → ${nFill}`,
);
}
} catch (err) {
logger.warn(`Status check error: ${err.message}`);
}
}
// ── Market handler with per-asset queue ──────────────────────────────────────
const pendingByAsset = new Map();
const runningByAsset = new Set(); // tracked from start of runStrategy, not just active positions
/**
* Check if current market odds allow re-entry
* For current market: max odds must be <= currentMarketMaxOdds (default 70%)
*/
async function isCurrentMarketOddsValidForReentry(yesTokenId, noTokenId) {
if (!config.currentMarketEnabled) return false;
try {
const odds = await getExecutorMarketOdds(yesTokenId, noTokenId);
if (!odds) {
logger.warn(`MakerMM: cannot determine odds — blocking re-entry`);
return false;
}
const threshold = config.currentMarketMaxOdds;
const valid = odds.max <= threshold;
if (!valid) {
logger.warn(
`MakerMM: current market max odds ${(odds.max * 100).toFixed(1)}% > ${(threshold * 100).toFixed(0)}% ` +
`— STOPPING re-entry for this market`
);
} else {
logger.info(
`MakerMM: current market max odds ${(odds.max * 100).toFixed(1)}% <= ${(threshold * 100).toFixed(0)}% ` +
`— re-entry allowed`
);
}
return valid;
} catch (err) {
logger.warn(`MakerMM: odds check error — ${err.message}`);
return false;
}
}
async function runStrategy(market) {
const isCurrentMarket = market.isCurrentMarket ?? false;
const assetTag = market.asset?.toUpperCase() || '';
let cycleCount = 0;
runningByAsset.add(market.asset);
while (true) {
cycleCount++;
if (cycleCount > 1) {
logger.info(`MakerMM[${assetTag}]: re-entry cycle #${cycleCount}`);
}
// ── Check if already have active position for this asset ─────────────
// Wait for any existing position to complete before starting new one
const maxWaitMs = 120_000; // Max 2 minutes wait
const pollIntervalMs = 2_000;
const waitStart = Date.now();
while (true) {
const activePositions = getActiveMakerPositions();
const hasActivePosition = activePositions.some(p => p.asset === market.asset);
if (!hasActivePosition) break; // Safe to proceed
if (Date.now() - waitStart > maxWaitMs) {
logger.warn(`MakerMM[${assetTag}]: timeout waiting for previous position — skipping cycle`);
return; // Exit this runStrategy entirely
}
logger.info(`MakerMM[${assetTag}]: waiting for previous position to complete...`);
await new Promise(r => setTimeout(r, pollIntervalMs));
}
let cycleResult = { oneSided: false };
try {
cycleResult = await executeMakerRebateStrategy(market) ?? { oneSided: false };
} catch (err) {
logger.error(`MakerMM strategy error (${assetTag}): ${err.message}`);
}
// If cycle ended with one-sided fill (stuck), stop re-entry for this market
if (cycleResult.oneSided) {
logger.warn(`MakerMM[${assetTag}]: cycle ended one-sided — stopping re-entry to avoid accumulating exposure`);
break;
}
// Check if we can re-enter (market still active with enough time)
const msRemaining = new Date(market.endTime).getTime() - Date.now();
const secsLeft = Math.round(msRemaining / 1000);
const minTimeForReentry = 180; // 3 minutes minimum
if (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
@@ -0,0 +1,219 @@
/**
* maker-mm.js
* Entry point for the Maker Rebate MM bot (TUI).
* Buys YES+NO at top bid (maker) → merges → profit from spread + rebates.
* Run with: npm run maker-mm (live)
* npm run maker-mm-sim (simulation / dry-run)
*/
// Set proxy before any network calls
import './utils/proxy-patch.cjs';
import { validateMakerMMConfig } from './config/index.js';
import config from './config/index.js';
import logger from './utils/logger.js';
import { initClient, getClient } from './services/client.js';
import { initDashboard, appendLog, updateStatus, isDashboardActive } from './ui/dashboard.js';
import { startMMDetector, stopMMDetector, checkCurrentMarket } from './services/mmDetector.js';
import { executeMakerRebateStrategy, getActiveMakerPositions } from './services/makerRebateExecutor.js';
import { mmFillWatcher } from './services/mmWsFillWatcher.js';
import { getUsdcBalance } from './services/client.js';
// ── Validate config ────────────────────────────────────────────────────────────
try {
validateMakerMMConfig();
} catch (err) {
console.error(`Config error: ${err.message}`);
process.exit(1);
}
// ── Init TUI ──────────────────────────────────────────────────────────────────
initDashboard();
logger.setOutput(appendLog);
// ── Init CLOB client ──────────────────────────────────────────────────────────
try {
await initClient();
} catch (err) {
logger.error(`Client init error: ${err.message}`);
process.exit(1);
}
// ── Start WebSocket fill watcher for real-time order detection ────────────────
mmFillWatcher.start();
// ── Override mmDetector config to use maker-mm settings ──────────────────────
config.mmAssets = config.makerMmAssets;
config.mmDuration = config.makerMmDuration;
config.mmPollInterval = config.makerMmPollInterval;
config.mmEntryWindow = config.makerMmEntryWindow;
// ── Status panel refresh ──────────────────────────────────────────────────────
async function buildStatusContent() {
let lines = [];
// Balance
let balance = '?';
if (!config.dryRun) {
try { balance = (await getUsdcBalance()).toFixed(2); } catch { /* ignore */ }
} else {
balance = '{yellow-fg}SIM{/yellow-fg}';
}
lines.push(`{bold}BALANCE{/bold}`);
lines.push(` USDC.e: {green-fg}$${balance}{/green-fg}`);
lines.push('');
// Mode
lines.push(`{bold}MODE{/bold}`);
lines.push(` ${config.dryRun ? '{yellow-fg}SIMULATION{/yellow-fg}' : '{green-fg}LIVE{/green-fg}'}`);
lines.push(` Strategy: {cyan-fg}MAKER REBATE{/cyan-fg}`);
lines.push('');
// Config
lines.push(`{bold}MAKER MM CONFIG{/bold}`);
lines.push(` Assets : ${config.makerMmAssets.join(', ').toUpperCase()}`);
lines.push(` Duration : ${config.makerMmDuration}`);
lines.push(` Trade sz : $${config.makerMmTradeSize} per side`);
lines.push(` Max combined: $${config.makerMmMaxCombined}`);
lines.push(` Reprice : ${config.makerMmRepriceSec}s`);
lines.push(` Fill timeout: ${config.makerMmFillTimeout}s`);
lines.push(` Cut loss : ${config.makerMmCutLossTime}s before close`);
lines.push('');
// Active positions
const positions = getActiveMakerPositions();
lines.push(`{bold}ACTIVE POSITIONS (${positions.length}){/bold}`);
if (positions.length === 0) {
lines.push(' {gray-fg}Waiting for market...{/gray-fg}');
} else {
for (const pos of positions) {
const assetTag = pos.asset ? `[${pos.asset.toUpperCase()}] ` : '';
const label = pos.question.substring(0, 32);
const msLeft = new Date(pos.endTime).getTime() - Date.now();
const secsLeft = Math.max(0, Math.round(msLeft / 1000));
const timeStr = secsLeft > 60
? `${Math.floor(secsLeft / 60)}m${secsLeft % 60}s`
: `{red-fg}${secsLeft}s{/red-fg}`;
const combined = (pos.yes.buyPrice + pos.no.buyPrice).toFixed(4);
const spread = (1 - pos.yes.buyPrice - pos.no.buyPrice).toFixed(4);
lines.push(` {cyan-fg}${assetTag}${label}{/cyan-fg}`);
lines.push(` Status : ${pos.status} | Time left: ${timeStr}`);
lines.push(` Combined: $${combined} | Spread: $${spread}`);
// YES side
const yFill = pos.yes.filled
? `{green-fg}FILLED{/green-fg}`
: `{yellow-fg}bid $${pos.yes.buyPrice?.toFixed(3)}{/yellow-fg}`;
lines.push(` YES ${pos.targetShares?.toFixed(1)} sh @ $${pos.yes.buyPrice?.toFixed(3)}${yFill}`);
// NO side
const nFill = pos.no.filled
? `{green-fg}FILLED{/green-fg}`
: `{yellow-fg}bid $${pos.no.buyPrice?.toFixed(3)}{/yellow-fg}`;
lines.push(` NO ${pos.targetShares?.toFixed(1)} sh @ $${pos.no.buyPrice?.toFixed(3)}${nFill}`);
if (pos.totalProfit !== 0) {
const sign = pos.totalProfit >= 0 ? '+' : '';
const color = pos.totalProfit >= 0 ? 'green' : 'red';
lines.push(` P&L: {${color}-fg}${sign}$${pos.totalProfit.toFixed(2)}{/${color}-fg}`);
}
lines.push('');
}
}
return '\n' + lines.join('\n');
}
let refreshTimer = null;
function startRefresh() {
refreshTimer = setInterval(async () => {
if (!isDashboardActive()) return;
const content = await buildStatusContent();
updateStatus(content);
}, 3000);
// Immediate refresh
buildStatusContent().then(updateStatus);
}
// ── Market handler with per-asset queue ──────────────────────────────────────
const pendingByAsset = new Map();
async function runStrategy(market) {
try {
await executeMakerRebateStrategy(market);
} catch (err) {
logger.error(`MakerMM strategy error (${market.asset?.toUpperCase()}): ${err.message}`);
}
// After position clears, execute queued market for this asset
const queued = pendingByAsset.get(market.asset);
if (queued) {
pendingByAsset.delete(market.asset);
const endMs = new Date(queued.endTime).getTime();
const secsLeft = Math.round((endMs - Date.now()) / 1000);
if (secsLeft > config.makerMmCutLossTime) {
logger.success(
`MakerMM[${market.asset?.toUpperCase()}]: position cleared — ` +
`executing queued "${queued.question.substring(0, 40)}" (${secsLeft}s left)`,
);
runStrategy(queued);
} else {
logger.warn(
`MakerMM[${market.asset?.toUpperCase()}]: queued market "${queued.question.substring(0, 40)}" ` +
`expired (${secsLeft}s left) — discarding`,
);
}
}
}
async function handleNewMarket(market) {
const active = getActiveMakerPositions();
const isAssetBusy = active.some((p) => p.asset === market.asset);
if (isAssetBusy) {
pendingByAsset.set(market.asset, market);
logger.warn(
`MakerMM[${market.asset?.toUpperCase()}]: queued "${market.question.substring(0, 40)}" — ` +
`will enter after current position clears`,
);
return;
}
runStrategy(market);
}
// ── Graceful shutdown ─────────────────────────────────────────────────────────
function shutdown() {
logger.warn('MakerMM: shutting down...');
stopMMDetector();
mmFillWatcher.stop();
if (refreshTimer) clearInterval(refreshTimer);
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
// ── Start ─────────────────────────────────────────────────────────────────────
logger.info(`MakerMM bot starting — ${config.dryRun ? 'SIMULATION MODE' : 'LIVE MODE'} | assets: ${config.makerMmAssets.join(', ').toUpperCase()} | ${config.makerMmDuration}`);
startRefresh();
startMMDetector(handleNewMarket);
// Immediately check if there's a current active market to enter
checkCurrentMarket(handleNewMarket);
+9
View File
@@ -6,12 +6,16 @@
* 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();
@@ -44,6 +48,10 @@ if (config.mmTradeSize < MIN_SHARES_PER_SIDE) {
process.exit(1);
}
// ── Start WebSocket fill watcher for real-time order detection ────────────────
mmFillWatcher.start();
// ── Cleanup leftover positions on startup ─────────────────────────────────────
try {
@@ -163,6 +171,7 @@ const redeemTimer = setInterval(
function shutdown() {
logger.warn('MM: shutting down...');
stopMMDetector();
mmFillWatcher.stop();
clearInterval(statusTimer);
clearInterval(redeemTimer);
setTimeout(() => process.exit(0), 300);
+10 -1
View File
@@ -6,6 +6,9 @@
* 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';
@@ -13,6 +16,7 @@ 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';
@@ -49,7 +53,11 @@ if (config.mmTradeSize < MIN_SHARES_PER_SIDE) {
process.exit(1);
}
// ── Cleanup leftover positions on startup ─────────────────────────────────────
// ── Start WebSocket fill watcher for real-time order detection ────────────────
mmFillWatcher.start();
// ── Cleanup leftover positions on startup ────────────────────────────────────
try {
await cleanupOpenPositions(getClient());
@@ -207,6 +215,7 @@ 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);
+11 -8
View File
@@ -1,11 +1,12 @@
import { ClobClient } from '@polymarket/clob-client';
import { Wallet } from 'ethers';
import { ethers, 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
@@ -79,20 +80,22 @@ export function getSigner() {
}
/**
* Get a working Polygon provider using RPC from config
* Get (or create) the singleton Polygon provider.
* A single JsonRpcProvider instance is reused across all onchain calls
* to avoid reconnection overhead on every balance check.
*/
export async function getPolygonProvider() {
const { ethers } = await import('ethers');
const provider = new ethers.providers.JsonRpcProvider(config.polygonRpcUrl);
return provider;
export function getPolygonProvider() {
if (!_provider) {
_provider = new ethers.providers.JsonRpcProvider(config.polygonRpcUrl);
}
return _provider;
}
/**
* Get USDC.e balance of the proxy wallet on Polygon
*/
export async function getUsdcBalance() {
const { ethers } = await import('ethers');
const provider = await getPolygonProvider();
const provider = getPolygonProvider();
const usdcAddress = '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174'; // USDC.e on Polygon
const abi = ['function balanceOf(address) view returns (uint256)'];
const usdc = new ethers.Contract(usdcAddress, abi, provider);
+177 -22
View File
@@ -73,6 +73,10 @@ 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'))
@@ -101,20 +105,50 @@ 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 = '') {
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;
}
};
// Enqueue: this call will only start after the previous one resolves/rejects
const result = _txQueue.then(() => _doExecSafeCall(to, data, description));
const result = _txQueue.then(job);
// Don't let a failure poison the queue for subsequent calls
_txQueue = result.catch(() => { });
return result;
}
async function _doExecSafeCall(to, data, description = '') {
async function _doExecSafeCall(to, data, description = '', gasLimit = undefined) {
if (description) logger.info(`MM: exec safe tx — ${description}`);
let lastErr;
@@ -173,12 +207,15 @@ async function _doExecSafeCall(to, data, description = '') {
? MAX_FEE_CAP
: estimatedMaxFee.mul(Math.ceil(currentMultiplier * 100)).div(100);
const txOpts = { maxPriorityFeePerGas: gasTip, maxFeePerGas: gasFeeCap };
if (gasLimit) txOpts.gasLimit = gasLimit;
const tx = await safe.execTransaction(
to, 0, data, 0, 0, 0, 0,
ethers.constants.AddressZero,
ethers.constants.AddressZero,
signature,
{ maxPriorityFeePerGas: gasTip, maxFeePerGas: gasFeeCap },
txOpts,
);
const receipt = await tx.wait();
@@ -208,18 +245,28 @@ async function _doExecSafeCall(to, data, description = '') {
// ── 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)) return;
if (allowance.gte(amountWei)) {
_usdcApproved = true;
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');
}
@@ -229,18 +276,68 @@ 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) return;
if (approved) {
_exchangeApproved.add(exchange);
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 ───────────────────────────────────────────────────────
/**
@@ -309,7 +406,13 @@ export async function mergePositions(conditionId, sharesPerSide) {
return recovered;
}
const amountWei = ethers.utils.parseUnits(sharesPerSide.toFixed(6), 6);
// Floor to exact 6-decimal integer to prevent requesting more units than the Safe holds.
// Floating point round-trip (e.g. 4.910199 → toFixed(4) → 4.9102 → 4910200 wei)
// can exceed actual on-chain balance by 1 unit, causing the CTF merge to revert.
const amountWei = ethers.utils.parseUnits(
(Math.floor(sharesPerSide * 1_000_000) / 1_000_000).toFixed(6),
6,
);
const ctfIface = new ethers.utils.Interface(CTF_ABI);
const data = ctfIface.encodeFunctionData('mergePositions', [
@@ -320,7 +423,10 @@ export async function mergePositions(conditionId, sharesPerSide) {
amountWei,
]);
await execSafeCall(CTF_ADDRESS, data, `mergePositions conditionId=${conditionId.slice(0, 10)}...`);
// Pass explicit gasLimit to bypass eth_estimateGas — Polygon RPC instability
// can cause estimateGas to fail even when the tx would succeed onchain.
// 500k gas is well above the ~200-250k typically consumed by a Safe+CTF merge.
await execSafeCall(CTF_ADDRESS, data, `mergePositions conditionId=${conditionId.slice(0, 10)}...`, { gasLimit: 500_000 });
logger.success(`MM: merged — recovered $${sharesPerSide} USDC`);
return sharesPerSide;
}
@@ -523,7 +629,7 @@ export async function redeemMMPositions() {
conditionId,
[1, 2],
]);
await execSafeCall(CTF_ADDRESS, data, `redeemPositions ${label}`);
await execSafeCall(CTF_ADDRESS, data, `redeemPositions ${label}`, { priority: false });
logger.money(`MM redeemer: redeemed ${label} → ~$${expectedUsdc.toFixed(2)} USDC`);
redeemed++;
} catch (err) {
@@ -542,6 +648,29 @@ export async function redeemMMPositions() {
const _failedConditions = new Set();
const _skippedLosses = new Set();
// Callback invoked when a win is detected — receives conditionId
let _onWinCallback = null;
// Function to look up conditionId → { asset, yesTokenId, noTokenId }
// Injected from sniper entry point to avoid circular imports
let _getConditionInfo = null;
/**
* Register a callback to be called when a sniper win is detected.
* Callback signature: (conditionId: string) => void
*/
export function onSniperWin(cb) {
_onWinCallback = cb;
}
/**
* Register a function to look up sniper condition info (token mapping).
* Used to correctly map token balances to outcome indices.
*/
export function setSniperConditionLookup(fn) {
_getConditionInfo = fn;
}
/**
* Redeem sniper positions via Gnosis Safe.
* Only redeems WINNING positions — skip losses (they can be manually cleared).
@@ -616,37 +745,63 @@ export async function redeemSniperPositions() {
continue;
}
// Estimate payout from numerators (for logging)
const payoutFractions = await Promise.all(
// 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() / denominator.toNumber())
ctf.payoutNumerators(conditionId, i).then((n) => n.toNumber())
)
);
const expectedUsdc = balances.reduce(
(sum, shares, i) => sum + shares * (payoutFractions[i] ?? 0), 0
);
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) + '...';
const isWin = expectedUsdc >= 0.01;
// 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}${totalShares.toFixed(3)} shares (cached, no future checks)`);
logger.info(`SNIPER[SIM] skip loss: ${label}outcome=${winningOutcome}, win_shares=0 (cached)`);
} else {
logger.info(`SNIPER redeemer: skip loss ${label}cached for future cycles`);
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}${totalShares.toFixed(3)} shares → ~$${expectedUsdc.toFixed(2)} USDC (WIN)`);
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 — ${totalShares.toFixed(3)} shares → ~$${expectedUsdc.toFixed(2)} USDC`);
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', [
@@ -655,7 +810,7 @@ export async function redeemSniperPositions() {
conditionId,
[1, 2],
]);
const receipt = await execSafeCall(CTF_ADDRESS, data, `redeemPositions ${label}`);
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++;
+935
View File
@@ -0,0 +1,935 @@
/**
* 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 };
}
+54 -6
View File
@@ -7,28 +7,31 @@
* e.g. btc-updown-5m-1771755000
* eth-updown-15m-1771754100
*
* NEVER enters the currently active market always targets the NEXT upcoming slot.
* poll() targets the NEXT upcoming slot. checkCurrentMarket() enters the current slot on startup.
*/
import config from '../config/index.js';
import logger from '../utils/logger.js';
import { proxyFetch } from '../utils/proxy.js';
// Slot size in seconds (300 for 5m, 900 for 15m)
const SLOT_SEC = config.mmDuration === '15m' ? 900 : 300;
let pollTimer = null;
let onMarketCb = null;
const seenKeys = new Set(); // `${asset}-${slotTimestamp}` already scheduled
// ── Slot helpers ──────────────────────────────────────────────────────────────
// Computed dynamically so config.mmDuration overrides in maker-mm.js take effect.
function slotSec() {
return config.mmDuration === '15m' ? 900 : 300;
}
function currentSlot() {
return Math.floor(Date.now() / 1000 / SLOT_SEC) * SLOT_SEC;
const s = slotSec();
return Math.floor(Date.now() / 1000 / s) * s;
}
function nextSlot() {
return currentSlot() + SLOT_SEC;
return currentSlot() + slotSec();
}
// ── Gamma API fetch ───────────────────────────────────────────────────────────
@@ -150,3 +153,48 @@ 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);
}
}
+540 -79
View File
@@ -14,12 +14,16 @@ 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.
@@ -37,6 +41,37 @@ 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();
@@ -107,17 +142,89 @@ async function marketSell(tokenId, shares, tickSize, negRisk) {
// ── Order status check ────────────────────────────────────────────────────────
async function isOrderFilled(orderId, shares) {
async function isOrderFilled(orderId, shares, tokenId = null) {
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 false;
if (order.status === 'MATCHED') return true;
const matched = parseFloat(order.size_matched || '0');
return matched >= shares * 0.99;
if (!order) return 0;
if (order.status === 'MATCHED') return parseFloat(order.original_size || order.size || '0');
return parseFloat(order.size_matched || '0');
} catch {
return false;
return 0;
}
}
@@ -141,11 +248,106 @@ async function getMidprice(tokenId) {
} catch { return 0; }
}
// ── Core monitoring loop ──────────────────────────────────────────────────────
// ── 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) ───────────────────────────
async function monitorAndManage(pos) {
const label = pos.question.substring(0, 40);
// Register tokens with WS fill watcher for instant fill detection
mmFillWatcher.watch(pos.yes.tokenId);
mmFillWatcher.watch(pos.no.tokenId);
// Handle WS fill events — mark side as filled immediately
const onWsFill = (event) => {
for (const key of ['yes', 'no']) {
if (!pos[key].filled && event.tokenId === pos[key].tokenId && event.side === 'SELL') {
pos[key].filled = true;
pos[key].fillPrice = event.price || config.mmSellPrice;
const pnl = (pos[key].fillPrice - pos[key].entryPrice) * pos[key].shares;
logger.money(`MM: ${key.toUpperCase()} filled (WS realtime) @ $${pos[key].fillPrice.toFixed(3)} | P&L $${pnl.toFixed(2)}`);
}
}
};
mmFillWatcher.on('fill', onWsFill);
try {
await _monitorLoop(pos, label);
} finally {
// Cleanup WS listeners
mmFillWatcher.removeListener('fill', onWsFill);
mmFillWatcher.unwatch(pos.yes.tokenId);
mmFillWatcher.unwatch(pos.no.tokenId);
}
// Final P&L log
const totalPnl = calcPnl(pos);
const sign = totalPnl >= 0 ? '+' : '';
if (pos.status !== 'done') {
logger.info(`MM: strategy ended (${pos.status}) | P&L: ${sign}$${totalPnl.toFixed(2)} | ${label}`);
}
}
async function _monitorLoop(pos, label) {
while (true) {
const msRemaining = new Date(pos.endTime).getTime() - Date.now();
@@ -155,39 +357,11 @@ async function monitorAndManage(pos) {
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)}`);
}
}
// ── 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)}`);
}
}
// ── Check YES + NO sides in parallel ────────────────────
await Promise.all([
checkSideFill(pos, 'yes'),
checkSideFill(pos, 'no'),
]);
// ── Both filled → done ──────────────────────────────────
if (pos.yes.filled && pos.no.filled) {
@@ -204,31 +378,90 @@ async function monitorAndManage(pos) {
break;
}
// ── Defensive pivot: neither filled after timeout (5m markets only) ──
if (config.mmDefensiveEnabled && config.mmDuration === '5m'
&& !pos.yes.filled && !pos.no.filled && !pos._defensiveActive) {
const marketDurationMs = 5 * 60 * 1000;
const marketStartMs = new Date(pos.endTime).getTime() - marketDurationMs;
const elapsed = (Date.now() - marketStartMs) / 1000;
if (elapsed >= config.mmDefensiveTimeout) {
// Cancel both orders FIRST so they can't fill while we wait
logger.warn(`MM: neither side filled after ${Math.round(elapsed)}s since market open — cancelling orders | ${label}`);
await Promise.all([
cancelOrder(pos.yes.orderId),
cancelOrder(pos.no.orderId),
]);
pos.yes.orderId = null;
pos.no.orderId = null;
// Re-check fills after cancellation — CLOB may have filled one side
// between our last check and the cancel (race condition)
await sleep(2000);
const [yesBalance, noBalance] = await Promise.all([
getTokenBalance(pos.yes.tokenId),
getTokenBalance(pos.no.tokenId),
]);
for (const [key, balance] of [['yes', yesBalance], ['no', noBalance]]) {
if (!pos[key].filled && balance !== null && balance < pos[key].shares * 0.05) {
logger.warn(`MM: ${key.toUpperCase()} actually filled (on-chain balance ${balance.toFixed(3)} ≈ 0) — detected after cancel`);
pos[key].filled = true;
pos[key].fillPrice = config.mmSellPrice;
const pnl = (pos[key].fillPrice - pos[key].entryPrice) * pos[key].shares;
logger.money(`MM: ${key.toUpperCase()} filled @ $${pos[key].fillPrice.toFixed(3)} | P&L $${pnl.toFixed(2)}`);
}
}
// If one side is now filled, go to adaptive CL instead of defensive pivot
if (pos.yes.filled !== pos.no.filled) {
const unfilledKey = pos.yes.filled ? 'no' : 'yes';
logger.warn(`MM: one side filled after cancel — switching to adaptive CL for ${unfilledKey.toUpperCase()} instead of defensive pivot`);
await adaptiveLegCL(pos, unfilledKey);
break;
}
// If both filled (unlikely but possible), we're done
if (pos.yes.filled && pos.no.filled) {
pos.status = 'done';
const totalPnl = calcPnl(pos);
logger.money(`MM: BOTH sides filled! Total P&L: $${totalPnl.toFixed(2)} | ${label}`);
break;
}
// Neither filled — proceed with defensive pivot
pos._defensiveActive = true;
await defensivePivot(pos);
break;
}
}
// ── Cut-loss time ────────────────────────────────────────────────────
if (msRemaining <= config.mmCutLossTime * 1000) {
logger.warn(`MM: cut-loss triggered (${Math.round(msRemaining / 1000)}s left) — ${label}`);
pos.status = 'cutting';
const oneLegFilled = pos.yes.filled !== pos.no.filled;
if (!config.mmAdaptiveCL && oneLegFilled) {
// Legacy: one side sold → immediate market sell on the other
const unfilledKey = pos.yes.filled ? 'no' : 'yes';
await cutLossOneLegFilled(pos, unfilledKey);
pos.status = 'done';
} else {
// Neither filled → cancel both + merge back to USDC
await cutLossNeitherFilled(pos);
}
break;
}
await sleep(10_000);
}
// ── 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);
// Final P&L log
const totalPnl = calcPnl(pos);
const sign = totalPnl >= 0 ? '+' : '';
if (pos.status !== 'done') {
logger.info(`MM: strategy ended (${pos.status}) | P&L: ${sign}$${totalPnl.toFixed(2)} | ${label}`);
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`);
}
}
}
@@ -263,8 +496,10 @@ async function cutLossNeitherFilled(pos) {
// ── Best case: neither side sold → cancel both, merge back to USDC ──
logger.warn('MM: neither side filled — cancelling orders and merging back to USDC...');
await cancelOrder(pos.yes.orderId);
await cancelOrder(pos.no.orderId);
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([
@@ -296,6 +531,104 @@ 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;
@@ -310,8 +643,36 @@ async function adaptiveLegCL(pos, unfilledKey) {
const filledLegPrice = pos[filledKey].fillPrice ?? config.mmSellPrice;
const minAdaptivePrice = Math.max(0, config.mmAdaptiveMinCombined - filledLegPrice);
// ── Tiered floors (5m markets): progressively lower floor over time ────
// Start from minAdaptivePrice (MM_ADAPTIVE_MIN_COMBINED - filledPrice), then drop per phase
const floorDrop = config.mmDefensiveEnabled ? 0.10 : 0;
const emergencyPrice = config.mmDefensiveWorstThreshold; // default 0.10
const is5m = config.mmDuration === '5m';
/**
* Get the current floor based on time remaining (5m markets only).
* Other durations use the fixed mmAdaptiveMinCombined floor.
*
* Phase 1 (> 180s left): minAdaptivePrice (from MM_ADAPTIVE_MIN_COMBINED)
* Phase 2 (90180s): minAdaptivePrice - 0.10
* Phase 3 (3090s): minAdaptivePrice - 0.20
* Phase 4 (< 30s): market sell
*/
function getTieredFloor(msLeft) {
if (!is5m) return minAdaptivePrice; // non-5m: use fixed floor
if (msLeft > 180_000) return minAdaptivePrice;
if (msLeft > 90_000) return Math.max(0.01, minAdaptivePrice - floorDrop);
if (msLeft > 30_000) return Math.max(0.01, minAdaptivePrice - floorDrop * 2);
return 0; // phase 4: market sell
}
logger.warn(`MM: one leg filled — starting adaptive CL for ${unfilledKey.toUpperCase()} | ${label}`);
logger.info(`MM adaptive CL: filled leg @ $${filledLegPrice.toFixed(3)} | min floor for combined ≥ $${config.mmAdaptiveMinCombined.toFixed(2)}: $${minAdaptivePrice.toFixed(3)}`);
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);
@@ -329,25 +690,49 @@ async function adaptiveLegCL(pos, unfilledKey) {
return;
}
logger.info(`MM adaptive CL: monitoring ${unfilledKey.toUpperCase()} — limit only when price ≥ $${minAdaptivePrice.toFixed(3)}, market-sell only at CL time`);
// 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 ─────────────────────────────────────────────
// Every poll cycle:
// 1. CL time → cancel limit, market sell (last resort)
// 2. Check fill → done
// 3. Read current price
// 4a. Price < floor OR dropped >5% → cancel limit, keep watching
// 4b. Price improved >2% → cancel and re-place higher
// 5a. price >= floor → place/maintain limit at min(currentPrice, mmSellPrice)
// 5b. price < floor → no limit placed, log & wait (never sell below profit floor)
let lastPhaseLog = '';
while (true) {
const msLeft = new Date(pos.endTime).getTime() - Date.now();
// ── CL time: last resort market sell ───────────────────────────────
if (msLeft <= config.mmCutLossTime * 1000) {
// ── Phase 4 / CL time: force market sell ────────────────────────────
if (msLeft <= (is5m ? 30_000 : config.mmCutLossTime * 1000)) {
if (activeOrderId) {
await cancelOrder(activeOrderId);
activeOrderId = null;
@@ -355,6 +740,27 @@ async function adaptiveLegCL(pos, unfilledKey) {
break;
}
// ── Update tiered floor ─────────────────────────────────────────────
const newFloor = getTieredFloor(msLeft);
if (newFloor !== currentFloor) {
const phase = msLeft > 180_000 ? '1-breakeven' : msLeft > 90_000 ? '2-controlled' : '3-emergency';
if (phase !== lastPhaseLog) {
logger.info(`MM adaptive CL: phase ${phase} — floor $${currentFloor.toFixed(3)}$${newFloor.toFixed(3)} (${Math.round(msLeft / 1000)}s left)`);
lastPhaseLog = phase;
}
// If floor lowered and we have an active order above new floor, keep it
// Only cancel+re-place if the floor dropped below our current limit
if (activeOrderId && activeLimitPrice > newFloor) {
// Current limit is above new floor — that's fine, keep it
} else if (activeOrderId && activeLimitPrice < newFloor) {
// Floor raised (shouldn't happen in tiered, but safety)
await cancelOrder(activeOrderId);
activeOrderId = null;
activeLimitPrice = 0;
}
currentFloor = newFloor;
}
// ── Check fill ──────────────────────────────────────────────────────
if (activeOrderId) {
let filled = false;
@@ -362,7 +768,7 @@ async function adaptiveLegCL(pos, unfilledKey) {
const hitPrice = await simPriceHitTarget(s.tokenId);
if (hitPrice) { filled = true; s.fillPrice = hitPrice; }
} else {
filled = await isOrderFilled(activeOrderId, sellShares);
filled = await isOrderFilled(activeOrderId, sellShares, s.tokenId);
if (filled) s.fillPrice = activeLimitPrice;
}
@@ -383,17 +789,27 @@ async function adaptiveLegCL(pos, unfilledKey) {
continue;
}
// ── Emergency cut: price < 10c in phase 3 → market sell immediately ─
if (is5m && msLeft <= 90_000 && currentPrice < emergencyPrice) {
logger.warn(`MM adaptive CL: EMERGENCY — price $${currentPrice.toFixed(3)} < $${emergencyPrice} with ${Math.round(msLeft / 1000)}s left — market selling now`);
if (activeOrderId) {
await cancelOrder(activeOrderId);
activeOrderId = null;
}
break; // fall through to market sell below
}
const targetPrice = Math.min(currentPrice, config.mmSellPrice);
// ── Adjust or cancel active limit ───────────────────────────────────
if (activeOrderId) {
const belowFloor = currentPrice < minAdaptivePrice;
const belowFloor = currentPrice < currentFloor;
const droppedHard = currentPrice < activeLimitPrice * 0.95;
const priceImproved = targetPrice > activeLimitPrice * 1.02;
if (belowFloor || droppedHard) {
const reason = belowFloor
? `below floor $${minAdaptivePrice.toFixed(3)} (combined $${(filledLegPrice + currentPrice).toFixed(3)} < $${config.mmAdaptiveMinCombined.toFixed(2)})`
? `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);
@@ -408,17 +824,47 @@ async function adaptiveLegCL(pos, unfilledKey) {
}
}
// ── Place limit only above the profitable floor ─────────────────────
// ── Place limit at floor or above ───────────────────────────────────
if (!activeOrderId) {
if (currentPrice >= minAdaptivePrice) {
logger.info(`MM adaptive CL: placing limit sell @ $${targetPrice.toFixed(3)} (mid: $${currentPrice.toFixed(3)}, combined: $${(filledLegPrice + targetPrice).toFixed(3)}, ${Math.round(msLeft / 1000)}s left)`);
const result = await placeLimitSell(s.tokenId, sellShares, targetPrice, tickSize, negRisk);
// 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 = targetPrice;
activeLimitPrice = limitPrice;
}
} else {
logger.info(`MM adaptive CL: price $${currentPrice.toFixed(3)} below floor $${minAdaptivePrice.toFixed(3)} (combined $${(filledLegPrice + currentPrice).toFixed(3)}) — waiting for recovery (${Math.round(msLeft / 1000)}s left)`);
logger.info(`MM adaptive CL: price $${currentPrice.toFixed(3)} below floor $${currentFloor.toFixed(3)} — waiting for recovery (${Math.round(msLeft / 1000)}s left)`);
}
}
@@ -426,12 +872,25 @@ async function adaptiveLegCL(pos, unfilledKey) {
}
// ── Fallback: market sell at CL time ───────────────────────────────────────
logger.warn(`MM adaptive CL: CL time reached — market-selling ${sellShares.toFixed(3)} ${unfilledKey.toUpperCase()} shares`);
const result = await marketSell(s.tokenId, sellShares, tickSize, negRisk);
// 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) * sellShares;
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 ${sellShares.toFixed(3)} sh | P&L $${pnl.toFixed(2)}`);
logger.warn(`MM adaptive CL: ${unfilledKey.toUpperCase()} market-sold @ $${s.fillPrice.toFixed(3)} | combined $${combined.toFixed(3)} | sold ${finalShares.toFixed(3)} sh | P&L $${pnl.toFixed(2)}`);
s.filled = true;
pos.status = 'done';
@@ -626,10 +1085,12 @@ export async function executeMMStrategy(market) {
const entryPrice = 0.50;
logger.info(`MM${tag}: split done — ${shares} YES + ${shares} NO @ $${entryPrice}`);
// ── Place limit sells ───────────────────────────────────────
// ── Place limit sells (parallel) ────────────────────────────
logger.info(`MM${tag}: ${sim}placing limit sells @ $${config.mmSellPrice}`);
const yesSell = await placeLimitSell(yesTokenId, shares, config.mmSellPrice, tickSize, negRisk);
const noSell = await placeLimitSell(noTokenId, shares, config.mmSellPrice, tickSize, negRisk);
const [yesSell, noSell] = await Promise.all([
placeLimitSell(yesTokenId, shares, config.mmSellPrice, tickSize, negRisk),
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
@@ -0,0 +1,208 @@
/**
* 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();
+33 -4
View File
@@ -4,21 +4,34 @@
* - 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
* Min 5 shares per tier, total = SNIPER_MAX_SHARES × timeMultiplier
*/
import { Side, OrderType } from '@polymarket/clob-client';
import config from '../config/index.js';
import { getClient } from './client.js';
import logger from '../utils/logger.js';
import { getTimeMultiplier } from './sniperSizing.js';
// In-memory tracking of placed snipe orders
const activeSnipes = [];
// conditionId → { asset, yesTokenId, noTokenId } mapping
// yesTokenId = outcome 0 (clobTokenIds[0]), noTokenId = outcome 1 (clobTokenIds[1])
const conditionInfoMap = new Map();
export function getActiveSnipes() {
return [...activeSnipes];
}
export function getConditionAsset(conditionId) {
return conditionInfoMap.get(conditionId)?.asset || null;
}
export function getConditionInfo(conditionId) {
return conditionInfoMap.get(conditionId) || null;
}
/**
* Calculate tier sizes based on max shares.
* Distribution: 20% | 30% | 50% (highlow price)
@@ -48,15 +61,31 @@ export async function executeSnipe(market) {
const label = question.slice(0, 40);
const sim = config.dryRun ? '[SIM] ' : '';
// Track conditionId → { asset, token IDs } for win detection
// yesTokenId = outcome 0 (clobTokenIds[0]), noTokenId = outcome 1 (clobTokenIds[1])
conditionInfoMap.set(conditionId, {
asset: asset.toLowerCase(),
yesTokenId,
noTokenId,
});
const sides = [
{ name: 'UP', tokenId: yesTokenId },
{ name: 'DOWN', tokenId: noTokenId },
];
const prices = config.sniperTierPrices;
const sizes = calculateTierSizes(config.sniperMaxShares, config.sniperMinSharesPerTier);
// Apply time-based multiplier
const { multiplier, label: mulLabel } = getTimeMultiplier();
const effectiveMaxShares = Math.max(
config.sniperMinSharesPerTier * 3,
Math.round(config.sniperMaxShares * multiplier),
);
logger.info(`SNIPER: ${sim}${asset.toUpperCase()} — "${label}" | 3-tier: 3c×${sizes[0]} | 2c×${sizes[1]} | 1c×${sizes[2]}`);
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}`);
for (const { name, tokenId } of sides) {
// Place 3 orders per side
+50
View File
@@ -0,0 +1,50 @@
/**
* 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' };
}
+56 -3
View File
@@ -17,9 +17,10 @@ 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 } from './services/sniperExecutor.js';
import { redeemSniperPositions } from './services/ctf.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 ────────────────────────────────────────────────────────────
@@ -76,7 +77,20 @@ async function buildStatusContent() {
lines.push(` 3-Tier : ${prices[0]}c/${prices[1]}c/${prices[2]}c`);
lines.push(` Sizes : ${sizes[0]}/${sizes[1]}/${sizes[2]} shares`);
const costPerSide = (sizes[0] * prices[0]) + (sizes[1] * prices[1]) + (sizes[2] * prices[2]);
lines.push(` Cost : $${(costPerSide * 2 * config.sniperAssets.length).toFixed(3)} per slot`);
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
@@ -139,9 +153,48 @@ function startRedeemer() {
logger.info(`Sniper redeemer started — checking every ${config.redeemInterval / 1000}s`);
}
// ── Pause-after-win tracking ─────────────────────────────────────────────────
const pauseCounters = {};
function handleWin(conditionId) {
const asset = getConditionAsset(conditionId);
if (!asset) return;
const rounds = config.sniperPauseRoundsAfterWin;
pauseCounters[asset] = rounds;
logger.success(`SNIPER: WIN on ${asset.toUpperCase()} — pausing ${rounds} rounds`);
}
function isAssetPaused(asset) {
const key = asset.toLowerCase();
return pauseCounters[key] > 0;
}
function tickPause(asset) {
const key = asset.toLowerCase();
if (pauseCounters[key] > 0) {
pauseCounters[key]--;
if (pauseCounters[key] <= 0) {
logger.info(`SNIPER: ${asset.toUpperCase()} pause ended — resuming`);
}
}
}
onSniperWin(handleWin);
setSniperConditionLookup(getConditionInfo);
// ── Market handler ────────────────────────────────────────────────────────────
async function handleNewMarket(market) {
const asset = market.asset.toLowerCase();
tickPause(asset);
if (isAssetPaused(asset)) {
logger.info(`SNIPER: ${asset.toUpperCase()} paused (${pauseCounters[asset]} rounds left) — skipping`);
return;
}
executeSnipe(market).catch((err) =>
logger.error(`SNIPER execute error (${market.asset}): ${err.message}`)
);
+82 -3
View File
@@ -3,6 +3,11 @@
* Console-only entry point for the Orderbook Sniper bot.
* Places tiny GTC BUY orders at a low price on both sides of 5-min markets.
*
* Features:
* - Time-based multiplier sizing (SNIPER_MULTIPLIERS, UTC+8)
* - Pause N rounds per asset after a win (SNIPER_PAUSE_ROUNDS_AFTER_WIN)
* - Win detection via outcome (payoutNumerators), not redeem value
*
* Run with: npm run sniper (live, console)
* npm run sniper-sim (simulation, console)
*
@@ -14,9 +19,10 @@ import config from './config/index.js';
import logger from './utils/logger.js';
import { initClient } from './services/client.js';
import { startSniperDetector, stopSniperDetector } from './services/sniperDetector.js';
import { executeSnipe } from './services/sniperExecutor.js';
import { redeemSniperPositions } from './services/ctf.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';
@@ -44,6 +50,53 @@ try {
process.exit(1);
}
// ── Pause-after-win tracking ─────────────────────────────────────────────────
// pauseCounters[asset] = number of rounds remaining to skip
const pauseCounters = {};
/**
* Called by the redeemer when a win is detected.
* Looks up the asset from the conditionId mapping and sets the pause counter.
*/
function handleWin(conditionId) {
const asset = getConditionAsset(conditionId);
if (!asset) {
logger.info(`SNIPER: win detected for ${conditionId.slice(0, 12)}... but no asset mapping found`);
return;
}
const rounds = config.sniperPauseRoundsAfterWin;
pauseCounters[asset] = rounds;
logger.success(`SNIPER: WIN on ${asset.toUpperCase()} — pausing ${rounds} rounds`);
}
/**
* Check if an asset is currently paused due to a recent win.
* Each call with decrement=true counts as one round passing.
*/
function isAssetPaused(asset) {
const key = asset.toLowerCase();
if (!pauseCounters[key] || pauseCounters[key] <= 0) return false;
return true;
}
/**
* Decrement pause counter for an asset (called once per round/slot).
*/
function tickPause(asset) {
const key = asset.toLowerCase();
if (pauseCounters[key] && pauseCounters[key] > 0) {
pauseCounters[key]--;
if (pauseCounters[key] <= 0) {
logger.info(`SNIPER: ${asset.toUpperCase()} pause ended — resuming`);
}
}
}
// Register win callback and token lookup for correct outcome mapping
onSniperWin(handleWin);
setSniperConditionLookup(getConditionInfo);
// ── Log session schedule ──────────────────────────────────────────────────────
function logSchedule() {
@@ -83,6 +136,17 @@ function startRedeemer() {
// ── Market handler ────────────────────────────────────────────────────────────
async function handleNewMarket(market) {
const asset = market.asset.toLowerCase();
// Tick pause counter for this asset (each new market = 1 round)
tickPause(asset);
// Check if asset is paused after a recent win
if (isAssetPaused(asset)) {
logger.info(`SNIPER: ${asset.toUpperCase()} paused (${pauseCounters[asset]} rounds left) — skipping`);
return;
}
executeSnipe(market).catch((err) =>
logger.error(`SNIPER execute error (${market.asset}): ${err.message}`)
);
@@ -108,7 +172,22 @@ const sizes = [Math.floor(config.sniperMaxShares * 0.20), Math.floor(config.snip
const costPerSide = (sizes[0] * prices[0]) + (sizes[1] * prices[1]) + (sizes[2] * prices[2]);
const costPerSlot = (costPerSide * 2 * config.sniperAssets.length).toFixed(3);
logger.info(`SNIPER starting — ${config.dryRun ? 'SIMULATION' : 'LIVE'}`);
logger.info(`Assets: ${config.sniperAssets.join(', ').toUpperCase()} | 3-tier: 3c×${sizes[0]}+2c×${sizes[1]}+1c×${sizes[2]} = $${costPerSlot}/slot`);
logger.info(`Assets: ${config.sniperAssets.join(', ').toUpperCase()} | 3-tier: 3c×${sizes[0]}+2c×${sizes[1]}+1c×${sizes[2]} = $${costPerSlot}/slot (base)`);
// Log multiplier config
if (config.sniperMultipliers.length > 0) {
logger.info('─── Sizing Multipliers (UTC+8) ───');
for (const w of config.sniperMultipliers) {
logger.info(` ${w.start}${w.end}${w.multiplier}x`);
}
const { multiplier, label } = getTimeMultiplier();
logger.info(` Current: ${label}`);
logger.info('──────────────────────────────────');
}
if (config.sniperPauseRoundsAfterWin > 0) {
logger.info(`Pause after win: ${config.sniperPauseRoundsAfterWin} rounds per asset`);
}
logSchedule();
startRedeemer();
+150 -5
View File
@@ -39,6 +39,18 @@ export async function setupAxiosProxy() {
axios.defaults.proxy = false;
axios.defaults.httpAgent = axiosAgent;
axios.defaults.httpsAgent = axiosAgent;
// Add request interceptor to force proxy agent on every request
// This catches cases where axios.create() instances ignore defaults
axios.interceptors.request.use((cfg) => {
if (cfg.url && cfg.url.includes('polymarket.com')) {
cfg.httpsAgent = axiosAgent;
cfg.httpAgent = axiosAgent;
cfg.proxy = false;
}
return cfg;
});
logger.info(`Axios proxy configured → ${maskProxyUrl(config.proxyUrl)}`);
} catch (err) {
logger.error(`Failed to configure axios proxy: ${err.message}`);
@@ -69,7 +81,80 @@ export async function proxyFetch(url, opts = {}) {
}
/**
* Test that the proxy works by making a simple request to the CLOB API.
* 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() {
@@ -77,28 +162,88 @@ export async function testProxy() {
logger.info(`Testing proxy connection → ${maskProxyUrl(config.proxyUrl)} ...`);
// Show both IPs so user can verify which IP Polymarket sees
await checkOutboundIP();
// ── Test 1: fetch (undici) ──────────────────────────────────────────
try {
if (!fetchDispatcher) {
throw new Error('Proxy dispatcher not initialized');
}
// Test with a simple GET to the CLOB time endpoint via proxied fetch
const resp = await fetch(`${config.clobHost}/time`, {
dispatcher: fetchDispatcher,
signal: AbortSignal.timeout(15000),
});
if (resp.status === 403) {
const body = await resp.text().catch(() => '');
const isGeoblock = body.includes('restricted') || body.includes('region') || body.includes('geoblock');
if (isGeoblock) {
logger.error('═══════════════════════════════════════════════════');
logger.error('GEOBLOCKED — Polymarket CLOB rejected your proxy IP!');
logger.error('Your proxy IP is in a restricted region.');
logger.error('Change PROXY_URL in .env to a proxy in an allowed region.');
logger.error('Allowed regions: https://docs.polymarket.com/developers/CLOB/geoblock');
logger.error('═══════════════════════════════════════════════════');
} else {
logger.error(`CLOB returned 403 Forbidden: ${body.substring(0, 200)}`);
}
return false;
}
if (!resp.ok) {
throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
}
logger.success(`Proxy test passed — connected via ${maskProxyUrl(config.proxyUrl)}`);
return true;
logger.success(`Proxy test (fetch) passed`);
} catch (err) {
logger.error(`Proxy test FAILED: ${err.message}`);
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;
}
/**