feat: full project overhaul — market maker, sniper, WebSocket watcher, terminal UI
- Rename project to polymarket-terminal - Add Market Maker bot (src/mm.js) with on-chain CTF split/merge/redeem via Gnosis Safe - Add Orderbook Sniper bot (src/sniper.js) with multi-asset GTC low-price orders - Add WebSocket watcher (src/services/wsWatcher.js) for real-time RTDS trade events - Add terminal dashboard UI (src/ui/dashboard.js) using blessed - Add CTF contract helpers (src/services/ctf.js) for splitPosition, mergePositions, redeemPositions - Add mmDetector, mmExecutor, sniperDetector, sniperExecutor services - Add simStats utility for dry-run P&L tracking - Translate all Indonesian-language strings to professional English across all files - Rewrite README.md in English with full setup guide, configuration reference, and architecture overview - Rewrite AGENT.MD in English as comprehensive AI agent and developer reference - Update package.json name, description, scripts, and keywords Co-Authored-By: direkturcrypto <direkturcrypto.x@mail3.me>
This commit is contained in:
+110
-16
@@ -1,32 +1,126 @@
|
||||
# Wallet
|
||||
PRIVATE_KEY=0xYOUR_PRIVATE_KEY_HERE
|
||||
WALLET_ADDRESS=0xYOUR_WALLET_ADDRESS_HERE
|
||||
# ─────────────────────────────────────────────
|
||||
# WALLET SETUP
|
||||
# ─────────────────────────────────────────────
|
||||
# EOA private key — used for SIGNING only, does NOT hold USDC
|
||||
PRIVATE_KEY=0xYOUR_EOA_PRIVATE_KEY_HERE
|
||||
|
||||
# Polymarket API Credentials (optional, auto-derived if not set)
|
||||
# Polymarket Proxy Wallet — the address shown when you click "Deposit" on Polymarket
|
||||
# This is where you deposit USDC.e, and where trades are funded from
|
||||
# How to find: Login to polymarket.com → Profile → Deposit → copy the address
|
||||
PROXY_WALLET_ADDRESS=0xYOUR_PROXY_WALLET_ADDRESS_HERE
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# POLYGON RPC
|
||||
# ─────────────────────────────────────────────
|
||||
POLYGON_RPC_URL=https://polygon.lava.build
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# POLYMARKET API CREDENTIALS (optional)
|
||||
# Leave blank to auto-derive from your private key
|
||||
# ─────────────────────────────────────────────
|
||||
CLOB_API_KEY=
|
||||
CLOB_API_SECRET=
|
||||
CLOB_API_PASSPHRASE=
|
||||
|
||||
# Trader to Copy
|
||||
TRADER_ADDRESS=0xTRADER_ADDRESS_TO_COPY
|
||||
# ─────────────────────────────────────────────
|
||||
# TRADER TO COPY
|
||||
# Use the proxy wallet address of the trader (visible on their Polymarket profile)
|
||||
# ─────────────────────────────────────────────
|
||||
TRADER_ADDRESS=0xTRADER_PROXY_WALLET_ADDRESS
|
||||
|
||||
# Trade Sizing
|
||||
# "percentage" = % of trader's trade size, "balance" = % of own balance
|
||||
SIZE_MODE=percentage
|
||||
SIZE_PERCENT=50
|
||||
# ─────────────────────────────────────────────
|
||||
# TRADE SIZING
|
||||
# ─────────────────────────────────────────────
|
||||
# SIZE_MODE:
|
||||
# "percentage" = SIZE_PERCENT% of MAX_POSITION_SIZE per market entry
|
||||
# (e.g. MAX_POSITION_SIZE=$10, SIZE_PERCENT=50 → buy $5 per entry)
|
||||
# "balance" = SIZE_PERCENT% of your current USDC.e balance per entry
|
||||
# (e.g. balance=$100, SIZE_PERCENT=10 → buy $10 per entry)
|
||||
# Note: sizing is independent of the trader's individual fill size.
|
||||
# Limit orders can fill in many small chunks — we always use our own sizing.
|
||||
SIZE_MODE=balance
|
||||
SIZE_PERCENT=10
|
||||
|
||||
# Minimum trade size in USDC (skip if calculated size is below this)
|
||||
MIN_TRADE_SIZE=1
|
||||
|
||||
# Auto Sell
|
||||
# Maximum total position per market in USDC (won't buy more once this is reached)
|
||||
MAX_POSITION_SIZE=10
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# AUTO SELL
|
||||
# ─────────────────────────────────────────────
|
||||
AUTO_SELL_ENABLED=true
|
||||
AUTO_SELL_PROFIT_PERCENT=10
|
||||
|
||||
# Sell Mode when copying trader's sell
|
||||
# "market" = sell at market price, "limit" = sell at trader's avg sell price
|
||||
# Sell mode when copying trader's sell
|
||||
# "market" = sell at market price immediately
|
||||
# "limit" = place limit order at trader's sell price
|
||||
SELL_MODE=market
|
||||
|
||||
# Polling Intervals (in seconds)
|
||||
POLL_INTERVAL=15
|
||||
# ─────────────────────────────────────────────
|
||||
# INTERVALS
|
||||
# ─────────────────────────────────────────────
|
||||
# How often (seconds) to check for resolved markets to redeem
|
||||
REDEEM_INTERVAL=60
|
||||
|
||||
# Dry Run (set to true to simulate without executing trades)
|
||||
# ─────────────────────────────────────────────
|
||||
# DRY RUN (set true to simulate without real trades)
|
||||
# ─────────────────────────────────────────────
|
||||
DRY_RUN=true
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# MARKET MAKER (mm.js / npm run mm-sim)
|
||||
# ─────────────────────────────────────────────
|
||||
# Comma-separated assets to market-make (same slug format as sniper)
|
||||
MM_ASSETS=btc
|
||||
|
||||
# Market duration: "5m" (5-minute) or "15m" (15-minute)
|
||||
MM_DURATION=5m
|
||||
|
||||
# USDC amount per side (total exposure = 2x this)
|
||||
MM_TRADE_SIZE=5
|
||||
|
||||
# Limit sell price target (e.g. 0.60 = sell at $0.60)
|
||||
MM_SELL_PRICE=0.60
|
||||
|
||||
# Seconds before market close to trigger cut-loss
|
||||
MM_CUT_LOSS_TIME=60
|
||||
|
||||
# Keyword to match market question (case-insensitive)
|
||||
MM_MARKET_KEYWORD=Bitcoin Up or Down
|
||||
|
||||
# Max seconds after market open to enter (0 = at open only)
|
||||
MM_ENTRY_WINDOW=45
|
||||
|
||||
# How often to poll for new markets (seconds)
|
||||
MM_POLL_INTERVAL=10
|
||||
|
||||
# ── Recovery Buy (after cut-loss) ───────────────────────────
|
||||
# After cut-loss triggers, monitor prices for 10s and market-buy
|
||||
# the dominant side if criteria are met. Does not affect the main
|
||||
# MM flow — purely an opt-in add-on.
|
||||
#
|
||||
# Enable recovery buy
|
||||
MM_RECOVERY_BUY=false
|
||||
|
||||
# Minimum price the dominant side must be at (and rising/stable) to qualify
|
||||
MM_RECOVERY_THRESHOLD=0.70
|
||||
|
||||
# USDC size for the recovery buy (0 = use MM_TRADE_SIZE)
|
||||
MM_RECOVERY_SIZE=0
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# ORDERBOOK SNIPER (sniper.js / npm run sniper-sim)
|
||||
# Places tiny GTC BUY orders at a low price on both sides of
|
||||
# ETH/SOL/XRP 5-minute markets — catches panic dumps near $0.
|
||||
# ─────────────────────────────────────────────
|
||||
# Comma-separated assets to snipe
|
||||
SNIPER_ASSETS=eth,sol,xrp
|
||||
|
||||
# Buy price per share (1 cent = $0.01)
|
||||
SNIPER_PRICE=0.01
|
||||
|
||||
# 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
|
||||
|
||||
@@ -1,64 +1,272 @@
|
||||
# AGENT.MD — Polymarket Copy Trade Tool
|
||||
# AGENT.md — Polymarket Terminal
|
||||
|
||||
Dokumentasi untuk AI Agent yang akan melanjutkan atau memodifikasi project ini.
|
||||
Developer and AI agent reference for understanding, extending, and maintaining this project.
|
||||
|
||||
## Overview
|
||||
---
|
||||
|
||||
Tool untuk copy trade otomatis dari trader di Polymarket. Dibangun dengan Node.js (ESM), menggunakan Polymarket CLOB SDK.
|
||||
## Project Overview
|
||||
|
||||
**Polymarket Terminal** is a Node.js (ESM) automated trading terminal for [Polymarket](https://polymarket.com). It provides three independent bots:
|
||||
|
||||
| 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 |
|
||||
| Orderbook Sniper | `src/sniper.js` | `npm run sniper` | Place low-price GTC orders to catch panic dumps |
|
||||
|
||||
---
|
||||
|
||||
## Setting Up the Project
|
||||
|
||||
### 1. Prerequisites
|
||||
|
||||
- **Node.js v18+** (project uses `"type": "module"` — native ESM, no Babel/transpilation)
|
||||
- **npm** (comes with Node.js)
|
||||
- A Polygon EOA wallet with:
|
||||
- A small MATIC balance (for gas)
|
||||
- USDC.e deposited via Polymarket's proxy wallet
|
||||
|
||||
### 2. Install Dependencies
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
Key packages:
|
||||
|
||||
| Package | Version | Role |
|
||||
|---|---|---|
|
||||
| `@polymarket/clob-client` | ^4.7.3 | Official Polymarket CLOB SDK (wraps ethers v5) |
|
||||
| `ethers` | ^5.x | Wallet signing, contract calls (pinned to v5 — do not upgrade to v6) |
|
||||
| `dotenv` | ^16.x | `.env` loading |
|
||||
| `blessed` | ^0.1.81 | Terminal dashboard UI |
|
||||
| `ws` | ^8.x | WebSocket client for real-time trade feed |
|
||||
| `nodemon` | ^3.x (dev) | Auto-reload during development |
|
||||
|
||||
### 3. Environment Configuration
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` and fill in all required fields. Refer to `.env.example` for detailed comments on each variable. The minimum required fields for the copy trade bot are:
|
||||
|
||||
```
|
||||
PRIVATE_KEY=0x...
|
||||
PROXY_WALLET_ADDRESS=0x...
|
||||
TRADER_ADDRESS=0x...
|
||||
```
|
||||
|
||||
### 4. Run in Simulation Mode First
|
||||
|
||||
```bash
|
||||
# Copy trade simulation
|
||||
DRY_RUN=true npm start
|
||||
|
||||
# Market maker simulation
|
||||
npm run mm-sim
|
||||
|
||||
# Sniper simulation
|
||||
npm run sniper-sim
|
||||
```
|
||||
|
||||
### 5. Run Live
|
||||
|
||||
```bash
|
||||
# Set DRY_RUN=false in .env, then:
|
||||
npm start # copy trade
|
||||
npm run mm # market maker
|
||||
npm run sniper # sniper
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Config** (`src/config/index.js`): Semua settings dari `.env`, validasi required fields
|
||||
- **Client** (`src/services/client.js`): Inisialisasi `ClobClient` dari `@polymarket/clob-client`, auto-derive API creds, cek balance USDC.e on-chain
|
||||
- **Watcher** (`src/services/watcher.js`): Polling `data-api.polymarket.com/activity?user={address}` untuk deteksi trade baru. Dedup via `processed_trades.json`
|
||||
- **Executor** (`src/services/executor.js`): Execute buy (market FOK order + retry) dan sell (market/limit). Sizing mode: percentage of trader size atau percentage of own balance
|
||||
- **Position** (`src/services/position.js`): CRUD posisi di `positions.json`. Prevent duplicate buy per conditionId (1 market = 1 buy only)
|
||||
- **AutoSell** (`src/services/autoSell.js`): Setelah buy filled, place GTC limit sell di `avgBuyPrice * (1 + profitPercent/100)`. Rounded ke tick size yang valid
|
||||
- **Redeemer** (`src/services/redeemer.js`): Check resolved markets via Gamma API + on-chain CTF `payoutDenominator`. Redeem via CTF `redeemPositions`
|
||||
- **State** (`src/utils/state.js`): Atomic JSON file writes (tmp file + rename) ke folder `data/`
|
||||
- **Logger** (`src/utils/logger.js`): Timestamped, color-coded, emoji-prefixed console logging
|
||||
### Module System
|
||||
|
||||
## Key APIs Used
|
||||
All files use **ES Modules** (`import`/`export`). Do not use `require()`.
|
||||
|
||||
### Configuration (`src/config/index.js`)
|
||||
|
||||
- Loads all settings from `.env` via `dotenv`
|
||||
- Parses and validates required fields per bot type
|
||||
- Exports two validator functions:
|
||||
- `validateCopyTradeConfig()` — for `src/index.js`
|
||||
- `validateMMConfig()` — for `src/mm.js` and `src/sniper.js`
|
||||
- Key exported fields: `privateKey`, `proxyWallet`, `traderAddress`, `dryRun`, `mmTradeSize`, etc.
|
||||
|
||||
### Client (`src/services/client.js`)
|
||||
|
||||
- `initClient()` — Initializes `ClobClient` from `@polymarket/clob-client`
|
||||
- Uses **signature type 2** (`POLY_PROXY`) — EOA signs on behalf of the proxy wallet
|
||||
- Auto-derives API credentials if not provided in `.env`
|
||||
- `getClient()` / `getSigner()` — Singleton getters (call `initClient()` first)
|
||||
- `getPolygonProvider()` — Returns a `JsonRpcProvider` for Polygon mainnet
|
||||
- `getUsdcBalance()` — Reads USDC.e balance from the ERC-20 contract
|
||||
|
||||
### Watcher (`src/services/watcher.js`)
|
||||
|
||||
- **Poll-based** fallback: queries `data-api.polymarket.com/activity?user={address}`
|
||||
- Deduplicates processed trade IDs via `data/processed_trades.json` (max 500 entries)
|
||||
- `fetchMarketInfo(conditionId)` — Fetches market metadata from Gamma API
|
||||
- `fetchMarketByTokenId(tokenId)` — Reverse-lookup market by token
|
||||
|
||||
### WebSocket Watcher (`src/services/wsWatcher.js`)
|
||||
|
||||
- Connects to Polymarket RTDS: `wss://ws-live-data.polymarket.com`
|
||||
- Subscribes to the `user` channel for the target trader address
|
||||
- Filters for `trade` type events matching `MATCHED` status
|
||||
- Auto-reconnects with exponential backoff on disconnect
|
||||
|
||||
### Executor (`src/services/executor.js`)
|
||||
|
||||
- `calculateTradeSize(traderFillSize)` — Computes our order size:
|
||||
- `percentage` mode: `SIZE_PERCENT% of MAX_POSITION_SIZE`
|
||||
- `balance` mode: `SIZE_PERCENT% of current USDC.e balance`
|
||||
- `executeBuy(tokenId, size, price)` — FOK market buy with retry loop
|
||||
- `executeSell(tokenId, shares, mode, price)` — Market or limit sell
|
||||
- `getMarketOptions(tokenId)` — Fetches tick size and `negRisk` flag from CLOB
|
||||
|
||||
### Position Manager (`src/services/position.js`)
|
||||
|
||||
- CRUD operations on `data/positions.json`
|
||||
- Key structure: `{ [conditionId]: { tokenId, shares, avgBuyPrice, autoSellOrderId, ... } }`
|
||||
- Enforces **one position per conditionId** — prevents double entry
|
||||
|
||||
### Auto Sell (`src/services/autoSell.js`)
|
||||
|
||||
- Called after a buy fills successfully
|
||||
- Places a GTC limit SELL at `avgBuyPrice × (1 + AUTO_SELL_PROFIT_PERCENT / 100)`
|
||||
- Price is rounded to the market's valid tick size and clamped to $0.01–$0.99
|
||||
|
||||
### Redeemer (`src/services/redeemer.js`)
|
||||
|
||||
- `checkMarketResolution(conditionId)` — Queries Gamma API for resolved status
|
||||
- `checkOnChainPayout(conditionId)` — Reads `payoutDenominator` and `payoutNumerators` from CTF contract
|
||||
- `redeemPosition(conditionId, tokenId, shares)` — Calls `redeemPositions` on CTF via CLOB client
|
||||
- `simulateRedeem(...)` — Tracks wins/losses in `data/sim_stats.json` during dry-run
|
||||
|
||||
### CTF Contract Helpers (`src/services/ctf.js`)
|
||||
|
||||
Used exclusively by the Market Maker bot. Interacts with the Gnosis Safe proxy wallet.
|
||||
|
||||
- `splitPosition(conditionId, amountUsdc)` — Deposits USDC → mints equal YES+NO tokens at $0.50 each
|
||||
- `mergePositions(conditionId, sharesPerSide)` — Burns equal YES+NO → recovers USDC (no-slippage cut-loss)
|
||||
- `cleanupOpenPositions(clobClient)` — On startup, cancels open orders and merges any leftover positions
|
||||
- `redeemMMPositions()` — Periodic redeemer for resolved markets held by the proxy wallet
|
||||
- `execSafeCall(to, data, description)` — Queued Safe transaction executor (serialized to avoid nonce collisions)
|
||||
|
||||
### Market Maker Detector (`src/services/mmDetector.js`)
|
||||
|
||||
- Deterministic slot-based lookup using slug format: `{asset}-updown-{duration}-{timestamp}`
|
||||
- Polls Gamma API every `MM_POLL_INTERVAL` seconds
|
||||
- Targets the **next upcoming** slot (skips the currently active one)
|
||||
- Tracks seen market IDs to avoid re-entry
|
||||
|
||||
### Market Maker Executor (`src/services/mmExecutor.js`)
|
||||
|
||||
- `executeMMStrategy(market)` — Full flow: split → place limit sells → monitor → cut-loss/redeem
|
||||
- `monitorAndManage(...)` — Watches for fill events; triggers cut-loss when `MM_CUT_LOSS_TIME` is reached
|
||||
- `cutLoss(...)` — Cancels remaining orders; merges back or market-sells residual tokens
|
||||
- `attemptRecoveryBuy(...)` — Optional directional bet post cut-loss (controlled by `MM_RECOVERY_BUY`)
|
||||
|
||||
### Sniper Detector (`src/services/sniperDetector.js`)
|
||||
|
||||
- Scans for 5-minute markets for a configurable list of assets
|
||||
- Checks both current and next upcoming slots
|
||||
- Skips markets with fewer than 30 seconds remaining
|
||||
|
||||
### Sniper Executor (`src/services/sniperExecutor.js`)
|
||||
|
||||
- `executeSnipe(market)` — Places two GTC BUY orders (one YES, one NO) at `SNIPER_PRICE`
|
||||
- Tracks placed orders in memory for display in the terminal UI
|
||||
- Total cost per market = `SNIPER_PRICE × SNIPER_SHARES × 2`
|
||||
|
||||
### Dashboard UI (`src/ui/dashboard.js`)
|
||||
|
||||
- Built with `blessed` — two-panel terminal layout
|
||||
- Left panel: event log (color-coded, auto-scrolling)
|
||||
- Right panel: live positions, balance, and bot status
|
||||
- Handles Ctrl+C and `q` for graceful shutdown
|
||||
|
||||
### Logger (`src/utils/logger.js`)
|
||||
|
||||
- `logger.info()`, `logger.success()`, `logger.warn()`, `logger.error()`
|
||||
- `logger.trade()`, `logger.watch()`, `logger.money()`
|
||||
- Outputs to console (with ANSI colors) or blessed dashboard markup
|
||||
|
||||
### State (`src/utils/state.js`)
|
||||
|
||||
- `readState(filename)` — Read a JSON file from `data/`; returns `{}` if missing
|
||||
- `writeState(filename, data)` — Atomic write (temp file + rename) to prevent partial writes
|
||||
|
||||
---
|
||||
|
||||
## Key API Endpoints
|
||||
|
||||
| API | Base URL | Auth | Purpose |
|
||||
|-----|----------|------|---------|
|
||||
| Gamma API | `gamma-api.polymarket.com` | No | Market info, resolution status |
|
||||
| Data API | `data-api.polymarket.com` | No | Trader activity, positions |
|
||||
| CLOB API | `clob.polymarket.com` | Yes (L2) | Place/cancel orders |
|
||||
| Polygon RPC | `polygon-rpc.com` | No | USDC balance, CTF redeem |
|
||||
|---|---|---|---|
|
||||
| Gamma API | `https://gamma-api.polymarket.com` | None | Market metadata, resolution status |
|
||||
| Data API | `https://data-api.polymarket.com` | None | Trader activity, wallet positions |
|
||||
| CLOB API | `https://clob.polymarket.com` | L2 ECDSA | Place, cancel, and query orders |
|
||||
| RTDS WebSocket | `wss://ws-live-data.polymarket.com` | None | Real-time trade event stream |
|
||||
| Polygon RPC | Configurable via `POLYGON_RPC_URL` | None | On-chain reads and Safe tx submission |
|
||||
|
||||
## Dependencies
|
||||
---
|
||||
|
||||
- `@polymarket/clob-client` — Official Polymarket CLOB SDK (uses ethers v5 internally)
|
||||
- `ethers@5` — Wallet signing, contract interaction
|
||||
- `dotenv` — Environment variable loading
|
||||
- `nodemon` (dev) — Auto-reload on file changes (ignores `data/*.json`)
|
||||
## Contract Addresses (Polygon Mainnet)
|
||||
|
||||
## State Files (data/)
|
||||
| Contract | Address |
|
||||
|---|---|
|
||||
| CTF (ConditionalTokens) | `0x4D97DCd97eC945f40cF65F87097ACe5EA0476045` |
|
||||
| Neg Risk CTF | `0xC5d563A36AE78145C45a50134d48A1215220f80a` |
|
||||
| USDC.e | `0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174` |
|
||||
| CTF Exchange | `0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E` |
|
||||
| Neg Risk Exchange | `0xC5d563A36AE78145C45a50134d48A1215220f80a` |
|
||||
|
||||
- `positions.json` — Active positions: `{ [conditionId]: { tokenId, shares, avgBuyPrice, ... } }`
|
||||
- `processed_trades.json` — Already-handled trade IDs (max 500): `{ tradeIds: [...] }`
|
||||
---
|
||||
|
||||
## Trade Flow
|
||||
## State Files (`data/`)
|
||||
|
||||
1. Watcher detects new BUY → check no existing position → calculate size → check balance → market buy (FOK + retry) → save position → auto-sell if enabled
|
||||
2. Watcher detects new SELL → check position exists → cancel auto-sell order → market/limit sell → remove position
|
||||
3. Redeemer loop → check resolved markets → redeem CTF on-chain → remove position
|
||||
> This directory is **gitignored** and created at runtime.
|
||||
|
||||
## Important Notes
|
||||
| File | Structure | Purpose |
|
||||
|---|---|---|
|
||||
| `positions.json` | `{ [conditionId]: { tokenId, shares, avgBuyPrice, autoSellOrderId, ... } }` | Active copy-trade positions |
|
||||
| `processed_trades.json` | `{ tradeIds: string[] }` | Deduplication (max 500 entries, FIFO eviction) |
|
||||
| `sim_stats.json` | `{ buys, wins, losses, totalPnl, ... }` | Simulation performance statistics |
|
||||
|
||||
- Module type: ESM (`"type": "module"` in package.json)
|
||||
- USDC on Polygon = USDC.e (`0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174`)
|
||||
- CTF Contract = `0x4D97DCd97eC945f40cF65F87097ACe5EA0476045`
|
||||
- Neg Risk CTF = `0xC5d563A36AE78145C45a50134d48A1215220f80a`
|
||||
- Signature type 0 = EOA wallet
|
||||
- Tick sizes: 0.1, 0.01, 0.001, 0.0001
|
||||
- Market orders use FOK (fill-or-kill), limit orders use GTC (good-til-cancelled)
|
||||
- Data API activity response fields vary — code handles multiple field name conventions
|
||||
---
|
||||
|
||||
## Order Types
|
||||
|
||||
| Type | Code | Used for |
|
||||
|---|---|---|
|
||||
| Fill-or-Kill | `FOK` | Market buys and sells (copy trade) |
|
||||
| Good-Til-Cancelled | `GTC` | Auto-sell limits, MM limit sells, sniper orders |
|
||||
|
||||
---
|
||||
|
||||
## Tick Sizes
|
||||
|
||||
Valid limit order prices must align to one of these tick sizes: `0.1`, `0.01`, `0.001`, `0.0001`. The tick size for each market is returned by the CLOB API and respected in `executor.js` and `autoSell.js`.
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- No WebSocket support yet (polling only)
|
||||
- No partial fill tracking for market orders
|
||||
- Redeem requires MATIC for gas
|
||||
- No proxy wallet support (EOA only, signature type 0)
|
||||
- **No partial fill tracking** — market buy retries treat each attempt as all-or-nothing (FOK)
|
||||
- **Redeem requires MATIC** — on-chain CTF redemption costs gas; keep EOA funded
|
||||
- **MM bot requires a Gnosis Safe proxy wallet** — EOA-only wallets are not supported for the MM bot
|
||||
- **Data API response shape varies** — the code handles multiple known field name conventions but new API changes may require updates
|
||||
|
||||
---
|
||||
|
||||
## Development Tips
|
||||
|
||||
- Use `npm run dev`, `npm run mm-dev`, or `npm run sniper-dev` for auto-reload during development
|
||||
- `nodemon` is configured to ignore `data/*.json` to avoid unnecessary restarts on state updates
|
||||
- All `console.log` usage is replaced by `logger.*` — keep it consistent
|
||||
- When adding a new service, export functions individually and import by name; avoid default-export classes
|
||||
- The Safe transaction queue (`_txQueue` in `ctf.js`) is critical — never bypass it or nonce collisions will cause transaction failures
|
||||
|
||||
@@ -1,111 +1,291 @@
|
||||
# 🎯 Polymarket Copy Trade Tool
|
||||
# Polymarket Terminal
|
||||
|
||||
Auto-copy trades dari trader manapun di Polymarket.
|
||||
> An automated trading terminal for [Polymarket](https://polymarket.com) — copy trades, provide liquidity, and snipe low-priced orderbook fills, all from your command line.
|
||||
|
||||
**Created by [@direkturcrypto](https://twitter.com/direkturcrypto)**
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Features](#features)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Installation](#installation)
|
||||
- [Configuration](#configuration)
|
||||
- [Usage](#usage)
|
||||
- [How It Works](#how-it-works)
|
||||
- [Project Structure](#project-structure)
|
||||
- [Important Warnings](#important-warnings)
|
||||
- [Contributing](#contributing)
|
||||
- [License](#license)
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- 👀 **Watch Trader** — Monitor aktivitas trading dari address wallet tertentu
|
||||
- 📊 **Copy Buy** — Otomatis buy ketika trader buy, dengan sizing yang bisa di-setting
|
||||
- 📉 **Copy Sell** — Otomatis sell ketika trader sell (market / limit)
|
||||
- 💰 **Auto Sell** — Pasang limit sell otomatis setelah buy filled (sesuai target profit %)
|
||||
- 🏆 **Auto Redeem** — Cek dan redeem posisi yang sudah WIN secara berkala
|
||||
- 🔄 **Smart Position** — 1 market hanya buy 1x, tidak duplikat
|
||||
- ✅ **Balance Check** — Cek saldo sebelum trade
|
||||
- 🧪 **Dry Run Mode** — Test tanpa eksekusi trade sungguhan
|
||||
### Copy Trade Bot (`npm start`)
|
||||
- **Watch Trader** — Monitor any Polymarket wallet address in real time via WebSocket
|
||||
- **Copy Buy** — Automatically mirror buy orders with configurable position sizing
|
||||
- **Copy Sell** — Automatically mirror sell orders (market or limit)
|
||||
- **Auto Sell** — Place a GTC limit sell at a target profit % immediately after a buy fills
|
||||
- **Auto Redeem** — Periodically check and redeem winning positions on-chain
|
||||
- **Deduplication** — Each market is entered at most once; no double buys
|
||||
- **Balance Guard** — Checks USDC.e balance before every order
|
||||
- **Dry Run Mode** — Simulate the full flow without placing real orders
|
||||
|
||||
## Setup
|
||||
### Market Maker Bot (`npm run mm`)
|
||||
- **Automated Liquidity** — Splits USDC into YES+NO tokens and places limit sells on both sides at $0.50 entry
|
||||
- **Cut-Loss Protection** — Merges unsold tokens back to USDC before market close
|
||||
- **Recovery Buy** — Optional directional bet after a cut-loss triggers
|
||||
- **Multi-Asset** — Supports BTC, ETH, SOL, and any 5m/15m Polymarket market
|
||||
- **Simulation Mode** — Full dry-run with P&L tracking (`npm run mm-sim`)
|
||||
|
||||
### 1. Clone & Install
|
||||
### Orderbook Sniper Bot (`npm run sniper`)
|
||||
- **Low-Price Orders** — Places tiny GTC BUY orders at a configurable price (e.g. $0.01) on both sides
|
||||
- **Multi-Asset** — Targets ETH, SOL, XRP, and more simultaneously
|
||||
- **Simulation Mode** — Preview orders without spending funds (`npm run sniper-sim`)
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Requirement | Details |
|
||||
|---|---|
|
||||
| Node.js | v18 or higher (ESM support required) |
|
||||
| Polygon Wallet | An EOA wallet with a private key |
|
||||
| Polymarket Proxy Wallet | Your proxy wallet address (visible on your Polymarket profile → Deposit) |
|
||||
| USDC.e on Polygon | Deposited via Polymarket's deposit flow |
|
||||
| MATIC on Polygon | A small amount for gas fees (redeem & on-chain operations) |
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
git clone <repo-url>
|
||||
cd polymarket-copy
|
||||
# 1. Clone the repository
|
||||
git clone https://github.com/direkturcrypto/polymarket-terminal.git
|
||||
cd polymarket-terminal
|
||||
|
||||
# 2. Install dependencies
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. Configure Environment
|
||||
|
||||
```bash
|
||||
# 3. Copy the environment template
|
||||
cp .env.example .env
|
||||
|
||||
# 4. Fill in your credentials (see Configuration section below)
|
||||
nano .env # or use your preferred editor
|
||||
```
|
||||
|
||||
Edit `.env` dengan setting Anda:
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
All settings are controlled via the `.env` file. **Never commit your `.env` file** — it is already listed in `.gitignore`.
|
||||
|
||||
### Wallet Setup
|
||||
|
||||
| Variable | Description | Required |
|
||||
|---|---|---|
|
||||
| `PRIVATE_KEY` | Your EOA private key (signing only, does not hold USDC) | Yes |
|
||||
| `PROXY_WALLET_ADDRESS` | Your Polymarket proxy wallet address | Yes |
|
||||
| `POLYGON_RPC_URL` | Polygon JSON-RPC endpoint | Yes |
|
||||
|
||||
> **How to find your Proxy Wallet:** Log in to polymarket.com → click your profile → Deposit → copy the wallet address shown.
|
||||
|
||||
### Polymarket API Credentials (Optional)
|
||||
|
||||
Leave these blank to have the client auto-derive credentials from your private key.
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `CLOB_API_KEY` | CLOB API key |
|
||||
| `CLOB_API_SECRET` | CLOB API secret |
|
||||
| `CLOB_API_PASSPHRASE` | CLOB API passphrase |
|
||||
|
||||
### Copy Trade Bot Settings
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
| `PRIVATE_KEY` | Private key wallet Polygon | (required) |
|
||||
| `WALLET_ADDRESS` | Address wallet Anda | (required) |
|
||||
| `TRADER_ADDRESS` | Address trader yang mau di-copy | (required) |
|
||||
| `SIZE_MODE` | `percentage` (dari size trader) atau `balance` (dari balance sendiri) | `percentage` |
|
||||
| `SIZE_PERCENT` | Persentase sizing | `50` |
|
||||
| `MIN_TRADE_SIZE` | Minimum trade dalam USDC | `1` |
|
||||
| `AUTO_SELL_ENABLED` | Aktifkan auto-sell | `true` |
|
||||
| `AUTO_SELL_PROFIT_PERCENT` | Target profit % untuk auto-sell | `10` |
|
||||
| `SELL_MODE` | `market` atau `limit` saat copy sell | `market` |
|
||||
| `POLL_INTERVAL` | Interval polling (detik) | `15` |
|
||||
| `REDEEM_INTERVAL` | Interval cek redeem (detik) | `60` |
|
||||
| `DRY_RUN` | Mode simulasi tanpa real trade | `true` |
|
||||
| `TRADER_ADDRESS` | Proxy wallet address of the trader to copy | (required) |
|
||||
| `SIZE_MODE` | `percentage` (of `MAX_POSITION_SIZE`) or `balance` (of your USDC balance) | `balance` |
|
||||
| `SIZE_PERCENT` | Percentage to use per trade | `10` |
|
||||
| `MIN_TRADE_SIZE` | Minimum trade size in USDC (skip if below) | `1` |
|
||||
| `MAX_POSITION_SIZE` | Maximum USDC per market position | `10` |
|
||||
| `AUTO_SELL_ENABLED` | Place a limit sell after each buy fills | `true` |
|
||||
| `AUTO_SELL_PROFIT_PERCENT` | Target profit % for the auto-sell limit order | `10` |
|
||||
| `SELL_MODE` | `market` or `limit` when copying a sell | `market` |
|
||||
| `REDEEM_INTERVAL` | Seconds between redemption checks | `60` |
|
||||
| `DRY_RUN` | Simulate without placing real orders | `true` |
|
||||
|
||||
### 3. Run
|
||||
### Market Maker Bot Settings
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
| `MM_ASSETS` | Comma-separated assets to market-make (e.g. `btc,eth`) | `btc` |
|
||||
| `MM_DURATION` | Market duration: `5m` or `15m` | `5m` |
|
||||
| `MM_TRADE_SIZE` | USDC per side (total exposure = 2×) | `5` |
|
||||
| `MM_SELL_PRICE` | Limit sell price target (e.g. `0.60`) | `0.60` |
|
||||
| `MM_CUT_LOSS_TIME` | Seconds before close to trigger cut-loss | `60` |
|
||||
| `MM_MARKET_KEYWORD` | Keyword to filter market questions | `Bitcoin Up or Down` |
|
||||
| `MM_ENTRY_WINDOW` | Max seconds after open to enter (0 = open only) | `45` |
|
||||
| `MM_POLL_INTERVAL` | Seconds between new market polls | `10` |
|
||||
| `MM_RECOVERY_BUY` | Enable recovery buy after cut-loss | `false` |
|
||||
| `MM_RECOVERY_THRESHOLD` | Minimum dominant-side price to qualify for recovery | `0.70` |
|
||||
| `MM_RECOVERY_SIZE` | USDC for recovery buy (0 = use `MM_TRADE_SIZE`) | `0` |
|
||||
|
||||
### Orderbook Sniper Settings
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
| `SNIPER_ASSETS` | Comma-separated assets to snipe (e.g. `eth,sol,xrp`) | `eth,sol,xrp` |
|
||||
| `SNIPER_PRICE` | Buy price per share (e.g. `0.01` = $0.01) | `0.01` |
|
||||
| `SNIPER_SHARES` | Shares per side (minimum 5 per Polymarket rules) | `5` |
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Development (auto-reload)
|
||||
npm run dev
|
||||
# ── Copy Trade Bot ─────────────────────────────────
|
||||
npm start # Production mode
|
||||
npm run dev # Development mode (auto-reload on file changes)
|
||||
|
||||
# Production
|
||||
npm start
|
||||
# ── 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
|
||||
|
||||
# ── 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
|
||||
```
|
||||
|
||||
> **Always test with `DRY_RUN=true` first** before committing real funds.
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ WATCHER LOOP │
|
||||
│ Poll Data API setiap N detik │
|
||||
│ → Cek trade baru dari trader │
|
||||
├─────────────────┬───────────────────────────┤
|
||||
│ NEW BUY │ NEW SELL │
|
||||
│ │ │
|
||||
│ ✓ Cek posisi │ ✓ Cek ada posisi? │
|
||||
│ ✓ Cek balance │ ✓ Cancel auto-sell │
|
||||
│ ✓ Market order │ ✓ Market/Limit sell │
|
||||
│ ✓ Retry loop │ ✓ Retry loop │
|
||||
│ ✓ Auto-sell │ ✓ Remove position │
|
||||
│ ✓ Save posisi │ │
|
||||
├─────────────────┴───────────────────────────┤
|
||||
│ REDEEMER LOOP │
|
||||
│ Cek berkala posisi yang sudah WIN │
|
||||
│ → Redeem on-chain via CTF contract │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Folder Structure
|
||||
### Copy Trade Bot Flow
|
||||
|
||||
```
|
||||
polymarket-copy/
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ WATCHER LOOP │
|
||||
│ WebSocket (RTDS) — real-time trade events from trader │
|
||||
│ Fallback: poll Data API every N seconds │
|
||||
├───────────────────────┬──────────────────────────────────┤
|
||||
│ NEW BUY │ NEW SELL │
|
||||
│ │ │
|
||||
│ ✓ Check position │ ✓ Check position exists │
|
||||
│ ✓ Check USDC balance │ ✓ Cancel existing auto-sell │
|
||||
│ ✓ Market buy (FOK) │ ✓ Market / limit sell │
|
||||
│ ✓ Retry on failure │ ✓ Retry on failure │
|
||||
│ ✓ Place auto-sell │ ✓ Remove position from state │
|
||||
│ ✓ Save position │ │
|
||||
├───────────────────────┴──────────────────────────────────┤
|
||||
│ REDEEMER LOOP │
|
||||
│ Periodically checks resolved markets │
|
||||
│ → Redeems winning positions via CTF contract on-chain │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Market Maker Flow
|
||||
|
||||
```
|
||||
New Market Detected
|
||||
│
|
||||
▼
|
||||
Split USDC → YES + NO tokens ($0.50 each, zero slippage)
|
||||
│
|
||||
▼
|
||||
Place limit SELL on both sides at MM_SELL_PRICE
|
||||
│
|
||||
▼
|
||||
Monitor fills every few seconds
|
||||
│
|
||||
┌────┴────┐
|
||||
│ │
|
||||
Fill Time < MM_CUT_LOSS_TIME
|
||||
│ │
|
||||
▼ ▼
|
||||
Collect Cancel orders → Merge YES+NO back to USDC
|
||||
profit (recovery buy optional)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
polymarket-terminal/
|
||||
├── src/
|
||||
│ ├── config/index.js — Environment vars & settings
|
||||
│ ├── index.js — Copy trade bot entry point
|
||||
│ ├── mm.js — Market maker bot entry point
|
||||
│ ├── sniper.js — Orderbook sniper bot entry point
|
||||
│ │
|
||||
│ ├── config/
|
||||
│ │ └── index.js — Environment variable loading & validation
|
||||
│ │
|
||||
│ ├── services/
|
||||
│ │ ├── client.js — CLOB client init & balance check
|
||||
│ │ ├── watcher.js — Poll trader activity
|
||||
│ │ ├── executor.js — Buy & sell logic
|
||||
│ │ ├── position.js — Position management
|
||||
│ │ ├── autoSell.js — Auto limit sell
|
||||
│ │ └── redeemer.js — Redeem winning positions
|
||||
│ ├── utils/
|
||||
│ │ ├── logger.js — Color-coded logging
|
||||
│ │ └── state.js — JSON state management
|
||||
│ └── index.js — Main entry point
|
||||
├── data/ — Runtime state (gitignored)
|
||||
├── .env.example
|
||||
│ │ ├── client.js — CLOB client initialization & USDC balance
|
||||
│ │ ├── watcher.js — Poll-based trader activity detection
|
||||
│ │ ├── wsWatcher.js — WebSocket real-time trade listener
|
||||
│ │ ├── executor.js — Buy & sell order execution logic
|
||||
│ │ ├── position.js — Position state management (CRUD)
|
||||
│ │ ├── autoSell.js — Auto limit-sell placement
|
||||
│ │ ├── redeemer.js — Market resolution check & CTF redemption
|
||||
│ │ ├── ctf.js — On-chain CTF contract interactions (MM bot)
|
||||
│ │ ├── mmDetector.js — Market detection for market maker
|
||||
│ │ ├── mmExecutor.js — Market maker strategy execution
|
||||
│ │ ├── sniperDetector.js — Market detection for sniper
|
||||
│ │ └── sniperExecutor.js — Orderbook sniper order placement
|
||||
│ │
|
||||
│ ├── ui/
|
||||
│ │ └── dashboard.js — Terminal UI (blessed)
|
||||
│ │
|
||||
│ └── utils/
|
||||
│ ├── logger.js — Color-coded, timestamped logging
|
||||
│ ├── state.js — Atomic JSON state file management
|
||||
│ └── simStats.js — Simulation P&L statistics
|
||||
│
|
||||
├── data/ — Runtime state files (gitignored)
|
||||
├── .env.example — Configuration template
|
||||
├── .gitignore
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
---
|
||||
|
||||
- ⚠️ **Test dengan DRY_RUN=true** terlebih dahulu
|
||||
- ⚠️ **Gunakan SIZE_PERCENT kecil** untuk percobaan awal
|
||||
- ⚠️ **Private key jangan di-commit** — sudah ada di .gitignore
|
||||
- Butuh USDC.e di Polygon untuk trading
|
||||
- Butuh sedikit MATIC untuk gas fee (redeem positions)
|
||||
## 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.
|
||||
- **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)**.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
ISC License — see [LICENSE](LICENSE) for details.
|
||||
|
||||
Generated
+201
-195
@@ -9,19 +9,41 @@
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@polymarket/clob-client": "^5.2.4",
|
||||
"dotenv": "^17.3.1",
|
||||
"ethers": "^5.8.0"
|
||||
"@polymarket/clob-client": "^4.7.3",
|
||||
"blessed": "^0.1.81",
|
||||
"dotenv": "^16.4.7",
|
||||
"ethers": "^5.8.0",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.14"
|
||||
"nodemon": "^3.1.9"
|
||||
}
|
||||
},
|
||||
"node_modules/@adraffy/ens-normalize": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz",
|
||||
"integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==",
|
||||
"license": "MIT"
|
||||
"node_modules/@ethereumjs/rlp": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@ethereumjs/rlp/-/rlp-4.0.1.tgz",
|
||||
"integrity": "sha512-tqsQiBQDQdmPWE1xkkBq4rlSW5QZpLOUJ5RJh2/9fug+q9tnUhuZoVLk7s0scUIKTOzEtR72DFBXI4WiZcMpvw==",
|
||||
"license": "MPL-2.0",
|
||||
"bin": {
|
||||
"rlp": "bin/rlp"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@ethereumjs/util": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@ethereumjs/util/-/util-8.1.0.tgz",
|
||||
"integrity": "sha512-zQ0IqbdX8FZ9aw11vP+dZkKDkS+kgIvQPHnSAXzP9pLu+Rfu3D3XEeLbicvoXJTYnhZiPmsZUxgdzXwNKxRPbA==",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"@ethereumjs/rlp": "^4.0.1",
|
||||
"ethereum-cryptography": "^2.0.0",
|
||||
"micro-ftch": "^0.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@ethersproject/abi": {
|
||||
"version": "5.8.0",
|
||||
@@ -467,6 +489,27 @@
|
||||
"ws": "8.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ethersproject/providers/node_modules/ws": {
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
|
||||
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@ethersproject/random": {
|
||||
"version": "5.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@ethersproject/random/-/random-5.8.0.tgz",
|
||||
@@ -724,40 +767,48 @@
|
||||
"@ethersproject/strings": "^5.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/ciphers": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
|
||||
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
"node_modules/@metamask/eth-sig-util": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@metamask/eth-sig-util/-/eth-sig-util-5.1.0.tgz",
|
||||
"integrity": "sha512-mlgziIHYlA9pi/XZerChqg4NocdOgBPB9NmxgXWQO2U2hH8RGOJQrz6j/AIKkYxgCMIE2PY000+joOwXfzeTDQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@ethereumjs/util": "^8.0.6",
|
||||
"bn.js": "^4.12.0",
|
||||
"ethereum-cryptography": "^2.0.0",
|
||||
"ethjs-util": "^0.1.6",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"tweetnacl-util": "^0.15.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@metamask/eth-sig-util/node_modules/bn.js": {
|
||||
"version": "4.12.3",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
|
||||
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@noble/curves": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz",
|
||||
"integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==",
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.4.2.tgz",
|
||||
"integrity": "sha512-TavHr8qycMChk8UwMld0ZDRvatedkzWfH8IiaeGCfymOP5i0hSCozz9vHOL0nkwk7HRMlFnAiKpS2jrUmSybcw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "1.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
"@noble/hashes": "1.4.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz",
|
||||
"integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
@@ -775,73 +826,73 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@polymarket/clob-client": {
|
||||
"version": "5.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@polymarket/clob-client/-/clob-client-5.2.4.tgz",
|
||||
"integrity": "sha512-kBTEEHNE111FmD4NKcHwRIL7DmCYqIu1iHTpbkUrwh08cjUSuz4caFUAYbyKiVFmyknSUblKmcAZnNdbY+KV5Q==",
|
||||
"version": "4.22.8",
|
||||
"resolved": "https://registry.npmjs.org/@polymarket/clob-client/-/clob-client-4.22.8.tgz",
|
||||
"integrity": "sha512-kwiOeTrZ4pVBaAlxi78rR7gXlvofFohlgSQAXslx7xsfMnwaDAjp3zr7+co+Kf+YGrBbU9ztEVM/FxMSm38jxA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ethersproject/providers": "^5.7.2",
|
||||
"@ethersproject/units": "^5.7.0",
|
||||
"@ethersproject/wallet": "^5.7.0",
|
||||
"@polymarket/builder-signing-sdk": "^0.0.8",
|
||||
"@polymarket/order-utils": "^3.0.1",
|
||||
"axios": "^1.0.0",
|
||||
"@polymarket/order-utils": "^2.1.0",
|
||||
"axios": "^0.27.2",
|
||||
"browser-or-node": "^2.1.1",
|
||||
"ethers": "^5.7.1",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.10"
|
||||
"ethers": "^5.7.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@polymarket/clob-client/node_modules/axios": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz",
|
||||
"integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.14.9",
|
||||
"form-data": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@polymarket/order-utils": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@polymarket/order-utils/-/order-utils-3.0.1.tgz",
|
||||
"integrity": "sha512-XVcVladfGtC/VmboMkcszqYs82rvath/0XFWqzIFfq8O4atVOU8ykPOGJ2ZfodBPcXETzL+2u1rcepLMLKu9AQ==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@polymarket/order-utils/-/order-utils-2.1.0.tgz",
|
||||
"integrity": "sha512-vUFj1WiEtm4vB0dxx2NweKKioPu7ehBoJ8fTTaVbR8Dur6AazVWyS+KhgrDE66fKTNneOxxgfdEpbNgxZ4pOIA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ethersproject/providers": "^5.7.2",
|
||||
"@ethersproject/wallet": "^5.7.0",
|
||||
"ethers": "^5.7.1",
|
||||
"tslib": "^2.4.0",
|
||||
"viem": "^2.31.4"
|
||||
"@metamask/eth-sig-util": "^5.0.0",
|
||||
"ethers": "^5.7.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.10",
|
||||
"yarn": ">=1"
|
||||
"node": ">=8",
|
||||
"npm": ">=5"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/base": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz",
|
||||
"integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==",
|
||||
"version": "1.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.9.tgz",
|
||||
"integrity": "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip32": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz",
|
||||
"integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==",
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.4.0.tgz",
|
||||
"integrity": "sha512-sVUpc0Vq3tXCkDGYVWGIZTRfnvu8LoTDaev7vbwh0omSvVORONr960MQWdKqJDCReIEmTj3PAr73O3aoxz7OPg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/curves": "~1.9.0",
|
||||
"@noble/hashes": "~1.8.0",
|
||||
"@scure/base": "~1.2.5"
|
||||
"@noble/curves": "~1.4.0",
|
||||
"@noble/hashes": "~1.4.0",
|
||||
"@scure/base": "~1.1.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip39": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz",
|
||||
"integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==",
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.3.0.tgz",
|
||||
"integrity": "sha512-disdg7gHuTDZtY+ZdkmLpPCk7fxZSu3gBiEGuoC1XYxv9cGx3Z6cpTggCgW6odSOOIXCiDjuGejW+aJKCY/pIQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "~1.8.0",
|
||||
"@scure/base": "~1.2.5"
|
||||
"@noble/hashes": "~1.4.0",
|
||||
"@scure/base": "~1.1.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
@@ -856,27 +907,6 @@
|
||||
"undici-types": "~5.26.4"
|
||||
}
|
||||
},
|
||||
"node_modules/abitype": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz",
|
||||
"integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/wevm"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=5.0.4",
|
||||
"zod": "^3.22.0 || ^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
},
|
||||
"zod": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/aes-js": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.0.0.tgz",
|
||||
@@ -943,6 +973,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/blessed": {
|
||||
"version": "0.1.81",
|
||||
"resolved": "https://registry.npmjs.org/blessed/-/blessed-0.1.81.tgz",
|
||||
"integrity": "sha512-LoF5gae+hlmfORcG1M5+5XZi4LBmvlXTzwJWzUlPryN/SJdSflZvROM2TwkT0GMpq7oqT48NRd4GS7BiVBc5OQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"blessed": "bin/tput.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bn.js": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.3.tgz",
|
||||
@@ -1065,9 +1107,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.3.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
|
||||
"integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==",
|
||||
"version": "16.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -1156,6 +1198,18 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ethereum-cryptography": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ethereum-cryptography/-/ethereum-cryptography-2.2.1.tgz",
|
||||
"integrity": "sha512-r/W8lkHSiTLxUxW8Rf3u4HGB0xQweG2RyETjywylKZSzLWoWAijRz8WCuOtJ6wah+avllXBqZuk29HCCvhEIRg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/curves": "1.4.2",
|
||||
"@noble/hashes": "1.4.0",
|
||||
"@scure/bip32": "1.4.0",
|
||||
"@scure/bip39": "1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ethers": {
|
||||
"version": "5.8.0",
|
||||
"resolved": "https://registry.npmjs.org/ethers/-/ethers-5.8.0.tgz",
|
||||
@@ -1204,11 +1258,19 @@
|
||||
"@ethersproject/wordlists": "5.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
|
||||
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
|
||||
"license": "MIT"
|
||||
"node_modules/ethjs-util": {
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmjs.org/ethjs-util/-/ethjs-util-0.1.6.tgz",
|
||||
"integrity": "sha512-CUnVOQq7gSpDHZVVrQW8ExxUETWrnrvXYvYz55wOU8Uj4VCgw56XC2B/fVqQN+f7gmrnRHSLVnFAwsCuNwji8w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-hex-prefixed": "1.0.0",
|
||||
"strip-hex-prefix": "1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.5.0",
|
||||
"npm": ">=3"
|
||||
}
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
@@ -1464,6 +1526,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-hex-prefixed": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-hex-prefixed/-/is-hex-prefixed-1.0.0.tgz",
|
||||
"integrity": "sha512-WvtOiug1VFrE9v1Cydwm+FnXd3+w9GaeVUss5W4v/SLy3UW00vP+6iNF2SdnfiBoLy4bTqVdkftNGTUeOFVsbA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.5.0",
|
||||
"npm": ">=3"
|
||||
}
|
||||
},
|
||||
"node_modules/is-number": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
@@ -1474,21 +1546,6 @@
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/isows": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz",
|
||||
"integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wevm"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"ws": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/js-sha3": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz",
|
||||
@@ -1504,6 +1561,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/micro-ftch": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/micro-ftch/-/micro-ftch-0.3.1.tgz",
|
||||
"integrity": "sha512-/0LLxhzP0tfiR5hcQebtudP56gUurs2CLkGarnCiB/OqEyUFQ6U3paQi/tgLv0hBJYt2rnr9MNpxz4fiiugstg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
@@ -1599,36 +1662,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ox": {
|
||||
"version": "0.12.4",
|
||||
"resolved": "https://registry.npmjs.org/ox/-/ox-0.12.4.tgz",
|
||||
"integrity": "sha512-+P+C7QzuwPV8lu79dOwjBKfB2CbnbEXe/hfyyrff1drrO1nOOj3Hc87svHfcW1yneRr3WXaKr6nz11nq+/DF9Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wevm"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@adraffy/ens-normalize": "^1.11.0",
|
||||
"@noble/ciphers": "^1.3.0",
|
||||
"@noble/curves": "1.9.1",
|
||||
"@noble/hashes": "^1.8.0",
|
||||
"@scure/bip32": "^1.7.0",
|
||||
"@scure/bip39": "^1.6.0",
|
||||
"abitype": "^1.2.3",
|
||||
"eventemitter3": "5.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=5.4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
@@ -1700,6 +1733,19 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-hex-prefix": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-hex-prefix/-/strip-hex-prefix-1.0.0.tgz",
|
||||
"integrity": "sha512-q8d4ue7JGEiVcypji1bALTos+0pWtyGlivAWyPuTkHzuTCJqrK9sWxYQZUq6Nq3cuyv3bm734IhHvHtGGURU6A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-hex-prefixed": "1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.5.0",
|
||||
"npm": ">=3"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
||||
@@ -1742,6 +1788,18 @@
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tweetnacl": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
|
||||
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/tweetnacl-util": {
|
||||
"version": "0.15.1",
|
||||
"resolved": "https://registry.npmjs.org/tweetnacl-util/-/tweetnacl-util-0.15.1.tgz",
|
||||
"integrity": "sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/undefsafe": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
||||
@@ -1755,63 +1813,11 @@
|
||||
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/viem": {
|
||||
"version": "2.46.2",
|
||||
"resolved": "https://registry.npmjs.org/viem/-/viem-2.46.2.tgz",
|
||||
"integrity": "sha512-w8Qv5Vyo7TfXcH3vgmxRa1NRvzJCDy2aSGSRsJn3503nC/qVbgEQ+n3aj/CkqWXbloudZh97h5o5aQrQSVGy0w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wevm"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/curves": "1.9.1",
|
||||
"@noble/hashes": "1.8.0",
|
||||
"@scure/bip32": "1.7.0",
|
||||
"@scure/bip39": "1.6.0",
|
||||
"abitype": "1.2.3",
|
||||
"isows": "1.0.7",
|
||||
"ox": "0.12.4",
|
||||
"ws": "8.18.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=5.0.4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/viem/node_modules/ws": {
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
|
||||
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
|
||||
+18
-6
@@ -1,22 +1,34 @@
|
||||
{
|
||||
"name": "polymarket-copy",
|
||||
"name": "polymarket-terminal",
|
||||
"version": "1.0.0",
|
||||
"description": "Polymarket Copy Trade Tool - Auto copy trades from any trader",
|
||||
"description": "Automated trading terminal for Polymarket — copy trades, market make, and snipe orderbooks from the command line",
|
||||
"main": "src/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "nodemon --ignore 'data/*.json' src/index.js"
|
||||
"dev": "nodemon --ignore 'data/*.json' src/index.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",
|
||||
"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"
|
||||
},
|
||||
"keywords": ["polymarket", "copy-trade", "crypto"],
|
||||
"keywords": [
|
||||
"polymarket",
|
||||
"copy-trade",
|
||||
"crypto"
|
||||
],
|
||||
"author": "direkturcrypto",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@polymarket/clob-client": "^4.7.3",
|
||||
"blessed": "^0.1.81",
|
||||
"dotenv": "^16.4.7",
|
||||
"ethers": "^5.8.0"
|
||||
"ethers": "^5.8.0",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.9"
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
-6
@@ -3,8 +3,8 @@ dotenv.config();
|
||||
|
||||
const config = {
|
||||
// Wallet
|
||||
privateKey: process.env.PRIVATE_KEY,
|
||||
walletAddress: process.env.WALLET_ADDRESS,
|
||||
privateKey: process.env.PRIVATE_KEY, // EOA private key (for signing only)
|
||||
proxyWallet: process.env.PROXY_WALLET_ADDRESS, // Polymarket proxy wallet (deposit USDC here)
|
||||
|
||||
// Polymarket API (optional, auto-derived if empty)
|
||||
clobApiKey: process.env.CLOB_API_KEY || '',
|
||||
@@ -17,6 +17,9 @@ const config = {
|
||||
dataHost: 'https://data-api.polymarket.com',
|
||||
chainId: 137,
|
||||
|
||||
// Polygon RPC
|
||||
polygonRpcUrl: process.env.POLYGON_RPC_URL || 'https://polygon-bor-rpc.publicnode.com',
|
||||
|
||||
// Trader to copy
|
||||
traderAddress: process.env.TRADER_ADDRESS,
|
||||
|
||||
@@ -24,6 +27,7 @@ const config = {
|
||||
sizeMode: process.env.SIZE_MODE || 'percentage', // "percentage" | "balance"
|
||||
sizePercent: parseFloat(process.env.SIZE_PERCENT || '50'),
|
||||
minTradeSize: parseFloat(process.env.MIN_TRADE_SIZE || '1'),
|
||||
maxPositionSize: parseFloat(process.env.MAX_POSITION_SIZE || '10'),
|
||||
|
||||
// Auto sell
|
||||
autoSellEnabled: process.env.AUTO_SELL_ENABLED === 'true',
|
||||
@@ -32,8 +36,7 @@ const config = {
|
||||
// Sell mode when copying sell
|
||||
sellMode: process.env.SELL_MODE || 'market', // "market" | "limit"
|
||||
|
||||
// Polling intervals (seconds)
|
||||
pollInterval: parseInt(process.env.POLL_INTERVAL || '15', 10) * 1000,
|
||||
// Redeem interval (seconds)
|
||||
redeemInterval: parseInt(process.env.REDEEM_INTERVAL || '60', 10) * 1000,
|
||||
|
||||
// Dry run
|
||||
@@ -42,11 +45,37 @@ const config = {
|
||||
// Retry settings
|
||||
maxRetries: 5,
|
||||
retryDelay: 3000,
|
||||
|
||||
// ── 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,
|
||||
|
||||
// ── 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',
|
||||
mmRecoveryThreshold: parseFloat(process.env.MM_RECOVERY_THRESHOLD || '0.70'), // min price to qualify
|
||||
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.
|
||||
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
|
||||
};
|
||||
|
||||
// Validation
|
||||
// Validation for copy-trade bot
|
||||
export function validateConfig() {
|
||||
const required = ['privateKey', 'walletAddress', 'traderAddress'];
|
||||
const required = ['privateKey', 'proxyWallet', 'traderAddress'];
|
||||
const missing = required.filter((key) => !config[key]);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Missing required config: ${missing.join(', ')}. Check your .env file.`);
|
||||
@@ -59,4 +88,16 @@ export function validateConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
// Validation for market-maker bot
|
||||
export function validateMMConfig() {
|
||||
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.mmTradeSize <= 0) throw new Error('MM_TRADE_SIZE must be > 0');
|
||||
if (config.mmSellPrice <= 0 || config.mmSellPrice >= 1)
|
||||
throw new Error('MM_SELL_PRICE must be between 0 and 1');
|
||||
}
|
||||
|
||||
export default config;
|
||||
|
||||
+167
-78
@@ -1,68 +1,153 @@
|
||||
import config, { validateConfig } from './config/index.js';
|
||||
import { initClient, getUsdcBalance } from './services/client.js';
|
||||
import { checkNewTrades, markTradeProcessed } from './services/watcher.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 { initDashboard, appendLog, updateStatus } from './ui/dashboard.js';
|
||||
import logger from './utils/logger.js';
|
||||
|
||||
// ASCII Art Banner
|
||||
function showBanner() {
|
||||
console.log(`
|
||||
\x1b[36m╔══════════════════════════════════════════════════════╗
|
||||
║ 🎯 POLYMARKET COPY TRADE TOOL 🎯 ║
|
||||
║ Auto-copy trades from any trader ║
|
||||
╚══════════════════════════════════════════════════════╝\x1b[0m
|
||||
`);
|
||||
}
|
||||
// ── Dashboard init (before any log output) ────────────────────────────────────
|
||||
initDashboard();
|
||||
logger.setOutput(appendLog);
|
||||
|
||||
// Show current settings
|
||||
function showSettings() {
|
||||
logger.info('=== Settings ===');
|
||||
logger.info(`Trader: ${config.traderAddress}`);
|
||||
logger.info(`Size Mode: ${config.sizeMode} (${config.sizePercent}%)`);
|
||||
logger.info(`Min Trade Size: $${config.minTradeSize}`);
|
||||
logger.info(`Auto Sell: ${config.autoSellEnabled ? `ON (${config.autoSellProfitPercent}% profit)` : 'OFF'}`);
|
||||
logger.info(`Sell Mode: ${config.sellMode}`);
|
||||
logger.info(`Poll Interval: ${config.pollInterval / 1000}s`);
|
||||
logger.info(`Redeem Interval: ${config.redeemInterval / 1000}s`);
|
||||
logger.info(`Dry Run: ${config.dryRun ? 'YES (no real trades)' : 'NO (live trading!)'}`);
|
||||
logger.info('================');
|
||||
}
|
||||
|
||||
// Main watcher loop
|
||||
async function watcherLoop() {
|
||||
// ── Handle a trade event from WebSocket ───────────────────────────────────────
|
||||
async function handleTrade(trade) {
|
||||
try {
|
||||
logger.watch('Checking trader activity...');
|
||||
const newTrades = await checkNewTrades();
|
||||
|
||||
if (newTrades.length === 0) {
|
||||
logger.watch('No new trades from trader');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.watch(`Found ${newTrades.length} new trade(s) from trader`);
|
||||
|
||||
for (const trade of newTrades) {
|
||||
try {
|
||||
if (trade.type === 'BUY') {
|
||||
await executeBuy(trade);
|
||||
} else if (trade.type === 'SELL') {
|
||||
await executeSell(trade);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Error processing trade ${trade.id}:`, err.message);
|
||||
}
|
||||
|
||||
// Mark as processed regardless of success/failure
|
||||
markTradeProcessed(trade.id);
|
||||
if (trade.type === 'BUY') {
|
||||
await executeBuy(trade);
|
||||
} else if (trade.type === 'SELL') {
|
||||
await executeSell(trade);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Watcher loop error:', err.message);
|
||||
logger.error(`Error processing trade ${trade.id}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Redeemer loop
|
||||
// ── Build the right-panel status content ──────────────────────────────────────
|
||||
async function buildStatusContent() {
|
||||
const lines = [];
|
||||
|
||||
// Header
|
||||
const mode = config.dryRun ? '{yellow-fg}[SIMULATION]{/yellow-fg}' : '{red-fg}[LIVE TRADING]{/red-fg}';
|
||||
lines.push(` ${mode} Trader: {cyan-fg}${config.traderAddress.substring(0, 12)}…{/cyan-fg}`);
|
||||
lines.push(` {gray-fg}${'─'.repeat(36)}{/gray-fg}`);
|
||||
|
||||
// Balance
|
||||
try {
|
||||
const balance = await getUsdcBalance();
|
||||
const balColor = balance > 0 ? 'green-fg' : 'gray-fg';
|
||||
lines.push(` {yellow-fg}💵 Balance:{/yellow-fg} {bold}{${balColor}}$${balance.toFixed(2)} USDC.e{/${balColor}}{/bold}`);
|
||||
} catch {
|
||||
lines.push(` {yellow-fg}💵 Balance:{/yellow-fg} {gray-fg}N/A{/gray-fg}`);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
// Open positions
|
||||
const positions = getOpenPositions();
|
||||
if (positions.length === 0) {
|
||||
lines.push(` {gray-fg}No open positions{/gray-fg}`);
|
||||
} else {
|
||||
lines.push(` {cyan-fg}📈 POSITIONS (${positions.length}){/cyan-fg}`);
|
||||
lines.push(` {gray-fg}${'─'.repeat(36)}{/gray-fg}`);
|
||||
|
||||
for (let i = 0; i < positions.length; i++) {
|
||||
const pos = positions[i];
|
||||
|
||||
// Fetch current midpoint price (best-effort)
|
||||
let currentPrice = null;
|
||||
try {
|
||||
const client = getClient();
|
||||
const mp = await client.getMidpoint(pos.tokenId);
|
||||
currentPrice = parseFloat(mp?.mid ?? mp ?? '0');
|
||||
if (!currentPrice || isNaN(currentPrice)) currentPrice = null;
|
||||
} catch { /* price unavailable */ }
|
||||
|
||||
const marketName = (pos.market || pos.tokenId || '').substring(0, 30);
|
||||
const spent = pos.totalCost || 0;
|
||||
const maxStr = config.maxPositionSize;
|
||||
const bar = buildBar(spent, maxStr, 18);
|
||||
|
||||
lines.push('');
|
||||
lines.push(` {bold}{white-fg}${i + 1}. ${marketName}{/white-fg}{/bold}`);
|
||||
lines.push(` {cyan-fg}${pos.outcome || '?'}{/cyan-fg} | ${pos.shares.toFixed(3)} sh @ $${pos.avgBuyPrice.toFixed(3)}`);
|
||||
lines.push(` ${bar} {yellow-fg}$${spent.toFixed(2)}/$${maxStr}{/yellow-fg}`);
|
||||
|
||||
if (currentPrice !== null) {
|
||||
const pnl = (currentPrice - pos.avgBuyPrice) * pos.shares;
|
||||
const pct = spent > 0 ? ((pnl / spent) * 100).toFixed(1) : '0.0';
|
||||
const sign = pnl >= 0 ? '+' : '';
|
||||
const pnlColor = pnl >= 0 ? 'green-fg' : 'red-fg';
|
||||
lines.push(` $${currentPrice.toFixed(3)} now | {${pnlColor}}${sign}$${pnl.toFixed(2)} (${sign}${pct}%){/${pnlColor}}`);
|
||||
} else {
|
||||
lines.push(` {gray-fg}Price unavailable{/gray-fg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Simulation stats (only when dryRun)
|
||||
if (config.dryRun) {
|
||||
const s = getSimStats();
|
||||
lines.push('');
|
||||
lines.push(` {gray-fg}${'─'.repeat(36)}{/gray-fg}`);
|
||||
lines.push(` {magenta-fg}📊 SIMULATION STATS{/magenta-fg}`);
|
||||
lines.push(` Buys tracked : ${s.totalBuys}`);
|
||||
lines.push(` Resolved : ${s.totalResolved}`);
|
||||
|
||||
if (s.totalResolved > 0) {
|
||||
const rate = ((s.wins / s.totalResolved) * 100).toFixed(0);
|
||||
lines.push(
|
||||
` {green-fg}Wins: ${s.wins}{/green-fg} {red-fg}Losses: ${s.losses}{/red-fg} Rate: ${rate}%`,
|
||||
);
|
||||
}
|
||||
|
||||
const pnl = s.closedPnl || 0;
|
||||
if (pnl !== 0) {
|
||||
const sign = pnl >= 0 ? '+' : '';
|
||||
const c = pnl >= 0 ? 'green-fg' : 'red-fg';
|
||||
lines.push(` Realized P&L : {${c}}{bold}${sign}$${pnl.toFixed(2)}{/bold}{/${c}}`);
|
||||
}
|
||||
|
||||
if (s.closedPositions && s.closedPositions.length > 0) {
|
||||
lines.push('');
|
||||
lines.push(` {gray-fg}── Recent Closed ──{/gray-fg}`);
|
||||
const recent = s.closedPositions.slice(-5).reverse();
|
||||
for (const cp of recent) {
|
||||
const icon = cp.result === 'WIN' ? '{green-fg}✅{/green-fg}' : '{red-fg}❌{/red-fg}';
|
||||
const sign = cp.pnl >= 0 ? '+' : '';
|
||||
lines.push(` ${icon} ${(cp.market || '').substring(0, 22)}`);
|
||||
lines.push(` {gray-fg}${sign}$${cp.pnl.toFixed(2)}{/gray-fg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push(` {gray-fg}Updated ${new Date().toISOString().substring(11, 19)}{/gray-fg}`);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/** Simple ASCII fill bar */
|
||||
function buildBar(value, max, width) {
|
||||
const pct = Math.min(value / max, 1);
|
||||
const filled = Math.round(pct * width);
|
||||
const empty = width - filled;
|
||||
const color = pct >= 1 ? 'red-fg' : pct >= 0.7 ? 'yellow-fg' : 'green-fg';
|
||||
return `{${color}}${'█'.repeat(filled)}{/${color}}{gray-fg}${'░'.repeat(empty)}{/gray-fg}`;
|
||||
}
|
||||
|
||||
// ── Status panel refresh loop ─────────────────────────────────────────────────
|
||||
async function refreshStatus() {
|
||||
try {
|
||||
const content = await buildStatusContent();
|
||||
updateStatus(content);
|
||||
} catch (err) {
|
||||
updateStatus(` {red-fg}Error refreshing status: ${err.message}{/red-fg}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Redeemer loop ─────────────────────────────────────────────────────────────
|
||||
async function redeemerLoop() {
|
||||
try {
|
||||
await checkAndRedeemPositions();
|
||||
@@ -71,10 +156,8 @@ async function redeemerLoop() {
|
||||
}
|
||||
}
|
||||
|
||||
// Main
|
||||
// ── Main ──────────────────────────────────────────────────────────────────────
|
||||
async function main() {
|
||||
showBanner();
|
||||
|
||||
// Validate config
|
||||
try {
|
||||
validateConfig();
|
||||
@@ -83,9 +166,18 @@ async function main() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
showSettings();
|
||||
logger.info('=== Settings ===');
|
||||
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(`Mode : ${config.dryRun ? 'SIMULATION (DRY RUN)' : 'LIVE TRADING'}`);
|
||||
logger.info('================');
|
||||
|
||||
// Initialize client
|
||||
// Initialize CLOB client
|
||||
try {
|
||||
await initClient();
|
||||
} catch (err) {
|
||||
@@ -93,7 +185,7 @@ async function main() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Show balance
|
||||
// Initial balance display
|
||||
try {
|
||||
const balance = await getUsdcBalance();
|
||||
logger.money(`USDC.e Balance: $${balance.toFixed(2)}`);
|
||||
@@ -101,34 +193,32 @@ async function main() {
|
||||
logger.warn('Could not fetch balance:', err.message);
|
||||
}
|
||||
|
||||
// Show existing positions
|
||||
const positions = getOpenPositions();
|
||||
if (positions.length > 0) {
|
||||
logger.info(`Existing positions: ${positions.length}`);
|
||||
positions.forEach((p) => {
|
||||
logger.info(` - ${p.market} | ${p.shares} shares @ $${p.avgBuyPrice}`);
|
||||
});
|
||||
}
|
||||
logger.success(
|
||||
config.dryRun
|
||||
? 'Simulation started! Watching trader in real-time...'
|
||||
: 'Bot started! Watching trader in real-time...',
|
||||
);
|
||||
|
||||
logger.success('Bot started! Watching trader activity...');
|
||||
logger.info('Press Ctrl+C to stop');
|
||||
// Initial status render
|
||||
await refreshStatus();
|
||||
|
||||
// Start loops
|
||||
// Initial run
|
||||
await watcherLoop();
|
||||
// Start real-time WebSocket watcher
|
||||
startWsWatcher(handleTrade);
|
||||
|
||||
// Run redeemer immediately then on interval
|
||||
await redeemerLoop();
|
||||
|
||||
// Interval loops
|
||||
const watcherInterval = setInterval(watcherLoop, config.pollInterval);
|
||||
const redeemerInterval = setInterval(redeemerLoop, config.redeemInterval);
|
||||
|
||||
// Refresh right panel every 5 seconds
|
||||
const statusInterval = setInterval(refreshStatus, 5000);
|
||||
|
||||
// Graceful shutdown
|
||||
const shutdown = () => {
|
||||
logger.info('Shutting down...');
|
||||
clearInterval(watcherInterval);
|
||||
stopWsWatcher();
|
||||
clearInterval(redeemerInterval);
|
||||
logger.info('Goodbye! 👋');
|
||||
process.exit(0);
|
||||
clearInterval(statusInterval);
|
||||
setTimeout(() => process.exit(0), 300);
|
||||
};
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
@@ -137,6 +227,5 @@ async function main() {
|
||||
|
||||
main().catch((err) => {
|
||||
logger.error('Fatal error:', err.message);
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* mm.js
|
||||
* Entry point for the Market Maker bot.
|
||||
* Detects new Bitcoin 5-minute markets and executes the MM strategy.
|
||||
* Run with: npm run mm (live)
|
||||
* npm run mm-sim (simulation / dry-run)
|
||||
*/
|
||||
|
||||
import { validateMMConfig } from './config/index.js';
|
||||
import config from './config/index.js';
|
||||
import logger from './utils/logger.js';
|
||||
import { initClient, getClient } from './services/client.js';
|
||||
import { initDashboard, appendLog, updateStatus, isDashboardActive } from './ui/dashboard.js';
|
||||
import { startMMDetector, stopMMDetector } from './services/mmDetector.js';
|
||||
import { executeMMStrategy, getActiveMMPositions } from './services/mmExecutor.js';
|
||||
import { getUsdcBalance } from './services/client.js';
|
||||
import { cleanupOpenPositions, redeemMMPositions, MIN_SHARES_PER_SIDE } from './services/ctf.js';
|
||||
|
||||
// ── Validate config ────────────────────────────────────────────────────────────
|
||||
|
||||
try {
|
||||
validateMMConfig();
|
||||
} 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);
|
||||
}
|
||||
|
||||
// ── 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}`);
|
||||
}
|
||||
|
||||
// ── Status panel refresh ──────────────────────────────────────────────────────
|
||||
|
||||
async function buildStatusContent() {
|
||||
let lines = [];
|
||||
|
||||
// Balance
|
||||
let balance = '?';
|
||||
if (!config.dryRun) {
|
||||
try { balance = (await getUsdcBalance()).toFixed(2); } catch { /* ignore */ }
|
||||
} else {
|
||||
balance = '{yellow-fg}SIM{/yellow-fg}';
|
||||
}
|
||||
lines.push(`{bold}BALANCE{/bold}`);
|
||||
lines.push(` USDC.e: {green-fg}$${balance}{/green-fg}`);
|
||||
lines.push('');
|
||||
|
||||
// Mode
|
||||
lines.push(`{bold}MODE{/bold}`);
|
||||
lines.push(` ${config.dryRun ? '{yellow-fg}SIMULATION{/yellow-fg}' : '{green-fg}LIVE{/green-fg}'}`);
|
||||
lines.push('');
|
||||
|
||||
// MM Config
|
||||
lines.push(`{bold}MM CONFIG{/bold}`);
|
||||
lines.push(` Assets : ${config.mmAssets.join(', ').toUpperCase()}`);
|
||||
lines.push(` Duration : ${config.mmDuration}`);
|
||||
lines.push(` Trade sz : $${config.mmTradeSize} per side`);
|
||||
lines.push(` Sell @ : $${config.mmSellPrice}`);
|
||||
lines.push(` Cut loss : ${config.mmCutLossTime}s before close`);
|
||||
lines.push('');
|
||||
|
||||
// Active positions
|
||||
const positions = getActiveMMPositions();
|
||||
lines.push(`{bold}ACTIVE POSITIONS (${positions.length}){/bold}`);
|
||||
|
||||
if (positions.length === 0) {
|
||||
lines.push(' {gray-fg}Waiting for market...{/gray-fg}');
|
||||
} else {
|
||||
for (const pos of positions) {
|
||||
const assetTag = pos.asset ? `[${pos.asset.toUpperCase()}] ` : '';
|
||||
const label = pos.question.substring(0, 32);
|
||||
const msLeft = new Date(pos.endTime).getTime() - Date.now();
|
||||
const secsLeft = Math.max(0, Math.round(msLeft / 1000));
|
||||
const timeStr = secsLeft > 60
|
||||
? `${Math.floor(secsLeft / 60)}m${secsLeft % 60}s`
|
||||
: `{red-fg}${secsLeft}s{/red-fg}`;
|
||||
|
||||
lines.push(` {cyan-fg}${assetTag}${label}{/cyan-fg}`);
|
||||
lines.push(` Status : ${pos.status} | Time left: ${timeStr}`);
|
||||
|
||||
// YES side
|
||||
const yFill = pos.yes.filled
|
||||
? `{green-fg}FILLED @ $${pos.yes.fillPrice?.toFixed(3)}{/green-fg}`
|
||||
: `{yellow-fg}waiting $${config.mmSellPrice}{/yellow-fg}`;
|
||||
lines.push(` YES ${pos.yes.shares?.toFixed(3)} sh @ $${pos.yes.entryPrice?.toFixed(3)} → ${yFill}`);
|
||||
|
||||
// NO side
|
||||
const nFill = pos.no.filled
|
||||
? `{green-fg}FILLED @ $${pos.no.fillPrice?.toFixed(3)}{/green-fg}`
|
||||
: `{yellow-fg}waiting $${config.mmSellPrice}{/yellow-fg}`;
|
||||
lines.push(` NO ${pos.no.shares?.toFixed(3)} sh @ $${pos.no.entryPrice?.toFixed(3)} → ${nFill}`);
|
||||
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
return '\n' + lines.join('\n');
|
||||
}
|
||||
|
||||
let refreshTimer = null;
|
||||
let redeemTimer = null;
|
||||
|
||||
function startRefresh() {
|
||||
refreshTimer = setInterval(async () => {
|
||||
if (!isDashboardActive()) return;
|
||||
const content = await buildStatusContent();
|
||||
updateStatus(content);
|
||||
}, 3000);
|
||||
|
||||
// Also do one immediate refresh
|
||||
buildStatusContent().then(updateStatus);
|
||||
}
|
||||
|
||||
function startRedeemer() {
|
||||
// Run once immediately, then every redeemInterval (default 60s)
|
||||
redeemMMPositions().catch((err) => logger.error('MM redeemer error:', err.message));
|
||||
redeemTimer = setInterval(
|
||||
() => redeemMMPositions().catch((err) => logger.error('MM redeemer error:', err.message)),
|
||||
config.redeemInterval,
|
||||
);
|
||||
logger.info(`MM redeemer started — checking every ${config.redeemInterval / 1000}s`);
|
||||
}
|
||||
|
||||
// ── 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) {
|
||||
// Queue this market for this asset — runs once the current position exits
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
// ── Graceful shutdown ─────────────────────────────────────────────────────────
|
||||
|
||||
function shutdown() {
|
||||
logger.warn('MM: shutting down...');
|
||||
stopMMDetector();
|
||||
if (refreshTimer) clearInterval(refreshTimer);
|
||||
if (redeemTimer) clearInterval(redeemTimer);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
|
||||
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
logger.info(`MM bot starting — ${config.dryRun ? 'SIMULATION MODE' : 'LIVE MODE'} | assets: ${config.mmAssets.join(', ').toUpperCase()} | ${config.mmDuration}`);
|
||||
startRefresh();
|
||||
startRedeemer();
|
||||
startMMDetector(handleNewMarket);
|
||||
+17
-8
@@ -14,8 +14,8 @@ export async function initClient() {
|
||||
logger.info('Initializing Polymarket CLOB client...');
|
||||
|
||||
signer = new Wallet(config.privateKey);
|
||||
const walletAddress = signer.address;
|
||||
logger.info(`Wallet address: ${walletAddress}`);
|
||||
logger.info(`EOA (signer) : ${signer.address}`);
|
||||
logger.info(`Proxy wallet : ${config.proxyWallet}`);
|
||||
|
||||
// Step 1: Create temp client to derive API credentials
|
||||
let apiCreds;
|
||||
@@ -33,13 +33,14 @@ export async function initClient() {
|
||||
}
|
||||
|
||||
// Step 2: Initialize full trading client
|
||||
// proxyWallet = funder address (where USDC.e is held)
|
||||
clobClient = new ClobClient(
|
||||
config.clobHost,
|
||||
config.chainId,
|
||||
signer,
|
||||
apiCreds,
|
||||
0, // Signature type: 0 = EOA
|
||||
config.walletAddress || walletAddress, // Funder address
|
||||
2, // Signature type: 2 = POLY_PROXY (EOA signs on behalf of proxy wallet)
|
||||
config.proxyWallet, // Funder = proxy wallet (deposit USDC.e here)
|
||||
);
|
||||
|
||||
logger.success('CLOB client initialized');
|
||||
@@ -67,15 +68,23 @@ export function getSigner() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get USDC.e balance on Polygon for the wallet
|
||||
* Get a working Polygon provider using RPC from config
|
||||
*/
|
||||
export async function getPolygonProvider() {
|
||||
const { ethers } = await import('ethers');
|
||||
const provider = new ethers.providers.JsonRpcProvider(config.polygonRpcUrl);
|
||||
return provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get USDC.e balance of the proxy wallet on Polygon
|
||||
*/
|
||||
export async function getUsdcBalance() {
|
||||
const { ethers } = await import('ethers');
|
||||
const provider = new ethers.providers.JsonRpcProvider('https://polygon-rpc.com');
|
||||
const provider = await getPolygonProvider();
|
||||
const usdcAddress = '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174'; // USDC.e on Polygon
|
||||
const abi = ['function balanceOf(address) view returns (uint256)'];
|
||||
const usdc = new ethers.Contract(usdcAddress, abi, provider);
|
||||
const funderAddress = config.walletAddress || signer.address;
|
||||
const balance = await usdc.balanceOf(funderAddress);
|
||||
const balance = await usdc.balanceOf(config.proxyWallet);
|
||||
return parseFloat(ethers.utils.formatUnits(balance, 6));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
/**
|
||||
* ctf.js
|
||||
* Helpers for interacting with Polymarket's ConditionalTokens (CTF) contract
|
||||
* directly from the Gnosis Safe proxy wallet.
|
||||
*
|
||||
* Key operations:
|
||||
* splitPosition — deposit USDC → receive equal YES+NO tokens at $0.50 each
|
||||
* mergePositions — return equal YES+NO tokens → recover USDC (cut-loss with no slippage)
|
||||
*/
|
||||
|
||||
import { ethers } from 'ethers';
|
||||
import config from '../config/index.js';
|
||||
import { getSigner, getPolygonProvider } from './client.js';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
// ── Contract addresses (Polygon mainnet) ──────────────────────────────────────
|
||||
|
||||
export const CTF_ADDRESS = '0x4D97DCd97eC945f40cF65F87097ACe5EA0476045';
|
||||
export const USDC_ADDRESS = '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174'; // USDC.e
|
||||
export const CTF_EXCHANGE = '0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E';
|
||||
export const NEG_RISK_EXCHANGE = '0xC5d563A36AE78145C45a50134d48A1215220f80a';
|
||||
|
||||
// ── ABIs (minimal) ────────────────────────────────────────────────────────────
|
||||
|
||||
const SAFE_ABI = [
|
||||
'function nonce() view returns (uint256)',
|
||||
'function getTransactionHash(address to, uint256 value, bytes data, uint8 operation, uint256 safeTxGas, uint256 baseGas, uint256 gasPrice, address gasToken, address refundReceiver, uint256 nonce) view returns (bytes32)',
|
||||
'function execTransaction(address to, uint256 value, bytes data, uint8 operation, uint256 safeTxGas, uint256 baseGas, uint256 gasPrice, address gasToken, address refundReceiver, bytes signatures) payable returns (bool)',
|
||||
];
|
||||
|
||||
// Minimum shares per side (Polymarket allows fractional; we enforce 2.5 as practical floor)
|
||||
export const MIN_SHARES_PER_SIDE = 2.5;
|
||||
|
||||
const CTF_ABI = [
|
||||
'function splitPosition(address collateralToken, bytes32 parentCollectionId, bytes32 conditionId, uint256[] partition, uint256 amount) external',
|
||||
'function mergePositions(address collateralToken, bytes32 parentCollectionId, bytes32 conditionId, uint256[] partition, uint256 amount) external',
|
||||
'function redeemPositions(address collateralToken, bytes32 parentCollectionId, bytes32 conditionId, uint256[] indexSets) external',
|
||||
'function balanceOf(address account, uint256 id) view returns (uint256)',
|
||||
'function payoutDenominator(bytes32 conditionId) view returns (uint256)',
|
||||
'function payoutNumerators(bytes32 conditionId, uint256 outcomeIndex) view returns (uint256)',
|
||||
];
|
||||
|
||||
const ERC20_ABI = [
|
||||
'function approve(address spender, uint256 amount) returns (bool)',
|
||||
'function allowance(address owner, address spender) view returns (uint256)',
|
||||
];
|
||||
|
||||
const ERC1155_ABI = [
|
||||
'function isApprovedForAll(address account, address operator) view returns (bool)',
|
||||
'function setApprovalForAll(address operator, bool approved)',
|
||||
];
|
||||
|
||||
// ── Error helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/**
|
||||
* Convert a raw ethers.js / RPC error into a short, human-readable message.
|
||||
* 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 || '';
|
||||
|
||||
if (msg.includes('insufficient funds') || msg.includes('insufficient balance'))
|
||||
return 'Insufficient MATIC balance for gas fees';
|
||||
if (msg.includes('nonce too low') || msg.includes('nonce has already been used'))
|
||||
return 'Transaction nonce conflict (nonce already used)';
|
||||
if (msg.includes('replacement transaction underpriced'))
|
||||
return 'Gas price too low to replace previous transaction';
|
||||
if (msg.includes('gas tip cap') && msg.includes('minimum needed'))
|
||||
return 'Priority fee below Polygon minimum (25 Gwei)';
|
||||
if (msg.includes('UNPREDICTABLE_GAS_LIMIT'))
|
||||
return 'Gas estimation failed — transaction will likely revert';
|
||||
if (msg.includes('execution reverted') || err?.code === 'CALL_EXCEPTION')
|
||||
return reason ? `Transaction reverted: ${reason}` : 'Transaction reverted by smart contract';
|
||||
if (msg.includes('timeout') || msg.includes('TIMEOUT'))
|
||||
return 'RPC request timed out';
|
||||
if (msg.includes('SERVER_ERROR') || msg.includes('Internal Server Error'))
|
||||
return 'RPC server error';
|
||||
if (msg.includes('NETWORK_ERROR') || msg.includes('network changed'))
|
||||
return 'Network connection lost';
|
||||
if (msg.includes('ECONNREFUSED') || msg.includes('connection refused'))
|
||||
return 'Cannot connect to Polygon RPC';
|
||||
if (msg.includes('header not found'))
|
||||
return 'RPC node not synced — please retry';
|
||||
|
||||
// Fallback: extract the first sentence before ethers noise
|
||||
const first = msg.split('\n')[0].split('(')[0].trim();
|
||||
return first.length > 120 ? first.slice(0, 120) + '…' : (first || 'Unknown error');
|
||||
}
|
||||
|
||||
// ── Safe transaction executor ─────────────────────────────────────────────────
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_DELAY = 3000; // ms
|
||||
|
||||
// Gnosis Safe nonces are sequential — concurrent calls would read the same nonce
|
||||
// and cause "nonce too low" for all but the first. This queue ensures every on-chain
|
||||
// tx waits for the previous one to fully confirm before starting.
|
||||
let _txQueue = Promise.resolve();
|
||||
|
||||
/**
|
||||
* Execute an arbitrary call through the Gnosis Safe proxy wallet.
|
||||
* Calls are serialized via an internal queue so nonces never collide.
|
||||
* Retries up to MAX_RETRIES times on transient errors.
|
||||
*/
|
||||
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(() => {});
|
||||
return result;
|
||||
}
|
||||
|
||||
async function _doExecSafeCall(to, data, description = '') {
|
||||
if (description) logger.info(`MM: exec safe tx — ${description}`);
|
||||
|
||||
let lastErr;
|
||||
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 nonce = await safe.nonce();
|
||||
|
||||
// Get the Safe's typed transaction hash
|
||||
const txHash = await safe.getTransactionHash(
|
||||
to,
|
||||
0, // value (ETH)
|
||||
data,
|
||||
0, // operation: CALL
|
||||
0, // safeTxGas
|
||||
0, // baseGas
|
||||
0, // gasPrice
|
||||
ethers.constants.AddressZero, // gasToken
|
||||
ethers.constants.AddressZero, // refundReceiver
|
||||
nonce,
|
||||
);
|
||||
|
||||
// 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);
|
||||
|
||||
// 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');
|
||||
|
||||
const tx = await safe.execTransaction(
|
||||
to, 0, data, 0, 0, 0, 0,
|
||||
ethers.constants.AddressZero,
|
||||
ethers.constants.AddressZero,
|
||||
signature,
|
||||
{ maxPriorityFeePerGas: gasTip, maxFeePerGas: gasFeeCap },
|
||||
);
|
||||
|
||||
const receipt = await tx.wait();
|
||||
return receipt;
|
||||
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
const friendly = parseOnchainError(err);
|
||||
|
||||
if (attempt < MAX_RETRIES) {
|
||||
logger.warn(`MM: transaction failed (attempt ${attempt}/${MAX_RETRIES}): ${friendly} — retrying in ${RETRY_DELAY / 1000}s...`);
|
||||
await sleep(RETRY_DELAY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All retries exhausted — throw a clean, human-readable error
|
||||
throw new Error(parseOnchainError(lastErr));
|
||||
}
|
||||
|
||||
// ── Approval helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Ensure the CTF contract can spend USDC from the proxy wallet.
|
||||
*/
|
||||
async function ensureUsdcApproval(amountWei) {
|
||||
const provider = await getPolygonProvider();
|
||||
const usdc = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, provider);
|
||||
const allowance = await usdc.allowance(config.proxyWallet, CTF_ADDRESS);
|
||||
if (allowance.gte(amountWei)) return;
|
||||
|
||||
const iface = new ethers.utils.Interface(ERC20_ABI);
|
||||
const data = iface.encodeFunctionData('approve', [CTF_ADDRESS, ethers.constants.MaxUint256]);
|
||||
await execSafeCall(USDC_ADDRESS, data, 'approve USDC → CTF');
|
||||
logger.success('MM: USDC approved to CTF contract');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the CTF exchange is an approved ERC1155 operator (needed for limit sell orders).
|
||||
* This is a one-time per-wallet setup.
|
||||
*/
|
||||
export async function ensureExchangeApproval(negRisk = false) {
|
||||
const exchange = negRisk ? NEG_RISK_EXCHANGE : CTF_EXCHANGE;
|
||||
const provider = await getPolygonProvider();
|
||||
const ctf = new ethers.Contract(CTF_ADDRESS, ERC1155_ABI, provider);
|
||||
|
||||
const approved = await ctf.isApprovedForAll(config.proxyWallet, exchange);
|
||||
if (approved) return;
|
||||
|
||||
const iface = new ethers.utils.Interface(ERC1155_ABI);
|
||||
const data = iface.encodeFunctionData('setApprovalForAll', [exchange, true]);
|
||||
await execSafeCall(CTF_ADDRESS, data, 'setApprovalForAll → CTF Exchange');
|
||||
logger.success(`MM: CTF exchange approved as ERC1155 operator`);
|
||||
}
|
||||
|
||||
// ── Core CTF operations ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Split `amountUsdc` USDC into equal YES+NO conditional tokens via the CTF contract.
|
||||
*
|
||||
* This gives a flat $0.50 entry on BOTH sides with zero slippage:
|
||||
* e.g. split $10 → 10 YES tokens + 10 NO tokens, each at $0.50 entry cost
|
||||
*
|
||||
* @param {string} conditionId - Market conditionId (bytes32 hex string)
|
||||
* @param {number} amountUsdc - Total USDC to split (both sides combined)
|
||||
* @param {boolean} negRisk - Whether the market uses negRisk exchange
|
||||
* @returns {number} shares - Number of tokens per side (= amountUsdc)
|
||||
*/
|
||||
export async function splitPosition(conditionId, amountUsdc, negRisk = false) {
|
||||
// shares per side = amountUsdc (each token entry price = $0.50, so $10 gives 10 shares each side)
|
||||
const shares = amountUsdc;
|
||||
|
||||
// Practical minimum: 2.5 shares per side → minimum $5 total (2 × $2.5)
|
||||
if (shares < MIN_SHARES_PER_SIDE) {
|
||||
throw new Error(
|
||||
`MM_TRADE_SIZE too small: ${shares} shares per side (minimum is ${MIN_SHARES_PER_SIDE}). ` +
|
||||
`Set MM_TRADE_SIZE ≥ ${MIN_SHARES_PER_SIDE} in your .env (current value: ${config.mmTradeSize}).`,
|
||||
);
|
||||
}
|
||||
|
||||
if (config.dryRun) {
|
||||
logger.info(`MM[SIM]: split $${amountUsdc} USDC → ${shares} YES + ${shares} NO @ $0.50 each`);
|
||||
return shares;
|
||||
}
|
||||
|
||||
const amountWei = ethers.utils.parseUnits(amountUsdc.toFixed(6), 6);
|
||||
|
||||
// 1. Ensure USDC is approved to CTF contract
|
||||
await ensureUsdcApproval(amountWei);
|
||||
|
||||
// 2. Ensure CTF exchange is approved to move tokens (needed for limit sells)
|
||||
await ensureExchangeApproval(negRisk);
|
||||
|
||||
// 3. Call splitPosition on CTF contract
|
||||
const ctfIface = new ethers.utils.Interface(CTF_ABI);
|
||||
const data = ctfIface.encodeFunctionData('splitPosition', [
|
||||
USDC_ADDRESS,
|
||||
ethers.constants.HashZero, // parentCollectionId = bytes32(0) for root positions
|
||||
conditionId,
|
||||
[1, 2], // full binary partition: YES=indexSet(1), NO=indexSet(2)
|
||||
amountWei,
|
||||
]);
|
||||
|
||||
await execSafeCall(CTF_ADDRESS, data, `splitPosition conditionId=${conditionId.slice(0, 10)}...`);
|
||||
logger.success(`MM: split $${amountUsdc} USDC → ${shares} YES + ${shares} NO @ $0.50`);
|
||||
return shares;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge equal YES+NO tokens back into USDC via the CTF contract.
|
||||
* Used for cut-loss when neither limit sell has been filled — recovers entry cost with no slippage.
|
||||
*
|
||||
* @param {string} conditionId - Market conditionId
|
||||
* @param {number} sharesPerSide - How many tokens to merge (must be equal on both sides)
|
||||
* @returns {number} recoveredUsdc - USDC recovered (= sharesPerSide)
|
||||
*/
|
||||
export async function mergePositions(conditionId, sharesPerSide) {
|
||||
if (config.dryRun) {
|
||||
const recovered = sharesPerSide;
|
||||
logger.info(`MM[SIM]: merge ${sharesPerSide} YES+NO → $${recovered} USDC recovered`);
|
||||
return recovered;
|
||||
}
|
||||
|
||||
const amountWei = ethers.utils.parseUnits(sharesPerSide.toFixed(6), 6);
|
||||
|
||||
const ctfIface = new ethers.utils.Interface(CTF_ABI);
|
||||
const data = ctfIface.encodeFunctionData('mergePositions', [
|
||||
USDC_ADDRESS,
|
||||
ethers.constants.HashZero,
|
||||
conditionId,
|
||||
[1, 2],
|
||||
amountWei,
|
||||
]);
|
||||
|
||||
await execSafeCall(CTF_ADDRESS, data, `mergePositions conditionId=${conditionId.slice(0, 10)}...`);
|
||||
logger.success(`MM: merged — recovered $${sharesPerSide} USDC`);
|
||||
return sharesPerSide;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup on startup: find any open CTF token positions in the proxy wallet
|
||||
* and merge them back to USDC so we start with a clean slate.
|
||||
*
|
||||
* Strategy:
|
||||
* 1. Query Data API for the proxy wallet's open positions
|
||||
* 2. For each conditionId found, check on-chain ERC1155 balances for YES and NO tokens
|
||||
* 3. If the market is NOT yet resolved (payoutDenominator == 0), merge equal YES+NO back to USDC
|
||||
* 4. Cancel any open CLOB orders via the CLOB client
|
||||
*
|
||||
* @param {import('@polymarket/clob-client').ClobClient} clobClient
|
||||
*/
|
||||
export async function cleanupOpenPositions(clobClient) {
|
||||
logger.info('MM: scanning for leftover positions to clean up...');
|
||||
|
||||
// ── 1. Cancel all open CLOB orders ──────────────────────────────────────────
|
||||
try {
|
||||
if (!config.dryRun) {
|
||||
const openOrders = await clobClient.getOpenOrders();
|
||||
if (Array.isArray(openOrders) && openOrders.length > 0) {
|
||||
logger.warn(`MM: cancelling ${openOrders.length} dangling open order(s)...`);
|
||||
for (const order of openOrders) {
|
||||
try { await clobClient.cancelOrder({ orderID: order.id ?? order.order_id }); } catch { /* ignore */ }
|
||||
}
|
||||
logger.success('MM: all open orders cancelled');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('MM: could not fetch open orders:', err.message);
|
||||
}
|
||||
|
||||
// ── 2. Query Data API for proxy wallet positions ─────────────────────────────
|
||||
let dataPositions = [];
|
||||
try {
|
||||
const url = `https://data-api.polymarket.com/positions?user=${config.proxyWallet}`;
|
||||
const resp = await fetch(url);
|
||||
if (resp.ok) dataPositions = await resp.json();
|
||||
if (!Array.isArray(dataPositions)) dataPositions = [];
|
||||
} catch (err) {
|
||||
logger.warn('MM: could not fetch positions from Data API:', err.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (dataPositions.length === 0) {
|
||||
logger.info('MM: no open positions found — starting clean ✅');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn(`MM: found ${dataPositions.length} open position(s) — attempting to merge back to USDC...`);
|
||||
|
||||
const provider = await getPolygonProvider();
|
||||
const ctf = new ethers.Contract(CTF_ADDRESS, CTF_ABI, provider);
|
||||
|
||||
// Group 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 mergedCount = 0;
|
||||
for (const [conditionId, tokens] of byCondition) {
|
||||
try {
|
||||
// Check if market is already resolved (skip if so — redeemer handles those)
|
||||
const denominator = await ctf.payoutDenominator(conditionId);
|
||||
if (!denominator.isZero()) {
|
||||
logger.info(`MM: conditionId ${conditionId.slice(0, 10)}... already resolved — skipping (redeemer will handle)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check on-chain ERC1155 token balances for each token
|
||||
const balances = await Promise.all(
|
||||
tokens.map(({ tokenId }) =>
|
||||
ctf.balanceOf(config.proxyWallet, tokenId).then((b) => ({
|
||||
tokenId,
|
||||
shares: parseFloat(ethers.utils.formatUnits(b, 6)),
|
||||
raw: b,
|
||||
}))
|
||||
)
|
||||
);
|
||||
|
||||
const nonZero = balances.filter((b) => b.shares >= MIN_SHARES_PER_SIDE);
|
||||
if (nonZero.length < 2) {
|
||||
logger.info(`MM: conditionId ${conditionId.slice(0, 10)}... balance too low to merge — skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use the minimum balance across both sides as the merge amount
|
||||
const minShares = Math.min(...nonZero.map((b) => b.shares));
|
||||
logger.warn(`MM: merging ${minShares.toFixed(3)} YES+NO → USDC for ${conditionId.slice(0, 10)}...`);
|
||||
|
||||
if (!config.dryRun) {
|
||||
await mergePositions(conditionId, minShares);
|
||||
mergedCount++;
|
||||
} else {
|
||||
logger.info(`MM[SIM]: would merge ${minShares.toFixed(3)} shares for ${conditionId.slice(0, 10)}...`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`MM: failed to clean up ${conditionId.slice(0, 10)}... — ${parseOnchainError(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (mergedCount > 0) {
|
||||
logger.success(`MM: cleanup complete — merged ${mergedCount} position(s) back to USDC ✅`);
|
||||
} else {
|
||||
logger.info('MM: cleanup done — nothing needed merging ✅');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Periodic redeemer ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check all positions held by the proxy wallet, find resolved markets,
|
||||
* and call redeemPositions via the Safe to collect USDC.
|
||||
*
|
||||
* Covers recovery buy positions, residual tokens from splits, and anything
|
||||
* else that resolved without being sold through the CLOB.
|
||||
*
|
||||
* Called automatically every redeemInterval seconds from mm.js.
|
||||
*/
|
||||
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}`);
|
||||
if (resp.ok) dataPositions = await resp.json();
|
||||
if (!Array.isArray(dataPositions)) dataPositions = [];
|
||||
} catch {
|
||||
return; // silent — will retry next interval
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
for (const [conditionId, tokens] of byCondition) {
|
||||
try {
|
||||
// Skip unresolved markets
|
||||
const denominator = await ctf.payoutDenominator(conditionId);
|
||||
if (denominator.isZero()) 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) continue; // nothing on-chain to redeem
|
||||
|
||||
// Estimate payout from numerators (for logging only)
|
||||
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) + '...';
|
||||
|
||||
if (config.dryRun) {
|
||||
logger.money(`MM[SIM] redeem: ${label} — ${totalShares.toFixed(3)} shares → ~$${expectedUsdc.toFixed(2)} USDC`);
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.info(`MM redeemer: ${label} resolved — ${totalShares.toFixed(3)} shares → ~$${expectedUsdc.toFixed(2)} USDC`);
|
||||
|
||||
// Call redeemPositions through Safe (indexSets [1,2] covers both YES and NO)
|
||||
const data = ctfIface.encodeFunctionData('redeemPositions', [
|
||||
USDC_ADDRESS,
|
||||
ethers.constants.HashZero,
|
||||
conditionId,
|
||||
[1, 2],
|
||||
]);
|
||||
await execSafeCall(CTF_ADDRESS, data, `redeemPositions ${label}`);
|
||||
logger.money(`MM redeemer: redeemed ${label} → ~$${expectedUsdc.toFixed(2)} USDC`);
|
||||
redeemed++;
|
||||
} catch (err) {
|
||||
logger.error(`MM redeemer: failed to redeem ${conditionId.slice(0, 12)}... — ${parseOnchainError(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (redeemed > 0) {
|
||||
logger.success(`MM redeemer: collected ${redeemed} resolved position(s)`);
|
||||
}
|
||||
}
|
||||
+85
-49
@@ -4,19 +4,22 @@ import { getClient, getUsdcBalance } from './client.js';
|
||||
import { hasPosition, addPosition, getPosition, updatePosition, removePosition } from './position.js';
|
||||
import { fetchMarketByTokenId } from './watcher.js';
|
||||
import { placeAutoSell } from './autoSell.js';
|
||||
import { recordSimBuy } from '../utils/simStats.js';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
/**
|
||||
* Calculate trade size based on settings
|
||||
* @param {number} traderSize - Trader's trade size in USDC
|
||||
* @returns {number} Our trade size in USDC
|
||||
* Calculate trade size for our entry — independent of the individual fill event.
|
||||
*
|
||||
* Limit orders can be filled in many small chunks; using the event's fill size
|
||||
* would give inconsistent (often sub-minimum) results.
|
||||
*
|
||||
* SIZE_MODE=percentage → SIZE_PERCENT% of MAX_POSITION_SIZE per market
|
||||
* SIZE_MODE=balance → SIZE_PERCENT% of our current USDC.e balance
|
||||
*/
|
||||
async function calculateTradeSize(traderSize) {
|
||||
async function calculateTradeSize() {
|
||||
if (config.sizeMode === 'percentage') {
|
||||
// % of trader's trade size
|
||||
return traderSize * (config.sizePercent / 100);
|
||||
return config.maxPositionSize * (config.sizePercent / 100);
|
||||
} else if (config.sizeMode === 'balance') {
|
||||
// % of our own balance
|
||||
const balance = await getUsdcBalance();
|
||||
return balance * (config.sizePercent / 100);
|
||||
}
|
||||
@@ -61,14 +64,32 @@ async function getMarketOptions(tokenId) {
|
||||
export async function executeBuy(trade) {
|
||||
const { tokenId, conditionId, market, price, size } = trade;
|
||||
|
||||
// Check if already have position for this market
|
||||
if (hasPosition(conditionId)) {
|
||||
logger.warn(`Already have position for: ${market || conditionId}. Skipping buy.`);
|
||||
return;
|
||||
// Get market options first to resolve conditionId
|
||||
const marketOpts = await getMarketOptions(tokenId);
|
||||
const effectiveConditionId = conditionId || marketOpts.conditionId;
|
||||
|
||||
// Check existing position and max position size cap
|
||||
const existingPos = getPosition(effectiveConditionId);
|
||||
if (existingPos) {
|
||||
const spent = existingPos.totalCost || 0;
|
||||
if (spent >= config.maxPositionSize) {
|
||||
logger.warn(`Max position $${config.maxPositionSize} reached for: ${market || effectiveConditionId} (spent $${spent.toFixed(2)}). Skipping.`);
|
||||
return;
|
||||
}
|
||||
logger.info(`Adding to existing position (spent $${spent.toFixed(2)} / $${config.maxPositionSize})`);
|
||||
}
|
||||
|
||||
// Calculate our trade size (independent of individual fill event)
|
||||
let tradeSize = await calculateTradeSize();
|
||||
|
||||
// Cap so we don't exceed maxPositionSize
|
||||
if (existingPos) {
|
||||
const remaining = config.maxPositionSize - (existingPos.totalCost || 0);
|
||||
tradeSize = Math.min(tradeSize, remaining);
|
||||
} else {
|
||||
tradeSize = Math.min(tradeSize, config.maxPositionSize);
|
||||
}
|
||||
|
||||
// Calculate our trade size
|
||||
const tradeSize = await calculateTradeSize(size * price); // trader's USDC amount
|
||||
if (tradeSize < config.minTradeSize) {
|
||||
logger.warn(`Trade size $${tradeSize.toFixed(2)} below minimum $${config.minTradeSize}. Skipping.`);
|
||||
return;
|
||||
@@ -81,30 +102,32 @@ export async function executeBuy(trade) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get market options
|
||||
const marketOpts = await getMarketOptions(tokenId);
|
||||
const effectiveConditionId = conditionId || marketOpts.conditionId;
|
||||
|
||||
// Double check no position exists
|
||||
if (effectiveConditionId && hasPosition(effectiveConditionId)) {
|
||||
logger.warn(`Already have position for: ${market || effectiveConditionId}. Skipping buy.`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.trade(`BUY ${market || tokenId} | Size: $${tradeSize.toFixed(2)} | Trader price: ${price}`);
|
||||
|
||||
if (config.dryRun) {
|
||||
logger.info('[DRY RUN] Would place market buy order');
|
||||
// Still record position in dry run for testing
|
||||
addPosition({
|
||||
conditionId: effectiveConditionId,
|
||||
tokenId,
|
||||
market: market || marketOpts.question || tokenId,
|
||||
shares: tradeSize / price,
|
||||
avgBuyPrice: price,
|
||||
totalCost: tradeSize,
|
||||
outcome: trade.outcome,
|
||||
});
|
||||
logger.trade(`[SIM] BUY ${market || tokenId} | $${tradeSize.toFixed(2)} @ $${price} | outcome: ${trade.outcome || '?'}`);
|
||||
const dryShares = tradeSize / price;
|
||||
if (existingPos) {
|
||||
const newShares = existingPos.shares + dryShares;
|
||||
const newTotalCost = existingPos.totalCost + tradeSize;
|
||||
updatePosition(effectiveConditionId, {
|
||||
shares: newShares,
|
||||
avgBuyPrice: newTotalCost / newShares,
|
||||
totalCost: newTotalCost,
|
||||
});
|
||||
logger.info(`[SIM] Position accumulated: $${newTotalCost.toFixed(2)} / $${config.maxPositionSize}`);
|
||||
} else {
|
||||
addPosition({
|
||||
conditionId: effectiveConditionId,
|
||||
tokenId,
|
||||
market: market || marketOpts.question || tokenId,
|
||||
shares: dryShares,
|
||||
avgBuyPrice: price,
|
||||
totalCost: tradeSize,
|
||||
outcome: trade.outcome,
|
||||
});
|
||||
}
|
||||
recordSimBuy();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -172,23 +195,36 @@ export async function executeBuy(trade) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate avg buy price
|
||||
const avgBuyPrice = totalSharesFilled > 0 ? totalCostFilled / totalSharesFilled : price;
|
||||
// Calculate avg buy price for this fill
|
||||
const fillAvgPrice = totalSharesFilled > 0 ? totalCostFilled / totalSharesFilled : price;
|
||||
|
||||
// Record position
|
||||
addPosition({
|
||||
conditionId: effectiveConditionId,
|
||||
tokenId,
|
||||
market: market || marketOpts.question || tokenId,
|
||||
shares: totalSharesFilled,
|
||||
avgBuyPrice,
|
||||
totalCost: totalCostFilled,
|
||||
outcome: trade.outcome,
|
||||
});
|
||||
if (existingPos) {
|
||||
// Accumulate into existing position (weighted avg price)
|
||||
const newShares = existingPos.shares + totalSharesFilled;
|
||||
const newTotalCost = existingPos.totalCost + totalCostFilled;
|
||||
const newAvgBuyPrice = newTotalCost / newShares;
|
||||
updatePosition(effectiveConditionId, {
|
||||
shares: newShares,
|
||||
avgBuyPrice: newAvgBuyPrice,
|
||||
totalCost: newTotalCost,
|
||||
});
|
||||
logger.success(`Position updated: ${existingPos.market} | total $${newTotalCost.toFixed(2)} / $${config.maxPositionSize}`);
|
||||
} else {
|
||||
// New position
|
||||
addPosition({
|
||||
conditionId: effectiveConditionId,
|
||||
tokenId,
|
||||
market: market || marketOpts.question || tokenId,
|
||||
shares: totalSharesFilled,
|
||||
avgBuyPrice: fillAvgPrice,
|
||||
totalCost: totalCostFilled,
|
||||
outcome: trade.outcome,
|
||||
});
|
||||
|
||||
// Auto-sell if enabled
|
||||
if (config.autoSellEnabled) {
|
||||
await placeAutoSell(effectiveConditionId, tokenId, totalSharesFilled, avgBuyPrice, marketOpts);
|
||||
// Auto-sell only on initial entry, not on accumulation
|
||||
if (config.autoSellEnabled) {
|
||||
await placeAutoSell(effectiveConditionId, tokenId, totalSharesFilled, fillAvgPrice, marketOpts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* mmDetector.js
|
||||
* Detects upcoming markets for configured assets (BTC, ETH, SOL, …)
|
||||
* using deterministic slug construction — supports 5-minute and 15-minute durations.
|
||||
*
|
||||
* Slug format: {asset}-updown-{duration}-{eventStartTimestamp}
|
||||
* e.g. btc-updown-5m-1771755000
|
||||
* eth-updown-15m-1771754100
|
||||
*
|
||||
* NEVER enters the currently active market — always targets the NEXT upcoming slot.
|
||||
*/
|
||||
|
||||
import config from '../config/index.js';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
// Slot size in seconds (300 for 5m, 900 for 15m)
|
||||
const SLOT_SEC = config.mmDuration === '15m' ? 900 : 300;
|
||||
|
||||
let pollTimer = null;
|
||||
let onMarketCb = null;
|
||||
const seenKeys = new Set(); // `${asset}-${slotTimestamp}` already scheduled
|
||||
|
||||
// ── Slot helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function currentSlot() {
|
||||
return Math.floor(Date.now() / 1000 / SLOT_SEC) * SLOT_SEC;
|
||||
}
|
||||
|
||||
function nextSlot() {
|
||||
return currentSlot() + SLOT_SEC;
|
||||
}
|
||||
|
||||
// ── Gamma API fetch ───────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchBySlug(asset, slotTimestamp) {
|
||||
const slug = `${asset}-updown-${config.mmDuration}-${slotTimestamp}`;
|
||||
try {
|
||||
const resp = await fetch(`${config.gammaHost}/markets/slug/${slug}`);
|
||||
if (!resp.ok) return null;
|
||||
const data = await resp.json();
|
||||
return data?.conditionId ? data : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Market data extraction ────────────────────────────────────────────────────
|
||||
|
||||
function extractMarketData(market, asset) {
|
||||
const conditionId = market.conditionId || market.condition_id || '';
|
||||
if (!conditionId) return null;
|
||||
|
||||
// clobTokenIds may arrive as a JSON string or an actual array
|
||||
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,
|
||||
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'),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Schedule an asset slot ────────────────────────────────────────────────────
|
||||
|
||||
async function scheduleAsset(asset, slotTimestamp) {
|
||||
const key = `${asset}-${slotTimestamp}`;
|
||||
if (seenKeys.has(key)) return;
|
||||
|
||||
const market = await fetchBySlug(asset, slotTimestamp);
|
||||
if (!market) return; // not in API yet — poll will retry
|
||||
|
||||
const data = extractMarketData(market, asset);
|
||||
if (!data) {
|
||||
logger.warn(`MM: skipping ${asset.toUpperCase()} slot ${slotTimestamp} — missing token IDs`);
|
||||
seenKeys.add(key);
|
||||
return;
|
||||
}
|
||||
|
||||
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 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`);
|
||||
return;
|
||||
}
|
||||
|
||||
const secsUntilOpen = Math.round((openAt - Date.now()) / 1000);
|
||||
if (secsUntilOpen > 0) {
|
||||
logger.success(`MM: ${asset.toUpperCase()} found "${data.question.slice(0, 40)}" — splitting position now (${secsUntilOpen}s before open)`);
|
||||
} else {
|
||||
logger.success(`MM: ${asset.toUpperCase()} found "${data.question.slice(0, 40)}" — splitting position now`);
|
||||
}
|
||||
|
||||
if (onMarketCb) onMarketCb(data);
|
||||
}
|
||||
|
||||
// ── Poll ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
// Schedule NEXT slot only — never the currently active market
|
||||
const next = nextSlot();
|
||||
await Promise.all(config.mmAssets.map((asset) => scheduleAsset(asset, next)));
|
||||
} catch (err) {
|
||||
logger.error('MM detector poll error:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function startMMDetector(onNewMarket) {
|
||||
onMarketCb = onNewMarket;
|
||||
seenKeys.clear();
|
||||
|
||||
poll();
|
||||
pollTimer = setInterval(poll, config.mmPollInterval);
|
||||
|
||||
const ns = nextSlot();
|
||||
const secsUntil = ns - Math.floor(Date.now() / 1000);
|
||||
logger.info(`MM detector started — assets: ${config.mmAssets.join(', ').toUpperCase()} | duration: ${config.mmDuration}`);
|
||||
logger.info(`Next slot: *-updown-${config.mmDuration}-${ns} (opens in ${secsUntil}s)`);
|
||||
logger.info(`Order: $${config.mmTradeSize}/side × 2 sides = $${config.mmTradeSize * 2} per market`);
|
||||
}
|
||||
|
||||
export function stopMMDetector() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
/**
|
||||
* mmExecutor.js
|
||||
* Executes the market-maker strategy for a single Bitcoin 5-minute market:
|
||||
* 1. Call CTF splitPosition — deposit USDC, receive equal YES+NO tokens at $0.50 flat
|
||||
* 2. Place GTC limit sells at mmSellPrice for both YES and NO
|
||||
* 3. Monitor until both fills or cut-loss time triggers
|
||||
* 4. On cut-loss:
|
||||
* - If NEITHER side filled → mergePositions (burn YES+NO, recover USDC, zero loss)
|
||||
* - If ONE side already sold → cancel the other, market-sell remaining tokens
|
||||
*/
|
||||
|
||||
import { Side, OrderType } from '@polymarket/clob-client';
|
||||
import { ethers } from 'ethers';
|
||||
import config from '../config/index.js';
|
||||
import { getClient, getUsdcBalance, getPolygonProvider } from './client.js';
|
||||
import { splitPosition, mergePositions } from './ctf.js';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
// CTF contract for on-chain balance queries
|
||||
const CTF_ADDRESS = '0x4D97DCd97eC945f40cF65F87097ACe5EA0476045';
|
||||
const CTF_BALANCE_ABI = ['function balanceOf(address account, uint256 id) view returns (uint256)'];
|
||||
|
||||
/**
|
||||
* Get actual on-chain ERC1155 token balance for the proxy wallet.
|
||||
* Used before market-sell to avoid 'not enough balance' errors from partial fills.
|
||||
*/
|
||||
async function getTokenBalance(tokenId) {
|
||||
try {
|
||||
const provider = await getPolygonProvider();
|
||||
const ctf = new ethers.Contract(CTF_ADDRESS, CTF_BALANCE_ABI, provider);
|
||||
const raw = await ctf.balanceOf(config.proxyWallet, tokenId);
|
||||
return parseFloat(ethers.utils.formatUnits(raw, 6));
|
||||
} catch {
|
||||
return null; // fallback: caller will use pos.shares
|
||||
}
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// In-memory store of all active MM positions (conditionId → position)
|
||||
const activePositions = new Map();
|
||||
|
||||
export function getActiveMMPositions() {
|
||||
return Array.from(activePositions.values());
|
||||
}
|
||||
|
||||
// ── Order helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function placeLimitSell(tokenId, shares, price, tickSize, negRisk) {
|
||||
if (config.dryRun) {
|
||||
return { success: true, orderId: `sim-${Date.now()}-${tokenId.slice(-6)}` };
|
||||
}
|
||||
|
||||
const client = getClient();
|
||||
try {
|
||||
const res = await client.createAndPostOrder(
|
||||
{ tokenID: tokenId, side: Side.SELL, price, size: shares },
|
||||
{ tickSize, negRisk },
|
||||
OrderType.GTC,
|
||||
);
|
||||
if (!res?.success) return { success: false };
|
||||
return { success: true, orderId: res.orderID };
|
||||
} catch (err) {
|
||||
logger.error('MM limit sell error:', err.message);
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelOrder(orderId) {
|
||||
if (config.dryRun || !orderId || orderId.startsWith('sim-')) return true;
|
||||
try {
|
||||
const client = getClient();
|
||||
await client.cancelOrder({ orderID: orderId }); // SDK expects { orderID } object
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.warn('MM cancel order error:', err.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function marketSell(tokenId, shares, tickSize, negRisk) {
|
||||
if (config.dryRun) {
|
||||
try {
|
||||
const client = getClient();
|
||||
const mp = await client.getMidpoint(tokenId);
|
||||
const price = parseFloat(mp?.mid ?? mp ?? '0') || 0;
|
||||
return { success: true, fillPrice: price };
|
||||
} catch {
|
||||
return { success: true, fillPrice: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
const client = getClient();
|
||||
try {
|
||||
const res = await client.createAndPostMarketOrder(
|
||||
{ tokenID: tokenId, side: Side.SELL, amount: shares, price: 0.01 },
|
||||
{ tickSize, negRisk },
|
||||
OrderType.FOK,
|
||||
);
|
||||
if (!res?.success) return { success: false, fillPrice: 0 };
|
||||
return { success: true, fillPrice: parseFloat(res.price || '0') };
|
||||
} catch (err) {
|
||||
logger.error('MM market sell error:', err.message);
|
||||
return { success: false, fillPrice: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Order status check ────────────────────────────────────────────────────────
|
||||
|
||||
async function isOrderFilled(orderId, shares) {
|
||||
if (!orderId || orderId.startsWith('sim-')) return false;
|
||||
try {
|
||||
const client = getClient();
|
||||
const order = await client.getOrder(orderId);
|
||||
if (!order) return false;
|
||||
if (order.status === 'MATCHED') return true;
|
||||
const matched = parseFloat(order.size_matched || '0');
|
||||
return matched >= shares * 0.99;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// For simulation: check if market price has reached the sell target
|
||||
async function simPriceHitTarget(tokenId) {
|
||||
try {
|
||||
const client = getClient();
|
||||
const mp = await client.getMidpoint(tokenId);
|
||||
const price = parseFloat(mp?.mid ?? mp ?? '0');
|
||||
return price >= config.mmSellPrice ? price : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Core monitoring loop ──────────────────────────────────────────────────────
|
||||
|
||||
async function monitorAndManage(pos) {
|
||||
const label = pos.question.substring(0, 40);
|
||||
|
||||
while (true) {
|
||||
const msRemaining = new Date(pos.endTime).getTime() - Date.now();
|
||||
|
||||
if (msRemaining <= 0) {
|
||||
logger.warn(`MM: market expired — ${label}`);
|
||||
pos.status = 'expired';
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Check YES side ──────────────────────────────────────
|
||||
if (!pos.yes.filled) {
|
||||
let filled = false;
|
||||
if (config.dryRun) {
|
||||
const hitPrice = await simPriceHitTarget(pos.yes.tokenId);
|
||||
if (hitPrice) { filled = true; pos.yes.fillPrice = hitPrice; }
|
||||
} else {
|
||||
filled = await isOrderFilled(pos.yes.orderId, pos.yes.shares);
|
||||
if (filled) pos.yes.fillPrice = config.mmSellPrice;
|
||||
}
|
||||
if (filled) {
|
||||
pos.yes.filled = true;
|
||||
const pnl = (pos.yes.fillPrice - pos.yes.entryPrice) * pos.yes.shares;
|
||||
logger.money(`MM${config.dryRun ? '[SIM]' : ''}: YES filled @ $${pos.yes.fillPrice.toFixed(3)} | P&L $${pnl.toFixed(2)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Check NO side ───────────────────────────────────────
|
||||
if (!pos.no.filled) {
|
||||
let filled = false;
|
||||
if (config.dryRun) {
|
||||
const hitPrice = await simPriceHitTarget(pos.no.tokenId);
|
||||
if (hitPrice) { filled = true; pos.no.fillPrice = hitPrice; }
|
||||
} else {
|
||||
filled = await isOrderFilled(pos.no.orderId, pos.no.shares);
|
||||
if (filled) pos.no.fillPrice = config.mmSellPrice;
|
||||
}
|
||||
if (filled) {
|
||||
pos.no.filled = true;
|
||||
const pnl = (pos.no.fillPrice - pos.no.entryPrice) * pos.no.shares;
|
||||
logger.money(`MM${config.dryRun ? '[SIM]' : ''}: NO filled @ $${pos.no.fillPrice.toFixed(3)} | P&L $${pnl.toFixed(2)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Both filled → done ──────────────────────────────────
|
||||
if (pos.yes.filled && pos.no.filled) {
|
||||
pos.status = 'done';
|
||||
const totalPnl = calcPnl(pos);
|
||||
logger.money(`MM: BOTH sides filled! Total P&L: $${totalPnl.toFixed(2)} | ${label}`);
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Cut-loss time ───────────────────────────────────────
|
||||
if (msRemaining <= config.mmCutLossTime * 1000) {
|
||||
logger.warn(`MM: cut-loss triggered (${Math.round(msRemaining / 1000)}s left) — ${label}`);
|
||||
pos.status = 'cutting';
|
||||
await cutLoss(pos);
|
||||
break;
|
||||
}
|
||||
|
||||
await sleep(10_000);
|
||||
}
|
||||
|
||||
// Final P&L log
|
||||
const totalPnl = calcPnl(pos);
|
||||
const sign = totalPnl >= 0 ? '+' : '';
|
||||
if (pos.status !== 'done') {
|
||||
logger.info(`MM: strategy ended (${pos.status}) | P&L: ${sign}$${totalPnl.toFixed(2)} | ${label}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function cutLoss(pos) {
|
||||
const { conditionId, tickSize, negRisk } = pos;
|
||||
const neitherFilled = !pos.yes.filled && !pos.no.filled;
|
||||
|
||||
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);
|
||||
|
||||
// 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;
|
||||
|
||||
} 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)}`);
|
||||
}
|
||||
}
|
||||
|
||||
pos.status = 'done';
|
||||
|
||||
// Optional recovery buy (enabled via MM_RECOVERY_BUY=true)
|
||||
await attemptRecoveryBuy(pos);
|
||||
}
|
||||
|
||||
// ── Recovery buy ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* After a cut-loss, optionally take a directional bet on the dominant side.
|
||||
*
|
||||
* Criteria (all must pass):
|
||||
* 1. MM_RECOVERY_BUY=true in .env
|
||||
* 2. One side's price is above MM_RECOVERY_THRESHOLD (default 70%)
|
||||
* 3. That price is stable or rising over a 10-second sample (1 fetch/second)
|
||||
* 4. Wallet balance is sufficient for the recovery size
|
||||
*/
|
||||
async function attemptRecoveryBuy(pos) {
|
||||
if (!config.mmRecoveryBuy) return;
|
||||
|
||||
const { tickSize, negRisk } = pos;
|
||||
const label = pos.question.substring(0, 40);
|
||||
const recoverySize = config.mmRecoverySize > 0 ? config.mmRecoverySize : config.mmTradeSize;
|
||||
const client = getClient();
|
||||
|
||||
logger.info(`MM recovery: monitoring prices for 10s | ${label}`);
|
||||
|
||||
// ── Sample both sides once per second for 10 seconds ─────────
|
||||
const samples = { yes: [], no: [] };
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
for (const [key, tokenId] of [['yes', pos.yes.tokenId], ['no', pos.no.tokenId]]) {
|
||||
try {
|
||||
const mp = await client.getMidpoint(tokenId);
|
||||
const price = parseFloat(mp?.mid ?? mp ?? '0') || 0;
|
||||
samples[key].push(price);
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
if (i < 9) await sleep(1000);
|
||||
}
|
||||
|
||||
// ── Determine eligible side ───────────────────────────────────
|
||||
// Need: last price ≥ threshold AND last price ≥ first price (not declining)
|
||||
let candidate = null;
|
||||
for (const [key, tokenId] of [['yes', pos.yes.tokenId], ['no', pos.no.tokenId]]) {
|
||||
const arr = samples[key];
|
||||
if (arr.length < 2) continue;
|
||||
|
||||
const firstPrice = arr[0];
|
||||
const lastPrice = arr[arr.length - 1];
|
||||
|
||||
if (lastPrice >= config.mmRecoveryThreshold && lastPrice >= firstPrice) {
|
||||
candidate = { side: key.toUpperCase(), tokenId, price: lastPrice };
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!candidate) {
|
||||
logger.info(`MM recovery: no eligible side — need price ≥ ${config.mmRecoveryThreshold} and rising/stable`);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Balance check ─────────────────────────────────────────────
|
||||
if (!config.dryRun) {
|
||||
const balance = await getUsdcBalance();
|
||||
if (balance < recoverySize) {
|
||||
logger.warn(`MM recovery: insufficient balance $${balance.toFixed(2)} < $${recoverySize} needed`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
logger.trade(`MM recovery${config.dryRun ? '[SIM]' : ''}: buying ${candidate.side} @ $${candidate.price.toFixed(3)} | size $${recoverySize}`);
|
||||
|
||||
// ── Market buy ────────────────────────────────────────────────
|
||||
let entryPrice = candidate.price;
|
||||
let filledShares = recoverySize / entryPrice; // default estimate
|
||||
|
||||
if (config.dryRun) {
|
||||
logger.money(`MM recovery[SIM]: bought ${filledShares.toFixed(3)} ${candidate.side} @ $${entryPrice.toFixed(3)}`);
|
||||
} else {
|
||||
try {
|
||||
const res = await client.createAndPostMarketOrder(
|
||||
{ tokenID: candidate.tokenId, side: Side.BUY, amount: recoverySize, price: 0.99 },
|
||||
{ tickSize, negRisk },
|
||||
OrderType.FOK,
|
||||
);
|
||||
if (!res?.success) {
|
||||
logger.warn(`MM recovery: order not filled — ${res?.errorMsg || 'no fill'}`);
|
||||
return;
|
||||
}
|
||||
entryPrice = parseFloat(res.price || String(candidate.price));
|
||||
filledShares = parseFloat(res.takingAmount || String(recoverySize / entryPrice));
|
||||
logger.money(`MM recovery: FILLED ${candidate.side} ${filledShares.toFixed(3)} sh @ $${entryPrice.toFixed(3)} | potential payout $${filledShares.toFixed(2)}`);
|
||||
} catch (err) {
|
||||
logger.error(`MM recovery: buy error — ${err.message}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Monitor for 30s — cut loss if price worsens ───────────────
|
||||
logger.info(`MM recovery: holding ${candidate.side} — will cut if price < $${entryPrice.toFixed(3)} after 30s`);
|
||||
await sleep(30_000);
|
||||
|
||||
// Skip second CL if market is already closed or about to close (< 5s left)
|
||||
const msLeft = new Date(pos.endTime).getTime() - Date.now();
|
||||
if (msLeft < 5_000) {
|
||||
logger.info(`MM recovery: market closing — skipping 2nd CL, letting position resolve`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check current price
|
||||
let currentPrice = entryPrice;
|
||||
try {
|
||||
const mp = await client.getMidpoint(candidate.tokenId);
|
||||
currentPrice = parseFloat(mp?.mid ?? mp ?? String(entryPrice)) || entryPrice;
|
||||
} catch { /* use entryPrice as fallback */ }
|
||||
|
||||
if (currentPrice >= entryPrice) {
|
||||
logger.success(`MM recovery: price holding $${currentPrice.toFixed(3)} ≥ entry $${entryPrice.toFixed(3)} — keeping position`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Price has worsened — cut loss
|
||||
const priceDrop = ((entryPrice - currentPrice) / entryPrice * 100).toFixed(1);
|
||||
logger.warn(`MM recovery: price dropped $${entryPrice.toFixed(3)} → $${currentPrice.toFixed(3)} (-${priceDrop}%) — cutting loss`);
|
||||
|
||||
if (config.dryRun) {
|
||||
const simPnl = (currentPrice - entryPrice) * filledShares;
|
||||
logger.warn(`MM recovery[SIM]: 2nd CL @ $${currentPrice.toFixed(3)} | P&L $${simPnl.toFixed(2)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sellRes = await client.createAndPostMarketOrder(
|
||||
{ tokenID: candidate.tokenId, side: Side.SELL, amount: filledShares, price: 0.01 },
|
||||
{ tickSize, negRisk },
|
||||
OrderType.FOK,
|
||||
);
|
||||
if (sellRes?.success) {
|
||||
const sellPrice = parseFloat(sellRes.price || String(currentPrice));
|
||||
const pnl = (sellPrice - entryPrice) * filledShares;
|
||||
logger.warn(`MM recovery: 2nd CL sold @ $${sellPrice.toFixed(3)} | P&L $${pnl.toFixed(2)}`);
|
||||
} else {
|
||||
logger.warn(`MM recovery: 2nd CL sell failed — ${sellRes?.errorMsg || 'no fill'} — position will resolve at close`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`MM recovery: 2nd CL sell error — ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function calcPnl(pos) {
|
||||
const yesPnl = pos.yes.filled
|
||||
? (pos.yes.fillPrice - pos.yes.entryPrice) * pos.yes.shares
|
||||
: 0;
|
||||
const noPnl = pos.no.filled
|
||||
? (pos.no.fillPrice - pos.no.entryPrice) * pos.no.shares
|
||||
: 0;
|
||||
return yesPnl + noPnl;
|
||||
}
|
||||
|
||||
// ── Main entry point ──────────────────────────────────────────────────────────
|
||||
|
||||
export async function executeMMStrategy(market) {
|
||||
const { asset, conditionId, question, endTime, yesTokenId, noTokenId, negRisk, tickSize } = market;
|
||||
const tag = asset ? `[${asset.toUpperCase()}]` : '';
|
||||
const label = question.substring(0, 40);
|
||||
const sim = config.dryRun ? '[SIM] ' : '';
|
||||
|
||||
logger.info(`MM${tag}: ${sim}entering — ${label}`);
|
||||
|
||||
// ── Balance check ───────────────────────────────────────────
|
||||
const totalNeeded = config.mmTradeSize * 2; // $10 total → 10 YES + 10 NO
|
||||
if (!config.dryRun) {
|
||||
const balance = await getUsdcBalance();
|
||||
if (balance < totalNeeded) {
|
||||
logger.error(`MM${tag}: insufficient balance $${balance.toFixed(2)} (need $${totalNeeded})`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Split USDC into YES+NO via CTF splitPosition ────────────
|
||||
// Deposit mmTradeSize*2 USDC → get mmTradeSize*2 YES + mmTradeSize*2 NO tokens
|
||||
// Entry price is exactly $0.50 per token on both sides (no spread, no slippage)
|
||||
logger.trade(`MM${tag}: ${sim}splitPosition $${totalNeeded} USDC → YES + NO @ $0.50`);
|
||||
let shares;
|
||||
try {
|
||||
shares = await splitPosition(conditionId, totalNeeded, negRisk);
|
||||
} catch (err) {
|
||||
logger.error(`MM${tag}: splitPosition failed — ${err.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const entryPrice = 0.50;
|
||||
logger.info(`MM${tag}: split done — ${shares} YES + ${shares} NO @ $${entryPrice}`);
|
||||
|
||||
// ── Place limit sells ───────────────────────────────────────
|
||||
logger.info(`MM${tag}: ${sim}placing limit sells @ $${config.mmSellPrice}`);
|
||||
const yesSell = await placeLimitSell(yesTokenId, shares, config.mmSellPrice, tickSize, negRisk);
|
||||
const noSell = await placeLimitSell(noTokenId, shares, config.mmSellPrice, tickSize, negRisk);
|
||||
|
||||
if (!yesSell.success || !noSell.success) {
|
||||
logger.error(`MM${tag}: failed to place limit sells — cutting immediately`);
|
||||
}
|
||||
|
||||
// ── Build position object ───────────────────────────────────
|
||||
const pos = {
|
||||
asset: asset || 'btc',
|
||||
conditionId,
|
||||
question,
|
||||
endTime,
|
||||
tickSize,
|
||||
negRisk,
|
||||
status: 'monitoring',
|
||||
enteredAt: new Date().toISOString(),
|
||||
yes: {
|
||||
tokenId: yesTokenId,
|
||||
shares,
|
||||
entryPrice,
|
||||
entryCost: config.mmTradeSize, // $5 per side
|
||||
orderId: yesSell.orderId,
|
||||
filled: !yesSell.success, // mark as needing cut if sell failed
|
||||
fillPrice: null,
|
||||
},
|
||||
no: {
|
||||
tokenId: noTokenId,
|
||||
shares,
|
||||
entryPrice,
|
||||
entryCost: config.mmTradeSize,
|
||||
orderId: noSell.orderId,
|
||||
filled: !noSell.success,
|
||||
fillPrice: null,
|
||||
},
|
||||
};
|
||||
|
||||
activePositions.set(conditionId, pos);
|
||||
|
||||
// ── Monitor (runs until done/cut/expired) ───────────────────
|
||||
await monitorAndManage(pos);
|
||||
|
||||
activePositions.delete(conditionId);
|
||||
}
|
||||
+70
-40
@@ -1,6 +1,8 @@
|
||||
import { ethers } from 'ethers';
|
||||
import config from '../config/index.js';
|
||||
import { getOpenPositions, updatePosition, removePosition } from './position.js';
|
||||
import { getPolygonProvider } from './client.js';
|
||||
import { getOpenPositions, removePosition } from './position.js';
|
||||
import { recordSimResult } from '../utils/simStats.js';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
// Contract addresses on Polygon
|
||||
@@ -17,13 +19,10 @@ const CTF_ABI = [
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if a market has been resolved and our position is a winner
|
||||
* @param {string} conditionId
|
||||
* @returns {Object|null} { resolved, won }
|
||||
* Check if a market has been resolved via Gamma API
|
||||
*/
|
||||
async function checkMarketResolution(conditionId) {
|
||||
try {
|
||||
// Check via Gamma API
|
||||
const url = `${config.gammaHost}/markets?condition_id=${conditionId}`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) return null;
|
||||
@@ -35,8 +34,6 @@ async function checkMarketResolution(conditionId) {
|
||||
return {
|
||||
resolved: market.closed || market.resolved || false,
|
||||
active: market.active,
|
||||
endDate: market.end_date_iso,
|
||||
resolutionSource: market.resolution_source,
|
||||
question: market.question,
|
||||
};
|
||||
} catch (err) {
|
||||
@@ -46,17 +43,17 @@ async function checkMarketResolution(conditionId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check on-chain if a position (token) has value (payout available)
|
||||
* Check on-chain payout fractions for a condition
|
||||
* Returns: { resolved: bool, payouts: [yes_fraction, no_fraction] }
|
||||
*/
|
||||
async function checkOnChainPayout(conditionId) {
|
||||
try {
|
||||
const provider = new ethers.providers.JsonRpcProvider('https://polygon-rpc.com');
|
||||
const provider = await getPolygonProvider();
|
||||
const ctf = new ethers.Contract(CTF_ADDRESS, CTF_ABI, provider);
|
||||
|
||||
const denominator = await ctf.payoutDenominator(conditionId);
|
||||
if (denominator.isZero()) return { resolved: false, payouts: [] };
|
||||
|
||||
// Check payouts for both outcomes (YES=0, NO=1)
|
||||
const payouts = [];
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const numerator = await ctf.payoutNumerators(conditionId, i);
|
||||
@@ -64,27 +61,25 @@ async function checkOnChainPayout(conditionId) {
|
||||
}
|
||||
|
||||
return { resolved: true, payouts };
|
||||
} catch (err) {
|
||||
// If payoutDenominator is 0 or reverts, market not resolved
|
||||
} catch {
|
||||
return { resolved: false, payouts: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeem winning position on-chain
|
||||
* Redeem winning position on-chain (real mode only)
|
||||
*/
|
||||
async function redeemPosition(conditionId, isNegRisk = false) {
|
||||
try {
|
||||
const provider = new ethers.providers.JsonRpcProvider('https://polygon-rpc.com');
|
||||
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]; // Both outcomes
|
||||
|
||||
logger.info(`Redeeming position for conditionId: ${conditionId}`);
|
||||
const indexSets = [1, 2];
|
||||
|
||||
logger.info(`Redeeming position: ${conditionId}`);
|
||||
const tx = await ctf.redeemPositions(
|
||||
USDC_ADDRESS,
|
||||
parentCollectionId,
|
||||
@@ -93,56 +88,91 @@ async function redeemPosition(conditionId, isNegRisk = false) {
|
||||
{ gasLimit: 300000 },
|
||||
);
|
||||
|
||||
logger.info(`Redeem tx sent: ${tx.hash}`);
|
||||
logger.info(`Redeem tx: ${tx.hash}`);
|
||||
const receipt = await tx.wait();
|
||||
logger.success(`Redeem confirmed in block ${receipt.blockNumber}`);
|
||||
|
||||
logger.success(`Redeemed in block ${receipt.blockNumber}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.error('Failed to redeem position:', err.message);
|
||||
logger.error('Failed to redeem:', err.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all open positions for redeemable (resolved & won) markets
|
||||
* Simulate redemption: determine win/loss and record stats
|
||||
*/
|
||||
async function simulateRedeem(position) {
|
||||
// Need on-chain payout to know who actually won
|
||||
const onChain = await checkOnChainPayout(position.conditionId);
|
||||
|
||||
if (!onChain.resolved) {
|
||||
logger.info(`[SIM] Market resolved via API but payout not on-chain yet: ${position.market}`);
|
||||
return false; // check again next interval
|
||||
}
|
||||
|
||||
// outcome index: YES = 0, NO = 1
|
||||
const outcomeStr = (position.outcome || 'yes').toLowerCase();
|
||||
const outcomeIdx = outcomeStr === 'yes' ? 0 : 1;
|
||||
const payoutFraction = onChain.payouts[outcomeIdx] ?? 0;
|
||||
|
||||
// In Polymarket, winning shares redeem at $1 each
|
||||
const returned = payoutFraction * position.shares;
|
||||
const pnl = returned - position.totalCost;
|
||||
|
||||
if (payoutFraction > 0) {
|
||||
logger.money(
|
||||
`[SIM] WIN! "${position.market}" | ${position.outcome} won` +
|
||||
` | +$${pnl.toFixed(2)} (+${((pnl / position.totalCost) * 100).toFixed(1)}%)`,
|
||||
);
|
||||
recordSimResult(position, 'WIN', pnl, returned);
|
||||
} else {
|
||||
logger.error(
|
||||
`[SIM] LOSS: "${position.market}" | ${position.outcome} lost` +
|
||||
` | -$${position.totalCost.toFixed(2)} (-100%)`,
|
||||
);
|
||||
recordSimResult(position, 'LOSS', pnl, returned);
|
||||
}
|
||||
|
||||
removePosition(position.conditionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all open positions for resolved markets and redeem/simulate
|
||||
*/
|
||||
export async function checkAndRedeemPositions() {
|
||||
const positions = getOpenPositions();
|
||||
if (positions.length === 0) return;
|
||||
|
||||
logger.info(`Checking ${positions.length} position(s) for redemption...`);
|
||||
logger.info(`Checking ${positions.length} position(s) for resolution...`);
|
||||
|
||||
for (const position of positions) {
|
||||
try {
|
||||
// Check via API first
|
||||
// 1. Check via Gamma API
|
||||
const resolution = await checkMarketResolution(position.conditionId);
|
||||
const isResolved = resolution?.resolved;
|
||||
|
||||
if (!resolution || !resolution.resolved) {
|
||||
// Try on-chain check as fallback
|
||||
if (!isResolved) {
|
||||
// 2. Fallback: on-chain check
|
||||
const onChain = await checkOnChainPayout(position.conditionId);
|
||||
if (!onChain.resolved) continue;
|
||||
|
||||
// Check if our outcome won
|
||||
// Determine outcome index (0=YES, 1=NO based on token position)
|
||||
logger.info(`Market resolved on-chain: ${position.market} | Payouts: ${onChain.payouts}`);
|
||||
logger.info(`Market resolved on-chain: ${position.market}`);
|
||||
} else {
|
||||
logger.info(`Market resolved: ${position.market}`);
|
||||
}
|
||||
|
||||
// 3. Simulate or execute real redeem
|
||||
if (config.dryRun) {
|
||||
logger.info(`[DRY RUN] Would redeem position: ${position.market}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Attempt to redeem
|
||||
const success = await redeemPosition(position.conditionId);
|
||||
if (success) {
|
||||
removePosition(position.conditionId);
|
||||
logger.money(`Redeemed: ${position.market}`);
|
||||
await simulateRedeem(position);
|
||||
} else {
|
||||
const success = await redeemPosition(position.conditionId);
|
||||
if (success) {
|
||||
removePosition(position.conditionId);
|
||||
logger.money(`Redeemed: ${position.market}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Error checking position ${position.market}:`, err.message);
|
||||
logger.error(`Error checking ${position.market}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* sniperDetector.js
|
||||
* Detects upcoming 5-minute markets for configured assets (ETH, SOL, XRP, …)
|
||||
* using deterministic slug construction — same logic as mmDetector but for
|
||||
* multiple assets simultaneously.
|
||||
*
|
||||
* Slug format: {asset}-updown-5m-{eventStartTimestamp}
|
||||
* e.g. eth-updown-5m-1771790700
|
||||
* sol-updown-5m-1771790700
|
||||
* xrp-updown-5m-1771790700
|
||||
*
|
||||
* NEVER enters the currently active market — always the NEXT upcoming slot.
|
||||
*/
|
||||
|
||||
import config from '../config/index.js';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
const SLOT_SEC = 5 * 60; // 300 seconds
|
||||
|
||||
let pollTimer = null;
|
||||
let onMarketCb = null;
|
||||
const seenKeys = new Set(); // `${asset}-${slotTimestamp}` already handled
|
||||
|
||||
// ── Slot helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function currentSlot() {
|
||||
return Math.floor(Date.now() / 1000 / SLOT_SEC) * SLOT_SEC;
|
||||
}
|
||||
|
||||
function nextSlot() {
|
||||
return currentSlot() + SLOT_SEC;
|
||||
}
|
||||
|
||||
// ── Gamma API fetch ───────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchBySlug(asset, slotTimestamp) {
|
||||
const slug = `${asset}-updown-5m-${slotTimestamp}`;
|
||||
try {
|
||||
const resp = await fetch(`${config.gammaHost}/markets/slug/${slug}`);
|
||||
if (!resp.ok) return null;
|
||||
const data = await resp.json();
|
||||
return data?.conditionId ? data : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Market data extraction ────────────────────────────────────────────────────
|
||||
|
||||
function extractMarketData(market, asset) {
|
||||
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,
|
||||
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 ?? '0.01'),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Schedule an asset slot ────────────────────────────────────────────────────
|
||||
|
||||
async function scheduleAsset(asset, slotTimestamp, isCurrent = false) {
|
||||
const key = `${asset}-${slotTimestamp}`;
|
||||
if (seenKeys.has(key)) return;
|
||||
|
||||
const market = await fetchBySlug(asset, slotTimestamp);
|
||||
if (!market) return; // not in API yet, poll will retry
|
||||
|
||||
const data = extractMarketData(market, asset);
|
||||
if (!data) {
|
||||
logger.warn(`SNIPER: skipping ${asset} slot ${slotTimestamp} — missing token IDs`);
|
||||
seenKeys.add(key);
|
||||
return;
|
||||
}
|
||||
|
||||
seenKeys.add(key);
|
||||
|
||||
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);
|
||||
if (secsLeft < 30) {
|
||||
logger.info(`SNIPER: ${asset.toUpperCase()} current market closing soon (${secsLeft}s) — skipping`);
|
||||
return;
|
||||
}
|
||||
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 secsUntilOpen = Math.round((openAt - Date.now()) / 1000);
|
||||
logger.success(`SNIPER: ${asset.toUpperCase()} found "${data.question.slice(0, 40)}"${secsUntilOpen > 0 ? ` — ${secsUntilOpen}s before open` : ''}`);
|
||||
}
|
||||
|
||||
if (onMarketCb) onMarketCb(data);
|
||||
}
|
||||
|
||||
// ── Poll ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const curr = currentSlot();
|
||||
const next = nextSlot();
|
||||
// Check current active market AND the upcoming next one, in parallel for each asset
|
||||
await Promise.all(config.sniperAssets.flatMap((asset) => [
|
||||
scheduleAsset(asset, curr, true), // current market (if still has time left)
|
||||
scheduleAsset(asset, next, false), // next upcoming market
|
||||
]));
|
||||
} catch (err) {
|
||||
logger.error('SNIPER detector poll error:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function startSniperDetector(onNewMarket) {
|
||||
onMarketCb = onNewMarket;
|
||||
seenKeys.clear();
|
||||
|
||||
poll();
|
||||
pollTimer = setInterval(poll, config.mmPollInterval);
|
||||
|
||||
const ns = nextSlot();
|
||||
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`);
|
||||
}
|
||||
|
||||
export function stopSniperDetector() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
import { Side, OrderType } from '@polymarket/clob-client';
|
||||
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 }
|
||||
|
||||
export function getActiveSnipes() {
|
||||
return [...activeSnipes];
|
||||
}
|
||||
|
||||
export async function executeSnipe(market) {
|
||||
const { asset, conditionId, question, yesTokenId, noTokenId, tickSize, negRisk } = market;
|
||||
const label = question.slice(0, 40);
|
||||
const sim = config.dryRun ? '[SIM] ' : '';
|
||||
|
||||
const sides = [
|
||||
{ name: 'UP', tokenId: yesTokenId },
|
||||
{ name: 'DOWN', tokenId: noTokenId },
|
||||
];
|
||||
|
||||
logger.info(`SNIPER: ${sim}${asset.toUpperCase()} — "${label}" | $${config.sniperPrice} × ${config.sniperShares}sh each side`);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const client = getClient();
|
||||
try {
|
||||
const res = await client.createAndPostOrder(
|
||||
{
|
||||
tokenID: tokenId,
|
||||
side: Side.BUY,
|
||||
price: config.sniperPrice,
|
||||
size: config.sniperShares,
|
||||
},
|
||||
{ tickSize, negRisk },
|
||||
OrderType.GTC,
|
||||
);
|
||||
|
||||
if (res?.success) {
|
||||
const cost = config.sniperPrice * config.sniperShares;
|
||||
logger.trade(`SNIPER: ${asset.toUpperCase()} ${name} @ $${config.sniperPrice} × ${config.sniperShares}sh | cost $${cost.toFixed(3)} | order ${res.orderID}`);
|
||||
activeSnipes.push({
|
||||
asset: asset.toUpperCase(),
|
||||
side: name,
|
||||
question: label,
|
||||
orderId: res.orderID,
|
||||
price: config.sniperPrice,
|
||||
shares: config.sniperShares,
|
||||
cost,
|
||||
potentialPayout: config.sniperShares,
|
||||
});
|
||||
} else {
|
||||
logger.warn(`SNIPER: ${asset.toUpperCase()} ${name} order failed — ${res?.errorMsg || 'unknown'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`SNIPER: ${asset.toUpperCase()} ${name} error — ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
-16
@@ -57,36 +57,41 @@ export async function checkNewTrades() {
|
||||
const newTrades = [];
|
||||
|
||||
for (const activity of activities) {
|
||||
// Skip already processed
|
||||
const tradeId = activity.id || activity.transaction_hash || `${activity.timestamp}_${activity.asset}`;
|
||||
// Data API: type = "TRADE" always, direction is in "side" (BUY / SELL)
|
||||
// Unique dedup key: txHash + asset + side (one tx can have multiple token trades)
|
||||
const tradeId = activity.transactionHash
|
||||
? `${activity.transactionHash}_${activity.asset}_${activity.side}`
|
||||
: `${activity.timestamp}_${activity.asset}_${activity.side}`;
|
||||
|
||||
if (processed.tradeIds.includes(tradeId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only process filled trades (buys and sells)
|
||||
const type = activity.type?.toUpperCase();
|
||||
if (!['BUY', 'SELL'].includes(type)) {
|
||||
// Mark non-buy/sell as processed so we don't re-check
|
||||
// Only process TRADE type with BUY or SELL side
|
||||
const actType = (activity.type || '').toUpperCase();
|
||||
const side = (activity.side || '').toUpperCase();
|
||||
|
||||
if (actType !== 'TRADE' || !['BUY', 'SELL'].includes(side)) {
|
||||
markTradeProcessed(tradeId);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract trade info
|
||||
const trade = {
|
||||
id: tradeId,
|
||||
type, // BUY or SELL
|
||||
tokenId: activity.asset || activity.token_id || '',
|
||||
conditionId: activity.condition_id || activity.conditionId || '',
|
||||
market: activity.title || activity.question || activity.market || '',
|
||||
type: side, // BUY or SELL
|
||||
tokenId: activity.asset || '',
|
||||
conditionId: activity.conditionId || '',
|
||||
market: activity.title || activity.question || '',
|
||||
price: parseFloat(activity.price || '0'),
|
||||
size: parseFloat(activity.size || activity.amount || '0'),
|
||||
side: activity.side || type,
|
||||
timestamp: activity.timestamp || activity.created_at || new Date().toISOString(),
|
||||
size: parseFloat(activity.usdcSize || '0'), // USDC value
|
||||
shares: parseFloat(activity.size || '0'), // token shares
|
||||
side,
|
||||
outcome: activity.outcome || '',
|
||||
proxyWalletAddress: activity.proxyWalletAddress || '',
|
||||
outcomeIndex: activity.outcomeIndex ?? null,
|
||||
timestamp: activity.timestamp || Date.now() / 1000,
|
||||
txHash: activity.transactionHash || '',
|
||||
};
|
||||
|
||||
// Need tokenId to trade
|
||||
if (!trade.tokenId) {
|
||||
logger.warn(`Skipping trade without tokenId: ${tradeId}`);
|
||||
markTradeProcessed(tradeId);
|
||||
@@ -99,6 +104,7 @@ export async function checkNewTrades() {
|
||||
return newTrades;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Mark trade as processed after handling
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import WebSocket from 'ws';
|
||||
import config from '../config/index.js';
|
||||
import logger from '../utils/logger.js';
|
||||
import { readState, writeState } from '../utils/state.js';
|
||||
|
||||
const RTDS_WS_URL = 'wss://ws-live-data.polymarket.com';
|
||||
const PING_INTERVAL_MS = 5000;
|
||||
const INITIAL_RECONNECT_DELAY = 2000;
|
||||
const MAX_RECONNECT_DELAY = 30000;
|
||||
const PROCESSED_FILE = 'processed_trades.json';
|
||||
|
||||
let ws = null;
|
||||
let pingTimer = null;
|
||||
let reconnectTimer = null;
|
||||
let reconnectDelay = INITIAL_RECONNECT_DELAY;
|
||||
let tradeHandler = null;
|
||||
let isShuttingDown = false;
|
||||
|
||||
function getProcessedIds() {
|
||||
return readState(PROCESSED_FILE, { tradeIds: [] });
|
||||
}
|
||||
|
||||
function markProcessed(tradeId) {
|
||||
const data = getProcessedIds();
|
||||
if (data.tradeIds.includes(tradeId)) return false;
|
||||
data.tradeIds.push(tradeId);
|
||||
if (data.tradeIds.length > 500) {
|
||||
data.tradeIds = data.tradeIds.slice(-500);
|
||||
}
|
||||
writeState(PROCESSED_FILE, data);
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleMessage(rawData) {
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(rawData.toString());
|
||||
} catch {
|
||||
const text = rawData.toString().trim();
|
||||
if (text === 'ping') {
|
||||
ws?.send('pong');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle ping/heartbeat
|
||||
if (msg.type === 'ping' || msg === 'ping') {
|
||||
ws?.send('pong');
|
||||
return;
|
||||
}
|
||||
|
||||
// Only process activity trade events
|
||||
if (msg.topic !== 'activity') return;
|
||||
|
||||
const payload = msg.payload;
|
||||
if (!payload) return;
|
||||
|
||||
// Filter by target trader's address (case-insensitive)
|
||||
const traderAddr = config.traderAddress.toLowerCase();
|
||||
const proxyWallet = (payload.proxyWallet || payload.proxy_wallet || '').toLowerCase();
|
||||
|
||||
if (!proxyWallet || proxyWallet !== traderAddr) return;
|
||||
|
||||
// Build trade ID
|
||||
const tradeId = payload.transactionHash || payload.transaction_hash ||
|
||||
`${payload.timestamp}_${payload.asset}`;
|
||||
|
||||
// Deduplication
|
||||
if (!markProcessed(tradeId)) {
|
||||
logger.watch(`Duplicate trade skipped: ${tradeId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse trade type
|
||||
const type = (payload.side || '').toUpperCase();
|
||||
if (!['BUY', 'SELL'].includes(type)) {
|
||||
logger.warn(`Unknown trade side: ${payload.side}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenId = payload.asset || '';
|
||||
if (!tokenId) {
|
||||
logger.warn(`Trade missing asset/tokenId: ${tradeId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const trade = {
|
||||
id: tradeId,
|
||||
type,
|
||||
tokenId,
|
||||
conditionId: payload.conditionId || payload.condition_id || '',
|
||||
market: payload.title || payload.name || '',
|
||||
price: parseFloat(payload.price || '0'),
|
||||
size: parseFloat(payload.size || '0'),
|
||||
side: type,
|
||||
timestamp: payload.timestamp || new Date().toISOString(),
|
||||
outcome: payload.outcome || '',
|
||||
proxyWalletAddress: payload.proxyWallet || '',
|
||||
};
|
||||
|
||||
logger.watch(`Trade detected! ${type} - ${trade.market || trade.tokenId}`);
|
||||
logger.watch(` Size: ${trade.size} shares @ $${trade.price}`);
|
||||
|
||||
if (tradeHandler) {
|
||||
tradeHandler(trade).catch((err) => {
|
||||
logger.error(`Error handling trade: ${err.message}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function startPing() {
|
||||
stopPing();
|
||||
pingTimer = setInterval(() => {
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send('ping');
|
||||
}
|
||||
}, PING_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function stopPing() {
|
||||
if (pingTimer) {
|
||||
clearInterval(pingTimer);
|
||||
pingTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function cleanup(reconnect = true) {
|
||||
stopPing();
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
if (ws) {
|
||||
ws.removeAllListeners();
|
||||
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
||||
ws.terminate();
|
||||
}
|
||||
ws = null;
|
||||
}
|
||||
if (reconnect && !isShuttingDown) {
|
||||
scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
logger.info(`Reconnecting in ${reconnectDelay / 1000}s...`);
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
|
||||
connect();
|
||||
}, reconnectDelay);
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (isShuttingDown) return;
|
||||
|
||||
logger.info('Connecting to Polymarket RTDS WebSocket...');
|
||||
ws = new WebSocket(RTDS_WS_URL);
|
||||
|
||||
ws.on('open', () => {
|
||||
logger.success('WebSocket connected! Subscribing to activity feed...');
|
||||
logger.watch(`Watching trader: ${config.traderAddress}`);
|
||||
reconnectDelay = INITIAL_RECONNECT_DELAY;
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
action: 'subscribe',
|
||||
subscriptions: [{
|
||||
topic: 'activity',
|
||||
type: 'trades',
|
||||
}],
|
||||
}));
|
||||
|
||||
startPing();
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
handleMessage(data);
|
||||
});
|
||||
|
||||
ws.on('ping', () => {
|
||||
ws?.pong();
|
||||
});
|
||||
|
||||
ws.on('close', (code, reason) => {
|
||||
const reasonStr = reason ? reason.toString() : 'no reason';
|
||||
logger.warn(`WebSocket closed (${code}): ${reasonStr}`);
|
||||
cleanup(true);
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
logger.error(`WebSocket error: ${err.message}`);
|
||||
cleanup(true);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the real-time WebSocket watcher
|
||||
* @param {Function} onTrade - async function called when trader makes a trade
|
||||
*/
|
||||
export function startWsWatcher(onTrade) {
|
||||
tradeHandler = onTrade;
|
||||
isShuttingDown = false;
|
||||
reconnectDelay = INITIAL_RECONNECT_DELAY;
|
||||
connect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the WebSocket watcher
|
||||
*/
|
||||
export function stopWsWatcher() {
|
||||
isShuttingDown = true;
|
||||
cleanup(false);
|
||||
logger.info('WebSocket watcher stopped');
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Run with: npm run sniper (live)
|
||||
* npm run sniper-sim (simulation)
|
||||
*/
|
||||
|
||||
import { validateMMConfig } from './config/index.js';
|
||||
import config from './config/index.js';
|
||||
import logger from './utils/logger.js';
|
||||
import { initClient } from './services/client.js';
|
||||
import { getUsdcBalance } from './services/client.js';
|
||||
import { initDashboard, appendLog, updateStatus, isDashboardActive } from './ui/dashboard.js';
|
||||
import { startSniperDetector, stopSniperDetector } from './services/sniperDetector.js';
|
||||
import { executeSnipe, getActiveSnipes } from './services/sniperExecutor.js';
|
||||
import { redeemMMPositions } from './services/ctf.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}');
|
||||
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`);
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
redeemMMPositions().catch((err) => logger.error('Sniper redeemer error:', err.message));
|
||||
redeemTimer = setInterval(
|
||||
() => redeemMMPositions().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 costPerSlot = (config.sniperPrice * config.sniperShares * 2 * config.sniperAssets.length).toFixed(3);
|
||||
logger.info(`SNIPER starting — ${config.dryRun ? 'SIMULATION' : 'LIVE'}`);
|
||||
logger.info(`Assets: ${config.sniperAssets.join(', ').toUpperCase()} | $${config.sniperPrice} × ${config.sniperShares}sh = $${costPerSlot}/slot`);
|
||||
|
||||
startRefresh();
|
||||
startRedeemer();
|
||||
startSniperDetector(handleNewMarket);
|
||||
@@ -0,0 +1,126 @@
|
||||
import { createRequire } from 'module';
|
||||
const require = createRequire(import.meta.url);
|
||||
const blessed = require('blessed');
|
||||
|
||||
let screen = null;
|
||||
let logBox = null;
|
||||
let statusBox = null;
|
||||
let active = false;
|
||||
|
||||
|
||||
export function initDashboard() {
|
||||
screen = blessed.screen({
|
||||
smartCSR: false, // avoid complex cursor escape sequences
|
||||
title: 'Polymarket Copy Trade',
|
||||
fullUnicode: true,
|
||||
forceUnicode: true,
|
||||
});
|
||||
|
||||
// ── Left panel: event log (60%) ────────────────────────────
|
||||
// blessed.log auto-tails (newest line always at bottom).
|
||||
// Keys are swallowed globally via screen.on('keypress') below,
|
||||
// so no raw escape codes can leak into this widget.
|
||||
logBox = blessed.log({
|
||||
parent: screen,
|
||||
label: ' LIVE EVENTS ',
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: '60%',
|
||||
height: '100%-1',
|
||||
border: { type: 'line' },
|
||||
tags: true,
|
||||
scrollable: true,
|
||||
alwaysScroll: true,
|
||||
mouse: false,
|
||||
keys: false,
|
||||
input: false,
|
||||
style: {
|
||||
border: { fg: 'cyan' },
|
||||
label: { fg: 'cyan', bold: true },
|
||||
},
|
||||
});
|
||||
|
||||
// ── Right panel: positions & balance (40%) ─────────────────
|
||||
statusBox = blessed.box({
|
||||
parent: screen,
|
||||
label: ' POSITIONS & BALANCE ',
|
||||
left: '60%',
|
||||
top: 0,
|
||||
width: '40%',
|
||||
height: '100%-1',
|
||||
border: { type: 'line' },
|
||||
tags: true,
|
||||
scrollable: false,
|
||||
input: false,
|
||||
clickable: false,
|
||||
style: {
|
||||
border: { fg: 'yellow' },
|
||||
label: { fg: 'yellow', bold: true },
|
||||
},
|
||||
content: '\n {gray-fg}Initializing...{/gray-fg}',
|
||||
});
|
||||
|
||||
// ── Bottom status bar ──────────────────────────────────────
|
||||
blessed.box({
|
||||
parent: screen,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: 1,
|
||||
tags: true,
|
||||
content:
|
||||
' {gray-fg}powered by{/gray-fg} {cyan-fg}@direkturcrypto{/cyan-fg} {gray-fg}terminal{/gray-fg}' +
|
||||
' {gray-fg}Ctrl+C / q = exit{/gray-fg}',
|
||||
style: { bg: 'black', fg: 'white' },
|
||||
});
|
||||
|
||||
// ── Capture ALL keypresses at screen level ─────────────────
|
||||
// This prevents any raw escape sequence from leaking into panels
|
||||
screen.on('keypress', (_ch, key) => {
|
||||
if (!key) return;
|
||||
if (key.full === 'C-c' || key.sequence === '\x03' || key.name === 'q') {
|
||||
screen.destroy();
|
||||
process.exit(0);
|
||||
}
|
||||
// every other key: swallowed here, never reaches any widget
|
||||
});
|
||||
|
||||
// Redirect raw console so nothing bypasses the TUI
|
||||
console.log = (...a) => appendLog(a.join(' '));
|
||||
console.info = (...a) => appendLog(a.join(' '));
|
||||
console.warn = (...a) => appendLog(`{yellow-fg}${a.join(' ')}{/yellow-fg}`);
|
||||
console.error = (...a) => appendLog(`{red-fg}${a.join(' ')}{/red-fg}`);
|
||||
|
||||
active = true;
|
||||
screen.render();
|
||||
|
||||
// Force a clean redraw after the event loop starts — fixes the
|
||||
// "looks messy until resized" issue common in blessed on first paint.
|
||||
setTimeout(() => {
|
||||
screen.alloc(); // reallocate internal screen buffer
|
||||
screen.render();
|
||||
}, 50);
|
||||
|
||||
return screen;
|
||||
}
|
||||
|
||||
/** Append a line to the live event log (auto-tails to newest) */
|
||||
export function appendLog(text) {
|
||||
if (!active || !logBox) {
|
||||
process.stdout.write(String(text) + '\n');
|
||||
return;
|
||||
}
|
||||
logBox.log(String(text));
|
||||
screen.render();
|
||||
}
|
||||
|
||||
/** Replace the right-panel content */
|
||||
export function updateStatus(content) {
|
||||
if (!active || !statusBox) return;
|
||||
statusBox.setContent(content);
|
||||
screen.render();
|
||||
}
|
||||
|
||||
export function isDashboardActive() {
|
||||
return active;
|
||||
}
|
||||
+48
-22
@@ -1,34 +1,60 @@
|
||||
const COLORS = {
|
||||
reset: '\x1b[0m',
|
||||
bright: '\x1b[1m',
|
||||
dim: '\x1b[2m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
// ANSI color codes (used in normal terminal mode)
|
||||
const A = {
|
||||
reset: '\x1b[0m',
|
||||
dim: '\x1b[2m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
magenta: '\x1b[35m',
|
||||
cyan: '\x1b[36m',
|
||||
white: '\x1b[37m',
|
||||
cyan: '\x1b[36m',
|
||||
};
|
||||
|
||||
function timestamp() {
|
||||
// Blessed tag pairs (used when dashboard is active)
|
||||
const B = {
|
||||
red: ['{red-fg}', '{/red-fg}'],
|
||||
green: ['{green-fg}', '{/green-fg}'],
|
||||
yellow: ['{yellow-fg}', '{/yellow-fg}'],
|
||||
blue: ['{blue-fg}', '{/blue-fg}'],
|
||||
magenta: ['{magenta-fg}', '{/magenta-fg}'],
|
||||
cyan: ['{cyan-fg}', '{/cyan-fg}'],
|
||||
};
|
||||
|
||||
let outputFn = null; // When set, all log goes here (blessed dashboard mode)
|
||||
|
||||
function ts() {
|
||||
return new Date().toISOString().replace('T', ' ').substring(0, 19);
|
||||
}
|
||||
|
||||
function formatMsg(level, color, emoji, ...args) {
|
||||
const ts = timestamp();
|
||||
const prefix = `${COLORS.dim}[${ts}]${COLORS.reset} ${color}${emoji} ${level}${COLORS.reset}`;
|
||||
console.log(prefix, ...args);
|
||||
function stringify(args) {
|
||||
return args.map((a) => (a && typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ');
|
||||
}
|
||||
|
||||
function log(ansiColor, bColor, emoji, level, ...args) {
|
||||
const msg = stringify(args);
|
||||
if (outputFn) {
|
||||
const [open, close] = bColor;
|
||||
outputFn(`{gray-fg}[${ts()}]{/gray-fg} ${open}${emoji} ${level}${close} ${msg}`);
|
||||
} else {
|
||||
process.stdout.write(
|
||||
`${A.dim}[${ts()}]${A.reset} ${ansiColor}${emoji} ${level}${A.reset} ${msg}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const logger = {
|
||||
info: (...args) => formatMsg('INFO', COLORS.blue, 'ℹ️ ', ...args),
|
||||
success: (...args) => formatMsg('SUCCESS', COLORS.green, '✅', ...args),
|
||||
warn: (...args) => formatMsg('WARN', COLORS.yellow, '⚠️ ', ...args),
|
||||
error: (...args) => formatMsg('ERROR', COLORS.red, '❌', ...args),
|
||||
trade: (...args) => formatMsg('TRADE', COLORS.magenta, '📊', ...args),
|
||||
watch: (...args) => formatMsg('WATCH', COLORS.cyan, '👀', ...args),
|
||||
money: (...args) => formatMsg('MONEY', COLORS.green, '💰', ...args),
|
||||
info: (...a) => log(A.blue, B.blue, 'ℹ️ ', 'INFO', ...a),
|
||||
success: (...a) => log(A.green, B.green, '✅', 'SUCCESS', ...a),
|
||||
warn: (...a) => log(A.yellow, B.yellow, '⚠️ ', 'WARN', ...a),
|
||||
error: (...a) => log(A.red, B.red, '❌', 'ERROR', ...a),
|
||||
trade: (...a) => log(A.magenta, B.magenta, '📊', 'TRADE', ...a),
|
||||
watch: (...a) => log(A.cyan, B.cyan, '👀', 'WATCH', ...a),
|
||||
money: (...a) => log(A.green, B.green, '💰', 'MONEY', ...a),
|
||||
|
||||
/** Call once after initDashboard() to redirect all logs to the TUI */
|
||||
setOutput(fn) {
|
||||
outputFn = fn;
|
||||
},
|
||||
};
|
||||
|
||||
export default logger;
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { readState, writeState } from './state.js';
|
||||
|
||||
const SIM_FILE = 'sim_stats.json';
|
||||
|
||||
function defaultStats() {
|
||||
return {
|
||||
startTime: new Date().toISOString(),
|
||||
totalBuys: 0,
|
||||
totalResolved: 0,
|
||||
wins: 0,
|
||||
losses: 0,
|
||||
closedPnl: 0,
|
||||
closedPositions: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function getSimStats() {
|
||||
return readState(SIM_FILE, defaultStats());
|
||||
}
|
||||
|
||||
export function recordSimBuy() {
|
||||
const stats = getSimStats();
|
||||
stats.totalBuys = (stats.totalBuys || 0) + 1;
|
||||
writeState(SIM_FILE, stats);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record result of a resolved simulation position
|
||||
* @param {Object} position - the position object
|
||||
* @param {'WIN'|'LOSS'} result
|
||||
* @param {number} pnl - realized P&L in USDC
|
||||
* @param {number} returned - USDC returned
|
||||
*/
|
||||
export function recordSimResult(position, result, pnl, returned) {
|
||||
const stats = getSimStats();
|
||||
stats.totalResolved = (stats.totalResolved || 0) + 1;
|
||||
if (result === 'WIN') stats.wins = (stats.wins || 0) + 1;
|
||||
else stats.losses = (stats.losses || 0) + 1;
|
||||
stats.closedPnl = ((stats.closedPnl || 0) + pnl);
|
||||
|
||||
stats.closedPositions = stats.closedPositions || [];
|
||||
stats.closedPositions.push({
|
||||
market: position.market,
|
||||
outcome: position.outcome,
|
||||
totalCost: position.totalCost,
|
||||
shares: position.shares,
|
||||
returned,
|
||||
pnl,
|
||||
result,
|
||||
closedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Keep last 100 entries
|
||||
if (stats.closedPositions.length > 100) {
|
||||
stats.closedPositions = stats.closedPositions.slice(-100);
|
||||
}
|
||||
|
||||
writeState(SIM_FILE, stats);
|
||||
}
|
||||
|
||||
export function resetSimStats() {
|
||||
writeState(SIM_FILE, defaultStats());
|
||||
}
|
||||
Reference in New Issue
Block a user