36 Commits

Author SHA1 Message Date
direkturcrypto c156a68fdc fix: enforce minimum 5 shares per order (Polymarket minimum)
- Add MIN_ORDER_SIZE=5 constant, apply Math.max + Math.floor to buy/sell sizes
- Skip sell placement if unsold shares < 5 (wait for more fills)
- Config validation: MAKER_TRADE_SIZE must be >= 5

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:10:04 +07:00
direkturcrypto 5f4aa04d13 feat: add buy CL at 10s, multi-duration support, and multi-asset concurrent maker
- CL at 10s before close: cancel unfilled buy orders, place sells for filled
  positions with 3x retry for on-chain settlement delay
- Sell orders stay live until market close (no early cancellation)
- Support multiple durations simultaneously (MAKER_DURATION=5m,15m)
- Queue key changed to asset+duration so BTC/5m and BTC/15m run concurrently
- Update AGENT.MD and README.md with Maker v2 documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:58:35 +07:00
direkturcrypto 930dffafbd fix: add exchange approval, sell retry with settlement delay, CL at 10s
Three fixes for live trading:
1. Call ensureExchangeApproval before selling — CLOB buy gives tokens
   but exchange needs ERC1155 operator approval to place sell orders
2. Wait 2s after buy fill for on-chain settlement before placing sell,
   retry up to 3x with increasing delay if balance not yet available
3. CL at 10s before market close — cancel unfilled sell orders and let
   tokens resolve on-chain naturally

Also adds simulation stats tracking:
- Starting balance $50, tracks cumulative P&L
- Win/Loss/Skip counters with win rate %
- Trade history displayed in TUI and bot status

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 18:58:11 +07:00
direkturcrypto 9a89efb071 fix: remove cut-loss from maker strategy — hold to resolution
No cut-loss needed: worst case is losing buy cost (2c/share) which
resolves on-chain. If on winning side, payout is $1/share.
Removed marketSell, cutLossSells, and makerCutLossTime config.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:59:58 +07:00
direkturcrypto 23bb74127c fix: rewrite makerWs to handle CLOB WebSocket message formats correctly
- Handle initial book snapshot (arrives as array without event_type)
- Handle price_change deltas (incremental updates to bid/ask levels)
- Use Maps for O(1) price level updates instead of full array replace
- Use proper WebSocket ping frames instead of string "ping"
- Subscribe all assets in single message
- Add getBestBid/getBestAsk methods using cached values from events
- Improve TUI orderbook display with depth bars and spread indicator

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:53:04 +07:00
direkturcrypto 991fd6caa4 feat: implement buy-low-sell-high market maker (no splitPosition)
New market maker strategy that places limit BUY on UP+DOWN at low price
(default 2c), then sells at target price (default 3c) when filled.

Key features:
- Concurrent limit BUY on both sides (multi-thread style via Promise.all)
- Immediate partial sell placement on partial fill detection
- Cancel opposite side when one fills completely
- WebSocket orderbook client for real-time simulation visualization
- Both TUI (maker.js) and bot (maker-bot.js) entry points

Files:
- services/makerDetector.js: market detection (reuses slug pattern)
- services/makerExecutor.js: core buy-low-sell-high strategy
- services/makerWs.js: WebSocket orderbook for live data
- maker.js: TUI version with blessed dashboard
- maker-bot.js: PM2/VPS plain-text version

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:47:26 +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
33 changed files with 3942 additions and 424 deletions
+77 -7
View File
@@ -64,6 +64,15 @@ SELL_MODE=market
# How often (seconds) to check for resolved markets to redeem
REDEEM_INTERVAL=60
# Skip BUY if the market closes within this many seconds from now
# Default 300 = skip if market ends in less than 5 minutes
MIN_MARKET_TIME_LEFT=300
# When FAK finds no liquidity (e.g. copying into "next market" before it opens),
# fall back to a GTC limit order and wait this many seconds for it to fill.
# Set to 0 to disable the GTC fallback entirely.
GTC_FALLBACK_TIMEOUT=60
# ─────────────────────────────────────────────
# DRY RUN (set true to simulate without real trades)
# ─────────────────────────────────────────────
@@ -110,17 +119,78 @@ MM_RECOVERY_THRESHOLD=0.70
# USDC size for the recovery buy (0 = use MM_TRADE_SIZE)
MM_RECOVERY_SIZE=0
# Enable adaptive CL when one leg fills (true = patient limit orders, false = immediate market sell)
MM_ADAPTIVE_CL=true
# Minimum combined sell price (both legs) required to place a limit order.
# Formula: filledLegPrice + unfilledLegPrice >= MM_ADAPTIVE_MIN_COMBINED
# Example: filledLeg=0.60, MM_ADAPTIVE_MIN_COMBINED=1.20 → floor=0.60 (won't limit-sell below $0.60)
# filledLeg=0.55, MM_ADAPTIVE_MIN_COMBINED=1.20 → floor=0.65
# If price is below floor, bot waits for recovery. Market-sell only at CL time as last resort.
MM_ADAPTIVE_MIN_COMBINED=1.20
# Poll interval (seconds) for the adaptive CL loop after one leg fills.
# Smaller = more responsive to price changes, more API calls.
MM_ADAPTIVE_MONITOR_SEC=5
# ─────────────────────────────────────────────
# ORDERBOOK SNIPER (sniper.js / npm run sniper-sim)
# Places tiny GTC BUY orders at a low price on both sides of
# ETH/SOL/XRP 5-minute markets — catches panic dumps near $0.
# 3-Tier Strategy: Places GTC BUY orders at 3 prices with weighted sizing
# Tier 1 (3c): smallest size | Tier 2 (2c): medium | Tier 3 (1c): largest
# Catches panic dumps at multiple price levels with optimal capital allocation
# ─────────────────────────────────────────────
# Comma-separated assets to snipe
SNIPER_ASSETS=eth,sol,xrp
# Buy price per share (1 cent = $0.01)
SNIPER_PRICE=0.01
# 3-Tier pricing (high to low)
SNIPER_TIER1_PRICE=0.03 # Highest price, smallest allocation (20%)
SNIPER_TIER2_PRICE=0.02 # Mid price, medium allocation (30%)
SNIPER_TIER3_PRICE=0.01 # Lowest price, largest allocation (50%)
# 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 per side (min 5 shares per tier)
# Example: 15 shares → 3@3c + 5@2c + 7@1c
# Example: 30 shares → 6@3c + 9@2c + 15@1c
SNIPER_MAX_SHARES=15
# ── Sniper Session Schedule (all times UTC+8) ──────────────
# Format: HH:MM-HH:MM,HH:MM-HH:MM (comma-separated sessions)
# Assets without a schedule entry are always active.
SNIPER_SCHEDULE_BTC=19:40-22:40,03:40-06:10
SNIPER_SCHEDULE_ETH=11:40-15:40,16:40-19:40
SNIPER_SCHEDULE_SOL=09:40-12:40,21:40-23:40
SNIPER_SCHEDULE_XRP=18:40-20:40,08:40-09:50
# ─────────────────────────────────────────────
# MARKET MAKER v2 (maker.js / npm run maker-sim)
# Buy Low, Sell High — no splitPosition.
# Places limit BUY on UP+DOWN at low price, sells at target when filled.
# Only one side will fill — the other gets cancelled.
# ─────────────────────────────────────────────
# Comma-separated assets
MAKER_ASSETS=btc,eth,sol,xrp
# Market duration: comma-separated, e.g. "5m" or "5m,15m" for both
MAKER_DURATION=5m,15m
# Limit BUY price (e.g. 0.02 = 2 cents per share)
MAKER_BUY_PRICE=0.02
# Limit SELL price (e.g. 0.03 = 3 cents per share)
MAKER_SELL_PRICE=0.03
# Shares per side (e.g. 50 shares × 0.02 = $1.00 cost per side)
MAKER_TRADE_SIZE=50
# How often to poll for new markets (seconds)
MAKER_POLL_INTERVAL=10
# How fast to check order fill status (milliseconds)
MAKER_MONITOR_MS=2000
# ─────────────────────────────────────────────
# PROXY (Polymarket API only, NOT Polygon RPC)
# Supports HTTP/HTTPS/SOCKS5 proxies
# Leave empty to connect directly (no proxy)
# Example: http://user:pass@proxy.example.com:8080
# ─────────────────────────────────────────────
PROXY_URL=
+2
View File
@@ -1,4 +1,6 @@
node_modules/
.env
data/*.json
data/*.jsonl
.DS_Store
logs/*.log
+21 -2
View File
@@ -11,7 +11,8 @@ Developer and AI agent reference for understanding, extending, and maintaining t
| Bot | Entry Point | Command | Purpose |
|---|---|---|---|
| Copy Trade | `src/index.js` | `npm start` | Mirror trades from a target trader wallet |
| Market Maker | `src/mm.js` | `npm run mm` | Provide liquidity on 5m/15m binary markets |
| Market Maker v1 | `src/mm.js` | `npm run mm` | Provide liquidity via splitPosition on 5m/15m markets |
| Market Maker v2 | `src/maker.js` | `npm run maker` | Buy low, sell high — pure orderbook maker (no splitPosition) |
| Orderbook Sniper | `src/sniper.js` | `npm run sniper` | Place low-price GTC orders to catch panic dumps |
---
@@ -91,9 +92,10 @@ All files use **ES Modules** (`import`/`export`). Do not use `require()`.
- Loads all settings from `.env` via `dotenv`
- Parses and validates required fields per bot type
- Exports two validator functions:
- Exports three validator functions:
- `validateCopyTradeConfig()` — for `src/index.js`
- `validateMMConfig()` — for `src/mm.js` and `src/sniper.js`
- `validateMakerConfig()` — for `src/maker.js`
- Key exported fields: `privateKey`, `proxyWallet`, `traderAddress`, `dryRun`, `mmTradeSize`, etc.
### Client (`src/services/client.js`)
@@ -171,6 +173,23 @@ Used exclusively by the Market Maker bot. Interacts with the Gnosis Safe proxy w
- `cutLoss(...)` — Cancels remaining orders; merges back or market-sells residual tokens
- `attemptRecoveryBuy(...)` — Optional directional bet post cut-loss (controlled by `MM_RECOVERY_BUY`)
### Maker Detector (`src/services/makerDetector.js`)
- Slot-based detection for the Maker v2 bot (buy low, sell high)
- Supports **multiple durations** simultaneously (e.g. `MAKER_DURATION=5m,15m`)
- Polls all `asset × duration` combinations in parallel
- Each detected market includes a `duration` field for concurrent queue management
- Same slug format as other bots: `{asset}-updown-{duration}-{timestamp}`
### Maker Executor (`src/services/makerExecutor.js`)
- `executeMakerStrategy(market)` — Full flow: place limit BUY on UP+DOWN → monitor fills → place limit SELL → monitor sells
- **CL at 10s before close:** Cancels unfilled buy orders, places sells for any filled positions (retry 3x for settlement delays)
- **Sell phase:** Sell orders stay live until market close (no early cancellation)
- **Multi-market concurrent:** Each position keyed by `conditionId`, queue keyed by `${asset}-${duration}`
- Simulation mode with full P&L tracking (win/loss/skip stats)
- `placeLimitSellWithRetry()` — Retries sell placement 3x with increasing delay for on-chain settlement
### Sniper Detector (`src/services/sniperDetector.js`)
- Scans for 5-minute markets for a configurable list of assets
+228 -66
View File
@@ -13,37 +13,49 @@
- [Installation](#installation)
- [Configuration](#configuration)
- [Usage](#usage)
- [Running on VPS with PM2](#running-on-vps-with-pm2)
- [How It Works](#how-it-works)
- [Project Structure](#project-structure)
- [Important Warnings](#important-warnings)
- [Contributing](#contributing)
- [License](#license)
---
## Features
### Copy Trade Bot (`npm start`)
### Copy Trade Bot
- **Watch Trader** — Monitor any Polymarket wallet address in real time via WebSocket
- **Copy Buy** — Automatically mirror buy orders with configurable position sizing
- **Copy Sell** — Automatically mirror sell orders (market or limit)
- **Auto Sell** — Place a GTC limit sell at a target profit % immediately after a buy fills
- **Auto Redeem** — Periodically check and redeem winning positions on-chain
- **Deduplication** — Each market is entered at most once; no double buys
- **Balance Guard** — Checks USDC.e balance before every order
- **Market Expiry Guard** — Skip buys if market closes within `MIN_MARKET_TIME_LEFT` seconds
- **GTC Fallback** — Falls back to a GTC limit order when copying "next market" trades with no liquidity
- **Per-Market Queue** — Concurrent events for the same market are serialized to prevent duplicate buys
- **Dry Run Mode** — Simulate the full flow without placing real orders
### Market Maker Bot (`npm run mm`)
### Market Maker v1 Bot (Split Position)
- **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`)
- **Simulation Mode** — Full dry-run with P&L tracking
### 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
### Market Maker v2 Bot (Buy Low, Sell High)
- **Pure Orderbook** — Places limit BUY on both UP+DOWN at low price (e.g. 2c), sells at higher price (e.g. 3c) when filled. No splitPosition needed
- **Cut-Loss at 10s** — Cancels unfilled buy orders 10 seconds before market close; sells positions as much as possible
- **Sell Retry 3x** — Retries sell placement with delay for on-chain token settlement
- **Multi-Asset** — Supports BTC, ETH, SOL, XRP simultaneously
- **Multi-Duration** — Run 5m and 15m markets concurrently (`MAKER_DURATION=5m,15m`)
- **Concurrent Markets** — Each asset+duration combination runs independently (e.g. BTC/5m and BTC/15m don't block each other)
- **Simulation Mode** — Full dry-run with win/loss/skip tracking and P&L stats
- **Proxy Support** — All API calls go through `PROXY_URL` if configured
### Orderbook Sniper Bot
- **3-Tier Strategy** — Places GTC BUY orders at 3c, 2c, and 1c with weighted sizing (20%/30%/50%)
- **Multi-Asset** — Targets ETH, SOL, XRP, and more simultaneously
- **Simulation Mode** — Preview orders without spending funds (`npm run sniper-sim`)
- **Simulation Mode** — Preview orders without spending funds
- **Session Scheduling** — Per-asset time windows (UTC+8) for selective trading
---
@@ -56,6 +68,7 @@
| Polymarket Proxy Wallet | Your proxy wallet address (visible on your Polymarket profile → Deposit) |
| USDC.e on Polygon | Deposited via Polymarket's deposit flow |
| MATIC on Polygon | A small amount for gas fees (redeem & on-chain operations) |
| PM2 *(optional)* | For running on a VPS: `npm install -g pm2` |
---
@@ -72,8 +85,8 @@ 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
# 4. Fill in your credentials
nano .env
```
---
@@ -115,6 +128,8 @@ Leave these blank to have the client auto-derive credentials from your private k
| `AUTO_SELL_PROFIT_PERCENT` | Target profit % for the auto-sell limit order | `10` |
| `SELL_MODE` | `market` or `limit` when copying a sell | `market` |
| `REDEEM_INTERVAL` | Seconds between redemption checks | `60` |
| `MIN_MARKET_TIME_LEFT` | Skip buy if market closes within this many seconds | `300` |
| `GTC_FALLBACK_TIMEOUT` | Seconds to wait for GTC fill when FAK finds no liquidity | `60` |
| `DRY_RUN` | Simulate without placing real orders | `true` |
### Market Maker Bot Settings
@@ -133,35 +148,151 @@ Leave these blank to have the client auto-derive credentials from your private k
| `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` |
### Market Maker v2 (Maker) Settings
| Variable | Description | Default |
|---|---|---|
| `MAKER_ASSETS` | Comma-separated assets (e.g. `btc,eth,sol,xrp`) | `btc` |
| `MAKER_DURATION` | Comma-separated durations (e.g. `5m` or `5m,15m`) | `5m` |
| `MAKER_BUY_PRICE` | Limit BUY price per share (e.g. `0.02` = 2c) | `0.02` |
| `MAKER_SELL_PRICE` | Limit SELL price per share (e.g. `0.03` = 3c) | `0.03` |
| `MAKER_TRADE_SIZE` | Shares per side (e.g. `50` × $0.02 = $1.00/side) | `50` |
| `MAKER_POLL_INTERVAL` | Seconds between new market polls | `10` |
| `MAKER_MONITOR_MS` | Milliseconds between order fill checks | `2000` |
**Cut-Loss:** At 10 seconds before market close, unfilled buy orders are cancelled. Any filled positions get sell orders placed (retry 3x). Sell orders stay live until market close.
### Orderbook Sniper Settings
**3-Tier Strategy:** Places orders at 3 price levels with weighted sizing
| Variable | Description | Default |
|---|---|---|
| `SNIPER_ASSETS` | Comma-separated assets to snipe (e.g. `eth,sol,xrp`) | `eth,sol,xrp` |
| `SNIPER_PRICE` | Buy price per share (e.g. `0.01` = $0.01) | `0.01` |
| `SNIPER_SHARES` | Shares per side (minimum 5 per Polymarket rules) | `5` |
| `SNIPER_TIER1_PRICE` | Highest price tier (e.g. `0.03` = 3c) | `0.03` |
| `SNIPER_TIER2_PRICE` | Mid price tier (e.g. `0.02` = 2c) | `0.02` |
| `SNIPER_TIER3_PRICE` | Lowest price tier (e.g. `0.01` = 1c) | `0.01` |
| `SNIPER_MAX_SHARES` | Max total shares per side (min 5 per tier) | `15` |
**Allocation:**
- Tier 1 (3c): 20% of max shares (min 5)
- Tier 2 (2c): 30% of max shares (min 5)
- Tier 3 (1c): 50% of max shares (min 5)
**Example with `SNIPER_MAX_SHARES=15`:**
- 3 shares @ 3c = $0.09
- 5 shares @ 2c = $0.10
- 7 shares @ 1c = $0.07
- **Total per side:** 15 shares = $0.26
---
## Usage
### Terminal UI (local)
Runs with an interactive split-panel dashboard (blessed TUI).
```bash
# ── Copy Trade Bot ─────────────────────────────────
npm start # Production mode
npm run dev # Development mode (auto-reload on file changes)
# Copy Trade Bot
npm start # live trading
npm run dev # live + auto-reload on file changes
# ── 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
# Market Maker Bot
npm run mm # live trading
npm run mm-sim # simulation (DRY_RUN=true)
npm run mm-dev # simulation + auto-reload
# ── 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
# Market Maker v2 (Maker) Bot
npm run maker # live trading
npm run maker-sim # simulation
npm run maker-dev # simulation + auto-reload
# Orderbook Sniper Bot
npm run sniper # live trading
npm run sniper-sim # simulation
npm run sniper-dev # simulation + auto-reload
```
> **Always test with `DRY_RUN=true` first** before committing real funds.
### Plain Log Mode (no TUI)
Writes plain timestamped text to stdout — suitable for piping, `tail -f`, or PM2.
```bash
# Copy Trade Bot
npm run bot # live trading
npm run bot-sim # simulation
npm run bot-dev # simulation + auto-reload
# Market Maker v1 Bot
npm run mm-bot # live trading
npm run mm-bot-sim # simulation
npm run mm-bot-dev # simulation + auto-reload
# Market Maker v2 (Maker) Bot
npm run maker-bot # live trading
npm run maker-bot-sim # simulation
npm run maker-bot-dev # simulation + auto-reload
```
> **Always test with `DRY_RUN=true` (or `*-sim` scripts) first** before committing real funds.
---
## Running on VPS with PM2
Each bot has its own PM2 config file inside the `pm2/` folder.
### Install PM2
```bash
npm install -g pm2
```
### Copy Trade Bot
```bash
# Live trading
pm2 start pm2/copy.config.cjs
# Simulation
pm2 start pm2/copy.config.cjs --env sim
# View logs
pm2 logs polymarket-copy
tail -f logs/copy-out.log
# Management
pm2 restart polymarket-copy
pm2 stop polymarket-copy
pm2 delete polymarket-copy
```
### Market Maker Bot
```bash
# Live trading
pm2 start pm2/mm.config.cjs
# Simulation
pm2 start pm2/mm.config.cjs --env sim
# View logs
pm2 logs polymarket-mm
tail -f logs/mm-out.log
# Management
pm2 restart polymarket-mm
pm2 stop polymarket-mm
pm2 delete polymarket-mm
```
### Auto-start on reboot
```bash
pm2 startup # generates a startup command — run the command it prints
pm2 save # saves current process list
```
---
@@ -170,27 +301,28 @@ npm run sniper-dev # Simulation + auto-reload
### Copy Trade Bot Flow
```
┌──────────────────────────────────────────────────────────┐
│ WATCHER LOOP
│ WebSocket (RTDS) — real-time trade events from trader │
│ Fallback: poll Data API every N seconds │
├───────────────────────┬──────────────────────────────────┤
│ NEW BUY │ NEW SELL │
│ │
│ ✓ Check position │ ✓ Check position exists │
✓ Check USDC balance │ ✓ Cancel existing auto-sell
│ ✓ Market buy (FOK) │ ✓ Market / limit sell │
│ ✓ Retry on failure │ ✓ Retry on failure │
│ ✓ Place auto-sell │ ✓ Remove position from state │
│ ✓ Save position │ │
├───────────────────────┴──────────────────────────────────┤
│ REDEEMER LOOP │
│ Periodically checks resolved markets
│ → Redeems winning positions via CTF contract on-chain │
└──────────────────────────────────────────────────────────┘
WebSocket (RTDS) — real-time trade events from trader
Per-market queue (prevents concurrent duplicate buys)
┌────┴──────┐
BUY SELL
├─ Expiry guard (MIN_MARKET_TIME_LEFT)
├─ Max position cap ├─ Cancel open orders
├─ FAK market buy ├─ Reconcile on-chain balance
│ └─ 0 fill? → GTC fallback ├─ FAK market sell / limit sell
├─ Place auto-sell GTC └─ Remove position
└─ Save position
Redeemer loop (every REDEEM_INTERVAL seconds)
→ Check on-chain payout → redeemPositions via Gnosis Safe
```
### Market Maker Flow
### Market Maker v1 Flow (Split Position)
```
New Market Detected
@@ -206,13 +338,44 @@ Monitor fills every few seconds
┌────┴────┐
│ │
Fill Time < MM_CUT_LOSS_TIME
Fill Time < MM_CUT_LOSS_TIME
│ │
▼ ▼
Collect Cancel orders → Merge YES+NO back to USDC
profit (recovery buy optional)
```
### Market Maker v2 Flow (Buy Low, Sell High)
```
Detector polls all asset × duration combos (e.g. BTC/5m, ETH/15m)
Place limit BUY on UP + DOWN @ MAKER_BUY_PRICE (e.g. $0.02)
Monitor both sides concurrently
┌────┴─────────────────┐
│ │
One side fills CL at 10s before close
│ │
▼ ▼
Cancel other buy Cancel unfilled buys
Place SELL @ 3c Place SELL for any fills (retry 3x)
│ │
└──────┬───────────────┘
Monitor sells until market close
┌────┴────┐
│ │
Sell fills Market closes
│ │
▼ ▼
WIN $ Tokens resolve on-chain
```
---
## Project Structure
@@ -220,9 +383,13 @@ Collect Cancel orders → Merge YES+NO back to USDC
```
polymarket-terminal/
├── src/
│ ├── index.js — Copy trade bot entry point
│ ├── mm.js — Market maker bot entry point
│ ├── sniper.js — Orderbook sniper bot entry point
│ ├── index.js — Copy trade bot (TUI)
│ ├── bot.js — Copy trade bot (plain log / PM2)
│ ├── mm.js — Market maker v1 bot (TUI)
│ ├── mm-bot.js — Market maker v1 bot (plain log / PM2)
│ ├── maker.js — Market maker v2 bot (TUI)
│ ├── maker-bot.js — Market maker v2 bot (plain log / PM2)
│ ├── sniper.js — Orderbook sniper bot
│ │
│ ├── config/
│ │ └── index.js — Environment variable loading & validation
@@ -235,9 +402,12 @@ polymarket-terminal/
│ │ ├── 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
│ │ ├── ctf.js — On-chain CTF contract interactions
│ │ ├── mmDetector.js — Market detection for MM v1
│ │ ├── mmExecutor.js — MM v1 strategy execution
│ │ ├── makerDetector.js — Market detection for Maker v2 (multi-duration)
│ │ ├── makerExecutor.js — Maker v2 strategy execution
│ │ ├── makerWs.js — Orderbook WebSocket for Maker v2
│ │ ├── sniperDetector.js — Market detection for sniper
│ │ └── sniperExecutor.js — Orderbook sniper order placement
│ │
@@ -245,11 +415,16 @@ polymarket-terminal/
│ │ └── dashboard.js — Terminal UI (blessed)
│ │
│ └── utils/
│ ├── logger.js — Color-coded, timestamped logging
│ ├── logger.js — Timestamped logging (TUI + plain modes)
│ ├── state.js — Atomic JSON state file management
│ └── simStats.js — Simulation P&L statistics
├── pm2/
│ ├── copy.config.cjs — PM2 config for copy trade bot
│ └── mm.config.cjs — PM2 config for market maker bot
├── data/ — Runtime state files (gitignored)
├── logs/ — PM2 log files (gitignored)
├── .env.example — Configuration template
├── .gitignore
└── package.json
@@ -260,26 +435,13 @@ polymarket-terminal/
## Important Warnings
- **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.
- **Always start with `DRY_RUN=true`** (or a `*-sim` script) to verify the bot behaves as expected before using real funds.
- **Use a small `SIZE_PERCENT`** for initial live runs to limit exposure.
- **Keep MATIC in your EOA wallet** for gas fees (redeem operations and on-chain CTF calls).
- **This software is provided as-is, with no guarantees.** Prediction market trading carries significant financial risk. You are solely responsible for any losses.
---
## Contributing
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)**.
View File
+176 -5
View File
@@ -1,11 +1,11 @@
{
"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",
"license": "ISC",
"dependencies": {
@@ -13,6 +13,9 @@
"blessed": "^0.1.81",
"dotenv": "^16.4.7",
"ethers": "^5.8.0",
"global-agent": "^4.1.2",
"https-proxy-agent": "^7.0.6",
"undici": "^7.22.0",
"ws": "^8.19.0"
},
"devDependencies": {
@@ -913,6 +916,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 +1095,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 +1108,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 +1243,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 +1452,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 +1505,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 +1577,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 +1665,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 +1748,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 +1789,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 +1847,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 +1855,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 +1950,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 +1969,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",
+20 -2
View File
@@ -7,12 +7,27 @@
"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": "DRY_RUN=false node src/maker.js",
"maker-sim": "DRY_RUN=true node src/maker.js",
"maker-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/maker.js",
"maker-bot": "node src/maker-bot.js",
"maker-bot-sim": "DRY_RUN=true node src/maker-bot.js",
"maker-bot-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/maker-bot.js"
},
"keywords": [
"polymarket",
@@ -26,9 +41,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],
},
],
};
+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);
});
+80 -16
View File
@@ -46,31 +46,80 @@ 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),
// ── 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
// ── 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 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;
})(),
// ── Market Maker v2 (Buy Low, Sell High) ──────────────────────
// Places limit BUY on UP+DOWN at low price, sells at higher price when filled.
// No splitPosition — pure orderbook strategy.
makerAssets: (process.env.MAKER_ASSETS || 'btc')
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean),
makerDurations: (process.env.MAKER_DURATION || '5m')
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean),
makerBuyPrice: parseFloat(process.env.MAKER_BUY_PRICE || '0.02'),
makerSellPrice: parseFloat(process.env.MAKER_SELL_PRICE || '0.03'),
makerTradeSize: parseFloat(process.env.MAKER_TRADE_SIZE || '50'), // shares per side
makerSimBalance: parseFloat(process.env.MAKER_SIM_BALANCE || '50'), // starting sim balance
makerPollInterval: parseInt(process.env.MAKER_POLL_INTERVAL || '10', 10) * 1000,
makerMonitorMs: parseInt(process.env.MAKER_MONITOR_MS || '2000', 10), // how fast to poll order status
// ── 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 +149,19 @@ export function validateMMConfig() {
throw new Error('MM_SELL_PRICE must be between 0 and 1');
}
// Validation for maker bot (buy low, sell high)
export function validateMakerConfig() {
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.makerBuyPrice <= 0 || config.makerBuyPrice >= 1)
throw new Error('MAKER_BUY_PRICE must be between 0 and 1');
if (config.makerSellPrice <= config.makerBuyPrice)
throw new Error('MAKER_SELL_PRICE must be greater than MAKER_BUY_PRICE');
if (config.makerTradeSize < 5)
throw new Error('MAKER_TRADE_SIZE must be >= 5 (Polymarket minimum order size)');
}
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) {
+183
View File
@@ -0,0 +1,183 @@
/**
* maker-bot.js — Buy Low, Sell High Market Maker, PM2 / VPS entry point (no TUI)
*
* Plain-text stdout output, compatible with:
* pm2 start ecosystem.config.cjs --only polymarket-maker
* pm2 logs polymarket-maker
*/
import './utils/proxy-patch.cjs';
import { validateMakerConfig } from './config/index.js';
import config from './config/index.js';
import logger from './utils/logger.js';
import { initClient, getUsdcBalance } from './services/client.js';
import { startMakerDetector, stopMakerDetector } from './services/makerDetector.js';
import { executeMakerStrategy, getActiveMakerPositions, getSimStats } from './services/makerExecutor.js';
import { OrderbookWs } from './services/makerWs.js';
logger.interceptConsole();
// ── Validate config ────────────────────────────────────────────────────────────
try {
validateMakerConfig();
} 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);
}
// ── WebSocket orderbook (for sim mode) ───────────────────────────────────────
const orderbookWs = new OrderbookWs();
// ── 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';
if (config.dryRun) {
const s = getSimStats();
const winRate = s.wins + s.losses > 0 ? ((s.wins / (s.wins + s.losses)) * 100).toFixed(1) : '0.0';
const pnlSign = s.cumulativePnl >= 0 ? '+' : '';
logger.info(`--- MAKER [SIM] | $${s.balance.toFixed(2)} (${pnlSign}$${s.cumulativePnl.toFixed(4)}) | W:${s.wins} L:${s.losses} S:${s.skips} Win%:${winRate}% | Active: ${positions.length} ---`);
} else {
logger.info(`--- MAKER Status [${mode}] | Balance: ${balanceStr} | Active: ${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` : `${secsLeft}s`;
const pnl = pos.totalRevenue - pos.totalCost;
const sign = pnl >= 0 ? '+' : '';
logger.info(
` ${assetTag}${label} | ${pos.status} | ${timeStr} left` +
` | UP: ${pos.up.buyFilled.toFixed(1)}sh bought, ${pos.up.totalSellFilled.toFixed(1)}sh sold` +
` | DOWN: ${pos.down.buyFilled.toFixed(1)}sh bought, ${pos.down.totalSellFilled.toFixed(1)}sh sold` +
` | P&L: ${sign}$${pnl.toFixed(4)}`,
);
// Orderbook snapshot
for (const [label, tokenId] of [['UP', pos.up.tokenId], ['DOWN', pos.down.tokenId]]) {
const bestBid = orderbookWs.getBestBid(tokenId);
const bestAsk = orderbookWs.getBestAsk(tokenId);
if (bestBid || bestAsk) {
const book = orderbookWs.getBook(tokenId);
const bidDepth = book.bids.slice(0, 3).map(b => `${b.price.toFixed(2)}×${b.size.toFixed(0)}`).join(' ');
const askDepth = book.asks.slice(0, 3).map(a => `${a.price.toFixed(2)}×${a.size.toFixed(0)}`).join(' ');
logger.info(` ${label}: [${bidDepth}] | [${askDepth}]`);
}
}
}
} catch (err) {
logger.warn(`Status check error: ${err.message}`);
}
}
// ── Market handler with per-asset queue ──────────────────────────────────────
const pendingByAsset = new Map();
function slotKey(market) {
return `${market.asset}-${market.duration || '5m'}`;
}
async function runStrategy(market) {
const key = slotKey(market);
const tag = `${market.asset?.toUpperCase()}/${market.duration || '5m'}`;
if (config.dryRun) {
orderbookWs.subscribe(market.conditionId, [market.yesTokenId, market.noTokenId]);
}
try {
await executeMakerStrategy(market);
} catch (err) {
logger.error(`MAKER strategy error (${tag}): ${err.message}`);
}
const queued = pendingByAsset.get(key);
if (queued) {
pendingByAsset.delete(key);
const secsLeft = Math.round((new Date(queued.endTime).getTime() - Date.now()) / 1000);
if (secsLeft > 30) {
logger.success(`MAKER[${tag}]: executing queued market (${secsLeft}s left)`);
runStrategy(queued);
} else {
logger.warn(`MAKER[${tag}]: queued market expired (${secsLeft}s left)`);
}
}
}
async function handleNewMarket(market) {
const key = slotKey(market);
const tag = `${market.asset?.toUpperCase()}/${market.duration || '5m'}`;
const active = getActiveMakerPositions();
const isSlotBusy = active.some((p) => p.asset === market.asset && p.duration === (market.duration || '5m'));
if (isSlotBusy) {
pendingByAsset.set(key, market);
logger.warn(`MAKER[${tag}]: queued — will enter after current position clears`);
return;
}
runStrategy(market);
}
// ── Timers ───────────────────────────────────────────────────────────────────
const statusTimer = setInterval(printStatus, 60_000);
// ── Graceful shutdown ─────────────────────────────────────────────────────────
function shutdown() {
logger.warn('MAKER: shutting down...');
stopMakerDetector();
orderbookWs.shutdown();
clearInterval(statusTimer);
setTimeout(() => process.exit(0), 300);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
// ── Start ─────────────────────────────────────────────────────────────────────
const mode = config.dryRun ? 'SIMULATION' : 'LIVE';
const costPerSide = config.makerTradeSize * config.makerBuyPrice;
const profitPerCycle = (config.makerSellPrice - config.makerBuyPrice) * config.makerTradeSize;
logger.info(`=== Market Maker v2 [${mode}] ===`);
logger.info(`Assets : ${config.makerAssets.join(', ').toUpperCase()}`);
logger.info(`Duration : ${config.makerDurations.join(', ')}`);
logger.info(`Buy @ : $${config.makerBuyPrice} per share`);
logger.info(`Sell @ : $${config.makerSellPrice} per share`);
logger.info(`Size : ${config.makerTradeSize} shares/side`);
logger.info(`Cost/side : $${costPerSide.toFixed(2)}`);
logger.info(`Profit : $${profitPerCycle.toFixed(2)} per cycle`);
logger.info(`CL : cancel buys at 10s, sell until close`);
logger.info('==========================================');
startMakerDetector(handleNewMarket);
logger.success(`MAKER bot started — watching for ${config.makerDurations.join('+')} ${config.makerAssets.join('/')} markets...`);
+281
View File
@@ -0,0 +1,281 @@
/**
* maker.js
* TUI version — Buy Low, Sell High Market Maker (blessed dashboard).
*
* Strategy: Place limit BUY on UP+DOWN at low price, sell at target when filled.
* No splitPosition — pure orderbook-based market making.
*
* Run with: npm run maker (live)
* npm run maker-sim (simulation with real orderbook via WebSocket)
*/
import './utils/proxy-patch.cjs';
import { validateMakerConfig } from './config/index.js';
import config from './config/index.js';
import logger from './utils/logger.js';
import { initClient, getUsdcBalance } from './services/client.js';
import { initDashboard, appendLog, updateStatus, isDashboardActive } from './ui/dashboard.js';
import { startMakerDetector, stopMakerDetector } from './services/makerDetector.js';
import { executeMakerStrategy, getActiveMakerPositions, getSimStats } from './services/makerExecutor.js';
import { OrderbookWs } from './services/makerWs.js';
// ── Validate config ────────────────────────────────────────────────────────────
try {
validateMakerConfig();
} 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);
}
// ── WebSocket orderbook (for sim visualization) ──────────────────────────────
const orderbookWs = new OrderbookWs();
let activeWsTokens = { up: null, down: null };
// ── Status panel refresh ──────────────────────────────────────────────────────
async function buildStatusContent() {
const lines = [];
// Balance + Sim Stats
if (config.dryRun) {
const s = getSimStats();
const pnlColor = s.cumulativePnl >= 0 ? 'green' : 'red';
const winRate = s.wins + s.losses > 0 ? ((s.wins / (s.wins + s.losses)) * 100).toFixed(1) : '0.0';
const pnlSign = s.cumulativePnl >= 0 ? '+' : '';
lines.push('{bold}SIMULATION{/bold}');
lines.push(` Balance : {green-fg}$${s.balance.toFixed(2)}{/green-fg} (start: $${s.startBalance.toFixed(2)})`);
lines.push(` PnL : {${pnlColor}-fg}${pnlSign}$${s.cumulativePnl.toFixed(4)}{/${pnlColor}-fg}`);
lines.push(` Trades : ${s.totalTrades} total`);
lines.push(` {green-fg}WIN ${s.wins}x{/green-fg} | {red-fg}LOSS ${s.losses}x{/red-fg} | {gray-fg}SKIP ${s.skips}x{/gray-fg}`);
lines.push(` Win% : ${winRate}%`);
lines.push('');
// Recent trade history
if (s.history.length > 0) {
lines.push('{bold}TRADE HISTORY{/bold}');
const recent = s.history.slice(-8);
for (const h of recent) {
const rColor = h.result === 'win' ? 'green' : h.result === 'loss' ? 'red' : 'gray';
const pSign = h.pnl >= 0 ? '+' : '';
lines.push(` {gray-fg}${h.time}{/gray-fg} {${rColor}-fg}${h.result.toUpperCase().padEnd(4)}{/${rColor}-fg} ${(h.side || '-').padEnd(4)} {${rColor}-fg}${pSign}$${h.pnl.toFixed(4)}{/${rColor}-fg} → $${h.balance.toFixed(2)}`);
}
lines.push('');
}
} else {
let balance = '?';
try { balance = (await getUsdcBalance()).toFixed(2); } catch { /* ignore */ }
lines.push('{bold}BALANCE{/bold}');
lines.push(` USDC.e: {green-fg}$${balance}{/green-fg}`);
lines.push('');
lines.push('{bold}MODE{/bold}');
lines.push(' {green-fg}LIVE{/green-fg}');
lines.push('');
}
// Maker Config
lines.push('{bold}MAKER CONFIG{/bold}');
lines.push(` Assets : ${config.makerAssets.join(', ').toUpperCase()}`);
lines.push(` Duration : ${config.makerDurations.join(', ')}`);
lines.push(` Buy @ : $${config.makerBuyPrice} per share`);
lines.push(` Sell @ : $${config.makerSellPrice} per share`);
lines.push(` Size : ${config.makerTradeSize} shares/side`);
lines.push(` Cost/side: $${(config.makerTradeSize * config.makerBuyPrice).toFixed(2)}`);
lines.push(` Profit : $${((config.makerSellPrice - config.makerBuyPrice) * config.makerTradeSize).toFixed(2)}/cycle`);
lines.push(` CL : cancel buys at ${config.makerCLSeconds || 10}s, sell until 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()}/${pos.duration || '5m'}] ` : '';
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}`;
lines.push(` {cyan-fg}${assetTag}${label}{/cyan-fg}`);
lines.push(` Status : ${pos.status} | Time left: ${timeStr}`);
// UP side
const upFill = pos.up.buyFilled > 0
? `{green-fg}BOUGHT ${pos.up.buyFilled.toFixed(1)}sh{/green-fg}`
: '{gray-fg}waiting...{/gray-fg}';
const upSold = pos.up.totalSellFilled > 0
? ` → {green-fg}SOLD ${pos.up.totalSellFilled.toFixed(1)}sh{/green-fg}`
: pos.up.sellOrders.length > 0 ? ' → {yellow-fg}selling...{/yellow-fg}' : '';
lines.push(` UP ${upFill}${upSold}`);
// DOWN side
const downFill = pos.down.buyFilled > 0
? `{green-fg}BOUGHT ${pos.down.buyFilled.toFixed(1)}sh{/green-fg}`
: '{gray-fg}waiting...{/gray-fg}';
const downSold = pos.down.totalSellFilled > 0
? ` → {green-fg}SOLD ${pos.down.totalSellFilled.toFixed(1)}sh{/green-fg}`
: pos.down.sellOrders.length > 0 ? ' → {yellow-fg}selling...{/yellow-fg}' : '';
lines.push(` DOWN ${downFill}${downSold}`);
// P&L
const pnl = pos.totalRevenue - pos.totalCost;
const pnlColor = pnl >= 0 ? 'green' : 'red';
lines.push(` P&L: {${pnlColor}-fg}$${pnl.toFixed(4)}{/${pnlColor}-fg}`);
lines.push('');
}
}
// Orderbook display (always show when tokens are active)
if (activeWsTokens.up) {
lines.push('{bold}LIVE ORDERBOOK{/bold}');
for (const [label, tokenId] of [['UP', activeWsTokens.up], ['DOWN', activeWsTokens.down]]) {
if (!tokenId) continue;
const book = orderbookWs.getBook(tokenId);
const bestBid = orderbookWs.getBestBid(tokenId);
const bestAsk = orderbookWs.getBestAsk(tokenId);
const mid = bestBid && bestAsk ? ((bestBid + bestAsk) / 2) : 0;
lines.push(` {cyan-fg}${label}{/cyan-fg} mid: $${mid.toFixed(3)} | bid: $${bestBid.toFixed(2)} ask: $${bestAsk.toFixed(2)}`);
// Top 5 asks (reversed so lowest is closest to spread)
const topAsks = book.asks.slice(0, 5).reverse();
for (const ask of topAsks) {
const bar = '█'.repeat(Math.min(10, Math.round(ask.size / 100)));
lines.push(` {red-fg}$${ask.price.toFixed(2)} ${ask.size.toFixed(0).padStart(7)} ${bar}{/red-fg}`);
}
// Spread line
if (bestBid && bestAsk) {
const spread = bestAsk - bestBid;
lines.push(` {yellow-fg}── spread $${spread.toFixed(2)} ──{/yellow-fg}`);
}
// Top 5 bids
const topBids = book.bids.slice(0, 5);
for (const bid of topBids) {
const bar = '█'.repeat(Math.min(10, Math.round(bid.size / 100)));
lines.push(` {green-fg}$${bid.price.toFixed(2)} ${bid.size.toFixed(0).padStart(7)} ${bar}{/green-fg}`);
}
lines.push('');
}
}
return '\n' + lines.join('\n');
}
let refreshTimer = null;
function startRefresh() {
refreshTimer = setInterval(async () => {
if (!isDashboardActive()) return;
updateStatus(await buildStatusContent());
}, 2000);
buildStatusContent().then(updateStatus);
}
// ── Market handler with per-asset queue ──────────────────────────────────────
const pendingByAsset = new Map();
function slotKey(market) {
return `${market.asset}-${market.duration || '5m'}`;
}
async function runStrategy(market) {
const key = slotKey(market);
const tag = `${market.asset?.toUpperCase()}/${market.duration || '5m'}`;
// Connect WebSocket for orderbook visualization in sim mode
if (config.dryRun) {
activeWsTokens = { up: market.yesTokenId, down: market.noTokenId };
orderbookWs.subscribe(market.conditionId, [market.yesTokenId, market.noTokenId]);
}
try {
await executeMakerStrategy(market);
} catch (err) {
logger.error(`MAKER strategy error (${tag}): ${err.message}`);
}
// Disconnect WS after strategy ends
if (config.dryRun) {
activeWsTokens = { up: null, down: null };
}
// Process queued market
const queued = pendingByAsset.get(key);
if (queued) {
pendingByAsset.delete(key);
const endMs = new Date(queued.endTime).getTime();
const secsLeft = Math.round((endMs - Date.now()) / 1000);
if (secsLeft > 30) {
logger.success(`MAKER[${tag}]: executing queued market (${secsLeft}s left)`);
runStrategy(queued);
} else {
logger.warn(`MAKER[${tag}]: queued market expired (${secsLeft}s left)`);
}
}
}
async function handleNewMarket(market) {
const key = slotKey(market);
const tag = `${market.asset?.toUpperCase()}/${market.duration || '5m'}`;
const active = getActiveMakerPositions();
const isSlotBusy = active.some((p) => p.asset === market.asset && p.duration === (market.duration || '5m'));
if (isSlotBusy) {
pendingByAsset.set(key, market);
logger.warn(`MAKER[${tag}]: queued — will enter after current position clears`);
return;
}
runStrategy(market);
}
// ── Graceful shutdown ─────────────────────────────────────────────────────────
function shutdown() {
logger.warn('MAKER: shutting down...');
stopMakerDetector();
orderbookWs.shutdown();
if (refreshTimer) clearInterval(refreshTimer);
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
// ── Start ─────────────────────────────────────────────────────────────────────
const costPerSide = config.makerTradeSize * config.makerBuyPrice;
const profitPerCycle = (config.makerSellPrice - config.makerBuyPrice) * config.makerTradeSize;
logger.info(`MAKER starting — ${config.dryRun ? 'SIMULATION' : 'LIVE'}`);
logger.info(`Assets: ${config.makerAssets.join(', ').toUpperCase()} | Durations: ${config.makerDurations.join(', ')} | BUY @ $${config.makerBuyPrice} → SELL @ $${config.makerSellPrice}`);
logger.info(`Size: ${config.makerTradeSize} sh/side | Cost: $${costPerSide.toFixed(2)}/side | Profit: $${profitPerCycle.toFixed(2)}/cycle`);
startRefresh();
startMakerDetector(handleNewMarket);
+188
View File
@@ -0,0 +1,188 @@
/**
* 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
*/
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 { 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);
}
// ── 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();
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...`);
+11
View File
@@ -2,6 +2,7 @@ import { ClobClient } from '@polymarket/clob-client';
import { Wallet } from 'ethers';
import config from '../config/index.js';
import logger from '../utils/logger.js';
import { setupAxiosProxy, testProxy } from '../utils/proxy.js';
let clobClient = null;
let signer = null;
@@ -11,6 +12,16 @@ let signer = null;
* 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);
+185 -18
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';
@@ -105,11 +106,11 @@ let _txQueue = Promise.resolve();
* Calls are serialized via an internal queue so nonces never collide.
* Retries up to MAX_RETRIES times on transient errors.
*/
function execSafeCall(to, data, description = '') {
export function execSafeCall(to, data, description = '') {
// Enqueue: this call will only start after the previous one resolves/rejects
const result = _txQueue.then(() => _doExecSafeCall(to, data, description));
// Don't let a failure poison the queue for subsequent calls
_txQueue = result.catch(() => {});
_txQueue = result.catch(() => { });
return result;
}
@@ -117,11 +118,14 @@ async function _doExecSafeCall(to, data, description = '') {
if (description) logger.info(`MM: exec safe tx — ${description}`);
let lastErr;
// Track gas price multiplier for replacement transactions
let gasMultiplier = 1;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
const provider = await getPolygonProvider();
const wallet = getSigner().connect(provider);
const safe = new ethers.Contract(config.proxyWallet, SAFE_ABI, wallet);
const wallet = getSigner().connect(provider);
const safe = new ethers.Contract(config.proxyWallet, SAFE_ABI, wallet);
const nonce = await safe.nonce();
@@ -142,15 +146,32 @@ 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 tx = await safe.execTransaction(
to, 0, data, 0, 0, 0, 0,
@@ -168,7 +189,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);
}
}
@@ -332,7 +360,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 +452,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 +469,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'),
});
}
@@ -507,3 +535,142 @@ 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();
/**
* 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;
}
// Estimate payout from numerators (for logging)
const payoutFractions = await Promise.all(
[0, 1].map((i) =>
ctf.payoutNumerators(conditionId, i)
.then((n) => n.toNumber() / denominator.toNumber())
)
);
const expectedUsdc = balances.reduce(
(sum, shares, i) => sum + shares * (payoutFractions[i] ?? 0), 0
);
const label = conditionId.slice(0, 12) + '...';
const isWin = expectedUsdc >= 0.01;
// SNIPER: only redeem WINNERS — cache losses to skip next time
if (!isWin) {
_skippedLosses.add(conditionId);
if (config.dryRun) {
logger.info(`SNIPER[SIM] skip loss: ${label}${totalShares.toFixed(3)} shares (cached, no future checks)`);
} else {
logger.info(`SNIPER redeemer: skip loss ${label} — cached for future cycles`);
}
continue;
}
if (config.dryRun) {
logger.money(`SNIPER[SIM] redeem: ${label}${totalShares.toFixed(3)} shares → ~$${expectedUsdc.toFixed(2)} USDC (WIN)`);
continue;
}
logger.info(`SNIPER redeemer: ${label} resolved WIN — ${totalShares.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}`);
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,
},
{
+145
View File
@@ -0,0 +1,145 @@
/**
* makerDetector.js
* Detects upcoming markets for the Maker strategy (buy low, sell high).
* Supports multiple assets AND multiple durations (e.g. 5m,15m).
*/
import config from '../config/index.js';
import logger from '../utils/logger.js';
import { proxyFetch } from '../utils/proxy.js';
const DURATION_SECS = { '5m': 300, '15m': 900 };
let pollTimer = null;
let onMarketCb = null;
const seenKeys = new Set();
function slotSec(duration) {
return DURATION_SECS[duration] || 300;
}
function currentSlot(duration) {
const sec = slotSec(duration);
return Math.floor(Date.now() / 1000 / sec) * sec;
}
function nextSlot(duration) {
return currentSlot(duration) + slotSec(duration);
}
async function fetchBySlug(asset, duration, slotTimestamp) {
const slug = `${asset}-updown-${duration}-${slotTimestamp}`;
try {
const resp = await proxyFetch(`${config.gammaHost}/markets/slug/${slug}`);
if (!resp.ok) return null;
const data = await resp.json();
return data?.conditionId ? data : null;
} catch {
return null;
}
}
function extractMarketData(market, asset, duration) {
const conditionId = market.conditionId || market.condition_id || '';
if (!conditionId) return null;
let tokenIds = market.clobTokenIds ?? market.clob_token_ids;
if (typeof tokenIds === 'string') {
try { tokenIds = JSON.parse(tokenIds); } catch { tokenIds = null; }
}
let yesTokenId, noTokenId;
if (Array.isArray(tokenIds) && tokenIds.length >= 2) {
[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;
}
if (!yesTokenId || !noTokenId) return null;
return {
asset,
duration,
conditionId,
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'),
};
}
async function scheduleAsset(asset, duration, slotTimestamp) {
const key = `${asset}-${duration}-${slotTimestamp}`;
if (seenKeys.has(key)) return;
const market = await fetchBySlug(asset, duration, slotTimestamp);
if (!market) return;
const data = extractMarketData(market, asset, duration);
if (!data) {
logger.warn(`MAKER: skipping ${asset.toUpperCase()} ${duration} slot ${slotTimestamp} — missing token IDs`);
seenKeys.add(key);
return;
}
seenKeys.add(key);
const openAt = data.eventStartTime ? new Date(data.eventStartTime).getTime() : slotTimestamp * 1000;
const elapsedSec = Math.round((Date.now() - openAt) / 1000);
if (elapsedSec > 15) {
logger.info(`MAKER: ${asset.toUpperCase()} ${duration} next slot already ${elapsedSec}s old — skipping`);
return;
}
const secsUntilOpen = Math.round((openAt - Date.now()) / 1000);
if (secsUntilOpen > 0) {
logger.success(`MAKER: ${asset.toUpperCase()} ${duration} found "${data.question.slice(0, 40)}" — placing orders (${secsUntilOpen}s before open)`);
} else {
logger.success(`MAKER: ${asset.toUpperCase()} ${duration} found "${data.question.slice(0, 40)}" — placing orders now`);
}
if (onMarketCb) onMarketCb(data);
}
async function poll() {
try {
const tasks = [];
for (const duration of config.makerDurations) {
const next = nextSlot(duration);
for (const asset of config.makerAssets) {
tasks.push(scheduleAsset(asset, duration, next));
}
}
await Promise.all(tasks);
} catch (err) {
logger.error('MAKER detector poll error:', err.message);
}
}
export function startMakerDetector(onNewMarket) {
onMarketCb = onNewMarket;
seenKeys.clear();
poll();
pollTimer = setInterval(poll, config.makerPollInterval);
const durStr = config.makerDurations.join(', ');
for (const duration of config.makerDurations) {
const ns = nextSlot(duration);
const secsUntil = ns - Math.floor(Date.now() / 1000);
logger.info(`MAKER detector — ${duration}: next slot *-updown-${duration}-${ns} (opens in ${secsUntil}s)`);
}
logger.info(`MAKER detector started — assets: ${config.makerAssets.join(', ').toUpperCase()} | durations: ${durStr}`);
logger.info(`Strategy: BUY @ $${config.makerBuyPrice} → SELL @ $${config.makerSellPrice} | ${config.makerTradeSize} shares/side`);
}
export function stopMakerDetector() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
+525
View File
@@ -0,0 +1,525 @@
/**
* makerExecutor.js
* Buy Low, Sell High Market Maker — no splitPosition.
*
* Flow:
* 1. Ensure ERC1155 exchange approval (one-time, needed to sell tokens)
* 2. Place concurrent limit BUY on UP + DOWN at makerBuyPrice (e.g. 2c)
* 3. Monitor both orders in parallel (multi-thread style)
* 4. When one side fills (even partial):
* a. Wait briefly for on-chain settlement
* b. Place limit SELL for filled shares at makerSellPrice (e.g. 3c)
* c. Cancel the other side's buy order
* 5. Partial fills → partial sells placed immediately
* 6. CL at 10s before close: cancel unfilled buy orders
* 7. Place sells for any filled positions (retry 3x if settlement pending)
* 8. Monitor sell orders until filled or market close
*/
import { Side, OrderType } from '@polymarket/clob-client';
import config from '../config/index.js';
import { getClient } from './client.js';
import { ensureExchangeApproval } from './ctf.js';
import logger from '../utils/logger.js';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const CL_SECONDS = 10; // cancel unfilled sells 10s before market close
const SELL_DELAY_MS = 2000; // wait for on-chain settlement before placing sell
const MAX_SELL_RETRIES = 3;
const MIN_ORDER_SIZE = 5; // Polymarket minimum order size
// In-memory store of active maker positions
const activePositions = new Map();
export function getActiveMakerPositions() {
return Array.from(activePositions.values());
}
// ── Simulation stats ─────────────────────────────────────────────────────────
const simStats = {
startBalance: config.makerSimBalance,
balance: config.makerSimBalance,
wins: 0, // sell filled → realized profit
losses: 0, // buy filled, sell NOT filled → loss = buy cost
skips: 0, // no fills at all → $0
totalTrades: 0,
cumulativePnl: 0,
history: [], // [{ time, side, result, pnl, balance }]
};
export function getSimStats() {
return { ...simStats, history: [...simStats.history] };
}
function recordTrade(result, side, pnl) {
simStats.totalTrades++;
simStats.cumulativePnl += pnl;
simStats.balance += pnl;
if (result === 'win') simStats.wins++;
else if (result === 'loss') simStats.losses++;
else simStats.skips++;
simStats.history.push({
time: new Date().toISOString().replace('T', ' ').substring(11, 19),
side: side || '-',
result,
pnl,
balance: simStats.balance,
});
if (simStats.history.length > 50) simStats.history.splice(0, simStats.history.length - 50);
}
// ── Approval tracking ────────────────────────────────────────────────────────
let approvalChecked = false;
async function ensureApproval(negRisk) {
if (config.dryRun || approvalChecked) return;
try {
await ensureExchangeApproval(negRisk);
approvalChecked = true;
} catch (err) {
logger.error(`MAKER: exchange approval failed — ${err.message}`);
}
}
// ── Order helpers ─────────────────────────────────────────────────────────────
async function placeLimitBuy(tokenId, shares, price, tickSize, negRisk) {
const size = Math.max(MIN_ORDER_SIZE, Math.floor(shares));
if (config.dryRun) {
return { success: true, orderId: `sim-buy-${Date.now()}-${tokenId.slice(-6)}`, size };
}
const client = getClient();
try {
const res = await client.createAndPostOrder(
{ tokenID: tokenId, side: Side.BUY, price, size },
{ tickSize, negRisk },
OrderType.GTC,
);
if (!res?.success) return { success: false };
return { success: true, orderId: res.orderID, size };
} catch (err) {
logger.error('MAKER limit buy error:', err.message);
return { success: false };
}
}
async function placeLimitSellWithRetry(tokenId, shares, price, tickSize, negRisk, tag) {
const size = Math.max(MIN_ORDER_SIZE, Math.floor(shares));
if (config.dryRun) {
return { success: true, orderId: `sim-sell-${Date.now()}-${tokenId.slice(-6)}` };
}
const client = getClient();
for (let attempt = 1; attempt <= MAX_SELL_RETRIES; attempt++) {
try {
const res = await client.createAndPostOrder(
{ tokenID: tokenId, side: Side.SELL, price, size },
{ tickSize, negRisk },
OrderType.GTC,
);
if (res?.success) {
return { success: true, orderId: res.orderID };
}
const errMsg = res?.errorMsg || 'unknown';
logger.warn(`MAKER${tag}: sell attempt ${attempt}/${MAX_SELL_RETRIES} failed: ${errMsg}`);
} catch (err) {
logger.warn(`MAKER${tag}: sell attempt ${attempt}/${MAX_SELL_RETRIES} error: ${err.message}`);
}
if (attempt < MAX_SELL_RETRIES) {
// Wait longer each retry — tokens might not have settled yet
const delay = SELL_DELAY_MS * attempt;
logger.info(`MAKER${tag}: waiting ${delay / 1000}s for on-chain settlement before retry...`);
await sleep(delay);
}
}
return { success: false };
}
async function cancelOrder(orderId) {
if (config.dryRun || !orderId || orderId.startsWith('sim-')) return true;
try {
await getClient().cancelOrder({ orderID: orderId });
return true;
} catch (err) {
logger.warn('MAKER cancel order error:', err.message);
return false;
}
}
// ── Order status ──────────────────────────────────────────────────────────────
async function getOrderFill(orderId) {
if (!orderId || orderId.startsWith('sim-')) {
return { matched: 0, status: 'LIVE', fullyFilled: false };
}
try {
const order = await getClient().getOrder(orderId);
if (!order) return { matched: 0, status: 'UNKNOWN', fullyFilled: false };
const matched = parseFloat(order.size_matched || '0');
return {
matched,
status: order.status,
fullyFilled: order.status === 'MATCHED',
};
} catch {
return { matched: 0, status: 'ERROR', fullyFilled: false };
}
}
// Simulation: check if market price would fill our order
async function simCheckFill(tokenId, side, price) {
try {
const mp = await getClient().getMidpoint(tokenId);
const midPrice = parseFloat(mp?.mid ?? mp ?? '0') || 0;
if (side === 'buy' && midPrice <= price) return midPrice;
if (side === 'sell' && midPrice >= price) return midPrice;
return null;
} catch {
return null;
}
}
// ── Core strategy ─────────────────────────────────────────────────────────────
export async function executeMakerStrategy(market) {
const { asset, duration, conditionId, question, endTime, yesTokenId, noTokenId, negRisk, tickSize } = market;
const tag = asset ? `[${asset.toUpperCase()}/${duration || '5m'}]` : '';
const label = question.substring(0, 40);
const sim = config.dryRun ? '[SIM] ' : '';
const { makerBuyPrice, makerSellPrice, makerTradeSize, makerMonitorMs } = config;
// Check sim balance
const costPerSide = makerTradeSize * makerBuyPrice;
if (config.dryRun && simStats.balance < costPerSide) {
logger.warn(`MAKER${tag}: ${sim}insufficient sim balance $${simStats.balance.toFixed(2)} (need $${costPerSide.toFixed(2)}) — skipping`);
recordTrade('skip', null, 0);
return;
}
logger.info(`MAKER${tag}: ${sim}entering — ${label}`);
logger.info(`MAKER${tag}: BUY @ $${makerBuyPrice} → SELL @ $${makerSellPrice} | ${makerTradeSize} shares/side | cost $${costPerSide.toFixed(2)}`);
// ── 0. Ensure ERC1155 exchange approval (one-time) ───────────
await ensureApproval(negRisk);
// ── 1. Place BUY UP + DOWN concurrently ──────────────────────
logger.trade(`MAKER${tag}: ${sim}placing BUY UP + DOWN @ $${makerBuyPrice}`);
const [upBuy, downBuy] = await Promise.all([
placeLimitBuy(yesTokenId, makerTradeSize, makerBuyPrice, tickSize, negRisk),
placeLimitBuy(noTokenId, makerTradeSize, makerBuyPrice, tickSize, negRisk),
]);
if (!upBuy.success && !downBuy.success) {
logger.error(`MAKER${tag}: both buy orders failed — aborting`);
recordTrade('skip', null, 0);
return;
}
if (upBuy.success) logger.trade(`MAKER${tag}: ${sim}UP BUY placed | order ${upBuy.orderId}`);
if (downBuy.success) logger.trade(`MAKER${tag}: ${sim}DOWN BUY placed | order ${downBuy.orderId}`);
// ── 2. Build position state ──────────────────────────────────
const pos = {
asset: asset || 'btc',
duration: duration || '5m',
conditionId,
question,
endTime,
tickSize,
negRisk,
status: 'buying',
enteredAt: new Date().toISOString(),
up: {
tokenId: yesTokenId,
buyOrderId: upBuy.success ? upBuy.orderId : null,
buyFilled: 0,
sellOrders: [],
totalSellFilled: 0,
cancelled: !upBuy.success,
},
down: {
tokenId: noTokenId,
buyOrderId: downBuy.success ? downBuy.orderId : null,
buyFilled: 0,
sellOrders: [],
totalSellFilled: 0,
cancelled: !downBuy.success,
},
winner: null,
totalCost: 0,
totalRevenue: 0,
};
activePositions.set(conditionId, pos);
// ── 3. Monitor buy → sell (concurrent) ───────────────────────
try {
await monitorBuyPhase(pos, tag, sim);
await monitorSellPhase(pos, tag, sim);
} catch (err) {
logger.error(`MAKER${tag}: strategy error — ${err.message}`);
}
// ── Final result + sim stats ─────────────────────────────────
const pnl = pos.totalRevenue - pos.totalCost;
const winnerSide = pos.winner?.toUpperCase() || '-';
if (pos.status === 'done' && pos.totalRevenue > 0) {
// WIN: sell filled
recordTrade('win', winnerSide, pnl);
logger.money(`MAKER${tag}: ${sim}WIN | ${winnerSide} | cost $${pos.totalCost.toFixed(4)} → revenue $${pos.totalRevenue.toFixed(4)} | P&L +$${pnl.toFixed(4)}`);
} else if (pos.totalCost > 0) {
// LOSS: buy filled but sell didn't fill (expired-holding)
recordTrade('loss', winnerSide, -pos.totalCost);
logger.warn(`MAKER${tag}: ${sim}LOSS | ${winnerSide} | cost $${pos.totalCost.toFixed(4)} (sell not filled, held to expiry)`);
} else {
// SKIP: nothing filled
recordTrade('skip', null, 0);
logger.info(`MAKER${tag}: ${sim}SKIP | no fills, $0 cost`);
}
// Log running stats
const s = simStats;
const winRate = s.wins + s.losses > 0 ? ((s.wins / (s.wins + s.losses)) * 100).toFixed(1) : '0.0';
logger.info(`MAKER${tag}: ${sim}STATS | W:${s.wins} L:${s.losses} S:${s.skips} | Win%: ${winRate}% | PnL: $${s.cumulativePnl.toFixed(4)} | Balance: $${s.balance.toFixed(2)}`);
activePositions.delete(conditionId);
}
// ── Buy phase: monitor both sides concurrently ───────────────────────────────
async function monitorBuyPhase(pos, tag, sim) {
const { makerBuyPrice, makerSellPrice, makerMonitorMs } = config;
const monitorSide = async (sideKey) => {
const side = pos[sideKey];
const otherKey = sideKey === 'up' ? 'down' : 'up';
const sideName = sideKey.toUpperCase();
if (!side.buyOrderId) return;
while (!pos.winner) {
const msLeft = new Date(pos.endTime).getTime() - Date.now();
// CL: cancel unfilled buy at 10s before market close
if (msLeft <= CL_SECONDS * 1000) {
if (side.buyOrderId && !side.cancelled) {
logger.warn(`MAKER${tag}: CL ${CL_SECONDS}s — cancelling ${sideName} buy`);
await cancelOrder(side.buyOrderId);
side.cancelled = true;
}
break;
}
// Check fill
let fill;
if (config.dryRun) {
const hitPrice = await simCheckFill(side.tokenId, 'buy', makerBuyPrice);
if (hitPrice !== null) {
fill = { matched: config.makerTradeSize, fullyFilled: true };
} else {
fill = { matched: 0, fullyFilled: false };
}
} else {
fill = await getOrderFill(side.buyOrderId);
}
// New fills detected → wait for settlement, then place sell
const newFill = fill.matched - side.buyFilled;
if (newFill > 0) {
side.buyFilled = fill.matched;
pos.totalCost += newFill * makerBuyPrice;
logger.money(`MAKER${tag}: ${sim}${sideName} BUY filled ${newFill.toFixed(2)} shares @ $${makerBuyPrice} (total: ${side.buyFilled.toFixed(2)}/${config.makerTradeSize})`);
// Check total unsold shares — only place sell if >= MIN_ORDER_SIZE
const soldShares = side.sellOrders.reduce((sum, so) => sum + so.shares, 0);
const unsold = side.buyFilled - soldShares;
if (unsold >= MIN_ORDER_SIZE) {
// Wait for on-chain token settlement before placing sell
if (!config.dryRun) {
logger.info(`MAKER${tag}: waiting ${SELL_DELAY_MS / 1000}s for on-chain settlement...`);
await sleep(SELL_DELAY_MS);
}
const sellResult = await placeLimitSellWithRetry(
side.tokenId, unsold, makerSellPrice,
pos.tickSize, pos.negRisk, tag,
);
if (sellResult.success) {
side.sellOrders.push({
orderId: sellResult.orderId,
shares: unsold,
filled: false,
fillPrice: null,
});
logger.trade(`MAKER${tag}: ${sim}${sideName} SELL placed ${unsold.toFixed(2)} shares @ $${makerSellPrice}`);
} else {
logger.error(`MAKER${tag}: ${sideName} SELL failed after ${MAX_SELL_RETRIES} retries — tokens held to resolution`);
}
} else {
logger.info(`MAKER${tag}: ${sideName} unsold ${unsold.toFixed(2)} shares < ${MIN_ORDER_SIZE} min — waiting for more fills`);
}
}
// Fully filled → winner
if (fill.fullyFilled) {
pos.winner = sideKey;
pos.status = 'selling';
logger.success(`MAKER${tag}: ${sim}${sideName} fully filled! Cancelling ${otherKey.toUpperCase()} buy...`);
const other = pos[otherKey];
if (other.buyOrderId && !other.cancelled) {
await cancelOrder(other.buyOrderId);
other.cancelled = true;
logger.info(`MAKER${tag}: ${otherKey.toUpperCase()} buy cancelled`);
}
return;
}
await sleep(makerMonitorMs);
}
};
await Promise.race([
monitorSide('up'),
monitorSide('down'),
]);
// Cleanup: cancel any remaining unfilled buy orders
for (const key of ['up', 'down']) {
const s = pos[key];
if (s.buyOrderId && !s.cancelled) {
await cancelOrder(s.buyOrderId);
s.cancelled = true;
logger.info(`MAKER${tag}: cancelled ${key.toUpperCase()} buy order`);
}
}
// Place sells for filled buys that don't have sell orders yet
for (const key of ['up', 'down']) {
const s = pos[key];
const soldShares = s.sellOrders.reduce((sum, so) => sum + so.shares, 0);
const unsold = s.buyFilled - soldShares;
if (unsold >= MIN_ORDER_SIZE) {
logger.info(`MAKER${tag}: placing sell for ${key.toUpperCase()} ${unsold.toFixed(2)} unsold shares`);
if (!config.dryRun) {
logger.info(`MAKER${tag}: waiting ${SELL_DELAY_MS / 1000}s for on-chain settlement...`);
await sleep(SELL_DELAY_MS);
}
const sellResult = await placeLimitSellWithRetry(
s.tokenId, unsold, makerSellPrice,
pos.tickSize, pos.negRisk, tag,
);
if (sellResult.success) {
s.sellOrders.push({
orderId: sellResult.orderId,
shares: unsold,
filled: false,
fillPrice: null,
});
logger.trade(`MAKER${tag}: ${sim}${key.toUpperCase()} SELL placed ${unsold.toFixed(2)} shares @ $${makerSellPrice}`);
} else {
logger.error(`MAKER${tag}: ${key.toUpperCase()} SELL failed after ${MAX_SELL_RETRIES} retries — tokens held to resolution`);
}
}
}
if (!pos.winner) {
const anyFill = pos.up.buyFilled > 0 || pos.down.buyFilled > 0;
if (anyFill) {
pos.winner = pos.up.buyFilled >= pos.down.buyFilled ? 'up' : 'down';
pos.status = 'selling';
logger.info(`MAKER${tag}: partial fill — monitoring sell for ${pos.winner.toUpperCase()} ${pos[pos.winner].buyFilled.toFixed(2)} shares`);
} else {
pos.status = 'done';
logger.info(`MAKER${tag}: no fills — buy orders expired naturally, $0 loss`);
}
}
}
// ── Sell phase: monitor sell orders until market close ────────────────────────
async function monitorSellPhase(pos, tag, sim) {
if (pos.status === 'done') return;
const { makerSellPrice, makerMonitorMs } = config;
const winnerKey = pos.winner;
if (!winnerKey) return;
const side = pos[winnerKey];
const sideName = winnerKey.toUpperCase();
if (side.sellOrders.length === 0) {
// Sell placement failed — tokens held to resolution
pos.status = 'expired-holding';
logger.warn(`MAKER${tag}: no sell orders placed — held to resolution`);
return;
}
logger.info(`MAKER${tag}: monitoring ${side.sellOrders.length} sell order(s) for ${sideName}`);
while (true) {
const msLeft = new Date(pos.endTime).getTime() - Date.now();
// Market closed — unfilled sells resolve on-chain
if (msLeft <= 0) {
pos.status = side.totalSellFilled > 0 ? 'done' : 'expired-holding';
if (pos.status === 'expired-holding') {
logger.warn(`MAKER${tag}: market closed — unfilled sells held to resolution`);
}
break;
}
// Check all sell orders concurrently
const checks = await Promise.all(
side.sellOrders.map(async (so) => {
if (so.filled) return true;
let filled = false;
if (config.dryRun) {
const hitPrice = await simCheckFill(side.tokenId, 'sell', makerSellPrice);
if (hitPrice !== null) {
filled = true;
so.fillPrice = hitPrice;
}
} else {
const fill = await getOrderFill(so.orderId);
if (fill.fullyFilled || fill.matched >= so.shares * 0.99) {
filled = true;
so.fillPrice = makerSellPrice;
}
}
if (filled) {
so.filled = true;
side.totalSellFilled += so.shares;
pos.totalRevenue += so.shares * (so.fillPrice || makerSellPrice);
logger.money(`MAKER${tag}: ${sim}${sideName} SELL filled ${so.shares.toFixed(2)} shares @ $${(so.fillPrice || makerSellPrice).toFixed(3)}`);
}
return so.filled;
})
);
// All sells filled → done
if (checks.every(Boolean) && side.sellOrders.length > 0) {
pos.status = 'done';
logger.success(`MAKER${tag}: ${sim}all sells filled!`);
break;
}
await sleep(makerMonitorMs);
}
}
+363
View File
@@ -0,0 +1,363 @@
/**
* makerWs.js
* WebSocket client for Polymarket CLOB orderbook + trade data.
* Used by the simulation to display real-time orderbook and simulate fills.
*
* Message formats from CLOB WS:
* 1. Book snapshot (initial): [{asset_id, bids, asks, timestamp, hash}] (array, no event_type)
* 2. price_change: {event_type:"price_change", price_changes:[{asset_id, price, size, side, best_bid, best_ask}]}
* 3. last_trade_price: {event_type:"last_trade_price", asset_id, price}
*/
import WebSocket from 'ws';
import logger from '../utils/logger.js';
const WS_URL = 'wss://ws-subscriptions-clob.polymarket.com/ws/market';
const PING_INTERVAL = 30_000;
const RECONNECT_DELAY = 3000;
const MAX_RECONNECT_DELAY = 30_000;
export class OrderbookWs {
constructor() {
this.ws = null;
this.pingTimer = null;
this.reconnectTimer = null;
this.reconnectDelay = RECONNECT_DELAY;
this.isShutdown = false;
// Subscribed assets
this.assetIds = [];
this.conditionId = null;
// Orderbook state per asset: Map<price, size>
this.bids = new Map(); // assetId → Map<price, size>
this.asks = new Map(); // assetId → Map<price, size>
// Best bid/ask per asset (from price_change events)
this.bestBid = new Map(); // assetId → number
this.bestAsk = new Map(); // assetId → number
// Recent trades per asset
this.trades = new Map(); // assetId → [{ price, side, size, timestamp }]
// Last trade price per asset
this.lastPrice = new Map(); // assetId → number
// Callbacks
this.onBookUpdate = null;
this.onTradeUpdate = null;
this.onPriceUpdate = null;
}
subscribe(conditionId, assetIds) {
// Shutdown existing connection if any
if (this.ws) {
this.cleanup(false);
}
this.conditionId = conditionId;
this.assetIds = assetIds;
this.isShutdown = false;
for (const id of assetIds) {
this.bids.set(id, new Map());
this.asks.set(id, new Map());
this.trades.set(id, []);
}
this.connect();
}
connect() {
if (this.isShutdown) return;
this.ws = new WebSocket(WS_URL);
this.ws.on('open', () => {
logger.info('MAKER WS: connected to orderbook feed');
this.reconnectDelay = RECONNECT_DELAY;
// Subscribe to all assets in one message
const msg = {
auth: {},
type: 'subscribe',
markets: [],
assets_ids: this.assetIds,
channels: ['book'],
};
this.ws.send(JSON.stringify(msg));
this.startPing();
});
this.ws.on('message', (raw) => {
this.handleMessage(raw);
});
this.ws.on('ping', () => {
this.ws?.pong();
});
this.ws.on('close', (code, reason) => {
logger.warn(`MAKER WS: disconnected (${code})`);
this.cleanup(true);
});
this.ws.on('error', (err) => {
logger.warn(`MAKER WS error: ${err.message}`);
this.cleanup(true);
});
}
handleMessage(raw) {
let msg;
try {
msg = JSON.parse(raw.toString());
} catch {
return;
}
// Initial book snapshot comes as an array [{...}]
if (Array.isArray(msg)) {
for (const evt of msg) {
if (evt.asset_id && evt.bids) {
this.handleBookSnapshot(evt.asset_id, evt);
}
}
return;
}
// Subsequent messages are objects with event_type
switch (msg.event_type) {
case 'book':
if (msg.asset_id) {
this.handleBookSnapshot(msg.asset_id, msg);
}
break;
case 'price_change':
this.handlePriceChange(msg);
break;
case 'last_trade_price':
if (msg.asset_id) {
this.handleLastPrice(msg.asset_id, msg);
}
break;
default:
// Full book updates without event_type (non-array single object)
if (msg.asset_id && msg.bids) {
this.handleBookSnapshot(msg.asset_id, msg);
}
break;
}
}
handleBookSnapshot(assetId, evt) {
if (!this.assetIds.includes(assetId)) return;
// Replace entire book for this asset
const bidMap = new Map();
for (const b of (evt.bids || [])) {
const price = parseFloat(b.price);
const size = parseFloat(b.size);
if (size > 0) bidMap.set(price, size);
}
this.bids.set(assetId, bidMap);
const askMap = new Map();
for (const a of (evt.asks || [])) {
const price = parseFloat(a.price);
const size = parseFloat(a.size);
if (size > 0) askMap.set(price, size);
}
this.asks.set(assetId, askMap);
if (this.onBookUpdate) {
this.onBookUpdate(assetId, this.getBook(assetId));
}
}
handlePriceChange(msg) {
const changes = msg.price_changes || [];
for (const change of changes) {
const assetId = change.asset_id;
if (!assetId || !this.assetIds.includes(assetId)) continue;
const price = parseFloat(change.price);
const size = parseFloat(change.size);
const side = change.side; // "BUY" = bid, "SELL" = ask
if (side === 'BUY') {
const bidMap = this.bids.get(assetId);
if (bidMap) {
if (size > 0) {
bidMap.set(price, size);
} else {
bidMap.delete(price); // size 0 = remove level
}
}
} else if (side === 'SELL') {
const askMap = this.asks.get(assetId);
if (askMap) {
if (size > 0) {
askMap.set(price, size);
} else {
askMap.delete(price);
}
}
}
// Update best bid/ask from the event
if (change.best_bid) this.bestBid.set(assetId, parseFloat(change.best_bid));
if (change.best_ask) this.bestAsk.set(assetId, parseFloat(change.best_ask));
}
// Notify for each affected asset
const affectedAssets = new Set(changes.map(c => c.asset_id).filter(id => this.assetIds.includes(id)));
for (const assetId of affectedAssets) {
if (this.onBookUpdate) {
this.onBookUpdate(assetId, this.getBook(assetId));
}
}
}
handleLastPrice(assetId, evt) {
if (!this.assetIds.includes(assetId)) return;
const price = parseFloat(evt.price || '0');
this.lastPrice.set(assetId, price);
if (this.onPriceUpdate) {
this.onPriceUpdate(assetId, price);
}
}
/**
* Get sorted orderbook for an asset
*/
getBook(assetId) {
const bidMap = this.bids.get(assetId) || new Map();
const askMap = this.asks.get(assetId) || new Map();
const bids = Array.from(bidMap.entries())
.map(([price, size]) => ({ price, size }))
.sort((a, b) => b.price - a.price);
const asks = Array.from(askMap.entries())
.map(([price, size]) => ({ price, size }))
.sort((a, b) => a.price - b.price);
return { bids, asks };
}
/**
* Check if a simulated order would fill based on current orderbook.
*/
checkSimFill(assetId, side, price, size) {
const book = this.getBook(assetId);
if (side === 'buy') {
const eligible = book.asks.filter((a) => a.price <= price);
if (eligible.length === 0) return null;
let filled = 0;
let totalCost = 0;
for (const ask of eligible) {
const take = Math.min(ask.size, size - filled);
filled += take;
totalCost += take * ask.price;
if (filled >= size) break;
}
if (filled > 0) {
return { filled: Math.min(filled, size), avgPrice: totalCost / filled };
}
} else {
const eligible = book.bids.filter((b) => b.price >= price);
if (eligible.length === 0) return null;
let filled = 0;
let totalRevenue = 0;
for (const bid of eligible) {
const take = Math.min(bid.size, size - filled);
filled += take;
totalRevenue += take * bid.price;
if (filled >= size) break;
}
if (filled > 0) {
return { filled: Math.min(filled, size), avgPrice: totalRevenue / filled };
}
}
return null;
}
getLastPrice(assetId) {
return this.lastPrice.get(assetId) || 0;
}
getBestBid(assetId) {
// Try from price_change data first, fallback to computed from book
const cached = this.bestBid.get(assetId);
if (cached) return cached;
const book = this.getBook(assetId);
return book.bids[0]?.price || 0;
}
getBestAsk(assetId) {
const cached = this.bestAsk.get(assetId);
if (cached) return cached;
const book = this.getBook(assetId);
return book.asks[0]?.price || 0;
}
getRecentTrades(assetId, limit = 10) {
const trades = this.trades.get(assetId) || [];
return trades.slice(-limit);
}
startPing() {
this.stopPing();
this.pingTimer = setInterval(() => {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.ping(); // Use proper WebSocket ping frames
}
}, PING_INTERVAL);
}
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;
}
if (reconnect && !this.isShutdown) {
this.reconnectTimer = setTimeout(() => {
this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_RECONNECT_DELAY);
this.connect();
}, this.reconnectDelay);
}
}
shutdown() {
this.isShutdown = true;
this.cleanup(false);
}
}
+13 -12
View File
@@ -12,13 +12,14 @@
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 ──────────────────────────────────────────────────────────────
@@ -35,7 +36,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 +62,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 +70,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 +99,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`);
+220 -62
View File
@@ -133,6 +133,14 @@ async function simPriceHitTarget(tokenId) {
}
}
// Get current mid price for a token (0 on error)
async function getMidprice(tokenId) {
try {
const mp = await getClient().getMidpoint(tokenId);
return parseFloat(mp?.mid ?? mp ?? '0') || 0;
} catch { return 0; }
}
// ── Core monitoring loop ──────────────────────────────────────────────────────
async function monitorAndManage(pos) {
@@ -189,11 +197,27 @@ async function monitorAndManage(pos) {
break;
}
// ── Cut-loss time ───────────────────────────────────────
// ── 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;
}
// ── 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);
const oneLegFilled = pos.yes.filled !== pos.no.filled;
if (!config.mmAdaptiveCL && oneLegFilled) {
// Legacy: one side sold → immediate market sell on the other
const unfilledKey = pos.yes.filled ? 'no' : 'yes';
await cutLossOneLegFilled(pos, unfilledKey);
pos.status = 'done';
} else {
// Neither filled → cancel both + merge back to USDC
await cutLossNeitherFilled(pos);
}
break;
}
@@ -208,77 +232,211 @@ async function monitorAndManage(pos) {
}
}
async function cutLoss(pos) {
const { conditionId, tickSize, negRisk } = pos;
const neitherFilled = !pos.yes.filled && !pos.no.filled;
// 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;
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);
logger.warn(`MM: cancelling ${unfilledKey.toUpperCase()} limit order and market-selling...`);
await cancelOrder(s.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),
]);
const actualShares = await getTokenBalance(s.tokenId);
const sellShares = actualShares !== null ? actualShares : s.shares;
// 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;
} 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;
logger.warn(`MM: cancelling ${side.toUpperCase()} limit order and market-selling...`);
await cancelOrder(s.orderId);
// Fetch actual on-chain balance — partial fills reduce this below s.shares
const actualShares = await getTokenBalance(s.tokenId);
const sellShares = actualShares !== null ? actualShares : s.shares;
if (sellShares < 0.001) {
logger.warn(`MM: ${side.toUpperCase()} balance is 0 — already fully sold via partial fills`);
s.fillPrice = config.mmSellPrice; // assume sold at target
s.filled = true;
continue;
}
logger.warn(`MM: ${side.toUpperCase()} actual balance: ${sellShares.toFixed(3)} shares (original: ${s.shares})`);
const result = await marketSell(s.tokenId, sellShares, tickSize, negRisk);
s.fillPrice = result.fillPrice;
s.filled = true;
// PnL uses actual sold amount (not original pos.shares)
const pnl = (s.fillPrice - s.entryPrice) * sellShares;
logger.warn(`MM: ${side.toUpperCase()} cut @ $${s.fillPrice.toFixed(3)} | sold ${sellShares.toFixed(3)} sh | P&L $${pnl.toFixed(2)}`);
}
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 cancelOrder(pos.yes.orderId);
await 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';
// Optional recovery buy (enabled via MM_RECOVERY_BUY=true)
await attemptRecoveryBuy(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);
logger.warn(`MM: one leg filled — starting adaptive CL for ${unfilledKey.toUpperCase()} | ${label}`);
logger.info(`MM adaptive CL: filled leg @ $${filledLegPrice.toFixed(3)} | min floor for combined ≥ $${config.mmAdaptiveMinCombined.toFixed(2)}: $${minAdaptivePrice.toFixed(3)}`);
// 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;
}
logger.info(`MM adaptive CL: monitoring ${unfilledKey.toUpperCase()} — limit only when price ≥ $${minAdaptivePrice.toFixed(3)}, market-sell only at CL time`);
let activeOrderId = null;
let activeLimitPrice = 0;
// ── Continuous monitoring loop ─────────────────────────────────────────────
// Every poll cycle:
// 1. CL time → cancel limit, market sell (last resort)
// 2. Check fill → done
// 3. Read current price
// 4a. Price < floor OR dropped >5% → cancel limit, keep watching
// 4b. Price improved >2% → cancel and re-place higher
// 5a. price >= floor → place/maintain limit at min(currentPrice, mmSellPrice)
// 5b. price < floor → no limit placed, log & wait (never sell below profit floor)
while (true) {
const msLeft = new Date(pos.endTime).getTime() - Date.now();
// ── CL time: last resort market sell ───────────────────────────────
if (msLeft <= config.mmCutLossTime * 1000) {
if (activeOrderId) {
await cancelOrder(activeOrderId);
activeOrderId = null;
}
break;
}
// ── 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);
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;
}
const targetPrice = Math.min(currentPrice, config.mmSellPrice);
// ── Adjust or cancel active limit ───────────────────────────────────
if (activeOrderId) {
const belowFloor = currentPrice < minAdaptivePrice;
const droppedHard = currentPrice < activeLimitPrice * 0.95;
const priceImproved = targetPrice > activeLimitPrice * 1.02;
if (belowFloor || droppedHard) {
const reason = belowFloor
? `below floor $${minAdaptivePrice.toFixed(3)} (combined $${(filledLegPrice + currentPrice).toFixed(3)} < $${config.mmAdaptiveMinCombined.toFixed(2)})`
: `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 only above the profitable floor ─────────────────────
if (!activeOrderId) {
if (currentPrice >= minAdaptivePrice) {
logger.info(`MM adaptive CL: placing limit sell @ $${targetPrice.toFixed(3)} (mid: $${currentPrice.toFixed(3)}, combined: $${(filledLegPrice + targetPrice).toFixed(3)}, ${Math.round(msLeft / 1000)}s left)`);
const result = await placeLimitSell(s.tokenId, sellShares, targetPrice, tickSize, negRisk);
if (result.success) {
activeOrderId = result.orderId;
activeLimitPrice = targetPrice;
}
} else {
logger.info(`MM adaptive CL: price $${currentPrice.toFixed(3)} below floor $${minAdaptivePrice.toFixed(3)} (combined $${(filledLegPrice + currentPrice).toFixed(3)}) — waiting for recovery (${Math.round(msLeft / 1000)}s left)`);
}
}
await sleep(pollMs);
}
// ── Fallback: market sell at CL time ───────────────────────────────────────
logger.warn(`MM adaptive CL: CL time reached — market-selling ${sellShares.toFixed(3)} ${unfilledKey.toUpperCase()} shares`);
const result = await marketSell(s.tokenId, sellShares, tickSize, negRisk);
s.fillPrice = result.fillPrice;
const pnl = (s.fillPrice - s.entryPrice) * sellShares;
const combined = filledLegPrice + s.fillPrice;
logger.warn(`MM adaptive CL: ${unfilledKey.toUpperCase()} market-sold @ $${s.fillPrice.toFixed(3)} | combined $${combined.toFixed(3)} | sold ${sellShares.toFixed(3)} sh | P&L $${pnl.toFixed(2)}`);
s.filled = true;
pos.status = 'done';
}
// ── Recovery buy ──────────────────────────────────────────────────────────────
/**
+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() {
+82 -54
View File
@@ -1,17 +1,10 @@
/**
* 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
*/
import { Side, OrderType } from '@polymarket/clob-client';
@@ -19,13 +12,37 @@ import config from '../config/index.js';
import { getClient } from './client.js';
import logger from '../utils/logger.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 = [];
export function getActiveSnipes() {
return [...activeSnipes];
}
/**
* 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);
@@ -36,56 +53,67 @@ export async function executeSnipe(market) {
{ name: 'DOWN', tokenId: noTokenId },
];
logger.info(`SNIPER: ${sim}${asset.toUpperCase()} — "${label}" | $${config.sniperPrice} × ${config.sniperShares}sh each side`);
const prices = config.sniperTierPrices;
const sizes = calculateTierSizes(config.sniperMaxShares, config.sniperMinSharesPerTier);
logger.info(`SNIPER: ${sim}${asset.toUpperCase()} — "${label}" | 3-tier: 3c×${sizes[0]} | 2c×${sizes[1]} | 1c×${sizes[2]}`);
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}`);
}
}
}
+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;
+174
View File
@@ -0,0 +1,174 @@
/**
* 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 } from './services/sniperExecutor.js';
import { redeemSniperPositions } from './services/ctf.js';
import { getSchedule, isAssetInSession, getNextSessionInfo } from './services/schedule.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`);
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`);
}
// ── Market handler ────────────────────────────────────────────────────────────
async function handleNewMarket(market) {
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);
+44 -71
View File
@@ -1,21 +1,25 @@
/**
* 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)
* 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 } from './services/sniperExecutor.js';
import { redeemSniperPositions } from './services/ctf.js';
import { getSchedule, isAssetInSession, getNextSessionInfo } from './services/schedule.js';
// Set proxy before any network calls
import './utils/proxy-patch.cjs';
// ── Validate config ────────────────────────────────────────────────────────────
@@ -31,11 +35,6 @@ if (config.sniperAssets.length === 0) {
process.exit(1);
}
// ── Init TUI ──────────────────────────────────────────────────────────────────
initDashboard();
logger.setOutput(appendLog);
// ── Init CLOB client ──────────────────────────────────────────────────────────
try {
@@ -45,69 +44,40 @@ try {
process.exit(1);
}
// ── Status panel ──────────────────────────────────────────────────────────────
// ── Log session schedule ──────────────────────────────────────────────────────
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}');
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`);
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)`);
}
}
return '\n' + lines.join('\n');
logger.info('────────────────────────────────');
}
let refreshTimer = null;
let redeemTimer = null;
// ── Redeemer ──────────────────────────────────────────────────────────────────
function startRefresh() {
refreshTimer = setInterval(async () => {
if (!isDashboardActive()) return;
updateStatus(await buildStatusContent());
}, 3000);
buildStatusContent().then(updateStatus);
}
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 ────────────────────────────────────────────────────────────
@@ -123,20 +93,23 @@ 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`);
startRefresh();
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');
}
+119
View File
@@ -0,0 +1,119 @@
/**
* 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;
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);
}
/**
* Test that the proxy works by making a simple request to the CLOB API.
* 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)} ...`);
try {
if (!fetchDispatcher) {
throw new Error('Proxy dispatcher not initialized');
}
// Test with a simple GET to the CLOB time endpoint via proxied fetch
const resp = await fetch(`${config.clobHost}/time`, {
dispatcher: fetchDispatcher,
signal: AbortSignal.timeout(15000),
});
if (!resp.ok) {
throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
}
logger.success(`Proxy test passed — connected via ${maskProxyUrl(config.proxyUrl)}`);
return true;
} catch (err) {
logger.error(`Proxy test FAILED: ${err.message}`);
logger.error('Check PROXY_URL in .env. Bot cannot reach Polymarket without a working proxy.');
return false;
}
}
/**
* 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)';
}
}