70 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
direkturcrypto 9e78fb1bfe fix: update sniper logging for 3-tier config
- Fix sniperDetector.js to use sniperTierPrices instead of old config
- Fix sniper-tui.js display for 3-tier pricing
- Remove references to deleted sniperPrice/sniperShares vars

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 14:59:14 +07:00
direkturcrypto c524ed92ef feat: implement 3-tier sniper strategy with weighted sizing
- Replace single price/shares with 3-tier system (3c/2c/1c)
- Tier allocation: 20%/30%/50% (high→low price)
- Min 5 shares per tier enforced
- Update .env.example with new config vars
- Update README.md with strategy documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 14:52:37 +07:00
direkturcrypto 00235dc850 fix: inject proxy agent into @polymarket/clob-client
Add proxy support directly to clob-client http-helpers:
- Import https-proxy-agent
- Add getProxyAgent() helper
- Inject agent into axios config for polymarket.com endpoints

This ensures all CLOB API calls route through the proxy.
2026-03-01 22:36:23 +07:00
direkturcrypto f5c777ec0c simplify: use HttpsProxyAgent directly for proxy routing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 22:33:45 +07:00
direkturcrypto 048d4433c2 fix: implement CONNECT tunnel for proxy routing
Full HTTPS CONNECT tunnel implementation that:
- Routes only Polymarket domains through proxy
- Uses HTTP CONNECT method for HTTPS tunneling
- Leaves all other traffic (RPC, etc.) untouched

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 22:27:18 +07:00
direkturcrypto b770f831db fix: simplify proxy patch to use HttpsProxyAgent globally
Set https.globalAgent and override https.request to use proxy
for all Polymarket domains. Ensures @polymarket/clob-client
axios calls route through proxy.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 22:22:20 +07:00
direkturcrypto 368a3f8da5 fix: ensure proxy is used by @polymarket/clob-client
Add proxy-patch.cjs that patches https.request BEFORE axios is loaded.
This forces all Polymarket API calls through the proxy, including
internal axios instances used by @polymarket/clob-client.

Import patch as very first thing in sniper.js and sniper-tui.js.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 22:18:06 +07:00
direkturcrypto b2f8447d0b perf: cache sniper losses to speed up redeem checks
- Add _skippedLosses Set to cache confirmed losses (skip future checks)
- Add counters for skipped categories (unresolved, losses, no balance)
- Losses only checked once, subsequent cycles skip immediately
- Add summary logging for better visibility

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 22:01:56 +07:00
direkturcrypto c5b169b47e feat: sniper redeem - winners only, high gas, no startup check
- Remove startup redeem check (only runs on interval)
- Only redeem WINNING positions (skip losses for manual cleanup)
- Increase gas fees: 1.5x estimate, min 50 Gwei priority fee
- Add tx hash logging for successful redeems

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 21:42:26 +07:00
direkturcrypto 374e943cc2 feat: redeem logging improvements
- Add transaction hash to redeem success log
- Continue to next position when one fails (don't get stuck)
- Add clearer logging for retry behavior

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 21:30:19 +07:00
direkturcrypto d24c5f2e81 feat: add undici dependency + dynamic gas multiplier for tx retry
- Install undici package for fetch proxy support
- Add gas multiplier on retry to handle "replacement transaction underpriced"
  - Attempt 1: 1x gas
  - Attempt 2: 2x gas
  - Attempt 3: 4x gas

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 21:23:37 +07:00
direkturcrypto 56f59eabdf fix: sniper redeem matches MM redeemer — redeem all, skip failed conditions
- Redeem ALL resolved positions (not just winners) — burns loser tokens
  to clean them from Data API, prevents buildup that slows down cycles
- Add _failedConditions set: skip conditionIds that revert on-chain
  so they don't retry every 60 seconds
- Same code path as the working MM redeemer (redeemMMPositions)
- Log win/loss separately for profit tracking
2026-03-01 21:10:12 +07:00
direkturcrypto 4af99a78f5 fix: simplify sniper redeem — remove MultiSend and gas estimation timeout
- Remove MultiSend batching (was causing hangs)
- Revert execSafeCall to original behavior (no gas estimation timeout)
- Keep win-only filter: skip positions with $0 payout
- Simple 1-by-1 redeem identical to the working MM redeemer
- Fix proxy: use undici ProxyAgent with dispatcher for native fetch
- Move schedule to .env: SNIPER_SCHEDULE_BTC=19:40-22:40,03:40-06:10
2026-03-01 20:55:06 +07:00
direkturcrypto 0dc8024ceb fix: proxy uses undici ProxyAgent for fetch, schedule moved to .env, verbose redeem logging
- Fix proxy: native fetch needs undici ProxyAgent with dispatcher option
  (https-proxy-agent only works with axios, not Node's built-in fetch)
- Move schedule from hardcoded to .env: SNIPER_SCHEDULE_BTC=19:40-22:40,03:40-06:10
- Add verbose logging to redeemSniperPositions for debugging
- Config dynamically reads all SNIPER_SCHEDULE_* env vars
2026-03-01 19:07:57 +07:00
direkturcrypto f31f257da5 feat: sniper bot updates — TUI separation, session scheduling, proxy support, win-only redeem
- Separate TUI from sniper: npm run sniper (console), npm run sniper-tui (TUI)
- Add trading session scheduling per asset (BTC/ETH/SOL/XRP) in UTC+8
- Add PROXY_URL support for Polymarket CLOB/Gamma/Data APIs (not Polygon RPC)
- Create redeemSniperPositions: win-only filter, bulk MultiSend batching
- Add gas estimation with 10s timeout in execSafeCall
- Replace native fetch with proxyFetch in all Polymarket API services
2026-03-01 16:24:25 +07:00
direkturcrypto f4490c12ac refactor: split PM2 ecosystem config into pm2/ folder + update README
- ecosystem.config.cjs removed from root
- pm2/copy.config.cjs — copy trade bot config
- pm2/mm.config.cjs   — market maker bot config
  Both use __dirname-relative paths so they work regardless of cwd.

README updated:
- Added PM2/VPS section with per-bot start/log/management commands
- Added plain log mode section (bot / mm-bot scripts)
- Updated project structure tree with new files
- Added MIN_MARKET_TIME_LEFT and GTC_FALLBACK_TIMEOUT to config table
- Updated copy trade flow diagram

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 02:11:20 +07:00
direkturcrypto 5ea7f3d2b0 feat: add PM2-compatible MM entry point (src/mm-bot.js)
- src/mm-bot.js: same MM logic as mm.js but no blessed TUI; writes plain
  text to stdout so pm2 logs works on VPS; status printed every 60s
- ecosystem.config.cjs: added polymarket-mm app with separate log files
  (logs/mm-out.log, logs/mm-error.log); both copy and MM bots now managed
- package.json: added mm-bot / mm-bot-sim / mm-bot-dev scripts

Usage:
  pm2 start ecosystem.config.cjs --only polymarket-mm
  pm2 start ecosystem.config.cjs --only polymarket-mm --env sim
  pm2 logs polymarket-mm

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 02:05:06 +07:00
direkturcrypto a6f2e148a6 remove logs data oneshot 2026-02-26 01:36:27 +07:00
direkturcrypto 363d74ff2d Merge branch 'feat/mm-adaptive-cl' 2026-02-26 01:35:04 +07:00
direkturcrypto 0148b0bbef feat: GTC fallback when FAK finds no liquidity (next market copy trades)
When copying a trader who buys into the next periodic market before sellers
exist, FAK returns 0 fill each attempt. After exhausting FAK retries, the
bot now falls back to a GTC limit order at price*1.02 and polls getOrder()
every 3s until filled or GTC_FALLBACK_TIMEOUT (default 60s) expires.

If the GTC times out it is cancelled. GTC_FALLBACK_TIMEOUT=0 disables the
fallback entirely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 04:30:12 +07:00
direkturcrypto 365dc4e164 fix: serialize concurrent buys per market + always verify on-chain payout before redeem
executeBuy (race condition):
- Multiple WebSocket events for the same market can arrive concurrently.
  All calls saw no existing position and all proceeded to buy → 3x fills.
- Added _buyQueue (Map<conditionId, Promise>) that chains each buy for
  the same market after the previous one. Second call now sees the filled
  position and respects maxPositionSize.

redeemer (gas estimation error):
- Gamma API can return resolved=true before payoutDenominator is written
  on-chain. Calling redeemPositions when payoutDenominator==0 causes the
  contract to revert → UNPREDICTABLE_GAS_LIMIT from ethers.js.
- Now always verify on-chain payout after the API check. If on-chain
  payout not set yet, skip and retry next interval.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 04:22:09 +07:00
direkturcrypto 526fc7a057 feat: add PM2-compatible entry point (src/bot.js) and ecosystem config
- src/bot.js: same logic as index.js but no blessed TUI; writes plain
  text to stdout so pm2 logs works cleanly on VPS
- ecosystem.config.cjs: pm2 config with live/sim envs, log files at
  logs/out.log + logs/error.log, auto-restart policy
- logs/.gitkeep: track the logs/ dir (*.log files are gitignored)
- package.json: added bot / bot-sim / bot-dev scripts

Usage on VPS:
  pm2 start ecosystem.config.cjs            # live
  pm2 start ecosystem.config.cjs --env sim  # simulation
  pm2 logs polymarket-copy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 04:17:16 +07:00
direkturcrypto 0650cb9ef7 fix: correct Gamma API field names in getMarketOptions (all camelCase)
The Gamma API returns camelCase field names but the code used snake_case,
causing all lookups to return undefined:
  - end_date_iso  → endDate  (full ISO datetime, not date-only endDateIso)
  - game_start_time → removed (field doesn't exist)
  - minimum_tick_size → orderPriceMinTickSize
  - neg_risk      → negRisk
  - condition_id  → conditionId
  - accepting_orders → acceptingOrders

Because endDate was always null, the MIN_MARKET_TIME_LEFT expiry guard
was silently skipped on every buy — allowing buys into expired markets.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 03:49:38 +07:00
direkturcrypto 59295b08cb fix: remove duplicate 'client' declaration in executeSell
The const client = getClient() was declared twice in the same function scope
(line 318 for cancel-orders block, line 371 for the sell loop), causing a
SyntaxError at startup. Removed the redundant second declaration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 03:44:32 +07:00
direkturcrypto b52892172e fix: enforce $1 USDC minimum per FAK buy order (Polymarket hard minimum)
Polymarket rejects market orders below $1 USDC. Added CLOB_MIN_ORDER_USDC=1
constant and applied Math.max(config.minTradeSize, CLOB_MIN_ORDER_USDC) at
both the loop entry check and post-fill remainder check so sub-$1 remainders
are silently skipped instead of causing API errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 03:43:38 +07:00
direkturcrypto 097148c501 fix: cancel all open orders before sell to release CLOB locked balance
Root cause: auto-sell GTC orders lock all shares in the CLOB's internal
ledger. When executeSell runs, it tried to cancel only position.sellOrderId
but this can fail silently, leaving tokens locked. The CLOB then rejects
the new sell with "not enough balance or allowance" because the balance
is committed to the existing GTC order.

Fix:
- Fetch all open orders for the specific tokenId via client.getOpenOrders()
- Cancel all of them with Promise.allSettled (non-fatal per order)
- Wait 600ms after cancellation so the CLOB updates its locked-balance
  ledger before we place the new sell
- Fallback: if getOpenOrders fails, still attempt to cancel by
  position.sellOrderId (previous behaviour) then wait 600ms
- Use correct cancelOrder({ orderID }) object form throughout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 03:38:38 +07:00
direkturcrypto ec2c948a00 fix: redeemer executes via Gnosis Safe proxy wallet (same as MM bot)
redeemer.js was calling redeemPositions() directly from the EOA private
key wallet. This is wrong for two reasons:
1. Conditional tokens are held in config.proxyWallet (Gnosis Safe), not
   in the EOA — so the EOA has nothing to redeem
2. No maxPriorityFeePerGas was set, causing Polygon's "gas tip below
   minimum" error (1.5 Gwei sent, 25 Gwei required)

Fix: replace the direct contract call with execSafeCall() (exported from
ctf.js), which is identical to the MM bot's redemption pattern:
- encodes calldata and executes via Safe.execTransaction()
- signed by the EOA, but msg.sender on-chain = proxy wallet
- enforces 30 Gwei minimum priority fee for Polygon
- retries up to 3x on transient RPC errors

Also remove the now-unused NEG_RISK_CTF_ADDRESS local constant
(CTF_ADDRESS and USDC_ADDRESS imported from ctf.js).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 03:32:01 +07:00
direkturcrypto e82c67824c fix: resolve 'not enough balance or allowance' on sell orders
Two root causes fixed:

1. Missing ERC-1155 setApprovalForAll for copy-trade flow
   - executeBuy: call ensureExchangeApproval() after a successful fill so
     the CTF Exchange is authorised to move tokens before the next sell
   - executeSell: call ensureExchangeApproval() before every sell attempt;
     the function is idempotent (checks isApprovedForAll first, only sends
     a Safe tx if approval is actually missing)

2. Stored shares can exceed real on-chain balance (fee deductions, float
   drift across partial FAK fills)
   - executeSell: query ctf.balanceOf(proxyWallet, tokenId) before selling
   - If on-chain balance is 0 → position is already gone, remove it and skip
   - If on-chain balance < stored shares → adjust down and log the delta
   - Round sell amount to 4 decimal places to avoid sub-unit precision errors

Also add getOnChainTokenBalance() helper using a minimal CTF ABI slice.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 03:29:10 +07:00
direkturcrypto 69d8d1401d fix: market expiry guard, FAK orders, 2% slippage, sanitize CLOB logs
Market expiry guard (executor.js):
- getMarketOptions() now returns endDateIso, active, acceptingOrders
  from Gamma API response
- executeBuy() skips immediately if market is closed / not accepting orders
- executeBuy() skips if market closes within MIN_MARKET_TIME_LEFT seconds
  (default 300 s = 5 min), logging exact time remaining
- Add MIN_MARKET_TIME_LEFT to config and .env.example

FAK + 2% slippage (executor.js):
- Replace OrderType.FOK with OrderType.FAK for both BUY and SELL
  market orders — eliminates "FOK fully filled or killed" failures
- Reduce slippage from 5% to 2% (price * 1.02 / price * 0.98)
- Fix zero-fill detection: FAK success with 0 shares logs "no liquidity"
  and retries instead of recording a phantom fill

CLOB log sanitization (logger.js + index.js):
- Add sanitizeClobMessage(): strips axios config object (auth headers)
  from [CLOB Client] dumps, keeps only HTTP status + error string
- Add interceptConsole(): overrides console.error/warn globally
- Call interceptConsole() at startup in index.js

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 03:18:57 +07:00
direkturcrypto d3e5567c97 feat: MM adaptive cut-loss with profit floor
When one leg fills, instead of immediately market-selling the unfilled
leg, enter a continuous monitoring loop that:
- Places a limit sell only when price >= minAdaptivePrice floor
  (floor = mmAdaptiveMinCombined - filledLegPrice, default combined 1.20)
- Chases price upward (>2% improvement → re-place limit higher)
- Cancels limit on dip >5% or below floor, then waits for recovery
- Market-sells only as last resort when CL time is reached

New config: MM_ADAPTIVE_CL (toggle), MM_ADAPTIVE_MIN_COMBINED (floor),
MM_ADAPTIVE_MONITOR_SEC (poll interval). Legacy immediate market-sell
path preserved when MM_ADAPTIVE_CL=false.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 02:34:45 +07:00
34 changed files with 5330 additions and 723 deletions
+158 -78
View File
@@ -1,126 +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
MIN_MARKET_TIME_LEFT=300
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
# ─────────────────────────────────────────────
# ORDERBOOK SNIPER (sniper.js / npm run sniper-sim)
# Places tiny GTC BUY orders at a low price on both sides of
# ETH/SOL/XRP 5-minute markets — catches panic dumps near $0.
# ─────────────────────────────────────────────
# Comma-separated assets to snipe
SNIPER_ASSETS=eth,sol,xrp
# Buy price per share (1 cent = $0.01)
SNIPER_PRICE=0.01
# 3-Tier pricing (descending) — orders placed at these prices
SNIPER_TIER1_PRICE=0.03
SNIPER_TIER2_PRICE=0.02
SNIPER_TIER3_PRICE=0.01
# Shares per side — minimum Polymarket order size is 5 shares
# At $0.01/share: 5 shares = $0.05 per side, $0.10 per market
SNIPER_SHARES=5
# Max total shares to deploy (split across tiers: 20% / 30% / 50%)
SNIPER_MAX_SHARES=15
# Time-based sizing multiplier (UTC+8). Format: HH:MM-HH:MM:factor,...
SNIPER_MULTIPLIERS=21:00-00:00:1.41,06:00-12:00:0.85
# Rounds to pause an asset after detecting a win (5-min intervals)
SNIPER_PAUSE_ROUNDS_AFTER_WIN=3
# Active session schedules per asset (UTC+8). Format: HH:MM-HH:MM,...
SNIPER_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
# ══════════════════════════════════════════════════════════════════
# 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
+2
View File
@@ -1,4 +1,6 @@
node_modules/
.env
data/*.json
data/*.jsonl
.DS_Store
logs/*.log
+136 -225
View File
@@ -1,216 +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)
- [How It Works](#how-it-works)
- [Project Structure](#project-structure)
- [Important Warnings](#important-warnings)
- [Contributing](#contributing)
- [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 (`npm start`)
- **Watch Trader** — Monitor any Polymarket wallet address in real time via WebSocket
- **Copy Buy** — Automatically mirror buy orders with configurable position sizing
- **Copy Sell** — Automatically mirror sell orders (market or limit)
- **Auto Sell** — Place a GTC limit sell at a target profit % immediately after a buy fills
- **Auto Redeem** — Periodically check and redeem winning positions on-chain
- **Deduplication** — Each market is entered at most once; no double buys
- **Balance Guard** — Checks USDC.e balance before every order
- **Dry Run Mode** — Simulate the full flow without placing real orders
Mirrors the trades of any target Polymarket wallet in real-time.
### Market Maker Bot (`npm run mm`)
- **Automated Liquidity** — Splits USDC into YES+NO tokens and places limit sells on both sides at $0.50 entry
- **Cut-Loss Protection** — Merges unsold tokens back to USDC before market close
- **Recovery Buy** — Optional directional bet after a cut-loss triggers
- **Multi-Asset** — Supports BTC, ETH, SOL, and any 5m/15m Polymarket market
- **Simulation Mode** — Full dry-run with P&L tracking (`npm run mm-sim`)
- 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 (`npm run sniper`)
- **Low-Price Orders** — Places tiny GTC BUY orders at a configurable price (e.g. $0.01) on both sides
- **Multi-Asset** — Targets ETH, SOL, XRP, and more simultaneously
- **Simulation Mode** — Preview orders without spending funds (`npm run sniper-sim`)
```
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) |
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 (see Configuration section below)
nano .env # or use your preferred editor
# 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` |
| `DRY_RUN` | Simulate without placing real orders | `true` |
### Market Maker Bot Settings
| Variable | Description | Default |
|---|---|---|
| `MM_ASSETS` | Comma-separated assets to market-make (e.g. `btc,eth`) | `btc` |
| `MM_DURATION` | Market duration: `5m` or `15m` | `5m` |
| `MM_TRADE_SIZE` | USDC per side (total exposure = 2×) | `5` |
| `MM_SELL_PRICE` | Limit sell price target (e.g. `0.60`) | `0.60` |
| `MM_CUT_LOSS_TIME` | Seconds before close to trigger cut-loss | `60` |
| `MM_MARKET_KEYWORD` | Keyword to filter market questions | `Bitcoin Up or Down` |
| `MM_ENTRY_WINDOW` | Max seconds after open to enter (0 = open only) | `45` |
| `MM_POLL_INTERVAL` | Seconds between new market polls | `10` |
| `MM_RECOVERY_BUY` | Enable recovery buy after cut-loss | `false` |
| `MM_RECOVERY_THRESHOLD` | Minimum dominant-side price to qualify for recovery | `0.70` |
| `MM_RECOVERY_SIZE` | USDC for recovery buy (0 = use `MM_TRADE_SIZE`) | `0` |
### Orderbook Sniper Settings
| Variable | Description | Default |
|---|---|---|
| `SNIPER_ASSETS` | Comma-separated assets to snipe (e.g. `eth,sol,xrp`) | `eth,sol,xrp` |
| `SNIPER_PRICE` | Buy price per share (e.g. `0.01` = $0.01) | `0.01` |
| `SNIPER_SHARES` | Shares per side (minimum 5 per Polymarket rules) | `5` |
---
## Usage
**Always test with simulation mode first:**
```bash
# ── Copy Trade Bot ─────────────────────────────────
npm start # Production mode
npm run dev # Development mode (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 (DRY_RUN=false)
npm run mm-sim # Simulation mode (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 (DRY_RUN=false)
npm run sniper-sim # Simulation mode (DRY_RUN=true)
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
```
> **Always test with `DRY_RUN=true` first** before committing real funds.
---
## How It Works
## Running with PM2 (recommended for VPS)
### Copy Trade Bot Flow
```bash
npm install -g pm2
```
┌──────────────────────────────────────────────────────────┐
│ WATCHER LOOP │
│ WebSocket (RTDS) — real-time trade events from trader │
│ Fallback: poll Data API every N seconds │
├───────────────────────┬──────────────────────────────────┤
│ NEW BUY │ NEW SELL │
│ │ │
│ ✓ Check position │ ✓ Check position exists │
│ ✓ Check USDC balance │ ✓ Cancel existing auto-sell │
│ ✓ Market buy (FOK) │ ✓ Market / limit sell │
│ ✓ Retry on failure │ ✓ Retry on failure │
│ ✓ Place auto-sell │ ✓ Remove position from state │
│ ✓ Save position │ │
├───────────────────────┴──────────────────────────────────┤
│ REDEEMER LOOP │
│ Periodically checks resolved markets │
│ → Redeems winning positions via CTF contract on-chain │
└──────────────────────────────────────────────────────────┘
```
# Start maker MM
pm2 start src/maker-mm-bot.js --name polymarket-maker-mm --interpreter node
### Market Maker Flow
# Start copy trader
pm2 start src/bot.js --name polymarket-bot --interpreter node
```
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)
# View logs
pm2 logs polymarket-maker-mm
pm2 logs polymarket-bot
```
---
@@ -218,74 +149,54 @@ Collect Cancel orders → Merge YES+NO back to USDC
## Project Structure
```
polymarket-terminal/
├── src/
│ ├── index.js — Copy trade bot entry point
├── mm.js — Market maker bot entry point
├── sniper.js Orderbook sniper bot entry point
│ │
├── config/
│ └── index.js — Environment variable loading & validation
│ │
├── services/
│ │ ├── client.js — CLOB client initialization & USDC balance
│ │ ├── watcher.js — Poll-based trader activity detection
│ │ ├── wsWatcher.js — WebSocket real-time trade listener
│ ├── executor.js — Buy & sell order execution logic
│ │ ├── position.js — Position state management (CRUD)
│ │ ├── autoSell.js — Auto limit-sell placement
│ │ ├── redeemer.js — Market resolution check & CTF redemption
│ │ ├── ctf.js — On-chain CTF contract interactions (MM bot)
│ │ ├── mmDetector.js — Market detection for market maker
│ │ ├── mmExecutor.js — Market maker strategy execution
│ │ ├── sniperDetector.js — Market detection for sniper
│ │ └── sniperExecutor.js — Orderbook sniper order placement
│ │
│ ├── ui/
│ │ └── dashboard.js — Terminal UI (blessed)
│ │
│ └── utils/
│ ├── logger.js — Color-coded, timestamped logging
│ ├── state.js — Atomic JSON state file management
│ └── simStats.js — Simulation P&L statistics
├── data/ — Runtime state files (gitignored)
├── .env.example — Configuration template
├── .gitignore
└── package.json
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`** 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.
---
## Contributing
## Risk Management
Contributions are welcome! To get started:
1. Fork the repository
2. Create a feature branch: `git checkout -b feat/your-feature`
3. Make your changes and ensure the code is clean and well-documented
4. Open a pull request describing what you changed and why
Please keep pull requests focused and avoid mixing unrelated changes.
---
## Credits
Built and maintained by **[@direkturcrypto](https://twitter.com/direkturcrypto)**.
- **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.
View File
+177 -5
View File
@@ -1,18 +1,22 @@
{
"name": "polymarket-copy",
"name": "polymarket-terminal",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "polymarket-copy",
"name": "polymarket-terminal",
"version": "1.0.0",
"hasInstallScript": true,
"license": "ISC",
"dependencies": {
"@polymarket/clob-client": "^4.7.3",
"blessed": "^0.1.81",
"dotenv": "^16.4.7",
"ethers": "^5.8.0",
"global-agent": "^4.1.2",
"https-proxy-agent": "^7.0.6",
"undici": "^7.22.0",
"ws": "^8.19.0"
},
"devDependencies": {
@@ -913,6 +917,15 @@
"integrity": "sha512-H7wUZRn8WpTq9jocdxQ2c8x2sKo9ZVmzfRE13GiNJXfp7NcKYEdvl3vspKjXox6RIG2VtaRe4JFvxG4rqp2Zuw==",
"license": "MIT"
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
@@ -1083,7 +1096,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -1097,6 +1109,40 @@
}
}
},
"node_modules/define-data-property": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0",
"es-errors": "^1.3.0",
"gopd": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/define-properties": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
"license": "MIT",
"dependencies": {
"define-data-property": "^1.0.1",
"has-property-descriptors": "^1.0.0",
"object-keys": "^1.1.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -1198,6 +1244,18 @@
"node": ">= 0.4"
}
},
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ethereum-cryptography": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/ethereum-cryptography/-/ethereum-cryptography-2.2.1.tgz",
@@ -1395,6 +1453,37 @@
"node": ">= 6"
}
},
"node_modules/global-agent": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/global-agent/-/global-agent-4.1.2.tgz",
"integrity": "sha512-CvUwIRwmDHu33Y71P8v1Pr8yo5/U2inzrdRNEtPdiYKBd8J4oQ6aDkt9WqZhMQDtAcTAl+akDTHssRYaBTTlhQ==",
"license": "BSD-3-Clause",
"dependencies": {
"globalthis": "^1.0.2",
"matcher": "^4.0.0",
"semver": "^7.3.5",
"serialize-error": "^8.1.0"
},
"engines": {
"node": ">=10.0"
}
},
"node_modules/globalthis": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
"integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
"license": "MIT",
"dependencies": {
"define-properties": "^1.2.1",
"gopd": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -1417,6 +1506,18 @@
"node": ">=4"
}
},
"node_modules/has-property-descriptors": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@@ -1477,6 +1578,19 @@
"minimalistic-crypto-utils": "^1.0.1"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/ignore-by-default": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
@@ -1552,6 +1666,21 @@
"integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==",
"license": "MIT"
},
"node_modules/matcher": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/matcher/-/matcher-4.0.0.tgz",
"integrity": "sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ==",
"license": "MIT",
"dependencies": {
"escape-string-regexp": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -1620,7 +1749,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nodemon": {
@@ -1662,6 +1790,15 @@
"node": ">=0.10.0"
}
},
"node_modules/object-keys": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
@@ -1711,7 +1848,6 @@
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -1720,6 +1856,21 @@
"node": ">=10"
}
},
"node_modules/serialize-error": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-8.1.0.tgz",
"integrity": "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==",
"license": "MIT",
"dependencies": {
"type-fest": "^0.20.2"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/simple-update-notifier": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
@@ -1800,6 +1951,18 @@
"integrity": "sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw==",
"license": "Unlicense"
},
"node_modules/type-fest": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/undefsafe": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
@@ -1807,6 +1970,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/undici": {
"version": "7.22.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz",
"integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
+19 -2
View File
@@ -7,12 +7,26 @@
"scripts": {
"start": "node src/index.js",
"dev": "nodemon --ignore 'data/*.json' src/index.js",
"bot": "node src/bot.js",
"bot-sim": "DRY_RUN=true node src/bot.js",
"bot-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/bot.js",
"mm": "DRY_RUN=false node src/mm.js",
"mm-sim": "DRY_RUN=true node src/mm.js",
"mm-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/mm.js",
"mm-bot": "node src/mm-bot.js",
"mm-bot-sim": "DRY_RUN=true node src/mm-bot.js",
"mm-bot-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/mm-bot.js",
"sniper": "DRY_RUN=false node src/sniper.js",
"sniper-sim": "DRY_RUN=true node src/sniper.js",
"sniper-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/sniper.js"
"sniper-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/sniper.js",
"sniper-tui": "DRY_RUN=false node src/sniper-tui.js",
"sniper-tui-sim": "DRY_RUN=true node src/sniper-tui.js",
"sniper-tui-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/sniper-tui.js",
"maker-mm": "DRY_RUN=false node src/maker-mm.js",
"maker-mm-sim": "DRY_RUN=true node src/maker-mm.js",
"maker-mm-bot": "node src/maker-mm-bot.js",
"maker-mm-bot-sim": "DRY_RUN=true node src/maker-mm-bot.js",
"postinstall": "node scripts/patch-clob-client.cjs"
},
"keywords": [
"polymarket",
@@ -26,9 +40,12 @@
"blessed": "^0.1.81",
"dotenv": "^16.4.7",
"ethers": "^5.8.0",
"global-agent": "^4.1.2",
"https-proxy-agent": "^7.0.6",
"undici": "^7.22.0",
"ws": "^8.19.0"
},
"devDependencies": {
"nodemon": "^3.1.9"
}
}
}
+47
View File
@@ -0,0 +1,47 @@
/**
* PM2 config — Copy Trade Bot
*
* Usage (from project root):
* pm2 start pm2/copy.config.cjs # live trading
* pm2 start pm2/copy.config.cjs --env sim # simulation / dry-run
*
* pm2 logs polymarket-copy
* pm2 restart polymarket-copy
* pm2 stop polymarket-copy
* pm2 delete polymarket-copy
*/
const path = require('path');
const root = path.join(__dirname, '..');
module.exports = {
apps: [
{
name: 'polymarket-copy',
script: path.join(root, 'src/bot.js'),
interpreter: 'node',
// Live trading (default)
env: {
NODE_ENV: 'production',
DRY_RUN: 'false',
},
// Simulation: pm2 start pm2/copy.config.cjs --env sim
env_sim: {
NODE_ENV: 'production',
DRY_RUN: 'true',
},
out_file: path.join(root, 'logs/copy-out.log'),
error_file: path.join(root, 'logs/copy-error.log'),
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
merge_logs: true,
restart_delay: 5000,
max_restarts: 10,
min_uptime: '10s',
max_memory_restart: '256M',
stop_exit_codes: [0],
},
],
};
+47
View File
@@ -0,0 +1,47 @@
/**
* PM2 config — Market Maker Bot
*
* Usage (from project root):
* pm2 start pm2/mm.config.cjs # live trading
* pm2 start pm2/mm.config.cjs --env sim # simulation / dry-run
*
* pm2 logs polymarket-mm
* pm2 restart polymarket-mm
* pm2 stop polymarket-mm
* pm2 delete polymarket-mm
*/
const path = require('path');
const root = path.join(__dirname, '..');
module.exports = {
apps: [
{
name: 'polymarket-mm',
script: path.join(root, 'src/mm-bot.js'),
interpreter: 'node',
// Live trading (default)
env: {
NODE_ENV: 'production',
DRY_RUN: 'false',
},
// Simulation: pm2 start pm2/mm.config.cjs --env sim
env_sim: {
NODE_ENV: 'production',
DRY_RUN: 'true',
},
out_file: path.join(root, 'logs/mm-out.log'),
error_file: path.join(root, 'logs/mm-error.log'),
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
merge_logs: true,
restart_delay: 5000,
max_restarts: 10,
min_uptime: '10s',
max_memory_restart: '256M',
stop_exit_codes: [0],
},
],
};
+114
View File
@@ -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 ✅');
+149
View File
@@ -0,0 +1,149 @@
/**
* bot.js — PM2 / VPS entry point (no TUI)
*
* Plain-text stdout output, compatible with:
* pm2 start ecosystem.config.cjs
* pm2 logs polymarket-copy
*/
import config, { validateConfig } from './config/index.js';
import { initClient, getUsdcBalance, getClient } from './services/client.js';
import { executeBuy, executeSell } from './services/executor.js';
import { checkAndRedeemPositions } from './services/redeemer.js';
import { getOpenPositions } from './services/position.js';
import { startWsWatcher, stopWsWatcher } from './services/wsWatcher.js';
import { getSimStats } from './utils/simStats.js';
import logger from './utils/logger.js';
logger.interceptConsole(); // strip auth headers from CLOB axios error dumps
// ── Handle a trade event from WebSocket ───────────────────────────────────────
async function handleTrade(trade) {
try {
if (trade.type === 'BUY') await executeBuy(trade);
if (trade.type === 'SELL') await executeSell(trade);
} catch (err) {
logger.error(`Error processing trade ${trade.id}: ${err.message}`);
}
}
// ── Periodic status log (replaces TUI right panel) ────────────────────────────
async function printStatus() {
try {
const balance = await getUsdcBalance();
const positions = getOpenPositions();
logger.info(`--- Status | Balance: $${balance.toFixed(2)} USDC | Open positions: ${positions.length} ---`);
for (const pos of positions) {
let pnlStr = '';
try {
const client = getClient();
const mp = await client.getMidpoint(pos.tokenId);
const mid = parseFloat(mp?.mid ?? mp ?? '0');
if (mid > 0) {
const pnl = (mid - pos.avgBuyPrice) * pos.shares;
const sign = pnl >= 0 ? '+' : '';
const pct = pos.totalCost > 0 ? ((pnl / pos.totalCost) * 100).toFixed(1) : '0.0';
pnlStr = ` | unrealized ${sign}$${pnl.toFixed(2)} (${sign}${pct}%)`;
}
} catch { /* price unavailable */ }
const name = (pos.market || pos.tokenId || '').substring(0, 50);
logger.info(
` [${pos.outcome || '?'}] ${name}` +
` | ${pos.shares.toFixed(4)} sh @ $${pos.avgBuyPrice.toFixed(4)}` +
` | spent $${(pos.totalCost || 0).toFixed(2)}${pnlStr}`,
);
}
if (config.dryRun) {
const s = getSimStats();
if (s.totalBuys > 0 || s.totalResolved > 0) {
const rate = s.totalResolved > 0
? `${((s.wins / s.totalResolved) * 100).toFixed(0)}% win`
: 'no resolved yet';
logger.info(
` [SIM] ${s.totalBuys} buys tracked | ${s.wins}W/${s.losses}L (${rate})` +
` | realized P&L: $${(s.closedPnl || 0).toFixed(2)}`,
);
}
}
} catch (err) {
logger.warn(`Status check error: ${err.message}`);
}
}
// ── Redeemer loop ─────────────────────────────────────────────────────────────
async function redeemerLoop() {
try {
await checkAndRedeemPositions();
} catch (err) {
logger.error('Redeemer loop error:', err.message);
}
}
// ── Main ──────────────────────────────────────────────────────────────────────
async function main() {
try {
validateConfig();
} catch (err) {
logger.error(err.message);
process.exit(1);
}
const mode = config.dryRun ? 'SIMULATION' : 'LIVE TRADING';
logger.info(`=== Polymarket Copy Trade [${mode}] ===`);
logger.info(`Trader : ${config.traderAddress}`);
logger.info(`Proxy wallet : ${config.proxyWallet}`);
logger.info(`Size mode : ${config.sizeMode} (${config.sizePercent}%)`);
logger.info(`Min trade : $${config.minTradeSize}`);
logger.info(`Max position : $${config.maxPositionSize} per market`);
logger.info(`Auto sell : ${config.autoSellEnabled ? `ON (+${config.autoSellProfitPercent}%)` : 'OFF'}`);
logger.info(`Sell mode : ${config.sellMode}`);
logger.info(`Min time left: ${config.minMarketTimeLeft}s`);
logger.info('==========================================');
try {
await initClient();
} catch (err) {
logger.error('Failed to initialize CLOB client:', err.message);
process.exit(1);
}
try {
const balance = await getUsdcBalance();
logger.money(`USDC.e Balance: $${balance.toFixed(2)}`);
} catch (err) {
logger.warn('Could not fetch balance:', err.message);
}
logger.success(
config.dryRun
? 'Simulation started — watching trader in real-time...'
: 'Bot started — watching trader in real-time...',
);
startWsWatcher(handleTrade);
await redeemerLoop();
const redeemerInterval = setInterval(redeemerLoop, config.redeemInterval);
// Print status every 60 seconds
const statusInterval = setInterval(printStatus, 60_000);
const shutdown = () => {
logger.info('Shutting down...');
stopWsWatcher();
clearInterval(redeemerInterval);
clearInterval(statusInterval);
setTimeout(() => process.exit(0), 300);
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}
main().catch((err) => {
logger.error('Fatal error:', err.message);
process.exit(1);
});
+119 -16
View File
@@ -46,31 +46,122 @@ const config = {
maxRetries: 5,
retryDelay: 3000,
// Skip buy if market closes within this many seconds (default 5 minutes)
minMarketTimeLeft: parseInt(process.env.MIN_MARKET_TIME_LEFT || '300', 10),
// Seconds to wait for a GTC limit order to fill when FAK finds no liquidity
// (happens when copying trades into "next market" before sellers arrive)
gtcFallbackTimeout: parseInt(process.env.GTC_FALLBACK_TIMEOUT || '60', 10),
// ── Market Maker ──────────────────────────────────────────────
mmAssets: (process.env.MM_ASSETS || 'btc')
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean),
mmDuration: process.env.MM_DURATION || '5m', // '5m' or '15m'
mmTradeSize: parseFloat(process.env.MM_TRADE_SIZE || '5'), // USDC per side
mmSellPrice: parseFloat(process.env.MM_SELL_PRICE || '0.60'), // limit sell target
mmCutLossTime: parseInt( process.env.MM_CUT_LOSS_TIME || '60', 10), // seconds before close
mmMarketKeyword: process.env.MM_MARKET_KEYWORD || 'Bitcoin Up or Down',
mmEntryWindow: parseInt( process.env.MM_ENTRY_WINDOW || '45', 10), // max secs after open
mmPollInterval: parseInt( process.env.MM_POLL_INTERVAL || '10', 10) * 1000,
mmAssets: (process.env.MM_ASSETS || 'btc')
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean),
mmDuration: process.env.MM_DURATION || '5m', // '5m' or '15m'
mmTradeSize: parseFloat(process.env.MM_TRADE_SIZE || '5'), // USDC per side
mmSellPrice: parseFloat(process.env.MM_SELL_PRICE || '0.60'), // limit sell target
mmCutLossTime: parseInt(process.env.MM_CUT_LOSS_TIME || '60', 10), // seconds before close
mmMarketKeyword: process.env.MM_MARKET_KEYWORD || 'Bitcoin Up or Down',
mmEntryWindow: parseInt(process.env.MM_ENTRY_WINDOW || '45', 10), // max secs after open
mmPollInterval: parseInt(process.env.MM_POLL_INTERVAL || '10', 10) * 1000,
mmAdaptiveCL: process.env.MM_ADAPTIVE_CL !== 'false', // true = adaptive, false = legacy immediate market-sell
mmAdaptiveMinCombined: parseFloat(process.env.MM_ADAPTIVE_MIN_COMBINED || '1.20'), // min combined sell (both legs) to qualify for limit
mmAdaptiveMonitorSec: parseInt(process.env.MM_ADAPTIVE_MONITOR_SEC || '5', 10),
// ── Defensive Pivot (5m markets only) ─────────────────────────
// When NEITHER side fills within timeout, enter defensive mode:
// At 30s before close, if worst side < threshold → market sell worst, keep best
// Otherwise merge back to USDC (zero P&L)
mmDefensiveEnabled: process.env.MM_DEFENSIVE_ENABLED !== 'false', // default on
mmDefensiveTimeout: parseInt(process.env.MM_DEFENSIVE_TIMEOUT || '120', 10), // secs without fill → defensive
mmDefensiveWorstThreshold: parseFloat(process.env.MM_DEFENSIVE_WORST_THRESHOLD || '0.10'), // sell worst if price < this
// ── Recovery Buy (after cut-loss) ─────────────────────────────
// When enabled: after cutting loss, monitor prices for 10s and
// market-buy the dominant side if it's above threshold and rising/stable.
mmRecoveryBuy: process.env.MM_RECOVERY_BUY === 'true',
mmRecoveryBuy: process.env.MM_RECOVERY_BUY === 'true',
mmRecoveryThreshold: parseFloat(process.env.MM_RECOVERY_THRESHOLD || '0.70'), // min price to qualify
mmRecoverySize: parseFloat(process.env.MM_RECOVERY_SIZE || '0'), // 0 = use mmTradeSize
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 ───────────────────────────────────────────
// Places tiny GTC limit BUY orders at a very low price on each side
// of ETH/SOL/XRP 5-minute markets — catches panic dumps near $0.
// 3-tier strategy: places GTC limit BUY orders at 3c, 2c, and 1c
// Tier 1 (3c): smallest size | Tier 2 (2c): medium size | Tier 3 (1c): largest size
// Min 5 shares per tier, total = SNIPER_MAX_SHARES_PER_SIDE
sniperAssets: (process.env.SNIPER_ASSETS || 'eth,sol,xrp')
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean),
sniperPrice: parseFloat(process.env.SNIPER_PRICE || '0.01'), // $ per share
sniperShares: parseFloat(process.env.SNIPER_SHARES || '5'), // shares per side
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean),
sniperTierPrices: [
parseFloat(process.env.SNIPER_TIER1_PRICE || '0.03'), // high price, small size
parseFloat(process.env.SNIPER_TIER2_PRICE || '0.02'), // mid price, medium size
parseFloat(process.env.SNIPER_TIER3_PRICE || '0.01'), // low price, large size
],
sniperMaxShares: parseFloat(process.env.SNIPER_MAX_SHARES || '15'), // max total per side
sniperMinSharesPerTier: 5, // minimum shares for each tier
// ── Sniper Sizing Multiplier (UTC+8) ──────────────────────────
// Time-based bet sizing multiplier. Format: HH:MM-HH:MM:factor,...
// Example: SNIPER_MULTIPLIERS=21:00-00:00:1.41,06:00-12:00:0.85
// Default multiplier outside any window = 1.0
sniperMultipliers: (() => {
const raw = process.env.SNIPER_MULTIPLIERS || '';
if (!raw.trim()) return [];
return raw.split(',').map((s) => s.trim()).filter(Boolean).map((entry) => {
const m = entry.match(/^(\d{1,2}:\d{2})\s*[-]\s*(\d{1,2}:\d{2}):(\d+\.?\d*)$/);
if (!m) return null;
return { start: m[1], end: m[2], multiplier: parseFloat(m[3]) };
}).filter(Boolean);
})(),
// ── Sniper Pause After Win ───────────────────────────────────
// Number of rounds (5-min slots) to pause an asset after a win is detected.
sniperPauseRoundsAfterWin: parseInt(process.env.SNIPER_PAUSE_ROUNDS_AFTER_WIN || '3', 10),
// ── Sniper Schedule (UTC+8) ────────────────────────────────────
// Per-asset session windows. Format: SNIPER_SCHEDULE_{ASSET}=HH:MM-HH:MM,HH:MM-HH:MM
// Assets without a schedule are always active.
sniperSchedule: (() => {
const schedule = {};
const prefix = 'SNIPER_SCHEDULE_';
for (const [key, value] of Object.entries(process.env)) {
if (key.startsWith(prefix) && value) {
const asset = key.slice(prefix.length).toLowerCase();
schedule[asset] = value;
}
}
return schedule;
})(),
// ── Proxy (Polymarket API only, NOT Polygon RPC) ──────────────
// Supports HTTP/HTTPS. Example: http://user:pass@host:port
proxyUrl: process.env.PROXY_URL || '',
};
// Validation for copy-trade bot
@@ -100,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;
+1
View File
@@ -11,6 +11,7 @@ import logger from './utils/logger.js';
// ── Dashboard init (before any log output) ────────────────────────────────────
initDashboard();
logger.setOutput(appendLog);
logger.interceptConsole(); // strip auth headers from CLOB client axios error dumps
// ── Handle a trade event from WebSocket ───────────────────────────────────────
async function handleTrade(trade) {
+290
View File
@@ -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);
+197
View File
@@ -0,0 +1,197 @@
/**
* mm-bot.js — Market Maker, PM2 / VPS entry point (no TUI)
*
* Plain-text stdout output, compatible with:
* pm2 start ecosystem.config.cjs --only polymarket-mm
* pm2 logs polymarket-mm
*/
// Set proxy before any network calls
import './utils/proxy-patch.cjs';
import { validateMMConfig } from './config/index.js';
import config from './config/index.js';
import logger from './utils/logger.js';
import { initClient, getClient, getUsdcBalance } from './services/client.js';
import { startMMDetector, stopMMDetector } from './services/mmDetector.js';
import { executeMMStrategy, getActiveMMPositions } from './services/mmExecutor.js';
import { mmFillWatcher } from './services/mmWsFillWatcher.js';
import { cleanupOpenPositions, redeemMMPositions, MIN_SHARES_PER_SIDE } from './services/ctf.js';
logger.interceptConsole();
// ── Validate config ────────────────────────────────────────────────────────────
try {
validateMMConfig();
} catch (err) {
logger.error(`Config error: ${err.message}`);
process.exit(1);
}
// ── Init CLOB client ──────────────────────────────────────────────────────────
try {
await initClient();
} catch (err) {
logger.error(`Client init error: ${err.message}`);
process.exit(1);
}
// ── Validate MM_TRADE_SIZE minimum ────────────────────────────────────────────
if (config.mmTradeSize < MIN_SHARES_PER_SIDE) {
logger.error(
`MM_TRADE_SIZE=${config.mmTradeSize} is below Polymarket minimum of ${MIN_SHARES_PER_SIDE} shares. ` +
`Set MM_TRADE_SIZE ≥ ${MIN_SHARES_PER_SIDE} in your .env and restart.`,
);
process.exit(1);
}
// ── Start WebSocket fill watcher for real-time order detection ────────────────
mmFillWatcher.start();
// ── Cleanup leftover positions on startup ─────────────────────────────────────
try {
await cleanupOpenPositions(getClient());
} catch (err) {
logger.warn(`MM: startup cleanup failed (non-fatal): ${err.message}`);
}
// ── Periodic status log (replaces TUI right panel) ────────────────────────────
async function printStatus() {
try {
let balanceStr = 'SIM';
if (!config.dryRun) {
try { balanceStr = `$${(await getUsdcBalance()).toFixed(2)} USDC`; } catch { balanceStr = 'N/A'; }
}
const positions = getActiveMMPositions();
const mode = config.dryRun ? 'SIMULATION' : 'LIVE';
logger.info(
`--- MM Status [${mode}] | Balance: ${balanceStr} | Active positions: ${positions.length} ---`,
);
for (const pos of positions) {
const assetTag = pos.asset ? `[${pos.asset.toUpperCase()}] ` : '';
const label = pos.question.substring(0, 50);
const msLeft = new Date(pos.endTime).getTime() - Date.now();
const secsLeft = Math.max(0, Math.round(msLeft / 1000));
const timeStr = secsLeft > 60
? `${Math.floor(secsLeft / 60)}m${secsLeft % 60}s left`
: `${secsLeft}s left`;
const yFill = pos.yes.filled
? `FILLED @ $${pos.yes.fillPrice?.toFixed(3)}`
: `waiting $${config.mmSellPrice}`;
const nFill = pos.no.filled
? `FILLED @ $${pos.no.fillPrice?.toFixed(3)}`
: `waiting $${config.mmSellPrice}`;
logger.info(
` ${assetTag}${label} | ${pos.status} | ${timeStr}` +
` | YES ${pos.yes.shares?.toFixed(3)}sh@$${pos.yes.entryPrice?.toFixed(3)}${yFill}` +
` | NO ${pos.no.shares?.toFixed(3)}sh@$${pos.no.entryPrice?.toFixed(3)}${nFill}`,
);
}
} catch (err) {
logger.warn(`Status check error: ${err.message}`);
}
}
// ── Market handler with per-asset queue ───────────────────────────────────────
// Each asset can hold one pending market while its current position is active.
const pendingByAsset = new Map(); // asset → market
async function runStrategy(market) {
try {
await executeMMStrategy(market);
} catch (err) {
logger.error(`MM strategy error (${market.asset?.toUpperCase()}): ${err.message}`);
}
// After position clears, execute the queued market for this asset if still valid
const queued = pendingByAsset.get(market.asset);
if (queued) {
pendingByAsset.delete(market.asset);
const endMs = new Date(queued.endTime).getTime();
const secsLeft = Math.round((endMs - Date.now()) / 1000);
if (secsLeft > config.mmCutLossTime) {
logger.success(
`MM[${market.asset?.toUpperCase()}]: position cleared — ` +
`executing queued "${queued.question.substring(0, 40)}" (${secsLeft}s left)`,
);
runStrategy(queued); // non-blocking
} else {
logger.warn(
`MM[${market.asset?.toUpperCase()}]: queued market "${queued.question.substring(0, 40)}" ` +
`expired (${secsLeft}s left) — discarding`,
);
}
}
}
async function handleNewMarket(market) {
const active = getActiveMMPositions();
const isAssetBusy = active.some((p) => p.asset === market.asset);
if (isAssetBusy) {
pendingByAsset.set(market.asset, market);
logger.warn(
`MM[${market.asset?.toUpperCase()}]: queued "${market.question.substring(0, 40)}" — ` +
`will enter after current ${market.asset?.toUpperCase()} position clears`,
);
return;
}
runStrategy(market); // non-blocking
}
// ── Timers ────────────────────────────────────────────────────────────────────
// Print status every 60 seconds
const statusTimer = setInterval(printStatus, 60_000);
// Redeemer: run immediately then every redeemInterval
redeemMMPositions().catch((err) => logger.error('MM redeemer error:', err.message));
const redeemTimer = setInterval(
() => redeemMMPositions().catch((err) => logger.error('MM redeemer error:', err.message)),
config.redeemInterval,
);
// ── Graceful shutdown ─────────────────────────────────────────────────────────
function shutdown() {
logger.warn('MM: shutting down...');
stopMMDetector();
mmFillWatcher.stop();
clearInterval(statusTimer);
clearInterval(redeemTimer);
setTimeout(() => process.exit(0), 300);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
// ── Start ─────────────────────────────────────────────────────────────────────
const mode = config.dryRun ? 'SIMULATION' : 'LIVE';
logger.info(`=== Market Maker [${mode}] ===`);
logger.info(`Assets : ${config.mmAssets.join(', ').toUpperCase()}`);
logger.info(`Duration : ${config.mmDuration}`);
logger.info(`Trade size: $${config.mmTradeSize} per side`);
logger.info(`Sell @ : $${config.mmSellPrice}`);
logger.info(`Cut loss : ${config.mmCutLossTime}s before close`);
logger.info(`Keyword : ${config.mmMarketKeyword}`);
logger.info(`Entry win : ${config.mmEntryWindow}s after open`);
logger.info('==========================================');
startMMDetector(handleNewMarket);
logger.success(`MM bot started — watching for ${config.mmDuration} ${config.mmAssets.join('/')} markets...`);
+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);
+22 -8
View File
@@ -1,16 +1,28 @@
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
* Auto-derives API credentials if not provided in .env
*/
export async function initClient() {
// ── Set up proxy (if configured) BEFORE any Polymarket API calls ──
await setupAxiosProxy();
// Test proxy connectivity
const proxyOk = await testProxy();
if (!proxyOk) {
logger.error('Proxy test failed — cannot reach Polymarket. Exiting.');
process.exit(1);
}
logger.info('Initializing Polymarket CLOB client...');
signer = new Wallet(config.privateKey);
@@ -68,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);
+348 -26
View File
@@ -12,6 +12,7 @@ import { ethers } from 'ethers';
import config from '../config/index.js';
import { getSigner, getPolygonProvider } from './client.js';
import logger from '../utils/logger.js';
import { proxyFetch } from '../utils/proxy.js';
// ── Contract addresses (Polygon mainnet) ──────────────────────────────────────
@@ -59,8 +60,8 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
* Strips the lengthy internal stack info that ethers appends.
*/
function parseOnchainError(err) {
const msg = err?.message || String(err);
const reason = err?.reason || err?.error?.reason || '';
const msg = err?.message || String(err);
const reason = err?.reason || err?.error?.reason || '';
if (msg.includes('insufficient funds') || msg.includes('insufficient balance'))
return 'Insufficient MATIC balance for gas fees';
@@ -72,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'))
@@ -100,28 +105,61 @@ 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.
*/
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(() => {});
_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;
// Track gas price multiplier for replacement transactions
let gasMultiplier = 1;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
const provider = await getPolygonProvider();
const wallet = getSigner().connect(provider);
const safe = new ethers.Contract(config.proxyWallet, SAFE_ABI, wallet);
const wallet = getSigner().connect(provider);
const safe = new ethers.Contract(config.proxyWallet, SAFE_ABI, wallet);
const nonce = await safe.nonce();
@@ -142,22 +180,42 @@ async function _doExecSafeCall(to, data, description = '') {
// Sign the raw hash with the EOA signing key (no EIP-191 prefix)
// Gnosis Safe v1.3.0 treats plain ECDSA signatures (v=27/28) on the tx hash directly
const signingKey = new ethers.utils.SigningKey(config.privateKey);
const rawSig = signingKey.signDigest(txHash);
const signature = ethers.utils.joinSignature(rawSig);
const rawSig = signingKey.signDigest(txHash);
const signature = ethers.utils.joinSignature(rawSig);
// Polygon requires maxPriorityFeePerGas ≥ 25 Gwei.
// Some RPC nodes (e.g. lava.build) return a stale low estimate, so we enforce a floor.
const feeData = await provider.getFeeData();
const MIN_TIP = ethers.utils.parseUnits('30', 'gwei');
const gasTip = feeData.maxPriorityFeePerGas?.gt(MIN_TIP) ? feeData.maxPriorityFeePerGas : MIN_TIP;
const gasFeeCap = feeData.maxFeePerGas ?? ethers.utils.parseUnits('500', 'gwei');
// Use HIGH gas prices for fast inclusion — especially important for redeem.
const feeData = await provider.getFeeData();
// Increase gas price on retry to replace pending transaction
// Base: use 150% of estimated fees for fast inclusion
// Multiplier on retry: 1.5x → 3x → 6x
const BASE_MULTIPLIER = 1.5;
const currentMultiplier = BASE_MULTIPLIER * gasMultiplier;
// Priority fee: minimum 50 Gwei, or 150%+ of estimate
const MIN_TIP = ethers.utils.parseUnits('50', 'gwei');
const estimatedTip = feeData.maxPriorityFeePerGas || MIN_TIP;
const gasTip = estimatedTip.mul(Math.ceil(currentMultiplier * 100)).div(100).gt(MIN_TIP)
? estimatedTip.mul(Math.ceil(currentMultiplier * 100)).div(100)
: MIN_TIP;
// Max fee: use high ceiling to ensure inclusion
const MAX_FEE_CAP = ethers.utils.parseUnits('1000', 'gwei');
const estimatedMaxFee = feeData.maxFeePerGas || ethers.utils.parseUnits('500', 'gwei');
const gasFeeCap = estimatedMaxFee.mul(Math.ceil(currentMultiplier * 100)).div(100).gt(MAX_FEE_CAP)
? MAX_FEE_CAP
: estimatedMaxFee.mul(Math.ceil(currentMultiplier * 100)).div(100);
const txOpts = { maxPriorityFeePerGas: gasTip, maxFeePerGas: gasFeeCap };
if (gasLimit) txOpts.gasLimit = gasLimit;
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();
@@ -168,7 +226,14 @@ async function _doExecSafeCall(to, data, description = '') {
const friendly = parseOnchainError(err);
if (attempt < MAX_RETRIES) {
logger.warn(`MM: transaction failed (attempt ${attempt}/${MAX_RETRIES}): ${friendly} — retrying in ${RETRY_DELAY / 1000}s...`);
// Increase gas multiplier for replacement transaction
if (err?.message?.includes('replacement transaction underpriced') ||
err?.message?.includes('Gas price too low to replace')) {
gasMultiplier *= 2;
logger.warn(`MM: transaction failed (attempt ${attempt}/${MAX_RETRIES}): ${friendly} — increasing gas ${gasMultiplier}x and retrying...`);
} else {
logger.warn(`MM: transaction failed (attempt ${attempt}/${MAX_RETRIES}): ${friendly} — retrying in ${RETRY_DELAY / 1000}s...`);
}
await sleep(RETRY_DELAY);
}
}
@@ -180,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');
}
@@ -201,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 ───────────────────────────────────────────────────────
/**
@@ -281,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', [
@@ -292,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;
}
@@ -332,7 +466,7 @@ export async function cleanupOpenPositions(clobClient) {
let dataPositions = [];
try {
const url = `https://data-api.polymarket.com/positions?user=${config.proxyWallet}`;
const resp = await fetch(url);
const resp = await proxyFetch(url);
if (resp.ok) dataPositions = await resp.json();
if (!Array.isArray(dataPositions)) dataPositions = [];
} catch (err) {
@@ -424,7 +558,7 @@ export async function redeemMMPositions() {
// 1. Query Data API for all positions held by the proxy wallet
let dataPositions = [];
try {
const resp = await fetch(`${config.dataHost}/positions?user=${config.proxyWallet}`);
const resp = await proxyFetch(`${config.dataHost}/positions?user=${config.proxyWallet}`);
if (resp.ok) dataPositions = await resp.json();
if (!Array.isArray(dataPositions)) dataPositions = [];
} catch {
@@ -441,12 +575,12 @@ export async function redeemMMPositions() {
const byCondition = new Map();
for (const pos of dataPositions) {
const cid = pos.conditionId || pos.condition_id;
const tid = pos.asset || pos.tokenId || pos.token_id;
const tid = pos.asset || pos.tokenId || pos.token_id;
if (!cid || !tid) continue;
if (!byCondition.has(cid)) byCondition.set(cid, []);
byCondition.get(cid).push({
tokenId: String(tid),
size: parseFloat(pos.size || pos.currentValue || '0'),
size: parseFloat(pos.size || pos.currentValue || '0'),
});
}
@@ -495,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) {
@@ -507,3 +641,191 @@ export async function redeemMMPositions() {
logger.success(`MM redeemer: collected ${redeemed} resolved position(s)`);
}
}
// ── Sniper-specific redeemer ──────────────────────────────────────────────────
// Track conditionIds that repeatedly fail or are losses to avoid retrying every cycle
const _failedConditions = new Set();
const _skippedLosses = new Set();
// Callback invoked when a win is detected — receives conditionId
let _onWinCallback = null;
// Function to look up conditionId → { asset, yesTokenId, noTokenId }
// Injected from sniper entry point to avoid circular imports
let _getConditionInfo = null;
/**
* Register a callback to be called when a sniper win is detected.
* Callback signature: (conditionId: string) => void
*/
export function onSniperWin(cb) {
_onWinCallback = cb;
}
/**
* Register a function to look up sniper condition info (token mapping).
* Used to correctly map token balances to outcome indices.
*/
export function setSniperConditionLookup(fn) {
_getConditionInfo = fn;
}
/**
* Redeem sniper positions via Gnosis Safe.
* Only redeems WINNING positions — skip losses (they can be manually cleared).
* Runs on interval only (no startup check) to catch new winners.
*/
export async function redeemSniperPositions() {
// 1. Query Data API for all positions held by the proxy wallet
let dataPositions = [];
try {
const resp = await proxyFetch(`${config.dataHost}/positions?user=${config.proxyWallet}`);
if (!resp.ok) {
logger.warn(`SNIPER redeemer: Data API returned ${resp.status} — will retry`);
return;
}
dataPositions = await resp.json();
if (!Array.isArray(dataPositions)) dataPositions = [];
} catch (err) {
logger.warn(`SNIPER redeemer: Data API fetch failed — ${err.message}`);
return;
}
if (dataPositions.length === 0) return;
const provider = await getPolygonProvider();
const ctf = new ethers.Contract(CTF_ADDRESS, CTF_ABI, provider);
const ctfIface = new ethers.utils.Interface(CTF_ABI);
// Group tokens by conditionId
const byCondition = new Map();
for (const pos of dataPositions) {
const cid = pos.conditionId || pos.condition_id;
const tid = pos.asset || pos.tokenId || pos.token_id;
if (!cid || !tid) continue;
if (!byCondition.has(cid)) byCondition.set(cid, []);
byCondition.get(cid).push({
tokenId: String(tid),
size: parseFloat(pos.size || pos.currentValue || '0'),
});
}
let redeemed = 0;
let skippedUnresolved = 0;
let skippedLosses = 0;
let skippedNoBalance = 0;
for (const [conditionId, tokens] of byCondition) {
// Fast skip: conditionIds that previously failed on-chain or confirmed losses
if (_failedConditions.has(conditionId)) continue;
if (_skippedLosses.has(conditionId)) {
skippedLosses++;
continue;
}
try {
// Skip unresolved markets (fast check)
const denominator = await ctf.payoutDenominator(conditionId);
if (denominator.isZero()) {
skippedUnresolved++;
continue;
}
// Check actual on-chain token balances (positions API can lag)
const balances = await Promise.all(
tokens.map(({ tokenId }) =>
ctf.balanceOf(config.proxyWallet, tokenId)
.then((b) => parseFloat(ethers.utils.formatUnits(b, 6)))
)
);
const totalShares = balances.reduce((a, b) => a + b, 0);
if (totalShares < 0.001) {
skippedNoBalance++;
continue;
}
// Check outcome via payoutNumerators — which outcome index won?
const payoutNums = await Promise.all(
[0, 1].map((i) =>
ctf.payoutNumerators(conditionId, i).then((n) => n.toNumber())
)
);
const denom = denominator.toNumber();
const payoutFractions = payoutNums.map((n) => n / denom);
// Determine winning outcome index (the one with payoutFraction > 0)
const winningOutcome = payoutFractions[0] > 0 ? 0 : payoutFractions[1] > 0 ? 1 : -1;
const label = conditionId.slice(0, 12) + '...';
// Map token balances to outcome indices using sniper's token mapping.
// yesTokenId = outcome 0 (clobTokenIds[0]), noTokenId = outcome 1
const sniperInfo = _getConditionInfo ? _getConditionInfo(conditionId) : null;
// Build outcome→balance mapping (keyed by outcome index, not array index)
const outcomeBalances = [0, 0];
if (sniperInfo) {
for (let i = 0; i < tokens.length; i++) {
if (tokens[i].tokenId === sniperInfo.yesTokenId) outcomeBalances[0] = balances[i];
else if (tokens[i].tokenId === sniperInfo.noTokenId) outcomeBalances[1] = balances[i];
}
} else {
// No sniper mapping — skip (not a sniper position)
continue;
}
// Win = we hold shares on the winning outcome side
const winShares = winningOutcome >= 0 ? outcomeBalances[winningOutcome] : 0;
const isWin = winShares > 0;
const expectedUsdc = outcomeBalances.reduce(
(sum, shares, i) => sum + shares * (payoutFractions[i] ?? 0), 0
);
// SNIPER: only redeem WINNERS — cache losses to skip next time
if (!isWin) {
_skippedLosses.add(conditionId);
if (config.dryRun) {
logger.info(`SNIPER[SIM] skip loss: ${label} — outcome=${winningOutcome}, win_shares=0 (cached)`);
} else {
logger.info(`SNIPER redeemer: skip loss ${label} — outcome=${winningOutcome}, no shares on winner`);
}
continue;
}
// Track win for pause-after-win (notify via callback)
if (_onWinCallback) _onWinCallback(conditionId);
if (config.dryRun) {
logger.money(`SNIPER[SIM] redeem: ${label}${winShares.toFixed(3)} shares on outcome ${winningOutcome} → ~$${expectedUsdc.toFixed(2)} USDC (WIN)`);
continue;
}
logger.info(`SNIPER redeemer: ${label} resolved WIN — outcome ${winningOutcome}, ${winShares.toFixed(3)} shares → ~$${expectedUsdc.toFixed(2)} USDC`);
// Call redeemPositions through Safe — winners only
const data = ctfIface.encodeFunctionData('redeemPositions', [
USDC_ADDRESS,
ethers.constants.HashZero,
conditionId,
[1, 2],
]);
const receipt = await execSafeCall(CTF_ADDRESS, data, `redeemPositions ${label}`, { priority: false });
logger.money(`SNIPER redeemer: redeemed ${label} → ~$${expectedUsdc.toFixed(2)} USDC ✅ | tx: ${receipt.transactionHash}`);
redeemed++;
} catch (err) {
const friendly = parseOnchainError(err);
logger.error(`SNIPER redeemer: failed ${conditionId.slice(0, 12)}... — ${friendly}`);
// Don't retry this conditionId next cycle — it will keep failing
_failedConditions.add(conditionId);
logger.warn(`SNIPER redeemer: skipping ${conditionId.slice(0, 12)}... in future cycles`);
}
}
// Summary log
const totalSkipped = skippedUnresolved + skippedLosses + skippedNoBalance + _skippedLosses.size + _failedConditions.size;
if (redeemed > 0 || totalSkipped > 0) {
logger.info(`SNIPER redeemer: ${redeemed} redeemed, ${_skippedLosses.size} losses cached, ${skippedUnresolved} unresolved skipped`);
}
}
+248 -55
View File
@@ -1,12 +1,37 @@
import { Side, OrderType } from '@polymarket/clob-client';
import { ethers } from 'ethers';
import config from '../config/index.js';
import { getClient, getUsdcBalance } from './client.js';
import { getClient, getUsdcBalance, getPolygonProvider } from './client.js';
import { hasPosition, addPosition, getPosition, updatePosition, removePosition } from './position.js';
import { fetchMarketByTokenId } from './watcher.js';
import { placeAutoSell } from './autoSell.js';
import { ensureExchangeApproval, CTF_ADDRESS } from './ctf.js';
import { recordSimBuy } from '../utils/simStats.js';
import logger from '../utils/logger.js';
const CTF_ABI_BALANCE = ['function balanceOf(address account, uint256 id) view returns (uint256)'];
// Per-market buy queue: prevents concurrent buys for the same market.
// Each conditionId maps to the Promise tail of its queue so calls are
// chained — the next buy only starts after the previous one finishes.
const _buyQueue = new Map();
/**
* Fetch the actual on-chain ERC-1155 balance for a conditional token.
* Returns shares as a plain float (6-decimal conversion).
*/
async function getOnChainTokenBalance(tokenId) {
try {
const provider = await getPolygonProvider();
const ctf = new ethers.Contract(CTF_ADDRESS, CTF_ABI_BALANCE, provider);
const raw = await ctf.balanceOf(config.proxyWallet, tokenId);
return parseFloat(ethers.utils.formatUnits(raw, 6));
} catch (err) {
logger.warn(`Could not fetch on-chain token balance: ${err.message}`);
return null;
}
}
/**
* Calculate trade size for our entry independent of the individual fill event.
*
@@ -36,10 +61,13 @@ async function getMarketOptions(tokenId) {
const marketInfo = await fetchMarketByTokenId(tokenId);
if (marketInfo) {
return {
tickSize: String(marketInfo.minimum_tick_size || '0.01'),
negRisk: marketInfo.neg_risk || false,
conditionId: marketInfo.condition_id || '',
question: marketInfo.question || '',
tickSize: String(marketInfo.orderPriceMinTickSize || '0.01'),
negRisk: marketInfo.negRisk || false,
conditionId: marketInfo.conditionId || '',
question: marketInfo.question || '',
endDateIso: marketInfo.endDate || null,
active: marketInfo.active !== false,
acceptingOrders: marketInfo.acceptingOrders !== false,
};
}
} catch (err) {
@@ -50,23 +78,128 @@ async function getMarketOptions(tokenId) {
try {
const tickSize = await client.getTickSize(tokenId);
const negRisk = await client.getNegRisk(tokenId);
return { tickSize: String(tickSize), negRisk, conditionId: '', question: '' };
return { tickSize: String(tickSize), negRisk, conditionId: '', question: '', endDateIso: null, active: true, acceptingOrders: true };
} catch (err) {
logger.warn('Failed to get tick size from SDK, using default 0.01');
return { tickSize: '0.01', negRisk: false, conditionId: '', question: '' };
return { tickSize: '0.01', negRisk: false, conditionId: '', question: '', endDateIso: null, active: true, acceptingOrders: true };
}
}
/**
* Execute a BUY trade (copy trader's buy)
* @param {Object} trade - Trade info from watcher
* Execute a BUY trade (copy trader's buy).
* Calls are serialized per market concurrent events for the same market
* are queued and processed one at a time to prevent duplicate positions.
*/
export async function executeBuy(trade) {
export function executeBuy(trade) {
const { tokenId, conditionId } = trade;
// Resolve conditionId to use as queue key.
// getMarketOptions is a read-only fetch — safe to run outside the queue.
const queued = getMarketOptions(tokenId).then((marketOpts) => {
const effectiveConditionId = conditionId || marketOpts.conditionId;
// Chain this buy after the previous one for the same market
const prev = _buyQueue.get(effectiveConditionId) ?? Promise.resolve();
const current = prev
.then(() => _doExecuteBuy(trade, marketOpts, effectiveConditionId))
.finally(() => {
// Remove from map only if we're still the tail (no newer call queued)
if (_buyQueue.get(effectiveConditionId) === current) {
_buyQueue.delete(effectiveConditionId);
}
});
_buyQueue.set(effectiveConditionId, current);
return current;
});
return queued;
}
/**
* GTC fallback for when FAK finds no liquidity (e.g. trader buys into "next market"
* before any sellers exist). Places a GTC limit order and polls until filled or timeout.
*
* Returns { sharesFilled, costFilled } on success, or null on failure/timeout.
*/
async function _tryGtcFallback(client, tokenId, tradeSize, price, marketOpts) {
const gtcPrice = parseFloat(Math.min(price * 1.02, 0.99).toFixed(4));
const shares = parseFloat((tradeSize / gtcPrice).toFixed(4));
logger.info(`No liquidity via FAK — placing GTC limit buy: ${shares} shares @ $${gtcPrice}`);
let orderId;
try {
const resp = await client.createAndPostOrder(
{ tokenID: tokenId, side: Side.BUY, price: gtcPrice, size: shares },
{ tickSize: marketOpts.tickSize, negRisk: marketOpts.negRisk },
OrderType.GTC,
);
if (!resp?.success) {
logger.warn(`GTC fallback rejected: ${resp?.errorMsg || 'unknown'}`);
return null;
}
orderId = resp.orderID;
logger.info(`GTC order placed: ${orderId} — waiting for fill (up to ${config.gtcFallbackTimeout}s)...`);
} catch (err) {
logger.warn(`GTC fallback order failed: ${err.message}`);
return null;
}
const deadline = Date.now() + config.gtcFallbackTimeout * 1000;
const pollMs = 3000;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, pollMs));
try {
const order = await client.getOrder(orderId);
const matched = parseFloat(order?.size_matched ?? order?.matched_amount ?? '0');
const status = (order?.status ?? order?.order_status ?? '').toLowerCase();
if (matched > 0 || status === 'matched' || status === 'filled') {
const sharesFilled = matched > 0 ? matched : shares;
const costFilled = sharesFilled * gtcPrice;
logger.success(`GTC filled: ${sharesFilled.toFixed(4)} shares @ $${gtcPrice} | orderID: ${orderId}`);
return { sharesFilled, costFilled };
}
// Order gone from open orders also means it was matched
if (status === 'cancelled') {
logger.warn(`GTC order ${orderId} was cancelled externally`);
return null;
}
} catch { /* getOrder can 404 briefly — keep polling */ }
}
// Timed out — cancel the GTC
logger.warn(`GTC order ${orderId} not filled in ${config.gtcFallbackTimeout}s — cancelling`);
try { await client.cancelOrder({ orderID: orderId }); } catch { /* ignore */ }
return null;
}
/**
* Internal: the actual buy logic, guaranteed to run serially per market.
*/
async function _doExecuteBuy(trade, marketOpts, effectiveConditionId) {
const { tokenId, conditionId, market, price, size } = trade;
// Get market options first to resolve conditionId
const marketOpts = await getMarketOptions(tokenId);
const effectiveConditionId = conditionId || marketOpts.conditionId;
// ── Market expiry guard ────────────────────────────────────────────────────
if (!marketOpts.active || !marketOpts.acceptingOrders) {
logger.warn(`Market closed/not accepting orders: ${market || effectiveConditionId} — skipping buy`);
return;
}
if (marketOpts.endDateIso) {
const secsLeft = (new Date(marketOpts.endDateIso).getTime() - Date.now()) / 1000;
if (secsLeft < config.minMarketTimeLeft) {
const minsLeft = Math.max(0, Math.floor(secsLeft / 60));
const sLeft = Math.max(0, Math.floor(secsLeft % 60));
logger.warn(
`Market expires in ${minsLeft}m ${sLeft}s — below MIN_MARKET_TIME_LEFT ` +
`(${config.minMarketTimeLeft}s). Skipping buy: ${market || effectiveConditionId}`,
);
return;
}
}
// ──────────────────────────────────────────────────────────────────────────
// Check existing position and max position size cap
const existingPos = getPosition(effectiveConditionId);
@@ -90,8 +223,11 @@ export async function executeBuy(trade) {
tradeSize = Math.min(tradeSize, config.maxPositionSize);
}
if (tradeSize < config.minTradeSize) {
logger.warn(`Trade size $${tradeSize.toFixed(2)} below minimum $${config.minTradeSize}. Skipping.`);
// Polymarket enforces a hard $1 minimum per market order.
const CLOB_MIN_ORDER_USDC = 1;
const effectiveMin = Math.max(config.minTradeSize, CLOB_MIN_ORDER_USDC);
if (tradeSize < effectiveMin) {
logger.warn(`Trade size $${tradeSize.toFixed(2)} below $${effectiveMin} minimum — skipping buy`);
return;
}
@@ -131,7 +267,7 @@ export async function executeBuy(trade) {
return;
}
// Place market order with retries
// Place market order (FAK) with retries
const client = getClient();
let filled = false;
let totalSharesFilled = 0;
@@ -140,56 +276,63 @@ export async function executeBuy(trade) {
for (let attempt = 1; attempt <= config.maxRetries; attempt++) {
try {
const remainingAmount = tradeSize - totalCostFilled;
if (remainingAmount < config.minTradeSize) break;
if (remainingAmount < effectiveMin) {
if (remainingAmount > 0) logger.info(`Remaining $${remainingAmount.toFixed(2)} below $${effectiveMin} minimum — stopping`);
break;
}
logger.info(`Buy attempt ${attempt}/${config.maxRetries} | Amount: $${remainingAmount.toFixed(2)}`);
// Use FAK (fill-and-kill) to get what's available, then retry remainder
const response = await client.createAndPostMarketOrder(
{
tokenID: tokenId,
side: Side.BUY,
amount: remainingAmount,
price: Math.min(price * 1.05, 0.99), // 5% slippage allowance, max 0.99
price: Math.min(price * 1.02, 0.99), // 2% slippage, max 0.99
},
{
tickSize: marketOpts.tickSize,
negRisk: marketOpts.negRisk,
},
OrderType.FOK,
OrderType.FAK, // Fill-and-Kill: takes what's available, no full-fill requirement
);
if (response && response.success) {
logger.success(`Order placed: ${response.orderID} | Status: ${response.status}`);
const sharesFilled = parseFloat(response.takingAmount || '0');
const costFilled = parseFloat(response.makingAmount || '0');
// Check if fully filled by trying to get trade info
const takingAmount = parseFloat(response.takingAmount || '0');
const makingAmount = parseFloat(response.makingAmount || '0');
if (takingAmount > 0 || makingAmount > 0) {
totalSharesFilled += takingAmount || (remainingAmount / price);
totalCostFilled += makingAmount || remainingAmount;
if (sharesFilled > 0) {
logger.success(`Order filled: ${response.orderID} | ${sharesFilled.toFixed(4)} shares @ ~$${(costFilled / sharesFilled).toFixed(4)}`);
totalSharesFilled += sharesFilled;
totalCostFilled += costFilled || (sharesFilled * price);
filled = true;
break; // FOK either fills fully or cancels
// If remainder is below $1 minimum, stop; otherwise loop for partial fill
if (tradeSize - totalCostFilled < effectiveMin) break;
} else {
filled = true;
totalSharesFilled = tradeSize / price;
totalCostFilled = tradeSize;
break;
logger.warn(`No liquidity — FAK filled 0 shares (attempt ${attempt})`);
}
} else {
logger.warn(`Order not filled. Error: ${response?.errorMsg || 'Unknown'}`);
logger.warn(`Order rejected: ${response?.errorMsg || 'unknown'}`);
}
} catch (err) {
logger.error(`Buy attempt ${attempt} failed:`, err.message);
logger.error(`Buy attempt ${attempt} failed: ${err.message}`);
}
// Wait before retry
if (attempt < config.maxRetries) {
await new Promise((r) => setTimeout(r, config.retryDelay));
}
}
// FAK found no liquidity — fall back to GTC limit order and wait for fill
if (!filled && config.gtcFallbackTimeout > 0) {
const gtcResult = await _tryGtcFallback(client, tokenId, tradeSize, price, marketOpts);
if (gtcResult) {
totalSharesFilled = gtcResult.sharesFilled;
totalCostFilled = gtcResult.costFilled;
filled = true;
}
}
if (!filled || totalCostFilled === 0) {
logger.error(`Failed to fill buy order for ${market || tokenId} after ${config.maxRetries} attempts`);
return;
@@ -221,6 +364,13 @@ export async function executeBuy(trade) {
outcome: trade.outcome,
});
// Ensure the CTF Exchange is approved to move our ERC-1155 tokens (needed for future sells)
try {
await ensureExchangeApproval(marketOpts.negRisk);
} catch (err) {
logger.warn(`Could not verify ERC-1155 approval: ${err.message}`);
}
// Auto-sell only on initial entry, not on accumulation
if (config.autoSellEnabled) {
await placeAutoSell(effectiveConditionId, tokenId, totalSharesFilled, fillAvgPrice, marketOpts);
@@ -263,15 +413,29 @@ export async function executeSell(trade) {
return;
}
// Cancel existing auto-sell order if any
if (position.sellOrderId) {
try {
const client = getClient();
await client.cancelOrder(position.sellOrderId);
logger.info(`Cancelled auto-sell order: ${position.sellOrderId}`);
} catch (err) {
logger.warn(`Failed to cancel auto-sell: ${err.message}`);
// Cancel ALL open orders for this token so the CLOB frees up locked balance.
// Only cancelling by sellOrderId is not enough — the cancel can fail silently
// and locked tokens cause "not enough balance" on the subsequent sell.
const client = getClient();
try {
const openOrders = await client.getOpenOrders({ asset_id: tokenId });
if (Array.isArray(openOrders) && openOrders.length > 0) {
logger.info(`Cancelling ${openOrders.length} open order(s) for token before sell`);
await Promise.allSettled(
openOrders.map((o) => client.cancelOrder({ orderID: o.id ?? o.order_id })),
);
// Brief pause so the CLOB can update the locked-balance ledger
await new Promise((r) => setTimeout(r, 600));
}
} catch (err) {
// Fallback: try to cancel just the tracked auto-sell order ID
if (position.sellOrderId) {
try {
await client.cancelOrder({ orderID: position.sellOrderId });
await new Promise((r) => setTimeout(r, 600));
} catch { /* ignore */ }
}
logger.warn(`Could not fetch open orders to cancel: ${err.message}`);
}
updatePosition(effectiveConditionId, { status: 'selling' });
@@ -280,35 +444,64 @@ export async function executeSell(trade) {
marketOpts = await getMarketOptions(tokenId);
}
const client = getClient();
// Ensure ERC-1155 approval so the exchange can transfer our tokens
try {
await ensureExchangeApproval(marketOpts.negRisk);
} catch (err) {
logger.warn(`Could not verify ERC-1155 approval: ${err.message}`);
}
// Reconcile stored shares with actual on-chain balance to prevent "not enough balance" errors.
// The stored amount can be higher than on-chain due to fee deductions or precision drift.
const onChain = await getOnChainTokenBalance(tokenId);
let sharesToSell = position.shares;
if (onChain !== null) {
if (onChain < 0.0001) {
logger.warn(`On-chain balance is 0 for ${position.market} — position already sold or redeemed`);
removePosition(effectiveConditionId);
return;
}
if (onChain < sharesToSell) {
logger.info(`Adjusting sell amount: stored ${sharesToSell.toFixed(6)} → on-chain ${onChain.toFixed(6)} shares`);
sharesToSell = onChain;
}
}
// Round down to 4 decimal places to avoid sub-unit precision errors
sharesToSell = Math.floor(sharesToSell * 10000) / 10000;
let filled = false;
for (let attempt = 1; attempt <= config.maxRetries; attempt++) {
try {
if (config.sellMode === 'market') {
// Market sell (FOK)
logger.info(`Sell attempt ${attempt}/${config.maxRetries} (market) | Shares: ${position.shares}`);
// Market sell (FAK) — takes what's available at 2% slippage
logger.info(`Sell attempt ${attempt}/${config.maxRetries} (market) | Shares: ${sharesToSell}`);
const response = await client.createAndPostMarketOrder(
{
tokenID: tokenId,
side: Side.SELL,
amount: position.shares,
price: Math.max(price * 0.95, 0.01), // 5% slippage, min 0.01
amount: sharesToSell,
price: Math.max(price * 0.98, 0.01), // 2% slippage, min 0.01
},
{
tickSize: marketOpts.tickSize,
negRisk: marketOpts.negRisk,
},
OrderType.FOK,
OrderType.FAK, // Fill-and-Kill: takes what's available
);
if (response && response.success) {
logger.success(`Sell order placed: ${response.orderID}`);
filled = true;
break;
const sharesFilled = parseFloat(response.takingAmount || '0');
if (sharesFilled > 0) {
logger.success(`Sell filled: ${response.orderID} | ${sharesFilled.toFixed(4)} shares`);
filled = true;
break;
} else {
logger.warn(`No bid liquidity — FAK filled 0 shares (attempt ${attempt})`);
}
} else {
logger.warn(`Sell not filled: ${response?.errorMsg || 'Unknown'}`);
logger.warn(`Sell rejected: ${response?.errorMsg || 'unknown'}`);
}
} else {
// Limit sell at trader's sell price
@@ -318,7 +511,7 @@ export async function executeSell(trade) {
{
tokenID: tokenId,
price: price,
size: position.shares,
size: sharesToSell,
side: Side.SELL,
},
{
+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 };
}
+67 -18
View File
@@ -7,27 +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
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 ───────────────────────────────────────────────────────────
@@ -35,7 +39,7 @@ function nextSlot() {
async function fetchBySlug(asset, slotTimestamp) {
const slug = `${asset}-updown-${config.mmDuration}-${slotTimestamp}`;
try {
const resp = await fetch(`${config.gammaHost}/markets/slug/${slug}`);
const resp = await proxyFetch(`${config.gammaHost}/markets/slug/${slug}`);
if (!resp.ok) return null;
const data = await resp.json();
return data?.conditionId ? data : null;
@@ -61,7 +65,7 @@ function extractMarketData(market, asset) {
[yesTokenId, noTokenId] = tokenIds;
} else if (Array.isArray(market.tokens) && market.tokens.length >= 2) {
yesTokenId = market.tokens[0]?.token_id ?? market.tokens[0]?.tokenId;
noTokenId = market.tokens[1]?.token_id ?? market.tokens[1]?.tokenId;
noTokenId = market.tokens[1]?.token_id ?? market.tokens[1]?.tokenId;
}
if (!yesTokenId || !noTokenId) return null;
@@ -69,13 +73,13 @@ function extractMarketData(market, asset) {
return {
asset,
conditionId,
question: market.question || market.title || '',
endTime: market.endDate || market.end_date_iso || market.endDateIso,
question: market.question || market.title || '',
endTime: market.endDate || market.end_date_iso || market.endDateIso,
eventStartTime: market.eventStartTime || market.event_start_time,
yesTokenId: String(yesTokenId),
noTokenId: String(noTokenId),
negRisk: market.negRisk ?? market.neg_risk ?? false,
tickSize: String(market.orderPriceMinTickSize ?? market.minimum_tick_size ?? market.minimumTickSize ?? '0.01'),
yesTokenId: String(yesTokenId),
noTokenId: String(noTokenId),
negRisk: market.negRisk ?? market.neg_risk ?? false,
tickSize: String(market.orderPriceMinTickSize ?? market.minimum_tick_size ?? market.minimumTickSize ?? '0.01'),
};
}
@@ -98,7 +102,7 @@ async function scheduleAsset(asset, slotTimestamp) {
seenKeys.add(key);
// Refuse to enter a market already well into its window (e.g., bot restart mid-slot)
const openAt = data.eventStartTime ? new Date(data.eventStartTime).getTime() : slotTimestamp * 1000;
const openAt = data.eventStartTime ? new Date(data.eventStartTime).getTime() : slotTimestamp * 1000;
const elapsedSec = Math.round((Date.now() - openAt) / 1000);
if (elapsedSec > 15) {
logger.info(`MM: ${asset.toUpperCase()} next slot already ${elapsedSec}s old — skipping, will catch next`);
@@ -149,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);
}
}
+734 -115
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;
}
}
@@ -133,71 +240,103 @@ async function simPriceHitTarget(tokenId) {
}
}
// ── Core monitoring loop ──────────────────────────────────────────────────────
// Get current mid price for a token (0 on error)
async function getMidprice(tokenId) {
try {
const mp = await getClient().getMidpoint(tokenId);
return parseFloat(mp?.mid ?? mp ?? '0') || 0;
} catch { return 0; }
}
// ── Per-side fill check (parallel-safe) ──────────────────────────────────────
/**
* Check one side (yes/no) for fills and partial fills.
* Returns true if this side became fully filled during this check.
* Safe to run in parallel for both sides.
*/
async function checkSideFill(pos, key) {
const s = pos[key];
if (s.filled) return false;
const label = key.toUpperCase();
let filled = false;
if (config.dryRun) {
const hitPrice = await simPriceHitTarget(s.tokenId);
if (hitPrice) { filled = true; s.fillPrice = hitPrice; }
} else {
filled = await isOrderFilled(s.orderId, s.shares, s.tokenId);
if (filled) s.fillPrice = config.mmSellPrice;
}
if (filled) {
s.filled = true;
const pnl = (s.fillPrice - s.entryPrice) * s.shares;
logger.money(`MM${config.dryRun ? '[SIM]' : ''}: ${label} filled @ $${s.fillPrice.toFixed(3)} | P&L $${pnl.toFixed(2)}`);
return true;
}
// Partial fill handling (live only)
if (config.dryRun) return false;
const info = await getPartialFillInfo(s.orderId, s.shares, s.tokenId);
if (info.matched > 0 && info.remaining > 0 && info.remaining < s.shares * 0.90) {
logger.warn(`MM: ${label} partially filled — ${info.matched.toFixed(3)}/${info.total.toFixed(3)} matched, ${info.remaining.toFixed(3)} remaining`);
await cancelOrder(s.orderId);
s.orderId = null;
s.shares = info.remaining;
s._partialRevenue = (s._partialRevenue || 0) + info.matched * config.mmSellPrice;
if (info.remaining < CLOB_MIN_ORDER_SHARES) {
logger.warn(`MM: ${label} remaining ${info.remaining.toFixed(3)} < ${CLOB_MIN_ORDER_SHARES} min — market selling remainder`);
const result = await marketSell(s.tokenId, info.remaining, pos.tickSize, pos.negRisk);
s.fillPrice = config.mmSellPrice;
s.filled = true;
const pnl = (s._partialRevenue + result.fillPrice * info.remaining) - s.entryPrice * info.total;
logger.money(`MM: ${label} fully sold (partial+market) | P&L $${pnl.toFixed(2)}`);
return true;
} else {
const res = await placeLimitSell(s.tokenId, info.remaining, config.mmSellPrice, pos.tickSize, pos.negRisk);
if (res.success) {
s.orderId = res.orderId;
logger.info(`MM: ${label} re-placed limit sell for ${info.remaining.toFixed(3)} shares @ $${config.mmSellPrice}`);
}
}
}
return false;
}
// ── Core monitoring loop (event-driven + parallel) ───────────────────────────
async function monitorAndManage(pos) {
const label = pos.question.substring(0, 40);
while (true) {
const msRemaining = new Date(pos.endTime).getTime() - Date.now();
// Register tokens with WS fill watcher for instant fill detection
mmFillWatcher.watch(pos.yes.tokenId);
mmFillWatcher.watch(pos.no.tokenId);
if (msRemaining <= 0) {
logger.warn(`MM: market expired — ${label}`);
pos.status = 'expired';
break;
}
// ── Check YES side ──────────────────────────────────────
if (!pos.yes.filled) {
let filled = false;
if (config.dryRun) {
const hitPrice = await simPriceHitTarget(pos.yes.tokenId);
if (hitPrice) { filled = true; pos.yes.fillPrice = hitPrice; }
} else {
filled = await isOrderFilled(pos.yes.orderId, pos.yes.shares);
if (filled) pos.yes.fillPrice = config.mmSellPrice;
}
if (filled) {
pos.yes.filled = true;
const pnl = (pos.yes.fillPrice - pos.yes.entryPrice) * pos.yes.shares;
logger.money(`MM${config.dryRun ? '[SIM]' : ''}: YES filled @ $${pos.yes.fillPrice.toFixed(3)} | P&L $${pnl.toFixed(2)}`);
// 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);
// ── Check NO side ───────────────────────────────────────
if (!pos.no.filled) {
let filled = false;
if (config.dryRun) {
const hitPrice = await simPriceHitTarget(pos.no.tokenId);
if (hitPrice) { filled = true; pos.no.fillPrice = hitPrice; }
} else {
filled = await isOrderFilled(pos.no.orderId, pos.no.shares);
if (filled) pos.no.fillPrice = config.mmSellPrice;
}
if (filled) {
pos.no.filled = true;
const pnl = (pos.no.fillPrice - pos.no.entryPrice) * pos.no.shares;
logger.money(`MM${config.dryRun ? '[SIM]' : ''}: NO filled @ $${pos.no.fillPrice.toFixed(3)} | P&L $${pnl.toFixed(2)}`);
}
}
// ── Both filled → done ──────────────────────────────────
if (pos.yes.filled && pos.no.filled) {
pos.status = 'done';
const totalPnl = calcPnl(pos);
logger.money(`MM: BOTH sides filled! Total P&L: $${totalPnl.toFixed(2)} | ${label}`);
break;
}
// ── Cut-loss time ───────────────────────────────────────
if (msRemaining <= config.mmCutLossTime * 1000) {
logger.warn(`MM: cut-loss triggered (${Math.round(msRemaining / 1000)}s left) — ${label}`);
pos.status = 'cutting';
await cutLoss(pos);
break;
}
await sleep(10_000);
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
@@ -208,70 +347,183 @@ async function monitorAndManage(pos) {
}
}
async function cutLoss(pos) {
const { conditionId, tickSize, negRisk } = pos;
const neitherFilled = !pos.yes.filled && !pos.no.filled;
async function _monitorLoop(pos, label) {
while (true) {
const msRemaining = new Date(pos.endTime).getTime() - Date.now();
if (neitherFilled) {
// ── Best case: neither side sold → cancel both, merge back to USDC ──
logger.warn('MM: neither side filled — cancelling orders and merging back to USDC...');
await cancelOrder(pos.yes.orderId);
await cancelOrder(pos.no.orderId);
if (msRemaining <= 0) {
logger.warn(`MM: market expired — ${label}`);
pos.status = 'expired';
break;
}
// Read actual on-chain balances (may differ from original if partially consumed)
const [yesActual, noActual] = await Promise.all([
getTokenBalance(pos.yes.tokenId),
getTokenBalance(pos.no.tokenId),
// ── Check YES + NO sides in parallel ────────────────────
await Promise.all([
checkSideFill(pos, 'yes'),
checkSideFill(pos, 'no'),
]);
// mergePositions needs equal amounts — use the minimum actual balance
const yesShares = yesActual ?? pos.yes.shares;
const noShares = noActual ?? pos.no.shares;
const mergeAmt = Math.min(yesShares, noShares);
if (mergeAmt < 0.001) {
logger.warn('MM: balances too low to merge — nothing to recover');
} else {
const recovered = await mergePositions(conditionId, mergeAmt);
logger.money(`MM: merge complete — recovered ~$${recovered.toFixed ? recovered.toFixed(2) : recovered} USDC (P&L ≈ $0)`);
// ── Both filled → done ──────────────────────────────────
if (pos.yes.filled && pos.no.filled) {
pos.status = 'done';
const totalPnl = calcPnl(pos);
logger.money(`MM: BOTH sides filled! Total P&L: $${totalPnl.toFixed(2)} | ${label}`);
break;
}
// Mark both sides closed at entry price
pos.yes.fillPrice = pos.yes.entryPrice;
pos.yes.filled = true;
pos.no.fillPrice = pos.no.entryPrice;
pos.no.filled = true;
// ── Exactly one leg filled → adaptive cut-loss (if enabled) ────────
if (config.mmAdaptiveCL && pos.yes.filled !== pos.no.filled) {
const unfilledKey = pos.yes.filled ? 'no' : 'yes';
await adaptiveLegCL(pos, unfilledKey);
break;
}
} else {
// ── One side already (partly) sold → market-sell the unfilled side ──
for (const side of ['yes', 'no']) {
const s = pos[side];
if (s.filled) continue;
// ── 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;
logger.warn(`MM: cancelling ${side.toUpperCase()} limit order and market-selling...`);
await cancelOrder(s.orderId);
// 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)}`);
}
}
// Fetch actual on-chain balance — partial fills reduce this below s.shares
const actualShares = await getTokenBalance(s.tokenId);
const sellShares = actualShares !== null ? actualShares : s.shares;
// If 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 (sellShares < 0.001) {
logger.warn(`MM: ${side.toUpperCase()} balance is 0 — already fully sold via partial fills`);
s.fillPrice = config.mmSellPrice; // assume sold at target
s.filled = true;
continue;
// 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;
}
}
logger.warn(`MM: ${side.toUpperCase()} actual balance: ${sellShares.toFixed(3)} shares (original: ${s.shares})`);
// ── Cut-loss time ────────────────────────────────────────────────────
if (msRemaining <= config.mmCutLossTime * 1000) {
logger.warn(`MM: cut-loss triggered (${Math.round(msRemaining / 1000)}s left) — ${label}`);
pos.status = 'cutting';
const oneLegFilled = pos.yes.filled !== pos.no.filled;
if (!config.mmAdaptiveCL && oneLegFilled) {
const unfilledKey = pos.yes.filled ? 'no' : 'yes';
await cutLossOneLegFilled(pos, unfilledKey);
pos.status = 'done';
} else {
await cutLossNeitherFilled(pos);
}
break;
}
const result = await marketSell(s.tokenId, sellShares, tickSize, negRisk);
s.fillPrice = result.fillPrice;
s.filled = true;
// PnL uses actual sold amount (not original pos.shares)
const pnl = (s.fillPrice - s.entryPrice) * sellShares;
logger.warn(`MM: ${side.toUpperCase()} cut @ $${s.fillPrice.toFixed(3)} | sold ${sellShares.toFixed(3)} sh | P&L $${pnl.toFixed(2)}`);
// ── Wait for WS fill event or polling fallback ───────────────────────
// WS gives us instant fill detection; polling at 30s is just a safety net
const watchTokens = [];
if (!pos.yes.filled) watchTokens.push(pos.yes.tokenId);
if (!pos.no.filled) watchTokens.push(pos.no.tokenId);
const wsEvent = await waitForFillOrTimeout(watchTokens, POLL_INTERVAL_MS);
if (wsEvent) {
// WS detected a fill — the onWsFill listener already updated pos,
// but loop back immediately to run the decision logic
logger.info(`MM: WS fill event received — re-checking immediately`);
}
}
}
// Legacy one-leg CL: cancel unfilled order, immediate market sell (no patience)
async function cutLossOneLegFilled(pos, unfilledKey) {
const s = pos[unfilledKey];
const { tickSize, negRisk } = pos;
logger.warn(`MM: cancelling ${unfilledKey.toUpperCase()} limit order and market-selling...`);
await cancelOrder(s.orderId);
const actualShares = await getTokenBalance(s.tokenId);
const sellShares = actualShares !== null ? actualShares : s.shares;
if (sellShares < 0.001) {
logger.warn(`MM: ${unfilledKey.toUpperCase()} balance is 0 — already fully sold via partial fills`);
s.fillPrice = config.mmSellPrice;
s.filled = true;
return;
}
logger.warn(`MM: ${unfilledKey.toUpperCase()} actual balance: ${sellShares.toFixed(3)} shares (original: ${s.shares})`);
const result = await marketSell(s.tokenId, sellShares, tickSize, negRisk);
s.fillPrice = result.fillPrice;
s.filled = true;
const pnl = (s.fillPrice - s.entryPrice) * sellShares;
logger.warn(`MM: ${unfilledKey.toUpperCase()} cut @ $${s.fillPrice.toFixed(3)} | sold ${sellShares.toFixed(3)} sh | P&L $${pnl.toFixed(2)}`);
}
async function cutLossNeitherFilled(pos) {
const { conditionId } = pos;
// ── Best case: neither side sold → cancel both, merge back to USDC ──
logger.warn('MM: neither side filled — cancelling orders and merging back to USDC...');
await Promise.all([
cancelOrder(pos.yes.orderId),
cancelOrder(pos.no.orderId),
]);
// Read actual on-chain balances (may differ from original if partially consumed)
const [yesActual, noActual] = await Promise.all([
getTokenBalance(pos.yes.tokenId),
getTokenBalance(pos.no.tokenId),
]);
// mergePositions needs equal amounts — use the minimum actual balance
const yesShares = yesActual ?? pos.yes.shares;
const noShares = noActual ?? pos.no.shares;
const mergeAmt = Math.min(yesShares, noShares);
if (mergeAmt < 0.001) {
logger.warn('MM: balances too low to merge — nothing to recover');
} else {
const recovered = await mergePositions(conditionId, mergeAmt);
logger.money(`MM: merge complete — recovered ~$${recovered.toFixed ? recovered.toFixed(2) : recovered} USDC (P&L ≈ $0)`);
}
// Mark both sides closed at entry price
pos.yes.fillPrice = pos.yes.entryPrice;
pos.yes.filled = true;
pos.no.fillPrice = pos.no.entryPrice;
pos.no.filled = true;
pos.status = 'done';
@@ -279,6 +531,371 @@ async function cutLoss(pos) {
await attemptRecoveryBuy(pos);
}
// ── Defensive Pivot (5m markets, neither side filled) ────────────────────────
/**
* Defensive pivot: neither side has filled after MM_DEFENSIVE_TIMEOUT.
*
* Strategy:
* 1. Orders already cancelled by caller (monitorAndManage)
* 2. Wait until 45s before close
* 3. Check prices: identify worst (lower price) and best (higher price) side
* 4. If worst < MM_DEFENSIVE_WORST_THRESHOLD (default 10c):
* market sell worst side, keep best side (let it resolve at close)
* since YES+NO $1, best side is ~90c+ profit potential
* 5. If worst threshold: market is still uncertain merge back ($0 P&L)
*/
async function defensivePivot(pos) {
const { conditionId, tickSize, negRisk } = pos;
const label = pos.question.substring(0, 40);
const threshold = config.mmDefensiveWorstThreshold;
// Orders already cancelled by monitorAndManage before entering here
logger.info(`MM defensive: waiting for 45s before close | ${label}`);
// Wait until 45s before close, checking every 5s
while (true) {
const msLeft = new Date(pos.endTime).getTime() - Date.now();
if (msLeft <= 45_000) break; // 45s mark reached
if (msLeft <= 0) {
pos.status = 'expired';
return;
}
await sleep(5000);
}
// Read current prices for both sides
const [yesPrice, noPrice] = await Promise.all([
getMidprice(pos.yes.tokenId),
getMidprice(pos.no.tokenId),
]);
logger.info(`MM defensive: 45s mark — YES=$${yesPrice.toFixed(3)}, NO=$${noPrice.toFixed(3)} | threshold=$${threshold} | ${label}`);
// Determine worst and best sides
const worstKey = yesPrice <= noPrice ? 'yes' : 'no';
const bestKey = worstKey === 'yes' ? 'no' : 'yes';
const worstPrice = Math.min(yesPrice, noPrice);
const bestPrice = Math.max(yesPrice, noPrice);
// ── Decision: pivot or merge? ─────────────────────────────────────────
if (worstPrice < threshold) {
// Worst side < 10c → market is decisive, pivot!
logger.trade(`MM defensive: worst side ${worstKey.toUpperCase()} @ $${worstPrice.toFixed(3)} < $${threshold} — selling worst, keeping ${bestKey.toUpperCase()} @ $${bestPrice.toFixed(3)}`);
const worstSide = pos[worstKey];
const bestSide = pos[bestKey];
// Get actual on-chain balances
const [worstBalance, bestBalance] = await Promise.all([
getTokenBalance(worstSide.tokenId),
getTokenBalance(bestSide.tokenId),
]);
const worstShares = worstBalance !== null ? worstBalance : worstSide.shares;
const bestShares = bestBalance !== null ? bestBalance : bestSide.shares;
// Market sell worst side
if (worstShares >= 0.001) {
const result = await marketSell(worstSide.tokenId, worstShares, tickSize, negRisk);
worstSide.fillPrice = result.fillPrice;
worstSide.filled = true;
logger.warn(`MM defensive: sold ${worstKey.toUpperCase()} ${worstShares.toFixed(3)} sh @ $${result.fillPrice.toFixed(3)}`);
} else {
worstSide.fillPrice = 0;
worstSide.filled = true;
}
// Best side: let it resolve at market close (hold the tokens)
// The market will resolve and we can redeem via the redeemer
// Best side price is ~90c+ so payout ≈ $1 per share if it wins
logger.money(`MM defensive: holding ${bestKey.toUpperCase()} ${bestShares.toFixed(3)} sh @ ~$${bestPrice.toFixed(3)} — waiting for resolution`);
logger.info(`MM defensive: expected payout if ${bestKey.toUpperCase()} wins: ~$${bestShares.toFixed(2)} | cost was $${(bestSide.entryPrice * bestShares).toFixed(2)}`);
// Mark best side as filled at entry price for now — actual payout handled by redeemer
bestSide.fillPrice = bestSide.entryPrice;
bestSide.filled = true;
pos.status = 'done';
const worstPnl = worstSide.fillPrice
? (worstSide.fillPrice - worstSide.entryPrice) * worstShares
: 0;
logger.info(`MM defensive: worst side P&L: $${worstPnl.toFixed(2)} | best side will be redeemed after resolution`);
} else {
// Worst side ≥ 10c → market uncertain, safer to merge
logger.info(`MM defensive: worst side ${worstKey.toUpperCase()} @ $${worstPrice.toFixed(3)}$${threshold} — market uncertain, merging back to USDC`);
await cutLossNeitherFilled(pos);
}
}
async function adaptiveLegCL(pos, unfilledKey) {
const s = pos[unfilledKey];
const { tickSize, negRisk } = pos;
const label = pos.question.substring(0, 40);
const pollMs = config.mmAdaptiveMonitorSec * 1000;
// ── Minimum floor: unfilled leg must sell at least this price ──────────────
// Ensures: filledLegPrice + unfilledLegPrice >= mmAdaptiveMinCombined
// Example: filledLeg=0.60, minCombined=1.20 → floor=0.60
// filledLeg=0.55, minCombined=1.20 → floor=0.65
const filledKey = unfilledKey === 'yes' ? 'no' : 'yes';
const filledLegPrice = pos[filledKey].fillPrice ?? config.mmSellPrice;
const minAdaptivePrice = Math.max(0, config.mmAdaptiveMinCombined - filledLegPrice);
// ── Tiered floors (5m markets): progressively lower floor over time ────
// Start from minAdaptivePrice (MM_ADAPTIVE_MIN_COMBINED - filledPrice), then drop per phase
const floorDrop = config.mmDefensiveEnabled ? 0.10 : 0;
const emergencyPrice = config.mmDefensiveWorstThreshold; // default 0.10
const is5m = config.mmDuration === '5m';
/**
* Get the current floor based on time remaining (5m markets only).
* Other durations use the fixed mmAdaptiveMinCombined floor.
*
* Phase 1 (> 180s left): minAdaptivePrice (from MM_ADAPTIVE_MIN_COMBINED)
* Phase 2 (90180s): minAdaptivePrice - 0.10
* Phase 3 (3090s): minAdaptivePrice - 0.20
* Phase 4 (< 30s): market sell
*/
function getTieredFloor(msLeft) {
if (!is5m) return minAdaptivePrice; // non-5m: use fixed floor
if (msLeft > 180_000) return minAdaptivePrice;
if (msLeft > 90_000) return Math.max(0.01, minAdaptivePrice - floorDrop);
if (msLeft > 30_000) return Math.max(0.01, minAdaptivePrice - floorDrop * 2);
return 0; // phase 4: market sell
}
logger.warn(`MM: one leg filled — starting adaptive CL for ${unfilledKey.toUpperCase()} | ${label}`);
if (is5m) {
logger.info(`MM adaptive CL: filled @ $${filledLegPrice.toFixed(3)} | floor (minCombined $${config.mmAdaptiveMinCombined.toFixed(2)}): $${minAdaptivePrice.toFixed(3)} | tiered: $${minAdaptivePrice.toFixed(2)}$${Math.max(0.01, minAdaptivePrice - floorDrop).toFixed(2)}$${Math.max(0.01, minAdaptivePrice - floorDrop * 2).toFixed(2)}`);
} else {
logger.info(`MM adaptive CL: filled leg @ $${filledLegPrice.toFixed(3)} | min floor for combined ≥ $${config.mmAdaptiveMinCombined.toFixed(2)}: $${minAdaptivePrice.toFixed(3)}`);
}
// Cancel the unfilled leg's old GTC order immediately
await cancelOrder(s.orderId);
s.orderId = null;
// Read actual on-chain balance once — reused for all subsequent sell orders
const actualShares = await getTokenBalance(s.tokenId);
const sellShares = actualShares !== null ? actualShares : s.shares;
if (sellShares < 0.001) {
logger.warn(`MM adaptive CL: ${unfilledKey.toUpperCase()} balance is 0 — already fully sold`);
s.fillPrice = config.mmSellPrice;
s.filled = true;
pos.status = 'done';
return;
}
// If remaining shares below CLOB minimum, market sell immediately instead of trying limit
if (sellShares < CLOB_MIN_ORDER_SHARES) {
logger.warn(`MM adaptive CL: ${unfilledKey.toUpperCase()} remaining ${sellShares.toFixed(3)} shares < ${CLOB_MIN_ORDER_SHARES} minimum — market selling immediately`);
const result = await marketSell(s.tokenId, sellShares, tickSize, negRisk);
s.fillPrice = result.fillPrice;
s.filled = true;
pos.status = 'done';
const pnl = (s.fillPrice - s.entryPrice) * sellShares;
const combined = filledLegPrice + s.fillPrice;
logger.warn(`MM adaptive CL: ${unfilledKey.toUpperCase()} market-sold ${sellShares.toFixed(3)} sh @ $${s.fillPrice.toFixed(3)} | combined $${combined.toFixed(3)} | P&L $${pnl.toFixed(2)}`);
return;
}
// Place standing order at breakeven floor immediately (5m) so brief bounces get caught
let activeOrderId = null;
let activeLimitPrice = 0;
let currentFloor = minAdaptivePrice;
if (is5m && sellShares >= CLOB_MIN_ORDER_SHARES) {
// Check mid price first — place at market price (not just breakeven floor)
const initMid = await getMidprice(s.tokenId);
// Use mid price if above floor, otherwise use floor as safety net
const initSellPrice = initMid >= currentFloor
? Math.min(initMid, config.mmSellPrice)
: currentFloor;
logger.info(`MM adaptive CL: mid=$${initMid.toFixed(3)}, placing initial limit sell @ $${initSellPrice.toFixed(3)} (floor=$${currentFloor.toFixed(3)})`);
const standing = await placeLimitSell(s.tokenId, sellShares, initSellPrice, tickSize, negRisk);
if (standing.success) {
activeOrderId = standing.orderId;
activeLimitPrice = initSellPrice;
}
} else {
logger.info(`MM adaptive CL: monitoring ${unfilledKey.toUpperCase()} — floor $${currentFloor.toFixed(3)}, market-sell at CL time`);
}
// ── Continuous monitoring loop ─────────────────────────────────────────────
let lastPhaseLog = '';
while (true) {
const msLeft = new Date(pos.endTime).getTime() - Date.now();
// ── Phase 4 / CL time: force market sell ────────────────────────────
if (msLeft <= (is5m ? 30_000 : config.mmCutLossTime * 1000)) {
if (activeOrderId) {
await cancelOrder(activeOrderId);
activeOrderId = null;
}
break;
}
// ── Update tiered floor ─────────────────────────────────────────────
const newFloor = getTieredFloor(msLeft);
if (newFloor !== currentFloor) {
const phase = msLeft > 180_000 ? '1-breakeven' : msLeft > 90_000 ? '2-controlled' : '3-emergency';
if (phase !== lastPhaseLog) {
logger.info(`MM adaptive CL: phase ${phase} — floor $${currentFloor.toFixed(3)}$${newFloor.toFixed(3)} (${Math.round(msLeft / 1000)}s left)`);
lastPhaseLog = phase;
}
// If floor lowered and we have an active order above new floor, keep it
// Only cancel+re-place if the floor dropped below our current limit
if (activeOrderId && activeLimitPrice > newFloor) {
// Current limit is above new floor — that's fine, keep it
} else if (activeOrderId && activeLimitPrice < newFloor) {
// Floor raised (shouldn't happen in tiered, but safety)
await cancelOrder(activeOrderId);
activeOrderId = null;
activeLimitPrice = 0;
}
currentFloor = newFloor;
}
// ── Check fill ──────────────────────────────────────────────────────
if (activeOrderId) {
let filled = false;
if (config.dryRun) {
const hitPrice = await simPriceHitTarget(s.tokenId);
if (hitPrice) { filled = true; s.fillPrice = hitPrice; }
} else {
filled = await isOrderFilled(activeOrderId, sellShares, s.tokenId);
if (filled) s.fillPrice = activeLimitPrice;
}
if (filled) {
const pnl = (s.fillPrice - s.entryPrice) * sellShares;
const combined = filledLegPrice + s.fillPrice;
logger.money(`MM adaptive CL: ${unfilledKey.toUpperCase()} limit filled @ $${s.fillPrice.toFixed(3)} | combined $${combined.toFixed(3)} | P&L $${pnl.toFixed(2)}`);
s.filled = true;
pos.status = 'done';
return;
}
}
// ── Read current price ──────────────────────────────────────────────
const currentPrice = await getMidprice(s.tokenId);
if (currentPrice <= 0) {
await sleep(pollMs);
continue;
}
// ── Emergency cut: price < 10c in phase 3 → market sell immediately ─
if (is5m && msLeft <= 90_000 && currentPrice < emergencyPrice) {
logger.warn(`MM adaptive CL: EMERGENCY — price $${currentPrice.toFixed(3)} < $${emergencyPrice} with ${Math.round(msLeft / 1000)}s left — market selling now`);
if (activeOrderId) {
await cancelOrder(activeOrderId);
activeOrderId = null;
}
break; // fall through to market sell below
}
const targetPrice = Math.min(currentPrice, config.mmSellPrice);
// ── Adjust or cancel active limit ───────────────────────────────────
if (activeOrderId) {
const belowFloor = currentPrice < currentFloor;
const droppedHard = currentPrice < activeLimitPrice * 0.95;
const priceImproved = targetPrice > activeLimitPrice * 1.02;
if (belowFloor || droppedHard) {
const reason = belowFloor
? `below floor $${currentFloor.toFixed(3)}`
: `dropped >5% from limit $${activeLimitPrice.toFixed(3)}`;
logger.info(`MM adaptive CL: price $${currentPrice.toFixed(3)} ${reason} — cancelling limit, watching for recovery`);
await cancelOrder(activeOrderId);
activeOrderId = null;
activeLimitPrice = 0;
} else if (priceImproved) {
logger.info(`MM adaptive CL: price improved $${activeLimitPrice.toFixed(3)}$${currentPrice.toFixed(3)} — raising limit to $${targetPrice.toFixed(3)}`);
await cancelOrder(activeOrderId);
activeOrderId = null;
activeLimitPrice = 0;
}
}
// ── Place limit at floor or above ───────────────────────────────────
if (!activeOrderId) {
// Re-check actual balance — partial fills may have reduced it
const currentBalance = await getTokenBalance(s.tokenId);
const remainingShares = currentBalance !== null ? currentBalance : sellShares;
if (remainingShares < 0.001) {
logger.warn(`MM adaptive CL: ${unfilledKey.toUpperCase()} balance is 0 — fully sold via partial fills`);
s.fillPrice = config.mmSellPrice;
s.filled = true;
pos.status = 'done';
return;
}
if (remainingShares < CLOB_MIN_ORDER_SHARES) {
logger.warn(`MM adaptive CL: ${unfilledKey.toUpperCase()} remaining ${remainingShares.toFixed(3)} shares < ${CLOB_MIN_ORDER_SHARES} minimum — market selling`);
const result = await marketSell(s.tokenId, remainingShares, tickSize, negRisk);
s.fillPrice = result.fillPrice;
s.filled = true;
pos.status = 'done';
const pnl = (s.fillPrice - s.entryPrice) * remainingShares;
const combined = filledLegPrice + s.fillPrice;
logger.warn(`MM adaptive CL: ${unfilledKey.toUpperCase()} market-sold ${remainingShares.toFixed(3)} sh @ $${s.fillPrice.toFixed(3)} | combined $${combined.toFixed(3)} | P&L $${pnl.toFixed(2)}`);
return;
}
// Place at max(currentPrice, floor) — standing order strategy
const sellPrice = Math.max(currentPrice, currentFloor);
const limitPrice = Math.min(sellPrice, config.mmSellPrice);
if (currentPrice >= currentFloor || is5m) {
// 5m: always place at floor or above (standing order catches bounces)
// non-5m: only place when price >= floor
logger.info(`MM adaptive CL: placing limit sell @ $${limitPrice.toFixed(3)} (mid: $${currentPrice.toFixed(3)}, floor: $${currentFloor.toFixed(3)}, ${Math.round(msLeft / 1000)}s left)`);
const result = await placeLimitSell(s.tokenId, remainingShares, limitPrice, tickSize, negRisk);
if (result.success) {
activeOrderId = result.orderId;
activeLimitPrice = limitPrice;
}
} else {
logger.info(`MM adaptive CL: price $${currentPrice.toFixed(3)} below floor $${currentFloor.toFixed(3)} — waiting for recovery (${Math.round(msLeft / 1000)}s left)`);
}
}
await sleep(pollMs);
}
// ── Fallback: market sell at CL time ───────────────────────────────────────
// Re-check actual balance before market sell (partial fills may have occurred)
const finalBalance = await getTokenBalance(s.tokenId);
const finalShares = finalBalance !== null ? finalBalance : sellShares;
if (finalShares < 0.001) {
logger.warn(`MM adaptive CL: ${unfilledKey.toUpperCase()} balance is 0 at CL time — already fully sold`);
s.fillPrice = config.mmSellPrice;
s.filled = true;
pos.status = 'done';
return;
}
const exitReason = is5m ? 'phase 4 force exit (<30s)' : 'CL time reached';
logger.warn(`MM adaptive CL: ${exitReason} — market-selling ${finalShares.toFixed(3)} ${unfilledKey.toUpperCase()} shares`);
const result = await marketSell(s.tokenId, finalShares, tickSize, negRisk);
s.fillPrice = result.fillPrice;
const pnl = (s.fillPrice - s.entryPrice) * finalShares;
const combined = filledLegPrice + s.fillPrice;
logger.warn(`MM adaptive CL: ${unfilledKey.toUpperCase()} market-sold @ $${s.fillPrice.toFixed(3)} | combined $${combined.toFixed(3)} | sold ${finalShares.toFixed(3)} sh | P&L $${pnl.toFixed(2)}`);
s.filled = true;
pos.status = 'done';
}
// ── Recovery buy ──────────────────────────────────────────────────────────────
/**
@@ -468,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();
+39 -37
View File
@@ -1,16 +1,13 @@
import { ethers } from 'ethers';
import config from '../config/index.js';
import { getPolygonProvider } from './client.js';
import { execSafeCall, CTF_ADDRESS, USDC_ADDRESS } from './ctf.js';
import { getOpenPositions, removePosition } from './position.js';
import { recordSimResult } from '../utils/simStats.js';
import logger from '../utils/logger.js';
import { proxyFetch } from '../utils/proxy.js';
// Contract addresses on Polygon
const CTF_ADDRESS = '0x4D97DCd97eC945f40cF65F87097ACe5EA0476045';
const NEG_RISK_CTF_ADDRESS = '0xC5d563A36AE78145C45a50134d48A1215220f80a';
const USDC_ADDRESS = '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174';
// CTF ABI (minimal for redeemPositions & balanceOf)
// CTF ABI (minimal — read-only calls only; writes go through execSafeCall)
const CTF_ABI = [
'function redeemPositions(address collateralToken, bytes32 parentCollectionId, bytes32 conditionId, uint256[] indexSets)',
'function balanceOf(address owner, uint256 tokenId) view returns (uint256)',
@@ -24,7 +21,7 @@ const CTF_ABI = [
async function checkMarketResolution(conditionId) {
try {
const url = `${config.gammaHost}/markets?condition_id=${conditionId}`;
const response = await fetch(url);
const response = await proxyFetch(url);
if (!response.ok) return null;
const markets = await response.json();
@@ -67,33 +64,30 @@ async function checkOnChainPayout(conditionId) {
}
/**
* Redeem winning position on-chain (real mode only)
* Redeem winning position on-chain via the Gnosis Safe proxy wallet.
* Uses execSafeCall (same as MM bot) so:
* - tx is signed by the EOA but executed FROM the proxy wallet
* - Polygon 30 Gwei minimum tip is enforced
* - automatic retry on transient errors
*/
async function redeemPosition(conditionId, isNegRisk = false) {
async function redeemPosition(conditionId) {
try {
const provider = await getPolygonProvider();
const wallet = new ethers.Wallet(config.privateKey, provider);
const ctfAddress = isNegRisk ? NEG_RISK_CTF_ADDRESS : CTF_ADDRESS;
const ctf = new ethers.Contract(ctfAddress, CTF_ABI, wallet);
const parentCollectionId = ethers.constants.HashZero;
const indexSets = [1, 2];
logger.info(`Redeeming position: ${conditionId}`);
const tx = await ctf.redeemPositions(
const ctfIface = new ethers.utils.Interface(CTF_ABI);
const data = ctfIface.encodeFunctionData('redeemPositions', [
USDC_ADDRESS,
parentCollectionId,
ethers.constants.HashZero,
conditionId,
indexSets,
{ gasLimit: 300000 },
);
[1, 2],
]);
logger.info(`Redeem tx: ${tx.hash}`);
const receipt = await tx.wait();
logger.success(`Redeemed in block ${receipt.blockNumber}`);
const label = conditionId.slice(0, 12) + '...';
logger.info(`Redeeming position: ${label}`);
const receipt = await execSafeCall(CTF_ADDRESS, data, `redeemPositions ${label}`);
const txHash = receipt.transactionHash;
logger.success(`Redeemed in block ${receipt.blockNumber} | tx: ${txHash}`);
return true;
} catch (err) {
logger.error('Failed to redeem:', err.message);
logger.error(`Failed to redeem: ${err.message}`);
return false;
}
}
@@ -148,17 +142,22 @@ export async function checkAndRedeemPositions() {
for (const position of positions) {
try {
// 1. Check via Gamma API
// 1. Quick check via Gamma API (low cost)
const resolution = await checkMarketResolution(position.conditionId);
const isResolved = resolution?.resolved;
const apiResolved = resolution?.resolved;
if (!isResolved) {
// 2. Fallback: on-chain check
const onChain = await checkOnChainPayout(position.conditionId);
if (!onChain.resolved) continue;
logger.info(`Market resolved on-chain: ${position.market}`);
} else {
logger.info(`Market resolved: ${position.market}`);
if (!apiResolved) continue; // Not resolved yet — check again next interval
logger.info(`Market resolved via API: ${position.market}`);
// 2. ALWAYS verify on-chain payout before calling redeemPositions.
// Gamma API can report "resolved" before payoutDenominator is written
// on-chain. Calling redeemPositions with payoutDenominator == 0 causes
// the contract to revert → gas estimation failure.
const onChain = await checkOnChainPayout(position.conditionId);
if (!onChain.resolved) {
logger.info(`On-chain payout not set yet for ${position.market} — will retry next interval`);
continue;
}
// 3. Simulate or execute real redeem
@@ -168,11 +167,14 @@ export async function checkAndRedeemPositions() {
const success = await redeemPosition(position.conditionId);
if (success) {
removePosition(position.conditionId);
logger.money(`Redeemed: ${position.market}`);
logger.money(`Redeemed: ${position.market} → USDC recovered`);
} else {
logger.warn(`Redeem failed for ${position.market}, will retry next interval — continuing to next position...`);
}
}
} catch (err) {
logger.error(`Error checking ${position.market}:`, err.message);
logger.info(`Continuing to next position...`);
}
}
}
+139
View File
@@ -0,0 +1,139 @@
/**
* schedule.js
* Trading session schedule for the sniper bot.
* Reads schedule from .env via config (SNIPER_SCHEDULE_*).
*
* All times are in UTC+8 and converted to UTC internally.
* Assets outside their session window are skipped by the sniper detector.
*
* .env format per asset:
* SNIPER_SCHEDULE_BTC=19:40-22:40,03:40-06:10
* SNIPER_SCHEDULE_ETH=11:40-15:40,16:40-19:40
*/
import config from '../config/index.js';
import logger from '../utils/logger.js';
// UTC+8 offset in hours
const UTC8_OFFSET = 8;
/**
* Parse schedule string from .env.
* Format: "HH:MM-HH:MM,HH:MM-HH:MM"
* Returns: [{ startUtc8, endUtc8, startMin, endMin }]
*/
function parseScheduleString(str) {
if (!str || !str.trim()) return null;
const sessions = [];
const parts = str.split(',').map((s) => s.trim()).filter(Boolean);
for (const part of parts) {
const match = part.match(/^(\d{1,2}:\d{2})\s*[-]\s*(\d{1,2}:\d{2})$/);
if (!match) {
logger.warn(`SCHEDULE: invalid session format "${part}" — expected HH:MM-HH:MM`);
continue;
}
const [, startUtc8, endUtc8] = match;
sessions.push({
startUtc8,
endUtc8,
startMin: utc8ToUtcMinutes(startUtc8),
endMin: utc8ToUtcMinutes(endUtc8),
});
}
return sessions.length > 0 ? sessions : null;
}
/**
* Convert HH:MM in UTC+8 to minutes-since-midnight in UTC.
* Result is always in [0, 1440).
*/
function utc8ToUtcMinutes(hhmm) {
const [h, m] = hhmm.split(':').map(Number);
let totalMin = (h * 60 + m) - (UTC8_OFFSET * 60);
if (totalMin < 0) totalMin += 1440;
if (totalMin >= 1440) totalMin -= 1440;
return totalMin;
}
// Build schedule from config (reads SNIPER_SCHEDULE_* from .env)
const SCHEDULE = {};
const SCHEDULE_DISPLAY = {};
for (const [asset, raw] of Object.entries(config.sniperSchedule || {})) {
const sessions = parseScheduleString(raw);
if (sessions) {
SCHEDULE[asset] = sessions;
SCHEDULE_DISPLAY[asset] = sessions.map((s) => ({
startUtc8: s.startUtc8,
endUtc8: s.endUtc8,
}));
}
}
/**
* Get current time as minutes since midnight UTC.
*/
function nowMinutesUTC() {
const d = new Date();
return d.getUTCHours() * 60 + d.getUTCMinutes();
}
/**
* Check if `nowMin` falls within range [start, end).
* Handles overnight wrap (e.g. 22:0002:00).
*/
function inRange(nowMin, startMin, endMin) {
if (startMin <= endMin) {
return nowMin >= startMin && nowMin < endMin;
} else {
return nowMin >= startMin || nowMin < endMin;
}
}
/**
* Check if an asset is currently within its trading session.
* Returns true if asset has no schedule (always active).
*/
export function isAssetInSession(asset) {
const sessions = SCHEDULE[asset.toLowerCase()];
if (!sessions) return true; // no schedule = always active
const now = nowMinutesUTC();
return sessions.some((s) => inRange(now, s.startMin, s.endMin));
}
/**
* Get human-readable time until the next session opens.
* Returns string like "2h 15m" or null if currently in session.
*/
export function getNextSessionInfo(asset) {
const sessions = SCHEDULE[asset.toLowerCase()];
if (!sessions) return null;
const now = nowMinutesUTC();
if (sessions.some((s) => inRange(now, s.startMin, s.endMin))) return null;
let minWait = Infinity;
for (const s of sessions) {
let wait = s.startMin - now;
if (wait <= 0) wait += 1440;
if (wait < minWait) minWait = wait;
}
if (minWait === Infinity) return null;
const hours = Math.floor(minWait / 60);
const mins = minWait % 60;
if (hours > 0) return `${hours}h ${mins}m`;
return `${mins}m`;
}
/**
* Get the schedule for display (UTC+8 strings).
*/
export function getSchedule() {
return SCHEDULE_DISPLAY;
}
+34 -14
View File
@@ -14,10 +14,12 @@
import config from '../config/index.js';
import logger from '../utils/logger.js';
import { isAssetInSession, getNextSessionInfo } from './schedule.js';
import { proxyFetch } from '../utils/proxy.js';
const SLOT_SEC = 5 * 60; // 300 seconds
let pollTimer = null;
let pollTimer = null;
let onMarketCb = null;
const seenKeys = new Set(); // `${asset}-${slotTimestamp}` already handled
@@ -36,7 +38,7 @@ function nextSlot() {
async function fetchBySlug(asset, slotTimestamp) {
const slug = `${asset}-updown-5m-${slotTimestamp}`;
try {
const resp = await fetch(`${config.gammaHost}/markets/slug/${slug}`);
const resp = await proxyFetch(`${config.gammaHost}/markets/slug/${slug}`);
if (!resp.ok) return null;
const data = await resp.json();
return data?.conditionId ? data : null;
@@ -61,7 +63,7 @@ function extractMarketData(market, asset) {
[yesTokenId, noTokenId] = tokenIds;
} else if (Array.isArray(market.tokens) && market.tokens.length >= 2) {
yesTokenId = market.tokens[0]?.token_id ?? market.tokens[0]?.tokenId;
noTokenId = market.tokens[1]?.token_id ?? market.tokens[1]?.tokenId;
noTokenId = market.tokens[1]?.token_id ?? market.tokens[1]?.tokenId;
}
if (!yesTokenId || !noTokenId) return null;
@@ -69,13 +71,13 @@ function extractMarketData(market, asset) {
return {
asset,
conditionId,
question: market.question || market.title || '',
endTime: market.endDate || market.end_date_iso || market.endDateIso,
question: market.question || market.title || '',
endTime: market.endDate || market.end_date_iso || market.endDateIso,
eventStartTime: market.eventStartTime || market.event_start_time,
yesTokenId: String(yesTokenId),
noTokenId: String(noTokenId),
negRisk: market.negRisk ?? market.neg_risk ?? false,
tickSize: String(market.orderPriceMinTickSize ?? market.minimum_tick_size ?? '0.01'),
yesTokenId: String(yesTokenId),
noTokenId: String(noTokenId),
negRisk: market.negRisk ?? market.neg_risk ?? false,
tickSize: String(market.orderPriceMinTickSize ?? market.minimum_tick_size ?? '0.01'),
};
}
@@ -99,8 +101,8 @@ async function scheduleAsset(asset, slotTimestamp, isCurrent = false) {
if (isCurrent) {
// Current slot: only place orders if there's at least 30 seconds of market left
const endAt = data.endTime ? new Date(data.endTime).getTime() : (slotTimestamp + SLOT_SEC) * 1000;
const secsLeft = Math.round((endAt - Date.now()) / 1000);
const endAt = data.endTime ? new Date(data.endTime).getTime() : (slotTimestamp + SLOT_SEC) * 1000;
const secsLeft = Math.round((endAt - Date.now()) / 1000);
if (secsLeft < 30) {
logger.info(`SNIPER: ${asset.toUpperCase()} current market closing soon (${secsLeft}s) — skipping`);
return;
@@ -108,7 +110,7 @@ async function scheduleAsset(asset, slotTimestamp, isCurrent = false) {
logger.success(`SNIPER: ${asset.toUpperCase()} current market active (${secsLeft}s left) — placing orders now`);
} else {
// Next slot: market hasn't opened yet
const openAt = data.eventStartTime ? new Date(data.eventStartTime).getTime() : slotTimestamp * 1000;
const openAt = data.eventStartTime ? new Date(data.eventStartTime).getTime() : slotTimestamp * 1000;
const secsUntilOpen = Math.round((openAt - Date.now()) / 1000);
logger.success(`SNIPER: ${asset.toUpperCase()} found "${data.question.slice(0, 40)}"${secsUntilOpen > 0 ? `${secsUntilOpen}s before open` : ''}`);
}
@@ -122,8 +124,25 @@ async function poll() {
try {
const curr = currentSlot();
const next = nextSlot();
// Filter assets by trading session schedule
const activeAssets = config.sniperAssets.filter((asset) => {
if (!isAssetInSession(asset)) {
const nextInfo = getNextSessionInfo(asset);
const key = `skip-${asset}-${Math.floor(Date.now() / 60000)}`; // log once per minute
if (!seenKeys.has(key)) {
seenKeys.add(key);
logger.info(`SNIPER: ${asset.toUpperCase()} outside session window${nextInfo ? ` — next in ${nextInfo}` : ''}`);
}
return false;
}
return true;
});
if (activeAssets.length === 0) return;
// Check current active market AND the upcoming next one, in parallel for each asset
await Promise.all(config.sniperAssets.flatMap((asset) => [
await Promise.all(activeAssets.flatMap((asset) => [
scheduleAsset(asset, curr, true), // current market (if still has time left)
scheduleAsset(asset, next, false), // next upcoming market
]));
@@ -145,7 +164,8 @@ export function startSniperDetector(onNewMarket) {
const secsUntil = ns - Math.floor(Date.now() / 1000);
logger.info(`SNIPER detector started — assets: ${config.sniperAssets.join(', ').toUpperCase()}`);
logger.info(`Next slot: *-updown-5m-${ns} (opens in ${secsUntil}s)`);
logger.info(`Order: $${config.sniperPrice} × ${config.sniperShares} shares per side`);
const prices = config.sniperTierPrices;
logger.info(`Order: 3-tier ${prices[0]}c/${prices[1]}c/${prices[2]}c (max ${config.sniperMaxShares} shares per side)`);
}
export function stopSniperDetector() {
+111 -54
View File
@@ -1,91 +1,148 @@
/**
* sniperExecutor.js
* Places GTC limit BUY orders at a very low price on both sides of a market.
*
* Strategy:
* - For each market detected by sniperDetector, place two GTC BUY orders:
* UP token at $SNIPER_PRICE × SNIPER_SHARES shares
* DOWN token at $SNIPER_PRICE × SNIPER_SHARES shares
* - Orders sit in the orderbook. If someone panic-dumps below the price,
* the order fills and becomes redeemable if that side wins.
* - GTC orders expire automatically when the market closes no cleanup needed.
*
* Cost per market: SNIPER_PRICE × SNIPER_SHARES × 2 sides
* e.g. $0.01 × 5 × 2 = $0.10 per market, $0.30 for 3 assets per 5-min slot
* 3-Tier Sniper Strategy:
* - Tier 1: 3c price, smallest size (20% of max)
* - Tier 2: 2c price, medium size (30% of max)
* - Tier 3: 1c price, largest size (50% of max)
* Min 5 shares per tier, total = SNIPER_MAX_SHARES × timeMultiplier
*/
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 (for TUI status panel)
const activeSnipes = []; // { asset, side, question, orderId, price, shares, cost, potentialPayout }
// 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)
* Minimum 5 shares per tier.
*/
function calculateTierSizes(maxShares, minPerTier) {
// Distribution percentages
const ratios = [0.20, 0.30, 0.50]; // Tier 1, 2, 3
const sizes = ratios.map(ratio => {
const size = Math.floor(maxShares * ratio);
return Math.max(size, minPerTier);
});
// Ensure we don't exceed maxShares after rounding up to minimums
const total = sizes.reduce((a, b) => a + b, 0);
if (total > maxShares) {
// Adjust tier 3 (largest) down if needed
sizes[2] = Math.max(minPerTier, sizes[2] - (total - maxShares));
}
return sizes;
}
export async function executeSnipe(market) {
const { asset, conditionId, question, yesTokenId, noTokenId, tickSize, negRisk } = market;
const label = question.slice(0, 40);
const sim = config.dryRun ? '[SIM] ' : '';
// Track conditionId → { asset, token IDs } for win detection
// yesTokenId = outcome 0 (clobTokenIds[0]), noTokenId = outcome 1 (clobTokenIds[1])
conditionInfoMap.set(conditionId, {
asset: asset.toLowerCase(),
yesTokenId,
noTokenId,
});
const sides = [
{ name: 'UP', tokenId: yesTokenId },
{ name: 'DOWN', tokenId: noTokenId },
];
logger.info(`SNIPER: ${sim}${asset.toUpperCase()} — "${label}" | $${config.sniperPrice} × ${config.sniperShares}sh each side`);
// Apply time-based multiplier
const { multiplier, label: mulLabel } = getTimeMultiplier();
const effectiveMaxShares = Math.max(
config.sniperMinSharesPerTier * 3,
Math.round(config.sniperMaxShares * multiplier),
);
const prices = config.sniperTierPrices;
const sizes = calculateTierSizes(effectiveMaxShares, config.sniperMinSharesPerTier);
const mulInfo = multiplier !== 1.0 ? ` | mul ${mulLabel}` : '';
logger.info(`SNIPER: ${sim}${asset.toUpperCase()} — "${label}" | 3-tier: 3c×${sizes[0]} | 2c×${sizes[1]} | 1c×${sizes[2]}${mulInfo}`);
for (const { name, tokenId } of sides) {
if (config.dryRun) {
const cost = config.sniperPrice * config.sniperShares;
logger.trade(`SNIPER[SIM]: ${asset.toUpperCase()} ${name} @ $${config.sniperPrice} × ${config.sniperShares}sh | cost $${cost.toFixed(3)} | payout $${config.sniperShares} if wins`);
activeSnipes.push({
asset: asset.toUpperCase(),
side: name,
question: label,
orderId: `sim-${Date.now()}-${tokenId.slice(-6)}`,
price: config.sniperPrice,
shares: config.sniperShares,
cost,
potentialPayout: config.sniperShares,
});
continue;
}
// Place 3 orders per side
for (let tier = 0; tier < 3; tier++) {
const price = prices[tier];
const size = sizes[tier];
const client = getClient();
try {
const res = await client.createAndPostOrder(
{
tokenID: tokenId,
side: Side.BUY,
price: config.sniperPrice,
size: config.sniperShares,
},
{ tickSize, negRisk },
OrderType.GTC,
);
if (res?.success) {
const cost = config.sniperPrice * config.sniperShares;
logger.trade(`SNIPER: ${asset.toUpperCase()} ${name} @ $${config.sniperPrice} × ${config.sniperShares}sh | cost $${cost.toFixed(3)} | order ${res.orderID}`);
if (config.dryRun) {
const cost = price * size;
logger.trade(`SNIPER[SIM]: ${asset.toUpperCase()} ${name} T${tier+1} @ $${price.toFixed(2)} × ${size}sh | cost $${cost.toFixed(3)}`);
activeSnipes.push({
asset: asset.toUpperCase(),
side: name,
tier: tier + 1,
question: label,
orderId: res.orderID,
price: config.sniperPrice,
shares: config.sniperShares,
orderId: `sim-${Date.now()}-${tier}-${tokenId.slice(-6)}`,
price,
shares: size,
cost,
potentialPayout: config.sniperShares,
potentialPayout: size,
});
} else {
logger.warn(`SNIPER: ${asset.toUpperCase()} ${name} order failed — ${res?.errorMsg || 'unknown'}`);
continue;
}
const client = getClient();
try {
const res = await client.createAndPostOrder(
{
tokenID: tokenId,
side: Side.BUY,
price: price,
size: size,
},
{ tickSize, negRisk },
OrderType.GTC,
);
if (res?.success) {
const cost = price * size;
logger.trade(`SNIPER: ${asset.toUpperCase()} ${name} T${tier+1} @ $${price.toFixed(2)} × ${size}sh | cost $${cost.toFixed(3)} | order ${res.orderID}`);
activeSnipes.push({
asset: asset.toUpperCase(),
side: name,
tier: tier + 1,
question: label,
orderId: res.orderID,
price,
shares: size,
cost,
potentialPayout: size,
});
} else {
logger.warn(`SNIPER: ${asset.toUpperCase()} ${name} T${tier+1} failed — ${res?.errorMsg || 'unknown'}`);
}
} catch (err) {
logger.error(`SNIPER: ${asset.toUpperCase()} ${name} T${tier+1} error — ${err.message}`);
}
} catch (err) {
logger.error(`SNIPER: ${asset.toUpperCase()} ${name} error — ${err.message}`);
}
}
}
+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' };
}
+4 -3
View File
@@ -1,6 +1,7 @@
import config from '../config/index.js';
import logger from '../utils/logger.js';
import { readState, writeState } from '../utils/state.js';
import { proxyFetch } from '../utils/proxy.js';
const PROCESSED_FILE = 'processed_trades.json';
@@ -11,7 +12,7 @@ const PROCESSED_FILE = 'processed_trades.json';
async function fetchTraderActivity() {
const url = `${config.dataHost}/activity?user=${config.traderAddress}`;
try {
const response = await fetch(url);
const response = await proxyFetch(url);
if (!response.ok) {
throw new Error(`Data API returned ${response.status}`);
}
@@ -116,7 +117,7 @@ export { markTradeProcessed };
export async function fetchMarketInfo(conditionId) {
try {
const url = `${config.gammaHost}/markets?condition_id=${conditionId}`;
const response = await fetch(url);
const response = await proxyFetch(url);
if (!response.ok) return null;
const markets = await response.json();
return markets && markets.length > 0 ? markets[0] : null;
@@ -132,7 +133,7 @@ export async function fetchMarketInfo(conditionId) {
export async function fetchMarketByTokenId(tokenId) {
try {
const url = `${config.gammaHost}/markets?clob_token_ids=${tokenId}`;
const response = await fetch(url);
const response = await proxyFetch(url);
if (!response.ok) return null;
const markets = await response.json();
return markets && markets.length > 0 ? markets[0] : null;
+227
View File
@@ -0,0 +1,227 @@
/**
* sniper-tui.js
* TUI version of the Orderbook Sniper bot (blessed dashboard).
* Places tiny GTC BUY orders at a low price on both sides of 5-min markets.
*
* Run with: npm run sniper-tui (live)
* npm run sniper-tui-sim (simulation)
*/
// Load proxy patch BEFORE any other imports (must patch https before axios is loaded)
import './utils/proxy-patch.cjs';
import { validateMMConfig } from './config/index.js';
import config from './config/index.js';
import logger from './utils/logger.js';
import { initClient } from './services/client.js';
import { getUsdcBalance } from './services/client.js';
import { initDashboard, appendLog, updateStatus, isDashboardActive } from './ui/dashboard.js';
import { startSniperDetector, stopSniperDetector } from './services/sniperDetector.js';
import { executeSnipe, getActiveSnipes, getConditionAsset, getConditionInfo } from './services/sniperExecutor.js';
import { redeemSniperPositions, onSniperWin, setSniperConditionLookup } from './services/ctf.js';
import { getSchedule, isAssetInSession, getNextSessionInfo } from './services/schedule.js';
import { getTimeMultiplier } from './services/sniperSizing.js';
// ── Validate config ────────────────────────────────────────────────────────────
try {
validateMMConfig();
} catch (err) {
console.error(`Config error: ${err.message}`);
process.exit(1);
}
if (config.sniperAssets.length === 0) {
console.error('SNIPER_ASSETS is empty. Set e.g. SNIPER_ASSETS=eth,sol,xrp in .env');
process.exit(1);
}
// ── Init TUI ──────────────────────────────────────────────────────────────────
initDashboard();
logger.setOutput(appendLog);
// ── Init CLOB client ──────────────────────────────────────────────────────────
try {
await initClient();
} catch (err) {
logger.error(`Client init error: ${err.message}`);
process.exit(1);
}
// ── Status panel ──────────────────────────────────────────────────────────────
async function buildStatusContent() {
const lines = [];
// Balance
let balance = '?';
if (!config.dryRun) {
try { balance = (await getUsdcBalance()).toFixed(2); } catch { /* ignore */ }
} else {
balance = '{yellow-fg}SIM{/yellow-fg}';
}
lines.push('{bold}BALANCE{/bold}');
lines.push(` USDC.e: {green-fg}$${balance}{/green-fg}`);
lines.push('');
lines.push('{bold}MODE{/bold}');
lines.push(` ${config.dryRun ? '{yellow-fg}SIMULATION{/yellow-fg}' : '{green-fg}LIVE{/green-fg}'}`);
lines.push('');
lines.push('{bold}SNIPER CONFIG{/bold}');
const prices = config.sniperTierPrices;
const sizes = [Math.floor(config.sniperMaxShares * 0.20), Math.floor(config.sniperMaxShares * 0.30), Math.floor(config.sniperMaxShares * 0.50)];
lines.push(` Assets : ${config.sniperAssets.join(', ').toUpperCase()}`);
lines.push(` 3-Tier : ${prices[0]}c/${prices[1]}c/${prices[2]}c`);
lines.push(` Sizes : ${sizes[0]}/${sizes[1]}/${sizes[2]} shares`);
const costPerSide = (sizes[0] * prices[0]) + (sizes[1] * prices[1]) + (sizes[2] * prices[2]);
lines.push(` Cost : $${(costPerSide * 2 * config.sniperAssets.length).toFixed(3)} per slot (base)`);
const { multiplier, label: mulLabel } = getTimeMultiplier();
if (config.sniperMultipliers.length > 0) {
lines.push(` Mul : ${mulLabel}`);
}
if (config.sniperPauseRoundsAfterWin > 0) {
lines.push(` Pause : ${config.sniperPauseRoundsAfterWin} rounds after win`);
}
// Show per-asset pause status
for (const a of config.sniperAssets) {
if (pauseCounters[a] > 0) {
lines.push(` {yellow-fg}${a.toUpperCase()} paused (${pauseCounters[a]} rounds){/yellow-fg}`);
}
}
lines.push('');
// Session schedule
lines.push('{bold}SESSION SCHEDULE (UTC+8){/bold}');
const schedule = getSchedule();
for (const asset of config.sniperAssets) {
const sessions = schedule[asset];
const active = isAssetInSession(asset);
const statusTag = active
? '{green-fg}● ACTIVE{/green-fg}'
: '{red-fg}○ IDLE{/red-fg}';
if (sessions) {
const sessionStr = sessions.map(s => `${s.startUtc8}${s.endUtc8}`).join(', ');
lines.push(` ${asset.toUpperCase()} ${statusTag} ${sessionStr}`);
if (!active) {
const next = getNextSessionInfo(asset);
if (next) lines.push(` {gray-fg}Next in ${next}{/gray-fg}`);
}
} else {
lines.push(` ${asset.toUpperCase()} {yellow-fg}NO SCHEDULE{/yellow-fg} (always active)`);
}
}
lines.push('');
// Recent snipe orders
const snipes = getActiveSnipes();
lines.push(`{bold}SNIPE ORDERS (${snipes.length} total){/bold}`);
if (snipes.length === 0) {
lines.push(' {gray-fg}Waiting for next slot...{/gray-fg}');
} else {
// Show last 10 orders (most recent first)
const recent = snipes.slice(-10).reverse();
for (const s of recent) {
const payout = s.potentialPayout.toFixed(2);
lines.push(` {cyan-fg}${s.asset}{/cyan-fg} ${s.side} @ $${s.price} × ${s.shares}sh | pay $${payout} if win`);
}
}
return '\n' + lines.join('\n');
}
let refreshTimer = null;
let redeemTimer = null;
function startRefresh() {
refreshTimer = setInterval(async () => {
if (!isDashboardActive()) return;
updateStatus(await buildStatusContent());
}, 3000);
buildStatusContent().then(updateStatus);
}
function startRedeemer() {
redeemSniperPositions().catch((err) => logger.error('Sniper redeemer error:', err.message));
redeemTimer = setInterval(
() => redeemSniperPositions().catch((err) => logger.error('Sniper redeemer error:', err.message)),
config.redeemInterval,
);
logger.info(`Sniper redeemer started — checking every ${config.redeemInterval / 1000}s`);
}
// ── Pause-after-win tracking ─────────────────────────────────────────────────
const pauseCounters = {};
function handleWin(conditionId) {
const asset = getConditionAsset(conditionId);
if (!asset) return;
const rounds = config.sniperPauseRoundsAfterWin;
pauseCounters[asset] = rounds;
logger.success(`SNIPER: WIN on ${asset.toUpperCase()} — pausing ${rounds} rounds`);
}
function isAssetPaused(asset) {
const key = asset.toLowerCase();
return pauseCounters[key] > 0;
}
function tickPause(asset) {
const key = asset.toLowerCase();
if (pauseCounters[key] > 0) {
pauseCounters[key]--;
if (pauseCounters[key] <= 0) {
logger.info(`SNIPER: ${asset.toUpperCase()} pause ended — resuming`);
}
}
}
onSniperWin(handleWin);
setSniperConditionLookup(getConditionInfo);
// ── Market handler ────────────────────────────────────────────────────────────
async function handleNewMarket(market) {
const asset = market.asset.toLowerCase();
tickPause(asset);
if (isAssetPaused(asset)) {
logger.info(`SNIPER: ${asset.toUpperCase()} paused (${pauseCounters[asset]} rounds left) — skipping`);
return;
}
executeSnipe(market).catch((err) =>
logger.error(`SNIPER execute error (${market.asset}): ${err.message}`)
);
}
// ── Graceful shutdown ─────────────────────────────────────────────────────────
function shutdown() {
logger.warn('SNIPER: shutting down...');
stopSniperDetector();
if (refreshTimer) clearInterval(refreshTimer);
if (redeemTimer) clearInterval(redeemTimer);
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
// ── Start ─────────────────────────────────────────────────────────────────────
const prices = config.sniperTierPrices;
const sizes = [Math.floor(config.sniperMaxShares * 0.20), Math.floor(config.sniperMaxShares * 0.30), Math.floor(config.sniperMaxShares * 0.50)];
const costPerSide = (sizes[0] * prices[0]) + (sizes[1] * prices[1]) + (sizes[2] * prices[2]);
const costPerSlot = (costPerSide * 2 * config.sniperAssets.length).toFixed(3);
logger.info(`SNIPER starting — ${config.dryRun ? 'SIMULATION' : 'LIVE'}`);
logger.info(`Assets: ${config.sniperAssets.join(', ').toUpperCase()} | 3-tier: 3c×${sizes[0]}+2c×${sizes[1]}+1c×${sizes[2]} = $${costPerSlot}/slot`);
startRefresh();
startRedeemer();
startSniperDetector(handleNewMarket);
+118 -66
View File
@@ -1,21 +1,31 @@
/**
* sniper.js
* Entry point for the Orderbook Sniper bot.
* Places tiny GTC BUY orders at $0.01 on both sides of ETH/SOL/XRP 5-min markets.
* 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.
*
* Run with: npm run sniper (live)
* npm run sniper-sim (simulation)
* Features:
* - Time-based multiplier sizing (SNIPER_MULTIPLIERS, UTC+8)
* - Pause N rounds per asset after a win (SNIPER_PAUSE_ROUNDS_AFTER_WIN)
* - Win detection via outcome (payoutNumerators), not redeem value
*
* Run with: npm run sniper (live, console)
* npm run sniper-sim (simulation, console)
*
* For the TUI dashboard version, use: npm run sniper-tui
*/
import { validateMMConfig } from './config/index.js';
import config from './config/index.js';
import logger from './utils/logger.js';
import { initClient } from './services/client.js';
import { getUsdcBalance } from './services/client.js';
import { initDashboard, appendLog, updateStatus, isDashboardActive } from './ui/dashboard.js';
import { startSniperDetector, stopSniperDetector } from './services/sniperDetector.js';
import { executeSnipe, getActiveSnipes } from './services/sniperExecutor.js';
import { redeemMMPositions } 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';
// ── Validate config ────────────────────────────────────────────────────────────
@@ -31,11 +41,6 @@ if (config.sniperAssets.length === 0) {
process.exit(1);
}
// ── Init TUI ──────────────────────────────────────────────────────────────────
initDashboard();
logger.setOutput(appendLog);
// ── Init CLOB client ──────────────────────────────────────────────────────────
try {
@@ -45,74 +50,103 @@ try {
process.exit(1);
}
// ── Status panel ──────────────────────────────────────────────────────────────
// ── Pause-after-win tracking ─────────────────────────────────────────────────
async function buildStatusContent() {
const lines = [];
// pauseCounters[asset] = number of rounds remaining to skip
const pauseCounters = {};
// Balance
let balance = '?';
if (!config.dryRun) {
try { balance = (await getUsdcBalance()).toFixed(2); } catch { /* ignore */ }
} else {
balance = '{yellow-fg}SIM{/yellow-fg}';
/**
* 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;
}
lines.push('{bold}BALANCE{/bold}');
lines.push(` USDC.e: {green-fg}$${balance}{/green-fg}`);
lines.push('');
const rounds = config.sniperPauseRoundsAfterWin;
pauseCounters[asset] = rounds;
logger.success(`SNIPER: WIN on ${asset.toUpperCase()} — pausing ${rounds} rounds`);
}
lines.push('{bold}MODE{/bold}');
lines.push(` ${config.dryRun ? '{yellow-fg}SIMULATION{/yellow-fg}' : '{green-fg}LIVE{/green-fg}'}`);
lines.push('');
/**
* Check if an asset is currently paused due to a recent win.
* Each call with decrement=true counts as one round passing.
*/
function isAssetPaused(asset) {
const key = asset.toLowerCase();
if (!pauseCounters[key] || pauseCounters[key] <= 0) return false;
return true;
}
lines.push('{bold}SNIPER CONFIG{/bold}');
lines.push(` Assets : ${config.sniperAssets.join(', ').toUpperCase()}`);
lines.push(` Price : $${config.sniperPrice} per share`);
lines.push(` Shares : ${config.sniperShares} per side`);
lines.push(` Cost : $${(config.sniperPrice * config.sniperShares * 2 * config.sniperAssets.length).toFixed(3)} per slot`);
lines.push('');
// Recent snipe orders
const snipes = getActiveSnipes();
lines.push(`{bold}SNIPE ORDERS (${snipes.length} total){/bold}`);
if (snipes.length === 0) {
lines.push(' {gray-fg}Waiting for next slot...{/gray-fg}');
} else {
// Show last 10 orders (most recent first)
const recent = snipes.slice(-10).reverse();
for (const s of recent) {
const payout = s.potentialPayout.toFixed(2);
lines.push(` {cyan-fg}${s.asset}{/cyan-fg} ${s.side} @ $${s.price} × ${s.shares}sh | pay $${payout} if win`);
/**
* 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`);
}
}
return '\n' + lines.join('\n');
}
let refreshTimer = null;
let redeemTimer = null;
// Register win callback and token lookup for correct outcome mapping
onSniperWin(handleWin);
setSniperConditionLookup(getConditionInfo);
function startRefresh() {
refreshTimer = setInterval(async () => {
if (!isDashboardActive()) return;
updateStatus(await buildStatusContent());
}, 3000);
buildStatusContent().then(updateStatus);
// ── Log session schedule ──────────────────────────────────────────────────────
function logSchedule() {
const schedule = getSchedule();
logger.info('─── Session Schedule (UTC+8) ───');
for (const asset of config.sniperAssets) {
const sessions = schedule[asset];
const active = isAssetInSession(asset);
const status = active ? '● ACTIVE' : '○ IDLE';
if (sessions) {
const sessionStr = sessions.map(s => `${s.startUtc8}${s.endUtc8}`).join(', ');
logger.info(` ${asset.toUpperCase()} [${status}] ${sessionStr}`);
if (!active) {
const next = getNextSessionInfo(asset);
if (next) logger.info(` → Next in ${next}`);
}
} else {
logger.info(` ${asset.toUpperCase()} [NO SCHEDULE] (always active)`);
}
}
logger.info('────────────────────────────────');
}
// ── Redeemer ──────────────────────────────────────────────────────────────────
let redeemTimer = null;
function startRedeemer() {
redeemMMPositions().catch((err) => logger.error('Sniper redeemer error:', err.message));
// Only run on interval, NOT on startup (we only want to redeem NEW winning positions)
redeemTimer = setInterval(
() => redeemMMPositions().catch((err) => logger.error('Sniper redeemer error:', err.message)),
() => redeemSniperPositions().catch((err) => logger.error('Sniper redeemer error:', err.message)),
config.redeemInterval,
);
logger.info(`Sniper redeemer started — checking every ${config.redeemInterval / 1000}s`);
logger.info(`Sniper redeemer started — checking every ${config.redeemInterval / 1000}s (winners only, no startup check)`);
}
// ── 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}`)
);
@@ -123,20 +157,38 @@ async function handleNewMarket(market) {
function shutdown() {
logger.warn('SNIPER: shutting down...');
stopSniperDetector();
if (refreshTimer) clearInterval(refreshTimer);
if (redeemTimer) clearInterval(redeemTimer);
if (redeemTimer) clearInterval(redeemTimer);
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
// ── Start ─────────────────────────────────────────────────────────────────────
const costPerSlot = (config.sniperPrice * config.sniperShares * 2 * config.sniperAssets.length).toFixed(3);
// Calculate cost for 3-tier strategy
const prices = config.sniperTierPrices;
const sizes = [Math.floor(config.sniperMaxShares * 0.20), Math.floor(config.sniperMaxShares * 0.30), Math.floor(config.sniperMaxShares * 0.50)];
const costPerSide = (sizes[0] * prices[0]) + (sizes[1] * prices[1]) + (sizes[2] * prices[2]);
const costPerSlot = (costPerSide * 2 * config.sniperAssets.length).toFixed(3);
logger.info(`SNIPER starting — ${config.dryRun ? 'SIMULATION' : 'LIVE'}`);
logger.info(`Assets: ${config.sniperAssets.join(', ').toUpperCase()} | $${config.sniperPrice} × ${config.sniperShares}sh = $${costPerSlot}/slot`);
logger.info(`Assets: ${config.sniperAssets.join(', ').toUpperCase()} | 3-tier: 3c×${sizes[0]}+2c×${sizes[1]}+1c×${sizes[2]} = $${costPerSlot}/slot (base)`);
startRefresh();
// 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();
startSniperDetector(handleNewMarket);
+39
View File
@@ -22,6 +22,26 @@ const B = {
let outputFn = null; // When set, all log goes here (blessed dashboard mode)
/**
* Sanitize a CLOB client console message.
* Strips the full axios config (which may contain auth headers) and returns
* only the HTTP status code + API error message.
*/
function sanitizeClobMessage(raw) {
if (!raw.includes('[CLOB Client]')) return raw;
try {
const jsonStart = raw.indexOf('{');
if (jsonStart === -1) return raw;
const parsed = JSON.parse(raw.slice(jsonStart));
const status = parsed.status || '';
const errMsg = parsed.data?.error || parsed.statusText || 'unknown error';
const prefix = raw.slice(0, jsonStart).trim();
return `${prefix}: ${status}${errMsg}`;
} catch {
return raw;
}
}
function ts() {
return new Date().toISOString().replace('T', ' ').substring(0, 19);
}
@@ -55,6 +75,25 @@ const logger = {
setOutput(fn) {
outputFn = fn;
},
/**
* Override console.error and console.log globally so that the CLOB client's
* internal axios error dumps are sanitized (no auth headers / full config).
* Call this once at startup, before any CLOB requests.
*/
interceptConsole() {
const handle = (originalFn, logFn) => (...args) => {
const raw = args.map((a) => (a && typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ');
const sanitized = sanitizeClobMessage(raw);
if (sanitized !== raw || raw.includes('[CLOB Client]')) {
logFn(sanitized);
} else {
originalFn(...args);
}
};
console.error = handle(console.error.bind(console), logger.error);
console.warn = handle(console.warn.bind(console), logger.warn);
},
};
export default logger;
+58
View File
@@ -0,0 +1,58 @@
/**
* proxy-patch.cjs
*
* Patches the Node.js https module to route Polymarket traffic through a proxy.
* This needs to be the VERY FIRST import in the application.
*/
const PROXY_URL = process.env.PROXY_URL || '';
if (PROXY_URL) {
const https = require('https');
const { HttpsProxyAgent } = require('https-proxy-agent');
const agent = new HttpsProxyAgent(PROXY_URL);
const POLY_DOMAINS = [
'polymarket.com',
'clob.polymarket.com',
'gamma-api.polymarket.com',
'data-api.polymarket.com',
];
const shouldProxy = (hostname) => {
if (!hostname) return false;
return POLY_DOMAINS.some(d => hostname === d || hostname.endsWith('.' + d));
};
const originalRequest = https.request;
https.request = function(...args) {
let url;
if (typeof args[0] === 'string') {
url = args[0];
} else if (args[0] && args[0].hostname) {
url = args[0].hostname;
}
if (url && shouldProxy(url)) {
if (typeof args[0] === 'object') {
args[0].agent = agent;
} else if (typeof args[0] === 'string') {
// Axios might pass string, convert to options object
const parsed = new URL(args[0]);
args[0] = {
protocol: parsed.protocol,
hostname: parsed.hostname,
port: parsed.port,
path: parsed.pathname + parsed.search,
agent: agent,
};
}
}
return originalRequest.apply(this, args);
};
console.log('[proxy-patch] HTTPS patched for Polymarket');
}
+264
View File
@@ -0,0 +1,264 @@
/**
* proxy.js
* Proxy support for Polymarket API calls only.
*
* - CLOB API: uses axios internally (via @polymarket/clob-client)
* we set axios.defaults.httpAgent/httpsAgent via https-proxy-agent.
* - Gamma / Data API: uses native fetch (undici)
* we use undici.ProxyAgent with the `dispatcher` option.
* - Polygon RPC: NOT proxied (separate ethers.js provider).
*
* Set PROXY_URL in .env to enable. Supports HTTP/HTTPS proxies.
* Example: PROXY_URL=http://user:pass@proxy.example.com:8080
*/
import config from '../config/index.js';
import logger from './logger.js';
let axiosAgent = null; // https-proxy-agent for axios (CLOB client)
let fetchDispatcher = null; // undici ProxyAgent for native fetch
/**
* Set up axios defaults so that the @polymarket/clob-client's
* internal axios calls go through the proxy.
* Call this BEFORE creating ClobClient.
*/
export async function setupAxiosProxy() {
if (!config.proxyUrl) {
logger.info('No PROXY_URL set — Polymarket API calls will be direct');
return;
}
try {
// 1. Setup axios proxy (for CLOB client)
const { HttpsProxyAgent } = await import('https-proxy-agent');
axiosAgent = new HttpsProxyAgent(config.proxyUrl);
const axiosModule = await import('axios');
const axios = axiosModule.default || axiosModule;
axios.defaults.proxy = false;
axios.defaults.httpAgent = axiosAgent;
axios.defaults.httpsAgent = axiosAgent;
// Add request interceptor to force proxy agent on every request
// This catches cases where axios.create() instances ignore defaults
axios.interceptors.request.use((cfg) => {
if (cfg.url && cfg.url.includes('polymarket.com')) {
cfg.httpsAgent = axiosAgent;
cfg.httpAgent = axiosAgent;
cfg.proxy = false;
}
return cfg;
});
logger.info(`Axios proxy configured → ${maskProxyUrl(config.proxyUrl)}`);
} catch (err) {
logger.error(`Failed to configure axios proxy: ${err.message}`);
logger.error('Make sure https-proxy-agent is installed: npm i https-proxy-agent');
}
try {
// 2. Setup undici ProxyAgent (for native fetch)
const undici = await import('undici');
fetchDispatcher = new undici.ProxyAgent(config.proxyUrl);
logger.info(`Fetch proxy configured → ${maskProxyUrl(config.proxyUrl)}`);
} catch (err) {
logger.error(`Failed to configure fetch proxy: ${err.message}`);
}
}
/**
* Proxy-aware fetch wrapper.
* Drop-in replacement for global fetch() uses undici ProxyAgent
* as dispatcher when PROXY_URL is configured.
* Use this for Gamma API and Data API calls.
*/
export async function proxyFetch(url, opts = {}) {
if (fetchDispatcher) {
opts.dispatcher = fetchDispatcher;
}
return fetch(url, opts);
}
/**
* Check the outbound IP address that Polymarket sees.
* Uses both direct and proxied requests so you can compare.
*/
async function checkOutboundIP() {
const IP_SERVICE = 'https://api.ipify.org?format=json';
const GEOBLOCK_API = 'https://polymarket.com/api/geoblock';
// 1. Check VPS direct IP
try {
const directResp = await fetch(IP_SERVICE, { signal: AbortSignal.timeout(10000) });
if (directResp.ok) {
const data = await directResp.json();
logger.info(`VPS direct IP : ${data.ip}`);
}
} catch {
logger.warn('Could not detect VPS direct IP');
}
// 2. Check if VPS direct IP is geoblocked
try {
const geoResp = await fetch(GEOBLOCK_API, { signal: AbortSignal.timeout(10000) });
if (geoResp.ok) {
const geo = await geoResp.json();
if (geo.blocked) {
logger.warn(`VPS direct IP GEOBLOCKED — country: ${geo.country}, region: ${geo.region}`);
} else {
logger.info(`VPS direct IP NOT geoblocked — country: ${geo.country}`);
}
}
} catch {
logger.warn('Could not check VPS geoblock status');
}
// 3. Check proxied IP (what Polymarket will see)
if (fetchDispatcher) {
try {
const proxyResp = await fetch(IP_SERVICE, {
dispatcher: fetchDispatcher,
signal: AbortSignal.timeout(10000),
});
if (proxyResp.ok) {
const data = await proxyResp.json();
logger.info(`Proxy IP : ${data.ip} ← Polymarket sees this`);
}
} catch {
logger.warn('Could not detect proxy IP — proxy may not be working');
}
// 4. Check if proxy IP is geoblocked
try {
const geoResp = await fetch(GEOBLOCK_API, {
dispatcher: fetchDispatcher,
signal: AbortSignal.timeout(10000),
});
if (geoResp.ok) {
const geo = await geoResp.json();
if (geo.blocked) {
logger.error('═══════════════════════════════════════════════════');
logger.error(`PROXY IP GEOBLOCKED by Polymarket!`);
logger.error(`IP: ${geo.ip} | Country: ${geo.country} | Region: ${geo.region}`);
logger.error('Change PROXY_URL in .env to a proxy in an allowed region.');
logger.error('═══════════════════════════════════════════════════');
} else {
logger.success(`Proxy IP NOT geoblocked — country: ${geo.country}`);
}
}
} catch {
logger.warn('Could not check proxy geoblock status');
}
}
}
/**
* Test that the proxy works and is not geoblocked by Polymarket CLOB.
* Call this at startup to fail fast if the proxy is misconfigured.
*/
export async function testProxy() {
if (!config.proxyUrl) return true; // no proxy = nothing to test
logger.info(`Testing proxy connection → ${maskProxyUrl(config.proxyUrl)} ...`);
// Show both IPs so user can verify which IP Polymarket sees
await checkOutboundIP();
// ── Test 1: fetch (undici) ──────────────────────────────────────────
try {
if (!fetchDispatcher) {
throw new Error('Proxy dispatcher not initialized');
}
const resp = await fetch(`${config.clobHost}/time`, {
dispatcher: fetchDispatcher,
signal: AbortSignal.timeout(15000),
});
if (resp.status === 403) {
const body = await resp.text().catch(() => '');
const isGeoblock = body.includes('restricted') || body.includes('region') || body.includes('geoblock');
if (isGeoblock) {
logger.error('═══════════════════════════════════════════════════');
logger.error('GEOBLOCKED — Polymarket CLOB rejected your proxy IP!');
logger.error('Your proxy IP is in a restricted region.');
logger.error('Change PROXY_URL in .env to a proxy in an allowed region.');
logger.error('Allowed regions: https://docs.polymarket.com/developers/CLOB/geoblock');
logger.error('═══════════════════════════════════════════════════');
} else {
logger.error(`CLOB returned 403 Forbidden: ${body.substring(0, 200)}`);
}
return false;
}
if (!resp.ok) {
throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
}
logger.success(`Proxy test (fetch) passed`);
} catch (err) {
logger.error(`Proxy test (fetch) FAILED: ${err.message}`);
logger.error('Check PROXY_URL in .env. Bot cannot reach Polymarket without a working proxy.');
return false;
}
// ── Test 2: axios (same transport as CLOB client) ────────────────────
try {
if (!axiosAgent) {
throw new Error('Axios proxy agent not initialized');
}
const axiosModule = await import('axios');
const axios = axiosModule.default || axiosModule;
const axiosResp = await axios.get(`${config.clobHost}/time`, {
httpsAgent: axiosAgent,
proxy: false,
timeout: 15000,
});
logger.success(`Proxy test (axios) passed`);
} catch (err) {
const status = err?.response?.status;
const data = err?.response?.data;
if (status === 403) {
const body = typeof data === 'string' ? data : JSON.stringify(data || '');
const isGeoblock = body.includes('restricted') || body.includes('region') || body.includes('geoblock');
if (isGeoblock) {
logger.error('═══════════════════════════════════════════════════');
logger.error('GEOBLOCKED (axios) — Polymarket CLOB rejected your proxy IP!');
logger.error('Your proxy IP is in a restricted region.');
logger.error('The CLOB client uses axios — this test confirms proxy routing.');
logger.error('Change PROXY_URL in .env to a proxy in an allowed region.');
logger.error('═══════════════════════════════════════════════════');
} else {
logger.error(`CLOB returned 403 via axios: ${body.substring(0, 200)}`);
}
return false;
}
logger.error(`Proxy test (axios) FAILED: ${err.message}`);
return false;
}
logger.success(`All proxy tests passed — connected via ${maskProxyUrl(config.proxyUrl)}`);
return true;
}
/**
* Mask credentials in proxy URL for safe logging.
* http://user:pass@host:port → http://***:***@host:port
*/
function maskProxyUrl(url) {
try {
const u = new URL(url);
if (u.username || u.password) {
u.username = '***';
u.password = '***';
}
return u.toString();
} catch {
return '(invalid URL)';
}
}