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:
direkturcrypto
2026-02-23 23:03:06 +07:00
parent 7c7fad45f3
commit 526076fe6e
22 changed files with 3485 additions and 558 deletions
+110 -16
View File
@@ -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
+252 -44
View File
@@ -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
+258 -78
View File
@@ -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.
+201 -195
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
});
+223
View File
@@ -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
View File
@@ -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));
}
+509
View File
@@ -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
View File
@@ -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);
}
}
}
+151
View File
@@ -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;
}
}
+516
View File
@@ -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
View File
@@ -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);
}
}
}
+156
View File
@@ -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;
}
}
+91
View File
@@ -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
View File
@@ -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
*/
+213
View File
@@ -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
View File
@@ -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);
+126
View File
@@ -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
View File
@@ -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;
+63
View File
@@ -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());
}