14 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: direkturcrypto <direkturcrypto.x@mail3.me>
2026-02-24 13:00:19 +07:00
46 changed files with 2921 additions and 3929 deletions
+72 -67
View File
@@ -64,15 +64,6 @@ 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)
# ─────────────────────────────────────────────
@@ -119,78 +110,92 @@ MM_RECOVERY_THRESHOLD=0.70
# USDC size for the recovery buy (0 = use MM_TRADE_SIZE)
MM_RECOVERY_SIZE=0
# Enable adaptive CL when one leg fills (true = patient limit orders, false = immediate market sell)
MM_ADAPTIVE_CL=true
# Minimum combined sell price (both legs) required to place a limit order.
# Formula: filledLegPrice + unfilledLegPrice >= MM_ADAPTIVE_MIN_COMBINED
# Example: filledLeg=0.60, MM_ADAPTIVE_MIN_COMBINED=1.20 → floor=0.60 (won't limit-sell below $0.60)
# filledLeg=0.55, MM_ADAPTIVE_MIN_COMBINED=1.20 → floor=0.65
# If price is below floor, bot waits for recovery. Market-sell only at CL time as last resort.
MM_ADAPTIVE_MIN_COMBINED=1.20
# Poll interval (seconds) for the adaptive CL loop after one leg fills.
# Smaller = more responsive to price changes, more API calls.
MM_ADAPTIVE_MONITOR_SEC=5
# ─────────────────────────────────────────────
# ORDERBOOK SNIPER (sniper.js / npm run sniper-sim)
# 3-Tier Strategy: Places GTC BUY orders at 3 prices with weighted sizing
# Tier 1 (3c): smallest size | Tier 2 (2c): medium | Tier 3 (1c): largest
# Catches panic dumps at multiple price levels with optimal capital allocation
# Places tiny GTC BUY orders at a low price on both sides of
# ETH/SOL/XRP 5-minute markets — catches panic dumps near $0.
# ─────────────────────────────────────────────
# Comma-separated assets to snipe
SNIPER_ASSETS=eth,sol,xrp
# 3-Tier pricing (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%)
# Buy price per share (1 cent = $0.01)
SNIPER_PRICE=0.01
# Max total shares per side (min 5 shares per tier)
# Example: 15 shares → 3@3c + 5@2c + 7@1c
# Example: 30 shares → 6@3c + 9@2c + 15@1c
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
# 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
# ─────────────────────────────────────────────
# 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.
# ONESHOT ENGINE (oneshot.js / npm run oneshot-sim)
# Dominant Side Hold strategy.
# Enters ONLY the side that the market already prices as the probable
# winner (mid >= ONESHOT_MIN_DOMINANT_MID), then holds the position
# to market expiry for on-chain redemption at $1.00.
# No take-profit sells. No momentum-based exits.
# ALWAYS test with DRY_RUN=true before going live.
# ─────────────────────────────────────────────
# 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
# Comma-separated assets to monitor (e.g. btc,eth,sol)
ONESHOT_ASSETS=btc
# Limit BUY price (e.g. 0.02 = 2 cents per share)
MAKER_BUY_PRICE=0.02
# Market duration: "5m" (5-minute) or "15m" (15-minute)
ONESHOT_DURATION=5m
# Limit SELL price (e.g. 0.03 = 3 cents per share)
MAKER_SELL_PRICE=0.03
# Book poll interval in milliseconds (200500ms recommended)
ONESHOT_POLL_INTERVAL_MS=300
# Shares per side (e.g. 50 shares × 0.02 = $1.00 cost per side)
MAKER_TRADE_SIZE=50
# USDC risk per trade — size = floor(ONESHOT_BASE_RISK_USDC / entryPrice), min 5 shares
ONESHOT_BASE_RISK_USDC=5
# How often to poll for new markets (seconds)
MAKER_POLL_INTERVAL=10
# ── Entry filters ──────────────────────────────────────────────────────
# How fast to check order fill status (milliseconds)
MAKER_MONITOR_MS=2000
# Minimum mid price for the dominant side to qualify as an entry candidate.
# Example: 0.58 means the token must be priced at ≥58% probability of winning.
# Lower = more trades but more uncertain outcomes. Higher = fewer but more confident.
# Recommended range: 0.550.65. The momentum gate provides additional conviction filtering.
ONESHOT_MIN_DOMINANT_MID=0.58
# ─────────────────────────────────────────────
# 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=
# Minimum composite score to trigger entry (01).
# Score = mid strength (35%) + momentum direction (30%) + book imbalance (20%) + spread (15%).
# Lower threshold captures more "decent but not perfect" setups.
ONESHOT_SCORE_THRESHOLD=0.42
# TTE (time-to-expiry) window in seconds for entry.
# TTE_MAX=150 captures the "direction establishment" phase (last 2.5 minutes).
# TTE_MIN=15 ensures enough time to get a fill before market locks.
# Tighter window = higher conviction but fewer entries per session.
ONESHOT_TTE_MIN=15
ONESHOT_TTE_MAX=150
# Minimum shares at the best bid AND best ask for the depth hard gate
ONESHOT_MIN_TOP_SIZE=10
# ── Exit settings ──────────────────────────────────────────────────────
# Emergency stop-loss: exit if the token's mid price drops below this absolute level.
# Protects against a complete market reversal (e.g. entered UP at 0.70, price drops to 0.18).
# Set to 0 to disable (pure hold-to-expiry — binary win/loss outcome).
ONESHOT_STOP_LOSS_MID=0.20
# ── Risk settings ──────────────────────────────────────────────────────
# Number of consecutive emergency exits (losses) before entering cooldown
ONESHOT_MAX_CONSEC_LOSSES=2
# Number of market slots to skip during cooldown
ONESHOT_COOLDOWN_ROUNDS=3
# Maximum cumulative daily loss in USDC before halting all trading
ONESHOT_DAILY_LOSS_CAP=20
# Maximum milliseconds to wait for a FOK fill ack (timeout → cancel → IDLE)
ONESHOT_FILL_TIMEOUT_MS=800
# How often (ms) to poll for on-chain redemption after market expiry.
# 5m markets typically settle on-chain within 25 minutes after close.
ONESHOT_REDEEM_POLL_MS=30000
# Enable verbose debug logging (discovery probes, gate results, scoring, heartbeat)
# Can also be enabled with: npm run oneshot-debug
# Or on the command line: ONESHOT_DEBUG=true npm run oneshot
ONESHOT_DEBUG=false
-2
View File
@@ -1,6 +1,4 @@
node_modules/
.env
data/*.json
data/*.jsonl
.DS_Store
logs/*.log
+2 -21
View File
@@ -11,8 +11,7 @@ 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 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) |
| Market Maker | `src/mm.js` | `npm run mm` | Provide liquidity on 5m/15m binary markets |
| Orderbook Sniper | `src/sniper.js` | `npm run sniper` | Place low-price GTC orders to catch panic dumps |
---
@@ -92,10 +91,9 @@ 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 three validator functions:
- Exports two 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`)
@@ -173,23 +171,6 @@ 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
+66 -228
View File
@@ -13,49 +13,37 @@
- [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
### Copy Trade Bot (`npm start`)
- **Watch Trader** — Monitor any Polymarket wallet address in real time via WebSocket
- **Copy Buy** — Automatically mirror buy orders with configurable position sizing
- **Copy Sell** — Automatically mirror sell orders (market or limit)
- **Auto Sell** — Place a GTC limit sell at a target profit % immediately after a buy fills
- **Auto Redeem** — Periodically check and redeem winning positions on-chain
- **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
- **Deduplication** — Each market is entered at most once; no double buys
- **Balance Guard** — Checks USDC.e balance before every order
- **Dry Run Mode** — Simulate the full flow without placing real orders
### Market Maker v1 Bot (Split Position)
### Market Maker Bot (`npm run mm`)
- **Automated Liquidity** — Splits USDC into YES+NO tokens and places limit sells on both sides at $0.50 entry
- **Cut-Loss Protection** — Merges unsold tokens back to USDC before market close
- **Recovery Buy** — Optional directional bet after a cut-loss triggers
- **Multi-Asset** — Supports BTC, ETH, SOL, and any 5m/15m Polymarket market
- **Simulation Mode** — Full dry-run with P&L tracking
- **Simulation Mode** — Full dry-run with P&L tracking (`npm run mm-sim`)
### 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%)
### Orderbook Sniper Bot (`npm run sniper`)
- **Low-Price Orders** — Places tiny GTC BUY orders at a configurable price (e.g. $0.01) on both sides
- **Multi-Asset** — Targets ETH, SOL, XRP, and more simultaneously
- **Simulation Mode** — Preview orders without spending funds
- **Session Scheduling** — Per-asset time windows (UTC+8) for selective trading
- **Simulation Mode** — Preview orders without spending funds (`npm run sniper-sim`)
---
@@ -68,7 +56,6 @@
| 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` |
---
@@ -85,8 +72,8 @@ npm install
# 3. Copy the environment template
cp .env.example .env
# 4. Fill in your credentials
nano .env
# 4. Fill in your credentials (see Configuration section below)
nano .env # or use your preferred editor
```
---
@@ -128,8 +115,6 @@ 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
@@ -148,151 +133,35 @@ 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_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
| `SNIPER_PRICE` | Buy price per share (e.g. `0.01` = $0.01) | `0.01` |
| `SNIPER_SHARES` | Shares per side (minimum 5 per Polymarket rules) | `5` |
---
## Usage
### Terminal UI (local)
Runs with an interactive split-panel dashboard (blessed TUI).
```bash
# Copy Trade Bot
npm start # live trading
npm run dev # live + auto-reload on file changes
# ── Copy Trade Bot ─────────────────────────────────
npm start # Production mode
npm run dev # Development mode (auto-reload on file changes)
# Market Maker Bot
npm run mm # live trading
npm run mm-sim # simulation (DRY_RUN=true)
npm run mm-dev # simulation + auto-reload
# ── 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 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
# ── 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
```
### 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
```
> **Always test with `DRY_RUN=true` first** before committing real funds.
---
@@ -301,28 +170,27 @@ pm2 save # saves current process list
### Copy Trade Bot Flow
```
WebSocket (RTDS) — real-time trade events from trader
Per-market queue (prevents concurrent duplicate buys)
┌────┴──────┐
│ │
BUY SELL
├─ Expiry guard (MIN_MARKET_TIME_LEFT)
├─ Max position cap ├─ Cancel open orders
├─ FAK market buy ├─ Reconcile on-chain balance
│ └─ 0 fill? → GTC fallback ├─ FAK market sell / limit sell
├─ Place auto-sell GTC └─ Remove position
└─ Save position
Redeemer loop (every REDEEM_INTERVAL seconds)
→ Check on-chain payout → redeemPositions via Gnosis Safe
┌──────────────────────────────────────────────────────────┐
│ WATCHER LOOP
│ WebSocket (RTDS) — real-time trade events from trader │
│ Fallback: poll Data API every N seconds │
├───────────────────────┬──────────────────────────────────┤
│ NEW BUY │ NEW SELL │
│ ✓ Check position │ ✓ Check position exists │
✓ Check USDC balance │ ✓ Cancel existing auto-sell
│ ✓ Market buy (FOK) │ ✓ Market / limit sell │
│ ✓ Retry on failure │ ✓ Retry on failure │
✓ Place auto-sell Remove position from state │
│ ✓ Save position │ │
├───────────────────────┴──────────────────────────────────┤
│ REDEEMER LOOP │
│ Periodically checks resolved markets
│ → Redeems winning positions via CTF contract on-chain │
└──────────────────────────────────────────────────────────┘
```
### Market Maker v1 Flow (Split Position)
### Market Maker Flow
```
New Market Detected
@@ -338,44 +206,13 @@ 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
@@ -383,13 +220,9 @@ Monitor sells until market close
```
polymarket-terminal/
├── src/
│ ├── 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
│ ├── index.js — Copy trade bot entry point
│ ├── mm.js Market maker bot entry point
│ ├── sniper.js — Orderbook sniper bot entry point
│ │
│ ├── config/
│ │ └── index.js — Environment variable loading & validation
@@ -402,12 +235,9 @@ 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
│ │ ├── 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
│ │ ├── ctf.js — On-chain CTF contract interactions (MM bot)
│ │ ├── mmDetector.js — Market detection for market maker
│ │ ├── mmExecutor.js — Market maker strategy execution
│ │ ├── sniperDetector.js — Market detection for sniper
│ │ └── sniperExecutor.js — Orderbook sniper order placement
│ │
@@ -415,16 +245,11 @@ polymarket-terminal/
│ │ └── dashboard.js — Terminal UI (blessed)
│ │
│ └── utils/
│ ├── logger.js — Timestamped logging (TUI + plain modes)
│ ├── logger.js — Color-coded, timestamped logging
│ ├── 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
@@ -435,13 +260,26 @@ 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`** (or a `*-sim` script) to verify the bot behaves as expected before using real funds.
- **Always start with `DRY_RUN=true`** to verify the bot behaves as expected before using real funds.
- **Use a small `SIZE_PERCENT`** for initial live runs to limit exposure.
- **Keep MATIC in your EOA wallet** for gas fees (redeem operations and on-chain CTF calls).
- **This software is provided as-is, with no guarantees.** Prediction market trading carries significant financial risk. You are solely responsible for any losses.
---
## Contributing
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
+5 -176
View File
@@ -1,11 +1,11 @@
{
"name": "polymarket-terminal",
"name": "polymarket-copy",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "polymarket-terminal",
"name": "polymarket-copy",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
@@ -13,9 +13,6 @@
"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": {
@@ -916,15 +913,6 @@
"integrity": "sha512-H7wUZRn8WpTq9jocdxQ2c8x2sKo9ZVmzfRE13GiNJXfp7NcKYEdvl3vspKjXox6RIG2VtaRe4JFvxG4rqp2Zuw==",
"license": "MIT"
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
@@ -1095,6 +1083,7 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -1108,40 +1097,6 @@
}
}
},
"node_modules/define-data-property": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0",
"es-errors": "^1.3.0",
"gopd": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/define-properties": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
"license": "MIT",
"dependencies": {
"define-data-property": "^1.0.1",
"has-property-descriptors": "^1.0.0",
"object-keys": "^1.1.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -1243,18 +1198,6 @@
"node": ">= 0.4"
}
},
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ethereum-cryptography": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/ethereum-cryptography/-/ethereum-cryptography-2.2.1.tgz",
@@ -1452,37 +1395,6 @@
"node": ">= 6"
}
},
"node_modules/global-agent": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/global-agent/-/global-agent-4.1.2.tgz",
"integrity": "sha512-CvUwIRwmDHu33Y71P8v1Pr8yo5/U2inzrdRNEtPdiYKBd8J4oQ6aDkt9WqZhMQDtAcTAl+akDTHssRYaBTTlhQ==",
"license": "BSD-3-Clause",
"dependencies": {
"globalthis": "^1.0.2",
"matcher": "^4.0.0",
"semver": "^7.3.5",
"serialize-error": "^8.1.0"
},
"engines": {
"node": ">=10.0"
}
},
"node_modules/globalthis": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
"integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
"license": "MIT",
"dependencies": {
"define-properties": "^1.2.1",
"gopd": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -1505,18 +1417,6 @@
"node": ">=4"
}
},
"node_modules/has-property-descriptors": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@@ -1577,19 +1477,6 @@
"minimalistic-crypto-utils": "^1.0.1"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/ignore-by-default": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
@@ -1665,21 +1552,6 @@
"integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==",
"license": "MIT"
},
"node_modules/matcher": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/matcher/-/matcher-4.0.0.tgz",
"integrity": "sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ==",
"license": "MIT",
"dependencies": {
"escape-string-regexp": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -1748,6 +1620,7 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nodemon": {
@@ -1789,15 +1662,6 @@
"node": ">=0.10.0"
}
},
"node_modules/object-keys": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
@@ -1847,6 +1711,7 @@
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -1855,21 +1720,6 @@
"node": ">=10"
}
},
"node_modules/serialize-error": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-8.1.0.tgz",
"integrity": "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==",
"license": "MIT",
"dependencies": {
"type-fest": "^0.20.2"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/simple-update-notifier": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
@@ -1950,18 +1800,6 @@
"integrity": "sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw==",
"license": "Unlicense"
},
"node_modules/type-fest": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/undefsafe": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
@@ -1969,15 +1807,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/undici": {
"version": "7.22.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz",
"integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
+5 -19
View File
@@ -7,27 +7,16 @@
"scripts": {
"start": "node src/index.js",
"dev": "nodemon --ignore 'data/*.json' src/index.js",
"bot": "node src/bot.js",
"bot-sim": "DRY_RUN=true node src/bot.js",
"bot-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/bot.js",
"mm": "DRY_RUN=false node src/mm.js",
"mm-sim": "DRY_RUN=true node src/mm.js",
"mm-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/mm.js",
"mm-bot": "node src/mm-bot.js",
"mm-bot-sim": "DRY_RUN=true node src/mm-bot.js",
"mm-bot-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/mm-bot.js",
"sniper": "DRY_RUN=false node src/sniper.js",
"sniper-sim": "DRY_RUN=true node src/sniper.js",
"sniper-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/sniper.js",
"sniper-tui": "DRY_RUN=false node src/sniper-tui.js",
"sniper-tui-sim": "DRY_RUN=true node src/sniper-tui.js",
"sniper-tui-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/sniper-tui.js",
"maker": "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"
"oneshot": "DRY_RUN=false node src/oneshot.js",
"oneshot-sim": "DRY_RUN=true node src/oneshot.js",
"oneshot-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/oneshot.js",
"oneshot-debug": "DRY_RUN=true ONESHOT_DEBUG=true node src/oneshot.js"
},
"keywords": [
"polymarket",
@@ -41,12 +30,9 @@
"blessed": "^0.1.81",
"dotenv": "^16.4.7",
"ethers": "^5.8.0",
"global-agent": "^4.1.2",
"https-proxy-agent": "^7.0.6",
"undici": "^7.22.0",
"ws": "^8.19.0"
},
"devDependencies": {
"nodemon": "^3.1.9"
}
}
}
-47
View File
@@ -1,47 +0,0 @@
/**
* PM2 config — Copy Trade Bot
*
* Usage (from project root):
* pm2 start pm2/copy.config.cjs # live trading
* pm2 start pm2/copy.config.cjs --env sim # simulation / dry-run
*
* pm2 logs polymarket-copy
* pm2 restart polymarket-copy
* pm2 stop polymarket-copy
* pm2 delete polymarket-copy
*/
const path = require('path');
const root = path.join(__dirname, '..');
module.exports = {
apps: [
{
name: 'polymarket-copy',
script: path.join(root, 'src/bot.js'),
interpreter: 'node',
// Live trading (default)
env: {
NODE_ENV: 'production',
DRY_RUN: 'false',
},
// Simulation: pm2 start pm2/copy.config.cjs --env sim
env_sim: {
NODE_ENV: 'production',
DRY_RUN: 'true',
},
out_file: path.join(root, 'logs/copy-out.log'),
error_file: path.join(root, 'logs/copy-error.log'),
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
merge_logs: true,
restart_delay: 5000,
max_restarts: 10,
min_uptime: '10s',
max_memory_restart: '256M',
stop_exit_codes: [0],
},
],
};
-47
View File
@@ -1,47 +0,0 @@
/**
* PM2 config — Market Maker Bot
*
* Usage (from project root):
* pm2 start pm2/mm.config.cjs # live trading
* pm2 start pm2/mm.config.cjs --env sim # simulation / dry-run
*
* pm2 logs polymarket-mm
* pm2 restart polymarket-mm
* pm2 stop polymarket-mm
* pm2 delete polymarket-mm
*/
const path = require('path');
const root = path.join(__dirname, '..');
module.exports = {
apps: [
{
name: 'polymarket-mm',
script: path.join(root, 'src/mm-bot.js'),
interpreter: 'node',
// Live trading (default)
env: {
NODE_ENV: 'production',
DRY_RUN: 'false',
},
// Simulation: pm2 start pm2/mm.config.cjs --env sim
env_sim: {
NODE_ENV: 'production',
DRY_RUN: 'true',
},
out_file: path.join(root, 'logs/mm-out.log'),
error_file: path.join(root, 'logs/mm-error.log'),
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
merge_logs: true,
restart_delay: 5000,
max_restarts: 10,
min_uptime: '10s',
max_memory_restart: '256M',
stop_exit_codes: [0],
},
],
};
-149
View File
@@ -1,149 +0,0 @@
/**
* bot.js — PM2 / VPS entry point (no TUI)
*
* Plain-text stdout output, compatible with:
* pm2 start ecosystem.config.cjs
* pm2 logs polymarket-copy
*/
import config, { validateConfig } from './config/index.js';
import { initClient, getUsdcBalance, getClient } from './services/client.js';
import { executeBuy, executeSell } from './services/executor.js';
import { checkAndRedeemPositions } from './services/redeemer.js';
import { getOpenPositions } from './services/position.js';
import { startWsWatcher, stopWsWatcher } from './services/wsWatcher.js';
import { getSimStats } from './utils/simStats.js';
import logger from './utils/logger.js';
logger.interceptConsole(); // strip auth headers from CLOB axios error dumps
// ── Handle a trade event from WebSocket ───────────────────────────────────────
async function handleTrade(trade) {
try {
if (trade.type === 'BUY') await executeBuy(trade);
if (trade.type === 'SELL') await executeSell(trade);
} catch (err) {
logger.error(`Error processing trade ${trade.id}: ${err.message}`);
}
}
// ── Periodic status log (replaces TUI right panel) ────────────────────────────
async function printStatus() {
try {
const balance = await getUsdcBalance();
const positions = getOpenPositions();
logger.info(`--- Status | Balance: $${balance.toFixed(2)} USDC | Open positions: ${positions.length} ---`);
for (const pos of positions) {
let pnlStr = '';
try {
const client = getClient();
const mp = await client.getMidpoint(pos.tokenId);
const mid = parseFloat(mp?.mid ?? mp ?? '0');
if (mid > 0) {
const pnl = (mid - pos.avgBuyPrice) * pos.shares;
const sign = pnl >= 0 ? '+' : '';
const pct = pos.totalCost > 0 ? ((pnl / pos.totalCost) * 100).toFixed(1) : '0.0';
pnlStr = ` | unrealized ${sign}$${pnl.toFixed(2)} (${sign}${pct}%)`;
}
} catch { /* price unavailable */ }
const name = (pos.market || pos.tokenId || '').substring(0, 50);
logger.info(
` [${pos.outcome || '?'}] ${name}` +
` | ${pos.shares.toFixed(4)} sh @ $${pos.avgBuyPrice.toFixed(4)}` +
` | spent $${(pos.totalCost || 0).toFixed(2)}${pnlStr}`,
);
}
if (config.dryRun) {
const s = getSimStats();
if (s.totalBuys > 0 || s.totalResolved > 0) {
const rate = s.totalResolved > 0
? `${((s.wins / s.totalResolved) * 100).toFixed(0)}% win`
: 'no resolved yet';
logger.info(
` [SIM] ${s.totalBuys} buys tracked | ${s.wins}W/${s.losses}L (${rate})` +
` | realized P&L: $${(s.closedPnl || 0).toFixed(2)}`,
);
}
}
} catch (err) {
logger.warn(`Status check error: ${err.message}`);
}
}
// ── Redeemer loop ─────────────────────────────────────────────────────────────
async function redeemerLoop() {
try {
await checkAndRedeemPositions();
} catch (err) {
logger.error('Redeemer loop error:', err.message);
}
}
// ── Main ──────────────────────────────────────────────────────────────────────
async function main() {
try {
validateConfig();
} catch (err) {
logger.error(err.message);
process.exit(1);
}
const mode = config.dryRun ? 'SIMULATION' : 'LIVE TRADING';
logger.info(`=== Polymarket Copy Trade [${mode}] ===`);
logger.info(`Trader : ${config.traderAddress}`);
logger.info(`Proxy wallet : ${config.proxyWallet}`);
logger.info(`Size mode : ${config.sizeMode} (${config.sizePercent}%)`);
logger.info(`Min trade : $${config.minTradeSize}`);
logger.info(`Max position : $${config.maxPositionSize} per market`);
logger.info(`Auto sell : ${config.autoSellEnabled ? `ON (+${config.autoSellProfitPercent}%)` : 'OFF'}`);
logger.info(`Sell mode : ${config.sellMode}`);
logger.info(`Min time left: ${config.minMarketTimeLeft}s`);
logger.info('==========================================');
try {
await initClient();
} catch (err) {
logger.error('Failed to initialize CLOB client:', err.message);
process.exit(1);
}
try {
const balance = await getUsdcBalance();
logger.money(`USDC.e Balance: $${balance.toFixed(2)}`);
} catch (err) {
logger.warn('Could not fetch balance:', err.message);
}
logger.success(
config.dryRun
? 'Simulation started — watching trader in real-time...'
: 'Bot started — watching trader in real-time...',
);
startWsWatcher(handleTrade);
await redeemerLoop();
const redeemerInterval = setInterval(redeemerLoop, config.redeemInterval);
// Print status every 60 seconds
const statusInterval = setInterval(printStatus, 60_000);
const shutdown = () => {
logger.info('Shutting down...');
stopWsWatcher();
clearInterval(redeemerInterval);
clearInterval(statusInterval);
setTimeout(() => process.exit(0), 300);
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}
main().catch((err) => {
logger.error('Fatal error:', err.message);
process.exit(1);
});
+16 -80
View File
@@ -46,80 +46,31 @@ const config = {
maxRetries: 5,
retryDelay: 3000,
// Skip buy if market closes within this many seconds (default 5 minutes)
minMarketTimeLeft: parseInt(process.env.MIN_MARKET_TIME_LEFT || '300', 10),
// Seconds to wait for a GTC limit order to fill when FAK finds no liquidity
// (happens when copying trades into "next market" before sellers arrive)
gtcFallbackTimeout: parseInt(process.env.GTC_FALLBACK_TIMEOUT || '60', 10),
// ── Market Maker ──────────────────────────────────────────────
mmAssets: (process.env.MM_ASSETS || 'btc')
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean),
mmDuration: process.env.MM_DURATION || '5m', // '5m' or '15m'
mmTradeSize: parseFloat(process.env.MM_TRADE_SIZE || '5'), // USDC per side
mmSellPrice: parseFloat(process.env.MM_SELL_PRICE || '0.60'), // limit sell target
mmCutLossTime: parseInt(process.env.MM_CUT_LOSS_TIME || '60', 10), // seconds before close
mmMarketKeyword: process.env.MM_MARKET_KEYWORD || 'Bitcoin Up or Down',
mmEntryWindow: parseInt(process.env.MM_ENTRY_WINDOW || '45', 10), // max secs after open
mmPollInterval: parseInt(process.env.MM_POLL_INTERVAL || '10', 10) * 1000,
mmAdaptiveCL: process.env.MM_ADAPTIVE_CL !== 'false', // true = adaptive, false = legacy immediate market-sell
mmAdaptiveMinCombined: parseFloat(process.env.MM_ADAPTIVE_MIN_COMBINED || '1.20'), // min combined sell (both legs) to qualify for limit
mmAdaptiveMonitorSec: parseInt(process.env.MM_ADAPTIVE_MONITOR_SEC || '5', 10),
mmAssets: (process.env.MM_ASSETS || 'btc')
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean),
mmDuration: process.env.MM_DURATION || '5m', // '5m' or '15m'
mmTradeSize: parseFloat(process.env.MM_TRADE_SIZE || '5'), // USDC per side
mmSellPrice: parseFloat(process.env.MM_SELL_PRICE || '0.60'), // limit sell target
mmCutLossTime: parseInt( process.env.MM_CUT_LOSS_TIME || '60', 10), // seconds before close
mmMarketKeyword: process.env.MM_MARKET_KEYWORD || 'Bitcoin Up or Down',
mmEntryWindow: parseInt( process.env.MM_ENTRY_WINDOW || '45', 10), // max secs after open
mmPollInterval: parseInt( process.env.MM_POLL_INTERVAL || '10', 10) * 1000,
// ── Recovery Buy (after cut-loss) ─────────────────────────────
// When enabled: after cutting loss, monitor prices for 10s and
// market-buy the dominant side if it's above threshold and rising/stable.
mmRecoveryBuy: process.env.MM_RECOVERY_BUY === 'true',
mmRecoveryBuy: process.env.MM_RECOVERY_BUY === 'true',
mmRecoveryThreshold: parseFloat(process.env.MM_RECOVERY_THRESHOLD || '0.70'), // min price to qualify
mmRecoverySize: parseFloat(process.env.MM_RECOVERY_SIZE || '0'), // 0 = use mmTradeSize
mmRecoverySize: parseFloat(process.env.MM_RECOVERY_SIZE || '0'), // 0 = use mmTradeSize
// ── Orderbook Sniper ───────────────────────────────────────────
// 3-tier strategy: places GTC limit BUY orders at 3c, 2c, and 1c
// Tier 1 (3c): smallest size | Tier 2 (2c): medium size | Tier 3 (1c): largest size
// Min 5 shares per tier, total = SNIPER_MAX_SHARES_PER_SIDE
// Places tiny GTC limit BUY orders at a very low price on each side
// of ETH/SOL/XRP 5-minute markets — catches panic dumps near $0.
sniperAssets: (process.env.SNIPER_ASSETS || 'eth,sol,xrp')
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean),
sniperTierPrices: [
parseFloat(process.env.SNIPER_TIER1_PRICE || '0.03'), // high price, small size
parseFloat(process.env.SNIPER_TIER2_PRICE || '0.02'), // mid price, medium size
parseFloat(process.env.SNIPER_TIER3_PRICE || '0.01'), // low price, large size
],
sniperMaxShares: parseFloat(process.env.SNIPER_MAX_SHARES || '15'), // max total per side
sniperMinSharesPerTier: 5, // minimum shares for each tier
// ── Sniper 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 || '',
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean),
sniperPrice: parseFloat(process.env.SNIPER_PRICE || '0.01'), // $ per share
sniperShares: parseFloat(process.env.SNIPER_SHARES || '5'), // shares per side
};
// Validation for copy-trade bot
@@ -149,19 +100,4 @@ 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,7 +11,6 @@ import logger from './utils/logger.js';
// ── Dashboard init (before any log output) ────────────────────────────────────
initDashboard();
logger.setOutput(appendLog);
logger.interceptConsole(); // strip auth headers from CLOB client axios error dumps
// ── Handle a trade event from WebSocket ───────────────────────────────────────
async function handleTrade(trade) {
-183
View File
@@ -1,183 +0,0 @@
/**
* 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
@@ -1,281 +0,0 @@
/**
* 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
@@ -1,188 +0,0 @@
/**
* mm-bot.js — Market Maker, PM2 / VPS entry point (no TUI)
*
* Plain-text stdout output, compatible with:
* pm2 start ecosystem.config.cjs --only polymarket-mm
* pm2 logs polymarket-mm
*/
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...`);
+465
View File
@@ -0,0 +1,465 @@
/**
* src/oneshot.js
* Dominant Side Hold Engine — main orchestrator entry point.
*
* Strategy: enter the probable winner (dominant side, mid >= minDominantMid),
* hold the position until the market expires, then let redeemer.js claim
* the on-chain payout. There are no take-profit sells or momentum-based exits.
*
* Runtime sequence (per market, per tick):
* A → MarketFeedService emits 'snapshot'
* B → FeatureEngine processes snapshot, emits 'features'
* C+D → SignalEngine evaluates gates + dominant side, emits 'signal'
* E → Orchestrator submits FOK buy on ENTER signal
* F → Fill handling (full / partial / timeout)
* G → PositionEngine evaluates exit on each snapshot
* H → RiskEngine updated on emergency exits only
*
* State machine (per market):
* IDLE → SETUP_READY → ORDER_PENDING → POSITION_OPEN → IDLE (expired)
* POSITION_OPEN → IDLE (emergency stop-loss exit)
* ANY → COOLDOWN → IDLE
* ANY → HALTED (terminal for the session)
*/
import { initClient, getClient } from './services/client.js';
import logger from './utils/logger.js';
import eventBus from './oneshot/EventBus.js';
import { StateMachine } from './oneshot/StateMachine.js';
import { MarketFeedService } from './oneshot/MarketFeedService.js';
import { FeatureEngine } from './oneshot/FeatureEngine.js';
import { SignalEngine } from './oneshot/SignalEngine.js';
import { ExecutionEngine } from './oneshot/ExecutionEngine.js';
import { RiskEngine } from './oneshot/RiskEngine.js';
import { PositionEngine } from './oneshot/PositionEngine.js';
import { Telemetry } from './oneshot/Telemetry.js';
import { RedeemEngine } from './oneshot/RedeemEngine.js';
import { State, Signal, ReasonCode } from './oneshot/constants.js';
import { DEBUG, dbg } from './oneshot/debug.js';
// ── Configuration ──────────────────────────────────────────────────────────────
const cfg = {
assets: (process.env.ONESHOT_ASSETS || 'btc').split(',').map((s) => s.trim().toLowerCase()),
duration: process.env.ONESHOT_DURATION || '5m',
baseRiskUsdc: parseFloat(process.env.ONESHOT_BASE_RISK_USDC || '5'),
minDominantMid: parseFloat(process.env.ONESHOT_MIN_DOMINANT_MID || '0.58'),
stopLossMid: parseFloat(process.env.ONESHOT_STOP_LOSS_MID || '0.20'),
scoreThreshold: parseFloat(process.env.ONESHOT_SCORE_THRESHOLD || '0.42'),
pollIntervalMs: parseInt(process.env.ONESHOT_POLL_INTERVAL_MS || '300', 10),
minTopSize: parseFloat(process.env.ONESHOT_MIN_TOP_SIZE || '10'),
tteMin: parseInt(process.env.ONESHOT_TTE_MIN || '15', 10),
tteMax: parseInt(process.env.ONESHOT_TTE_MAX || '150', 10),
maxConsecLosses: parseInt(process.env.ONESHOT_MAX_CONSEC_LOSSES || '2', 10),
cooldownRounds: parseInt(process.env.ONESHOT_COOLDOWN_ROUNDS || '3', 10),
dailyLossCap: parseFloat(process.env.ONESHOT_DAILY_LOSS_CAP || '20'),
fillTimeoutMs: parseInt(process.env.ONESHOT_FILL_TIMEOUT_MS || '800', 10),
redeemPollMs: parseInt(process.env.ONESHOT_REDEEM_POLL_MS || '30000', 10),
dryRun: process.env.DRY_RUN !== 'false',
};
// ── Per-market state ───────────────────────────────────────────────────────────
/** @type {Map<string, StateMachine>} */
const stateMachines = new Map();
// ── Service instances ─────────────────────────────────────────────────────────
let feedService;
let featureEngine;
let signalEngine;
let execEngine;
let riskEngine;
let posEngine;
let redeemEngine;
let telemetry;
// ── Entry point ───────────────────────────────────────────────────────────────
async function main() {
logger.success('=== OneShot Dominant Side Hold Engine starting ===');
logger.info(`Assets: [${cfg.assets}] | Duration: ${cfg.duration} | DRY_RUN: ${cfg.dryRun}`);
logger.info(
`Strategy: enter dominant side (mid >= ${cfg.minDominantMid}) | ` +
`TTE window: ${cfg.tteMin}${cfg.tteMax}s | hold to expiry`,
);
logger.info(
`Risk: baseRisk=$${cfg.baseRiskUsdc} | stopLoss=${cfg.stopLossMid > 0 ? cfg.stopLossMid : 'disabled'} | ` +
`scoreMin=${cfg.scoreThreshold}`,
);
await initClient();
const client = getClient();
telemetry = new Telemetry();
redeemEngine = new RedeemEngine({
dryRun: cfg.dryRun,
pollIntervalMs: cfg.redeemPollMs,
eventBus,
});
riskEngine = new RiskEngine({
maxConsecLosses: cfg.maxConsecLosses,
cooldownRounds: cfg.cooldownRounds,
dailyLossCap: cfg.dailyLossCap,
});
posEngine = new PositionEngine({ stopLossMid: cfg.stopLossMid });
execEngine = new ExecutionEngine({ client, dryRun: cfg.dryRun, fillTimeoutMs: cfg.fillTimeoutMs });
featureEngine = new FeatureEngine({ eventBus });
signalEngine = new SignalEngine({
eventBus,
scoreThreshold: cfg.scoreThreshold,
minTopSize: cfg.minTopSize,
minDominantMid: cfg.minDominantMid,
tteMin: cfg.tteMin,
tteMax: cfg.tteMax,
});
feedService = new MarketFeedService({
client,
assets: cfg.assets,
duration: cfg.duration,
pollIntervalMs: cfg.pollIntervalMs,
eventBus,
});
// Wire orchestrator handlers
eventBus.on('signal', onSignal);
eventBus.on('snapshot', onSnapshotForPositionMgmt);
eventBus.on('state:transition', onStateTransition);
redeemEngine.start();
await feedService.start();
// Report final P&L when a redemption settles
eventBus.on('redemption:complete', ({ marketSlug, won, pnl }) => {
riskEngine.recordResult(pnl);
logger.info(`[REDEEM] ${marketSlug} settled | ${won ? 'WIN' : 'LOSS'} | pnl=${won ? '+' : ''}$${pnl.toFixed(4)}`);
});
logger.success('OneShot Engine running — waiting for dominant side signals...');
if (DEBUG) {
logger.info(
'[DBG] Debug mode active. Tags: FEED=discovery/poll, GATE=hard gates, ' +
'SCORE=dominant side scoring, SIGNAL=entry trigger, SM=state changes, HEART=heartbeat',
);
setInterval(() => {
const markets = feedService.activeMarkets;
const states = markets.map((slug) => {
const sm = stateMachines.get(slug);
return `${slug.split('-')[0]}:${sm?.state ?? 'none'}`;
}).join(' | ') || '(none)';
const risk = riskEngine.stats();
dbg('HEART',
`active=${markets.length} | states=[${states}] | ` +
`dailyPnl=$${risk.dailyPnl.toFixed(4)} | consec=${risk.consecLosses} | ` +
`cooldown=${risk.cooldownLeft} | halted=${risk.halted}`,
);
}, 5_000);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}
// ── Signal handler (Steps C/D/E/F) ───────────────────────────────────────────
async function onSignal(evt) {
const { marketSlug, signal, side, score, reason, snapshot, features } = evt;
const sm = getOrCreateSM(marketSlug);
// Log every evaluation tick for later analysis
const sideFeatures = side ? features[side] : (features.up ?? features.down ?? {});
telemetry.logDecision({
marketSlug,
ts: snapshot.ts,
tteSec: snapshot.tteSec,
spread: sideFeatures.spread ?? 0,
imbalance: sideFeatures.imbalance ?? 0,
slope: sideFeatures.midSlope6s ?? 0,
retrace: sideFeatures.retrace3s ?? 0,
depth: sideFeatures.depthTop3 ?? 0,
gatePass: signal !== Signal.NO_TRADE,
reasonCode: reason ?? '',
score,
action: signal,
});
if (signal === Signal.NO_TRADE) return;
// Only enter from IDLE — one position per market slot
if (!sm.is(State.IDLE)) return;
// ── Risk gate ─────────────────────────────────────────────────────────
const riskCheck = riskEngine.canTrade();
if (!riskCheck.ok) {
if (riskCheck.halted && sm.canTransitionTo(State.HALTED)) {
sm.transition(State.HALTED, ReasonCode.RISK_DAILY_CAP);
} else if (riskEngine.isCooldown()) {
riskEngine.decrementCooldown();
}
return;
}
// ── Step E: order submission ───────────────────────────────────────────
const bookSide = side === 'up' ? snapshot.up : snapshot.down;
const entryPrice = bookSide.bestAsk;
// Size: floor(baseRiskUSDC / entryPrice), minimum 5 shares
const rawSize = cfg.baseRiskUsdc / entryPrice;
const size = Math.max(5, Math.floor(rawSize));
logger.trade(
`OneShot ENTER | ${signal} | ${marketSlug} | ` +
`mid=${bookSide.mid.toFixed(4)} px=$${entryPrice} | size=${size} | score=${score.toFixed(3)} | tte=${snapshot.tteSec}s`,
);
sm.transition(State.SETUP_READY, 'signal_passed');
try {
sm.transition(State.ORDER_PENDING, 'submitting');
const result = await execEngine.submitBuy({
tokenId: bookSide.tokenId,
size,
price: entryPrice,
marketSlug,
});
telemetry.logOrder({
clientOrderId: result.orderId,
side: signal,
marketSlug,
px: entryPrice,
qty: size,
ackMs: result.ackMs,
fillMs: result.fillMs,
status: result.status,
});
// ── Step F: fill handling ──────────────────────────────────────────
if (result.status === 'filled') {
posEngine.open(marketSlug, {
tokenId: bookSide.tokenId,
side,
shares: result.filledSize,
entryPrice: result.avgFillPrice || entryPrice,
tickSize: snapshot.tickSize,
conditionId: snapshot.conditionId,
negRisk: snapshot.negRisk,
});
sm.transition(State.POSITION_OPEN, 'fill_confirmed');
logger.success(
`OneShot: position OPEN | ${marketSlug} | ` +
`${result.filledSize} shares @ $${(result.avgFillPrice || entryPrice).toFixed(4)} | ` +
`holding to expiry`,
);
} else if (result.status === 'partial' && result.filledSize > 0) {
// Accept partial fill and hold to expiry
posEngine.open(marketSlug, {
tokenId: bookSide.tokenId,
side,
shares: result.filledSize,
entryPrice: result.avgFillPrice || entryPrice,
tickSize: snapshot.tickSize,
conditionId: snapshot.conditionId,
negRisk: snapshot.negRisk,
});
sm.transition(State.POSITION_OPEN, 'partial_fill_accepted');
logger.warn(`OneShot: partial fill accepted | ${result.filledSize}/${size} shares | holding to expiry`);
} else {
logger.warn(`OneShot: no fill on ${marketSlug} — returning to IDLE`);
sm.transition(State.IDLE, ReasonCode.EXEC_TIMEOUT_NO_FILL);
}
} catch (err) {
logger.error(`OneShot: order error on ${marketSlug}${err.message}`);
if (sm.is(State.ORDER_PENDING) || sm.is(State.SETUP_READY)) {
sm.transition(State.IDLE, ReasonCode.EXEC_SUBMIT_ERROR);
}
}
}
// ── Position management handler (Step G) ──────────────────────────────────────
async function onSnapshotForPositionMgmt(snapshot) {
const { marketSlug, tteSec } = snapshot;
const sm = stateMachines.get(marketSlug);
if (!sm) return;
// Clean up state machines for fully expired markets with no open position
if (tteSec < -10 && sm.is(State.IDLE)) {
stateMachines.delete(marketSlug);
return;
}
if (!sm.is(State.POSITION_OPEN)) return;
const pos = posEngine.getPosition(marketSlug);
if (!pos) {
if (sm.canTransitionTo(State.IDLE)) sm.transition(State.IDLE, 'position_missing');
return;
}
// Evaluate exit conditions
const exitResult = posEngine.evaluateExit(marketSlug, snapshot);
// Market expired — position goes to on-chain redeemer
if (exitResult.isExpired) {
await expirePosition(marketSlug, pos);
return;
}
// Emergency stop-loss (catastrophic market reversal)
if (exitResult.shouldExit) {
const bookSide = pos.side === 'up' ? snapshot.up : snapshot.down;
await flattenPosition(marketSlug, pos, bookSide, exitResult.reason, snapshot);
}
}
// ── Expire helper (market closed, pending on-chain redemption) ────────────────
async function expirePosition(marketSlug, pos) {
const sm = stateMachines.get(marketSlug);
if (!sm) return;
logger.success(
`OneShot: market EXPIRED | ${marketSlug} | ` +
`${pos.shares} shares of ${pos.side.toUpperCase()} @ entry $${pos.entryPrice.toFixed(4)} | ` +
`queuing for auto-redemption`,
);
posEngine.closeExpired(marketSlug);
telemetry.logExit({
marketSlug,
exitReason: ReasonCode.EXIT_EXPIRED,
entryPx: pos.entryPrice,
exitPx: null, // settled on-chain — see redemption:complete event
pnl: null,
shares: pos.shares,
});
// Hand off to RedeemEngine — it will poll until settled and report final P&L
redeemEngine.queueRedemption({
conditionId: pos.conditionId,
marketSlug,
side: pos.side,
shares: pos.shares,
entryPrice: pos.entryPrice,
negRisk: pos.negRisk,
});
if (sm.canTransitionTo(State.IDLE)) {
sm.transition(State.IDLE, ReasonCode.EXIT_EXPIRED);
}
}
// ── Emergency flatten helper (adverse-move stop-loss only) ────────────────────
async function flattenPosition(marketSlug, pos, bookSide, reason, snapshot) {
const sm = stateMachines.get(marketSlug);
if (!sm || !sm.is(State.POSITION_OPEN)) return;
const exitPrice = bookSide.bestBid;
logger.warn(
`OneShot: EMERGENCY EXIT | ${marketSlug} | reason=${reason} | ` +
`mid=${bookSide.mid.toFixed(4)} exitPx=$${exitPrice.toFixed(4)}`,
);
try {
await execEngine.submitSell({
tokenId: pos.tokenId,
size: pos.shares,
price: exitPrice,
marketSlug,
});
const exitData = posEngine.close(marketSlug, exitPrice);
riskEngine.recordResult(exitData.pnl);
telemetry.logExit({
marketSlug,
exitReason: reason,
entryPx: pos.entryPrice,
exitPx: exitPrice,
pnl: exitData.pnl,
shares: pos.shares,
});
const { ok, halted } = riskEngine.canTrade();
if (halted && sm.canTransitionTo(State.HALTED)) {
sm.transition(State.HALTED, ReasonCode.RISK_DAILY_CAP);
} else if (!ok && riskEngine.isCooldown() && sm.canTransitionTo(State.COOLDOWN)) {
sm.transition(State.COOLDOWN, ReasonCode.RISK_CONSEC_LOSS);
} else {
sm.transition(State.IDLE, `emergency_exit_${reason}`);
}
} catch (err) {
logger.error(`OneShot: flatten error on ${marketSlug}${err.message}`);
}
}
// ── State transition logging ──────────────────────────────────────────────────
function onStateTransition(evt) {
telemetry.logTransition(evt);
dbg('SM', `${evt.marketSlug}: ${evt.from}${evt.to} | ${evt.reason}`);
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function getOrCreateSM(marketSlug) {
if (!stateMachines.has(marketSlug)) {
stateMachines.set(marketSlug, new StateMachine(marketSlug, eventBus));
}
return stateMachines.get(marketSlug);
}
// ── Graceful shutdown ─────────────────────────────────────────────────────────
async function shutdown() {
logger.warn('OneShot: shutting down...');
feedService?.stop();
redeemEngine?.stop();
// Report any positions still open at shutdown
const markets = feedService?.activeMarkets ?? [];
for (const slug of markets) {
const pos = posEngine?.getPosition(slug);
if (pos) {
logger.warn(
`OneShot: position still open at shutdown — ${slug} | ` +
`${pos.shares} shares @ $${pos.entryPrice.toFixed(4)} | redeemer.js will settle`,
);
}
}
const stats = riskEngine?.stats();
if (stats) {
const sign = stats.dailyPnl >= 0 ? '+' : '';
logger.money(
`Session summary | emergencyExitPnl=${sign}$${stats.dailyPnl.toFixed(4)} | ` +
`consecLosses=${stats.consecLosses} | halted=${stats.halted}`,
);
}
process.exit(0);
}
// ── Bootstrap ─────────────────────────────────────────────────────────────────
main().catch((err) => {
logger.error(`OneShot fatal: ${err.message}`);
process.exit(1);
});
+22
View File
@@ -0,0 +1,22 @@
/**
* EventBus.js
* Central event bus for the OneShot engine.
* All inter-service communication flows through this singleton.
*
* Event catalogue:
* snapshot MarketFeedService → FeatureEngine, orchestrator
* features FeatureEngine → SignalEngine, orchestrator
* signal SignalEngine → orchestrator
* state:transition StateMachine → orchestrator, Telemetry
*/
import { EventEmitter } from 'events';
class OneShotEventBus extends EventEmitter {}
const bus = new OneShotEventBus();
// Prevent memory-leak warnings for high subscriber counts across many markets
bus.setMaxListeners(50);
export default bus;
+221
View File
@@ -0,0 +1,221 @@
/**
* ExecutionEngine.js
* Steps E & F of the runtime sequence.
*
* Responsibilities:
* - Submit a limit-marketable FOK BUY order at bestAsk
* - Wait up to fillTimeoutMs for an ack/fill response
* - Return structured fill result (filled | partial | cancelled)
* - Submit market-sell (FOK) for exits and cut-losses
* - Place GTC limit-sell for take-profit orders
*
* In dry-run mode all calls short-circuit with simulated successful results.
*/
import { Side, OrderType } from '@polymarket/clob-client';
import logger from '../utils/logger.js';
const FILL_TIMEOUT_MS = 800;
export class ExecutionEngine {
/**
* @param {Object} opts
* @param {import('@polymarket/clob-client').ClobClient} opts.client
* @param {boolean} opts.dryRun
* @param {number} [opts.fillTimeoutMs=800]
*/
constructor({ client, dryRun, fillTimeoutMs = FILL_TIMEOUT_MS }) {
this._client = client;
this._dryRun = dryRun;
this._fillTimeoutMs = fillTimeoutMs;
/** Cache tick sizes to avoid repeated API calls */
this._tickCache = new Map();
}
// ── Buy ───────────────────────────────────────────────────────────────────
/**
* Submit a limit-marketable FOK buy and wait for the fill result.
*
* @param {Object} opts
* @param {string} opts.tokenId - ERC1155 token ID (UP or DOWN)
* @param {number} opts.size - Number of shares to buy (≥ 5)
* @param {number} opts.price - Limit price (bestAsk from snapshot)
* @param {string} opts.marketSlug - For logging
*
* @returns {Promise<FillResult>}
*/
async submitBuy({ tokenId, size, price, marketSlug }) {
if (this._dryRun) {
logger.trade(`[SIM] BUY ${marketSlug} | ${size} shares @ $${price}`);
return {
orderId: `sim_buy_${Date.now()}`,
status: 'filled',
filledSize: size,
avgFillPrice: price,
ackMs: 45,
fillMs: 90,
};
}
const startTs = Date.now();
const { tickSize, negRisk } = await this._getMarketOpts(tokenId);
logger.trade(`BUY ${marketSlug} | ${size} shares @ $${price}`);
const response = await this._withTimeout(
this._client.createAndPostOrder(
{ tokenID: tokenId, price: price.toString(), size, side: Side.BUY },
{ tickSize, negRisk },
OrderType.FOK,
),
this._fillTimeoutMs,
);
const ackMs = Date.now() - startTs;
const fillMs = ackMs;
if (!response?.success) {
logger.warn(`ExecutionEngine: buy not filled — ${response?.errorMsg ?? 'no response'}`);
return { orderId: null, status: 'cancelled', filledSize: 0, ackMs, fillMs };
}
const takingAmt = parseFloat(response.takingAmount || '0');
const makingAmt = parseFloat(response.makingAmount || '0');
if (takingAmt > 0) {
const avgFillPrice = makingAmt > 0 ? makingAmt / takingAmt : price;
logger.success(`ExecutionEngine: filled ${takingAmt.toFixed(2)} shares @ avg $${avgFillPrice.toFixed(4)}`);
return { orderId: response.orderID, status: 'filled', filledSize: takingAmt, avgFillPrice, ackMs, fillMs };
}
// Some CLOB responses indicate fill via status string rather than amounts
const isMatched = /matched|filled/i.test(response.status ?? '');
if (isMatched || response.success) {
return { orderId: response.orderID, status: 'filled', filledSize: size, avgFillPrice: price, ackMs, fillMs };
}
return { orderId: response.orderID, status: 'cancelled', filledSize: 0, ackMs, fillMs };
}
// ── Sell (exit / cut-loss) ────────────────────────────────────────────────
/**
* Submit a market-sell FOK order to exit a position immediately.
*
* @param {Object} opts
* @param {string} opts.tokenId
* @param {number} opts.size - Shares to sell
* @param {number} opts.price - Minimum acceptable sell price (5% slippage floor applied internally)
* @param {string} opts.marketSlug
*/
async submitSell({ tokenId, size, price, marketSlug }) {
if (this._dryRun) {
logger.trade(`[SIM] SELL ${marketSlug} | ${size} shares @ ~$${price}`);
return { orderId: `sim_sell_${Date.now()}`, status: 'filled' };
}
const { tickSize, negRisk } = await this._getMarketOpts(tokenId);
const minPrice = Math.max(price * 0.95, 0.01);
logger.trade(`SELL ${marketSlug} | ${size} shares @ min $${minPrice.toFixed(4)}`);
const response = await this._client.createAndPostMarketOrder(
{ tokenID: tokenId, side: Side.SELL, amount: size, price: minPrice },
{ tickSize, negRisk },
OrderType.FOK,
).catch((err) => {
logger.warn(`ExecutionEngine: sell error — ${err.message}`);
return null;
});
const filled = response?.success ?? false;
if (!filled) logger.warn(`ExecutionEngine: sell not filled — ${response?.errorMsg ?? 'unknown'}`);
return { orderId: response?.orderID ?? null, status: filled ? 'filled' : 'failed' };
}
/**
* Place a GTC limit-sell order for take-profit.
* Returns the order ID so the caller can cancel it if exit conditions change.
*
* @param {Object} opts
* @param {string} opts.tokenId
* @param {number} opts.size - Shares to sell
* @param {number} opts.tpPrice - Exact target sell price (aligned to tick size)
* @param {string} opts.marketSlug
*/
async submitTPOrder({ tokenId, size, tpPrice, marketSlug }) {
if (this._dryRun) {
logger.trade(`[SIM] TP ORDER ${marketSlug} | ${size} shares @ $${tpPrice}`);
return { orderId: `sim_tp_${Date.now()}`, status: 'placed' };
}
const { tickSize, negRisk } = await this._getMarketOpts(tokenId);
const response = await this._client.createAndPostOrder(
{ tokenID: tokenId, price: tpPrice.toString(), size, side: Side.SELL },
{ tickSize, negRisk },
OrderType.GTC,
).catch((err) => {
logger.warn(`ExecutionEngine: TP order error — ${err.message}`);
return null;
});
const placed = response?.success ?? false;
logger.info(`ExecutionEngine: TP order ${placed ? 'placed' : 'failed'} | ${marketSlug} @ $${tpPrice}`);
return { orderId: response?.orderID ?? null, status: placed ? 'placed' : 'failed' };
}
/** Cancel an open order by order ID */
async cancelOrder(orderId) {
if (this._dryRun || !orderId) return;
try {
await this._client.cancelOrder({ orderID: orderId });
} catch (err) {
logger.warn(`ExecutionEngine: cancel failed for ${orderId}${err.message}`);
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
async _getMarketOpts(tokenId) {
if (this._tickCache.has(tokenId)) return this._tickCache.get(tokenId);
let tickSize = '0.01';
let negRisk = false;
try {
tickSize = String(await this._client.getTickSize(tokenId) ?? '0.01');
negRisk = await this._client.getNegRisk(tokenId).catch(() => false) ?? false;
} catch { /* use defaults */ }
const opts = { tickSize, negRisk };
this._tickCache.set(tokenId, opts);
return opts;
}
/**
* Wrap a promise with a hard timeout.
* Resolves to null on timeout rather than rejecting — execution layer
* treats null as a no-fill and transitions back to IDLE cleanly.
*/
_withTimeout(promise, ms) {
return Promise.race([
promise,
new Promise((resolve) => setTimeout(() => resolve(null), ms)),
]);
}
}
/**
* @typedef {Object} FillResult
* @property {string|null} orderId
* @property {'filled'|'partial'|'cancelled'} status
* @property {number} filledSize
* @property {number} avgFillPrice
* @property {number} ackMs
* @property {number} fillMs
*/
+174
View File
@@ -0,0 +1,174 @@
/**
* FeatureEngine.js
* Step B of the runtime sequence.
*
* Maintains a rolling 15-second buffer of market snapshots per market
* and computes the following features on each incoming snapshot:
*
* midSlope6s — Linear regression slope of the mid price over the last 6s
* (positive = upward momentum, unit: price change per second)
* retrace3s — Fractional pullback from the 6s rolling peak to current mid
* (0 = no retrace, 1 = fully retraced to baseline)
* imbalance — (depthBid - depthAsk) / (depthBid + depthAsk)
* (positive = buyers dominate, negative = sellers dominate)
* spread — Current bestAsk - bestBid
* depthTop3 — Sum of the top-3 bid levels (buy-side depth at best prices)
*
* Features are computed independently for both UP and DOWN book sides.
*
* Emits a 'features' event on the event bus with shape:
* { ts, marketSlug, tteSec, up: SideFeatures, down: SideFeatures, snapshot }
*/
const BUFFER_WINDOW_MS = 15_000;
const SLOPE_WINDOW_MS = 6_000;
const RETRACE_PEAK_MS = 6_000; // Look-back window for peak in retrace calc
const DEPTH_TOP_N = 3;
export class FeatureEngine {
/**
* @param {Object} opts
* @param {import('./EventBus.js').default} opts.eventBus
*/
constructor({ eventBus }) {
this._eventBus = eventBus;
/** @type {Map<string, Array<{ts, up_mid, down_mid, up_spread, up_depthBid, up_depthAsk, up_bestBidSize, up_bestAskSize, down_spread, down_depthBid, down_depthAsk, down_bestBidSize, down_bestAskSize}>>} */
this._buffers = new Map();
/** @type {Map<string, Object>} Most recent features per market */
this._latest = new Map();
this._eventBus.on('snapshot', (snap) => this._onSnapshot(snap));
}
/** Retrieve the most recently computed features for a given market */
getLatest(marketSlug) {
return this._latest.get(marketSlug) ?? null;
}
// ── Internal ──────────────────────────────────────────────────────────────
_onSnapshot(snap) {
const { marketSlug, ts, tteSec, up, down } = snap;
// Add to rolling buffer
if (!this._buffers.has(marketSlug)) this._buffers.set(marketSlug, []);
const buf = this._buffers.get(marketSlug);
buf.push({
ts,
up_mid: up.mid,
up_spread: up.spread,
up_depthBid: up.depthBid,
up_depthAsk: up.depthAsk,
up_bestBidSize: up.bestBidSize,
up_bestAskSize: up.bestAskSize,
down_mid: down.mid,
down_spread: down.spread,
down_depthBid: down.depthBid,
down_depthAsk: down.depthAsk,
down_bestBidSize: down.bestBidSize,
down_bestAskSize: down.bestAskSize,
});
// Evict entries older than the buffer window
const cutoff = ts - BUFFER_WINDOW_MS;
while (buf.length > 0 && buf[0].ts < cutoff) buf.shift();
const features = {
ts,
marketSlug,
tteSec,
up: this._computeSideFeatures(buf, 'up', up),
down: this._computeSideFeatures(buf, 'down', down),
snapshot: snap,
};
this._latest.set(marketSlug, features);
this._eventBus.emit('features', features);
}
/**
* Compute all features for one book side using the rolling buffer.
*
* @param {Array} buf - Rolling buffer entries (ascending ts)
* @param {string} side - 'up' or 'down'
* @param {Object} currentBook - Live BookSide from current snapshot
*/
_computeSideFeatures(buf, side, currentBook) {
const now = buf[buf.length - 1]?.ts ?? Date.now();
const midKey = `${side}_mid`;
// Slice for slope window (last 6s)
const slopeBuf = buf.filter((e) => e.ts >= now - SLOPE_WINDOW_MS);
const mids6s = slopeBuf.map((e) => e[midKey]);
// Slice for retrace peak look-back (last 6s)
const retraceBuf = buf.filter((e) => e.ts >= now - RETRACE_PEAK_MS);
const midsRetrace = retraceBuf.map((e) => e[midKey]);
const midSlope6s = this._linearSlope(mids6s);
const retrace3s = this._retrace(midsRetrace, currentBook.mid);
// Imbalance from depth
const totalDepth = currentBook.depthBid + currentBook.depthAsk;
const imbalance = totalDepth > 0
? (currentBook.depthBid - currentBook.depthAsk) / totalDepth
: 0;
// Top-3 bid depth from current book
const depthTop3 = currentBook.bids
.slice(0, DEPTH_TOP_N)
.reduce((s, l) => s + l.size, 0);
return {
midSlope6s,
retrace3s,
imbalance,
spread: currentBook.spread,
depthTop3,
bufLen: slopeBuf.length, // diagnostic
};
}
/**
* Ordinary least-squares slope through an array of mid-price values.
* Returns slope in units of "price change per sample interval".
* Returns 0 if fewer than 2 data points are available.
*/
_linearSlope(values) {
const n = values.length;
if (n < 2) return 0;
const meanX = (n - 1) / 2;
const meanY = values.reduce((a, b) => a + b, 0) / n;
let num = 0;
let den = 0;
for (let i = 0; i < n; i++) {
const dx = i - meanX;
num += dx * (values[i] - meanY);
den += dx * dx;
}
return den === 0 ? 0 : num / den;
}
/**
* Fractional retrace: how far the current mid has pulled back from
* the rolling peak within the look-back window.
*
* 0 = price is at its peak (no retrace)
* 1 = price is at its trough (full retrace)
*/
_retrace(mids, currentMid) {
if (mids.length === 0) return 0;
const peak = Math.max(...mids, currentMid);
const trough = Math.min(...mids, currentMid);
const range = peak - trough;
if (range < 1e-9) return 0;
return Math.max(0, (peak - currentMid) / range);
}
}
+374
View File
@@ -0,0 +1,374 @@
/**
* MarketFeedService.js
* Step A of the runtime sequence.
*
* Responsibilities:
* 1. Discover active 5m/15m UP↑DOWN↓ markets for configured assets via Gamma API
* 2. Poll the CLOB orderbook for both UP and DOWN tokens every pollIntervalMs
* 3. Normalise raw book data into a consistent snapshot format
* 4. Detect stale books (no levels, or fetch latency > STALE_THRESHOLD_MS)
* 5. Emit 'snapshot' events on the event bus
*
* Market discovery mirrors the logic in sniperDetector.js / mmDetector.js:
* - API endpoint: /markets/slug/{slug} (not /markets?slug=...)
* - Token IDs: clobTokenIds[0/1] (JSON string parsed if needed)
* - Tick size: market.orderPriceMinTickSize (no separate API call)
* - Slot formula: Math.floor(Date.now()/1000/SLOT_SEC) * SLOT_SEC
*
* Snapshot shape:
* { ts, marketSlug, conditionId, tteSec, tickSize, up: BookSide, down: BookSide, stale }
*
* BookSide shape:
* { tokenId, bids, asks, bestBid, bestAsk, mid, spread, depthBid, depthAsk,
* bestBidSize, bestAskSize }
*/
import config from '../config/index.js';
import logger from '../utils/logger.js';
import { dbg, DEBUG } from './debug.js';
const STALE_THRESHOLD_MS = 1500;
const TOP_N_LEVELS = 5; // Levels counted for depth calculation
const DISCOVER_INTERVAL = 30_000; // Re-scan for new markets every 30s
const DEBUG_POLL_EVERY = 10; // Throttle: log one poll summary every N ticks
export class MarketFeedService {
/**
* @param {Object} opts
* @param {import('@polymarket/clob-client').ClobClient} opts.client
* @param {string[]} opts.assets - e.g. ['btc', 'eth', 'sol']
* @param {string} opts.duration - '5m' or '15m'
* @param {number} opts.pollIntervalMs - Book poll cadence in ms (200500)
* @param {import('./EventBus.js').default} opts.eventBus
*/
constructor({ client, assets, duration = '5m', pollIntervalMs = 300, eventBus }) {
this._client = client;
this._assets = assets;
this._duration = duration;
this._slotSec = duration === '15m' ? 900 : 300; // same as sniperDetector/mmDetector
this._pollMs = pollIntervalMs;
this._eventBus = eventBus;
/** @type {Map<string, MarketRecord>} slug → market record */
this._markets = new Map();
this._pollTimer = null;
this._discoverTimer = null;
/** Per-market tick counter for throttled debug logs */
this._pollCount = new Map();
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
async start() {
await this._discoverMarkets();
this._pollTimer = setInterval(() => this._tick().catch(() => {}), this._pollMs);
this._discoverTimer = setInterval(() => this._discoverMarkets().catch(() => {}), DISCOVER_INTERVAL);
logger.info(`MarketFeedService: started | assets=[${this._assets}] interval=${this._pollMs}ms`);
if (DEBUG) logger.info('[DBG:FEED] Debug mode ON — verbose feed logging enabled');
}
stop() {
clearInterval(this._pollTimer);
clearInterval(this._discoverTimer);
logger.info('MarketFeedService: stopped');
}
/** Active market slugs currently being polled */
get activeMarkets() {
return [...this._markets.keys()];
}
// ── Market discovery ──────────────────────────────────────────────────────
async _discoverMarkets() {
// Probe current slot AND next upcoming slot (same as sniperDetector)
const curr = this._currentSlot();
const next = curr + this._slotSec;
const slots = [curr, next];
dbg('FEED', `--- discovery cycle | curr=${curr} next=${next} | probing ${this._assets.length * 2} slug(s) ---`);
for (const asset of this._assets) {
for (const slotTs of slots) {
const slug = `${asset}-updown-${this._duration}-${slotTs}`;
if (this._markets.has(slug)) {
dbg('FEED', ` ${slug} → already tracked`);
continue;
}
dbg('FEED', ` probing ${slug} ...`);
try {
// ── Use /markets/slug/{slug} — same endpoint as sniperDetector ──
const market = await this._fetchBySlug(slug);
if (!market) {
dbg('FEED', ` ${slug} → not found (API returned null)`);
continue;
}
// ── Extract end time ─────────────────────────────────────────
// endDate = "2026-02-24T06:35:00Z" (full datetime — use this)
// endDateIso = "2026-02-24" (date only, parses to midnight UTC — skip)
const endTs = this._parseEndTs(market);
if (!endTs) {
dbg('FEED', ` ${slug} → found but endDate unparseable (keys: ${Object.keys(market).slice(0, 8).join(',')})`);
continue;
}
if (Date.now() >= endTs) {
dbg('FEED', ` ${slug} → found but expired (endTs=${new Date(endTs).toISOString()})`);
continue;
}
// ── Extract token IDs — same logic as sniperDetector/mmDetector ──
const { upTokenId, downTokenId } = this._extractTokenIds(market);
if (!upTokenId || !downTokenId) {
logger.warn(`MarketFeedService: missing token IDs for ${slug}`);
dbg('FEED', ` clobTokenIds raw: ${JSON.stringify(market.clobTokenIds)}`);
continue;
}
// ── Tick size from market object — same as mmDetector ────────
const tickSize = parseFloat(
market.orderPriceMinTickSize ??
market.minimum_tick_size ??
market.minimumTickSize ??
'0.01',
) || 0.01;
const negRisk = market.negRisk ?? market.neg_risk ?? false;
this._markets.set(slug, {
slug,
conditionId: market.conditionId || market.condition_id,
upTokenId,
downTokenId,
endTs,
tickSize,
negRisk,
});
const secLeft = Math.floor((endTs - Date.now()) / 1000);
logger.success(`MarketFeedService: tracking ${slug} (closes in ${secLeft}s)`);
dbg('FEED',
` up=${upTokenId.slice(0, 16)}... ` +
`down=${downTokenId.slice(0, 16)}... ` +
`tick=${tickSize} negRisk=${negRisk}`,
);
} catch (err) {
dbg('FEED', ` ${slug} → error: ${err.message}`);
// Network blip — will retry on next cycle
}
}
}
// Prune markets that have fully expired (5s grace for final snapshots)
for (const [slug, mkt] of this._markets) {
if (Date.now() > mkt.endTs + 5_000) {
this._markets.delete(slug);
this._pollCount.delete(slug);
logger.info(`MarketFeedService: pruned ${slug}`);
}
}
if (this._markets.size === 0) {
dbg('FEED', 'No active markets — retrying in 30s');
} else {
dbg('FEED', `Tracking: [${[...this._markets.keys()].join(', ')}]`);
}
}
// ── Slot helpers (identical to sniperDetector / mmDetector) ──────────────
_currentSlot() {
return Math.floor(Date.now() / 1000 / this._slotSec) * this._slotSec;
}
// ── Gamma API ─────────────────────────────────────────────────────────────
/** Uses /markets/slug/{slug} — the same direct endpoint as sniperDetector */
async _fetchBySlug(slug) {
const resp = await fetch(`${config.gammaHost}/markets/slug/${slug}`);
if (!resp.ok) return null;
const data = await resp.json();
// Returns a single object (not an array) when using the slug endpoint
return data?.conditionId || data?.condition_id ? data : null;
}
_parseEndTs(market) {
// endDate = "2026-02-24T06:35:00Z" → correct full datetime
// endDateIso = "2026-02-24" → date-only, parses to midnight UTC (wrong!)
const raw = market.endDate || market.end_date || market.endDateIso || market.end_date_iso;
if (!raw) return null;
const ts = new Date(raw).getTime();
return Number.isFinite(ts) ? ts : null;
}
/**
* Extract UP/DOWN token IDs using the same logic as sniperDetector / mmDetector.
*
* clobTokenIds may be:
* - a real JS array: ["123...", "456..."]
* - a JSON string: '["123...","456..."]'
* UP = clobTokenIds[0] (YES / Up)
* DOWN = clobTokenIds[1] (NO / Down)
*/
_extractTokenIds(market) {
let tokenIds = market.clobTokenIds ?? market.clob_token_ids;
// Unwrap JSON string if the API returned it encoded
if (typeof tokenIds === 'string') {
try { tokenIds = JSON.parse(tokenIds); } catch { tokenIds = null; }
}
let upTokenId = null;
let downTokenId = null;
if (Array.isArray(tokenIds) && tokenIds.length >= 2) {
[upTokenId, downTokenId] = tokenIds.map(String);
} else if (Array.isArray(market.tokens) && market.tokens.length >= 2) {
// Fallback: named tokens array (less common)
upTokenId = String(market.tokens[0]?.token_id ?? market.tokens[0]?.tokenId ?? '');
downTokenId = String(market.tokens[1]?.token_id ?? market.tokens[1]?.tokenId ?? '');
if (!upTokenId || !downTokenId) { upTokenId = null; downTokenId = null; }
}
return { upTokenId, downTokenId };
}
// ── Book polling ──────────────────────────────────────────────────────────
async _tick() {
if (this._markets.size === 0) return;
for (const [, mkt] of this._markets) {
const tteSec = Math.floor((mkt.endTs - Date.now()) / 1000);
// Market has expired — emit a synthetic snapshot so the position manager
// can detect the expiry and queue the position for on-chain redemption.
// Without this, positions in expired markets never reach RedeemEngine.
if (tteSec < 0) {
this._eventBus.emit('snapshot', this._buildExpiredSnapshot(mkt, tteSec));
continue;
}
const fetchStart = Date.now();
try {
const [upBook, downBook] = await Promise.all([
this._client.getOrderBook(mkt.upTokenId),
this._client.getOrderBook(mkt.downTokenId),
]);
const fetchMs = Date.now() - fetchStart;
const stale = fetchMs > STALE_THRESHOLD_MS;
const snapshot = this._buildSnapshot(mkt, upBook, downBook, tteSec, stale);
this._eventBus.emit('snapshot', snapshot);
// ── Throttled debug poll summary ──────────────────────────────
if (DEBUG) {
const count = (this._pollCount.get(mkt.slug) ?? 0) + 1;
this._pollCount.set(mkt.slug, count);
if (count % DEBUG_POLL_EVERY === 1) {
const u = snapshot.up;
const d = snapshot.down;
dbg('POLL',
`${mkt.slug} | tte=${tteSec}s | fetchMs=${fetchMs}ms${stale ? ' [STALE]' : ''}\n` +
` UP bid=${u.bestBid.toFixed(4)}/ask=${u.bestAsk.toFixed(4)} ` +
`sprd=${u.spread.toFixed(4)} mid=${u.mid.toFixed(4)} ` +
`dBid=${u.depthBid.toFixed(1)} dAsk=${u.depthAsk.toFixed(1)}\n` +
` DOWN bid=${d.bestBid.toFixed(4)}/ask=${d.bestAsk.toFixed(4)} ` +
`sprd=${d.spread.toFixed(4)} mid=${d.mid.toFixed(4)} ` +
`dBid=${d.depthBid.toFixed(1)} dAsk=${d.depthAsk.toFixed(1)}`,
);
}
}
} catch (err) {
dbg('POLL', `${mkt.slug} → poll error: ${err.message}`);
}
}
}
// ── Snapshot builder ──────────────────────────────────────────────────────
/** Synthetic snapshot emitted when a market has already closed (tteSec < 0). */
_buildExpiredSnapshot(mkt, tteSec) {
const emptySide = (tokenId) => ({
tokenId,
bids: [], asks: [],
bestBid: 0, bestAsk: 1, mid: 0.5,
spread: 1, depthBid: 0, depthAsk: 0,
bestBidSize: 0, bestAskSize: 0,
});
return {
ts: Date.now(),
marketSlug: mkt.slug,
conditionId: mkt.conditionId,
tteSec,
tickSize: mkt.tickSize,
negRisk: mkt.negRisk,
up: emptySide(mkt.upTokenId),
down: emptySide(mkt.downTokenId),
stale: true, // blocks SignalEngine gates — no new entries on expired book
};
}
_buildSnapshot(mkt, upBook, downBook, tteSec, stale) {
const up = this._buildSide(mkt.upTokenId, upBook);
const down = this._buildSide(mkt.downTokenId, downBook);
return {
ts: Date.now(),
marketSlug: mkt.slug,
conditionId: mkt.conditionId,
tteSec,
tickSize: mkt.tickSize,
negRisk: mkt.negRisk,
up,
down,
stale: stale || up.bestBid === 0 || down.bestBid === 0,
};
}
_buildSide(tokenId, book) {
const parse = (raw = []) =>
(Array.isArray(raw) ? raw : [])
.filter((l) => l?.price && l?.size)
.map((l) => ({ price: parseFloat(l.price), size: parseFloat(l.size) }))
.filter((l) => l.price > 0 && l.size > 0);
const bids = parse(book?.bids).sort((a, b) => b.price - a.price);
const asks = parse(book?.asks).sort((a, b) => a.price - b.price);
const bestBid = bids[0]?.price ?? 0;
const bestAsk = asks[0]?.price ?? 1;
const mid = bestBid > 0 && bestAsk < 1
? (bestBid + bestAsk) / 2
: (bestBid || bestAsk || 0.5);
const spread = Math.max(0, bestAsk - bestBid);
const topN = Math.min(TOP_N_LEVELS, Math.max(bids.length, asks.length));
const depthBid = bids.slice(0, topN).reduce((s, l) => s + l.size, 0);
const depthAsk = asks.slice(0, topN).reduce((s, l) => s + l.size, 0);
return {
tokenId,
bids,
asks,
bestBid,
bestAsk,
mid,
spread,
depthBid,
depthAsk,
bestBidSize: bids[0]?.size ?? 0,
bestAskSize: asks[0]?.size ?? 0,
};
}
}
+153
View File
@@ -0,0 +1,153 @@
/**
* PositionEngine.js
* Step G of the runtime sequence.
*
* Maintains position state per market and evaluates exit conditions
* on every incoming snapshot tick.
*
* Strategy: Hold to Expiry (Dominant Side)
* ─────────────────────────────────────────
* Positions entered on the dominant (probable winner) side are held until
* the market expires and the payout is claimed via the on-chain redeemer.
* There are no take-profit sells, no momentum-based exits.
*
* Exit conditions (priority order):
* 1. EXIT_EXPIRED — TTE <= 0: market has closed, pending on-chain redemption
* 2. EXIT_ADVERSE_MOVE — Token mid has collapsed below the stop-loss floor
* (configurable absolute threshold, e.g. 0.20)
* Protects against a complete market reversal while still
* allowing normal price fluctuations in the dominant range.
*/
import { ReasonCode } from './constants.js';
export class PositionEngine {
/**
* @param {Object} opts
* @param {number} [opts.stopLossMid=0.20] - Exit if token mid falls below this absolute level.
* Set to 0 to disable the stop-loss entirely.
*/
constructor({ stopLossMid = 0.20 } = {}) {
this._stopLossMid = stopLossMid;
/** @type {Map<string, PositionState>} */
this._positions = new Map();
}
// ── Position lifecycle ─────────────────────────────────────────────────
/**
* Record a newly filled position.
*
* @param {string} marketSlug
* @param {Object} data
* @param {string} data.tokenId
* @param {'up'|'down'} data.side
* @param {number} data.shares
* @param {number} data.entryPrice
* @param {number} data.tickSize
* @param {string} [data.conditionId] - Required for auto-redemption
* @param {boolean} [data.negRisk] - Which CTF contract to use for redemption
*/
open(marketSlug, { tokenId, side, shares, entryPrice, tickSize, conditionId = null, negRisk = false }) {
this._positions.set(marketSlug, {
marketSlug,
tokenId,
side,
shares,
entryPrice,
tickSize,
conditionId,
negRisk,
openedAt: Date.now(),
});
}
/** @returns {PositionState|null} */
getPosition(marketSlug) {
return this._positions.get(marketSlug) ?? null;
}
hasPosition(marketSlug) {
return this._positions.has(marketSlug);
}
/** True if ANY position is open across all tracked markets */
hasAnyPosition() {
return this._positions.size > 0;
}
/**
* Close the position actively (adverse-move emergency exit) and return exit data.
*
* @param {string} marketSlug
* @param {number} exitPrice - Actual fill price of the sell order
* @returns {{ pnl: number, shares: number, entryPrice: number, exitPrice: number }}
*/
close(marketSlug, exitPrice) {
const pos = this._positions.get(marketSlug);
if (!pos) return { pnl: 0, shares: 0, entryPrice: 0, exitPrice };
const pnl = (exitPrice - pos.entryPrice) * pos.shares;
this._positions.delete(marketSlug);
return { pnl, shares: pos.shares, entryPrice: pos.entryPrice, exitPrice };
}
/**
* Mark a position as expired (market closed, pending on-chain redemption).
* Does NOT compute final P&L — that is settled by the redeemer service.
*
* @param {string} marketSlug
* @returns {PositionState|null}
*/
closeExpired(marketSlug) {
const pos = this._positions.get(marketSlug) ?? null;
if (pos) this._positions.delete(marketSlug);
return pos;
}
// ── Exit evaluation ────────────────────────────────────────────────────
/**
* Evaluate whether the current position should be exited.
* Called on every snapshot tick while in POSITION_OPEN state.
*
* @param {string} marketSlug
* @param {Object} snapshot - Current market snapshot
* @returns {{ shouldExit: boolean, reason: string|null, isExpired: boolean }}
*/
evaluateExit(marketSlug, snapshot) {
const pos = this._positions.get(marketSlug);
if (!pos) return { shouldExit: false, reason: null, isExpired: false };
const { tteSec } = snapshot;
const bookSide = pos.side === 'up' ? snapshot.up : snapshot.down;
// 1. Market expired — hand off to on-chain redeemer
if (tteSec <= 0) {
return { shouldExit: false, reason: ReasonCode.EXIT_EXPIRED, isExpired: true };
}
// 2. Catastrophic stop-loss: token has completely collapsed
// (market reversed strongly against us — salvage remaining value)
if (this._stopLossMid > 0 && bookSide.mid < this._stopLossMid) {
return { shouldExit: true, reason: ReasonCode.EXIT_ADVERSE_MOVE, isExpired: false };
}
return { shouldExit: false, reason: null, isExpired: false };
}
}
/**
* @typedef {Object} PositionState
* @property {string} marketSlug
* @property {string} tokenId
* @property {'up'|'down'} side
* @property {number} shares
* @property {number} entryPrice
* @property {number} tickSize
* @property {string|null} conditionId - CTF condition ID for on-chain redemption
* @property {boolean} negRisk - Whether to use NegRisk CTF contract
* @property {number} openedAt
*/
+280
View File
@@ -0,0 +1,280 @@
/**
* RedeemEngine.js
* Auto-redemption service for the OneShot Dominant Side Hold engine.
*
* When a market expires and the position is cleared, this service queues the
* position and polls at a regular interval until the CTF contract shows a
* non-zero payout denominator (i.e. the market has been resolved on-chain).
* It then either:
* - DRY_RUN=true → simulates the outcome, logs win/loss P&L
* - DRY_RUN=false → submits a real redeemPositions() transaction on Polygon
*
* Resolution flow:
* 1. Gamma API check → market.closed || market.resolved
* 2. On-chain check → CTF.payoutDenominator(conditionId) > 0
* 3. Compute payout → payouts[0] for UP (YES), payouts[1] for DOWN (NO)
* 4. Execute / log
* 5. Emit 'redemption:complete' on EventBus with final P&L
*
* Payout index mapping:
* side === 'up' → outcome index 0 (YES / Up token)
* side === 'down' → outcome index 1 (NO / Down token)
*/
import { ethers } from 'ethers';
import logger from '../utils/logger.js';
import { getPolygonProvider } from '../services/client.js';
import { redeemPosition, CTF_ADDRESS } from '../services/ctf.js';
import { dbg } from './debug.js';
// ── On-chain constants (read-only — no writes go through EOA) ─────────────────
const CTF_ABI = [
'function payoutNumerators(bytes32 conditionId, uint256 outcomeIndex) view returns (uint256)',
'function payoutDenominator(bytes32 conditionId) view returns (uint256)',
];
export class RedeemEngine {
/**
* @param {Object} opts
* @param {boolean} opts.dryRun - If true, simulate instead of real tx
* @param {number} [opts.pollIntervalMs] - How often to check pending queue (ms)
* @param {import('./EventBus.js').default} opts.eventBus
*/
constructor({ dryRun, pollIntervalMs = 30_000, eventBus }) {
this._dryRun = dryRun;
this._pollMs = pollIntervalMs;
this._eventBus = eventBus;
this._pollTimer = null;
/**
* @type {Map<string, PendingRedemption>}
* Key: conditionId
*/
this._queue = new Map();
/** Prevent concurrent processing of the same conditionId */
this._processing = new Set();
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
start() {
this._pollTimer = setInterval(() => this._poll().catch(() => {}), this._pollMs);
logger.info(`RedeemEngine: started | poll every ${this._pollMs / 1000}s | dryRun=${this._dryRun}`);
}
stop() {
clearInterval(this._pollTimer);
if (this._queue.size > 0) {
logger.warn(`RedeemEngine: stopped — ${this._queue.size} position(s) still pending redemption:`);
for (const [, item] of this._queue) {
logger.warn(`${item.marketSlug} | ${item.side.toUpperCase()} | ${item.shares} shares @ $${item.entryPrice.toFixed(4)}`);
}
} else {
logger.info('RedeemEngine: stopped — no pending redemptions');
}
}
// ── Public API ────────────────────────────────────────────────────────────
/**
* Add an expired position to the redemption queue.
* Safe to call multiple times — duplicate conditionIds are ignored.
*
* @param {Object} data
* @param {string} data.conditionId
* @param {string} data.marketSlug
* @param {'up'|'down'} data.side
* @param {number} data.shares
* @param {number} data.entryPrice
* @param {boolean} data.negRisk
*/
queueRedemption({ conditionId, marketSlug, side, shares, entryPrice, negRisk }) {
if (!conditionId) {
logger.warn(`RedeemEngine: missing conditionId for ${marketSlug} — skipping queue`);
return;
}
if (this._queue.has(conditionId)) return;
this._queue.set(conditionId, {
conditionId,
marketSlug,
side,
shares,
entryPrice,
negRisk: negRisk ?? false,
queuedAt: Date.now(),
});
logger.info(
`RedeemEngine: queued ${marketSlug} | ${side.toUpperCase()} | ` +
`${shares} shares @ $${entryPrice.toFixed(4)} | pending on-chain resolution`,
);
// Trigger an immediate check rather than waiting for the first poll tick
this._checkAndRedeem(this._queue.get(conditionId)).catch(() => {});
}
/** Number of positions waiting to be redeemed */
get pendingCount() {
return this._queue.size;
}
// ── Poll loop ─────────────────────────────────────────────────────────────
async _poll() {
if (this._queue.size === 0) return;
dbg('REDEEM', `poll — ${this._queue.size} pending: [${[...this._queue.keys()].map((id) => id.slice(0, 8) + '...').join(', ')}]`);
for (const [, item] of this._queue) {
if (this._processing.has(item.conditionId)) continue;
this._processing.add(item.conditionId);
this._checkAndRedeem(item)
.catch((err) => logger.error(`RedeemEngine: error on ${item.marketSlug}${err.message}`))
.finally(() => this._processing.delete(item.conditionId));
}
}
// ── Resolution check ──────────────────────────────────────────────────────
async _checkAndRedeem(item) {
// Always use on-chain as ground truth for payout data
const onChain = await this._checkOnChainPayout(item.conditionId);
if (!onChain.resolved) {
// Gamma API as a secondary status check (informational only)
const gammaResolved = await this._checkGammaResolution(item.conditionId);
const secWaiting = Math.floor((Date.now() - item.queuedAt) / 1000);
dbg('REDEEM',
`${item.marketSlug} | not yet settled on-chain | ` +
`gammaResolved=${gammaResolved} | waited=${secWaiting}s`,
);
return; // retry on next poll tick
}
await this._settle(item, onChain.payouts);
}
// ── Settlement ────────────────────────────────────────────────────────────
async _settle(item, payouts) {
// UP token = outcome index 0 (YES), DOWN token = outcome index 1 (NO)
const outcomeIdx = item.side === 'up' ? 0 : 1;
const payoutFraction = payouts[outcomeIdx] ?? 0;
const won = payoutFraction > 0;
const received = payoutFraction * item.shares; // USDC back from CTF
const cost = item.entryPrice * item.shares; // USDC paid at entry
const pnl = received - cost;
if (this._dryRun) {
// Simulate: just log the outcome without touching the chain
this._logSettlement(item, won, pnl, received, cost);
} else {
// Always attempt redeemPositions — even for losses (burns the token, cleans wallet)
const success = await this._executeRedeem(item);
if (!success && won) {
// Win but tx failed — USDC unclaimed, keep in queue and retry next poll
logger.warn(`RedeemEngine: redemption tx failed for ${item.marketSlug} — will retry`);
return;
}
// Loss: clear from queue regardless of tx result — payout is 0, nothing to collect
this._logSettlement(item, won, pnl, received, cost);
}
// Clear from queue and notify orchestrator
this._queue.delete(item.conditionId);
this._eventBus.emit('redemption:complete', {
conditionId: item.conditionId,
marketSlug: item.marketSlug,
side: item.side,
won,
pnl,
shares: item.shares,
entryPrice: item.entryPrice,
});
}
_logSettlement(item, won, pnl, received, cost) {
const tag = this._dryRun ? '[SIM]' : '';
if (won) {
const pct = cost > 0 ? ((pnl / cost) * 100).toFixed(1) : '0.0';
logger.money(
`${tag} RedeemEngine WIN | ${item.marketSlug} | ${item.side.toUpperCase()} won | ` +
`+$${pnl.toFixed(4)} (+${pct}%) | ` +
`${item.shares} shares: paid $${cost.toFixed(4)} → received $${received.toFixed(4)}`,
);
} else {
logger.error(
`${tag} RedeemEngine LOSS | ${item.marketSlug} | ${item.side.toUpperCase()} lost | ` +
`-$${cost.toFixed(4)} (-100%) | ${item.shares} shares @ $${item.entryPrice.toFixed(4)}`,
);
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
async _checkGammaResolution(conditionId) {
try {
const url = `${config.gammaHost}/markets?condition_id=${conditionId}`;
const resp = await fetch(url);
if (!resp.ok) return false;
const markets = await resp.json();
if (!Array.isArray(markets) || markets.length === 0) return false;
const m = markets[0];
return !!(m.closed || m.resolved);
} catch {
return false;
}
}
/**
* Read payoutNumerators and payoutDenominator from the CTF contract.
* Returns resolved=true only when denominator > 0 (market has been settled).
*/
async _checkOnChainPayout(conditionId) {
try {
const provider = await getPolygonProvider();
const ctf = new ethers.Contract(CTF_ADDRESS, CTF_ABI, provider);
const denom = await ctf.payoutDenominator(conditionId);
if (denom.isZero()) return { resolved: false, payouts: [] };
const payouts = [];
for (let i = 0; i < 2; i++) {
const num = await ctf.payoutNumerators(conditionId, i);
payouts.push(num.toNumber() / denom.toNumber());
}
return { resolved: true, payouts };
} catch {
return { resolved: false, payouts: [] };
}
}
/** Submit redeemPositions() via Gnosis Safe proxy wallet (same path as MM) */
async _executeRedeem(item) {
try {
logger.info(`RedeemEngine: submitting redeem tx | ${item.marketSlug}...`);
await redeemPosition(item.conditionId, item.negRisk);
logger.success(`RedeemEngine: redeemed | ${item.marketSlug}`);
return true;
} catch (err) {
logger.error(`RedeemEngine: tx error | ${item.marketSlug}${err.message}`);
return false;
}
}
}
/**
* @typedef {Object} PendingRedemption
* @property {string} conditionId
* @property {string} marketSlug
* @property {'up'|'down'} side
* @property {number} shares
* @property {number} entryPrice
* @property {boolean} negRisk
* @property {number} queuedAt - timestamp when queued
*/
+138
View File
@@ -0,0 +1,138 @@
/**
* RiskEngine.js
* Step H of the runtime sequence.
*
* Global risk enforcement across all markets in the same session:
*
* Consecutive loss cap — after N consecutive losses, enter COOLDOWN for
* `cooldownRounds` market opportunities
* Daily loss cap — if total daily P&L drops below -dailyLossCap,
* HALT all trading for the rest of the day
*
* All policy violations are surfaced via canTrade() so the orchestrator
* can gate entries without needing direct access to internal state.
*/
import logger from '../utils/logger.js';
import { ReasonCode } from './constants.js';
export class RiskEngine {
/**
* @param {Object} opts
* @param {number} opts.maxConsecLosses - Consecutive losses before cooldown
* @param {number} opts.cooldownRounds - Market slots to skip during cooldown
* @param {number} opts.dailyLossCap - Max cumulative daily loss in USDC (positive number)
*/
constructor({ maxConsecLosses = 2, cooldownRounds = 3, dailyLossCap = 20 }) {
this._maxConsecLosses = maxConsecLosses;
this._cooldownRounds = cooldownRounds;
this._dailyLossCap = dailyLossCap;
this._dailyPnl = 0;
this._consecLosses = 0;
this._cooldownLeft = 0;
this._halted = false;
this._sessionStart = Date.now();
}
// ── Public API ────────────────────────────────────────────────────────────
/**
* Check whether a new entry is allowed.
* @returns {{ ok: boolean, reason: string|null, halted: boolean }}
*/
canTrade() {
if (this._halted) {
return { ok: false, reason: ReasonCode.RISK_DAILY_CAP, halted: true };
}
if (this._cooldownLeft > 0) {
return { ok: false, reason: ReasonCode.RISK_CONSEC_LOSS, halted: false };
}
return { ok: true, reason: null, halted: false };
}
/** True if the engine is in cooldown (but not halted) */
isCooldown() {
return !this._halted && this._cooldownLeft > 0;
}
/** True if trading has been permanently halted for today */
isHalted() {
return this._halted;
}
/**
* Record the P&L of a closed position and update risk counters.
* @param {number} pnl - Realised P&L in USDC (negative = loss)
*/
recordResult(pnl) {
this._dailyPnl += pnl;
if (pnl < 0) {
this._consecLosses++;
if (this._consecLosses >= this._maxConsecLosses) {
this._cooldownLeft = this._cooldownRounds;
logger.warn(
`RiskEngine: ${this._consecLosses} consecutive losses — ` +
`entering cooldown for ${this._cooldownRounds} rounds`,
);
}
} else {
// Reset consecutive loss streak on any win
this._consecLosses = 0;
}
// Daily cap check
if (this._dailyPnl <= -Math.abs(this._dailyLossCap)) {
this._halted = true;
logger.error(
`RiskEngine: daily loss cap hit ($${this._dailyPnl.toFixed(2)}) — ` +
`trading HALTED for the rest of the session`,
);
}
this._logState(pnl);
}
/**
* Decrement the cooldown counter by one market slot.
* Called by the orchestrator each time a new market opportunity is seen
* while in cooldown mode.
*/
decrementCooldown() {
if (this._cooldownLeft > 0) {
this._cooldownLeft--;
logger.info(`RiskEngine: cooldown rounds remaining: ${this._cooldownLeft}`);
if (this._cooldownLeft === 0) {
this._consecLosses = 0;
logger.success('RiskEngine: cooldown lifted — resuming normal trading');
}
}
}
/** Current session statistics snapshot */
stats() {
return {
dailyPnl: this._dailyPnl,
consecLosses: this._consecLosses,
cooldownLeft: this._cooldownLeft,
halted: this._halted,
};
}
// ── Internal ──────────────────────────────────────────────────────────────
_logState(pnl) {
const sign = pnl >= 0 ? '+' : '';
const stats = this.stats();
logger.info(
`RiskEngine: pnl=${sign}$${pnl.toFixed(4)} | ` +
`daily=$${stats.dailyPnl.toFixed(4)} | ` +
`streak=${stats.consecLosses} | ` +
`cooldown=${stats.cooldownLeft}`,
);
}
}
+304
View File
@@ -0,0 +1,304 @@
/**
* SignalEngine.js
* Steps C & D of the runtime sequence.
*
* Strategy: Dominant Side Hold — Momentum-Aware Entry
* ────────────────────────────────────────────────────
* Enters ONLY the side that the market already prices as probable winner
* (mid > 50%) AND whose price is either rising or stable.
*
* "Follow where the odds are moving" — midSlope6s from FeatureEngine is now
* a first-class scoring factor. A dominant side that is actively FADING
* (slope < SLOPE_CANCEL) is blocked entirely even if its mid is still > 0.60,
* because a fading dominant signals a potential reversal.
*
* Entry pipeline (per 'features' event):
* 1. Hard gates — stale, TTE out of [tteMin, tteMax], spread > SPREAD_MAX, depth thin
* 2. Dominant side — identify which token the market prices higher; require mid gap >= MIN_MID_GAP
* 3. Min probability — dominant mid must be >= minDominantMid (e.g. 0.58)
* 4. Momentum gate — dominant midSlope6s must be >= SLOPE_CANCEL (not actively fading)
* 5. Score — weighted: mid strength (35%) + momentum (30%) + imbalance (20%) + spread (15%)
* 6. Threshold — score >= scoreThreshold
*
* Key parameter changes vs previous version:
* - SPREAD_MAX: 0.02 → 0.04 (near-expiry books often have 0.03 spread)
* - tteMax: 90 → 150s (catch direction when it is being established)
* - Added W_MOMENTUM = 0.30 (replaces old W_SLOPE/W_RETRACE scalper metrics)
* - Added momentum gate (SIG_FADING_DOMINANT) to block reversals
*/
import { Signal, ReasonCode } from './constants.js';
import { dbg, DEBUG } from './debug.js';
// ── Score weights ──────────────────────────────────────────────────────────────
const W_MID = 0.35; // How strongly the market prices this side as winner
const W_MOMENTUM = 0.30; // Is the dominant odds direction being maintained?
const W_IMBALANCE = 0.20; // Order-book depth confirms the direction
const W_SPREAD = 0.15; // Execution cost (less critical for hold-to-expiry)
// ── Gate thresholds ────────────────────────────────────────────────────────────
const SPREAD_MAX = 0.04; // Hard gate: spread wider than this → skip
const MIN_MID_GAP = 0.08; // Hard gate: |up.mid - down.mid| must exceed this
// ── Momentum constants ─────────────────────────────────────────────────────────
// SLOPE_CANCEL: if dominant side's 6s slope is below this, the market may be
// reversing — block entry even if mid is still above threshold.
const SLOPE_CANCEL = -0.0020; // Active fade = potential reversal, do not enter
const SLOPE_STRONG = 0.0020; // Clearly rising — best signal
const SLOPE_MILD = 0.0005; // Gently rising — still good
// ── Imbalance constants ────────────────────────────────────────────────────────
const IMB_STRONG = 0.20;
const IMB_WEAK = 0.05;
/** Throttle debug output: log detail every N evaluations per market */
const DEBUG_EVERY = 5;
export class SignalEngine {
/**
* @param {Object} opts
* @param {import('./EventBus.js').default} opts.eventBus
* @param {number} opts.scoreThreshold - Minimum composite score to trigger entry (01)
* @param {number} opts.minTopSize - Minimum shares at best bid/ask for depth gate
* @param {number} opts.minDominantMid - Dominant side mid must be >= this (e.g. 0.58)
* @param {number} [opts.tteMin=15] - Minimum TTE in seconds
* @param {number} [opts.tteMax=150] - Maximum TTE in seconds
*/
constructor({ eventBus, scoreThreshold, minTopSize, minDominantMid = 0.58, tteMin = 15, tteMax = 150 }) {
this._eventBus = eventBus;
this._scoreThreshold = scoreThreshold;
this._minTopSize = minTopSize;
this._minDominantMid = minDominantMid;
this._tteMin = tteMin;
this._tteMax = tteMax;
/** Per-market evaluation counter for throttled debug logs */
this._evalCount = new Map();
this._eventBus.on('features', (feat) => this._onFeatures(feat));
}
// ── Internal ──────────────────────────────────────────────────────────────
_onFeatures(feat) {
const { ts, marketSlug, tteSec, snapshot } = feat;
const evalN = (this._evalCount.get(marketSlug) ?? 0) + 1;
this._evalCount.set(marketSlug, evalN);
const logThis = DEBUG && (evalN % DEBUG_EVERY === 1);
// ── Step C: hard gates ──────────────────────────────────────────────
const gate = this._hardGates(snapshot, tteSec);
if (logThis) {
if (!gate.pass) {
dbg('GATE',
`${marketSlug} | tte=${tteSec}s | FAIL → ${gate.reason} | ` +
`upSprd=${snapshot.up.spread.toFixed(3)} dnSprd=${snapshot.down.spread.toFixed(3)} ` +
`upMid=${snapshot.up.mid.toFixed(3)} dnMid=${snapshot.down.mid.toFixed(3)}`,
);
} else {
dbg('GATE',
`${marketSlug} | tte=${tteSec}s | PASS | ` +
`upMid=${snapshot.up.mid.toFixed(3)} dnMid=${snapshot.down.mid.toFixed(3)}`,
);
}
}
if (!gate.pass) {
this._emit(marketSlug, Signal.NO_TRADE, null, 0, gate.reason, ts, snapshot, feat);
return;
}
// ── Step D1: identify dominant side ─────────────────────────────────
// The dominant side is whichever token the market prices higher.
const upMid = snapshot.up.mid;
const downMid = snapshot.down.mid;
const midGap = Math.abs(upMid - downMid);
if (midGap < MIN_MID_GAP) {
if (logThis) {
dbg('SCORE',
`${marketSlug} | NO_DOMINANT | upMid=${upMid.toFixed(3)} dnMid=${downMid.toFixed(3)} ` +
`gap=${midGap.toFixed(3)} < ${MIN_MID_GAP}`,
);
}
this._emit(marketSlug, Signal.NO_TRADE, null, 0, ReasonCode.SIG_NO_DOMINANT, ts, snapshot, feat);
return;
}
const isDominantUp = upMid > downMid;
const dominantMid = isDominantUp ? upMid : downMid;
const dominantBook = isDominantUp ? snapshot.up : snapshot.down;
const dominantFeat = isDominantUp ? feat.up : feat.down;
const signal = isDominantUp ? Signal.ENTER_LONG : Signal.ENTER_SHORT;
const side = isDominantUp ? 'up' : 'down';
const slope = dominantFeat?.midSlope6s ?? 0;
// ── Step D2: minimum probability gate ───────────────────────────────
if (dominantMid < this._minDominantMid) {
if (logThis) {
dbg('SCORE',
`${marketSlug} | ${side.toUpperCase()} | LOW_DOMINANT | ` +
`mid=${dominantMid.toFixed(3)} < ${this._minDominantMid}`,
);
}
this._emit(marketSlug, Signal.NO_TRADE, null, 0, ReasonCode.SIG_LOW_DOMINANT, ts, snapshot, feat);
return;
}
// ── Step D3: momentum gate ───────────────────────────────────────────
// If the dominant side's price is actively falling, the market may be
// reversing. A fading dominant is more dangerous than a weak dominant.
if (slope < SLOPE_CANCEL) {
if (logThis) {
dbg('SCORE',
`${marketSlug} | ${side.toUpperCase()} | FADING | ` +
`slope=${slope.toFixed(5)} < ${SLOPE_CANCEL} (reversal risk)`,
);
}
this._emit(marketSlug, Signal.NO_TRADE, null, 0, ReasonCode.SIG_FADING_DOMINANT, ts, snapshot, feat);
return;
}
// ── Step D4: composite score ─────────────────────────────────────────
const midScore = this._scoreMid(dominantMid);
const momentumScore = this._scoreMomentum(slope);
const imbalanceScore = this._scoreImbalance(dominantFeat?.imbalance ?? 0);
const spreadScore = this._scoreSpread(dominantBook.spread);
const score =
W_MID * midScore +
W_MOMENTUM * momentumScore +
W_IMBALANCE * imbalanceScore +
W_SPREAD * spreadScore;
if (logThis) {
dbg('SCORE',
`${marketSlug} | ${side.toUpperCase()} dominant | ` +
`mid=${dominantMid.toFixed(3)} gap=${midGap.toFixed(3)} slope=${slope.toFixed(5)} | ` +
`midS=${midScore.toFixed(2)} momS=${momentumScore.toFixed(2)} ` +
`imbS=${imbalanceScore.toFixed(2)} sprdS=${spreadScore.toFixed(2)} ` +
`→ score=${score.toFixed(3)} (need ${this._scoreThreshold})`,
);
}
if (score < this._scoreThreshold) {
this._emit(marketSlug, Signal.NO_TRADE, null, score, ReasonCode.SIG_SCORE_LOW, ts, snapshot, feat);
return;
}
// Always log qualifying entries regardless of throttle
dbg('SIGNAL',
`>>> ${signal} | ${marketSlug} | ` +
`mid=${dominantMid.toFixed(3)} slope=${slope.toFixed(5)} ` +
`score=${score.toFixed(3)} tte=${tteSec}s`,
);
this._emit(marketSlug, signal, side, score, null, ts, snapshot, feat);
}
// ── Hard gates ────────────────────────────────────────────────────────────
_hardGates(snapshot, tteSec) {
if (snapshot.stale)
return { pass: false, reason: ReasonCode.GATE_STALE_BOOK };
if (tteSec < this._tteMin || tteSec > this._tteMax)
return { pass: false, reason: ReasonCode.GATE_TTE_FAIL };
// Use the dominant side's spread only — underdog's spread is irrelevant
// since we never buy the underdog.
const dominantSpread = Math.min(snapshot.up.spread, snapshot.down.spread);
if (dominantSpread > SPREAD_MAX)
return { pass: false, reason: ReasonCode.GATE_SPREAD_WIDE };
// Require adequate depth on at least one side (dominant side check happens after)
const thinUp = snapshot.up.bestBidSize < this._minTopSize
|| snapshot.up.bestAskSize < this._minTopSize;
const thinDown = snapshot.down.bestBidSize < this._minTopSize
|| snapshot.down.bestAskSize < this._minTopSize;
if (thinUp && thinDown)
return { pass: false, reason: ReasonCode.GATE_DEPTH_THIN };
return { pass: true, reason: null };
}
// ── Scoring helpers ───────────────────────────────────────────────────────
/**
* Score market confidence in the dominant side.
* Higher mid price = market is more certain = higher score.
* Entry "sweet spot" is 0.600.80 (clear direction, still worth holding).
*/
_scoreMid(mid) {
if (mid >= 0.85) return 1.00;
if (mid >= 0.75) return 0.85;
if (mid >= 0.65) return 0.65;
if (mid >= 0.58) return 0.40;
return 0;
}
/**
* Score the momentum (direction) of the dominant side's price movement.
* This is the "follow where the odds are moving" factor.
*
* Positive slope = dominant side is getting more expensive = conviction increasing.
* Flat slope = direction held, acceptable.
* Mild negative = slight give-back, cautious but still allowed.
* SLOPE_CANCEL = actively fading = blocked by momentum gate before reaching here.
*/
_scoreMomentum(slope) {
if (slope >= SLOPE_STRONG) return 1.00; // Strong, fast move in dominant direction
if (slope >= SLOPE_MILD) return 0.75; // Steady climb
if (slope >= 0) return 0.50; // Flat / holding
if (slope >= -0.0005) return 0.20; // Slight give-back — cautious
return 0.05; // Between -0.0005 and SLOPE_CANCEL — marginal
}
/**
* Score order-book imbalance for the dominant side.
* Positive = more buy depth on dominant side = confirms direction.
* Mildly negative = tolerated (sellers exist on winner too, normal).
*/
_scoreImbalance(imb) {
if (imb >= IMB_STRONG) return 1.00;
if (imb >= IMB_WEAK) return 0.70;
if (imb >= -0.10) return 0.40; // Neutral to slight sell pressure
if (imb >= -0.25) return 0.10; // Notable sell pressure
return 0;
}
/**
* Score execution cost (spread).
* For hold-to-expiry the spread is paid once at entry, so wider spreads
* are more tolerated than in a scalping strategy — hence 4 tiers up to SPREAD_MAX.
*/
_scoreSpread(spread) {
if (spread <= 0.01) return 1.00;
if (spread <= 0.02) return 0.70;
if (spread <= 0.03) return 0.40;
if (spread <= 0.04) return 0.10;
return 0;
}
_emit(marketSlug, signal, side, score, reason, ts, snapshot, features) {
this._eventBus.emit('signal', {
ts,
marketSlug,
tteSec: snapshot.tteSec,
signal,
side,
score,
reason,
snapshot,
features,
});
}
}
+69
View File
@@ -0,0 +1,69 @@
/**
* StateMachine.js
* Explicit per-market state machine with strict transition guards.
*
* Rule: never derive state from floating booleans.
* Every state change must go through transition() to be validated and logged.
*/
import { State, TRANSITIONS } from './constants.js';
export class StateMachine {
/**
* @param {string} marketSlug - Market identifier (used in error messages and logs)
* @param {import('./EventBus.js').default} eventBus
*/
constructor(marketSlug, eventBus) {
this._state = State.IDLE;
this._slug = marketSlug;
this._eventBus = eventBus;
}
/** Current state string */
get state() {
return this._state;
}
/**
* Attempt a state transition.
* Throws if the transition is not in the allowed graph — this is intentional:
* a programming error that bypasses the guard should be loud and traceable.
*
* @param {string} nextState - One of the State enum values
* @param {string} [reason] - Human-readable reason for the transition
* @returns {StateMachine} - Returns `this` for chaining
*/
transition(nextState, reason = '') {
const allowed = TRANSITIONS[this._state] ?? [];
if (!allowed.includes(nextState)) {
throw new Error(
`[StateMachine] Invalid transition: ${this._state}${nextState}` +
` (market: ${this._slug}, reason: ${reason})`,
);
}
const from = this._state;
this._state = nextState;
this._eventBus.emit('state:transition', {
marketSlug: this._slug,
from,
to: nextState,
reason,
ts: Date.now(),
});
return this;
}
/** @param {string} state */
is(state) {
return this._state === state;
}
/** @param {string} state */
canTransitionTo(state) {
return (TRANSITIONS[this._state] ?? []).includes(state);
}
}
+111
View File
@@ -0,0 +1,111 @@
/**
* Telemetry.js
* Structured JSONL logger for the OneShot engine.
*
* Every decision tick, order lifecycle event, position exit, and state
* transition is recorded to data/oneshot_telemetry.jsonl — one JSON object
* per line — for offline analysis and strategy tuning.
*/
import fs from 'fs';
import path from 'path';
import logger from '../utils/logger.js';
const DATA_DIR = path.resolve('data');
const LOG_FILE = path.join(DATA_DIR, 'oneshot_telemetry.jsonl');
export class Telemetry {
constructor() {
// Ensure data/ directory exists
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
}
// ── Public log methods ───────────────────────────────────────────────────
/**
* Log a per-decision-tick evaluation record.
* Called for every signal evaluation, whether entry is taken or not.
*
* @param {Object} d
* @param {string} d.marketSlug
* @param {number} d.ts
* @param {number} d.tteSec
* @param {number} d.spread
* @param {number} d.imbalance
* @param {number} d.slope
* @param {number} d.retrace
* @param {number} d.depth
* @param {boolean} d.gatePass
* @param {string} d.reasonCode
* @param {number} d.score
* @param {string} d.action
*/
logDecision(d) {
this._write({ type: 'decision', ...d });
}
/**
* Log an order lifecycle event (submit → ack → fill / cancel).
*
* @param {Object} d
* @param {string} d.clientOrderId
* @param {string} d.side
* @param {string} d.marketSlug
* @param {number} d.px
* @param {number} d.qty
* @param {number} d.ackMs
* @param {number} d.fillMs
* @param {string} d.status
*/
logOrder(d) {
this._write({ type: 'order', ...d });
}
/**
* Log a position exit event.
*
* @param {Object} d
* @param {string} d.marketSlug
* @param {string} d.exitReason
* @param {number} d.entryPx
* @param {number} d.exitPx
* @param {number} d.pnl
* @param {number} d.shares
*/
logExit(d) {
this._write({ type: 'exit', ...d });
const pnlStr = d.pnl == null
? 'pending(on-chain)'
: d.pnl >= 0
? `+$${d.pnl.toFixed(4)}`
: `-$${Math.abs(d.pnl).toFixed(4)}`;
logger.money(`[Telemetry] exit ${d.marketSlug} | ${d.exitReason} | pnl=${pnlStr}`);
}
/**
* Log a state machine transition.
*
* @param {Object} d
* @param {string} d.marketSlug
* @param {string} d.from
* @param {string} d.to
* @param {string} d.reason
* @param {number} d.ts
*/
logTransition(d) {
this._write({ type: 'transition', ...d });
}
// ── Internal ─────────────────────────────────────────────────────────────
_write(record) {
const line = JSON.stringify({ ...record, ts: record.ts ?? Date.now() }) + '\n';
fs.appendFile(LOG_FILE, line, (err) => {
if (err) logger.warn(`[Telemetry] write error: ${err.message}`);
});
}
}
+70
View File
@@ -0,0 +1,70 @@
/**
* constants.js
* Shared enums and reason codes for the Anti-Flip 5m OneShot Engine.
* All objects are frozen to prevent accidental mutation at runtime.
*/
// ── State machine states ───────────────────────────────────────────────────────
export const State = Object.freeze({
IDLE: 'IDLE', // Waiting for a qualifying signal
SETUP_READY: 'SETUP_READY', // Signal passed — about to submit order
ORDER_PENDING: 'ORDER_PENDING', // Order submitted, awaiting fill ack
POSITION_OPEN: 'POSITION_OPEN', // Filled — actively managing position
REDUCE_ONLY: 'REDUCE_ONLY', // Time threshold reached — exit only, no new entry
COOLDOWN: 'COOLDOWN', // Short suspension after consecutive losses
HALTED: 'HALTED', // Daily stop-loss hit — no more trading today
});
// ── Reason / decision codes ────────────────────────────────────────────────────
export const ReasonCode = Object.freeze({
// Hard gate failures
GATE_TTE_FAIL: 'GATE_TTE_FAIL', // TTE outside [25, 120] range
GATE_SPREAD_WIDE: 'GATE_SPREAD_WIDE', // Spread exceeds maximum threshold
GATE_DEPTH_THIN: 'GATE_DEPTH_THIN', // Best bid/ask size below minimum
GATE_STALE_BOOK: 'GATE_STALE_BOOK', // Book snapshot is stale or empty
// Signal evaluation failures
SIG_SCORE_LOW: 'SIG_SCORE_LOW', // Composite score below threshold
SIG_NO_CONFIRM: 'SIG_NO_CONFIRM', // Trend confirmation failed (legacy)
SIG_NO_DOMINANT: 'SIG_NO_DOMINANT', // Neither side is clearly dominant (mid gap too small)
SIG_LOW_DOMINANT: 'SIG_LOW_DOMINANT', // Dominant side mid below minimum threshold
SIG_FADING_DOMINANT: 'SIG_FADING_DOMINANT', // Dominant side mid is actively falling — reversal risk
// Execution failures
EXEC_TIMEOUT_NO_FILL: 'EXEC_TIMEOUT_NO_FILL', // FOK timed out without fill
EXEC_PARTIAL_REDUCE: 'EXEC_PARTIAL_REDUCE', // Partial fill reduced & closed
EXEC_SUBMIT_ERROR: 'EXEC_SUBMIT_ERROR', // Order submission threw error
// Risk policy
RISK_CONSEC_LOSS: 'RISK_CONSEC_LOSS', // Consecutive loss limit triggered cooldown
RISK_DAILY_CAP: 'RISK_DAILY_CAP', // Daily loss cap reached — halted
RISK_STATE_BLOCK: 'RISK_STATE_BLOCK', // Risk engine blocked entry (cooldown/halted)
// Exit reasons
EXIT_ADVERSE_MOVE: 'EXIT_ADVERSE_MOVE', // Token mid collapsed below stop-loss floor
EXIT_EXPIRED: 'EXIT_EXPIRED', // Market expired — position pending on-chain redemption
EXIT_RISK_FORCED: 'EXIT_RISK_FORCED', // Risk engine forced exit
});
// ── Signal directions ──────────────────────────────────────────────────────────
export const Signal = Object.freeze({
NO_TRADE: 'NO_TRADE', // Conditions not met — skip
ENTER_LONG: 'ENTER_LONG', // Buy UP token
ENTER_SHORT: 'ENTER_SHORT', // Buy DOWN token
});
// ── Valid state transitions ────────────────────────────────────────────────────
// Used by StateMachine to enforce the explicit transition graph.
export const TRANSITIONS = Object.freeze({
[State.IDLE]: [State.SETUP_READY, State.COOLDOWN, State.HALTED],
[State.SETUP_READY]: [State.ORDER_PENDING, State.IDLE, State.COOLDOWN, State.HALTED],
[State.ORDER_PENDING]: [State.POSITION_OPEN, State.IDLE, State.COOLDOWN, State.HALTED],
[State.POSITION_OPEN]: [State.REDUCE_ONLY, State.IDLE, State.COOLDOWN, State.HALTED],
[State.REDUCE_ONLY]: [State.IDLE, State.COOLDOWN, State.HALTED],
[State.COOLDOWN]: [State.IDLE, State.HALTED],
[State.HALTED]: [],
});
+28
View File
@@ -0,0 +1,28 @@
/**
* debug.js
* Lightweight debug helper for the OneShot engine.
*
* Enable by setting ONESHOT_DEBUG=true in your .env or environment,
* or by passing --debug on the command line:
*
* ONESHOT_DEBUG=true npm run oneshot
* npm run oneshot -- --debug
* npm run oneshot-debug (shorthand script)
*/
import logger from '../utils/logger.js';
export const DEBUG = process.env.ONESHOT_DEBUG === 'true'
|| process.argv.includes('--debug');
/**
* Log a debug message — no-op when DEBUG is false.
* Prefixes every line with a [DBG <tag>] marker so you can grep by component.
*
* @param {string} tag - Component name, e.g. 'FEED', 'GATE', 'SCORE'
* @param {string} msg - Message string
*/
export function dbg(tag, msg) {
if (!DEBUG) return;
logger.info(`[DBG:${tag}] ${msg}`);
}
-11
View File
@@ -2,7 +2,6 @@ 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;
@@ -12,16 +11,6 @@ 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);
+40 -185
View File
@@ -12,7 +12,6 @@ import { ethers } from 'ethers';
import config from '../config/index.js';
import { getSigner, getPolygonProvider } from './client.js';
import logger from '../utils/logger.js';
import { proxyFetch } from '../utils/proxy.js';
// ── Contract addresses (Polygon mainnet) ──────────────────────────────────────
@@ -60,8 +59,8 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
* Strips the lengthy internal stack info that ethers appends.
*/
function parseOnchainError(err) {
const msg = err?.message || String(err);
const reason = err?.reason || err?.error?.reason || '';
const msg = err?.message || String(err);
const reason = err?.reason || err?.error?.reason || '';
if (msg.includes('insufficient funds') || msg.includes('insufficient balance'))
return 'Insufficient MATIC balance for gas fees';
@@ -106,11 +105,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.
*/
export function execSafeCall(to, data, description = '') {
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;
}
@@ -118,14 +117,11 @@ 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();
@@ -146,32 +142,15 @@ 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.
// 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);
// Some RPC nodes (e.g. lava.build) return a stale low estimate, so we enforce a floor.
const feeData = await provider.getFeeData();
const MIN_TIP = ethers.utils.parseUnits('30', 'gwei');
const gasTip = feeData.maxPriorityFeePerGas?.gt(MIN_TIP) ? feeData.maxPriorityFeePerGas : MIN_TIP;
const gasFeeCap = feeData.maxFeePerGas ?? ethers.utils.parseUnits('500', 'gwei');
const tx = await safe.execTransaction(
to, 0, data, 0, 0, 0, 0,
@@ -189,14 +168,7 @@ async function _doExecSafeCall(to, data, description = '') {
const friendly = parseOnchainError(err);
if (attempt < MAX_RETRIES) {
// Increase gas multiplier for replacement transaction
if (err?.message?.includes('replacement transaction underpriced') ||
err?.message?.includes('Gas price too low to replace')) {
gasMultiplier *= 2;
logger.warn(`MM: transaction failed (attempt ${attempt}/${MAX_RETRIES}): ${friendly} — increasing gas ${gasMultiplier}x and retrying...`);
} else {
logger.warn(`MM: transaction failed (attempt ${attempt}/${MAX_RETRIES}): ${friendly} — retrying in ${RETRY_DELAY / 1000}s...`);
}
logger.warn(`MM: transaction failed (attempt ${attempt}/${MAX_RETRIES}): ${friendly} — retrying in ${RETRY_DELAY / 1000}s...`);
await sleep(RETRY_DELAY);
}
}
@@ -360,7 +332,7 @@ export async function cleanupOpenPositions(clobClient) {
let dataPositions = [];
try {
const url = `https://data-api.polymarket.com/positions?user=${config.proxyWallet}`;
const resp = await proxyFetch(url);
const resp = await fetch(url);
if (resp.ok) dataPositions = await resp.json();
if (!Array.isArray(dataPositions)) dataPositions = [];
} catch (err) {
@@ -437,6 +409,28 @@ export async function cleanupOpenPositions(clobClient) {
}
}
// ── Single-position redeemer (used by OneShot RedeemEngine) ──────────────────
/**
* Redeem a single resolved position through the Gnosis Safe proxy wallet.
* Uses the same execSafeCall path as all other MM on-chain operations,
* enforcing the 30 Gwei priority fee floor required by Polygon.
*
* @param {string} conditionId - bytes32 condition ID
* @param {boolean} negRisk - use NegRisk CTF address if true
*/
export async function redeemPosition(conditionId, negRisk = false) {
const ctfAddress = negRisk ? NEG_RISK_EXCHANGE : CTF_ADDRESS;
const ctfIface = new ethers.utils.Interface(CTF_ABI);
const data = ctfIface.encodeFunctionData('redeemPositions', [
USDC_ADDRESS,
ethers.constants.HashZero,
conditionId,
[1, 2],
]);
await execSafeCall(ctfAddress, data, `redeemPositions ${conditionId.slice(0, 12)}...`);
}
// ── Periodic redeemer ─────────────────────────────────────────────────────────
/**
@@ -452,7 +446,7 @@ export async function redeemMMPositions() {
// 1. Query Data API for all positions held by the proxy wallet
let dataPositions = [];
try {
const resp = await proxyFetch(`${config.dataHost}/positions?user=${config.proxyWallet}`);
const resp = await fetch(`${config.dataHost}/positions?user=${config.proxyWallet}`);
if (resp.ok) dataPositions = await resp.json();
if (!Array.isArray(dataPositions)) dataPositions = [];
} catch {
@@ -469,12 +463,12 @@ export async function redeemMMPositions() {
const byCondition = new Map();
for (const pos of dataPositions) {
const cid = pos.conditionId || pos.condition_id;
const tid = pos.asset || pos.tokenId || pos.token_id;
const tid = pos.asset || pos.tokenId || pos.token_id;
if (!cid || !tid) continue;
if (!byCondition.has(cid)) byCondition.set(cid, []);
byCondition.get(cid).push({
tokenId: String(tid),
size: parseFloat(pos.size || pos.currentValue || '0'),
size: parseFloat(pos.size || pos.currentValue || '0'),
});
}
@@ -535,142 +529,3 @@ export async function redeemMMPositions() {
logger.success(`MM redeemer: collected ${redeemed} resolved position(s)`);
}
}
// ── Sniper-specific redeemer ──────────────────────────────────────────────────
// Track conditionIds that repeatedly fail or are losses to avoid retrying every cycle
const _failedConditions = new Set();
const _skippedLosses = new Set();
/**
* 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`);
}
}
+55 -248
View File
@@ -1,37 +1,12 @@
import { Side, OrderType } from '@polymarket/clob-client';
import { ethers } from 'ethers';
import config from '../config/index.js';
import { getClient, getUsdcBalance, getPolygonProvider } from './client.js';
import { getClient, getUsdcBalance } from './client.js';
import { hasPosition, addPosition, getPosition, updatePosition, removePosition } from './position.js';
import { fetchMarketByTokenId } from './watcher.js';
import { placeAutoSell } from './autoSell.js';
import { ensureExchangeApproval, CTF_ADDRESS } from './ctf.js';
import { recordSimBuy } from '../utils/simStats.js';
import logger from '../utils/logger.js';
const CTF_ABI_BALANCE = ['function balanceOf(address account, uint256 id) view returns (uint256)'];
// Per-market buy queue: prevents concurrent buys for the same market.
// Each conditionId maps to the Promise tail of its queue so calls are
// chained — the next buy only starts after the previous one finishes.
const _buyQueue = new Map();
/**
* Fetch the actual on-chain ERC-1155 balance for a conditional token.
* Returns shares as a plain float (6-decimal conversion).
*/
async function getOnChainTokenBalance(tokenId) {
try {
const provider = await getPolygonProvider();
const ctf = new ethers.Contract(CTF_ADDRESS, CTF_ABI_BALANCE, provider);
const raw = await ctf.balanceOf(config.proxyWallet, tokenId);
return parseFloat(ethers.utils.formatUnits(raw, 6));
} catch (err) {
logger.warn(`Could not fetch on-chain token balance: ${err.message}`);
return null;
}
}
/**
* Calculate trade size for our entry — independent of the individual fill event.
*
@@ -61,13 +36,10 @@ async function getMarketOptions(tokenId) {
const marketInfo = await fetchMarketByTokenId(tokenId);
if (marketInfo) {
return {
tickSize: String(marketInfo.orderPriceMinTickSize || '0.01'),
negRisk: marketInfo.negRisk || false,
conditionId: marketInfo.conditionId || '',
question: marketInfo.question || '',
endDateIso: marketInfo.endDate || null,
active: marketInfo.active !== false,
acceptingOrders: marketInfo.acceptingOrders !== false,
tickSize: String(marketInfo.minimum_tick_size || '0.01'),
negRisk: marketInfo.neg_risk || false,
conditionId: marketInfo.condition_id || '',
question: marketInfo.question || '',
};
}
} catch (err) {
@@ -78,128 +50,23 @@ async function getMarketOptions(tokenId) {
try {
const tickSize = await client.getTickSize(tokenId);
const negRisk = await client.getNegRisk(tokenId);
return { tickSize: String(tickSize), negRisk, conditionId: '', question: '', endDateIso: null, active: true, acceptingOrders: true };
return { tickSize: String(tickSize), negRisk, conditionId: '', question: '' };
} catch (err) {
logger.warn('Failed to get tick size from SDK, using default 0.01');
return { tickSize: '0.01', negRisk: false, conditionId: '', question: '', endDateIso: null, active: true, acceptingOrders: true };
return { tickSize: '0.01', negRisk: false, conditionId: '', question: '' };
}
}
/**
* Execute a BUY trade (copy trader's buy).
* Calls are serialized per market — concurrent events for the same market
* are queued and processed one at a time to prevent duplicate positions.
* Execute a BUY trade (copy trader's buy)
* @param {Object} trade - Trade info from watcher
*/
export function executeBuy(trade) {
const { tokenId, conditionId } = trade;
// Resolve conditionId to use as queue key.
// getMarketOptions is a read-only fetch — safe to run outside the queue.
const queued = getMarketOptions(tokenId).then((marketOpts) => {
const effectiveConditionId = conditionId || marketOpts.conditionId;
// Chain this buy after the previous one for the same market
const prev = _buyQueue.get(effectiveConditionId) ?? Promise.resolve();
const current = prev
.then(() => _doExecuteBuy(trade, marketOpts, effectiveConditionId))
.finally(() => {
// Remove from map only if we're still the tail (no newer call queued)
if (_buyQueue.get(effectiveConditionId) === current) {
_buyQueue.delete(effectiveConditionId);
}
});
_buyQueue.set(effectiveConditionId, current);
return current;
});
return queued;
}
/**
* GTC fallback for when FAK finds no liquidity (e.g. trader buys into "next market"
* before any sellers exist). Places a GTC limit order and polls until filled or timeout.
*
* Returns { sharesFilled, costFilled } on success, or null on failure/timeout.
*/
async function _tryGtcFallback(client, tokenId, tradeSize, price, marketOpts) {
const gtcPrice = parseFloat(Math.min(price * 1.02, 0.99).toFixed(4));
const shares = parseFloat((tradeSize / gtcPrice).toFixed(4));
logger.info(`No liquidity via FAK — placing GTC limit buy: ${shares} shares @ $${gtcPrice}`);
let orderId;
try {
const resp = await client.createAndPostOrder(
{ tokenID: tokenId, side: Side.BUY, price: gtcPrice, size: shares },
{ tickSize: marketOpts.tickSize, negRisk: marketOpts.negRisk },
OrderType.GTC,
);
if (!resp?.success) {
logger.warn(`GTC fallback rejected: ${resp?.errorMsg || 'unknown'}`);
return null;
}
orderId = resp.orderID;
logger.info(`GTC order placed: ${orderId} — waiting for fill (up to ${config.gtcFallbackTimeout}s)...`);
} catch (err) {
logger.warn(`GTC fallback order failed: ${err.message}`);
return null;
}
const deadline = Date.now() + config.gtcFallbackTimeout * 1000;
const pollMs = 3000;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, pollMs));
try {
const order = await client.getOrder(orderId);
const matched = parseFloat(order?.size_matched ?? order?.matched_amount ?? '0');
const status = (order?.status ?? order?.order_status ?? '').toLowerCase();
if (matched > 0 || status === 'matched' || status === 'filled') {
const sharesFilled = matched > 0 ? matched : shares;
const costFilled = sharesFilled * gtcPrice;
logger.success(`GTC filled: ${sharesFilled.toFixed(4)} shares @ $${gtcPrice} | orderID: ${orderId}`);
return { sharesFilled, costFilled };
}
// Order gone from open orders also means it was matched
if (status === 'cancelled') {
logger.warn(`GTC order ${orderId} was cancelled externally`);
return null;
}
} catch { /* getOrder can 404 briefly — keep polling */ }
}
// Timed out — cancel the GTC
logger.warn(`GTC order ${orderId} not filled in ${config.gtcFallbackTimeout}s — cancelling`);
try { await client.cancelOrder({ orderID: orderId }); } catch { /* ignore */ }
return null;
}
/**
* Internal: the actual buy logic, guaranteed to run serially per market.
*/
async function _doExecuteBuy(trade, marketOpts, effectiveConditionId) {
export async function executeBuy(trade) {
const { tokenId, conditionId, market, price, size } = trade;
// ── Market expiry guard ────────────────────────────────────────────────────
if (!marketOpts.active || !marketOpts.acceptingOrders) {
logger.warn(`Market closed/not accepting orders: ${market || effectiveConditionId} — skipping buy`);
return;
}
if (marketOpts.endDateIso) {
const secsLeft = (new Date(marketOpts.endDateIso).getTime() - Date.now()) / 1000;
if (secsLeft < config.minMarketTimeLeft) {
const minsLeft = Math.max(0, Math.floor(secsLeft / 60));
const sLeft = Math.max(0, Math.floor(secsLeft % 60));
logger.warn(
`Market expires in ${minsLeft}m ${sLeft}s — below MIN_MARKET_TIME_LEFT ` +
`(${config.minMarketTimeLeft}s). Skipping buy: ${market || effectiveConditionId}`,
);
return;
}
}
// ──────────────────────────────────────────────────────────────────────────
// Get market options first to resolve conditionId
const marketOpts = await getMarketOptions(tokenId);
const effectiveConditionId = conditionId || marketOpts.conditionId;
// Check existing position and max position size cap
const existingPos = getPosition(effectiveConditionId);
@@ -223,11 +90,8 @@ async function _doExecuteBuy(trade, marketOpts, effectiveConditionId) {
tradeSize = Math.min(tradeSize, config.maxPositionSize);
}
// Polymarket enforces a hard $1 minimum per market order.
const CLOB_MIN_ORDER_USDC = 1;
const effectiveMin = Math.max(config.minTradeSize, CLOB_MIN_ORDER_USDC);
if (tradeSize < effectiveMin) {
logger.warn(`Trade size $${tradeSize.toFixed(2)} below $${effectiveMin} minimum — skipping buy`);
if (tradeSize < config.minTradeSize) {
logger.warn(`Trade size $${tradeSize.toFixed(2)} below minimum $${config.minTradeSize}. Skipping.`);
return;
}
@@ -267,7 +131,7 @@ async function _doExecuteBuy(trade, marketOpts, effectiveConditionId) {
return;
}
// Place market order (FAK) with retries
// Place market order with retries
const client = getClient();
let filled = false;
let totalSharesFilled = 0;
@@ -276,63 +140,56 @@ async function _doExecuteBuy(trade, marketOpts, effectiveConditionId) {
for (let attempt = 1; attempt <= config.maxRetries; attempt++) {
try {
const remainingAmount = tradeSize - totalCostFilled;
if (remainingAmount < effectiveMin) {
if (remainingAmount > 0) logger.info(`Remaining $${remainingAmount.toFixed(2)} below $${effectiveMin} minimum — stopping`);
break;
}
if (remainingAmount < config.minTradeSize) break;
logger.info(`Buy attempt ${attempt}/${config.maxRetries} | Amount: $${remainingAmount.toFixed(2)}`);
// Use FAK (fill-and-kill) to get what's available, then retry remainder
const response = await client.createAndPostMarketOrder(
{
tokenID: tokenId,
side: Side.BUY,
amount: remainingAmount,
price: Math.min(price * 1.02, 0.99), // 2% slippage, max 0.99
price: Math.min(price * 1.05, 0.99), // 5% slippage allowance, max 0.99
},
{
tickSize: marketOpts.tickSize,
negRisk: marketOpts.negRisk,
},
OrderType.FAK, // Fill-and-Kill: takes what's available, no full-fill requirement
OrderType.FOK,
);
if (response && response.success) {
const sharesFilled = parseFloat(response.takingAmount || '0');
const costFilled = parseFloat(response.makingAmount || '0');
logger.success(`Order placed: ${response.orderID} | Status: ${response.status}`);
if (sharesFilled > 0) {
logger.success(`Order filled: ${response.orderID} | ${sharesFilled.toFixed(4)} shares @ ~$${(costFilled / sharesFilled).toFixed(4)}`);
totalSharesFilled += sharesFilled;
totalCostFilled += costFilled || (sharesFilled * price);
// Check if fully filled by trying to get trade info
const takingAmount = parseFloat(response.takingAmount || '0');
const makingAmount = parseFloat(response.makingAmount || '0');
if (takingAmount > 0 || makingAmount > 0) {
totalSharesFilled += takingAmount || (remainingAmount / price);
totalCostFilled += makingAmount || remainingAmount;
filled = true;
// If remainder is below $1 minimum, stop; otherwise loop for partial fill
if (tradeSize - totalCostFilled < effectiveMin) break;
break; // FOK either fills fully or cancels
} else {
logger.warn(`No liquidity — FAK filled 0 shares (attempt ${attempt})`);
filled = true;
totalSharesFilled = tradeSize / price;
totalCostFilled = tradeSize;
break;
}
} else {
logger.warn(`Order rejected: ${response?.errorMsg || 'unknown'}`);
logger.warn(`Order not filled. Error: ${response?.errorMsg || 'Unknown'}`);
}
} catch (err) {
logger.error(`Buy attempt ${attempt} failed: ${err.message}`);
logger.error(`Buy attempt ${attempt} failed:`, err.message);
}
// Wait before retry
if (attempt < config.maxRetries) {
await new Promise((r) => setTimeout(r, config.retryDelay));
}
}
// FAK found no liquidity — fall back to GTC limit order and wait for fill
if (!filled && config.gtcFallbackTimeout > 0) {
const gtcResult = await _tryGtcFallback(client, tokenId, tradeSize, price, marketOpts);
if (gtcResult) {
totalSharesFilled = gtcResult.sharesFilled;
totalCostFilled = gtcResult.costFilled;
filled = true;
}
}
if (!filled || totalCostFilled === 0) {
logger.error(`Failed to fill buy order for ${market || tokenId} after ${config.maxRetries} attempts`);
return;
@@ -364,13 +221,6 @@ async function _doExecuteBuy(trade, marketOpts, effectiveConditionId) {
outcome: trade.outcome,
});
// Ensure the CTF Exchange is approved to move our ERC-1155 tokens (needed for future sells)
try {
await ensureExchangeApproval(marketOpts.negRisk);
} catch (err) {
logger.warn(`Could not verify ERC-1155 approval: ${err.message}`);
}
// Auto-sell only on initial entry, not on accumulation
if (config.autoSellEnabled) {
await placeAutoSell(effectiveConditionId, tokenId, totalSharesFilled, fillAvgPrice, marketOpts);
@@ -413,29 +263,15 @@ export async function executeSell(trade) {
return;
}
// Cancel ALL open orders for this token so the CLOB frees up locked balance.
// Only cancelling by sellOrderId is not enough — the cancel can fail silently
// and locked tokens cause "not enough balance" on the subsequent sell.
const client = getClient();
try {
const openOrders = await client.getOpenOrders({ asset_id: tokenId });
if (Array.isArray(openOrders) && openOrders.length > 0) {
logger.info(`Cancelling ${openOrders.length} open order(s) for token before sell`);
await Promise.allSettled(
openOrders.map((o) => client.cancelOrder({ orderID: o.id ?? o.order_id })),
);
// Brief pause so the CLOB can update the locked-balance ledger
await new Promise((r) => setTimeout(r, 600));
// Cancel existing auto-sell order if any
if (position.sellOrderId) {
try {
const client = getClient();
await client.cancelOrder(position.sellOrderId);
logger.info(`Cancelled auto-sell order: ${position.sellOrderId}`);
} catch (err) {
logger.warn(`Failed to cancel auto-sell: ${err.message}`);
}
} catch (err) {
// Fallback: try to cancel just the tracked auto-sell order ID
if (position.sellOrderId) {
try {
await client.cancelOrder({ orderID: position.sellOrderId });
await new Promise((r) => setTimeout(r, 600));
} catch { /* ignore */ }
}
logger.warn(`Could not fetch open orders to cancel: ${err.message}`);
}
updatePosition(effectiveConditionId, { status: 'selling' });
@@ -444,64 +280,35 @@ export async function executeSell(trade) {
marketOpts = await getMarketOptions(tokenId);
}
// Ensure ERC-1155 approval so the exchange can transfer our tokens
try {
await ensureExchangeApproval(marketOpts.negRisk);
} catch (err) {
logger.warn(`Could not verify ERC-1155 approval: ${err.message}`);
}
// Reconcile stored shares with actual on-chain balance to prevent "not enough balance" errors.
// The stored amount can be higher than on-chain due to fee deductions or precision drift.
const onChain = await getOnChainTokenBalance(tokenId);
let sharesToSell = position.shares;
if (onChain !== null) {
if (onChain < 0.0001) {
logger.warn(`On-chain balance is 0 for ${position.market} — position already sold or redeemed`);
removePosition(effectiveConditionId);
return;
}
if (onChain < sharesToSell) {
logger.info(`Adjusting sell amount: stored ${sharesToSell.toFixed(6)} → on-chain ${onChain.toFixed(6)} shares`);
sharesToSell = onChain;
}
}
// Round down to 4 decimal places to avoid sub-unit precision errors
sharesToSell = Math.floor(sharesToSell * 10000) / 10000;
const client = getClient();
let filled = false;
for (let attempt = 1; attempt <= config.maxRetries; attempt++) {
try {
if (config.sellMode === 'market') {
// Market sell (FAK) — takes what's available at 2% slippage
logger.info(`Sell attempt ${attempt}/${config.maxRetries} (market) | Shares: ${sharesToSell}`);
// Market sell (FOK)
logger.info(`Sell attempt ${attempt}/${config.maxRetries} (market) | Shares: ${position.shares}`);
const response = await client.createAndPostMarketOrder(
{
tokenID: tokenId,
side: Side.SELL,
amount: sharesToSell,
price: Math.max(price * 0.98, 0.01), // 2% slippage, min 0.01
amount: position.shares,
price: Math.max(price * 0.95, 0.01), // 5% slippage, min 0.01
},
{
tickSize: marketOpts.tickSize,
negRisk: marketOpts.negRisk,
},
OrderType.FAK, // Fill-and-Kill: takes what's available
OrderType.FOK,
);
if (response && response.success) {
const sharesFilled = parseFloat(response.takingAmount || '0');
if (sharesFilled > 0) {
logger.success(`Sell filled: ${response.orderID} | ${sharesFilled.toFixed(4)} shares`);
filled = true;
break;
} else {
logger.warn(`No bid liquidity — FAK filled 0 shares (attempt ${attempt})`);
}
logger.success(`Sell order placed: ${response.orderID}`);
filled = true;
break;
} else {
logger.warn(`Sell rejected: ${response?.errorMsg || 'unknown'}`);
logger.warn(`Sell not filled: ${response?.errorMsg || 'Unknown'}`);
}
} else {
// Limit sell at trader's sell price
@@ -511,7 +318,7 @@ export async function executeSell(trade) {
{
tokenID: tokenId,
price: price,
size: sharesToSell,
size: position.shares,
side: Side.SELL,
},
{
-145
View File
@@ -1,145 +0,0 @@
/**
* 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
@@ -1,525 +0,0 @@
/**
* 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
@@ -1,363 +0,0 @@
/**
* 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);
}
}
+12 -13
View File
@@ -12,14 +12,13 @@
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 ──────────────────────────────────────────────────────────────
@@ -36,7 +35,7 @@ function nextSlot() {
async function fetchBySlug(asset, slotTimestamp) {
const slug = `${asset}-updown-${config.mmDuration}-${slotTimestamp}`;
try {
const resp = await proxyFetch(`${config.gammaHost}/markets/slug/${slug}`);
const resp = await fetch(`${config.gammaHost}/markets/slug/${slug}`);
if (!resp.ok) return null;
const data = await resp.json();
return data?.conditionId ? data : null;
@@ -62,7 +61,7 @@ function extractMarketData(market, asset) {
[yesTokenId, noTokenId] = tokenIds;
} else if (Array.isArray(market.tokens) && market.tokens.length >= 2) {
yesTokenId = market.tokens[0]?.token_id ?? market.tokens[0]?.tokenId;
noTokenId = market.tokens[1]?.token_id ?? market.tokens[1]?.tokenId;
noTokenId = market.tokens[1]?.token_id ?? market.tokens[1]?.tokenId;
}
if (!yesTokenId || !noTokenId) return null;
@@ -70,13 +69,13 @@ function extractMarketData(market, asset) {
return {
asset,
conditionId,
question: market.question || market.title || '',
endTime: market.endDate || market.end_date_iso || market.endDateIso,
question: market.question || market.title || '',
endTime: market.endDate || market.end_date_iso || market.endDateIso,
eventStartTime: market.eventStartTime || market.event_start_time,
yesTokenId: String(yesTokenId),
noTokenId: String(noTokenId),
negRisk: market.negRisk ?? market.neg_risk ?? false,
tickSize: String(market.orderPriceMinTickSize ?? market.minimum_tick_size ?? market.minimumTickSize ?? '0.01'),
yesTokenId: String(yesTokenId),
noTokenId: String(noTokenId),
negRisk: market.negRisk ?? market.neg_risk ?? false,
tickSize: String(market.orderPriceMinTickSize ?? market.minimum_tick_size ?? market.minimumTickSize ?? '0.01'),
};
}
@@ -99,7 +98,7 @@ async function scheduleAsset(asset, slotTimestamp) {
seenKeys.add(key);
// Refuse to enter a market already well into its window (e.g., bot restart mid-slot)
const openAt = data.eventStartTime ? new Date(data.eventStartTime).getTime() : slotTimestamp * 1000;
const openAt = data.eventStartTime ? new Date(data.eventStartTime).getTime() : slotTimestamp * 1000;
const elapsedSec = Math.round((Date.now() - openAt) / 1000);
if (elapsedSec > 15) {
logger.info(`MM: ${asset.toUpperCase()} next slot already ${elapsedSec}s old — skipping, will catch next`);
+58 -216
View File
@@ -133,14 +133,6 @@ 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) {
@@ -197,27 +189,11 @@ async function monitorAndManage(pos) {
break;
}
// ── 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 ────────────────────────────────────────────────────
// ── Cut-loss time ───────────────────────────────────────
if (msRemaining <= config.mmCutLossTime * 1000) {
logger.warn(`MM: cut-loss triggered (${Math.round(msRemaining / 1000)}s left) — ${label}`);
pos.status = 'cutting';
const oneLegFilled = pos.yes.filled !== pos.no.filled;
if (!config.mmAdaptiveCL && oneLegFilled) {
// Legacy: one side sold → immediate market sell on the other
const unfilledKey = pos.yes.filled ? 'no' : 'yes';
await cutLossOneLegFilled(pos, unfilledKey);
pos.status = 'done';
} else {
// Neither filled → cancel both + merge back to USDC
await cutLossNeitherFilled(pos);
}
await cutLoss(pos);
break;
}
@@ -232,63 +208,70 @@ async function monitorAndManage(pos) {
}
}
// 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;
async function cutLoss(pos) {
const { conditionId, tickSize, negRisk } = pos;
const neitherFilled = !pos.yes.filled && !pos.no.filled;
logger.warn(`MM: cancelling ${unfilledKey.toUpperCase()} limit order and market-selling...`);
await cancelOrder(s.orderId);
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);
const actualShares = await getTokenBalance(s.tokenId);
const sellShares = actualShares !== null ? actualShares : s.shares;
// 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),
]);
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;
}
// 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);
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)}`);
}
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)`);
}
async function cutLossNeitherFilled(pos) {
const { conditionId } = pos;
// 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;
// ── 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)`);
}
// ── One side already (partly) sold → market-sell the unfilled side ──
for (const side of ['yes', 'no']) {
const s = pos[side];
if (s.filled) continue;
// Mark both sides closed at entry price
pos.yes.fillPrice = pos.yes.entryPrice;
pos.yes.filled = true;
pos.no.fillPrice = pos.no.entryPrice;
pos.no.filled = true;
logger.warn(`MM: cancelling ${side.toUpperCase()} limit order and market-selling...`);
await cancelOrder(s.orderId);
// Fetch actual on-chain balance — partial fills reduce this below s.shares
const actualShares = await getTokenBalance(s.tokenId);
const sellShares = actualShares !== null ? actualShares : s.shares;
if (sellShares < 0.001) {
logger.warn(`MM: ${side.toUpperCase()} balance is 0 — already fully sold via partial fills`);
s.fillPrice = config.mmSellPrice; // assume sold at target
s.filled = true;
continue;
}
logger.warn(`MM: ${side.toUpperCase()} actual balance: ${sellShares.toFixed(3)} shares (original: ${s.shares})`);
const result = await marketSell(s.tokenId, sellShares, tickSize, negRisk);
s.fillPrice = result.fillPrice;
s.filled = true;
// PnL uses actual sold amount (not original pos.shares)
const pnl = (s.fillPrice - s.entryPrice) * sellShares;
logger.warn(`MM: ${side.toUpperCase()} cut @ $${s.fillPrice.toFixed(3)} | sold ${sellShares.toFixed(3)} sh | P&L $${pnl.toFixed(2)}`);
}
}
pos.status = 'done';
@@ -296,147 +279,6 @@ async function cutLossNeitherFilled(pos) {
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 -41
View File
@@ -1,13 +1,16 @@
import { ethers } from 'ethers';
import config from '../config/index.js';
import { getPolygonProvider } from './client.js';
import { execSafeCall, CTF_ADDRESS, USDC_ADDRESS } from './ctf.js';
import { getOpenPositions, removePosition } from './position.js';
import { recordSimResult } from '../utils/simStats.js';
import logger from '../utils/logger.js';
import { proxyFetch } from '../utils/proxy.js';
// CTF ABI (minimal — read-only calls only; writes go through execSafeCall)
// Contract addresses on Polygon
const CTF_ADDRESS = '0x4D97DCd97eC945f40cF65F87097ACe5EA0476045';
const NEG_RISK_CTF_ADDRESS = '0xC5d563A36AE78145C45a50134d48A1215220f80a';
const USDC_ADDRESS = '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174';
// CTF ABI (minimal for redeemPositions & balanceOf)
const CTF_ABI = [
'function redeemPositions(address collateralToken, bytes32 parentCollectionId, bytes32 conditionId, uint256[] indexSets)',
'function balanceOf(address owner, uint256 tokenId) view returns (uint256)',
@@ -21,7 +24,7 @@ const CTF_ABI = [
async function checkMarketResolution(conditionId) {
try {
const url = `${config.gammaHost}/markets?condition_id=${conditionId}`;
const response = await proxyFetch(url);
const response = await fetch(url);
if (!response.ok) return null;
const markets = await response.json();
@@ -64,30 +67,33 @@ async function checkOnChainPayout(conditionId) {
}
/**
* Redeem winning position on-chain via the Gnosis Safe proxy wallet.
* Uses execSafeCall (same as MM bot) so:
* - tx is signed by the EOA but executed FROM the proxy wallet
* - Polygon 30 Gwei minimum tip is enforced
* - automatic retry on transient errors
* Redeem winning position on-chain (real mode only)
*/
async function redeemPosition(conditionId) {
async function redeemPosition(conditionId, isNegRisk = false) {
try {
const ctfIface = new ethers.utils.Interface(CTF_ABI);
const data = ctfIface.encodeFunctionData('redeemPositions', [
USDC_ADDRESS,
ethers.constants.HashZero,
conditionId,
[1, 2],
]);
const provider = await getPolygonProvider();
const wallet = new ethers.Wallet(config.privateKey, provider);
const ctfAddress = isNegRisk ? NEG_RISK_CTF_ADDRESS : CTF_ADDRESS;
const ctf = new ethers.Contract(ctfAddress, CTF_ABI, wallet);
const label = conditionId.slice(0, 12) + '...';
logger.info(`Redeeming position: ${label}`);
const receipt = await execSafeCall(CTF_ADDRESS, data, `redeemPositions ${label}`);
const txHash = receipt.transactionHash;
logger.success(`Redeemed in block ${receipt.blockNumber} | tx: ${txHash}`);
const parentCollectionId = ethers.constants.HashZero;
const indexSets = [1, 2];
logger.info(`Redeeming position: ${conditionId}`);
const tx = await ctf.redeemPositions(
USDC_ADDRESS,
parentCollectionId,
conditionId,
indexSets,
{ gasLimit: 300000 },
);
logger.info(`Redeem tx: ${tx.hash}`);
const receipt = await tx.wait();
logger.success(`Redeemed in block ${receipt.blockNumber}`);
return true;
} catch (err) {
logger.error(`Failed to redeem: ${err.message}`);
logger.error('Failed to redeem:', err.message);
return false;
}
}
@@ -142,22 +148,17 @@ export async function checkAndRedeemPositions() {
for (const position of positions) {
try {
// 1. Quick check via Gamma API (low cost)
// 1. Check via Gamma API
const resolution = await checkMarketResolution(position.conditionId);
const apiResolved = resolution?.resolved;
const isResolved = resolution?.resolved;
if (!apiResolved) continue; // Not resolved yet — check again next interval
logger.info(`Market resolved via API: ${position.market}`);
// 2. ALWAYS verify on-chain payout before calling redeemPositions.
// Gamma API can report "resolved" before payoutDenominator is written
// on-chain. Calling redeemPositions with payoutDenominator == 0 causes
// the contract to revert → gas estimation failure.
const onChain = await checkOnChainPayout(position.conditionId);
if (!onChain.resolved) {
logger.info(`On-chain payout not set yet for ${position.market} — will retry next interval`);
continue;
if (!isResolved) {
// 2. Fallback: on-chain check
const onChain = await checkOnChainPayout(position.conditionId);
if (!onChain.resolved) continue;
logger.info(`Market resolved on-chain: ${position.market}`);
} else {
logger.info(`Market resolved: ${position.market}`);
}
// 3. Simulate or execute real redeem
@@ -167,14 +168,11 @@ export async function checkAndRedeemPositions() {
const success = await redeemPosition(position.conditionId);
if (success) {
removePosition(position.conditionId);
logger.money(`Redeemed: ${position.market} → USDC recovered`);
} else {
logger.warn(`Redeem failed for ${position.market}, will retry next interval — continuing to next position...`);
logger.money(`Redeemed: ${position.market}`);
}
}
} catch (err) {
logger.error(`Error checking ${position.market}:`, err.message);
logger.info(`Continuing to next position...`);
}
}
}
-139
View File
@@ -1,139 +0,0 @@
/**
* schedule.js
* Trading session schedule for the sniper bot.
* Reads schedule from .env via config (SNIPER_SCHEDULE_*).
*
* All times are in UTC+8 and converted to UTC internally.
* Assets outside their session window are skipped by the sniper detector.
*
* .env format per asset:
* SNIPER_SCHEDULE_BTC=19:40-22:40,03:40-06:10
* SNIPER_SCHEDULE_ETH=11:40-15:40,16:40-19:40
*/
import config from '../config/index.js';
import logger from '../utils/logger.js';
// UTC+8 offset in hours
const UTC8_OFFSET = 8;
/**
* Parse schedule string from .env.
* Format: "HH:MM-HH:MM,HH:MM-HH:MM"
* Returns: [{ startUtc8, endUtc8, startMin, endMin }]
*/
function parseScheduleString(str) {
if (!str || !str.trim()) return null;
const sessions = [];
const parts = str.split(',').map((s) => s.trim()).filter(Boolean);
for (const part of parts) {
const match = part.match(/^(\d{1,2}:\d{2})\s*[-]\s*(\d{1,2}:\d{2})$/);
if (!match) {
logger.warn(`SCHEDULE: invalid session format "${part}" — expected HH:MM-HH:MM`);
continue;
}
const [, startUtc8, endUtc8] = match;
sessions.push({
startUtc8,
endUtc8,
startMin: utc8ToUtcMinutes(startUtc8),
endMin: utc8ToUtcMinutes(endUtc8),
});
}
return sessions.length > 0 ? sessions : null;
}
/**
* Convert HH:MM in UTC+8 to minutes-since-midnight in UTC.
* Result is always in [0, 1440).
*/
function utc8ToUtcMinutes(hhmm) {
const [h, m] = hhmm.split(':').map(Number);
let totalMin = (h * 60 + m) - (UTC8_OFFSET * 60);
if (totalMin < 0) totalMin += 1440;
if (totalMin >= 1440) totalMin -= 1440;
return totalMin;
}
// Build schedule from config (reads SNIPER_SCHEDULE_* from .env)
const SCHEDULE = {};
const SCHEDULE_DISPLAY = {};
for (const [asset, raw] of Object.entries(config.sniperSchedule || {})) {
const sessions = parseScheduleString(raw);
if (sessions) {
SCHEDULE[asset] = sessions;
SCHEDULE_DISPLAY[asset] = sessions.map((s) => ({
startUtc8: s.startUtc8,
endUtc8: s.endUtc8,
}));
}
}
/**
* Get current time as minutes since midnight UTC.
*/
function nowMinutesUTC() {
const d = new Date();
return d.getUTCHours() * 60 + d.getUTCMinutes();
}
/**
* Check if `nowMin` falls within range [start, end).
* Handles overnight wrap (e.g. 22:0002:00).
*/
function inRange(nowMin, startMin, endMin) {
if (startMin <= endMin) {
return nowMin >= startMin && nowMin < endMin;
} else {
return nowMin >= startMin || nowMin < endMin;
}
}
/**
* Check if an asset is currently within its trading session.
* Returns true if asset has no schedule (always active).
*/
export function isAssetInSession(asset) {
const sessions = SCHEDULE[asset.toLowerCase()];
if (!sessions) return true; // no schedule = always active
const now = nowMinutesUTC();
return sessions.some((s) => inRange(now, s.startMin, s.endMin));
}
/**
* Get human-readable time until the next session opens.
* Returns string like "2h 15m" or null if currently in session.
*/
export function getNextSessionInfo(asset) {
const sessions = SCHEDULE[asset.toLowerCase()];
if (!sessions) return null;
const now = nowMinutesUTC();
if (sessions.some((s) => inRange(now, s.startMin, s.endMin))) return null;
let minWait = Infinity;
for (const s of sessions) {
let wait = s.startMin - now;
if (wait <= 0) wait += 1440;
if (wait < minWait) minWait = wait;
}
if (minWait === Infinity) return null;
const hours = Math.floor(minWait / 60);
const mins = minWait % 60;
if (hours > 0) return `${hours}h ${mins}m`;
return `${mins}m`;
}
/**
* Get the schedule for display (UTC+8 strings).
*/
export function getSchedule() {
return SCHEDULE_DISPLAY;
}
+14 -34
View File
@@ -14,12 +14,10 @@
import config from '../config/index.js';
import logger from '../utils/logger.js';
import { isAssetInSession, getNextSessionInfo } from './schedule.js';
import { proxyFetch } from '../utils/proxy.js';
const SLOT_SEC = 5 * 60; // 300 seconds
let pollTimer = null;
let pollTimer = null;
let onMarketCb = null;
const seenKeys = new Set(); // `${asset}-${slotTimestamp}` already handled
@@ -38,7 +36,7 @@ function nextSlot() {
async function fetchBySlug(asset, slotTimestamp) {
const slug = `${asset}-updown-5m-${slotTimestamp}`;
try {
const resp = await proxyFetch(`${config.gammaHost}/markets/slug/${slug}`);
const resp = await fetch(`${config.gammaHost}/markets/slug/${slug}`);
if (!resp.ok) return null;
const data = await resp.json();
return data?.conditionId ? data : null;
@@ -63,7 +61,7 @@ function extractMarketData(market, asset) {
[yesTokenId, noTokenId] = tokenIds;
} else if (Array.isArray(market.tokens) && market.tokens.length >= 2) {
yesTokenId = market.tokens[0]?.token_id ?? market.tokens[0]?.tokenId;
noTokenId = market.tokens[1]?.token_id ?? market.tokens[1]?.tokenId;
noTokenId = market.tokens[1]?.token_id ?? market.tokens[1]?.tokenId;
}
if (!yesTokenId || !noTokenId) return null;
@@ -71,13 +69,13 @@ function extractMarketData(market, asset) {
return {
asset,
conditionId,
question: market.question || market.title || '',
endTime: market.endDate || market.end_date_iso || market.endDateIso,
question: market.question || market.title || '',
endTime: market.endDate || market.end_date_iso || market.endDateIso,
eventStartTime: market.eventStartTime || market.event_start_time,
yesTokenId: String(yesTokenId),
noTokenId: String(noTokenId),
negRisk: market.negRisk ?? market.neg_risk ?? false,
tickSize: String(market.orderPriceMinTickSize ?? market.minimum_tick_size ?? '0.01'),
yesTokenId: String(yesTokenId),
noTokenId: String(noTokenId),
negRisk: market.negRisk ?? market.neg_risk ?? false,
tickSize: String(market.orderPriceMinTickSize ?? market.minimum_tick_size ?? '0.01'),
};
}
@@ -101,8 +99,8 @@ async function scheduleAsset(asset, slotTimestamp, isCurrent = false) {
if (isCurrent) {
// Current slot: only place orders if there's at least 30 seconds of market left
const endAt = data.endTime ? new Date(data.endTime).getTime() : (slotTimestamp + SLOT_SEC) * 1000;
const secsLeft = Math.round((endAt - Date.now()) / 1000);
const endAt = data.endTime ? new Date(data.endTime).getTime() : (slotTimestamp + SLOT_SEC) * 1000;
const secsLeft = Math.round((endAt - Date.now()) / 1000);
if (secsLeft < 30) {
logger.info(`SNIPER: ${asset.toUpperCase()} current market closing soon (${secsLeft}s) — skipping`);
return;
@@ -110,7 +108,7 @@ async function scheduleAsset(asset, slotTimestamp, isCurrent = false) {
logger.success(`SNIPER: ${asset.toUpperCase()} current market active (${secsLeft}s left) — placing orders now`);
} else {
// Next slot: market hasn't opened yet
const openAt = data.eventStartTime ? new Date(data.eventStartTime).getTime() : slotTimestamp * 1000;
const openAt = data.eventStartTime ? new Date(data.eventStartTime).getTime() : slotTimestamp * 1000;
const secsUntilOpen = Math.round((openAt - Date.now()) / 1000);
logger.success(`SNIPER: ${asset.toUpperCase()} found "${data.question.slice(0, 40)}"${secsUntilOpen > 0 ? `${secsUntilOpen}s before open` : ''}`);
}
@@ -124,25 +122,8 @@ async function poll() {
try {
const curr = currentSlot();
const next = nextSlot();
// Filter assets by trading session schedule
const activeAssets = config.sniperAssets.filter((asset) => {
if (!isAssetInSession(asset)) {
const nextInfo = getNextSessionInfo(asset);
const key = `skip-${asset}-${Math.floor(Date.now() / 60000)}`; // log once per minute
if (!seenKeys.has(key)) {
seenKeys.add(key);
logger.info(`SNIPER: ${asset.toUpperCase()} outside session window${nextInfo ? ` — next in ${nextInfo}` : ''}`);
}
return false;
}
return true;
});
if (activeAssets.length === 0) return;
// Check current active market AND the upcoming next one, in parallel for each asset
await Promise.all(activeAssets.flatMap((asset) => [
await Promise.all(config.sniperAssets.flatMap((asset) => [
scheduleAsset(asset, curr, true), // current market (if still has time left)
scheduleAsset(asset, next, false), // next upcoming market
]));
@@ -164,8 +145,7 @@ export function startSniperDetector(onNewMarket) {
const secsUntil = ns - Math.floor(Date.now() / 1000);
logger.info(`SNIPER detector started — assets: ${config.sniperAssets.join(', ').toUpperCase()}`);
logger.info(`Next slot: *-updown-5m-${ns} (opens in ${secsUntil}s)`);
const prices = config.sniperTierPrices;
logger.info(`Order: 3-tier ${prices[0]}c/${prices[1]}c/${prices[2]}c (max ${config.sniperMaxShares} shares per side)`);
logger.info(`Order: $${config.sniperPrice} × ${config.sniperShares} shares per side`);
}
export function stopSniperDetector() {
+54 -82
View File
@@ -1,10 +1,17 @@
/**
* sniperExecutor.js
* 3-Tier Sniper Strategy:
* - Tier 1: 3c price, smallest size (20% of max)
* - Tier 2: 2c price, medium size (30% of max)
* - Tier 3: 1c price, largest size (50% of max)
* Min 5 shares per tier, total = SNIPER_MAX_SHARES
* Places GTC limit BUY orders at a very low price on both sides of a market.
*
* Strategy:
* - For each market detected by sniperDetector, place two GTC BUY orders:
* UP token at $SNIPER_PRICE × SNIPER_SHARES shares
* DOWN token at $SNIPER_PRICE × SNIPER_SHARES shares
* - Orders sit in the orderbook. If someone panic-dumps below the price,
* the order fills and becomes redeemable if that side wins.
* - GTC orders expire automatically when the market closes — no cleanup needed.
*
* Cost per market: SNIPER_PRICE × SNIPER_SHARES × 2 sides
* e.g. $0.01 × 5 × 2 = $0.10 per market, $0.30 for 3 assets per 5-min slot
*/
import { Side, OrderType } from '@polymarket/clob-client';
@@ -12,37 +19,13 @@ import config from '../config/index.js';
import { getClient } from './client.js';
import logger from '../utils/logger.js';
// In-memory tracking of placed snipe orders
const activeSnipes = [];
// In-memory tracking of placed snipe orders (for TUI status panel)
const activeSnipes = []; // { asset, side, question, orderId, price, shares, cost, potentialPayout }
export function getActiveSnipes() {
return [...activeSnipes];
}
/**
* Calculate tier sizes based on max shares.
* Distribution: 20% | 30% | 50% (high→low price)
* Minimum 5 shares per tier.
*/
function calculateTierSizes(maxShares, minPerTier) {
// Distribution percentages
const ratios = [0.20, 0.30, 0.50]; // Tier 1, 2, 3
const sizes = ratios.map(ratio => {
const size = Math.floor(maxShares * ratio);
return Math.max(size, minPerTier);
});
// Ensure we don't exceed maxShares after rounding up to minimums
const total = sizes.reduce((a, b) => a + b, 0);
if (total > maxShares) {
// Adjust tier 3 (largest) down if needed
sizes[2] = Math.max(minPerTier, sizes[2] - (total - maxShares));
}
return sizes;
}
export async function executeSnipe(market) {
const { asset, conditionId, question, yesTokenId, noTokenId, tickSize, negRisk } = market;
const label = question.slice(0, 40);
@@ -53,67 +36,56 @@ export async function executeSnipe(market) {
{ name: 'DOWN', tokenId: noTokenId },
];
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]}`);
logger.info(`SNIPER: ${sim}${asset.toUpperCase()} — "${label}" | $${config.sniperPrice} × ${config.sniperShares}sh each side`);
for (const { name, tokenId } of sides) {
// Place 3 orders per side
for (let tier = 0; tier < 3; tier++) {
const price = prices[tier];
const size = sizes[tier];
if (config.dryRun) {
const cost = config.sniperPrice * config.sniperShares;
logger.trade(`SNIPER[SIM]: ${asset.toUpperCase()} ${name} @ $${config.sniperPrice} × ${config.sniperShares}sh | cost $${cost.toFixed(3)} | payout $${config.sniperShares} if wins`);
activeSnipes.push({
asset: asset.toUpperCase(),
side: name,
question: label,
orderId: `sim-${Date.now()}-${tokenId.slice(-6)}`,
price: config.sniperPrice,
shares: config.sniperShares,
cost,
potentialPayout: config.sniperShares,
});
continue;
}
if (config.dryRun) {
const cost = price * size;
logger.trade(`SNIPER[SIM]: ${asset.toUpperCase()} ${name} T${tier+1} @ $${price.toFixed(2)} × ${size}sh | cost $${cost.toFixed(3)}`);
const client = getClient();
try {
const res = await client.createAndPostOrder(
{
tokenID: tokenId,
side: Side.BUY,
price: config.sniperPrice,
size: config.sniperShares,
},
{ tickSize, negRisk },
OrderType.GTC,
);
if (res?.success) {
const cost = config.sniperPrice * config.sniperShares;
logger.trade(`SNIPER: ${asset.toUpperCase()} ${name} @ $${config.sniperPrice} × ${config.sniperShares}sh | cost $${cost.toFixed(3)} | order ${res.orderID}`);
activeSnipes.push({
asset: asset.toUpperCase(),
side: name,
tier: tier + 1,
question: label,
orderId: `sim-${Date.now()}-${tier}-${tokenId.slice(-6)}`,
price,
shares: size,
orderId: res.orderID,
price: config.sniperPrice,
shares: config.sniperShares,
cost,
potentialPayout: size,
potentialPayout: config.sniperShares,
});
continue;
}
const client = getClient();
try {
const res = await client.createAndPostOrder(
{
tokenID: tokenId,
side: Side.BUY,
price: price,
size: size,
},
{ tickSize, negRisk },
OrderType.GTC,
);
if (res?.success) {
const cost = price * size;
logger.trade(`SNIPER: ${asset.toUpperCase()} ${name} T${tier+1} @ $${price.toFixed(2)} × ${size}sh | cost $${cost.toFixed(3)} | order ${res.orderID}`);
activeSnipes.push({
asset: asset.toUpperCase(),
side: name,
tier: tier + 1,
question: label,
orderId: res.orderID,
price,
shares: size,
cost,
potentialPayout: size,
});
} else {
logger.warn(`SNIPER: ${asset.toUpperCase()} ${name} T${tier+1} failed — ${res?.errorMsg || 'unknown'}`);
}
} catch (err) {
logger.error(`SNIPER: ${asset.toUpperCase()} ${name} T${tier+1} error — ${err.message}`);
} else {
logger.warn(`SNIPER: ${asset.toUpperCase()} ${name} order failed — ${res?.errorMsg || 'unknown'}`);
}
} catch (err) {
logger.error(`SNIPER: ${asset.toUpperCase()} ${name} error — ${err.message}`);
}
}
}
+3 -4
View File
@@ -1,7 +1,6 @@
import config from '../config/index.js';
import logger from '../utils/logger.js';
import { readState, writeState } from '../utils/state.js';
import { proxyFetch } from '../utils/proxy.js';
const PROCESSED_FILE = 'processed_trades.json';
@@ -12,7 +11,7 @@ const PROCESSED_FILE = 'processed_trades.json';
async function fetchTraderActivity() {
const url = `${config.dataHost}/activity?user=${config.traderAddress}`;
try {
const response = await proxyFetch(url);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Data API returned ${response.status}`);
}
@@ -117,7 +116,7 @@ export { markTradeProcessed };
export async function fetchMarketInfo(conditionId) {
try {
const url = `${config.gammaHost}/markets?condition_id=${conditionId}`;
const response = await proxyFetch(url);
const response = await fetch(url);
if (!response.ok) return null;
const markets = await response.json();
return markets && markets.length > 0 ? markets[0] : null;
@@ -133,7 +132,7 @@ export async function fetchMarketInfo(conditionId) {
export async function fetchMarketByTokenId(tokenId) {
try {
const url = `${config.gammaHost}/markets?clob_token_ids=${tokenId}`;
const response = await proxyFetch(url);
const response = await fetch(url);
if (!response.ok) return null;
const markets = await response.json();
return markets && markets.length > 0 ? markets[0] : null;
-174
View File
@@ -1,174 +0,0 @@
/**
* sniper-tui.js
* TUI version of the Orderbook Sniper bot (blessed dashboard).
* Places tiny GTC BUY orders at a low price on both sides of 5-min markets.
*
* Run with: npm run sniper-tui (live)
* npm run sniper-tui-sim (simulation)
*/
// Load proxy patch BEFORE any other imports (must patch https before axios is loaded)
import './utils/proxy-patch.cjs';
import { validateMMConfig } from './config/index.js';
import config from './config/index.js';
import logger from './utils/logger.js';
import { initClient } from './services/client.js';
import { getUsdcBalance } from './services/client.js';
import { initDashboard, appendLog, updateStatus, isDashboardActive } from './ui/dashboard.js';
import { startSniperDetector, stopSniperDetector } from './services/sniperDetector.js';
import { executeSnipe, getActiveSnipes } 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);
+71 -44
View File
@@ -1,25 +1,21 @@
/**
* sniper.js
* Console-only entry point for the Orderbook Sniper bot.
* Places tiny GTC BUY orders at a low price on both sides of 5-min markets.
* Entry point for the Orderbook Sniper bot.
* Places tiny GTC BUY orders at $0.01 on both sides of ETH/SOL/XRP 5-min markets.
*
* Run with: npm run sniper (live, console)
* npm run sniper-sim (simulation, console)
*
* For the TUI dashboard version, use: npm run sniper-tui
* Run with: npm run sniper (live)
* npm run sniper-sim (simulation)
*/
import { validateMMConfig } from './config/index.js';
import config from './config/index.js';
import logger from './utils/logger.js';
import { initClient } from './services/client.js';
import { getUsdcBalance } from './services/client.js';
import { initDashboard, appendLog, updateStatus, isDashboardActive } from './ui/dashboard.js';
import { startSniperDetector, stopSniperDetector } from './services/sniperDetector.js';
import { executeSnipe } 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';
import { executeSnipe, getActiveSnipes } from './services/sniperExecutor.js';
import { redeemMMPositions } from './services/ctf.js';
// ── Validate config ────────────────────────────────────────────────────────────
@@ -35,6 +31,11 @@ if (config.sniperAssets.length === 0) {
process.exit(1);
}
// ── Init TUI ──────────────────────────────────────────────────────────────────
initDashboard();
logger.setOutput(appendLog);
// ── Init CLOB client ──────────────────────────────────────────────────────────
try {
@@ -44,40 +45,69 @@ try {
process.exit(1);
}
// ── Log session schedule ──────────────────────────────────────────────────────
// ── Status panel ──────────────────────────────────────────────────────────────
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)`);
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`);
}
}
logger.info('────────────────────────────────');
return '\n' + lines.join('\n');
}
// ── Redeemer ──────────────────────────────────────────────────────────────────
let refreshTimer = null;
let redeemTimer = null;
let redeemTimer = null;
function startRefresh() {
refreshTimer = setInterval(async () => {
if (!isDashboardActive()) return;
updateStatus(await buildStatusContent());
}, 3000);
buildStatusContent().then(updateStatus);
}
function startRedeemer() {
// Only run on interval, NOT on startup (we only want to redeem NEW winning positions)
redeemMMPositions().catch((err) => logger.error('Sniper redeemer error:', err.message));
redeemTimer = setInterval(
() => redeemSniperPositions().catch((err) => logger.error('Sniper redeemer error:', err.message)),
() => redeemMMPositions().catch((err) => logger.error('Sniper redeemer error:', err.message)),
config.redeemInterval,
);
logger.info(`Sniper redeemer started — checking every ${config.redeemInterval / 1000}s (winners only, no startup check)`);
logger.info(`Sniper redeemer started — checking every ${config.redeemInterval / 1000}s`);
}
// ── Market handler ────────────────────────────────────────────────────────────
@@ -93,23 +123,20 @@ async function handleNewMarket(market) {
function shutdown() {
logger.warn('SNIPER: shutting down...');
stopSniperDetector();
if (redeemTimer) clearInterval(redeemTimer);
if (refreshTimer) clearInterval(refreshTimer);
if (redeemTimer) clearInterval(redeemTimer);
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
// ── Start ─────────────────────────────────────────────────────────────────────
// Calculate cost for 3-tier strategy
const prices = config.sniperTierPrices;
const sizes = [Math.floor(config.sniperMaxShares * 0.20), Math.floor(config.sniperMaxShares * 0.30), Math.floor(config.sniperMaxShares * 0.50)];
const costPerSide = (sizes[0] * prices[0]) + (sizes[1] * prices[1]) + (sizes[2] * prices[2]);
const costPerSlot = (costPerSide * 2 * config.sniperAssets.length).toFixed(3);
const costPerSlot = (config.sniperPrice * config.sniperShares * 2 * config.sniperAssets.length).toFixed(3);
logger.info(`SNIPER starting — ${config.dryRun ? 'SIMULATION' : 'LIVE'}`);
logger.info(`Assets: ${config.sniperAssets.join(', ').toUpperCase()} | 3-tier: 3c×${sizes[0]}+2c×${sizes[1]}+1c×${sizes[2]} = $${costPerSlot}/slot`);
logger.info(`Assets: ${config.sniperAssets.join(', ').toUpperCase()} | $${config.sniperPrice} × ${config.sniperShares}sh = $${costPerSlot}/slot`);
logSchedule();
startRefresh();
startRedeemer();
startSniperDetector(handleNewMarket);
-39
View File
@@ -22,26 +22,6 @@ const B = {
let outputFn = null; // When set, all log goes here (blessed dashboard mode)
/**
* Sanitize a CLOB client console message.
* Strips the full axios config (which may contain auth headers) and returns
* only the HTTP status code + API error message.
*/
function sanitizeClobMessage(raw) {
if (!raw.includes('[CLOB Client]')) return raw;
try {
const jsonStart = raw.indexOf('{');
if (jsonStart === -1) return raw;
const parsed = JSON.parse(raw.slice(jsonStart));
const status = parsed.status || '';
const errMsg = parsed.data?.error || parsed.statusText || 'unknown error';
const prefix = raw.slice(0, jsonStart).trim();
return `${prefix}: ${status}${errMsg}`;
} catch {
return raw;
}
}
function ts() {
return new Date().toISOString().replace('T', ' ').substring(0, 19);
}
@@ -75,25 +55,6 @@ const logger = {
setOutput(fn) {
outputFn = fn;
},
/**
* Override console.error and console.log globally so that the CLOB client's
* internal axios error dumps are sanitized (no auth headers / full config).
* Call this once at startup, before any CLOB requests.
*/
interceptConsole() {
const handle = (originalFn, logFn) => (...args) => {
const raw = args.map((a) => (a && typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ');
const sanitized = sanitizeClobMessage(raw);
if (sanitized !== raw || raw.includes('[CLOB Client]')) {
logFn(sanitized);
} else {
originalFn(...args);
}
};
console.error = handle(console.error.bind(console), logger.error);
console.warn = handle(console.warn.bind(console), logger.warn);
},
};
export default logger;
-58
View File
@@ -1,58 +0,0 @@
/**
* proxy-patch.cjs
*
* Patches the Node.js https module to route Polymarket traffic through a proxy.
* This needs to be the VERY FIRST import in the application.
*/
const PROXY_URL = process.env.PROXY_URL || '';
if (PROXY_URL) {
const https = require('https');
const { HttpsProxyAgent } = require('https-proxy-agent');
const agent = new HttpsProxyAgent(PROXY_URL);
const POLY_DOMAINS = [
'polymarket.com',
'clob.polymarket.com',
'gamma-api.polymarket.com',
'data-api.polymarket.com',
];
const shouldProxy = (hostname) => {
if (!hostname) return false;
return POLY_DOMAINS.some(d => hostname === d || hostname.endsWith('.' + d));
};
const originalRequest = https.request;
https.request = function(...args) {
let url;
if (typeof args[0] === 'string') {
url = args[0];
} else if (args[0] && args[0].hostname) {
url = args[0].hostname;
}
if (url && shouldProxy(url)) {
if (typeof args[0] === 'object') {
args[0].agent = agent;
} else if (typeof args[0] === 'string') {
// Axios might pass string, convert to options object
const parsed = new URL(args[0]);
args[0] = {
protocol: parsed.protocol,
hostname: parsed.hostname,
port: parsed.port,
path: parsed.pathname + parsed.search,
agent: agent,
};
}
}
return originalRequest.apply(this, args);
};
console.log('[proxy-patch] HTTPS patched for Polymarket');
}
-119
View File
@@ -1,119 +0,0 @@
/**
* proxy.js
* Proxy support for Polymarket API calls only.
*
* - CLOB API: uses axios internally (via @polymarket/clob-client) →
* we set axios.defaults.httpAgent/httpsAgent via https-proxy-agent.
* - Gamma / Data API: uses native fetch (undici) →
* we use undici.ProxyAgent with the `dispatcher` option.
* - Polygon RPC: NOT proxied (separate ethers.js provider).
*
* Set PROXY_URL in .env to enable. Supports HTTP/HTTPS proxies.
* Example: PROXY_URL=http://user:pass@proxy.example.com:8080
*/
import config from '../config/index.js';
import logger from './logger.js';
let axiosAgent = null; // https-proxy-agent for axios (CLOB client)
let fetchDispatcher = null; // undici ProxyAgent for native fetch
/**
* Set up axios defaults so that the @polymarket/clob-client's
* internal axios calls go through the proxy.
* Call this BEFORE creating ClobClient.
*/
export async function setupAxiosProxy() {
if (!config.proxyUrl) {
logger.info('No PROXY_URL set — Polymarket API calls will be direct');
return;
}
try {
// 1. Setup axios proxy (for CLOB client)
const { HttpsProxyAgent } = await import('https-proxy-agent');
axiosAgent = new HttpsProxyAgent(config.proxyUrl);
const axiosModule = await import('axios');
const axios = axiosModule.default || axiosModule;
axios.defaults.proxy = false;
axios.defaults.httpAgent = axiosAgent;
axios.defaults.httpsAgent = axiosAgent;
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)';
}
}