Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c156a68fdc | |||
| 5f4aa04d13 | |||
| 930dffafbd | |||
| 9a89efb071 | |||
| 23bb74127c | |||
| 991fd6caa4 | |||
| 9e78fb1bfe | |||
| c524ed92ef | |||
| 00235dc850 | |||
| f5c777ec0c | |||
| 048d4433c2 | |||
| b770f831db | |||
| 368a3f8da5 | |||
| b2f8447d0b | |||
| c5b169b47e | |||
| 374e943cc2 | |||
| d24c5f2e81 | |||
| 56f59eabdf | |||
| 4af99a78f5 | |||
| 0dc8024ceb | |||
| f31f257da5 | |||
| f4490c12ac | |||
| 5ea7f3d2b0 | |||
| a6f2e148a6 | |||
| 363d74ff2d | |||
| 0148b0bbef | |||
| 365dc4e164 | |||
| 526fc7a057 | |||
| 0650cb9ef7 | |||
| 59295b08cb | |||
| b52892172e | |||
| 097148c501 | |||
| ec2c948a00 | |||
| e82c67824c | |||
| 69d8d1401d | |||
| d3e5567c97 |
+77
-7
@@ -64,6 +64,15 @@ SELL_MODE=market
|
||||
# How often (seconds) to check for resolved markets to redeem
|
||||
REDEEM_INTERVAL=60
|
||||
|
||||
# Skip BUY if the market closes within this many seconds from now
|
||||
# Default 300 = skip if market ends in less than 5 minutes
|
||||
MIN_MARKET_TIME_LEFT=300
|
||||
|
||||
# When FAK finds no liquidity (e.g. copying into "next market" before it opens),
|
||||
# fall back to a GTC limit order and wait this many seconds for it to fill.
|
||||
# Set to 0 to disable the GTC fallback entirely.
|
||||
GTC_FALLBACK_TIMEOUT=60
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# DRY RUN (set true to simulate without real trades)
|
||||
# ─────────────────────────────────────────────
|
||||
@@ -110,17 +119,78 @@ MM_RECOVERY_THRESHOLD=0.70
|
||||
# USDC size for the recovery buy (0 = use MM_TRADE_SIZE)
|
||||
MM_RECOVERY_SIZE=0
|
||||
|
||||
# Enable adaptive CL when one leg fills (true = patient limit orders, false = immediate market sell)
|
||||
MM_ADAPTIVE_CL=true
|
||||
|
||||
# Minimum combined sell price (both legs) required to place a limit order.
|
||||
# Formula: filledLegPrice + unfilledLegPrice >= MM_ADAPTIVE_MIN_COMBINED
|
||||
# Example: filledLeg=0.60, MM_ADAPTIVE_MIN_COMBINED=1.20 → floor=0.60 (won't limit-sell below $0.60)
|
||||
# filledLeg=0.55, MM_ADAPTIVE_MIN_COMBINED=1.20 → floor=0.65
|
||||
# If price is below floor, bot waits for recovery. Market-sell only at CL time as last resort.
|
||||
MM_ADAPTIVE_MIN_COMBINED=1.20
|
||||
|
||||
# Poll interval (seconds) for the adaptive CL loop after one leg fills.
|
||||
# Smaller = more responsive to price changes, more API calls.
|
||||
MM_ADAPTIVE_MONITOR_SEC=5
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# ORDERBOOK SNIPER (sniper.js / npm run sniper-sim)
|
||||
# Places tiny GTC BUY orders at a low price on both sides of
|
||||
# ETH/SOL/XRP 5-minute markets — catches panic dumps near $0.
|
||||
# 3-Tier Strategy: Places GTC BUY orders at 3 prices with weighted sizing
|
||||
# Tier 1 (3c): smallest size | Tier 2 (2c): medium | Tier 3 (1c): largest
|
||||
# Catches panic dumps at multiple price levels with optimal capital allocation
|
||||
# ─────────────────────────────────────────────
|
||||
# Comma-separated assets to snipe
|
||||
SNIPER_ASSETS=eth,sol,xrp
|
||||
|
||||
# Buy price per share (1 cent = $0.01)
|
||||
SNIPER_PRICE=0.01
|
||||
# 3-Tier pricing (high to low)
|
||||
SNIPER_TIER1_PRICE=0.03 # Highest price, smallest allocation (20%)
|
||||
SNIPER_TIER2_PRICE=0.02 # Mid price, medium allocation (30%)
|
||||
SNIPER_TIER3_PRICE=0.01 # Lowest price, largest allocation (50%)
|
||||
|
||||
# Shares per side — minimum Polymarket order size is 5 shares
|
||||
# At $0.01/share: 5 shares = $0.05 per side, $0.10 per market
|
||||
SNIPER_SHARES=5
|
||||
# Max total shares per side (min 5 shares per tier)
|
||||
# Example: 15 shares → 3@3c + 5@2c + 7@1c
|
||||
# Example: 30 shares → 6@3c + 9@2c + 15@1c
|
||||
SNIPER_MAX_SHARES=15
|
||||
|
||||
# ── Sniper Session Schedule (all times UTC+8) ──────────────
|
||||
# Format: HH:MM-HH:MM,HH:MM-HH:MM (comma-separated sessions)
|
||||
# Assets without a schedule entry are always active.
|
||||
SNIPER_SCHEDULE_BTC=19:40-22:40,03:40-06:10
|
||||
SNIPER_SCHEDULE_ETH=11:40-15:40,16:40-19:40
|
||||
SNIPER_SCHEDULE_SOL=09:40-12:40,21:40-23:40
|
||||
SNIPER_SCHEDULE_XRP=18:40-20:40,08:40-09:50
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# MARKET MAKER v2 (maker.js / npm run maker-sim)
|
||||
# Buy Low, Sell High — no splitPosition.
|
||||
# Places limit BUY on UP+DOWN at low price, sells at target when filled.
|
||||
# Only one side will fill — the other gets cancelled.
|
||||
# ─────────────────────────────────────────────
|
||||
# Comma-separated assets
|
||||
MAKER_ASSETS=btc,eth,sol,xrp
|
||||
|
||||
# Market duration: comma-separated, e.g. "5m" or "5m,15m" for both
|
||||
MAKER_DURATION=5m,15m
|
||||
|
||||
# Limit BUY price (e.g. 0.02 = 2 cents per share)
|
||||
MAKER_BUY_PRICE=0.02
|
||||
|
||||
# Limit SELL price (e.g. 0.03 = 3 cents per share)
|
||||
MAKER_SELL_PRICE=0.03
|
||||
|
||||
# Shares per side (e.g. 50 shares × 0.02 = $1.00 cost per side)
|
||||
MAKER_TRADE_SIZE=50
|
||||
|
||||
# How often to poll for new markets (seconds)
|
||||
MAKER_POLL_INTERVAL=10
|
||||
|
||||
# How fast to check order fill status (milliseconds)
|
||||
MAKER_MONITOR_MS=2000
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# PROXY (Polymarket API only, NOT Polygon RPC)
|
||||
# Supports HTTP/HTTPS/SOCKS5 proxies
|
||||
# Leave empty to connect directly (no proxy)
|
||||
# Example: http://user:pass@proxy.example.com:8080
|
||||
# ─────────────────────────────────────────────
|
||||
PROXY_URL=
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
node_modules/
|
||||
.env
|
||||
data/*.json
|
||||
data/*.jsonl
|
||||
.DS_Store
|
||||
logs/*.log
|
||||
|
||||
@@ -11,7 +11,8 @@ Developer and AI agent reference for understanding, extending, and maintaining t
|
||||
| Bot | Entry Point | Command | Purpose |
|
||||
|---|---|---|---|
|
||||
| Copy Trade | `src/index.js` | `npm start` | Mirror trades from a target trader wallet |
|
||||
| Market Maker | `src/mm.js` | `npm run mm` | Provide liquidity on 5m/15m binary markets |
|
||||
| Market Maker v1 | `src/mm.js` | `npm run mm` | Provide liquidity via splitPosition on 5m/15m markets |
|
||||
| Market Maker v2 | `src/maker.js` | `npm run maker` | Buy low, sell high — pure orderbook maker (no splitPosition) |
|
||||
| Orderbook Sniper | `src/sniper.js` | `npm run sniper` | Place low-price GTC orders to catch panic dumps |
|
||||
|
||||
---
|
||||
@@ -91,9 +92,10 @@ All files use **ES Modules** (`import`/`export`). Do not use `require()`.
|
||||
|
||||
- Loads all settings from `.env` via `dotenv`
|
||||
- Parses and validates required fields per bot type
|
||||
- Exports two validator functions:
|
||||
- Exports three validator functions:
|
||||
- `validateCopyTradeConfig()` — for `src/index.js`
|
||||
- `validateMMConfig()` — for `src/mm.js` and `src/sniper.js`
|
||||
- `validateMakerConfig()` — for `src/maker.js`
|
||||
- Key exported fields: `privateKey`, `proxyWallet`, `traderAddress`, `dryRun`, `mmTradeSize`, etc.
|
||||
|
||||
### Client (`src/services/client.js`)
|
||||
@@ -171,6 +173,23 @@ Used exclusively by the Market Maker bot. Interacts with the Gnosis Safe proxy w
|
||||
- `cutLoss(...)` — Cancels remaining orders; merges back or market-sells residual tokens
|
||||
- `attemptRecoveryBuy(...)` — Optional directional bet post cut-loss (controlled by `MM_RECOVERY_BUY`)
|
||||
|
||||
### Maker Detector (`src/services/makerDetector.js`)
|
||||
|
||||
- Slot-based detection for the Maker v2 bot (buy low, sell high)
|
||||
- Supports **multiple durations** simultaneously (e.g. `MAKER_DURATION=5m,15m`)
|
||||
- Polls all `asset × duration` combinations in parallel
|
||||
- Each detected market includes a `duration` field for concurrent queue management
|
||||
- Same slug format as other bots: `{asset}-updown-{duration}-{timestamp}`
|
||||
|
||||
### Maker Executor (`src/services/makerExecutor.js`)
|
||||
|
||||
- `executeMakerStrategy(market)` — Full flow: place limit BUY on UP+DOWN → monitor fills → place limit SELL → monitor sells
|
||||
- **CL at 10s before close:** Cancels unfilled buy orders, places sells for any filled positions (retry 3x for settlement delays)
|
||||
- **Sell phase:** Sell orders stay live until market close (no early cancellation)
|
||||
- **Multi-market concurrent:** Each position keyed by `conditionId`, queue keyed by `${asset}-${duration}`
|
||||
- Simulation mode with full P&L tracking (win/loss/skip stats)
|
||||
- `placeLimitSellWithRetry()` — Retries sell placement 3x with increasing delay for on-chain settlement
|
||||
|
||||
### Sniper Detector (`src/services/sniperDetector.js`)
|
||||
|
||||
- Scans for 5-minute markets for a configurable list of assets
|
||||
|
||||
@@ -13,37 +13,49 @@
|
||||
- [Installation](#installation)
|
||||
- [Configuration](#configuration)
|
||||
- [Usage](#usage)
|
||||
- [Running on VPS with PM2](#running-on-vps-with-pm2)
|
||||
- [How It Works](#how-it-works)
|
||||
- [Project Structure](#project-structure)
|
||||
- [Important Warnings](#important-warnings)
|
||||
- [Contributing](#contributing)
|
||||
- [License](#license)
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Copy Trade Bot (`npm start`)
|
||||
### Copy Trade Bot
|
||||
- **Watch Trader** — Monitor any Polymarket wallet address in real time via WebSocket
|
||||
- **Copy Buy** — Automatically mirror buy orders with configurable position sizing
|
||||
- **Copy Sell** — Automatically mirror sell orders (market or limit)
|
||||
- **Auto Sell** — Place a GTC limit sell at a target profit % immediately after a buy fills
|
||||
- **Auto Redeem** — Periodically check and redeem winning positions on-chain
|
||||
- **Deduplication** — Each market is entered at most once; no double buys
|
||||
- **Balance Guard** — Checks USDC.e balance before every order
|
||||
- **Market Expiry Guard** — Skip buys if market closes within `MIN_MARKET_TIME_LEFT` seconds
|
||||
- **GTC Fallback** — Falls back to a GTC limit order when copying "next market" trades with no liquidity
|
||||
- **Per-Market Queue** — Concurrent events for the same market are serialized to prevent duplicate buys
|
||||
- **Dry Run Mode** — Simulate the full flow without placing real orders
|
||||
|
||||
### Market Maker Bot (`npm run mm`)
|
||||
### Market Maker v1 Bot (Split Position)
|
||||
- **Automated Liquidity** — Splits USDC into YES+NO tokens and places limit sells on both sides at $0.50 entry
|
||||
- **Cut-Loss Protection** — Merges unsold tokens back to USDC before market close
|
||||
- **Recovery Buy** — Optional directional bet after a cut-loss triggers
|
||||
- **Multi-Asset** — Supports BTC, ETH, SOL, and any 5m/15m Polymarket market
|
||||
- **Simulation Mode** — Full dry-run with P&L tracking (`npm run mm-sim`)
|
||||
- **Simulation Mode** — Full dry-run with P&L tracking
|
||||
|
||||
### Orderbook Sniper Bot (`npm run sniper`)
|
||||
- **Low-Price Orders** — Places tiny GTC BUY orders at a configurable price (e.g. $0.01) on both sides
|
||||
### Market Maker v2 Bot (Buy Low, Sell High)
|
||||
- **Pure Orderbook** — Places limit BUY on both UP+DOWN at low price (e.g. 2c), sells at higher price (e.g. 3c) when filled. No splitPosition needed
|
||||
- **Cut-Loss at 10s** — Cancels unfilled buy orders 10 seconds before market close; sells positions as much as possible
|
||||
- **Sell Retry 3x** — Retries sell placement with delay for on-chain token settlement
|
||||
- **Multi-Asset** — Supports BTC, ETH, SOL, XRP simultaneously
|
||||
- **Multi-Duration** — Run 5m and 15m markets concurrently (`MAKER_DURATION=5m,15m`)
|
||||
- **Concurrent Markets** — Each asset+duration combination runs independently (e.g. BTC/5m and BTC/15m don't block each other)
|
||||
- **Simulation Mode** — Full dry-run with win/loss/skip tracking and P&L stats
|
||||
- **Proxy Support** — All API calls go through `PROXY_URL` if configured
|
||||
|
||||
### Orderbook Sniper Bot
|
||||
- **3-Tier Strategy** — Places GTC BUY orders at 3c, 2c, and 1c with weighted sizing (20%/30%/50%)
|
||||
- **Multi-Asset** — Targets ETH, SOL, XRP, and more simultaneously
|
||||
- **Simulation Mode** — Preview orders without spending funds (`npm run sniper-sim`)
|
||||
- **Simulation Mode** — Preview orders without spending funds
|
||||
- **Session Scheduling** — Per-asset time windows (UTC+8) for selective trading
|
||||
|
||||
---
|
||||
|
||||
@@ -56,6 +68,7 @@
|
||||
| Polymarket Proxy Wallet | Your proxy wallet address (visible on your Polymarket profile → Deposit) |
|
||||
| USDC.e on Polygon | Deposited via Polymarket's deposit flow |
|
||||
| MATIC on Polygon | A small amount for gas fees (redeem & on-chain operations) |
|
||||
| PM2 *(optional)* | For running on a VPS: `npm install -g pm2` |
|
||||
|
||||
---
|
||||
|
||||
@@ -72,8 +85,8 @@ npm install
|
||||
# 3. Copy the environment template
|
||||
cp .env.example .env
|
||||
|
||||
# 4. Fill in your credentials (see Configuration section below)
|
||||
nano .env # or use your preferred editor
|
||||
# 4. Fill in your credentials
|
||||
nano .env
|
||||
```
|
||||
|
||||
---
|
||||
@@ -115,6 +128,8 @@ Leave these blank to have the client auto-derive credentials from your private k
|
||||
| `AUTO_SELL_PROFIT_PERCENT` | Target profit % for the auto-sell limit order | `10` |
|
||||
| `SELL_MODE` | `market` or `limit` when copying a sell | `market` |
|
||||
| `REDEEM_INTERVAL` | Seconds between redemption checks | `60` |
|
||||
| `MIN_MARKET_TIME_LEFT` | Skip buy if market closes within this many seconds | `300` |
|
||||
| `GTC_FALLBACK_TIMEOUT` | Seconds to wait for GTC fill when FAK finds no liquidity | `60` |
|
||||
| `DRY_RUN` | Simulate without placing real orders | `true` |
|
||||
|
||||
### Market Maker Bot Settings
|
||||
@@ -133,35 +148,151 @@ Leave these blank to have the client auto-derive credentials from your private k
|
||||
| `MM_RECOVERY_THRESHOLD` | Minimum dominant-side price to qualify for recovery | `0.70` |
|
||||
| `MM_RECOVERY_SIZE` | USDC for recovery buy (0 = use `MM_TRADE_SIZE`) | `0` |
|
||||
|
||||
### Market Maker v2 (Maker) Settings
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
| `MAKER_ASSETS` | Comma-separated assets (e.g. `btc,eth,sol,xrp`) | `btc` |
|
||||
| `MAKER_DURATION` | Comma-separated durations (e.g. `5m` or `5m,15m`) | `5m` |
|
||||
| `MAKER_BUY_PRICE` | Limit BUY price per share (e.g. `0.02` = 2c) | `0.02` |
|
||||
| `MAKER_SELL_PRICE` | Limit SELL price per share (e.g. `0.03` = 3c) | `0.03` |
|
||||
| `MAKER_TRADE_SIZE` | Shares per side (e.g. `50` × $0.02 = $1.00/side) | `50` |
|
||||
| `MAKER_POLL_INTERVAL` | Seconds between new market polls | `10` |
|
||||
| `MAKER_MONITOR_MS` | Milliseconds between order fill checks | `2000` |
|
||||
|
||||
**Cut-Loss:** At 10 seconds before market close, unfilled buy orders are cancelled. Any filled positions get sell orders placed (retry 3x). Sell orders stay live until market close.
|
||||
|
||||
### Orderbook Sniper Settings
|
||||
|
||||
**3-Tier Strategy:** Places orders at 3 price levels with weighted sizing
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
| `SNIPER_ASSETS` | Comma-separated assets to snipe (e.g. `eth,sol,xrp`) | `eth,sol,xrp` |
|
||||
| `SNIPER_PRICE` | Buy price per share (e.g. `0.01` = $0.01) | `0.01` |
|
||||
| `SNIPER_SHARES` | Shares per side (minimum 5 per Polymarket rules) | `5` |
|
||||
| `SNIPER_TIER1_PRICE` | Highest price tier (e.g. `0.03` = 3c) | `0.03` |
|
||||
| `SNIPER_TIER2_PRICE` | Mid price tier (e.g. `0.02` = 2c) | `0.02` |
|
||||
| `SNIPER_TIER3_PRICE` | Lowest price tier (e.g. `0.01` = 1c) | `0.01` |
|
||||
| `SNIPER_MAX_SHARES` | Max total shares per side (min 5 per tier) | `15` |
|
||||
|
||||
**Allocation:**
|
||||
- Tier 1 (3c): 20% of max shares (min 5)
|
||||
- Tier 2 (2c): 30% of max shares (min 5)
|
||||
- Tier 3 (1c): 50% of max shares (min 5)
|
||||
|
||||
**Example with `SNIPER_MAX_SHARES=15`:**
|
||||
- 3 shares @ 3c = $0.09
|
||||
- 5 shares @ 2c = $0.10
|
||||
- 7 shares @ 1c = $0.07
|
||||
- **Total per side:** 15 shares = $0.26
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
### Terminal UI (local)
|
||||
|
||||
Runs with an interactive split-panel dashboard (blessed TUI).
|
||||
|
||||
```bash
|
||||
# ── Copy Trade Bot ─────────────────────────────────
|
||||
npm start # Production mode
|
||||
npm run dev # Development mode (auto-reload on file changes)
|
||||
# Copy Trade Bot
|
||||
npm start # live trading
|
||||
npm run dev # live + auto-reload on file changes
|
||||
|
||||
# ── Market Maker Bot ───────────────────────────────
|
||||
npm run mm # Live trading (DRY_RUN=false)
|
||||
npm run mm-sim # Simulation mode (DRY_RUN=true)
|
||||
npm run mm-dev # Simulation + auto-reload
|
||||
# Market Maker Bot
|
||||
npm run mm # live trading
|
||||
npm run mm-sim # simulation (DRY_RUN=true)
|
||||
npm run mm-dev # simulation + auto-reload
|
||||
|
||||
# ── Orderbook Sniper Bot ───────────────────────────
|
||||
npm run sniper # Live trading (DRY_RUN=false)
|
||||
npm run sniper-sim # Simulation mode (DRY_RUN=true)
|
||||
npm run sniper-dev # Simulation + auto-reload
|
||||
# Market Maker v2 (Maker) Bot
|
||||
npm run maker # live trading
|
||||
npm run maker-sim # simulation
|
||||
npm run maker-dev # simulation + auto-reload
|
||||
|
||||
# Orderbook Sniper Bot
|
||||
npm run sniper # live trading
|
||||
npm run sniper-sim # simulation
|
||||
npm run sniper-dev # simulation + auto-reload
|
||||
```
|
||||
|
||||
> **Always test with `DRY_RUN=true` first** before committing real funds.
|
||||
### Plain Log Mode (no TUI)
|
||||
|
||||
Writes plain timestamped text to stdout — suitable for piping, `tail -f`, or PM2.
|
||||
|
||||
```bash
|
||||
# Copy Trade Bot
|
||||
npm run bot # live trading
|
||||
npm run bot-sim # simulation
|
||||
npm run bot-dev # simulation + auto-reload
|
||||
|
||||
# Market Maker v1 Bot
|
||||
npm run mm-bot # live trading
|
||||
npm run mm-bot-sim # simulation
|
||||
npm run mm-bot-dev # simulation + auto-reload
|
||||
|
||||
# Market Maker v2 (Maker) Bot
|
||||
npm run maker-bot # live trading
|
||||
npm run maker-bot-sim # simulation
|
||||
npm run maker-bot-dev # simulation + auto-reload
|
||||
```
|
||||
|
||||
> **Always test with `DRY_RUN=true` (or `*-sim` scripts) first** before committing real funds.
|
||||
|
||||
---
|
||||
|
||||
## Running on VPS with PM2
|
||||
|
||||
Each bot has its own PM2 config file inside the `pm2/` folder.
|
||||
|
||||
### Install PM2
|
||||
|
||||
```bash
|
||||
npm install -g pm2
|
||||
```
|
||||
|
||||
### Copy Trade Bot
|
||||
|
||||
```bash
|
||||
# Live trading
|
||||
pm2 start pm2/copy.config.cjs
|
||||
|
||||
# Simulation
|
||||
pm2 start pm2/copy.config.cjs --env sim
|
||||
|
||||
# View logs
|
||||
pm2 logs polymarket-copy
|
||||
tail -f logs/copy-out.log
|
||||
|
||||
# Management
|
||||
pm2 restart polymarket-copy
|
||||
pm2 stop polymarket-copy
|
||||
pm2 delete polymarket-copy
|
||||
```
|
||||
|
||||
### Market Maker Bot
|
||||
|
||||
```bash
|
||||
# Live trading
|
||||
pm2 start pm2/mm.config.cjs
|
||||
|
||||
# Simulation
|
||||
pm2 start pm2/mm.config.cjs --env sim
|
||||
|
||||
# View logs
|
||||
pm2 logs polymarket-mm
|
||||
tail -f logs/mm-out.log
|
||||
|
||||
# Management
|
||||
pm2 restart polymarket-mm
|
||||
pm2 stop polymarket-mm
|
||||
pm2 delete polymarket-mm
|
||||
```
|
||||
|
||||
### Auto-start on reboot
|
||||
|
||||
```bash
|
||||
pm2 startup # generates a startup command — run the command it prints
|
||||
pm2 save # saves current process list
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -170,27 +301,28 @@ npm run sniper-dev # Simulation + auto-reload
|
||||
### Copy Trade Bot Flow
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ WATCHER LOOP │
|
||||
│ WebSocket (RTDS) — real-time trade events from trader │
|
||||
│ Fallback: poll Data API every N seconds │
|
||||
├───────────────────────┬──────────────────────────────────┤
|
||||
│ NEW BUY │ NEW SELL │
|
||||
│ │ │
|
||||
│ ✓ Check position │ ✓ Check position exists │
|
||||
│ ✓ Check USDC balance │ ✓ Cancel existing auto-sell │
|
||||
│ ✓ Market buy (FOK) │ ✓ Market / limit sell │
|
||||
│ ✓ Retry on failure │ ✓ Retry on failure │
|
||||
│ ✓ Place auto-sell │ ✓ Remove position from state │
|
||||
│ ✓ Save position │ │
|
||||
├───────────────────────┴──────────────────────────────────┤
|
||||
│ REDEEMER LOOP │
|
||||
│ Periodically checks resolved markets │
|
||||
│ → Redeems winning positions via CTF contract on-chain │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
WebSocket (RTDS) — real-time trade events from trader
|
||||
│
|
||||
▼
|
||||
Per-market queue (prevents concurrent duplicate buys)
|
||||
│
|
||||
┌────┴──────┐
|
||||
│ │
|
||||
BUY SELL
|
||||
│ │
|
||||
├─ Expiry guard (MIN_MARKET_TIME_LEFT)
|
||||
├─ Max position cap ├─ Cancel open orders
|
||||
├─ FAK market buy ├─ Reconcile on-chain balance
|
||||
│ └─ 0 fill? → GTC fallback ├─ FAK market sell / limit sell
|
||||
├─ Place auto-sell GTC └─ Remove position
|
||||
└─ Save position
|
||||
│
|
||||
▼
|
||||
Redeemer loop (every REDEEM_INTERVAL seconds)
|
||||
→ Check on-chain payout → redeemPositions via Gnosis Safe
|
||||
```
|
||||
|
||||
### Market Maker Flow
|
||||
### Market Maker v1 Flow (Split Position)
|
||||
|
||||
```
|
||||
New Market Detected
|
||||
@@ -206,13 +338,44 @@ Monitor fills every few seconds
|
||||
│
|
||||
┌────┴────┐
|
||||
│ │
|
||||
Fill Time < MM_CUT_LOSS_TIME
|
||||
Fill Time < MM_CUT_LOSS_TIME
|
||||
│ │
|
||||
▼ ▼
|
||||
Collect Cancel orders → Merge YES+NO back to USDC
|
||||
profit (recovery buy optional)
|
||||
```
|
||||
|
||||
### Market Maker v2 Flow (Buy Low, Sell High)
|
||||
|
||||
```
|
||||
Detector polls all asset × duration combos (e.g. BTC/5m, ETH/15m)
|
||||
│
|
||||
▼
|
||||
Place limit BUY on UP + DOWN @ MAKER_BUY_PRICE (e.g. $0.02)
|
||||
│
|
||||
▼
|
||||
Monitor both sides concurrently
|
||||
│
|
||||
┌────┴─────────────────┐
|
||||
│ │
|
||||
One side fills CL at 10s before close
|
||||
│ │
|
||||
▼ ▼
|
||||
Cancel other buy Cancel unfilled buys
|
||||
Place SELL @ 3c Place SELL for any fills (retry 3x)
|
||||
│ │
|
||||
└──────┬───────────────┘
|
||||
▼
|
||||
Monitor sells until market close
|
||||
│
|
||||
┌────┴────┐
|
||||
│ │
|
||||
Sell fills Market closes
|
||||
│ │
|
||||
▼ ▼
|
||||
WIN $ Tokens resolve on-chain
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
@@ -220,9 +383,13 @@ Collect Cancel orders → Merge YES+NO back to USDC
|
||||
```
|
||||
polymarket-terminal/
|
||||
├── src/
|
||||
│ ├── index.js — Copy trade bot entry point
|
||||
│ ├── mm.js — Market maker bot entry point
|
||||
│ ├── sniper.js — Orderbook sniper bot entry point
|
||||
│ ├── index.js — Copy trade bot (TUI)
|
||||
│ ├── bot.js — Copy trade bot (plain log / PM2)
|
||||
│ ├── mm.js — Market maker v1 bot (TUI)
|
||||
│ ├── mm-bot.js — Market maker v1 bot (plain log / PM2)
|
||||
│ ├── maker.js — Market maker v2 bot (TUI)
|
||||
│ ├── maker-bot.js — Market maker v2 bot (plain log / PM2)
|
||||
│ ├── sniper.js — Orderbook sniper bot
|
||||
│ │
|
||||
│ ├── config/
|
||||
│ │ └── index.js — Environment variable loading & validation
|
||||
@@ -235,9 +402,12 @@ polymarket-terminal/
|
||||
│ │ ├── position.js — Position state management (CRUD)
|
||||
│ │ ├── autoSell.js — Auto limit-sell placement
|
||||
│ │ ├── redeemer.js — Market resolution check & CTF redemption
|
||||
│ │ ├── ctf.js — On-chain CTF contract interactions (MM bot)
|
||||
│ │ ├── mmDetector.js — Market detection for market maker
|
||||
│ │ ├── mmExecutor.js — Market maker strategy execution
|
||||
│ │ ├── ctf.js — On-chain CTF contract interactions
|
||||
│ │ ├── mmDetector.js — Market detection for MM v1
|
||||
│ │ ├── mmExecutor.js — MM v1 strategy execution
|
||||
│ │ ├── makerDetector.js — Market detection for Maker v2 (multi-duration)
|
||||
│ │ ├── makerExecutor.js — Maker v2 strategy execution
|
||||
│ │ ├── makerWs.js — Orderbook WebSocket for Maker v2
|
||||
│ │ ├── sniperDetector.js — Market detection for sniper
|
||||
│ │ └── sniperExecutor.js — Orderbook sniper order placement
|
||||
│ │
|
||||
@@ -245,11 +415,16 @@ polymarket-terminal/
|
||||
│ │ └── dashboard.js — Terminal UI (blessed)
|
||||
│ │
|
||||
│ └── utils/
|
||||
│ ├── logger.js — Color-coded, timestamped logging
|
||||
│ ├── logger.js — Timestamped logging (TUI + plain modes)
|
||||
│ ├── state.js — Atomic JSON state file management
|
||||
│ └── simStats.js — Simulation P&L statistics
|
||||
│
|
||||
├── pm2/
|
||||
│ ├── copy.config.cjs — PM2 config for copy trade bot
|
||||
│ └── mm.config.cjs — PM2 config for market maker bot
|
||||
│
|
||||
├── data/ — Runtime state files (gitignored)
|
||||
├── logs/ — PM2 log files (gitignored)
|
||||
├── .env.example — Configuration template
|
||||
├── .gitignore
|
||||
└── package.json
|
||||
@@ -260,26 +435,13 @@ polymarket-terminal/
|
||||
## Important Warnings
|
||||
|
||||
- **Never commit your `.env` file.** Your private key must remain secret. The `.gitignore` already excludes it.
|
||||
- **Always start with `DRY_RUN=true`** to verify the bot behaves as expected before using real funds.
|
||||
- **Always start with `DRY_RUN=true`** (or a `*-sim` script) to verify the bot behaves as expected before using real funds.
|
||||
- **Use a small `SIZE_PERCENT`** for initial live runs to limit exposure.
|
||||
- **Keep MATIC in your EOA wallet** for gas fees (redeem operations and on-chain CTF calls).
|
||||
- **This software is provided as-is, with no guarantees.** Prediction market trading carries significant financial risk. You are solely responsible for any losses.
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! To get started:
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch: `git checkout -b feat/your-feature`
|
||||
3. Make your changes and ensure the code is clean and well-documented
|
||||
4. Open a pull request describing what you changed and why
|
||||
|
||||
Please keep pull requests focused and avoid mixing unrelated changes.
|
||||
|
||||
---
|
||||
|
||||
## Credits
|
||||
|
||||
Built and maintained by **[@direkturcrypto](https://twitter.com/direkturcrypto)**.
|
||||
|
||||
Generated
+176
-5
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "polymarket-copy",
|
||||
"name": "polymarket-terminal",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "polymarket-copy",
|
||||
"name": "polymarket-terminal",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
@@ -13,6 +13,9 @@
|
||||
"blessed": "^0.1.81",
|
||||
"dotenv": "^16.4.7",
|
||||
"ethers": "^5.8.0",
|
||||
"global-agent": "^4.1.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"undici": "^7.22.0",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -913,6 +916,15 @@
|
||||
"integrity": "sha512-H7wUZRn8WpTq9jocdxQ2c8x2sKo9ZVmzfRE13GiNJXfp7NcKYEdvl3vspKjXox6RIG2VtaRe4JFvxG4rqp2Zuw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/anymatch": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||
@@ -1083,7 +1095,6 @@
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
@@ -1097,6 +1108,40 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/define-data-property": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
||||
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.0",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/define-properties": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
|
||||
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"define-data-property": "^1.0.1",
|
||||
"has-property-descriptors": "^1.0.0",
|
||||
"object-keys": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
@@ -1198,6 +1243,18 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/ethereum-cryptography": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ethereum-cryptography/-/ethereum-cryptography-2.2.1.tgz",
|
||||
@@ -1395,6 +1452,37 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/global-agent": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/global-agent/-/global-agent-4.1.2.tgz",
|
||||
"integrity": "sha512-CvUwIRwmDHu33Y71P8v1Pr8yo5/U2inzrdRNEtPdiYKBd8J4oQ6aDkt9WqZhMQDtAcTAl+akDTHssRYaBTTlhQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"globalthis": "^1.0.2",
|
||||
"matcher": "^4.0.0",
|
||||
"semver": "^7.3.5",
|
||||
"serialize-error": "^8.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/globalthis": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
|
||||
"integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"define-properties": "^1.2.1",
|
||||
"gopd": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
@@ -1417,6 +1505,18 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/has-property-descriptors": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
|
||||
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
@@ -1477,6 +1577,19 @@
|
||||
"minimalistic-crypto-utils": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore-by-default": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
|
||||
@@ -1552,6 +1665,21 @@
|
||||
"integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/matcher": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/matcher/-/matcher-4.0.0.tgz",
|
||||
"integrity": "sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"escape-string-regexp": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -1620,7 +1748,6 @@
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemon": {
|
||||
@@ -1662,6 +1789,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-keys": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
|
||||
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
@@ -1711,7 +1847,6 @@
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
@@ -1720,6 +1855,21 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/serialize-error": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-8.1.0.tgz",
|
||||
"integrity": "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"type-fest": "^0.20.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-update-notifier": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
|
||||
@@ -1800,6 +1950,18 @@
|
||||
"integrity": "sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/type-fest": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
|
||||
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/undefsafe": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
||||
@@ -1807,6 +1969,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.22.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz",
|
||||
"integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "5.26.5",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||
|
||||
+20
-2
@@ -7,12 +7,27 @@
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "nodemon --ignore 'data/*.json' src/index.js",
|
||||
"bot": "node src/bot.js",
|
||||
"bot-sim": "DRY_RUN=true node src/bot.js",
|
||||
"bot-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/bot.js",
|
||||
"mm": "DRY_RUN=false node src/mm.js",
|
||||
"mm-sim": "DRY_RUN=true node src/mm.js",
|
||||
"mm-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/mm.js",
|
||||
"mm-bot": "node src/mm-bot.js",
|
||||
"mm-bot-sim": "DRY_RUN=true node src/mm-bot.js",
|
||||
"mm-bot-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/mm-bot.js",
|
||||
"sniper": "DRY_RUN=false node src/sniper.js",
|
||||
"sniper-sim": "DRY_RUN=true node src/sniper.js",
|
||||
"sniper-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/sniper.js"
|
||||
"sniper-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/sniper.js",
|
||||
"sniper-tui": "DRY_RUN=false node src/sniper-tui.js",
|
||||
"sniper-tui-sim": "DRY_RUN=true node src/sniper-tui.js",
|
||||
"sniper-tui-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/sniper-tui.js",
|
||||
"maker": "DRY_RUN=false node src/maker.js",
|
||||
"maker-sim": "DRY_RUN=true node src/maker.js",
|
||||
"maker-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/maker.js",
|
||||
"maker-bot": "node src/maker-bot.js",
|
||||
"maker-bot-sim": "DRY_RUN=true node src/maker-bot.js",
|
||||
"maker-bot-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/maker-bot.js"
|
||||
},
|
||||
"keywords": [
|
||||
"polymarket",
|
||||
@@ -26,9 +41,12 @@
|
||||
"blessed": "^0.1.81",
|
||||
"dotenv": "^16.4.7",
|
||||
"ethers": "^5.8.0",
|
||||
"global-agent": "^4.1.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"undici": "^7.22.0",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.9"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* PM2 config — Copy Trade Bot
|
||||
*
|
||||
* Usage (from project root):
|
||||
* pm2 start pm2/copy.config.cjs # live trading
|
||||
* pm2 start pm2/copy.config.cjs --env sim # simulation / dry-run
|
||||
*
|
||||
* pm2 logs polymarket-copy
|
||||
* pm2 restart polymarket-copy
|
||||
* pm2 stop polymarket-copy
|
||||
* pm2 delete polymarket-copy
|
||||
*/
|
||||
const path = require('path');
|
||||
const root = path.join(__dirname, '..');
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'polymarket-copy',
|
||||
script: path.join(root, 'src/bot.js'),
|
||||
interpreter: 'node',
|
||||
|
||||
// Live trading (default)
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
DRY_RUN: 'false',
|
||||
},
|
||||
|
||||
// Simulation: pm2 start pm2/copy.config.cjs --env sim
|
||||
env_sim: {
|
||||
NODE_ENV: 'production',
|
||||
DRY_RUN: 'true',
|
||||
},
|
||||
|
||||
out_file: path.join(root, 'logs/copy-out.log'),
|
||||
error_file: path.join(root, 'logs/copy-error.log'),
|
||||
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
|
||||
merge_logs: true,
|
||||
|
||||
restart_delay: 5000,
|
||||
max_restarts: 10,
|
||||
min_uptime: '10s',
|
||||
max_memory_restart: '256M',
|
||||
stop_exit_codes: [0],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* PM2 config — Market Maker Bot
|
||||
*
|
||||
* Usage (from project root):
|
||||
* pm2 start pm2/mm.config.cjs # live trading
|
||||
* pm2 start pm2/mm.config.cjs --env sim # simulation / dry-run
|
||||
*
|
||||
* pm2 logs polymarket-mm
|
||||
* pm2 restart polymarket-mm
|
||||
* pm2 stop polymarket-mm
|
||||
* pm2 delete polymarket-mm
|
||||
*/
|
||||
const path = require('path');
|
||||
const root = path.join(__dirname, '..');
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'polymarket-mm',
|
||||
script: path.join(root, 'src/mm-bot.js'),
|
||||
interpreter: 'node',
|
||||
|
||||
// Live trading (default)
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
DRY_RUN: 'false',
|
||||
},
|
||||
|
||||
// Simulation: pm2 start pm2/mm.config.cjs --env sim
|
||||
env_sim: {
|
||||
NODE_ENV: 'production',
|
||||
DRY_RUN: 'true',
|
||||
},
|
||||
|
||||
out_file: path.join(root, 'logs/mm-out.log'),
|
||||
error_file: path.join(root, 'logs/mm-error.log'),
|
||||
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
|
||||
merge_logs: true,
|
||||
|
||||
restart_delay: 5000,
|
||||
max_restarts: 10,
|
||||
min_uptime: '10s',
|
||||
max_memory_restart: '256M',
|
||||
stop_exit_codes: [0],
|
||||
},
|
||||
],
|
||||
};
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* bot.js — PM2 / VPS entry point (no TUI)
|
||||
*
|
||||
* Plain-text stdout output, compatible with:
|
||||
* pm2 start ecosystem.config.cjs
|
||||
* pm2 logs polymarket-copy
|
||||
*/
|
||||
import config, { validateConfig } from './config/index.js';
|
||||
import { initClient, getUsdcBalance, getClient } from './services/client.js';
|
||||
import { executeBuy, executeSell } from './services/executor.js';
|
||||
import { checkAndRedeemPositions } from './services/redeemer.js';
|
||||
import { getOpenPositions } from './services/position.js';
|
||||
import { startWsWatcher, stopWsWatcher } from './services/wsWatcher.js';
|
||||
import { getSimStats } from './utils/simStats.js';
|
||||
import logger from './utils/logger.js';
|
||||
|
||||
logger.interceptConsole(); // strip auth headers from CLOB axios error dumps
|
||||
|
||||
// ── Handle a trade event from WebSocket ───────────────────────────────────────
|
||||
async function handleTrade(trade) {
|
||||
try {
|
||||
if (trade.type === 'BUY') await executeBuy(trade);
|
||||
if (trade.type === 'SELL') await executeSell(trade);
|
||||
} catch (err) {
|
||||
logger.error(`Error processing trade ${trade.id}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Periodic status log (replaces TUI right panel) ────────────────────────────
|
||||
async function printStatus() {
|
||||
try {
|
||||
const balance = await getUsdcBalance();
|
||||
const positions = getOpenPositions();
|
||||
|
||||
logger.info(`--- Status | Balance: $${balance.toFixed(2)} USDC | Open positions: ${positions.length} ---`);
|
||||
|
||||
for (const pos of positions) {
|
||||
let pnlStr = '';
|
||||
try {
|
||||
const client = getClient();
|
||||
const mp = await client.getMidpoint(pos.tokenId);
|
||||
const mid = parseFloat(mp?.mid ?? mp ?? '0');
|
||||
if (mid > 0) {
|
||||
const pnl = (mid - pos.avgBuyPrice) * pos.shares;
|
||||
const sign = pnl >= 0 ? '+' : '';
|
||||
const pct = pos.totalCost > 0 ? ((pnl / pos.totalCost) * 100).toFixed(1) : '0.0';
|
||||
pnlStr = ` | unrealized ${sign}$${pnl.toFixed(2)} (${sign}${pct}%)`;
|
||||
}
|
||||
} catch { /* price unavailable */ }
|
||||
|
||||
const name = (pos.market || pos.tokenId || '').substring(0, 50);
|
||||
logger.info(
|
||||
` [${pos.outcome || '?'}] ${name}` +
|
||||
` | ${pos.shares.toFixed(4)} sh @ $${pos.avgBuyPrice.toFixed(4)}` +
|
||||
` | spent $${(pos.totalCost || 0).toFixed(2)}${pnlStr}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (config.dryRun) {
|
||||
const s = getSimStats();
|
||||
if (s.totalBuys > 0 || s.totalResolved > 0) {
|
||||
const rate = s.totalResolved > 0
|
||||
? `${((s.wins / s.totalResolved) * 100).toFixed(0)}% win`
|
||||
: 'no resolved yet';
|
||||
logger.info(
|
||||
` [SIM] ${s.totalBuys} buys tracked | ${s.wins}W/${s.losses}L (${rate})` +
|
||||
` | realized P&L: $${(s.closedPnl || 0).toFixed(2)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`Status check error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Redeemer loop ─────────────────────────────────────────────────────────────
|
||||
async function redeemerLoop() {
|
||||
try {
|
||||
await checkAndRedeemPositions();
|
||||
} catch (err) {
|
||||
logger.error('Redeemer loop error:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main ──────────────────────────────────────────────────────────────────────
|
||||
async function main() {
|
||||
try {
|
||||
validateConfig();
|
||||
} catch (err) {
|
||||
logger.error(err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const mode = config.dryRun ? 'SIMULATION' : 'LIVE TRADING';
|
||||
logger.info(`=== Polymarket Copy Trade [${mode}] ===`);
|
||||
logger.info(`Trader : ${config.traderAddress}`);
|
||||
logger.info(`Proxy wallet : ${config.proxyWallet}`);
|
||||
logger.info(`Size mode : ${config.sizeMode} (${config.sizePercent}%)`);
|
||||
logger.info(`Min trade : $${config.minTradeSize}`);
|
||||
logger.info(`Max position : $${config.maxPositionSize} per market`);
|
||||
logger.info(`Auto sell : ${config.autoSellEnabled ? `ON (+${config.autoSellProfitPercent}%)` : 'OFF'}`);
|
||||
logger.info(`Sell mode : ${config.sellMode}`);
|
||||
logger.info(`Min time left: ${config.minMarketTimeLeft}s`);
|
||||
logger.info('==========================================');
|
||||
|
||||
try {
|
||||
await initClient();
|
||||
} catch (err) {
|
||||
logger.error('Failed to initialize CLOB client:', err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const balance = await getUsdcBalance();
|
||||
logger.money(`USDC.e Balance: $${balance.toFixed(2)}`);
|
||||
} catch (err) {
|
||||
logger.warn('Could not fetch balance:', err.message);
|
||||
}
|
||||
|
||||
logger.success(
|
||||
config.dryRun
|
||||
? 'Simulation started — watching trader in real-time...'
|
||||
: 'Bot started — watching trader in real-time...',
|
||||
);
|
||||
|
||||
startWsWatcher(handleTrade);
|
||||
|
||||
await redeemerLoop();
|
||||
const redeemerInterval = setInterval(redeemerLoop, config.redeemInterval);
|
||||
|
||||
// Print status every 60 seconds
|
||||
const statusInterval = setInterval(printStatus, 60_000);
|
||||
|
||||
const shutdown = () => {
|
||||
logger.info('Shutting down...');
|
||||
stopWsWatcher();
|
||||
clearInterval(redeemerInterval);
|
||||
clearInterval(statusInterval);
|
||||
setTimeout(() => process.exit(0), 300);
|
||||
};
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
logger.error('Fatal error:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
+80
-16
@@ -46,31 +46,80 @@ const config = {
|
||||
maxRetries: 5,
|
||||
retryDelay: 3000,
|
||||
|
||||
// Skip buy if market closes within this many seconds (default 5 minutes)
|
||||
minMarketTimeLeft: parseInt(process.env.MIN_MARKET_TIME_LEFT || '300', 10),
|
||||
|
||||
// Seconds to wait for a GTC limit order to fill when FAK finds no liquidity
|
||||
// (happens when copying trades into "next market" before sellers arrive)
|
||||
gtcFallbackTimeout: parseInt(process.env.GTC_FALLBACK_TIMEOUT || '60', 10),
|
||||
|
||||
// ── Market Maker ──────────────────────────────────────────────
|
||||
mmAssets: (process.env.MM_ASSETS || 'btc')
|
||||
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean),
|
||||
mmDuration: process.env.MM_DURATION || '5m', // '5m' or '15m'
|
||||
mmTradeSize: parseFloat(process.env.MM_TRADE_SIZE || '5'), // USDC per side
|
||||
mmSellPrice: parseFloat(process.env.MM_SELL_PRICE || '0.60'), // limit sell target
|
||||
mmCutLossTime: parseInt( process.env.MM_CUT_LOSS_TIME || '60', 10), // seconds before close
|
||||
mmMarketKeyword: process.env.MM_MARKET_KEYWORD || 'Bitcoin Up or Down',
|
||||
mmEntryWindow: parseInt( process.env.MM_ENTRY_WINDOW || '45', 10), // max secs after open
|
||||
mmPollInterval: parseInt( process.env.MM_POLL_INTERVAL || '10', 10) * 1000,
|
||||
mmAssets: (process.env.MM_ASSETS || 'btc')
|
||||
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean),
|
||||
mmDuration: process.env.MM_DURATION || '5m', // '5m' or '15m'
|
||||
mmTradeSize: parseFloat(process.env.MM_TRADE_SIZE || '5'), // USDC per side
|
||||
mmSellPrice: parseFloat(process.env.MM_SELL_PRICE || '0.60'), // limit sell target
|
||||
mmCutLossTime: parseInt(process.env.MM_CUT_LOSS_TIME || '60', 10), // seconds before close
|
||||
mmMarketKeyword: process.env.MM_MARKET_KEYWORD || 'Bitcoin Up or Down',
|
||||
mmEntryWindow: parseInt(process.env.MM_ENTRY_WINDOW || '45', 10), // max secs after open
|
||||
mmPollInterval: parseInt(process.env.MM_POLL_INTERVAL || '10', 10) * 1000,
|
||||
mmAdaptiveCL: process.env.MM_ADAPTIVE_CL !== 'false', // true = adaptive, false = legacy immediate market-sell
|
||||
mmAdaptiveMinCombined: parseFloat(process.env.MM_ADAPTIVE_MIN_COMBINED || '1.20'), // min combined sell (both legs) to qualify for limit
|
||||
mmAdaptiveMonitorSec: parseInt(process.env.MM_ADAPTIVE_MONITOR_SEC || '5', 10),
|
||||
|
||||
// ── Recovery Buy (after cut-loss) ─────────────────────────────
|
||||
// When enabled: after cutting loss, monitor prices for 10s and
|
||||
// market-buy the dominant side if it's above threshold and rising/stable.
|
||||
mmRecoveryBuy: process.env.MM_RECOVERY_BUY === 'true',
|
||||
mmRecoveryBuy: process.env.MM_RECOVERY_BUY === 'true',
|
||||
mmRecoveryThreshold: parseFloat(process.env.MM_RECOVERY_THRESHOLD || '0.70'), // min price to qualify
|
||||
mmRecoverySize: parseFloat(process.env.MM_RECOVERY_SIZE || '0'), // 0 = use mmTradeSize
|
||||
mmRecoverySize: parseFloat(process.env.MM_RECOVERY_SIZE || '0'), // 0 = use mmTradeSize
|
||||
|
||||
// ── Orderbook Sniper ───────────────────────────────────────────
|
||||
// Places tiny GTC limit BUY orders at a very low price on each side
|
||||
// of ETH/SOL/XRP 5-minute markets — catches panic dumps near $0.
|
||||
// 3-tier strategy: places GTC limit BUY orders at 3c, 2c, and 1c
|
||||
// Tier 1 (3c): smallest size | Tier 2 (2c): medium size | Tier 3 (1c): largest size
|
||||
// Min 5 shares per tier, total = SNIPER_MAX_SHARES_PER_SIDE
|
||||
sniperAssets: (process.env.SNIPER_ASSETS || 'eth,sol,xrp')
|
||||
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean),
|
||||
sniperPrice: parseFloat(process.env.SNIPER_PRICE || '0.01'), // $ per share
|
||||
sniperShares: parseFloat(process.env.SNIPER_SHARES || '5'), // shares per side
|
||||
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean),
|
||||
sniperTierPrices: [
|
||||
parseFloat(process.env.SNIPER_TIER1_PRICE || '0.03'), // high price, small size
|
||||
parseFloat(process.env.SNIPER_TIER2_PRICE || '0.02'), // mid price, medium size
|
||||
parseFloat(process.env.SNIPER_TIER3_PRICE || '0.01'), // low price, large size
|
||||
],
|
||||
sniperMaxShares: parseFloat(process.env.SNIPER_MAX_SHARES || '15'), // max total per side
|
||||
sniperMinSharesPerTier: 5, // minimum shares for each tier
|
||||
|
||||
// ── Sniper Schedule (UTC+8) ────────────────────────────────────
|
||||
// Per-asset session windows. Format: SNIPER_SCHEDULE_{ASSET}=HH:MM-HH:MM,HH:MM-HH:MM
|
||||
// Assets without a schedule are always active.
|
||||
sniperSchedule: (() => {
|
||||
const schedule = {};
|
||||
const prefix = 'SNIPER_SCHEDULE_';
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (key.startsWith(prefix) && value) {
|
||||
const asset = key.slice(prefix.length).toLowerCase();
|
||||
schedule[asset] = value;
|
||||
}
|
||||
}
|
||||
return schedule;
|
||||
})(),
|
||||
|
||||
// ── Market Maker v2 (Buy Low, Sell High) ──────────────────────
|
||||
// Places limit BUY on UP+DOWN at low price, sells at higher price when filled.
|
||||
// No splitPosition — pure orderbook strategy.
|
||||
makerAssets: (process.env.MAKER_ASSETS || 'btc')
|
||||
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean),
|
||||
makerDurations: (process.env.MAKER_DURATION || '5m')
|
||||
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean),
|
||||
makerBuyPrice: parseFloat(process.env.MAKER_BUY_PRICE || '0.02'),
|
||||
makerSellPrice: parseFloat(process.env.MAKER_SELL_PRICE || '0.03'),
|
||||
makerTradeSize: parseFloat(process.env.MAKER_TRADE_SIZE || '50'), // shares per side
|
||||
makerSimBalance: parseFloat(process.env.MAKER_SIM_BALANCE || '50'), // starting sim balance
|
||||
makerPollInterval: parseInt(process.env.MAKER_POLL_INTERVAL || '10', 10) * 1000,
|
||||
makerMonitorMs: parseInt(process.env.MAKER_MONITOR_MS || '2000', 10), // how fast to poll order status
|
||||
|
||||
// ── Proxy (Polymarket API only, NOT Polygon RPC) ──────────────
|
||||
// Supports HTTP/HTTPS. Example: http://user:pass@host:port
|
||||
proxyUrl: process.env.PROXY_URL || '',
|
||||
};
|
||||
|
||||
// Validation for copy-trade bot
|
||||
@@ -100,4 +149,19 @@ export function validateMMConfig() {
|
||||
throw new Error('MM_SELL_PRICE must be between 0 and 1');
|
||||
}
|
||||
|
||||
// Validation for maker bot (buy low, sell high)
|
||||
export function validateMakerConfig() {
|
||||
const required = ['privateKey', 'proxyWallet'];
|
||||
const missing = required.filter((key) => !config[key]);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Missing required config: ${missing.join(', ')}. Check your .env file.`);
|
||||
}
|
||||
if (config.makerBuyPrice <= 0 || config.makerBuyPrice >= 1)
|
||||
throw new Error('MAKER_BUY_PRICE must be between 0 and 1');
|
||||
if (config.makerSellPrice <= config.makerBuyPrice)
|
||||
throw new Error('MAKER_SELL_PRICE must be greater than MAKER_BUY_PRICE');
|
||||
if (config.makerTradeSize < 5)
|
||||
throw new Error('MAKER_TRADE_SIZE must be >= 5 (Polymarket minimum order size)');
|
||||
}
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -11,6 +11,7 @@ import logger from './utils/logger.js';
|
||||
// ── Dashboard init (before any log output) ────────────────────────────────────
|
||||
initDashboard();
|
||||
logger.setOutput(appendLog);
|
||||
logger.interceptConsole(); // strip auth headers from CLOB client axios error dumps
|
||||
|
||||
// ── Handle a trade event from WebSocket ───────────────────────────────────────
|
||||
async function handleTrade(trade) {
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* maker-bot.js — Buy Low, Sell High Market Maker, PM2 / VPS entry point (no TUI)
|
||||
*
|
||||
* Plain-text stdout output, compatible with:
|
||||
* pm2 start ecosystem.config.cjs --only polymarket-maker
|
||||
* pm2 logs polymarket-maker
|
||||
*/
|
||||
|
||||
import './utils/proxy-patch.cjs';
|
||||
|
||||
import { validateMakerConfig } from './config/index.js';
|
||||
import config from './config/index.js';
|
||||
import logger from './utils/logger.js';
|
||||
import { initClient, getUsdcBalance } from './services/client.js';
|
||||
import { startMakerDetector, stopMakerDetector } from './services/makerDetector.js';
|
||||
import { executeMakerStrategy, getActiveMakerPositions, getSimStats } from './services/makerExecutor.js';
|
||||
import { OrderbookWs } from './services/makerWs.js';
|
||||
|
||||
logger.interceptConsole();
|
||||
|
||||
// ── Validate config ────────────────────────────────────────────────────────────
|
||||
|
||||
try {
|
||||
validateMakerConfig();
|
||||
} catch (err) {
|
||||
logger.error(`Config error: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Init CLOB client ──────────────────────────────────────────────────────────
|
||||
|
||||
try {
|
||||
await initClient();
|
||||
} catch (err) {
|
||||
logger.error(`Client init error: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── WebSocket orderbook (for sim mode) ───────────────────────────────────────
|
||||
|
||||
const orderbookWs = new OrderbookWs();
|
||||
|
||||
// ── Periodic status log ──────────────────────────────────────────────────────
|
||||
|
||||
async function printStatus() {
|
||||
try {
|
||||
let balanceStr = 'SIM';
|
||||
if (!config.dryRun) {
|
||||
try { balanceStr = `$${(await getUsdcBalance()).toFixed(2)} USDC`; } catch { balanceStr = 'N/A'; }
|
||||
}
|
||||
|
||||
const positions = getActiveMakerPositions();
|
||||
const mode = config.dryRun ? 'SIMULATION' : 'LIVE';
|
||||
|
||||
if (config.dryRun) {
|
||||
const s = getSimStats();
|
||||
const winRate = s.wins + s.losses > 0 ? ((s.wins / (s.wins + s.losses)) * 100).toFixed(1) : '0.0';
|
||||
const pnlSign = s.cumulativePnl >= 0 ? '+' : '';
|
||||
logger.info(`--- MAKER [SIM] | $${s.balance.toFixed(2)} (${pnlSign}$${s.cumulativePnl.toFixed(4)}) | W:${s.wins} L:${s.losses} S:${s.skips} Win%:${winRate}% | Active: ${positions.length} ---`);
|
||||
} else {
|
||||
logger.info(`--- MAKER Status [${mode}] | Balance: ${balanceStr} | Active: ${positions.length} ---`);
|
||||
}
|
||||
|
||||
for (const pos of positions) {
|
||||
const assetTag = pos.asset ? `[${pos.asset.toUpperCase()}] ` : '';
|
||||
const label = pos.question.substring(0, 50);
|
||||
const msLeft = new Date(pos.endTime).getTime() - Date.now();
|
||||
const secsLeft = Math.max(0, Math.round(msLeft / 1000));
|
||||
const timeStr = secsLeft > 60 ? `${Math.floor(secsLeft / 60)}m${secsLeft % 60}s` : `${secsLeft}s`;
|
||||
const pnl = pos.totalRevenue - pos.totalCost;
|
||||
const sign = pnl >= 0 ? '+' : '';
|
||||
|
||||
logger.info(
|
||||
` ${assetTag}${label} | ${pos.status} | ${timeStr} left` +
|
||||
` | UP: ${pos.up.buyFilled.toFixed(1)}sh bought, ${pos.up.totalSellFilled.toFixed(1)}sh sold` +
|
||||
` | DOWN: ${pos.down.buyFilled.toFixed(1)}sh bought, ${pos.down.totalSellFilled.toFixed(1)}sh sold` +
|
||||
` | P&L: ${sign}$${pnl.toFixed(4)}`,
|
||||
);
|
||||
|
||||
// Orderbook snapshot
|
||||
for (const [label, tokenId] of [['UP', pos.up.tokenId], ['DOWN', pos.down.tokenId]]) {
|
||||
const bestBid = orderbookWs.getBestBid(tokenId);
|
||||
const bestAsk = orderbookWs.getBestAsk(tokenId);
|
||||
if (bestBid || bestAsk) {
|
||||
const book = orderbookWs.getBook(tokenId);
|
||||
const bidDepth = book.bids.slice(0, 3).map(b => `${b.price.toFixed(2)}×${b.size.toFixed(0)}`).join(' ');
|
||||
const askDepth = book.asks.slice(0, 3).map(a => `${a.price.toFixed(2)}×${a.size.toFixed(0)}`).join(' ');
|
||||
logger.info(` ${label}: [${bidDepth}] | [${askDepth}]`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`Status check error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Market handler with per-asset queue ──────────────────────────────────────
|
||||
|
||||
const pendingByAsset = new Map();
|
||||
|
||||
function slotKey(market) {
|
||||
return `${market.asset}-${market.duration || '5m'}`;
|
||||
}
|
||||
|
||||
async function runStrategy(market) {
|
||||
const key = slotKey(market);
|
||||
const tag = `${market.asset?.toUpperCase()}/${market.duration || '5m'}`;
|
||||
|
||||
if (config.dryRun) {
|
||||
orderbookWs.subscribe(market.conditionId, [market.yesTokenId, market.noTokenId]);
|
||||
}
|
||||
|
||||
try {
|
||||
await executeMakerStrategy(market);
|
||||
} catch (err) {
|
||||
logger.error(`MAKER strategy error (${tag}): ${err.message}`);
|
||||
}
|
||||
|
||||
const queued = pendingByAsset.get(key);
|
||||
if (queued) {
|
||||
pendingByAsset.delete(key);
|
||||
const secsLeft = Math.round((new Date(queued.endTime).getTime() - Date.now()) / 1000);
|
||||
|
||||
if (secsLeft > 30) {
|
||||
logger.success(`MAKER[${tag}]: executing queued market (${secsLeft}s left)`);
|
||||
runStrategy(queued);
|
||||
} else {
|
||||
logger.warn(`MAKER[${tag}]: queued market expired (${secsLeft}s left)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNewMarket(market) {
|
||||
const key = slotKey(market);
|
||||
const tag = `${market.asset?.toUpperCase()}/${market.duration || '5m'}`;
|
||||
const active = getActiveMakerPositions();
|
||||
const isSlotBusy = active.some((p) => p.asset === market.asset && p.duration === (market.duration || '5m'));
|
||||
|
||||
if (isSlotBusy) {
|
||||
pendingByAsset.set(key, market);
|
||||
logger.warn(`MAKER[${tag}]: queued — will enter after current position clears`);
|
||||
return;
|
||||
}
|
||||
|
||||
runStrategy(market);
|
||||
}
|
||||
|
||||
// ── Timers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const statusTimer = setInterval(printStatus, 60_000);
|
||||
|
||||
// ── Graceful shutdown ─────────────────────────────────────────────────────────
|
||||
|
||||
function shutdown() {
|
||||
logger.warn('MAKER: shutting down...');
|
||||
stopMakerDetector();
|
||||
orderbookWs.shutdown();
|
||||
clearInterval(statusTimer);
|
||||
setTimeout(() => process.exit(0), 300);
|
||||
}
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
|
||||
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const mode = config.dryRun ? 'SIMULATION' : 'LIVE';
|
||||
const costPerSide = config.makerTradeSize * config.makerBuyPrice;
|
||||
const profitPerCycle = (config.makerSellPrice - config.makerBuyPrice) * config.makerTradeSize;
|
||||
|
||||
logger.info(`=== Market Maker v2 [${mode}] ===`);
|
||||
logger.info(`Assets : ${config.makerAssets.join(', ').toUpperCase()}`);
|
||||
logger.info(`Duration : ${config.makerDurations.join(', ')}`);
|
||||
logger.info(`Buy @ : $${config.makerBuyPrice} per share`);
|
||||
logger.info(`Sell @ : $${config.makerSellPrice} per share`);
|
||||
logger.info(`Size : ${config.makerTradeSize} shares/side`);
|
||||
logger.info(`Cost/side : $${costPerSide.toFixed(2)}`);
|
||||
logger.info(`Profit : $${profitPerCycle.toFixed(2)} per cycle`);
|
||||
logger.info(`CL : cancel buys at 10s, sell until close`);
|
||||
logger.info('==========================================');
|
||||
|
||||
startMakerDetector(handleNewMarket);
|
||||
logger.success(`MAKER bot started — watching for ${config.makerDurations.join('+')} ${config.makerAssets.join('/')} markets...`);
|
||||
+281
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* maker.js
|
||||
* TUI version — Buy Low, Sell High Market Maker (blessed dashboard).
|
||||
*
|
||||
* Strategy: Place limit BUY on UP+DOWN at low price, sell at target when filled.
|
||||
* No splitPosition — pure orderbook-based market making.
|
||||
*
|
||||
* Run with: npm run maker (live)
|
||||
* npm run maker-sim (simulation with real orderbook via WebSocket)
|
||||
*/
|
||||
|
||||
import './utils/proxy-patch.cjs';
|
||||
|
||||
import { validateMakerConfig } from './config/index.js';
|
||||
import config from './config/index.js';
|
||||
import logger from './utils/logger.js';
|
||||
import { initClient, getUsdcBalance } from './services/client.js';
|
||||
import { initDashboard, appendLog, updateStatus, isDashboardActive } from './ui/dashboard.js';
|
||||
import { startMakerDetector, stopMakerDetector } from './services/makerDetector.js';
|
||||
import { executeMakerStrategy, getActiveMakerPositions, getSimStats } from './services/makerExecutor.js';
|
||||
import { OrderbookWs } from './services/makerWs.js';
|
||||
|
||||
// ── Validate config ────────────────────────────────────────────────────────────
|
||||
|
||||
try {
|
||||
validateMakerConfig();
|
||||
} catch (err) {
|
||||
console.error(`Config error: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Init TUI ──────────────────────────────────────────────────────────────────
|
||||
|
||||
initDashboard();
|
||||
logger.setOutput(appendLog);
|
||||
|
||||
// ── Init CLOB client ──────────────────────────────────────────────────────────
|
||||
|
||||
try {
|
||||
await initClient();
|
||||
} catch (err) {
|
||||
logger.error(`Client init error: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── WebSocket orderbook (for sim visualization) ──────────────────────────────
|
||||
|
||||
const orderbookWs = new OrderbookWs();
|
||||
let activeWsTokens = { up: null, down: null };
|
||||
|
||||
// ── Status panel refresh ──────────────────────────────────────────────────────
|
||||
|
||||
async function buildStatusContent() {
|
||||
const lines = [];
|
||||
|
||||
// Balance + Sim Stats
|
||||
if (config.dryRun) {
|
||||
const s = getSimStats();
|
||||
const pnlColor = s.cumulativePnl >= 0 ? 'green' : 'red';
|
||||
const winRate = s.wins + s.losses > 0 ? ((s.wins / (s.wins + s.losses)) * 100).toFixed(1) : '0.0';
|
||||
const pnlSign = s.cumulativePnl >= 0 ? '+' : '';
|
||||
|
||||
lines.push('{bold}SIMULATION{/bold}');
|
||||
lines.push(` Balance : {green-fg}$${s.balance.toFixed(2)}{/green-fg} (start: $${s.startBalance.toFixed(2)})`);
|
||||
lines.push(` PnL : {${pnlColor}-fg}${pnlSign}$${s.cumulativePnl.toFixed(4)}{/${pnlColor}-fg}`);
|
||||
lines.push(` Trades : ${s.totalTrades} total`);
|
||||
lines.push(` {green-fg}WIN ${s.wins}x{/green-fg} | {red-fg}LOSS ${s.losses}x{/red-fg} | {gray-fg}SKIP ${s.skips}x{/gray-fg}`);
|
||||
lines.push(` Win% : ${winRate}%`);
|
||||
lines.push('');
|
||||
|
||||
// Recent trade history
|
||||
if (s.history.length > 0) {
|
||||
lines.push('{bold}TRADE HISTORY{/bold}');
|
||||
const recent = s.history.slice(-8);
|
||||
for (const h of recent) {
|
||||
const rColor = h.result === 'win' ? 'green' : h.result === 'loss' ? 'red' : 'gray';
|
||||
const pSign = h.pnl >= 0 ? '+' : '';
|
||||
lines.push(` {gray-fg}${h.time}{/gray-fg} {${rColor}-fg}${h.result.toUpperCase().padEnd(4)}{/${rColor}-fg} ${(h.side || '-').padEnd(4)} {${rColor}-fg}${pSign}$${h.pnl.toFixed(4)}{/${rColor}-fg} → $${h.balance.toFixed(2)}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
} else {
|
||||
let balance = '?';
|
||||
try { balance = (await getUsdcBalance()).toFixed(2); } catch { /* ignore */ }
|
||||
lines.push('{bold}BALANCE{/bold}');
|
||||
lines.push(` USDC.e: {green-fg}$${balance}{/green-fg}`);
|
||||
lines.push('');
|
||||
lines.push('{bold}MODE{/bold}');
|
||||
lines.push(' {green-fg}LIVE{/green-fg}');
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Maker Config
|
||||
lines.push('{bold}MAKER CONFIG{/bold}');
|
||||
lines.push(` Assets : ${config.makerAssets.join(', ').toUpperCase()}`);
|
||||
lines.push(` Duration : ${config.makerDurations.join(', ')}`);
|
||||
lines.push(` Buy @ : $${config.makerBuyPrice} per share`);
|
||||
lines.push(` Sell @ : $${config.makerSellPrice} per share`);
|
||||
lines.push(` Size : ${config.makerTradeSize} shares/side`);
|
||||
lines.push(` Cost/side: $${(config.makerTradeSize * config.makerBuyPrice).toFixed(2)}`);
|
||||
lines.push(` Profit : $${((config.makerSellPrice - config.makerBuyPrice) * config.makerTradeSize).toFixed(2)}/cycle`);
|
||||
lines.push(` CL : cancel buys at ${config.makerCLSeconds || 10}s, sell until close`);
|
||||
lines.push('');
|
||||
|
||||
// Active positions
|
||||
const positions = getActiveMakerPositions();
|
||||
lines.push(`{bold}ACTIVE POSITIONS (${positions.length}){/bold}`);
|
||||
|
||||
if (positions.length === 0) {
|
||||
lines.push(' {gray-fg}Waiting for market...{/gray-fg}');
|
||||
} else {
|
||||
for (const pos of positions) {
|
||||
const assetTag = pos.asset ? `[${pos.asset.toUpperCase()}/${pos.duration || '5m'}] ` : '';
|
||||
const label = pos.question.substring(0, 32);
|
||||
const msLeft = new Date(pos.endTime).getTime() - Date.now();
|
||||
const secsLeft = Math.max(0, Math.round(msLeft / 1000));
|
||||
const timeStr = secsLeft > 60
|
||||
? `${Math.floor(secsLeft / 60)}m${secsLeft % 60}s`
|
||||
: `{red-fg}${secsLeft}s{/red-fg}`;
|
||||
|
||||
lines.push(` {cyan-fg}${assetTag}${label}{/cyan-fg}`);
|
||||
lines.push(` Status : ${pos.status} | Time left: ${timeStr}`);
|
||||
|
||||
// UP side
|
||||
const upFill = pos.up.buyFilled > 0
|
||||
? `{green-fg}BOUGHT ${pos.up.buyFilled.toFixed(1)}sh{/green-fg}`
|
||||
: '{gray-fg}waiting...{/gray-fg}';
|
||||
const upSold = pos.up.totalSellFilled > 0
|
||||
? ` → {green-fg}SOLD ${pos.up.totalSellFilled.toFixed(1)}sh{/green-fg}`
|
||||
: pos.up.sellOrders.length > 0 ? ' → {yellow-fg}selling...{/yellow-fg}' : '';
|
||||
lines.push(` UP ${upFill}${upSold}`);
|
||||
|
||||
// DOWN side
|
||||
const downFill = pos.down.buyFilled > 0
|
||||
? `{green-fg}BOUGHT ${pos.down.buyFilled.toFixed(1)}sh{/green-fg}`
|
||||
: '{gray-fg}waiting...{/gray-fg}';
|
||||
const downSold = pos.down.totalSellFilled > 0
|
||||
? ` → {green-fg}SOLD ${pos.down.totalSellFilled.toFixed(1)}sh{/green-fg}`
|
||||
: pos.down.sellOrders.length > 0 ? ' → {yellow-fg}selling...{/yellow-fg}' : '';
|
||||
lines.push(` DOWN ${downFill}${downSold}`);
|
||||
|
||||
// P&L
|
||||
const pnl = pos.totalRevenue - pos.totalCost;
|
||||
const pnlColor = pnl >= 0 ? 'green' : 'red';
|
||||
lines.push(` P&L: {${pnlColor}-fg}$${pnl.toFixed(4)}{/${pnlColor}-fg}`);
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
// Orderbook display (always show when tokens are active)
|
||||
if (activeWsTokens.up) {
|
||||
lines.push('{bold}LIVE ORDERBOOK{/bold}');
|
||||
|
||||
for (const [label, tokenId] of [['UP', activeWsTokens.up], ['DOWN', activeWsTokens.down]]) {
|
||||
if (!tokenId) continue;
|
||||
const book = orderbookWs.getBook(tokenId);
|
||||
const bestBid = orderbookWs.getBestBid(tokenId);
|
||||
const bestAsk = orderbookWs.getBestAsk(tokenId);
|
||||
const mid = bestBid && bestAsk ? ((bestBid + bestAsk) / 2) : 0;
|
||||
|
||||
lines.push(` {cyan-fg}${label}{/cyan-fg} mid: $${mid.toFixed(3)} | bid: $${bestBid.toFixed(2)} ask: $${bestAsk.toFixed(2)}`);
|
||||
|
||||
// Top 5 asks (reversed so lowest is closest to spread)
|
||||
const topAsks = book.asks.slice(0, 5).reverse();
|
||||
for (const ask of topAsks) {
|
||||
const bar = '█'.repeat(Math.min(10, Math.round(ask.size / 100)));
|
||||
lines.push(` {red-fg}$${ask.price.toFixed(2)} ${ask.size.toFixed(0).padStart(7)} ${bar}{/red-fg}`);
|
||||
}
|
||||
|
||||
// Spread line
|
||||
if (bestBid && bestAsk) {
|
||||
const spread = bestAsk - bestBid;
|
||||
lines.push(` {yellow-fg}── spread $${spread.toFixed(2)} ──{/yellow-fg}`);
|
||||
}
|
||||
|
||||
// Top 5 bids
|
||||
const topBids = book.bids.slice(0, 5);
|
||||
for (const bid of topBids) {
|
||||
const bar = '█'.repeat(Math.min(10, Math.round(bid.size / 100)));
|
||||
lines.push(` {green-fg}$${bid.price.toFixed(2)} ${bid.size.toFixed(0).padStart(7)} ${bar}{/green-fg}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
return '\n' + lines.join('\n');
|
||||
}
|
||||
|
||||
let refreshTimer = null;
|
||||
|
||||
function startRefresh() {
|
||||
refreshTimer = setInterval(async () => {
|
||||
if (!isDashboardActive()) return;
|
||||
updateStatus(await buildStatusContent());
|
||||
}, 2000);
|
||||
buildStatusContent().then(updateStatus);
|
||||
}
|
||||
|
||||
// ── Market handler with per-asset queue ──────────────────────────────────────
|
||||
|
||||
const pendingByAsset = new Map();
|
||||
|
||||
function slotKey(market) {
|
||||
return `${market.asset}-${market.duration || '5m'}`;
|
||||
}
|
||||
|
||||
async function runStrategy(market) {
|
||||
const key = slotKey(market);
|
||||
const tag = `${market.asset?.toUpperCase()}/${market.duration || '5m'}`;
|
||||
|
||||
// Connect WebSocket for orderbook visualization in sim mode
|
||||
if (config.dryRun) {
|
||||
activeWsTokens = { up: market.yesTokenId, down: market.noTokenId };
|
||||
orderbookWs.subscribe(market.conditionId, [market.yesTokenId, market.noTokenId]);
|
||||
}
|
||||
|
||||
try {
|
||||
await executeMakerStrategy(market);
|
||||
} catch (err) {
|
||||
logger.error(`MAKER strategy error (${tag}): ${err.message}`);
|
||||
}
|
||||
|
||||
// Disconnect WS after strategy ends
|
||||
if (config.dryRun) {
|
||||
activeWsTokens = { up: null, down: null };
|
||||
}
|
||||
|
||||
// Process queued market
|
||||
const queued = pendingByAsset.get(key);
|
||||
if (queued) {
|
||||
pendingByAsset.delete(key);
|
||||
const endMs = new Date(queued.endTime).getTime();
|
||||
const secsLeft = Math.round((endMs - Date.now()) / 1000);
|
||||
|
||||
if (secsLeft > 30) {
|
||||
logger.success(`MAKER[${tag}]: executing queued market (${secsLeft}s left)`);
|
||||
runStrategy(queued);
|
||||
} else {
|
||||
logger.warn(`MAKER[${tag}]: queued market expired (${secsLeft}s left)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNewMarket(market) {
|
||||
const key = slotKey(market);
|
||||
const tag = `${market.asset?.toUpperCase()}/${market.duration || '5m'}`;
|
||||
const active = getActiveMakerPositions();
|
||||
const isSlotBusy = active.some((p) => p.asset === market.asset && p.duration === (market.duration || '5m'));
|
||||
|
||||
if (isSlotBusy) {
|
||||
pendingByAsset.set(key, market);
|
||||
logger.warn(`MAKER[${tag}]: queued — will enter after current position clears`);
|
||||
return;
|
||||
}
|
||||
|
||||
runStrategy(market);
|
||||
}
|
||||
|
||||
// ── Graceful shutdown ─────────────────────────────────────────────────────────
|
||||
|
||||
function shutdown() {
|
||||
logger.warn('MAKER: shutting down...');
|
||||
stopMakerDetector();
|
||||
orderbookWs.shutdown();
|
||||
if (refreshTimer) clearInterval(refreshTimer);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
|
||||
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const costPerSide = config.makerTradeSize * config.makerBuyPrice;
|
||||
const profitPerCycle = (config.makerSellPrice - config.makerBuyPrice) * config.makerTradeSize;
|
||||
logger.info(`MAKER starting — ${config.dryRun ? 'SIMULATION' : 'LIVE'}`);
|
||||
logger.info(`Assets: ${config.makerAssets.join(', ').toUpperCase()} | Durations: ${config.makerDurations.join(', ')} | BUY @ $${config.makerBuyPrice} → SELL @ $${config.makerSellPrice}`);
|
||||
logger.info(`Size: ${config.makerTradeSize} sh/side | Cost: $${costPerSide.toFixed(2)}/side | Profit: $${profitPerCycle.toFixed(2)}/cycle`);
|
||||
|
||||
startRefresh();
|
||||
startMakerDetector(handleNewMarket);
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* mm-bot.js — Market Maker, PM2 / VPS entry point (no TUI)
|
||||
*
|
||||
* Plain-text stdout output, compatible with:
|
||||
* pm2 start ecosystem.config.cjs --only polymarket-mm
|
||||
* pm2 logs polymarket-mm
|
||||
*/
|
||||
|
||||
import { validateMMConfig } from './config/index.js';
|
||||
import config from './config/index.js';
|
||||
import logger from './utils/logger.js';
|
||||
import { initClient, getClient, getUsdcBalance } from './services/client.js';
|
||||
import { startMMDetector, stopMMDetector } from './services/mmDetector.js';
|
||||
import { executeMMStrategy, getActiveMMPositions } from './services/mmExecutor.js';
|
||||
import { cleanupOpenPositions, redeemMMPositions, MIN_SHARES_PER_SIDE } from './services/ctf.js';
|
||||
|
||||
logger.interceptConsole();
|
||||
|
||||
// ── Validate config ────────────────────────────────────────────────────────────
|
||||
|
||||
try {
|
||||
validateMMConfig();
|
||||
} catch (err) {
|
||||
logger.error(`Config error: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Init CLOB client ──────────────────────────────────────────────────────────
|
||||
|
||||
try {
|
||||
await initClient();
|
||||
} catch (err) {
|
||||
logger.error(`Client init error: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Validate MM_TRADE_SIZE minimum ────────────────────────────────────────────
|
||||
|
||||
if (config.mmTradeSize < MIN_SHARES_PER_SIDE) {
|
||||
logger.error(
|
||||
`MM_TRADE_SIZE=${config.mmTradeSize} is below Polymarket minimum of ${MIN_SHARES_PER_SIDE} shares. ` +
|
||||
`Set MM_TRADE_SIZE ≥ ${MIN_SHARES_PER_SIDE} in your .env and restart.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Cleanup leftover positions on startup ─────────────────────────────────────
|
||||
|
||||
try {
|
||||
await cleanupOpenPositions(getClient());
|
||||
} catch (err) {
|
||||
logger.warn(`MM: startup cleanup failed (non-fatal): ${err.message}`);
|
||||
}
|
||||
|
||||
// ── Periodic status log (replaces TUI right panel) ────────────────────────────
|
||||
|
||||
async function printStatus() {
|
||||
try {
|
||||
let balanceStr = 'SIM';
|
||||
if (!config.dryRun) {
|
||||
try { balanceStr = `$${(await getUsdcBalance()).toFixed(2)} USDC`; } catch { balanceStr = 'N/A'; }
|
||||
}
|
||||
|
||||
const positions = getActiveMMPositions();
|
||||
const mode = config.dryRun ? 'SIMULATION' : 'LIVE';
|
||||
|
||||
logger.info(
|
||||
`--- MM Status [${mode}] | Balance: ${balanceStr} | Active positions: ${positions.length} ---`,
|
||||
);
|
||||
|
||||
for (const pos of positions) {
|
||||
const assetTag = pos.asset ? `[${pos.asset.toUpperCase()}] ` : '';
|
||||
const label = pos.question.substring(0, 50);
|
||||
const msLeft = new Date(pos.endTime).getTime() - Date.now();
|
||||
const secsLeft = Math.max(0, Math.round(msLeft / 1000));
|
||||
const timeStr = secsLeft > 60
|
||||
? `${Math.floor(secsLeft / 60)}m${secsLeft % 60}s left`
|
||||
: `${secsLeft}s left`;
|
||||
|
||||
const yFill = pos.yes.filled
|
||||
? `FILLED @ $${pos.yes.fillPrice?.toFixed(3)}`
|
||||
: `waiting $${config.mmSellPrice}`;
|
||||
const nFill = pos.no.filled
|
||||
? `FILLED @ $${pos.no.fillPrice?.toFixed(3)}`
|
||||
: `waiting $${config.mmSellPrice}`;
|
||||
|
||||
logger.info(
|
||||
` ${assetTag}${label} | ${pos.status} | ${timeStr}` +
|
||||
` | YES ${pos.yes.shares?.toFixed(3)}sh@$${pos.yes.entryPrice?.toFixed(3)} → ${yFill}` +
|
||||
` | NO ${pos.no.shares?.toFixed(3)}sh@$${pos.no.entryPrice?.toFixed(3)} → ${nFill}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`Status check error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Market handler with per-asset queue ───────────────────────────────────────
|
||||
|
||||
// Each asset can hold one pending market while its current position is active.
|
||||
const pendingByAsset = new Map(); // asset → market
|
||||
|
||||
async function runStrategy(market) {
|
||||
try {
|
||||
await executeMMStrategy(market);
|
||||
} catch (err) {
|
||||
logger.error(`MM strategy error (${market.asset?.toUpperCase()}): ${err.message}`);
|
||||
}
|
||||
|
||||
// After position clears, execute the queued market for this asset if still valid
|
||||
const queued = pendingByAsset.get(market.asset);
|
||||
if (queued) {
|
||||
pendingByAsset.delete(market.asset);
|
||||
|
||||
const endMs = new Date(queued.endTime).getTime();
|
||||
const secsLeft = Math.round((endMs - Date.now()) / 1000);
|
||||
|
||||
if (secsLeft > config.mmCutLossTime) {
|
||||
logger.success(
|
||||
`MM[${market.asset?.toUpperCase()}]: position cleared — ` +
|
||||
`executing queued "${queued.question.substring(0, 40)}" (${secsLeft}s left)`,
|
||||
);
|
||||
runStrategy(queued); // non-blocking
|
||||
} else {
|
||||
logger.warn(
|
||||
`MM[${market.asset?.toUpperCase()}]: queued market "${queued.question.substring(0, 40)}" ` +
|
||||
`expired (${secsLeft}s left) — discarding`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNewMarket(market) {
|
||||
const active = getActiveMMPositions();
|
||||
const isAssetBusy = active.some((p) => p.asset === market.asset);
|
||||
|
||||
if (isAssetBusy) {
|
||||
pendingByAsset.set(market.asset, market);
|
||||
logger.warn(
|
||||
`MM[${market.asset?.toUpperCase()}]: queued "${market.question.substring(0, 40)}" — ` +
|
||||
`will enter after current ${market.asset?.toUpperCase()} position clears`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
runStrategy(market); // non-blocking
|
||||
}
|
||||
|
||||
// ── Timers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Print status every 60 seconds
|
||||
const statusTimer = setInterval(printStatus, 60_000);
|
||||
|
||||
// Redeemer: run immediately then every redeemInterval
|
||||
redeemMMPositions().catch((err) => logger.error('MM redeemer error:', err.message));
|
||||
const redeemTimer = setInterval(
|
||||
() => redeemMMPositions().catch((err) => logger.error('MM redeemer error:', err.message)),
|
||||
config.redeemInterval,
|
||||
);
|
||||
|
||||
// ── Graceful shutdown ─────────────────────────────────────────────────────────
|
||||
|
||||
function shutdown() {
|
||||
logger.warn('MM: shutting down...');
|
||||
stopMMDetector();
|
||||
clearInterval(statusTimer);
|
||||
clearInterval(redeemTimer);
|
||||
setTimeout(() => process.exit(0), 300);
|
||||
}
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
|
||||
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const mode = config.dryRun ? 'SIMULATION' : 'LIVE';
|
||||
logger.info(`=== Market Maker [${mode}] ===`);
|
||||
logger.info(`Assets : ${config.mmAssets.join(', ').toUpperCase()}`);
|
||||
logger.info(`Duration : ${config.mmDuration}`);
|
||||
logger.info(`Trade size: $${config.mmTradeSize} per side`);
|
||||
logger.info(`Sell @ : $${config.mmSellPrice}`);
|
||||
logger.info(`Cut loss : ${config.mmCutLossTime}s before close`);
|
||||
logger.info(`Keyword : ${config.mmMarketKeyword}`);
|
||||
logger.info(`Entry win : ${config.mmEntryWindow}s after open`);
|
||||
logger.info('==========================================');
|
||||
|
||||
startMMDetector(handleNewMarket);
|
||||
logger.success(`MM bot started — watching for ${config.mmDuration} ${config.mmAssets.join('/')} markets...`);
|
||||
@@ -2,6 +2,7 @@ import { ClobClient } from '@polymarket/clob-client';
|
||||
import { Wallet } from 'ethers';
|
||||
import config from '../config/index.js';
|
||||
import logger from '../utils/logger.js';
|
||||
import { setupAxiosProxy, testProxy } from '../utils/proxy.js';
|
||||
|
||||
let clobClient = null;
|
||||
let signer = null;
|
||||
@@ -11,6 +12,16 @@ let signer = null;
|
||||
* Auto-derives API credentials if not provided in .env
|
||||
*/
|
||||
export async function initClient() {
|
||||
// ── Set up proxy (if configured) BEFORE any Polymarket API calls ──
|
||||
await setupAxiosProxy();
|
||||
|
||||
// Test proxy connectivity
|
||||
const proxyOk = await testProxy();
|
||||
if (!proxyOk) {
|
||||
logger.error('Proxy test failed — cannot reach Polymarket. Exiting.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
logger.info('Initializing Polymarket CLOB client...');
|
||||
|
||||
signer = new Wallet(config.privateKey);
|
||||
|
||||
+185
-18
@@ -12,6 +12,7 @@ import { ethers } from 'ethers';
|
||||
import config from '../config/index.js';
|
||||
import { getSigner, getPolygonProvider } from './client.js';
|
||||
import logger from '../utils/logger.js';
|
||||
import { proxyFetch } from '../utils/proxy.js';
|
||||
|
||||
// ── Contract addresses (Polygon mainnet) ──────────────────────────────────────
|
||||
|
||||
@@ -59,8 +60,8 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
* Strips the lengthy internal stack info that ethers appends.
|
||||
*/
|
||||
function parseOnchainError(err) {
|
||||
const msg = err?.message || String(err);
|
||||
const reason = err?.reason || err?.error?.reason || '';
|
||||
const msg = err?.message || String(err);
|
||||
const reason = err?.reason || err?.error?.reason || '';
|
||||
|
||||
if (msg.includes('insufficient funds') || msg.includes('insufficient balance'))
|
||||
return 'Insufficient MATIC balance for gas fees';
|
||||
@@ -105,11 +106,11 @@ let _txQueue = Promise.resolve();
|
||||
* Calls are serialized via an internal queue so nonces never collide.
|
||||
* Retries up to MAX_RETRIES times on transient errors.
|
||||
*/
|
||||
function execSafeCall(to, data, description = '') {
|
||||
export function execSafeCall(to, data, description = '') {
|
||||
// Enqueue: this call will only start after the previous one resolves/rejects
|
||||
const result = _txQueue.then(() => _doExecSafeCall(to, data, description));
|
||||
// Don't let a failure poison the queue for subsequent calls
|
||||
_txQueue = result.catch(() => {});
|
||||
_txQueue = result.catch(() => { });
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -117,11 +118,14 @@ async function _doExecSafeCall(to, data, description = '') {
|
||||
if (description) logger.info(`MM: exec safe tx — ${description}`);
|
||||
|
||||
let lastErr;
|
||||
// Track gas price multiplier for replacement transactions
|
||||
let gasMultiplier = 1;
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const provider = await getPolygonProvider();
|
||||
const wallet = getSigner().connect(provider);
|
||||
const safe = new ethers.Contract(config.proxyWallet, SAFE_ABI, wallet);
|
||||
const wallet = getSigner().connect(provider);
|
||||
const safe = new ethers.Contract(config.proxyWallet, SAFE_ABI, wallet);
|
||||
|
||||
const nonce = await safe.nonce();
|
||||
|
||||
@@ -142,15 +146,32 @@ async function _doExecSafeCall(to, data, description = '') {
|
||||
// Sign the raw hash with the EOA signing key (no EIP-191 prefix)
|
||||
// Gnosis Safe v1.3.0 treats plain ECDSA signatures (v=27/28) on the tx hash directly
|
||||
const signingKey = new ethers.utils.SigningKey(config.privateKey);
|
||||
const rawSig = signingKey.signDigest(txHash);
|
||||
const signature = ethers.utils.joinSignature(rawSig);
|
||||
const rawSig = signingKey.signDigest(txHash);
|
||||
const signature = ethers.utils.joinSignature(rawSig);
|
||||
|
||||
// Polygon requires maxPriorityFeePerGas ≥ 25 Gwei.
|
||||
// Some RPC nodes (e.g. lava.build) return a stale low estimate, so we enforce a floor.
|
||||
const feeData = await provider.getFeeData();
|
||||
const MIN_TIP = ethers.utils.parseUnits('30', 'gwei');
|
||||
const gasTip = feeData.maxPriorityFeePerGas?.gt(MIN_TIP) ? feeData.maxPriorityFeePerGas : MIN_TIP;
|
||||
const gasFeeCap = feeData.maxFeePerGas ?? ethers.utils.parseUnits('500', 'gwei');
|
||||
// Use HIGH gas prices for fast inclusion — especially important for redeem.
|
||||
const feeData = await provider.getFeeData();
|
||||
|
||||
// Increase gas price on retry to replace pending transaction
|
||||
// Base: use 150% of estimated fees for fast inclusion
|
||||
// Multiplier on retry: 1.5x → 3x → 6x
|
||||
const BASE_MULTIPLIER = 1.5;
|
||||
const currentMultiplier = BASE_MULTIPLIER * gasMultiplier;
|
||||
|
||||
// Priority fee: minimum 50 Gwei, or 150%+ of estimate
|
||||
const MIN_TIP = ethers.utils.parseUnits('50', 'gwei');
|
||||
const estimatedTip = feeData.maxPriorityFeePerGas || MIN_TIP;
|
||||
const gasTip = estimatedTip.mul(Math.ceil(currentMultiplier * 100)).div(100).gt(MIN_TIP)
|
||||
? estimatedTip.mul(Math.ceil(currentMultiplier * 100)).div(100)
|
||||
: MIN_TIP;
|
||||
|
||||
// Max fee: use high ceiling to ensure inclusion
|
||||
const MAX_FEE_CAP = ethers.utils.parseUnits('1000', 'gwei');
|
||||
const estimatedMaxFee = feeData.maxFeePerGas || ethers.utils.parseUnits('500', 'gwei');
|
||||
const gasFeeCap = estimatedMaxFee.mul(Math.ceil(currentMultiplier * 100)).div(100).gt(MAX_FEE_CAP)
|
||||
? MAX_FEE_CAP
|
||||
: estimatedMaxFee.mul(Math.ceil(currentMultiplier * 100)).div(100);
|
||||
|
||||
const tx = await safe.execTransaction(
|
||||
to, 0, data, 0, 0, 0, 0,
|
||||
@@ -168,7 +189,14 @@ async function _doExecSafeCall(to, data, description = '') {
|
||||
const friendly = parseOnchainError(err);
|
||||
|
||||
if (attempt < MAX_RETRIES) {
|
||||
logger.warn(`MM: transaction failed (attempt ${attempt}/${MAX_RETRIES}): ${friendly} — retrying in ${RETRY_DELAY / 1000}s...`);
|
||||
// Increase gas multiplier for replacement transaction
|
||||
if (err?.message?.includes('replacement transaction underpriced') ||
|
||||
err?.message?.includes('Gas price too low to replace')) {
|
||||
gasMultiplier *= 2;
|
||||
logger.warn(`MM: transaction failed (attempt ${attempt}/${MAX_RETRIES}): ${friendly} — increasing gas ${gasMultiplier}x and retrying...`);
|
||||
} else {
|
||||
logger.warn(`MM: transaction failed (attempt ${attempt}/${MAX_RETRIES}): ${friendly} — retrying in ${RETRY_DELAY / 1000}s...`);
|
||||
}
|
||||
await sleep(RETRY_DELAY);
|
||||
}
|
||||
}
|
||||
@@ -332,7 +360,7 @@ export async function cleanupOpenPositions(clobClient) {
|
||||
let dataPositions = [];
|
||||
try {
|
||||
const url = `https://data-api.polymarket.com/positions?user=${config.proxyWallet}`;
|
||||
const resp = await fetch(url);
|
||||
const resp = await proxyFetch(url);
|
||||
if (resp.ok) dataPositions = await resp.json();
|
||||
if (!Array.isArray(dataPositions)) dataPositions = [];
|
||||
} catch (err) {
|
||||
@@ -424,7 +452,7 @@ export async function redeemMMPositions() {
|
||||
// 1. Query Data API for all positions held by the proxy wallet
|
||||
let dataPositions = [];
|
||||
try {
|
||||
const resp = await fetch(`${config.dataHost}/positions?user=${config.proxyWallet}`);
|
||||
const resp = await proxyFetch(`${config.dataHost}/positions?user=${config.proxyWallet}`);
|
||||
if (resp.ok) dataPositions = await resp.json();
|
||||
if (!Array.isArray(dataPositions)) dataPositions = [];
|
||||
} catch {
|
||||
@@ -441,12 +469,12 @@ export async function redeemMMPositions() {
|
||||
const byCondition = new Map();
|
||||
for (const pos of dataPositions) {
|
||||
const cid = pos.conditionId || pos.condition_id;
|
||||
const tid = pos.asset || pos.tokenId || pos.token_id;
|
||||
const tid = pos.asset || pos.tokenId || pos.token_id;
|
||||
if (!cid || !tid) continue;
|
||||
if (!byCondition.has(cid)) byCondition.set(cid, []);
|
||||
byCondition.get(cid).push({
|
||||
tokenId: String(tid),
|
||||
size: parseFloat(pos.size || pos.currentValue || '0'),
|
||||
size: parseFloat(pos.size || pos.currentValue || '0'),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -507,3 +535,142 @@ export async function redeemMMPositions() {
|
||||
logger.success(`MM redeemer: collected ${redeemed} resolved position(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sniper-specific redeemer ──────────────────────────────────────────────────
|
||||
|
||||
// Track conditionIds that repeatedly fail or are losses to avoid retrying every cycle
|
||||
const _failedConditions = new Set();
|
||||
const _skippedLosses = new Set();
|
||||
|
||||
/**
|
||||
* Redeem sniper positions via Gnosis Safe.
|
||||
* Only redeems WINNING positions — skip losses (they can be manually cleared).
|
||||
* Runs on interval only (no startup check) to catch new winners.
|
||||
*/
|
||||
export async function redeemSniperPositions() {
|
||||
// 1. Query Data API for all positions held by the proxy wallet
|
||||
let dataPositions = [];
|
||||
try {
|
||||
const resp = await proxyFetch(`${config.dataHost}/positions?user=${config.proxyWallet}`);
|
||||
if (!resp.ok) {
|
||||
logger.warn(`SNIPER redeemer: Data API returned ${resp.status} — will retry`);
|
||||
return;
|
||||
}
|
||||
dataPositions = await resp.json();
|
||||
if (!Array.isArray(dataPositions)) dataPositions = [];
|
||||
} catch (err) {
|
||||
logger.warn(`SNIPER redeemer: Data API fetch failed — ${err.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (dataPositions.length === 0) return;
|
||||
|
||||
const provider = await getPolygonProvider();
|
||||
const ctf = new ethers.Contract(CTF_ADDRESS, CTF_ABI, provider);
|
||||
const ctfIface = new ethers.utils.Interface(CTF_ABI);
|
||||
|
||||
// Group tokens by conditionId
|
||||
const byCondition = new Map();
|
||||
for (const pos of dataPositions) {
|
||||
const cid = pos.conditionId || pos.condition_id;
|
||||
const tid = pos.asset || pos.tokenId || pos.token_id;
|
||||
if (!cid || !tid) continue;
|
||||
if (!byCondition.has(cid)) byCondition.set(cid, []);
|
||||
byCondition.get(cid).push({
|
||||
tokenId: String(tid),
|
||||
size: parseFloat(pos.size || pos.currentValue || '0'),
|
||||
});
|
||||
}
|
||||
|
||||
let redeemed = 0;
|
||||
let skippedUnresolved = 0;
|
||||
let skippedLosses = 0;
|
||||
let skippedNoBalance = 0;
|
||||
|
||||
for (const [conditionId, tokens] of byCondition) {
|
||||
// Fast skip: conditionIds that previously failed on-chain or confirmed losses
|
||||
if (_failedConditions.has(conditionId)) continue;
|
||||
if (_skippedLosses.has(conditionId)) {
|
||||
skippedLosses++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Skip unresolved markets (fast check)
|
||||
const denominator = await ctf.payoutDenominator(conditionId);
|
||||
if (denominator.isZero()) {
|
||||
skippedUnresolved++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check actual on-chain token balances (positions API can lag)
|
||||
const balances = await Promise.all(
|
||||
tokens.map(({ tokenId }) =>
|
||||
ctf.balanceOf(config.proxyWallet, tokenId)
|
||||
.then((b) => parseFloat(ethers.utils.formatUnits(b, 6)))
|
||||
)
|
||||
);
|
||||
const totalShares = balances.reduce((a, b) => a + b, 0);
|
||||
if (totalShares < 0.001) {
|
||||
skippedNoBalance++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Estimate payout from numerators (for logging)
|
||||
const payoutFractions = await Promise.all(
|
||||
[0, 1].map((i) =>
|
||||
ctf.payoutNumerators(conditionId, i)
|
||||
.then((n) => n.toNumber() / denominator.toNumber())
|
||||
)
|
||||
);
|
||||
const expectedUsdc = balances.reduce(
|
||||
(sum, shares, i) => sum + shares * (payoutFractions[i] ?? 0), 0
|
||||
);
|
||||
|
||||
const label = conditionId.slice(0, 12) + '...';
|
||||
const isWin = expectedUsdc >= 0.01;
|
||||
|
||||
// SNIPER: only redeem WINNERS — cache losses to skip next time
|
||||
if (!isWin) {
|
||||
_skippedLosses.add(conditionId);
|
||||
if (config.dryRun) {
|
||||
logger.info(`SNIPER[SIM] skip loss: ${label} — ${totalShares.toFixed(3)} shares (cached, no future checks)`);
|
||||
} else {
|
||||
logger.info(`SNIPER redeemer: skip loss ${label} — cached for future cycles`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (config.dryRun) {
|
||||
logger.money(`SNIPER[SIM] redeem: ${label} — ${totalShares.toFixed(3)} shares → ~$${expectedUsdc.toFixed(2)} USDC (WIN)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.info(`SNIPER redeemer: ${label} resolved WIN — ${totalShares.toFixed(3)} shares → ~$${expectedUsdc.toFixed(2)} USDC`);
|
||||
|
||||
// Call redeemPositions through Safe — winners only
|
||||
const data = ctfIface.encodeFunctionData('redeemPositions', [
|
||||
USDC_ADDRESS,
|
||||
ethers.constants.HashZero,
|
||||
conditionId,
|
||||
[1, 2],
|
||||
]);
|
||||
const receipt = await execSafeCall(CTF_ADDRESS, data, `redeemPositions ${label}`);
|
||||
|
||||
logger.money(`SNIPER redeemer: redeemed ${label} → ~$${expectedUsdc.toFixed(2)} USDC ✅ | tx: ${receipt.transactionHash}`);
|
||||
redeemed++;
|
||||
} catch (err) {
|
||||
const friendly = parseOnchainError(err);
|
||||
logger.error(`SNIPER redeemer: failed ${conditionId.slice(0, 12)}... — ${friendly}`);
|
||||
// Don't retry this conditionId next cycle — it will keep failing
|
||||
_failedConditions.add(conditionId);
|
||||
logger.warn(`SNIPER redeemer: skipping ${conditionId.slice(0, 12)}... in future cycles`);
|
||||
}
|
||||
}
|
||||
|
||||
// Summary log
|
||||
const totalSkipped = skippedUnresolved + skippedLosses + skippedNoBalance + _skippedLosses.size + _failedConditions.size;
|
||||
if (redeemed > 0 || totalSkipped > 0) {
|
||||
logger.info(`SNIPER redeemer: ${redeemed} redeemed, ${_skippedLosses.size} losses cached, ${skippedUnresolved} unresolved skipped`);
|
||||
}
|
||||
}
|
||||
|
||||
+248
-55
@@ -1,12 +1,37 @@
|
||||
import { Side, OrderType } from '@polymarket/clob-client';
|
||||
import { ethers } from 'ethers';
|
||||
import config from '../config/index.js';
|
||||
import { getClient, getUsdcBalance } from './client.js';
|
||||
import { getClient, getUsdcBalance, getPolygonProvider } from './client.js';
|
||||
import { hasPosition, addPosition, getPosition, updatePosition, removePosition } from './position.js';
|
||||
import { fetchMarketByTokenId } from './watcher.js';
|
||||
import { placeAutoSell } from './autoSell.js';
|
||||
import { ensureExchangeApproval, CTF_ADDRESS } from './ctf.js';
|
||||
import { recordSimBuy } from '../utils/simStats.js';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
const CTF_ABI_BALANCE = ['function balanceOf(address account, uint256 id) view returns (uint256)'];
|
||||
|
||||
// Per-market buy queue: prevents concurrent buys for the same market.
|
||||
// Each conditionId maps to the Promise tail of its queue so calls are
|
||||
// chained — the next buy only starts after the previous one finishes.
|
||||
const _buyQueue = new Map();
|
||||
|
||||
/**
|
||||
* Fetch the actual on-chain ERC-1155 balance for a conditional token.
|
||||
* Returns shares as a plain float (6-decimal conversion).
|
||||
*/
|
||||
async function getOnChainTokenBalance(tokenId) {
|
||||
try {
|
||||
const provider = await getPolygonProvider();
|
||||
const ctf = new ethers.Contract(CTF_ADDRESS, CTF_ABI_BALANCE, provider);
|
||||
const raw = await ctf.balanceOf(config.proxyWallet, tokenId);
|
||||
return parseFloat(ethers.utils.formatUnits(raw, 6));
|
||||
} catch (err) {
|
||||
logger.warn(`Could not fetch on-chain token balance: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate trade size for our entry — independent of the individual fill event.
|
||||
*
|
||||
@@ -36,10 +61,13 @@ async function getMarketOptions(tokenId) {
|
||||
const marketInfo = await fetchMarketByTokenId(tokenId);
|
||||
if (marketInfo) {
|
||||
return {
|
||||
tickSize: String(marketInfo.minimum_tick_size || '0.01'),
|
||||
negRisk: marketInfo.neg_risk || false,
|
||||
conditionId: marketInfo.condition_id || '',
|
||||
question: marketInfo.question || '',
|
||||
tickSize: String(marketInfo.orderPriceMinTickSize || '0.01'),
|
||||
negRisk: marketInfo.negRisk || false,
|
||||
conditionId: marketInfo.conditionId || '',
|
||||
question: marketInfo.question || '',
|
||||
endDateIso: marketInfo.endDate || null,
|
||||
active: marketInfo.active !== false,
|
||||
acceptingOrders: marketInfo.acceptingOrders !== false,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -50,23 +78,128 @@ async function getMarketOptions(tokenId) {
|
||||
try {
|
||||
const tickSize = await client.getTickSize(tokenId);
|
||||
const negRisk = await client.getNegRisk(tokenId);
|
||||
return { tickSize: String(tickSize), negRisk, conditionId: '', question: '' };
|
||||
return { tickSize: String(tickSize), negRisk, conditionId: '', question: '', endDateIso: null, active: true, acceptingOrders: true };
|
||||
} catch (err) {
|
||||
logger.warn('Failed to get tick size from SDK, using default 0.01');
|
||||
return { tickSize: '0.01', negRisk: false, conditionId: '', question: '' };
|
||||
return { tickSize: '0.01', negRisk: false, conditionId: '', question: '', endDateIso: null, active: true, acceptingOrders: true };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a BUY trade (copy trader's buy)
|
||||
* @param {Object} trade - Trade info from watcher
|
||||
* Execute a BUY trade (copy trader's buy).
|
||||
* Calls are serialized per market — concurrent events for the same market
|
||||
* are queued and processed one at a time to prevent duplicate positions.
|
||||
*/
|
||||
export async function executeBuy(trade) {
|
||||
export function executeBuy(trade) {
|
||||
const { tokenId, conditionId } = trade;
|
||||
|
||||
// Resolve conditionId to use as queue key.
|
||||
// getMarketOptions is a read-only fetch — safe to run outside the queue.
|
||||
const queued = getMarketOptions(tokenId).then((marketOpts) => {
|
||||
const effectiveConditionId = conditionId || marketOpts.conditionId;
|
||||
|
||||
// Chain this buy after the previous one for the same market
|
||||
const prev = _buyQueue.get(effectiveConditionId) ?? Promise.resolve();
|
||||
const current = prev
|
||||
.then(() => _doExecuteBuy(trade, marketOpts, effectiveConditionId))
|
||||
.finally(() => {
|
||||
// Remove from map only if we're still the tail (no newer call queued)
|
||||
if (_buyQueue.get(effectiveConditionId) === current) {
|
||||
_buyQueue.delete(effectiveConditionId);
|
||||
}
|
||||
});
|
||||
_buyQueue.set(effectiveConditionId, current);
|
||||
return current;
|
||||
});
|
||||
|
||||
return queued;
|
||||
}
|
||||
|
||||
/**
|
||||
* GTC fallback for when FAK finds no liquidity (e.g. trader buys into "next market"
|
||||
* before any sellers exist). Places a GTC limit order and polls until filled or timeout.
|
||||
*
|
||||
* Returns { sharesFilled, costFilled } on success, or null on failure/timeout.
|
||||
*/
|
||||
async function _tryGtcFallback(client, tokenId, tradeSize, price, marketOpts) {
|
||||
const gtcPrice = parseFloat(Math.min(price * 1.02, 0.99).toFixed(4));
|
||||
const shares = parseFloat((tradeSize / gtcPrice).toFixed(4));
|
||||
|
||||
logger.info(`No liquidity via FAK — placing GTC limit buy: ${shares} shares @ $${gtcPrice}`);
|
||||
|
||||
let orderId;
|
||||
try {
|
||||
const resp = await client.createAndPostOrder(
|
||||
{ tokenID: tokenId, side: Side.BUY, price: gtcPrice, size: shares },
|
||||
{ tickSize: marketOpts.tickSize, negRisk: marketOpts.negRisk },
|
||||
OrderType.GTC,
|
||||
);
|
||||
if (!resp?.success) {
|
||||
logger.warn(`GTC fallback rejected: ${resp?.errorMsg || 'unknown'}`);
|
||||
return null;
|
||||
}
|
||||
orderId = resp.orderID;
|
||||
logger.info(`GTC order placed: ${orderId} — waiting for fill (up to ${config.gtcFallbackTimeout}s)...`);
|
||||
} catch (err) {
|
||||
logger.warn(`GTC fallback order failed: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const deadline = Date.now() + config.gtcFallbackTimeout * 1000;
|
||||
const pollMs = 3000;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, pollMs));
|
||||
try {
|
||||
const order = await client.getOrder(orderId);
|
||||
const matched = parseFloat(order?.size_matched ?? order?.matched_amount ?? '0');
|
||||
const status = (order?.status ?? order?.order_status ?? '').toLowerCase();
|
||||
|
||||
if (matched > 0 || status === 'matched' || status === 'filled') {
|
||||
const sharesFilled = matched > 0 ? matched : shares;
|
||||
const costFilled = sharesFilled * gtcPrice;
|
||||
logger.success(`GTC filled: ${sharesFilled.toFixed(4)} shares @ $${gtcPrice} | orderID: ${orderId}`);
|
||||
return { sharesFilled, costFilled };
|
||||
}
|
||||
|
||||
// Order gone from open orders also means it was matched
|
||||
if (status === 'cancelled') {
|
||||
logger.warn(`GTC order ${orderId} was cancelled externally`);
|
||||
return null;
|
||||
}
|
||||
} catch { /* getOrder can 404 briefly — keep polling */ }
|
||||
}
|
||||
|
||||
// Timed out — cancel the GTC
|
||||
logger.warn(`GTC order ${orderId} not filled in ${config.gtcFallbackTimeout}s — cancelling`);
|
||||
try { await client.cancelOrder({ orderID: orderId }); } catch { /* ignore */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal: the actual buy logic, guaranteed to run serially per market.
|
||||
*/
|
||||
async function _doExecuteBuy(trade, marketOpts, effectiveConditionId) {
|
||||
const { tokenId, conditionId, market, price, size } = trade;
|
||||
|
||||
// Get market options first to resolve conditionId
|
||||
const marketOpts = await getMarketOptions(tokenId);
|
||||
const effectiveConditionId = conditionId || marketOpts.conditionId;
|
||||
// ── Market expiry guard ────────────────────────────────────────────────────
|
||||
if (!marketOpts.active || !marketOpts.acceptingOrders) {
|
||||
logger.warn(`Market closed/not accepting orders: ${market || effectiveConditionId} — skipping buy`);
|
||||
return;
|
||||
}
|
||||
if (marketOpts.endDateIso) {
|
||||
const secsLeft = (new Date(marketOpts.endDateIso).getTime() - Date.now()) / 1000;
|
||||
if (secsLeft < config.minMarketTimeLeft) {
|
||||
const minsLeft = Math.max(0, Math.floor(secsLeft / 60));
|
||||
const sLeft = Math.max(0, Math.floor(secsLeft % 60));
|
||||
logger.warn(
|
||||
`Market expires in ${minsLeft}m ${sLeft}s — below MIN_MARKET_TIME_LEFT ` +
|
||||
`(${config.minMarketTimeLeft}s). Skipping buy: ${market || effectiveConditionId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Check existing position and max position size cap
|
||||
const existingPos = getPosition(effectiveConditionId);
|
||||
@@ -90,8 +223,11 @@ export async function executeBuy(trade) {
|
||||
tradeSize = Math.min(tradeSize, config.maxPositionSize);
|
||||
}
|
||||
|
||||
if (tradeSize < config.minTradeSize) {
|
||||
logger.warn(`Trade size $${tradeSize.toFixed(2)} below minimum $${config.minTradeSize}. Skipping.`);
|
||||
// Polymarket enforces a hard $1 minimum per market order.
|
||||
const CLOB_MIN_ORDER_USDC = 1;
|
||||
const effectiveMin = Math.max(config.minTradeSize, CLOB_MIN_ORDER_USDC);
|
||||
if (tradeSize < effectiveMin) {
|
||||
logger.warn(`Trade size $${tradeSize.toFixed(2)} below $${effectiveMin} minimum — skipping buy`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -131,7 +267,7 @@ export async function executeBuy(trade) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Place market order with retries
|
||||
// Place market order (FAK) with retries
|
||||
const client = getClient();
|
||||
let filled = false;
|
||||
let totalSharesFilled = 0;
|
||||
@@ -140,56 +276,63 @@ export async function executeBuy(trade) {
|
||||
for (let attempt = 1; attempt <= config.maxRetries; attempt++) {
|
||||
try {
|
||||
const remainingAmount = tradeSize - totalCostFilled;
|
||||
if (remainingAmount < config.minTradeSize) break;
|
||||
if (remainingAmount < effectiveMin) {
|
||||
if (remainingAmount > 0) logger.info(`Remaining $${remainingAmount.toFixed(2)} below $${effectiveMin} minimum — stopping`);
|
||||
break;
|
||||
}
|
||||
|
||||
logger.info(`Buy attempt ${attempt}/${config.maxRetries} | Amount: $${remainingAmount.toFixed(2)}`);
|
||||
|
||||
// Use FAK (fill-and-kill) to get what's available, then retry remainder
|
||||
const response = await client.createAndPostMarketOrder(
|
||||
{
|
||||
tokenID: tokenId,
|
||||
side: Side.BUY,
|
||||
amount: remainingAmount,
|
||||
price: Math.min(price * 1.05, 0.99), // 5% slippage allowance, max 0.99
|
||||
price: Math.min(price * 1.02, 0.99), // 2% slippage, max 0.99
|
||||
},
|
||||
{
|
||||
tickSize: marketOpts.tickSize,
|
||||
negRisk: marketOpts.negRisk,
|
||||
},
|
||||
OrderType.FOK,
|
||||
OrderType.FAK, // Fill-and-Kill: takes what's available, no full-fill requirement
|
||||
);
|
||||
|
||||
if (response && response.success) {
|
||||
logger.success(`Order placed: ${response.orderID} | Status: ${response.status}`);
|
||||
const sharesFilled = parseFloat(response.takingAmount || '0');
|
||||
const costFilled = parseFloat(response.makingAmount || '0');
|
||||
|
||||
// Check if fully filled by trying to get trade info
|
||||
const takingAmount = parseFloat(response.takingAmount || '0');
|
||||
const makingAmount = parseFloat(response.makingAmount || '0');
|
||||
|
||||
if (takingAmount > 0 || makingAmount > 0) {
|
||||
totalSharesFilled += takingAmount || (remainingAmount / price);
|
||||
totalCostFilled += makingAmount || remainingAmount;
|
||||
if (sharesFilled > 0) {
|
||||
logger.success(`Order filled: ${response.orderID} | ${sharesFilled.toFixed(4)} shares @ ~$${(costFilled / sharesFilled).toFixed(4)}`);
|
||||
totalSharesFilled += sharesFilled;
|
||||
totalCostFilled += costFilled || (sharesFilled * price);
|
||||
filled = true;
|
||||
break; // FOK either fills fully or cancels
|
||||
// If remainder is below $1 minimum, stop; otherwise loop for partial fill
|
||||
if (tradeSize - totalCostFilled < effectiveMin) break;
|
||||
} else {
|
||||
filled = true;
|
||||
totalSharesFilled = tradeSize / price;
|
||||
totalCostFilled = tradeSize;
|
||||
break;
|
||||
logger.warn(`No liquidity — FAK filled 0 shares (attempt ${attempt})`);
|
||||
}
|
||||
} else {
|
||||
logger.warn(`Order not filled. Error: ${response?.errorMsg || 'Unknown'}`);
|
||||
logger.warn(`Order rejected: ${response?.errorMsg || 'unknown'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Buy attempt ${attempt} failed:`, err.message);
|
||||
logger.error(`Buy attempt ${attempt} failed: ${err.message}`);
|
||||
}
|
||||
|
||||
// Wait before retry
|
||||
if (attempt < config.maxRetries) {
|
||||
await new Promise((r) => setTimeout(r, config.retryDelay));
|
||||
}
|
||||
}
|
||||
|
||||
// FAK found no liquidity — fall back to GTC limit order and wait for fill
|
||||
if (!filled && config.gtcFallbackTimeout > 0) {
|
||||
const gtcResult = await _tryGtcFallback(client, tokenId, tradeSize, price, marketOpts);
|
||||
if (gtcResult) {
|
||||
totalSharesFilled = gtcResult.sharesFilled;
|
||||
totalCostFilled = gtcResult.costFilled;
|
||||
filled = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!filled || totalCostFilled === 0) {
|
||||
logger.error(`Failed to fill buy order for ${market || tokenId} after ${config.maxRetries} attempts`);
|
||||
return;
|
||||
@@ -221,6 +364,13 @@ export async function executeBuy(trade) {
|
||||
outcome: trade.outcome,
|
||||
});
|
||||
|
||||
// Ensure the CTF Exchange is approved to move our ERC-1155 tokens (needed for future sells)
|
||||
try {
|
||||
await ensureExchangeApproval(marketOpts.negRisk);
|
||||
} catch (err) {
|
||||
logger.warn(`Could not verify ERC-1155 approval: ${err.message}`);
|
||||
}
|
||||
|
||||
// Auto-sell only on initial entry, not on accumulation
|
||||
if (config.autoSellEnabled) {
|
||||
await placeAutoSell(effectiveConditionId, tokenId, totalSharesFilled, fillAvgPrice, marketOpts);
|
||||
@@ -263,15 +413,29 @@ export async function executeSell(trade) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel existing auto-sell order if any
|
||||
if (position.sellOrderId) {
|
||||
try {
|
||||
const client = getClient();
|
||||
await client.cancelOrder(position.sellOrderId);
|
||||
logger.info(`Cancelled auto-sell order: ${position.sellOrderId}`);
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to cancel auto-sell: ${err.message}`);
|
||||
// Cancel ALL open orders for this token so the CLOB frees up locked balance.
|
||||
// Only cancelling by sellOrderId is not enough — the cancel can fail silently
|
||||
// and locked tokens cause "not enough balance" on the subsequent sell.
|
||||
const client = getClient();
|
||||
try {
|
||||
const openOrders = await client.getOpenOrders({ asset_id: tokenId });
|
||||
if (Array.isArray(openOrders) && openOrders.length > 0) {
|
||||
logger.info(`Cancelling ${openOrders.length} open order(s) for token before sell`);
|
||||
await Promise.allSettled(
|
||||
openOrders.map((o) => client.cancelOrder({ orderID: o.id ?? o.order_id })),
|
||||
);
|
||||
// Brief pause so the CLOB can update the locked-balance ledger
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
}
|
||||
} catch (err) {
|
||||
// Fallback: try to cancel just the tracked auto-sell order ID
|
||||
if (position.sellOrderId) {
|
||||
try {
|
||||
await client.cancelOrder({ orderID: position.sellOrderId });
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
logger.warn(`Could not fetch open orders to cancel: ${err.message}`);
|
||||
}
|
||||
|
||||
updatePosition(effectiveConditionId, { status: 'selling' });
|
||||
@@ -280,35 +444,64 @@ export async function executeSell(trade) {
|
||||
marketOpts = await getMarketOptions(tokenId);
|
||||
}
|
||||
|
||||
const client = getClient();
|
||||
// Ensure ERC-1155 approval so the exchange can transfer our tokens
|
||||
try {
|
||||
await ensureExchangeApproval(marketOpts.negRisk);
|
||||
} catch (err) {
|
||||
logger.warn(`Could not verify ERC-1155 approval: ${err.message}`);
|
||||
}
|
||||
|
||||
// Reconcile stored shares with actual on-chain balance to prevent "not enough balance" errors.
|
||||
// The stored amount can be higher than on-chain due to fee deductions or precision drift.
|
||||
const onChain = await getOnChainTokenBalance(tokenId);
|
||||
let sharesToSell = position.shares;
|
||||
if (onChain !== null) {
|
||||
if (onChain < 0.0001) {
|
||||
logger.warn(`On-chain balance is 0 for ${position.market} — position already sold or redeemed`);
|
||||
removePosition(effectiveConditionId);
|
||||
return;
|
||||
}
|
||||
if (onChain < sharesToSell) {
|
||||
logger.info(`Adjusting sell amount: stored ${sharesToSell.toFixed(6)} → on-chain ${onChain.toFixed(6)} shares`);
|
||||
sharesToSell = onChain;
|
||||
}
|
||||
}
|
||||
// Round down to 4 decimal places to avoid sub-unit precision errors
|
||||
sharesToSell = Math.floor(sharesToSell * 10000) / 10000;
|
||||
|
||||
let filled = false;
|
||||
|
||||
for (let attempt = 1; attempt <= config.maxRetries; attempt++) {
|
||||
try {
|
||||
if (config.sellMode === 'market') {
|
||||
// Market sell (FOK)
|
||||
logger.info(`Sell attempt ${attempt}/${config.maxRetries} (market) | Shares: ${position.shares}`);
|
||||
// Market sell (FAK) — takes what's available at 2% slippage
|
||||
logger.info(`Sell attempt ${attempt}/${config.maxRetries} (market) | Shares: ${sharesToSell}`);
|
||||
|
||||
const response = await client.createAndPostMarketOrder(
|
||||
{
|
||||
tokenID: tokenId,
|
||||
side: Side.SELL,
|
||||
amount: position.shares,
|
||||
price: Math.max(price * 0.95, 0.01), // 5% slippage, min 0.01
|
||||
amount: sharesToSell,
|
||||
price: Math.max(price * 0.98, 0.01), // 2% slippage, min 0.01
|
||||
},
|
||||
{
|
||||
tickSize: marketOpts.tickSize,
|
||||
negRisk: marketOpts.negRisk,
|
||||
},
|
||||
OrderType.FOK,
|
||||
OrderType.FAK, // Fill-and-Kill: takes what's available
|
||||
);
|
||||
|
||||
if (response && response.success) {
|
||||
logger.success(`Sell order placed: ${response.orderID}`);
|
||||
filled = true;
|
||||
break;
|
||||
const sharesFilled = parseFloat(response.takingAmount || '0');
|
||||
if (sharesFilled > 0) {
|
||||
logger.success(`Sell filled: ${response.orderID} | ${sharesFilled.toFixed(4)} shares`);
|
||||
filled = true;
|
||||
break;
|
||||
} else {
|
||||
logger.warn(`No bid liquidity — FAK filled 0 shares (attempt ${attempt})`);
|
||||
}
|
||||
} else {
|
||||
logger.warn(`Sell not filled: ${response?.errorMsg || 'Unknown'}`);
|
||||
logger.warn(`Sell rejected: ${response?.errorMsg || 'unknown'}`);
|
||||
}
|
||||
} else {
|
||||
// Limit sell at trader's sell price
|
||||
@@ -318,7 +511,7 @@ export async function executeSell(trade) {
|
||||
{
|
||||
tokenID: tokenId,
|
||||
price: price,
|
||||
size: position.shares,
|
||||
size: sharesToSell,
|
||||
side: Side.SELL,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* makerDetector.js
|
||||
* Detects upcoming markets for the Maker strategy (buy low, sell high).
|
||||
* Supports multiple assets AND multiple durations (e.g. 5m,15m).
|
||||
*/
|
||||
|
||||
import config from '../config/index.js';
|
||||
import logger from '../utils/logger.js';
|
||||
import { proxyFetch } from '../utils/proxy.js';
|
||||
|
||||
const DURATION_SECS = { '5m': 300, '15m': 900 };
|
||||
|
||||
let pollTimer = null;
|
||||
let onMarketCb = null;
|
||||
const seenKeys = new Set();
|
||||
|
||||
function slotSec(duration) {
|
||||
return DURATION_SECS[duration] || 300;
|
||||
}
|
||||
|
||||
function currentSlot(duration) {
|
||||
const sec = slotSec(duration);
|
||||
return Math.floor(Date.now() / 1000 / sec) * sec;
|
||||
}
|
||||
|
||||
function nextSlot(duration) {
|
||||
return currentSlot(duration) + slotSec(duration);
|
||||
}
|
||||
|
||||
async function fetchBySlug(asset, duration, slotTimestamp) {
|
||||
const slug = `${asset}-updown-${duration}-${slotTimestamp}`;
|
||||
try {
|
||||
const resp = await proxyFetch(`${config.gammaHost}/markets/slug/${slug}`);
|
||||
if (!resp.ok) return null;
|
||||
const data = await resp.json();
|
||||
return data?.conditionId ? data : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractMarketData(market, asset, duration) {
|
||||
const conditionId = market.conditionId || market.condition_id || '';
|
||||
if (!conditionId) return null;
|
||||
|
||||
let tokenIds = market.clobTokenIds ?? market.clob_token_ids;
|
||||
if (typeof tokenIds === 'string') {
|
||||
try { tokenIds = JSON.parse(tokenIds); } catch { tokenIds = null; }
|
||||
}
|
||||
|
||||
let yesTokenId, noTokenId;
|
||||
if (Array.isArray(tokenIds) && tokenIds.length >= 2) {
|
||||
[yesTokenId, noTokenId] = tokenIds;
|
||||
} else if (Array.isArray(market.tokens) && market.tokens.length >= 2) {
|
||||
yesTokenId = market.tokens[0]?.token_id ?? market.tokens[0]?.tokenId;
|
||||
noTokenId = market.tokens[1]?.token_id ?? market.tokens[1]?.tokenId;
|
||||
}
|
||||
|
||||
if (!yesTokenId || !noTokenId) return null;
|
||||
|
||||
return {
|
||||
asset,
|
||||
duration,
|
||||
conditionId,
|
||||
question: market.question || market.title || '',
|
||||
endTime: market.endDate || market.end_date_iso || market.endDateIso,
|
||||
eventStartTime: market.eventStartTime || market.event_start_time,
|
||||
yesTokenId: String(yesTokenId),
|
||||
noTokenId: String(noTokenId),
|
||||
negRisk: market.negRisk ?? market.neg_risk ?? false,
|
||||
tickSize: String(market.orderPriceMinTickSize ?? market.minimum_tick_size ?? market.minimumTickSize ?? '0.01'),
|
||||
};
|
||||
}
|
||||
|
||||
async function scheduleAsset(asset, duration, slotTimestamp) {
|
||||
const key = `${asset}-${duration}-${slotTimestamp}`;
|
||||
if (seenKeys.has(key)) return;
|
||||
|
||||
const market = await fetchBySlug(asset, duration, slotTimestamp);
|
||||
if (!market) return;
|
||||
|
||||
const data = extractMarketData(market, asset, duration);
|
||||
if (!data) {
|
||||
logger.warn(`MAKER: skipping ${asset.toUpperCase()} ${duration} slot ${slotTimestamp} — missing token IDs`);
|
||||
seenKeys.add(key);
|
||||
return;
|
||||
}
|
||||
|
||||
seenKeys.add(key);
|
||||
|
||||
const openAt = data.eventStartTime ? new Date(data.eventStartTime).getTime() : slotTimestamp * 1000;
|
||||
const elapsedSec = Math.round((Date.now() - openAt) / 1000);
|
||||
if (elapsedSec > 15) {
|
||||
logger.info(`MAKER: ${asset.toUpperCase()} ${duration} next slot already ${elapsedSec}s old — skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
const secsUntilOpen = Math.round((openAt - Date.now()) / 1000);
|
||||
if (secsUntilOpen > 0) {
|
||||
logger.success(`MAKER: ${asset.toUpperCase()} ${duration} found "${data.question.slice(0, 40)}" — placing orders (${secsUntilOpen}s before open)`);
|
||||
} else {
|
||||
logger.success(`MAKER: ${asset.toUpperCase()} ${duration} found "${data.question.slice(0, 40)}" — placing orders now`);
|
||||
}
|
||||
|
||||
if (onMarketCb) onMarketCb(data);
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const tasks = [];
|
||||
for (const duration of config.makerDurations) {
|
||||
const next = nextSlot(duration);
|
||||
for (const asset of config.makerAssets) {
|
||||
tasks.push(scheduleAsset(asset, duration, next));
|
||||
}
|
||||
}
|
||||
await Promise.all(tasks);
|
||||
} catch (err) {
|
||||
logger.error('MAKER detector poll error:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
export function startMakerDetector(onNewMarket) {
|
||||
onMarketCb = onNewMarket;
|
||||
seenKeys.clear();
|
||||
|
||||
poll();
|
||||
pollTimer = setInterval(poll, config.makerPollInterval);
|
||||
|
||||
const durStr = config.makerDurations.join(', ');
|
||||
for (const duration of config.makerDurations) {
|
||||
const ns = nextSlot(duration);
|
||||
const secsUntil = ns - Math.floor(Date.now() / 1000);
|
||||
logger.info(`MAKER detector — ${duration}: next slot *-updown-${duration}-${ns} (opens in ${secsUntil}s)`);
|
||||
}
|
||||
logger.info(`MAKER detector started — assets: ${config.makerAssets.join(', ').toUpperCase()} | durations: ${durStr}`);
|
||||
logger.info(`Strategy: BUY @ $${config.makerBuyPrice} → SELL @ $${config.makerSellPrice} | ${config.makerTradeSize} shares/side`);
|
||||
}
|
||||
|
||||
export function stopMakerDetector() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
/**
|
||||
* makerExecutor.js
|
||||
* Buy Low, Sell High Market Maker — no splitPosition.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Ensure ERC1155 exchange approval (one-time, needed to sell tokens)
|
||||
* 2. Place concurrent limit BUY on UP + DOWN at makerBuyPrice (e.g. 2c)
|
||||
* 3. Monitor both orders in parallel (multi-thread style)
|
||||
* 4. When one side fills (even partial):
|
||||
* a. Wait briefly for on-chain settlement
|
||||
* b. Place limit SELL for filled shares at makerSellPrice (e.g. 3c)
|
||||
* c. Cancel the other side's buy order
|
||||
* 5. Partial fills → partial sells placed immediately
|
||||
* 6. CL at 10s before close: cancel unfilled buy orders
|
||||
* 7. Place sells for any filled positions (retry 3x if settlement pending)
|
||||
* 8. Monitor sell orders until filled or market close
|
||||
*/
|
||||
|
||||
import { Side, OrderType } from '@polymarket/clob-client';
|
||||
import config from '../config/index.js';
|
||||
import { getClient } from './client.js';
|
||||
import { ensureExchangeApproval } from './ctf.js';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
const CL_SECONDS = 10; // cancel unfilled sells 10s before market close
|
||||
const SELL_DELAY_MS = 2000; // wait for on-chain settlement before placing sell
|
||||
const MAX_SELL_RETRIES = 3;
|
||||
const MIN_ORDER_SIZE = 5; // Polymarket minimum order size
|
||||
|
||||
// In-memory store of active maker positions
|
||||
const activePositions = new Map();
|
||||
|
||||
export function getActiveMakerPositions() {
|
||||
return Array.from(activePositions.values());
|
||||
}
|
||||
|
||||
// ── Simulation stats ─────────────────────────────────────────────────────────
|
||||
|
||||
const simStats = {
|
||||
startBalance: config.makerSimBalance,
|
||||
balance: config.makerSimBalance,
|
||||
wins: 0, // sell filled → realized profit
|
||||
losses: 0, // buy filled, sell NOT filled → loss = buy cost
|
||||
skips: 0, // no fills at all → $0
|
||||
totalTrades: 0,
|
||||
cumulativePnl: 0,
|
||||
history: [], // [{ time, side, result, pnl, balance }]
|
||||
};
|
||||
|
||||
export function getSimStats() {
|
||||
return { ...simStats, history: [...simStats.history] };
|
||||
}
|
||||
|
||||
function recordTrade(result, side, pnl) {
|
||||
simStats.totalTrades++;
|
||||
simStats.cumulativePnl += pnl;
|
||||
simStats.balance += pnl;
|
||||
|
||||
if (result === 'win') simStats.wins++;
|
||||
else if (result === 'loss') simStats.losses++;
|
||||
else simStats.skips++;
|
||||
|
||||
simStats.history.push({
|
||||
time: new Date().toISOString().replace('T', ' ').substring(11, 19),
|
||||
side: side || '-',
|
||||
result,
|
||||
pnl,
|
||||
balance: simStats.balance,
|
||||
});
|
||||
|
||||
if (simStats.history.length > 50) simStats.history.splice(0, simStats.history.length - 50);
|
||||
}
|
||||
|
||||
// ── Approval tracking ────────────────────────────────────────────────────────
|
||||
|
||||
let approvalChecked = false;
|
||||
|
||||
async function ensureApproval(negRisk) {
|
||||
if (config.dryRun || approvalChecked) return;
|
||||
try {
|
||||
await ensureExchangeApproval(negRisk);
|
||||
approvalChecked = true;
|
||||
} catch (err) {
|
||||
logger.error(`MAKER: exchange approval failed — ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Order helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function placeLimitBuy(tokenId, shares, price, tickSize, negRisk) {
|
||||
const size = Math.max(MIN_ORDER_SIZE, Math.floor(shares));
|
||||
if (config.dryRun) {
|
||||
return { success: true, orderId: `sim-buy-${Date.now()}-${tokenId.slice(-6)}`, size };
|
||||
}
|
||||
const client = getClient();
|
||||
try {
|
||||
const res = await client.createAndPostOrder(
|
||||
{ tokenID: tokenId, side: Side.BUY, price, size },
|
||||
{ tickSize, negRisk },
|
||||
OrderType.GTC,
|
||||
);
|
||||
if (!res?.success) return { success: false };
|
||||
return { success: true, orderId: res.orderID, size };
|
||||
} catch (err) {
|
||||
logger.error('MAKER limit buy error:', err.message);
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function placeLimitSellWithRetry(tokenId, shares, price, tickSize, negRisk, tag) {
|
||||
const size = Math.max(MIN_ORDER_SIZE, Math.floor(shares));
|
||||
if (config.dryRun) {
|
||||
return { success: true, orderId: `sim-sell-${Date.now()}-${tokenId.slice(-6)}` };
|
||||
}
|
||||
|
||||
const client = getClient();
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_SELL_RETRIES; attempt++) {
|
||||
try {
|
||||
const res = await client.createAndPostOrder(
|
||||
{ tokenID: tokenId, side: Side.SELL, price, size },
|
||||
{ tickSize, negRisk },
|
||||
OrderType.GTC,
|
||||
);
|
||||
if (res?.success) {
|
||||
return { success: true, orderId: res.orderID };
|
||||
}
|
||||
|
||||
const errMsg = res?.errorMsg || 'unknown';
|
||||
logger.warn(`MAKER${tag}: sell attempt ${attempt}/${MAX_SELL_RETRIES} failed: ${errMsg}`);
|
||||
} catch (err) {
|
||||
logger.warn(`MAKER${tag}: sell attempt ${attempt}/${MAX_SELL_RETRIES} error: ${err.message}`);
|
||||
}
|
||||
|
||||
if (attempt < MAX_SELL_RETRIES) {
|
||||
// Wait longer each retry — tokens might not have settled yet
|
||||
const delay = SELL_DELAY_MS * attempt;
|
||||
logger.info(`MAKER${tag}: waiting ${delay / 1000}s for on-chain settlement before retry...`);
|
||||
await sleep(delay);
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
async function cancelOrder(orderId) {
|
||||
if (config.dryRun || !orderId || orderId.startsWith('sim-')) return true;
|
||||
try {
|
||||
await getClient().cancelOrder({ orderID: orderId });
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.warn('MAKER cancel order error:', err.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Order status ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function getOrderFill(orderId) {
|
||||
if (!orderId || orderId.startsWith('sim-')) {
|
||||
return { matched: 0, status: 'LIVE', fullyFilled: false };
|
||||
}
|
||||
try {
|
||||
const order = await getClient().getOrder(orderId);
|
||||
if (!order) return { matched: 0, status: 'UNKNOWN', fullyFilled: false };
|
||||
const matched = parseFloat(order.size_matched || '0');
|
||||
return {
|
||||
matched,
|
||||
status: order.status,
|
||||
fullyFilled: order.status === 'MATCHED',
|
||||
};
|
||||
} catch {
|
||||
return { matched: 0, status: 'ERROR', fullyFilled: false };
|
||||
}
|
||||
}
|
||||
|
||||
// Simulation: check if market price would fill our order
|
||||
async function simCheckFill(tokenId, side, price) {
|
||||
try {
|
||||
const mp = await getClient().getMidpoint(tokenId);
|
||||
const midPrice = parseFloat(mp?.mid ?? mp ?? '0') || 0;
|
||||
if (side === 'buy' && midPrice <= price) return midPrice;
|
||||
if (side === 'sell' && midPrice >= price) return midPrice;
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Core strategy ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function executeMakerStrategy(market) {
|
||||
const { asset, duration, conditionId, question, endTime, yesTokenId, noTokenId, negRisk, tickSize } = market;
|
||||
const tag = asset ? `[${asset.toUpperCase()}/${duration || '5m'}]` : '';
|
||||
const label = question.substring(0, 40);
|
||||
const sim = config.dryRun ? '[SIM] ' : '';
|
||||
const { makerBuyPrice, makerSellPrice, makerTradeSize, makerMonitorMs } = config;
|
||||
|
||||
// Check sim balance
|
||||
const costPerSide = makerTradeSize * makerBuyPrice;
|
||||
if (config.dryRun && simStats.balance < costPerSide) {
|
||||
logger.warn(`MAKER${tag}: ${sim}insufficient sim balance $${simStats.balance.toFixed(2)} (need $${costPerSide.toFixed(2)}) — skipping`);
|
||||
recordTrade('skip', null, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`MAKER${tag}: ${sim}entering — ${label}`);
|
||||
logger.info(`MAKER${tag}: BUY @ $${makerBuyPrice} → SELL @ $${makerSellPrice} | ${makerTradeSize} shares/side | cost $${costPerSide.toFixed(2)}`);
|
||||
|
||||
// ── 0. Ensure ERC1155 exchange approval (one-time) ───────────
|
||||
await ensureApproval(negRisk);
|
||||
|
||||
// ── 1. Place BUY UP + DOWN concurrently ──────────────────────
|
||||
logger.trade(`MAKER${tag}: ${sim}placing BUY UP + DOWN @ $${makerBuyPrice}`);
|
||||
|
||||
const [upBuy, downBuy] = await Promise.all([
|
||||
placeLimitBuy(yesTokenId, makerTradeSize, makerBuyPrice, tickSize, negRisk),
|
||||
placeLimitBuy(noTokenId, makerTradeSize, makerBuyPrice, tickSize, negRisk),
|
||||
]);
|
||||
|
||||
if (!upBuy.success && !downBuy.success) {
|
||||
logger.error(`MAKER${tag}: both buy orders failed — aborting`);
|
||||
recordTrade('skip', null, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (upBuy.success) logger.trade(`MAKER${tag}: ${sim}UP BUY placed | order ${upBuy.orderId}`);
|
||||
if (downBuy.success) logger.trade(`MAKER${tag}: ${sim}DOWN BUY placed | order ${downBuy.orderId}`);
|
||||
|
||||
// ── 2. Build position state ──────────────────────────────────
|
||||
const pos = {
|
||||
asset: asset || 'btc',
|
||||
duration: duration || '5m',
|
||||
conditionId,
|
||||
question,
|
||||
endTime,
|
||||
tickSize,
|
||||
negRisk,
|
||||
status: 'buying',
|
||||
enteredAt: new Date().toISOString(),
|
||||
up: {
|
||||
tokenId: yesTokenId,
|
||||
buyOrderId: upBuy.success ? upBuy.orderId : null,
|
||||
buyFilled: 0,
|
||||
sellOrders: [],
|
||||
totalSellFilled: 0,
|
||||
cancelled: !upBuy.success,
|
||||
},
|
||||
down: {
|
||||
tokenId: noTokenId,
|
||||
buyOrderId: downBuy.success ? downBuy.orderId : null,
|
||||
buyFilled: 0,
|
||||
sellOrders: [],
|
||||
totalSellFilled: 0,
|
||||
cancelled: !downBuy.success,
|
||||
},
|
||||
winner: null,
|
||||
totalCost: 0,
|
||||
totalRevenue: 0,
|
||||
};
|
||||
|
||||
activePositions.set(conditionId, pos);
|
||||
|
||||
// ── 3. Monitor buy → sell (concurrent) ───────────────────────
|
||||
try {
|
||||
await monitorBuyPhase(pos, tag, sim);
|
||||
await monitorSellPhase(pos, tag, sim);
|
||||
} catch (err) {
|
||||
logger.error(`MAKER${tag}: strategy error — ${err.message}`);
|
||||
}
|
||||
|
||||
// ── Final result + sim stats ─────────────────────────────────
|
||||
const pnl = pos.totalRevenue - pos.totalCost;
|
||||
const winnerSide = pos.winner?.toUpperCase() || '-';
|
||||
|
||||
if (pos.status === 'done' && pos.totalRevenue > 0) {
|
||||
// WIN: sell filled
|
||||
recordTrade('win', winnerSide, pnl);
|
||||
logger.money(`MAKER${tag}: ${sim}WIN | ${winnerSide} | cost $${pos.totalCost.toFixed(4)} → revenue $${pos.totalRevenue.toFixed(4)} | P&L +$${pnl.toFixed(4)}`);
|
||||
} else if (pos.totalCost > 0) {
|
||||
// LOSS: buy filled but sell didn't fill (expired-holding)
|
||||
recordTrade('loss', winnerSide, -pos.totalCost);
|
||||
logger.warn(`MAKER${tag}: ${sim}LOSS | ${winnerSide} | cost $${pos.totalCost.toFixed(4)} (sell not filled, held to expiry)`);
|
||||
} else {
|
||||
// SKIP: nothing filled
|
||||
recordTrade('skip', null, 0);
|
||||
logger.info(`MAKER${tag}: ${sim}SKIP | no fills, $0 cost`);
|
||||
}
|
||||
|
||||
// Log running stats
|
||||
const s = simStats;
|
||||
const winRate = s.wins + s.losses > 0 ? ((s.wins / (s.wins + s.losses)) * 100).toFixed(1) : '0.0';
|
||||
logger.info(`MAKER${tag}: ${sim}STATS | W:${s.wins} L:${s.losses} S:${s.skips} | Win%: ${winRate}% | PnL: $${s.cumulativePnl.toFixed(4)} | Balance: $${s.balance.toFixed(2)}`);
|
||||
|
||||
activePositions.delete(conditionId);
|
||||
}
|
||||
|
||||
// ── Buy phase: monitor both sides concurrently ───────────────────────────────
|
||||
|
||||
async function monitorBuyPhase(pos, tag, sim) {
|
||||
const { makerBuyPrice, makerSellPrice, makerMonitorMs } = config;
|
||||
|
||||
const monitorSide = async (sideKey) => {
|
||||
const side = pos[sideKey];
|
||||
const otherKey = sideKey === 'up' ? 'down' : 'up';
|
||||
const sideName = sideKey.toUpperCase();
|
||||
|
||||
if (!side.buyOrderId) return;
|
||||
|
||||
while (!pos.winner) {
|
||||
const msLeft = new Date(pos.endTime).getTime() - Date.now();
|
||||
// CL: cancel unfilled buy at 10s before market close
|
||||
if (msLeft <= CL_SECONDS * 1000) {
|
||||
if (side.buyOrderId && !side.cancelled) {
|
||||
logger.warn(`MAKER${tag}: CL ${CL_SECONDS}s — cancelling ${sideName} buy`);
|
||||
await cancelOrder(side.buyOrderId);
|
||||
side.cancelled = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Check fill
|
||||
let fill;
|
||||
if (config.dryRun) {
|
||||
const hitPrice = await simCheckFill(side.tokenId, 'buy', makerBuyPrice);
|
||||
if (hitPrice !== null) {
|
||||
fill = { matched: config.makerTradeSize, fullyFilled: true };
|
||||
} else {
|
||||
fill = { matched: 0, fullyFilled: false };
|
||||
}
|
||||
} else {
|
||||
fill = await getOrderFill(side.buyOrderId);
|
||||
}
|
||||
|
||||
// New fills detected → wait for settlement, then place sell
|
||||
const newFill = fill.matched - side.buyFilled;
|
||||
if (newFill > 0) {
|
||||
side.buyFilled = fill.matched;
|
||||
pos.totalCost += newFill * makerBuyPrice;
|
||||
|
||||
logger.money(`MAKER${tag}: ${sim}${sideName} BUY filled ${newFill.toFixed(2)} shares @ $${makerBuyPrice} (total: ${side.buyFilled.toFixed(2)}/${config.makerTradeSize})`);
|
||||
|
||||
// Check total unsold shares — only place sell if >= MIN_ORDER_SIZE
|
||||
const soldShares = side.sellOrders.reduce((sum, so) => sum + so.shares, 0);
|
||||
const unsold = side.buyFilled - soldShares;
|
||||
|
||||
if (unsold >= MIN_ORDER_SIZE) {
|
||||
// Wait for on-chain token settlement before placing sell
|
||||
if (!config.dryRun) {
|
||||
logger.info(`MAKER${tag}: waiting ${SELL_DELAY_MS / 1000}s for on-chain settlement...`);
|
||||
await sleep(SELL_DELAY_MS);
|
||||
}
|
||||
|
||||
const sellResult = await placeLimitSellWithRetry(
|
||||
side.tokenId, unsold, makerSellPrice,
|
||||
pos.tickSize, pos.negRisk, tag,
|
||||
);
|
||||
if (sellResult.success) {
|
||||
side.sellOrders.push({
|
||||
orderId: sellResult.orderId,
|
||||
shares: unsold,
|
||||
filled: false,
|
||||
fillPrice: null,
|
||||
});
|
||||
logger.trade(`MAKER${tag}: ${sim}${sideName} SELL placed ${unsold.toFixed(2)} shares @ $${makerSellPrice}`);
|
||||
} else {
|
||||
logger.error(`MAKER${tag}: ${sideName} SELL failed after ${MAX_SELL_RETRIES} retries — tokens held to resolution`);
|
||||
}
|
||||
} else {
|
||||
logger.info(`MAKER${tag}: ${sideName} unsold ${unsold.toFixed(2)} shares < ${MIN_ORDER_SIZE} min — waiting for more fills`);
|
||||
}
|
||||
}
|
||||
|
||||
// Fully filled → winner
|
||||
if (fill.fullyFilled) {
|
||||
pos.winner = sideKey;
|
||||
pos.status = 'selling';
|
||||
logger.success(`MAKER${tag}: ${sim}${sideName} fully filled! Cancelling ${otherKey.toUpperCase()} buy...`);
|
||||
|
||||
const other = pos[otherKey];
|
||||
if (other.buyOrderId && !other.cancelled) {
|
||||
await cancelOrder(other.buyOrderId);
|
||||
other.cancelled = true;
|
||||
logger.info(`MAKER${tag}: ${otherKey.toUpperCase()} buy cancelled`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await sleep(makerMonitorMs);
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.race([
|
||||
monitorSide('up'),
|
||||
monitorSide('down'),
|
||||
]);
|
||||
|
||||
// Cleanup: cancel any remaining unfilled buy orders
|
||||
for (const key of ['up', 'down']) {
|
||||
const s = pos[key];
|
||||
if (s.buyOrderId && !s.cancelled) {
|
||||
await cancelOrder(s.buyOrderId);
|
||||
s.cancelled = true;
|
||||
logger.info(`MAKER${tag}: cancelled ${key.toUpperCase()} buy order`);
|
||||
}
|
||||
}
|
||||
|
||||
// Place sells for filled buys that don't have sell orders yet
|
||||
for (const key of ['up', 'down']) {
|
||||
const s = pos[key];
|
||||
const soldShares = s.sellOrders.reduce((sum, so) => sum + so.shares, 0);
|
||||
const unsold = s.buyFilled - soldShares;
|
||||
if (unsold >= MIN_ORDER_SIZE) {
|
||||
logger.info(`MAKER${tag}: placing sell for ${key.toUpperCase()} ${unsold.toFixed(2)} unsold shares`);
|
||||
if (!config.dryRun) {
|
||||
logger.info(`MAKER${tag}: waiting ${SELL_DELAY_MS / 1000}s for on-chain settlement...`);
|
||||
await sleep(SELL_DELAY_MS);
|
||||
}
|
||||
const sellResult = await placeLimitSellWithRetry(
|
||||
s.tokenId, unsold, makerSellPrice,
|
||||
pos.tickSize, pos.negRisk, tag,
|
||||
);
|
||||
if (sellResult.success) {
|
||||
s.sellOrders.push({
|
||||
orderId: sellResult.orderId,
|
||||
shares: unsold,
|
||||
filled: false,
|
||||
fillPrice: null,
|
||||
});
|
||||
logger.trade(`MAKER${tag}: ${sim}${key.toUpperCase()} SELL placed ${unsold.toFixed(2)} shares @ $${makerSellPrice}`);
|
||||
} else {
|
||||
logger.error(`MAKER${tag}: ${key.toUpperCase()} SELL failed after ${MAX_SELL_RETRIES} retries — tokens held to resolution`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!pos.winner) {
|
||||
const anyFill = pos.up.buyFilled > 0 || pos.down.buyFilled > 0;
|
||||
if (anyFill) {
|
||||
pos.winner = pos.up.buyFilled >= pos.down.buyFilled ? 'up' : 'down';
|
||||
pos.status = 'selling';
|
||||
logger.info(`MAKER${tag}: partial fill — monitoring sell for ${pos.winner.toUpperCase()} ${pos[pos.winner].buyFilled.toFixed(2)} shares`);
|
||||
} else {
|
||||
pos.status = 'done';
|
||||
logger.info(`MAKER${tag}: no fills — buy orders expired naturally, $0 loss`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sell phase: monitor sell orders until market close ────────────────────────
|
||||
|
||||
async function monitorSellPhase(pos, tag, sim) {
|
||||
if (pos.status === 'done') return;
|
||||
|
||||
const { makerSellPrice, makerMonitorMs } = config;
|
||||
const winnerKey = pos.winner;
|
||||
if (!winnerKey) return;
|
||||
|
||||
const side = pos[winnerKey];
|
||||
const sideName = winnerKey.toUpperCase();
|
||||
|
||||
if (side.sellOrders.length === 0) {
|
||||
// Sell placement failed — tokens held to resolution
|
||||
pos.status = 'expired-holding';
|
||||
logger.warn(`MAKER${tag}: no sell orders placed — held to resolution`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`MAKER${tag}: monitoring ${side.sellOrders.length} sell order(s) for ${sideName}`);
|
||||
|
||||
while (true) {
|
||||
const msLeft = new Date(pos.endTime).getTime() - Date.now();
|
||||
|
||||
// Market closed — unfilled sells resolve on-chain
|
||||
if (msLeft <= 0) {
|
||||
pos.status = side.totalSellFilled > 0 ? 'done' : 'expired-holding';
|
||||
if (pos.status === 'expired-holding') {
|
||||
logger.warn(`MAKER${tag}: market closed — unfilled sells held to resolution`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Check all sell orders concurrently
|
||||
const checks = await Promise.all(
|
||||
side.sellOrders.map(async (so) => {
|
||||
if (so.filled) return true;
|
||||
|
||||
let filled = false;
|
||||
if (config.dryRun) {
|
||||
const hitPrice = await simCheckFill(side.tokenId, 'sell', makerSellPrice);
|
||||
if (hitPrice !== null) {
|
||||
filled = true;
|
||||
so.fillPrice = hitPrice;
|
||||
}
|
||||
} else {
|
||||
const fill = await getOrderFill(so.orderId);
|
||||
if (fill.fullyFilled || fill.matched >= so.shares * 0.99) {
|
||||
filled = true;
|
||||
so.fillPrice = makerSellPrice;
|
||||
}
|
||||
}
|
||||
|
||||
if (filled) {
|
||||
so.filled = true;
|
||||
side.totalSellFilled += so.shares;
|
||||
pos.totalRevenue += so.shares * (so.fillPrice || makerSellPrice);
|
||||
logger.money(`MAKER${tag}: ${sim}${sideName} SELL filled ${so.shares.toFixed(2)} shares @ $${(so.fillPrice || makerSellPrice).toFixed(3)}`);
|
||||
}
|
||||
|
||||
return so.filled;
|
||||
})
|
||||
);
|
||||
|
||||
// All sells filled → done
|
||||
if (checks.every(Boolean) && side.sellOrders.length > 0) {
|
||||
pos.status = 'done';
|
||||
logger.success(`MAKER${tag}: ${sim}all sells filled!`);
|
||||
break;
|
||||
}
|
||||
|
||||
await sleep(makerMonitorMs);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* makerWs.js
|
||||
* WebSocket client for Polymarket CLOB orderbook + trade data.
|
||||
* Used by the simulation to display real-time orderbook and simulate fills.
|
||||
*
|
||||
* Message formats from CLOB WS:
|
||||
* 1. Book snapshot (initial): [{asset_id, bids, asks, timestamp, hash}] (array, no event_type)
|
||||
* 2. price_change: {event_type:"price_change", price_changes:[{asset_id, price, size, side, best_bid, best_ask}]}
|
||||
* 3. last_trade_price: {event_type:"last_trade_price", asset_id, price}
|
||||
*/
|
||||
|
||||
import WebSocket from 'ws';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
const WS_URL = 'wss://ws-subscriptions-clob.polymarket.com/ws/market';
|
||||
const PING_INTERVAL = 30_000;
|
||||
const RECONNECT_DELAY = 3000;
|
||||
const MAX_RECONNECT_DELAY = 30_000;
|
||||
|
||||
export class OrderbookWs {
|
||||
constructor() {
|
||||
this.ws = null;
|
||||
this.pingTimer = null;
|
||||
this.reconnectTimer = null;
|
||||
this.reconnectDelay = RECONNECT_DELAY;
|
||||
this.isShutdown = false;
|
||||
|
||||
// Subscribed assets
|
||||
this.assetIds = [];
|
||||
this.conditionId = null;
|
||||
|
||||
// Orderbook state per asset: Map<price, size>
|
||||
this.bids = new Map(); // assetId → Map<price, size>
|
||||
this.asks = new Map(); // assetId → Map<price, size>
|
||||
|
||||
// Best bid/ask per asset (from price_change events)
|
||||
this.bestBid = new Map(); // assetId → number
|
||||
this.bestAsk = new Map(); // assetId → number
|
||||
|
||||
// Recent trades per asset
|
||||
this.trades = new Map(); // assetId → [{ price, side, size, timestamp }]
|
||||
|
||||
// Last trade price per asset
|
||||
this.lastPrice = new Map(); // assetId → number
|
||||
|
||||
// Callbacks
|
||||
this.onBookUpdate = null;
|
||||
this.onTradeUpdate = null;
|
||||
this.onPriceUpdate = null;
|
||||
}
|
||||
|
||||
subscribe(conditionId, assetIds) {
|
||||
// Shutdown existing connection if any
|
||||
if (this.ws) {
|
||||
this.cleanup(false);
|
||||
}
|
||||
|
||||
this.conditionId = conditionId;
|
||||
this.assetIds = assetIds;
|
||||
this.isShutdown = false;
|
||||
|
||||
for (const id of assetIds) {
|
||||
this.bids.set(id, new Map());
|
||||
this.asks.set(id, new Map());
|
||||
this.trades.set(id, []);
|
||||
}
|
||||
|
||||
this.connect();
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (this.isShutdown) return;
|
||||
|
||||
this.ws = new WebSocket(WS_URL);
|
||||
|
||||
this.ws.on('open', () => {
|
||||
logger.info('MAKER WS: connected to orderbook feed');
|
||||
this.reconnectDelay = RECONNECT_DELAY;
|
||||
|
||||
// Subscribe to all assets in one message
|
||||
const msg = {
|
||||
auth: {},
|
||||
type: 'subscribe',
|
||||
markets: [],
|
||||
assets_ids: this.assetIds,
|
||||
channels: ['book'],
|
||||
};
|
||||
this.ws.send(JSON.stringify(msg));
|
||||
|
||||
this.startPing();
|
||||
});
|
||||
|
||||
this.ws.on('message', (raw) => {
|
||||
this.handleMessage(raw);
|
||||
});
|
||||
|
||||
this.ws.on('ping', () => {
|
||||
this.ws?.pong();
|
||||
});
|
||||
|
||||
this.ws.on('close', (code, reason) => {
|
||||
logger.warn(`MAKER WS: disconnected (${code})`);
|
||||
this.cleanup(true);
|
||||
});
|
||||
|
||||
this.ws.on('error', (err) => {
|
||||
logger.warn(`MAKER WS error: ${err.message}`);
|
||||
this.cleanup(true);
|
||||
});
|
||||
}
|
||||
|
||||
handleMessage(raw) {
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(raw.toString());
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
// Initial book snapshot comes as an array [{...}]
|
||||
if (Array.isArray(msg)) {
|
||||
for (const evt of msg) {
|
||||
if (evt.asset_id && evt.bids) {
|
||||
this.handleBookSnapshot(evt.asset_id, evt);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Subsequent messages are objects with event_type
|
||||
switch (msg.event_type) {
|
||||
case 'book':
|
||||
if (msg.asset_id) {
|
||||
this.handleBookSnapshot(msg.asset_id, msg);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'price_change':
|
||||
this.handlePriceChange(msg);
|
||||
break;
|
||||
|
||||
case 'last_trade_price':
|
||||
if (msg.asset_id) {
|
||||
this.handleLastPrice(msg.asset_id, msg);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// Full book updates without event_type (non-array single object)
|
||||
if (msg.asset_id && msg.bids) {
|
||||
this.handleBookSnapshot(msg.asset_id, msg);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
handleBookSnapshot(assetId, evt) {
|
||||
if (!this.assetIds.includes(assetId)) return;
|
||||
|
||||
// Replace entire book for this asset
|
||||
const bidMap = new Map();
|
||||
for (const b of (evt.bids || [])) {
|
||||
const price = parseFloat(b.price);
|
||||
const size = parseFloat(b.size);
|
||||
if (size > 0) bidMap.set(price, size);
|
||||
}
|
||||
this.bids.set(assetId, bidMap);
|
||||
|
||||
const askMap = new Map();
|
||||
for (const a of (evt.asks || [])) {
|
||||
const price = parseFloat(a.price);
|
||||
const size = parseFloat(a.size);
|
||||
if (size > 0) askMap.set(price, size);
|
||||
}
|
||||
this.asks.set(assetId, askMap);
|
||||
|
||||
if (this.onBookUpdate) {
|
||||
this.onBookUpdate(assetId, this.getBook(assetId));
|
||||
}
|
||||
}
|
||||
|
||||
handlePriceChange(msg) {
|
||||
const changes = msg.price_changes || [];
|
||||
|
||||
for (const change of changes) {
|
||||
const assetId = change.asset_id;
|
||||
if (!assetId || !this.assetIds.includes(assetId)) continue;
|
||||
|
||||
const price = parseFloat(change.price);
|
||||
const size = parseFloat(change.size);
|
||||
const side = change.side; // "BUY" = bid, "SELL" = ask
|
||||
|
||||
if (side === 'BUY') {
|
||||
const bidMap = this.bids.get(assetId);
|
||||
if (bidMap) {
|
||||
if (size > 0) {
|
||||
bidMap.set(price, size);
|
||||
} else {
|
||||
bidMap.delete(price); // size 0 = remove level
|
||||
}
|
||||
}
|
||||
} else if (side === 'SELL') {
|
||||
const askMap = this.asks.get(assetId);
|
||||
if (askMap) {
|
||||
if (size > 0) {
|
||||
askMap.set(price, size);
|
||||
} else {
|
||||
askMap.delete(price);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update best bid/ask from the event
|
||||
if (change.best_bid) this.bestBid.set(assetId, parseFloat(change.best_bid));
|
||||
if (change.best_ask) this.bestAsk.set(assetId, parseFloat(change.best_ask));
|
||||
}
|
||||
|
||||
// Notify for each affected asset
|
||||
const affectedAssets = new Set(changes.map(c => c.asset_id).filter(id => this.assetIds.includes(id)));
|
||||
for (const assetId of affectedAssets) {
|
||||
if (this.onBookUpdate) {
|
||||
this.onBookUpdate(assetId, this.getBook(assetId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleLastPrice(assetId, evt) {
|
||||
if (!this.assetIds.includes(assetId)) return;
|
||||
const price = parseFloat(evt.price || '0');
|
||||
this.lastPrice.set(assetId, price);
|
||||
|
||||
if (this.onPriceUpdate) {
|
||||
this.onPriceUpdate(assetId, price);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sorted orderbook for an asset
|
||||
*/
|
||||
getBook(assetId) {
|
||||
const bidMap = this.bids.get(assetId) || new Map();
|
||||
const askMap = this.asks.get(assetId) || new Map();
|
||||
|
||||
const bids = Array.from(bidMap.entries())
|
||||
.map(([price, size]) => ({ price, size }))
|
||||
.sort((a, b) => b.price - a.price);
|
||||
|
||||
const asks = Array.from(askMap.entries())
|
||||
.map(([price, size]) => ({ price, size }))
|
||||
.sort((a, b) => a.price - b.price);
|
||||
|
||||
return { bids, asks };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a simulated order would fill based on current orderbook.
|
||||
*/
|
||||
checkSimFill(assetId, side, price, size) {
|
||||
const book = this.getBook(assetId);
|
||||
|
||||
if (side === 'buy') {
|
||||
const eligible = book.asks.filter((a) => a.price <= price);
|
||||
if (eligible.length === 0) return null;
|
||||
|
||||
let filled = 0;
|
||||
let totalCost = 0;
|
||||
for (const ask of eligible) {
|
||||
const take = Math.min(ask.size, size - filled);
|
||||
filled += take;
|
||||
totalCost += take * ask.price;
|
||||
if (filled >= size) break;
|
||||
}
|
||||
|
||||
if (filled > 0) {
|
||||
return { filled: Math.min(filled, size), avgPrice: totalCost / filled };
|
||||
}
|
||||
} else {
|
||||
const eligible = book.bids.filter((b) => b.price >= price);
|
||||
if (eligible.length === 0) return null;
|
||||
|
||||
let filled = 0;
|
||||
let totalRevenue = 0;
|
||||
for (const bid of eligible) {
|
||||
const take = Math.min(bid.size, size - filled);
|
||||
filled += take;
|
||||
totalRevenue += take * bid.price;
|
||||
if (filled >= size) break;
|
||||
}
|
||||
|
||||
if (filled > 0) {
|
||||
return { filled: Math.min(filled, size), avgPrice: totalRevenue / filled };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
getLastPrice(assetId) {
|
||||
return this.lastPrice.get(assetId) || 0;
|
||||
}
|
||||
|
||||
getBestBid(assetId) {
|
||||
// Try from price_change data first, fallback to computed from book
|
||||
const cached = this.bestBid.get(assetId);
|
||||
if (cached) return cached;
|
||||
const book = this.getBook(assetId);
|
||||
return book.bids[0]?.price || 0;
|
||||
}
|
||||
|
||||
getBestAsk(assetId) {
|
||||
const cached = this.bestAsk.get(assetId);
|
||||
if (cached) return cached;
|
||||
const book = this.getBook(assetId);
|
||||
return book.asks[0]?.price || 0;
|
||||
}
|
||||
|
||||
getRecentTrades(assetId, limit = 10) {
|
||||
const trades = this.trades.get(assetId) || [];
|
||||
return trades.slice(-limit);
|
||||
}
|
||||
|
||||
startPing() {
|
||||
this.stopPing();
|
||||
this.pingTimer = setInterval(() => {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.ping(); // Use proper WebSocket ping frames
|
||||
}
|
||||
}, PING_INTERVAL);
|
||||
}
|
||||
|
||||
stopPing() {
|
||||
if (this.pingTimer) {
|
||||
clearInterval(this.pingTimer);
|
||||
this.pingTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
cleanup(reconnect = true) {
|
||||
this.stopPing();
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
if (this.ws) {
|
||||
this.ws.removeAllListeners();
|
||||
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
|
||||
this.ws.terminate();
|
||||
}
|
||||
this.ws = null;
|
||||
}
|
||||
if (reconnect && !this.isShutdown) {
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_RECONNECT_DELAY);
|
||||
this.connect();
|
||||
}, this.reconnectDelay);
|
||||
}
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
this.isShutdown = true;
|
||||
this.cleanup(false);
|
||||
}
|
||||
}
|
||||
+13
-12
@@ -12,13 +12,14 @@
|
||||
|
||||
import config from '../config/index.js';
|
||||
import logger from '../utils/logger.js';
|
||||
import { proxyFetch } from '../utils/proxy.js';
|
||||
|
||||
// Slot size in seconds (300 for 5m, 900 for 15m)
|
||||
const SLOT_SEC = config.mmDuration === '15m' ? 900 : 300;
|
||||
|
||||
let pollTimer = null;
|
||||
let onMarketCb = null;
|
||||
const seenKeys = new Set(); // `${asset}-${slotTimestamp}` already scheduled
|
||||
let pollTimer = null;
|
||||
let onMarketCb = null;
|
||||
const seenKeys = new Set(); // `${asset}-${slotTimestamp}` already scheduled
|
||||
|
||||
// ── Slot helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -35,7 +36,7 @@ function nextSlot() {
|
||||
async function fetchBySlug(asset, slotTimestamp) {
|
||||
const slug = `${asset}-updown-${config.mmDuration}-${slotTimestamp}`;
|
||||
try {
|
||||
const resp = await fetch(`${config.gammaHost}/markets/slug/${slug}`);
|
||||
const resp = await proxyFetch(`${config.gammaHost}/markets/slug/${slug}`);
|
||||
if (!resp.ok) return null;
|
||||
const data = await resp.json();
|
||||
return data?.conditionId ? data : null;
|
||||
@@ -61,7 +62,7 @@ function extractMarketData(market, asset) {
|
||||
[yesTokenId, noTokenId] = tokenIds;
|
||||
} else if (Array.isArray(market.tokens) && market.tokens.length >= 2) {
|
||||
yesTokenId = market.tokens[0]?.token_id ?? market.tokens[0]?.tokenId;
|
||||
noTokenId = market.tokens[1]?.token_id ?? market.tokens[1]?.tokenId;
|
||||
noTokenId = market.tokens[1]?.token_id ?? market.tokens[1]?.tokenId;
|
||||
}
|
||||
|
||||
if (!yesTokenId || !noTokenId) return null;
|
||||
@@ -69,13 +70,13 @@ function extractMarketData(market, asset) {
|
||||
return {
|
||||
asset,
|
||||
conditionId,
|
||||
question: market.question || market.title || '',
|
||||
endTime: market.endDate || market.end_date_iso || market.endDateIso,
|
||||
question: market.question || market.title || '',
|
||||
endTime: market.endDate || market.end_date_iso || market.endDateIso,
|
||||
eventStartTime: market.eventStartTime || market.event_start_time,
|
||||
yesTokenId: String(yesTokenId),
|
||||
noTokenId: String(noTokenId),
|
||||
negRisk: market.negRisk ?? market.neg_risk ?? false,
|
||||
tickSize: String(market.orderPriceMinTickSize ?? market.minimum_tick_size ?? market.minimumTickSize ?? '0.01'),
|
||||
yesTokenId: String(yesTokenId),
|
||||
noTokenId: String(noTokenId),
|
||||
negRisk: market.negRisk ?? market.neg_risk ?? false,
|
||||
tickSize: String(market.orderPriceMinTickSize ?? market.minimum_tick_size ?? market.minimumTickSize ?? '0.01'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -98,7 +99,7 @@ async function scheduleAsset(asset, slotTimestamp) {
|
||||
seenKeys.add(key);
|
||||
|
||||
// Refuse to enter a market already well into its window (e.g., bot restart mid-slot)
|
||||
const openAt = data.eventStartTime ? new Date(data.eventStartTime).getTime() : slotTimestamp * 1000;
|
||||
const openAt = data.eventStartTime ? new Date(data.eventStartTime).getTime() : slotTimestamp * 1000;
|
||||
const elapsedSec = Math.round((Date.now() - openAt) / 1000);
|
||||
if (elapsedSec > 15) {
|
||||
logger.info(`MM: ${asset.toUpperCase()} next slot already ${elapsedSec}s old — skipping, will catch next`);
|
||||
|
||||
+220
-62
@@ -133,6 +133,14 @@ async function simPriceHitTarget(tokenId) {
|
||||
}
|
||||
}
|
||||
|
||||
// Get current mid price for a token (0 on error)
|
||||
async function getMidprice(tokenId) {
|
||||
try {
|
||||
const mp = await getClient().getMidpoint(tokenId);
|
||||
return parseFloat(mp?.mid ?? mp ?? '0') || 0;
|
||||
} catch { return 0; }
|
||||
}
|
||||
|
||||
// ── Core monitoring loop ──────────────────────────────────────────────────────
|
||||
|
||||
async function monitorAndManage(pos) {
|
||||
@@ -189,11 +197,27 @@ async function monitorAndManage(pos) {
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Cut-loss time ───────────────────────────────────────
|
||||
// ── Exactly one leg filled → adaptive cut-loss (if enabled) ────────
|
||||
if (config.mmAdaptiveCL && pos.yes.filled !== pos.no.filled) {
|
||||
const unfilledKey = pos.yes.filled ? 'no' : 'yes';
|
||||
await adaptiveLegCL(pos, unfilledKey);
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Cut-loss time ────────────────────────────────────────────────────
|
||||
if (msRemaining <= config.mmCutLossTime * 1000) {
|
||||
logger.warn(`MM: cut-loss triggered (${Math.round(msRemaining / 1000)}s left) — ${label}`);
|
||||
pos.status = 'cutting';
|
||||
await cutLoss(pos);
|
||||
const oneLegFilled = pos.yes.filled !== pos.no.filled;
|
||||
if (!config.mmAdaptiveCL && oneLegFilled) {
|
||||
// Legacy: one side sold → immediate market sell on the other
|
||||
const unfilledKey = pos.yes.filled ? 'no' : 'yes';
|
||||
await cutLossOneLegFilled(pos, unfilledKey);
|
||||
pos.status = 'done';
|
||||
} else {
|
||||
// Neither filled → cancel both + merge back to USDC
|
||||
await cutLossNeitherFilled(pos);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -208,77 +232,211 @@ async function monitorAndManage(pos) {
|
||||
}
|
||||
}
|
||||
|
||||
async function cutLoss(pos) {
|
||||
const { conditionId, tickSize, negRisk } = pos;
|
||||
const neitherFilled = !pos.yes.filled && !pos.no.filled;
|
||||
// Legacy one-leg CL: cancel unfilled order, immediate market sell (no patience)
|
||||
async function cutLossOneLegFilled(pos, unfilledKey) {
|
||||
const s = pos[unfilledKey];
|
||||
const { tickSize, negRisk } = pos;
|
||||
|
||||
if (neitherFilled) {
|
||||
// ── Best case: neither side sold → cancel both, merge back to USDC ──
|
||||
logger.warn('MM: neither side filled — cancelling orders and merging back to USDC...');
|
||||
await cancelOrder(pos.yes.orderId);
|
||||
await cancelOrder(pos.no.orderId);
|
||||
logger.warn(`MM: cancelling ${unfilledKey.toUpperCase()} limit order and market-selling...`);
|
||||
await cancelOrder(s.orderId);
|
||||
|
||||
// Read actual on-chain balances (may differ from original if partially consumed)
|
||||
const [yesActual, noActual] = await Promise.all([
|
||||
getTokenBalance(pos.yes.tokenId),
|
||||
getTokenBalance(pos.no.tokenId),
|
||||
]);
|
||||
const actualShares = await getTokenBalance(s.tokenId);
|
||||
const sellShares = actualShares !== null ? actualShares : s.shares;
|
||||
|
||||
// mergePositions needs equal amounts — use the minimum actual balance
|
||||
const yesShares = yesActual ?? pos.yes.shares;
|
||||
const noShares = noActual ?? pos.no.shares;
|
||||
const mergeAmt = Math.min(yesShares, noShares);
|
||||
|
||||
if (mergeAmt < 0.001) {
|
||||
logger.warn('MM: balances too low to merge — nothing to recover');
|
||||
} else {
|
||||
const recovered = await mergePositions(conditionId, mergeAmt);
|
||||
logger.money(`MM: merge complete — recovered ~$${recovered.toFixed ? recovered.toFixed(2) : recovered} USDC (P&L ≈ $0)`);
|
||||
}
|
||||
|
||||
// Mark both sides closed at entry price
|
||||
pos.yes.fillPrice = pos.yes.entryPrice;
|
||||
pos.yes.filled = true;
|
||||
pos.no.fillPrice = pos.no.entryPrice;
|
||||
pos.no.filled = true;
|
||||
|
||||
} else {
|
||||
// ── One side already (partly) sold → market-sell the unfilled side ──
|
||||
for (const side of ['yes', 'no']) {
|
||||
const s = pos[side];
|
||||
if (s.filled) continue;
|
||||
|
||||
logger.warn(`MM: cancelling ${side.toUpperCase()} limit order and market-selling...`);
|
||||
await cancelOrder(s.orderId);
|
||||
|
||||
// Fetch actual on-chain balance — partial fills reduce this below s.shares
|
||||
const actualShares = await getTokenBalance(s.tokenId);
|
||||
const sellShares = actualShares !== null ? actualShares : s.shares;
|
||||
|
||||
if (sellShares < 0.001) {
|
||||
logger.warn(`MM: ${side.toUpperCase()} balance is 0 — already fully sold via partial fills`);
|
||||
s.fillPrice = config.mmSellPrice; // assume sold at target
|
||||
s.filled = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.warn(`MM: ${side.toUpperCase()} actual balance: ${sellShares.toFixed(3)} shares (original: ${s.shares})`);
|
||||
|
||||
const result = await marketSell(s.tokenId, sellShares, tickSize, negRisk);
|
||||
s.fillPrice = result.fillPrice;
|
||||
s.filled = true;
|
||||
// PnL uses actual sold amount (not original pos.shares)
|
||||
const pnl = (s.fillPrice - s.entryPrice) * sellShares;
|
||||
logger.warn(`MM: ${side.toUpperCase()} cut @ $${s.fillPrice.toFixed(3)} | sold ${sellShares.toFixed(3)} sh | P&L $${pnl.toFixed(2)}`);
|
||||
}
|
||||
if (sellShares < 0.001) {
|
||||
logger.warn(`MM: ${unfilledKey.toUpperCase()} balance is 0 — already fully sold via partial fills`);
|
||||
s.fillPrice = config.mmSellPrice;
|
||||
s.filled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn(`MM: ${unfilledKey.toUpperCase()} actual balance: ${sellShares.toFixed(3)} shares (original: ${s.shares})`);
|
||||
const result = await marketSell(s.tokenId, sellShares, tickSize, negRisk);
|
||||
s.fillPrice = result.fillPrice;
|
||||
s.filled = true;
|
||||
const pnl = (s.fillPrice - s.entryPrice) * sellShares;
|
||||
logger.warn(`MM: ${unfilledKey.toUpperCase()} cut @ $${s.fillPrice.toFixed(3)} | sold ${sellShares.toFixed(3)} sh | P&L $${pnl.toFixed(2)}`);
|
||||
}
|
||||
|
||||
async function cutLossNeitherFilled(pos) {
|
||||
const { conditionId } = pos;
|
||||
|
||||
// ── Best case: neither side sold → cancel both, merge back to USDC ──
|
||||
logger.warn('MM: neither side filled — cancelling orders and merging back to USDC...');
|
||||
await cancelOrder(pos.yes.orderId);
|
||||
await cancelOrder(pos.no.orderId);
|
||||
|
||||
// Read actual on-chain balances (may differ from original if partially consumed)
|
||||
const [yesActual, noActual] = await Promise.all([
|
||||
getTokenBalance(pos.yes.tokenId),
|
||||
getTokenBalance(pos.no.tokenId),
|
||||
]);
|
||||
|
||||
// mergePositions needs equal amounts — use the minimum actual balance
|
||||
const yesShares = yesActual ?? pos.yes.shares;
|
||||
const noShares = noActual ?? pos.no.shares;
|
||||
const mergeAmt = Math.min(yesShares, noShares);
|
||||
|
||||
if (mergeAmt < 0.001) {
|
||||
logger.warn('MM: balances too low to merge — nothing to recover');
|
||||
} else {
|
||||
const recovered = await mergePositions(conditionId, mergeAmt);
|
||||
logger.money(`MM: merge complete — recovered ~$${recovered.toFixed ? recovered.toFixed(2) : recovered} USDC (P&L ≈ $0)`);
|
||||
}
|
||||
|
||||
// Mark both sides closed at entry price
|
||||
pos.yes.fillPrice = pos.yes.entryPrice;
|
||||
pos.yes.filled = true;
|
||||
pos.no.fillPrice = pos.no.entryPrice;
|
||||
pos.no.filled = true;
|
||||
|
||||
pos.status = 'done';
|
||||
|
||||
// Optional recovery buy (enabled via MM_RECOVERY_BUY=true)
|
||||
await attemptRecoveryBuy(pos);
|
||||
}
|
||||
|
||||
async function adaptiveLegCL(pos, unfilledKey) {
|
||||
const s = pos[unfilledKey];
|
||||
const { tickSize, negRisk } = pos;
|
||||
const label = pos.question.substring(0, 40);
|
||||
const pollMs = config.mmAdaptiveMonitorSec * 1000;
|
||||
|
||||
// ── Minimum floor: unfilled leg must sell at least this price ──────────────
|
||||
// Ensures: filledLegPrice + unfilledLegPrice >= mmAdaptiveMinCombined
|
||||
// Example: filledLeg=0.60, minCombined=1.20 → floor=0.60
|
||||
// filledLeg=0.55, minCombined=1.20 → floor=0.65
|
||||
const filledKey = unfilledKey === 'yes' ? 'no' : 'yes';
|
||||
const filledLegPrice = pos[filledKey].fillPrice ?? config.mmSellPrice;
|
||||
const minAdaptivePrice = Math.max(0, config.mmAdaptiveMinCombined - filledLegPrice);
|
||||
|
||||
logger.warn(`MM: one leg filled — starting adaptive CL for ${unfilledKey.toUpperCase()} | ${label}`);
|
||||
logger.info(`MM adaptive CL: filled leg @ $${filledLegPrice.toFixed(3)} | min floor for combined ≥ $${config.mmAdaptiveMinCombined.toFixed(2)}: $${minAdaptivePrice.toFixed(3)}`);
|
||||
|
||||
// Cancel the unfilled leg's old GTC order immediately
|
||||
await cancelOrder(s.orderId);
|
||||
s.orderId = null;
|
||||
|
||||
// Read actual on-chain balance once — reused for all subsequent sell orders
|
||||
const actualShares = await getTokenBalance(s.tokenId);
|
||||
const sellShares = actualShares !== null ? actualShares : s.shares;
|
||||
|
||||
if (sellShares < 0.001) {
|
||||
logger.warn(`MM adaptive CL: ${unfilledKey.toUpperCase()} balance is 0 — already fully sold`);
|
||||
s.fillPrice = config.mmSellPrice;
|
||||
s.filled = true;
|
||||
pos.status = 'done';
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`MM adaptive CL: monitoring ${unfilledKey.toUpperCase()} — limit only when price ≥ $${minAdaptivePrice.toFixed(3)}, market-sell only at CL time`);
|
||||
|
||||
let activeOrderId = null;
|
||||
let activeLimitPrice = 0;
|
||||
|
||||
// ── Continuous monitoring loop ─────────────────────────────────────────────
|
||||
// Every poll cycle:
|
||||
// 1. CL time → cancel limit, market sell (last resort)
|
||||
// 2. Check fill → done
|
||||
// 3. Read current price
|
||||
// 4a. Price < floor OR dropped >5% → cancel limit, keep watching
|
||||
// 4b. Price improved >2% → cancel and re-place higher
|
||||
// 5a. price >= floor → place/maintain limit at min(currentPrice, mmSellPrice)
|
||||
// 5b. price < floor → no limit placed, log & wait (never sell below profit floor)
|
||||
while (true) {
|
||||
const msLeft = new Date(pos.endTime).getTime() - Date.now();
|
||||
|
||||
// ── CL time: last resort market sell ───────────────────────────────
|
||||
if (msLeft <= config.mmCutLossTime * 1000) {
|
||||
if (activeOrderId) {
|
||||
await cancelOrder(activeOrderId);
|
||||
activeOrderId = null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Check fill ──────────────────────────────────────────────────────
|
||||
if (activeOrderId) {
|
||||
let filled = false;
|
||||
if (config.dryRun) {
|
||||
const hitPrice = await simPriceHitTarget(s.tokenId);
|
||||
if (hitPrice) { filled = true; s.fillPrice = hitPrice; }
|
||||
} else {
|
||||
filled = await isOrderFilled(activeOrderId, sellShares);
|
||||
if (filled) s.fillPrice = activeLimitPrice;
|
||||
}
|
||||
|
||||
if (filled) {
|
||||
const pnl = (s.fillPrice - s.entryPrice) * sellShares;
|
||||
const combined = filledLegPrice + s.fillPrice;
|
||||
logger.money(`MM adaptive CL: ${unfilledKey.toUpperCase()} limit filled @ $${s.fillPrice.toFixed(3)} | combined $${combined.toFixed(3)} | P&L $${pnl.toFixed(2)}`);
|
||||
s.filled = true;
|
||||
pos.status = 'done';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Read current price ──────────────────────────────────────────────
|
||||
const currentPrice = await getMidprice(s.tokenId);
|
||||
if (currentPrice <= 0) {
|
||||
await sleep(pollMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetPrice = Math.min(currentPrice, config.mmSellPrice);
|
||||
|
||||
// ── Adjust or cancel active limit ───────────────────────────────────
|
||||
if (activeOrderId) {
|
||||
const belowFloor = currentPrice < minAdaptivePrice;
|
||||
const droppedHard = currentPrice < activeLimitPrice * 0.95;
|
||||
const priceImproved = targetPrice > activeLimitPrice * 1.02;
|
||||
|
||||
if (belowFloor || droppedHard) {
|
||||
const reason = belowFloor
|
||||
? `below floor $${minAdaptivePrice.toFixed(3)} (combined $${(filledLegPrice + currentPrice).toFixed(3)} < $${config.mmAdaptiveMinCombined.toFixed(2)})`
|
||||
: `dropped >5% from limit $${activeLimitPrice.toFixed(3)}`;
|
||||
logger.info(`MM adaptive CL: price $${currentPrice.toFixed(3)} ${reason} — cancelling limit, watching for recovery`);
|
||||
await cancelOrder(activeOrderId);
|
||||
activeOrderId = null;
|
||||
activeLimitPrice = 0;
|
||||
|
||||
} else if (priceImproved) {
|
||||
logger.info(`MM adaptive CL: price improved $${activeLimitPrice.toFixed(3)} → $${currentPrice.toFixed(3)} — raising limit to $${targetPrice.toFixed(3)}`);
|
||||
await cancelOrder(activeOrderId);
|
||||
activeOrderId = null;
|
||||
activeLimitPrice = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Place limit only above the profitable floor ─────────────────────
|
||||
if (!activeOrderId) {
|
||||
if (currentPrice >= minAdaptivePrice) {
|
||||
logger.info(`MM adaptive CL: placing limit sell @ $${targetPrice.toFixed(3)} (mid: $${currentPrice.toFixed(3)}, combined: $${(filledLegPrice + targetPrice).toFixed(3)}, ${Math.round(msLeft / 1000)}s left)`);
|
||||
const result = await placeLimitSell(s.tokenId, sellShares, targetPrice, tickSize, negRisk);
|
||||
if (result.success) {
|
||||
activeOrderId = result.orderId;
|
||||
activeLimitPrice = targetPrice;
|
||||
}
|
||||
} else {
|
||||
logger.info(`MM adaptive CL: price $${currentPrice.toFixed(3)} below floor $${minAdaptivePrice.toFixed(3)} (combined $${(filledLegPrice + currentPrice).toFixed(3)}) — waiting for recovery (${Math.round(msLeft / 1000)}s left)`);
|
||||
}
|
||||
}
|
||||
|
||||
await sleep(pollMs);
|
||||
}
|
||||
|
||||
// ── Fallback: market sell at CL time ───────────────────────────────────────
|
||||
logger.warn(`MM adaptive CL: CL time reached — market-selling ${sellShares.toFixed(3)} ${unfilledKey.toUpperCase()} shares`);
|
||||
const result = await marketSell(s.tokenId, sellShares, tickSize, negRisk);
|
||||
s.fillPrice = result.fillPrice;
|
||||
const pnl = (s.fillPrice - s.entryPrice) * sellShares;
|
||||
const combined = filledLegPrice + s.fillPrice;
|
||||
logger.warn(`MM adaptive CL: ${unfilledKey.toUpperCase()} market-sold @ $${s.fillPrice.toFixed(3)} | combined $${combined.toFixed(3)} | sold ${sellShares.toFixed(3)} sh | P&L $${pnl.toFixed(2)}`);
|
||||
|
||||
s.filled = true;
|
||||
pos.status = 'done';
|
||||
}
|
||||
|
||||
// ── Recovery buy ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
+39
-37
@@ -1,16 +1,13 @@
|
||||
import { ethers } from 'ethers';
|
||||
import config from '../config/index.js';
|
||||
import { getPolygonProvider } from './client.js';
|
||||
import { execSafeCall, CTF_ADDRESS, USDC_ADDRESS } from './ctf.js';
|
||||
import { getOpenPositions, removePosition } from './position.js';
|
||||
import { recordSimResult } from '../utils/simStats.js';
|
||||
import logger from '../utils/logger.js';
|
||||
import { proxyFetch } from '../utils/proxy.js';
|
||||
|
||||
// Contract addresses on Polygon
|
||||
const CTF_ADDRESS = '0x4D97DCd97eC945f40cF65F87097ACe5EA0476045';
|
||||
const NEG_RISK_CTF_ADDRESS = '0xC5d563A36AE78145C45a50134d48A1215220f80a';
|
||||
const USDC_ADDRESS = '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174';
|
||||
|
||||
// CTF ABI (minimal for redeemPositions & balanceOf)
|
||||
// CTF ABI (minimal — read-only calls only; writes go through execSafeCall)
|
||||
const CTF_ABI = [
|
||||
'function redeemPositions(address collateralToken, bytes32 parentCollectionId, bytes32 conditionId, uint256[] indexSets)',
|
||||
'function balanceOf(address owner, uint256 tokenId) view returns (uint256)',
|
||||
@@ -24,7 +21,7 @@ const CTF_ABI = [
|
||||
async function checkMarketResolution(conditionId) {
|
||||
try {
|
||||
const url = `${config.gammaHost}/markets?condition_id=${conditionId}`;
|
||||
const response = await fetch(url);
|
||||
const response = await proxyFetch(url);
|
||||
if (!response.ok) return null;
|
||||
|
||||
const markets = await response.json();
|
||||
@@ -67,33 +64,30 @@ async function checkOnChainPayout(conditionId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeem winning position on-chain (real mode only)
|
||||
* Redeem winning position on-chain via the Gnosis Safe proxy wallet.
|
||||
* Uses execSafeCall (same as MM bot) so:
|
||||
* - tx is signed by the EOA but executed FROM the proxy wallet
|
||||
* - Polygon 30 Gwei minimum tip is enforced
|
||||
* - automatic retry on transient errors
|
||||
*/
|
||||
async function redeemPosition(conditionId, isNegRisk = false) {
|
||||
async function redeemPosition(conditionId) {
|
||||
try {
|
||||
const provider = await getPolygonProvider();
|
||||
const wallet = new ethers.Wallet(config.privateKey, provider);
|
||||
const ctfAddress = isNegRisk ? NEG_RISK_CTF_ADDRESS : CTF_ADDRESS;
|
||||
const ctf = new ethers.Contract(ctfAddress, CTF_ABI, wallet);
|
||||
|
||||
const parentCollectionId = ethers.constants.HashZero;
|
||||
const indexSets = [1, 2];
|
||||
|
||||
logger.info(`Redeeming position: ${conditionId}`);
|
||||
const tx = await ctf.redeemPositions(
|
||||
const ctfIface = new ethers.utils.Interface(CTF_ABI);
|
||||
const data = ctfIface.encodeFunctionData('redeemPositions', [
|
||||
USDC_ADDRESS,
|
||||
parentCollectionId,
|
||||
ethers.constants.HashZero,
|
||||
conditionId,
|
||||
indexSets,
|
||||
{ gasLimit: 300000 },
|
||||
);
|
||||
[1, 2],
|
||||
]);
|
||||
|
||||
logger.info(`Redeem tx: ${tx.hash}`);
|
||||
const receipt = await tx.wait();
|
||||
logger.success(`Redeemed in block ${receipt.blockNumber}`);
|
||||
const label = conditionId.slice(0, 12) + '...';
|
||||
logger.info(`Redeeming position: ${label}`);
|
||||
const receipt = await execSafeCall(CTF_ADDRESS, data, `redeemPositions ${label}`);
|
||||
const txHash = receipt.transactionHash;
|
||||
logger.success(`Redeemed in block ${receipt.blockNumber} | tx: ${txHash}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.error('Failed to redeem:', err.message);
|
||||
logger.error(`Failed to redeem: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -148,17 +142,22 @@ export async function checkAndRedeemPositions() {
|
||||
|
||||
for (const position of positions) {
|
||||
try {
|
||||
// 1. Check via Gamma API
|
||||
// 1. Quick check via Gamma API (low cost)
|
||||
const resolution = await checkMarketResolution(position.conditionId);
|
||||
const isResolved = resolution?.resolved;
|
||||
const apiResolved = resolution?.resolved;
|
||||
|
||||
if (!isResolved) {
|
||||
// 2. Fallback: on-chain check
|
||||
const onChain = await checkOnChainPayout(position.conditionId);
|
||||
if (!onChain.resolved) continue;
|
||||
logger.info(`Market resolved on-chain: ${position.market}`);
|
||||
} else {
|
||||
logger.info(`Market resolved: ${position.market}`);
|
||||
if (!apiResolved) continue; // Not resolved yet — check again next interval
|
||||
|
||||
logger.info(`Market resolved via API: ${position.market}`);
|
||||
|
||||
// 2. ALWAYS verify on-chain payout before calling redeemPositions.
|
||||
// Gamma API can report "resolved" before payoutDenominator is written
|
||||
// on-chain. Calling redeemPositions with payoutDenominator == 0 causes
|
||||
// the contract to revert → gas estimation failure.
|
||||
const onChain = await checkOnChainPayout(position.conditionId);
|
||||
if (!onChain.resolved) {
|
||||
logger.info(`On-chain payout not set yet for ${position.market} — will retry next interval`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. Simulate or execute real redeem
|
||||
@@ -168,11 +167,14 @@ export async function checkAndRedeemPositions() {
|
||||
const success = await redeemPosition(position.conditionId);
|
||||
if (success) {
|
||||
removePosition(position.conditionId);
|
||||
logger.money(`Redeemed: ${position.market}`);
|
||||
logger.money(`Redeemed: ${position.market} → USDC recovered`);
|
||||
} else {
|
||||
logger.warn(`Redeem failed for ${position.market}, will retry next interval — continuing to next position...`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Error checking ${position.market}:`, err.message);
|
||||
logger.info(`Continuing to next position...`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* schedule.js
|
||||
* Trading session schedule for the sniper bot.
|
||||
* Reads schedule from .env via config (SNIPER_SCHEDULE_*).
|
||||
*
|
||||
* All times are in UTC+8 and converted to UTC internally.
|
||||
* Assets outside their session window are skipped by the sniper detector.
|
||||
*
|
||||
* .env format per asset:
|
||||
* SNIPER_SCHEDULE_BTC=19:40-22:40,03:40-06:10
|
||||
* SNIPER_SCHEDULE_ETH=11:40-15:40,16:40-19:40
|
||||
*/
|
||||
|
||||
import config from '../config/index.js';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
// UTC+8 offset in hours
|
||||
const UTC8_OFFSET = 8;
|
||||
|
||||
/**
|
||||
* Parse schedule string from .env.
|
||||
* Format: "HH:MM-HH:MM,HH:MM-HH:MM"
|
||||
* Returns: [{ startUtc8, endUtc8, startMin, endMin }]
|
||||
*/
|
||||
function parseScheduleString(str) {
|
||||
if (!str || !str.trim()) return null;
|
||||
|
||||
const sessions = [];
|
||||
const parts = str.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
|
||||
for (const part of parts) {
|
||||
const match = part.match(/^(\d{1,2}:\d{2})\s*[-–]\s*(\d{1,2}:\d{2})$/);
|
||||
if (!match) {
|
||||
logger.warn(`SCHEDULE: invalid session format "${part}" — expected HH:MM-HH:MM`);
|
||||
continue;
|
||||
}
|
||||
const [, startUtc8, endUtc8] = match;
|
||||
sessions.push({
|
||||
startUtc8,
|
||||
endUtc8,
|
||||
startMin: utc8ToUtcMinutes(startUtc8),
|
||||
endMin: utc8ToUtcMinutes(endUtc8),
|
||||
});
|
||||
}
|
||||
|
||||
return sessions.length > 0 ? sessions : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert HH:MM in UTC+8 to minutes-since-midnight in UTC.
|
||||
* Result is always in [0, 1440).
|
||||
*/
|
||||
function utc8ToUtcMinutes(hhmm) {
|
||||
const [h, m] = hhmm.split(':').map(Number);
|
||||
let totalMin = (h * 60 + m) - (UTC8_OFFSET * 60);
|
||||
if (totalMin < 0) totalMin += 1440;
|
||||
if (totalMin >= 1440) totalMin -= 1440;
|
||||
return totalMin;
|
||||
}
|
||||
|
||||
// Build schedule from config (reads SNIPER_SCHEDULE_* from .env)
|
||||
const SCHEDULE = {};
|
||||
const SCHEDULE_DISPLAY = {};
|
||||
|
||||
for (const [asset, raw] of Object.entries(config.sniperSchedule || {})) {
|
||||
const sessions = parseScheduleString(raw);
|
||||
if (sessions) {
|
||||
SCHEDULE[asset] = sessions;
|
||||
SCHEDULE_DISPLAY[asset] = sessions.map((s) => ({
|
||||
startUtc8: s.startUtc8,
|
||||
endUtc8: s.endUtc8,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current time as minutes since midnight UTC.
|
||||
*/
|
||||
function nowMinutesUTC() {
|
||||
const d = new Date();
|
||||
return d.getUTCHours() * 60 + d.getUTCMinutes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if `nowMin` falls within range [start, end).
|
||||
* Handles overnight wrap (e.g. 22:00–02: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,10 +14,12 @@
|
||||
|
||||
import config from '../config/index.js';
|
||||
import logger from '../utils/logger.js';
|
||||
import { isAssetInSession, getNextSessionInfo } from './schedule.js';
|
||||
import { proxyFetch } from '../utils/proxy.js';
|
||||
|
||||
const SLOT_SEC = 5 * 60; // 300 seconds
|
||||
|
||||
let pollTimer = null;
|
||||
let pollTimer = null;
|
||||
let onMarketCb = null;
|
||||
const seenKeys = new Set(); // `${asset}-${slotTimestamp}` already handled
|
||||
|
||||
@@ -36,7 +38,7 @@ function nextSlot() {
|
||||
async function fetchBySlug(asset, slotTimestamp) {
|
||||
const slug = `${asset}-updown-5m-${slotTimestamp}`;
|
||||
try {
|
||||
const resp = await fetch(`${config.gammaHost}/markets/slug/${slug}`);
|
||||
const resp = await proxyFetch(`${config.gammaHost}/markets/slug/${slug}`);
|
||||
if (!resp.ok) return null;
|
||||
const data = await resp.json();
|
||||
return data?.conditionId ? data : null;
|
||||
@@ -61,7 +63,7 @@ function extractMarketData(market, asset) {
|
||||
[yesTokenId, noTokenId] = tokenIds;
|
||||
} else if (Array.isArray(market.tokens) && market.tokens.length >= 2) {
|
||||
yesTokenId = market.tokens[0]?.token_id ?? market.tokens[0]?.tokenId;
|
||||
noTokenId = market.tokens[1]?.token_id ?? market.tokens[1]?.tokenId;
|
||||
noTokenId = market.tokens[1]?.token_id ?? market.tokens[1]?.tokenId;
|
||||
}
|
||||
|
||||
if (!yesTokenId || !noTokenId) return null;
|
||||
@@ -69,13 +71,13 @@ function extractMarketData(market, asset) {
|
||||
return {
|
||||
asset,
|
||||
conditionId,
|
||||
question: market.question || market.title || '',
|
||||
endTime: market.endDate || market.end_date_iso || market.endDateIso,
|
||||
question: market.question || market.title || '',
|
||||
endTime: market.endDate || market.end_date_iso || market.endDateIso,
|
||||
eventStartTime: market.eventStartTime || market.event_start_time,
|
||||
yesTokenId: String(yesTokenId),
|
||||
noTokenId: String(noTokenId),
|
||||
negRisk: market.negRisk ?? market.neg_risk ?? false,
|
||||
tickSize: String(market.orderPriceMinTickSize ?? market.minimum_tick_size ?? '0.01'),
|
||||
yesTokenId: String(yesTokenId),
|
||||
noTokenId: String(noTokenId),
|
||||
negRisk: market.negRisk ?? market.neg_risk ?? false,
|
||||
tickSize: String(market.orderPriceMinTickSize ?? market.minimum_tick_size ?? '0.01'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -99,8 +101,8 @@ async function scheduleAsset(asset, slotTimestamp, isCurrent = false) {
|
||||
|
||||
if (isCurrent) {
|
||||
// Current slot: only place orders if there's at least 30 seconds of market left
|
||||
const endAt = data.endTime ? new Date(data.endTime).getTime() : (slotTimestamp + SLOT_SEC) * 1000;
|
||||
const secsLeft = Math.round((endAt - Date.now()) / 1000);
|
||||
const endAt = data.endTime ? new Date(data.endTime).getTime() : (slotTimestamp + SLOT_SEC) * 1000;
|
||||
const secsLeft = Math.round((endAt - Date.now()) / 1000);
|
||||
if (secsLeft < 30) {
|
||||
logger.info(`SNIPER: ${asset.toUpperCase()} current market closing soon (${secsLeft}s) — skipping`);
|
||||
return;
|
||||
@@ -108,7 +110,7 @@ async function scheduleAsset(asset, slotTimestamp, isCurrent = false) {
|
||||
logger.success(`SNIPER: ${asset.toUpperCase()} current market active (${secsLeft}s left) — placing orders now`);
|
||||
} else {
|
||||
// Next slot: market hasn't opened yet
|
||||
const openAt = data.eventStartTime ? new Date(data.eventStartTime).getTime() : slotTimestamp * 1000;
|
||||
const openAt = data.eventStartTime ? new Date(data.eventStartTime).getTime() : slotTimestamp * 1000;
|
||||
const secsUntilOpen = Math.round((openAt - Date.now()) / 1000);
|
||||
logger.success(`SNIPER: ${asset.toUpperCase()} found "${data.question.slice(0, 40)}"${secsUntilOpen > 0 ? ` — ${secsUntilOpen}s before open` : ''}`);
|
||||
}
|
||||
@@ -122,8 +124,25 @@ async function poll() {
|
||||
try {
|
||||
const curr = currentSlot();
|
||||
const next = nextSlot();
|
||||
|
||||
// Filter assets by trading session schedule
|
||||
const activeAssets = config.sniperAssets.filter((asset) => {
|
||||
if (!isAssetInSession(asset)) {
|
||||
const nextInfo = getNextSessionInfo(asset);
|
||||
const key = `skip-${asset}-${Math.floor(Date.now() / 60000)}`; // log once per minute
|
||||
if (!seenKeys.has(key)) {
|
||||
seenKeys.add(key);
|
||||
logger.info(`SNIPER: ${asset.toUpperCase()} outside session window${nextInfo ? ` — next in ${nextInfo}` : ''}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (activeAssets.length === 0) return;
|
||||
|
||||
// Check current active market AND the upcoming next one, in parallel for each asset
|
||||
await Promise.all(config.sniperAssets.flatMap((asset) => [
|
||||
await Promise.all(activeAssets.flatMap((asset) => [
|
||||
scheduleAsset(asset, curr, true), // current market (if still has time left)
|
||||
scheduleAsset(asset, next, false), // next upcoming market
|
||||
]));
|
||||
@@ -145,7 +164,8 @@ export function startSniperDetector(onNewMarket) {
|
||||
const secsUntil = ns - Math.floor(Date.now() / 1000);
|
||||
logger.info(`SNIPER detector started — assets: ${config.sniperAssets.join(', ').toUpperCase()}`);
|
||||
logger.info(`Next slot: *-updown-5m-${ns} (opens in ${secsUntil}s)`);
|
||||
logger.info(`Order: $${config.sniperPrice} × ${config.sniperShares} shares per side`);
|
||||
const prices = config.sniperTierPrices;
|
||||
logger.info(`Order: 3-tier ${prices[0]}c/${prices[1]}c/${prices[2]}c (max ${config.sniperMaxShares} shares per side)`);
|
||||
}
|
||||
|
||||
export function stopSniperDetector() {
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
/**
|
||||
* sniperExecutor.js
|
||||
* Places GTC limit BUY orders at a very low price on both sides of a market.
|
||||
*
|
||||
* Strategy:
|
||||
* - For each market detected by sniperDetector, place two GTC BUY orders:
|
||||
* UP token at $SNIPER_PRICE × SNIPER_SHARES shares
|
||||
* DOWN token at $SNIPER_PRICE × SNIPER_SHARES shares
|
||||
* - Orders sit in the orderbook. If someone panic-dumps below the price,
|
||||
* the order fills and becomes redeemable if that side wins.
|
||||
* - GTC orders expire automatically when the market closes — no cleanup needed.
|
||||
*
|
||||
* Cost per market: SNIPER_PRICE × SNIPER_SHARES × 2 sides
|
||||
* e.g. $0.01 × 5 × 2 = $0.10 per market, $0.30 for 3 assets per 5-min slot
|
||||
* 3-Tier Sniper Strategy:
|
||||
* - Tier 1: 3c price, smallest size (20% of max)
|
||||
* - Tier 2: 2c price, medium size (30% of max)
|
||||
* - Tier 3: 1c price, largest size (50% of max)
|
||||
* Min 5 shares per tier, total = SNIPER_MAX_SHARES
|
||||
*/
|
||||
|
||||
import { Side, OrderType } from '@polymarket/clob-client';
|
||||
@@ -19,13 +12,37 @@ import config from '../config/index.js';
|
||||
import { getClient } from './client.js';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
// In-memory tracking of placed snipe orders (for TUI status panel)
|
||||
const activeSnipes = []; // { asset, side, question, orderId, price, shares, cost, potentialPayout }
|
||||
// In-memory tracking of placed snipe orders
|
||||
const activeSnipes = [];
|
||||
|
||||
export function getActiveSnipes() {
|
||||
return [...activeSnipes];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate tier sizes based on max shares.
|
||||
* Distribution: 20% | 30% | 50% (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);
|
||||
@@ -36,56 +53,67 @@ export async function executeSnipe(market) {
|
||||
{ name: 'DOWN', tokenId: noTokenId },
|
||||
];
|
||||
|
||||
logger.info(`SNIPER: ${sim}${asset.toUpperCase()} — "${label}" | $${config.sniperPrice} × ${config.sniperShares}sh each side`);
|
||||
const prices = config.sniperTierPrices;
|
||||
const sizes = calculateTierSizes(config.sniperMaxShares, config.sniperMinSharesPerTier);
|
||||
|
||||
logger.info(`SNIPER: ${sim}${asset.toUpperCase()} — "${label}" | 3-tier: 3c×${sizes[0]} | 2c×${sizes[1]} | 1c×${sizes[2]}`);
|
||||
|
||||
for (const { name, tokenId } of sides) {
|
||||
if (config.dryRun) {
|
||||
const cost = config.sniperPrice * config.sniperShares;
|
||||
logger.trade(`SNIPER[SIM]: ${asset.toUpperCase()} ${name} @ $${config.sniperPrice} × ${config.sniperShares}sh | cost $${cost.toFixed(3)} | payout $${config.sniperShares} if wins`);
|
||||
activeSnipes.push({
|
||||
asset: asset.toUpperCase(),
|
||||
side: name,
|
||||
question: label,
|
||||
orderId: `sim-${Date.now()}-${tokenId.slice(-6)}`,
|
||||
price: config.sniperPrice,
|
||||
shares: config.sniperShares,
|
||||
cost,
|
||||
potentialPayout: config.sniperShares,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Place 3 orders per side
|
||||
for (let tier = 0; tier < 3; tier++) {
|
||||
const price = prices[tier];
|
||||
const size = sizes[tier];
|
||||
|
||||
const client = getClient();
|
||||
try {
|
||||
const res = await client.createAndPostOrder(
|
||||
{
|
||||
tokenID: tokenId,
|
||||
side: Side.BUY,
|
||||
price: config.sniperPrice,
|
||||
size: config.sniperShares,
|
||||
},
|
||||
{ tickSize, negRisk },
|
||||
OrderType.GTC,
|
||||
);
|
||||
|
||||
if (res?.success) {
|
||||
const cost = config.sniperPrice * config.sniperShares;
|
||||
logger.trade(`SNIPER: ${asset.toUpperCase()} ${name} @ $${config.sniperPrice} × ${config.sniperShares}sh | cost $${cost.toFixed(3)} | order ${res.orderID}`);
|
||||
if (config.dryRun) {
|
||||
const cost = price * size;
|
||||
logger.trade(`SNIPER[SIM]: ${asset.toUpperCase()} ${name} T${tier+1} @ $${price.toFixed(2)} × ${size}sh | cost $${cost.toFixed(3)}`);
|
||||
activeSnipes.push({
|
||||
asset: asset.toUpperCase(),
|
||||
side: name,
|
||||
tier: tier + 1,
|
||||
question: label,
|
||||
orderId: res.orderID,
|
||||
price: config.sniperPrice,
|
||||
shares: config.sniperShares,
|
||||
orderId: `sim-${Date.now()}-${tier}-${tokenId.slice(-6)}`,
|
||||
price,
|
||||
shares: size,
|
||||
cost,
|
||||
potentialPayout: config.sniperShares,
|
||||
potentialPayout: size,
|
||||
});
|
||||
} else {
|
||||
logger.warn(`SNIPER: ${asset.toUpperCase()} ${name} order failed — ${res?.errorMsg || 'unknown'}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const client = getClient();
|
||||
try {
|
||||
const res = await client.createAndPostOrder(
|
||||
{
|
||||
tokenID: tokenId,
|
||||
side: Side.BUY,
|
||||
price: price,
|
||||
size: size,
|
||||
},
|
||||
{ tickSize, negRisk },
|
||||
OrderType.GTC,
|
||||
);
|
||||
|
||||
if (res?.success) {
|
||||
const cost = price * size;
|
||||
logger.trade(`SNIPER: ${asset.toUpperCase()} ${name} T${tier+1} @ $${price.toFixed(2)} × ${size}sh | cost $${cost.toFixed(3)} | order ${res.orderID}`);
|
||||
activeSnipes.push({
|
||||
asset: asset.toUpperCase(),
|
||||
side: name,
|
||||
tier: tier + 1,
|
||||
question: label,
|
||||
orderId: res.orderID,
|
||||
price,
|
||||
shares: size,
|
||||
cost,
|
||||
potentialPayout: size,
|
||||
});
|
||||
} else {
|
||||
logger.warn(`SNIPER: ${asset.toUpperCase()} ${name} T${tier+1} failed — ${res?.errorMsg || 'unknown'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`SNIPER: ${asset.toUpperCase()} ${name} T${tier+1} error — ${err.message}`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`SNIPER: ${asset.toUpperCase()} ${name} error — ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import config from '../config/index.js';
|
||||
import logger from '../utils/logger.js';
|
||||
import { readState, writeState } from '../utils/state.js';
|
||||
import { proxyFetch } from '../utils/proxy.js';
|
||||
|
||||
const PROCESSED_FILE = 'processed_trades.json';
|
||||
|
||||
@@ -11,7 +12,7 @@ const PROCESSED_FILE = 'processed_trades.json';
|
||||
async function fetchTraderActivity() {
|
||||
const url = `${config.dataHost}/activity?user=${config.traderAddress}`;
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const response = await proxyFetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Data API returned ${response.status}`);
|
||||
}
|
||||
@@ -116,7 +117,7 @@ export { markTradeProcessed };
|
||||
export async function fetchMarketInfo(conditionId) {
|
||||
try {
|
||||
const url = `${config.gammaHost}/markets?condition_id=${conditionId}`;
|
||||
const response = await fetch(url);
|
||||
const response = await proxyFetch(url);
|
||||
if (!response.ok) return null;
|
||||
const markets = await response.json();
|
||||
return markets && markets.length > 0 ? markets[0] : null;
|
||||
@@ -132,7 +133,7 @@ export async function fetchMarketInfo(conditionId) {
|
||||
export async function fetchMarketByTokenId(tokenId) {
|
||||
try {
|
||||
const url = `${config.gammaHost}/markets?clob_token_ids=${tokenId}`;
|
||||
const response = await fetch(url);
|
||||
const response = await proxyFetch(url);
|
||||
if (!response.ok) return null;
|
||||
const markets = await response.json();
|
||||
return markets && markets.length > 0 ? markets[0] : null;
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* sniper-tui.js
|
||||
* TUI version of the Orderbook Sniper bot (blessed dashboard).
|
||||
* Places tiny GTC BUY orders at a low price on both sides of 5-min markets.
|
||||
*
|
||||
* Run with: npm run sniper-tui (live)
|
||||
* npm run sniper-tui-sim (simulation)
|
||||
*/
|
||||
|
||||
// Load proxy patch BEFORE any other imports (must patch https before axios is loaded)
|
||||
import './utils/proxy-patch.cjs';
|
||||
|
||||
import { validateMMConfig } from './config/index.js';
|
||||
import config from './config/index.js';
|
||||
import logger from './utils/logger.js';
|
||||
import { initClient } from './services/client.js';
|
||||
import { getUsdcBalance } from './services/client.js';
|
||||
import { initDashboard, appendLog, updateStatus, isDashboardActive } from './ui/dashboard.js';
|
||||
import { startSniperDetector, stopSniperDetector } from './services/sniperDetector.js';
|
||||
import { executeSnipe, getActiveSnipes } from './services/sniperExecutor.js';
|
||||
import { redeemSniperPositions } from './services/ctf.js';
|
||||
import { getSchedule, isAssetInSession, getNextSessionInfo } from './services/schedule.js';
|
||||
|
||||
// ── Validate config ────────────────────────────────────────────────────────────
|
||||
|
||||
try {
|
||||
validateMMConfig();
|
||||
} catch (err) {
|
||||
console.error(`Config error: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (config.sniperAssets.length === 0) {
|
||||
console.error('SNIPER_ASSETS is empty. Set e.g. SNIPER_ASSETS=eth,sol,xrp in .env');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Init TUI ──────────────────────────────────────────────────────────────────
|
||||
|
||||
initDashboard();
|
||||
logger.setOutput(appendLog);
|
||||
|
||||
// ── Init CLOB client ──────────────────────────────────────────────────────────
|
||||
|
||||
try {
|
||||
await initClient();
|
||||
} catch (err) {
|
||||
logger.error(`Client init error: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Status panel ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function buildStatusContent() {
|
||||
const lines = [];
|
||||
|
||||
// Balance
|
||||
let balance = '?';
|
||||
if (!config.dryRun) {
|
||||
try { balance = (await getUsdcBalance()).toFixed(2); } catch { /* ignore */ }
|
||||
} else {
|
||||
balance = '{yellow-fg}SIM{/yellow-fg}';
|
||||
}
|
||||
lines.push('{bold}BALANCE{/bold}');
|
||||
lines.push(` USDC.e: {green-fg}$${balance}{/green-fg}`);
|
||||
lines.push('');
|
||||
|
||||
lines.push('{bold}MODE{/bold}');
|
||||
lines.push(` ${config.dryRun ? '{yellow-fg}SIMULATION{/yellow-fg}' : '{green-fg}LIVE{/green-fg}'}`);
|
||||
lines.push('');
|
||||
|
||||
lines.push('{bold}SNIPER CONFIG{/bold}');
|
||||
const prices = config.sniperTierPrices;
|
||||
const sizes = [Math.floor(config.sniperMaxShares * 0.20), Math.floor(config.sniperMaxShares * 0.30), Math.floor(config.sniperMaxShares * 0.50)];
|
||||
lines.push(` Assets : ${config.sniperAssets.join(', ').toUpperCase()}`);
|
||||
lines.push(` 3-Tier : ${prices[0]}c/${prices[1]}c/${prices[2]}c`);
|
||||
lines.push(` Sizes : ${sizes[0]}/${sizes[1]}/${sizes[2]} shares`);
|
||||
const costPerSide = (sizes[0] * prices[0]) + (sizes[1] * prices[1]) + (sizes[2] * prices[2]);
|
||||
lines.push(` Cost : $${(costPerSide * 2 * config.sniperAssets.length).toFixed(3)} per slot`);
|
||||
lines.push('');
|
||||
|
||||
// Session schedule
|
||||
lines.push('{bold}SESSION SCHEDULE (UTC+8){/bold}');
|
||||
const schedule = getSchedule();
|
||||
for (const asset of config.sniperAssets) {
|
||||
const sessions = schedule[asset];
|
||||
const active = isAssetInSession(asset);
|
||||
const statusTag = active
|
||||
? '{green-fg}● ACTIVE{/green-fg}'
|
||||
: '{red-fg}○ IDLE{/red-fg}';
|
||||
if (sessions) {
|
||||
const sessionStr = sessions.map(s => `${s.startUtc8}–${s.endUtc8}`).join(', ');
|
||||
lines.push(` ${asset.toUpperCase()} ${statusTag} ${sessionStr}`);
|
||||
if (!active) {
|
||||
const next = getNextSessionInfo(asset);
|
||||
if (next) lines.push(` {gray-fg}Next in ${next}{/gray-fg}`);
|
||||
}
|
||||
} else {
|
||||
lines.push(` ${asset.toUpperCase()} {yellow-fg}NO SCHEDULE{/yellow-fg} (always active)`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
// Recent snipe orders
|
||||
const snipes = getActiveSnipes();
|
||||
lines.push(`{bold}SNIPE ORDERS (${snipes.length} total){/bold}`);
|
||||
|
||||
if (snipes.length === 0) {
|
||||
lines.push(' {gray-fg}Waiting for next slot...{/gray-fg}');
|
||||
} else {
|
||||
// Show last 10 orders (most recent first)
|
||||
const recent = snipes.slice(-10).reverse();
|
||||
for (const s of recent) {
|
||||
const payout = s.potentialPayout.toFixed(2);
|
||||
lines.push(` {cyan-fg}${s.asset}{/cyan-fg} ${s.side} @ $${s.price} × ${s.shares}sh | pay $${payout} if win`);
|
||||
}
|
||||
}
|
||||
|
||||
return '\n' + lines.join('\n');
|
||||
}
|
||||
|
||||
let refreshTimer = null;
|
||||
let redeemTimer = null;
|
||||
|
||||
function startRefresh() {
|
||||
refreshTimer = setInterval(async () => {
|
||||
if (!isDashboardActive()) return;
|
||||
updateStatus(await buildStatusContent());
|
||||
}, 3000);
|
||||
buildStatusContent().then(updateStatus);
|
||||
}
|
||||
|
||||
function startRedeemer() {
|
||||
redeemSniperPositions().catch((err) => logger.error('Sniper redeemer error:', err.message));
|
||||
redeemTimer = setInterval(
|
||||
() => redeemSniperPositions().catch((err) => logger.error('Sniper redeemer error:', err.message)),
|
||||
config.redeemInterval,
|
||||
);
|
||||
logger.info(`Sniper redeemer started — checking every ${config.redeemInterval / 1000}s`);
|
||||
}
|
||||
|
||||
// ── Market handler ────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleNewMarket(market) {
|
||||
executeSnipe(market).catch((err) =>
|
||||
logger.error(`SNIPER execute error (${market.asset}): ${err.message}`)
|
||||
);
|
||||
}
|
||||
|
||||
// ── Graceful shutdown ─────────────────────────────────────────────────────────
|
||||
|
||||
function shutdown() {
|
||||
logger.warn('SNIPER: shutting down...');
|
||||
stopSniperDetector();
|
||||
if (refreshTimer) clearInterval(refreshTimer);
|
||||
if (redeemTimer) clearInterval(redeemTimer);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
|
||||
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const prices = config.sniperTierPrices;
|
||||
const sizes = [Math.floor(config.sniperMaxShares * 0.20), Math.floor(config.sniperMaxShares * 0.30), Math.floor(config.sniperMaxShares * 0.50)];
|
||||
const costPerSide = (sizes[0] * prices[0]) + (sizes[1] * prices[1]) + (sizes[2] * prices[2]);
|
||||
const costPerSlot = (costPerSide * 2 * config.sniperAssets.length).toFixed(3);
|
||||
logger.info(`SNIPER starting — ${config.dryRun ? 'SIMULATION' : 'LIVE'}`);
|
||||
logger.info(`Assets: ${config.sniperAssets.join(', ').toUpperCase()} | 3-tier: 3c×${sizes[0]}+2c×${sizes[1]}+1c×${sizes[2]} = $${costPerSlot}/slot`);
|
||||
|
||||
startRefresh();
|
||||
startRedeemer();
|
||||
startSniperDetector(handleNewMarket);
|
||||
+44
-71
@@ -1,21 +1,25 @@
|
||||
/**
|
||||
* sniper.js
|
||||
* Entry point for the Orderbook Sniper bot.
|
||||
* Places tiny GTC BUY orders at $0.01 on both sides of ETH/SOL/XRP 5-min markets.
|
||||
* Console-only entry point for the Orderbook Sniper bot.
|
||||
* Places tiny GTC BUY orders at a low price on both sides of 5-min markets.
|
||||
*
|
||||
* Run with: npm run sniper (live)
|
||||
* npm run sniper-sim (simulation)
|
||||
* Run with: npm run sniper (live, console)
|
||||
* npm run sniper-sim (simulation, console)
|
||||
*
|
||||
* For the TUI dashboard version, use: npm run sniper-tui
|
||||
*/
|
||||
|
||||
import { validateMMConfig } from './config/index.js';
|
||||
import config from './config/index.js';
|
||||
import logger from './utils/logger.js';
|
||||
import { initClient } from './services/client.js';
|
||||
import { getUsdcBalance } from './services/client.js';
|
||||
import { initDashboard, appendLog, updateStatus, isDashboardActive } from './ui/dashboard.js';
|
||||
import { startSniperDetector, stopSniperDetector } from './services/sniperDetector.js';
|
||||
import { executeSnipe, getActiveSnipes } from './services/sniperExecutor.js';
|
||||
import { redeemMMPositions } from './services/ctf.js';
|
||||
import { executeSnipe } from './services/sniperExecutor.js';
|
||||
import { redeemSniperPositions } from './services/ctf.js';
|
||||
import { getSchedule, isAssetInSession, getNextSessionInfo } from './services/schedule.js';
|
||||
|
||||
// Set proxy before any network calls
|
||||
import './utils/proxy-patch.cjs';
|
||||
|
||||
// ── Validate config ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -31,11 +35,6 @@ if (config.sniperAssets.length === 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Init TUI ──────────────────────────────────────────────────────────────────
|
||||
|
||||
initDashboard();
|
||||
logger.setOutput(appendLog);
|
||||
|
||||
// ── Init CLOB client ──────────────────────────────────────────────────────────
|
||||
|
||||
try {
|
||||
@@ -45,69 +44,40 @@ try {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Status panel ──────────────────────────────────────────────────────────────
|
||||
// ── Log session schedule ──────────────────────────────────────────────────────
|
||||
|
||||
async function buildStatusContent() {
|
||||
const lines = [];
|
||||
|
||||
// Balance
|
||||
let balance = '?';
|
||||
if (!config.dryRun) {
|
||||
try { balance = (await getUsdcBalance()).toFixed(2); } catch { /* ignore */ }
|
||||
} else {
|
||||
balance = '{yellow-fg}SIM{/yellow-fg}';
|
||||
}
|
||||
lines.push('{bold}BALANCE{/bold}');
|
||||
lines.push(` USDC.e: {green-fg}$${balance}{/green-fg}`);
|
||||
lines.push('');
|
||||
|
||||
lines.push('{bold}MODE{/bold}');
|
||||
lines.push(` ${config.dryRun ? '{yellow-fg}SIMULATION{/yellow-fg}' : '{green-fg}LIVE{/green-fg}'}`);
|
||||
lines.push('');
|
||||
|
||||
lines.push('{bold}SNIPER CONFIG{/bold}');
|
||||
lines.push(` Assets : ${config.sniperAssets.join(', ').toUpperCase()}`);
|
||||
lines.push(` Price : $${config.sniperPrice} per share`);
|
||||
lines.push(` Shares : ${config.sniperShares} per side`);
|
||||
lines.push(` Cost : $${(config.sniperPrice * config.sniperShares * 2 * config.sniperAssets.length).toFixed(3)} per slot`);
|
||||
lines.push('');
|
||||
|
||||
// Recent snipe orders
|
||||
const snipes = getActiveSnipes();
|
||||
lines.push(`{bold}SNIPE ORDERS (${snipes.length} total){/bold}`);
|
||||
|
||||
if (snipes.length === 0) {
|
||||
lines.push(' {gray-fg}Waiting for next slot...{/gray-fg}');
|
||||
} else {
|
||||
// Show last 10 orders (most recent first)
|
||||
const recent = snipes.slice(-10).reverse();
|
||||
for (const s of recent) {
|
||||
const payout = s.potentialPayout.toFixed(2);
|
||||
lines.push(` {cyan-fg}${s.asset}{/cyan-fg} ${s.side} @ $${s.price} × ${s.shares}sh | pay $${payout} if win`);
|
||||
function logSchedule() {
|
||||
const schedule = getSchedule();
|
||||
logger.info('─── Session Schedule (UTC+8) ───');
|
||||
for (const asset of config.sniperAssets) {
|
||||
const sessions = schedule[asset];
|
||||
const active = isAssetInSession(asset);
|
||||
const status = active ? '● ACTIVE' : '○ IDLE';
|
||||
if (sessions) {
|
||||
const sessionStr = sessions.map(s => `${s.startUtc8}–${s.endUtc8}`).join(', ');
|
||||
logger.info(` ${asset.toUpperCase()} [${status}] ${sessionStr}`);
|
||||
if (!active) {
|
||||
const next = getNextSessionInfo(asset);
|
||||
if (next) logger.info(` → Next in ${next}`);
|
||||
}
|
||||
} else {
|
||||
logger.info(` ${asset.toUpperCase()} [NO SCHEDULE] (always active)`);
|
||||
}
|
||||
}
|
||||
|
||||
return '\n' + lines.join('\n');
|
||||
logger.info('────────────────────────────────');
|
||||
}
|
||||
|
||||
let refreshTimer = null;
|
||||
let redeemTimer = null;
|
||||
// ── Redeemer ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function startRefresh() {
|
||||
refreshTimer = setInterval(async () => {
|
||||
if (!isDashboardActive()) return;
|
||||
updateStatus(await buildStatusContent());
|
||||
}, 3000);
|
||||
buildStatusContent().then(updateStatus);
|
||||
}
|
||||
let redeemTimer = null;
|
||||
|
||||
function startRedeemer() {
|
||||
redeemMMPositions().catch((err) => logger.error('Sniper redeemer error:', err.message));
|
||||
// Only run on interval, NOT on startup (we only want to redeem NEW winning positions)
|
||||
redeemTimer = setInterval(
|
||||
() => redeemMMPositions().catch((err) => logger.error('Sniper redeemer error:', err.message)),
|
||||
() => redeemSniperPositions().catch((err) => logger.error('Sniper redeemer error:', err.message)),
|
||||
config.redeemInterval,
|
||||
);
|
||||
logger.info(`Sniper redeemer started — checking every ${config.redeemInterval / 1000}s`);
|
||||
logger.info(`Sniper redeemer started — checking every ${config.redeemInterval / 1000}s (winners only, no startup check)`);
|
||||
}
|
||||
|
||||
// ── Market handler ────────────────────────────────────────────────────────────
|
||||
@@ -123,20 +93,23 @@ async function handleNewMarket(market) {
|
||||
function shutdown() {
|
||||
logger.warn('SNIPER: shutting down...');
|
||||
stopSniperDetector();
|
||||
if (refreshTimer) clearInterval(refreshTimer);
|
||||
if (redeemTimer) clearInterval(redeemTimer);
|
||||
if (redeemTimer) clearInterval(redeemTimer);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
|
||||
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const costPerSlot = (config.sniperPrice * config.sniperShares * 2 * config.sniperAssets.length).toFixed(3);
|
||||
// Calculate cost for 3-tier strategy
|
||||
const prices = config.sniperTierPrices;
|
||||
const sizes = [Math.floor(config.sniperMaxShares * 0.20), Math.floor(config.sniperMaxShares * 0.30), Math.floor(config.sniperMaxShares * 0.50)];
|
||||
const costPerSide = (sizes[0] * prices[0]) + (sizes[1] * prices[1]) + (sizes[2] * prices[2]);
|
||||
const costPerSlot = (costPerSide * 2 * config.sniperAssets.length).toFixed(3);
|
||||
logger.info(`SNIPER starting — ${config.dryRun ? 'SIMULATION' : 'LIVE'}`);
|
||||
logger.info(`Assets: ${config.sniperAssets.join(', ').toUpperCase()} | $${config.sniperPrice} × ${config.sniperShares}sh = $${costPerSlot}/slot`);
|
||||
logger.info(`Assets: ${config.sniperAssets.join(', ').toUpperCase()} | 3-tier: 3c×${sizes[0]}+2c×${sizes[1]}+1c×${sizes[2]} = $${costPerSlot}/slot`);
|
||||
|
||||
startRefresh();
|
||||
logSchedule();
|
||||
startRedeemer();
|
||||
startSniperDetector(handleNewMarket);
|
||||
|
||||
@@ -22,6 +22,26 @@ const B = {
|
||||
|
||||
let outputFn = null; // When set, all log goes here (blessed dashboard mode)
|
||||
|
||||
/**
|
||||
* Sanitize a CLOB client console message.
|
||||
* Strips the full axios config (which may contain auth headers) and returns
|
||||
* only the HTTP status code + API error message.
|
||||
*/
|
||||
function sanitizeClobMessage(raw) {
|
||||
if (!raw.includes('[CLOB Client]')) return raw;
|
||||
try {
|
||||
const jsonStart = raw.indexOf('{');
|
||||
if (jsonStart === -1) return raw;
|
||||
const parsed = JSON.parse(raw.slice(jsonStart));
|
||||
const status = parsed.status || '';
|
||||
const errMsg = parsed.data?.error || parsed.statusText || 'unknown error';
|
||||
const prefix = raw.slice(0, jsonStart).trim();
|
||||
return `${prefix}: ${status} — ${errMsg}`;
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function ts() {
|
||||
return new Date().toISOString().replace('T', ' ').substring(0, 19);
|
||||
}
|
||||
@@ -55,6 +75,25 @@ const logger = {
|
||||
setOutput(fn) {
|
||||
outputFn = fn;
|
||||
},
|
||||
|
||||
/**
|
||||
* Override console.error and console.log globally so that the CLOB client's
|
||||
* internal axios error dumps are sanitized (no auth headers / full config).
|
||||
* Call this once at startup, before any CLOB requests.
|
||||
*/
|
||||
interceptConsole() {
|
||||
const handle = (originalFn, logFn) => (...args) => {
|
||||
const raw = args.map((a) => (a && typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ');
|
||||
const sanitized = sanitizeClobMessage(raw);
|
||||
if (sanitized !== raw || raw.includes('[CLOB Client]')) {
|
||||
logFn(sanitized);
|
||||
} else {
|
||||
originalFn(...args);
|
||||
}
|
||||
};
|
||||
console.error = handle(console.error.bind(console), logger.error);
|
||||
console.warn = handle(console.warn.bind(console), logger.warn);
|
||||
},
|
||||
};
|
||||
|
||||
export default logger;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* proxy-patch.cjs
|
||||
*
|
||||
* Patches the Node.js https module to route Polymarket traffic through a proxy.
|
||||
* This needs to be the VERY FIRST import in the application.
|
||||
*/
|
||||
|
||||
const PROXY_URL = process.env.PROXY_URL || '';
|
||||
|
||||
if (PROXY_URL) {
|
||||
const https = require('https');
|
||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||
|
||||
const agent = new HttpsProxyAgent(PROXY_URL);
|
||||
|
||||
const POLY_DOMAINS = [
|
||||
'polymarket.com',
|
||||
'clob.polymarket.com',
|
||||
'gamma-api.polymarket.com',
|
||||
'data-api.polymarket.com',
|
||||
];
|
||||
|
||||
const shouldProxy = (hostname) => {
|
||||
if (!hostname) return false;
|
||||
return POLY_DOMAINS.some(d => hostname === d || hostname.endsWith('.' + d));
|
||||
};
|
||||
|
||||
const originalRequest = https.request;
|
||||
|
||||
https.request = function(...args) {
|
||||
let url;
|
||||
if (typeof args[0] === 'string') {
|
||||
url = args[0];
|
||||
} else if (args[0] && args[0].hostname) {
|
||||
url = args[0].hostname;
|
||||
}
|
||||
|
||||
if (url && shouldProxy(url)) {
|
||||
if (typeof args[0] === 'object') {
|
||||
args[0].agent = agent;
|
||||
} else if (typeof args[0] === 'string') {
|
||||
// Axios might pass string, convert to options object
|
||||
const parsed = new URL(args[0]);
|
||||
args[0] = {
|
||||
protocol: parsed.protocol,
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port,
|
||||
path: parsed.pathname + parsed.search,
|
||||
agent: agent,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return originalRequest.apply(this, args);
|
||||
};
|
||||
|
||||
console.log('[proxy-patch] HTTPS patched for Polymarket');
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* proxy.js
|
||||
* Proxy support for Polymarket API calls only.
|
||||
*
|
||||
* - CLOB API: uses axios internally (via @polymarket/clob-client) →
|
||||
* we set axios.defaults.httpAgent/httpsAgent via https-proxy-agent.
|
||||
* - Gamma / Data API: uses native fetch (undici) →
|
||||
* we use undici.ProxyAgent with the `dispatcher` option.
|
||||
* - Polygon RPC: NOT proxied (separate ethers.js provider).
|
||||
*
|
||||
* Set PROXY_URL in .env to enable. Supports HTTP/HTTPS proxies.
|
||||
* Example: PROXY_URL=http://user:pass@proxy.example.com:8080
|
||||
*/
|
||||
|
||||
import config from '../config/index.js';
|
||||
import logger from './logger.js';
|
||||
|
||||
let axiosAgent = null; // https-proxy-agent for axios (CLOB client)
|
||||
let fetchDispatcher = null; // undici ProxyAgent for native fetch
|
||||
|
||||
/**
|
||||
* Set up axios defaults so that the @polymarket/clob-client's
|
||||
* internal axios calls go through the proxy.
|
||||
* Call this BEFORE creating ClobClient.
|
||||
*/
|
||||
export async function setupAxiosProxy() {
|
||||
if (!config.proxyUrl) {
|
||||
logger.info('No PROXY_URL set — Polymarket API calls will be direct');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Setup axios proxy (for CLOB client)
|
||||
const { HttpsProxyAgent } = await import('https-proxy-agent');
|
||||
axiosAgent = new HttpsProxyAgent(config.proxyUrl);
|
||||
|
||||
const axiosModule = await import('axios');
|
||||
const axios = axiosModule.default || axiosModule;
|
||||
axios.defaults.proxy = false;
|
||||
axios.defaults.httpAgent = axiosAgent;
|
||||
axios.defaults.httpsAgent = axiosAgent;
|
||||
logger.info(`Axios proxy configured → ${maskProxyUrl(config.proxyUrl)}`);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to configure axios proxy: ${err.message}`);
|
||||
logger.error('Make sure https-proxy-agent is installed: npm i https-proxy-agent');
|
||||
}
|
||||
|
||||
try {
|
||||
// 2. Setup undici ProxyAgent (for native fetch)
|
||||
const undici = await import('undici');
|
||||
fetchDispatcher = new undici.ProxyAgent(config.proxyUrl);
|
||||
logger.info(`Fetch proxy configured → ${maskProxyUrl(config.proxyUrl)}`);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to configure fetch proxy: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy-aware fetch wrapper.
|
||||
* Drop-in replacement for global fetch() — uses undici ProxyAgent
|
||||
* as dispatcher when PROXY_URL is configured.
|
||||
* Use this for Gamma API and Data API calls.
|
||||
*/
|
||||
export async function proxyFetch(url, opts = {}) {
|
||||
if (fetchDispatcher) {
|
||||
opts.dispatcher = fetchDispatcher;
|
||||
}
|
||||
return fetch(url, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that the proxy works by making a simple request to the CLOB API.
|
||||
* Call this at startup to fail fast if the proxy is misconfigured.
|
||||
*/
|
||||
export async function testProxy() {
|
||||
if (!config.proxyUrl) return true; // no proxy = nothing to test
|
||||
|
||||
logger.info(`Testing proxy connection → ${maskProxyUrl(config.proxyUrl)} ...`);
|
||||
|
||||
try {
|
||||
if (!fetchDispatcher) {
|
||||
throw new Error('Proxy dispatcher not initialized');
|
||||
}
|
||||
|
||||
// Test with a simple GET to the CLOB time endpoint via proxied fetch
|
||||
const resp = await fetch(`${config.clobHost}/time`, {
|
||||
dispatcher: fetchDispatcher,
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
|
||||
}
|
||||
|
||||
logger.success(`Proxy test passed — connected via ${maskProxyUrl(config.proxyUrl)}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.error(`Proxy test FAILED: ${err.message}`);
|
||||
logger.error('Check PROXY_URL in .env. Bot cannot reach Polymarket without a working proxy.');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask credentials in proxy URL for safe logging.
|
||||
* http://user:pass@host:port → http://***:***@host:port
|
||||
*/
|
||||
function maskProxyUrl(url) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if (u.username || u.password) {
|
||||
u.username = '***';
|
||||
u.password = '***';
|
||||
}
|
||||
return u.toString();
|
||||
} catch {
|
||||
return '(invalid URL)';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user