From 526076fe6eb9644e05457c9c4d276a0b5dd410fe Mon Sep 17 00:00:00 2001 From: direkturcrypto Date: Mon, 23 Feb 2026 23:03:06 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20full=20project=20overhaul=20=E2=80=94?= =?UTF-8?q?=20market=20maker,=20sniper,=20WebSocket=20watcher,=20terminal?= =?UTF-8?q?=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .env.example | 126 +++++++- AGENT.MD | 296 ++++++++++++++++--- README.md | 336 ++++++++++++++++----- package-lock.json | 396 ++++++++++++------------- package.json | 24 +- src/config/index.js | 53 +++- src/index.js | 245 +++++++++++----- src/mm.js | 223 ++++++++++++++ src/services/client.js | 25 +- src/services/ctf.js | 509 ++++++++++++++++++++++++++++++++ src/services/executor.js | 134 +++++---- src/services/mmDetector.js | 151 ++++++++++ src/services/mmExecutor.js | 516 +++++++++++++++++++++++++++++++++ src/services/redeemer.js | 110 ++++--- src/services/sniperDetector.js | 156 ++++++++++ src/services/sniperExecutor.js | 91 ++++++ src/services/watcher.js | 38 ++- src/services/wsWatcher.js | 213 ++++++++++++++ src/sniper.js | 142 +++++++++ src/ui/dashboard.js | 126 ++++++++ src/utils/logger.js | 70 +++-- src/utils/simStats.js | 63 ++++ 22 files changed, 3485 insertions(+), 558 deletions(-) create mode 100644 src/mm.js create mode 100644 src/services/ctf.js create mode 100644 src/services/mmDetector.js create mode 100644 src/services/mmExecutor.js create mode 100644 src/services/sniperDetector.js create mode 100644 src/services/sniperExecutor.js create mode 100644 src/services/wsWatcher.js create mode 100644 src/sniper.js create mode 100644 src/ui/dashboard.js create mode 100644 src/utils/simStats.js diff --git a/.env.example b/.env.example index 6128155..52717c2 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/AGENT.MD b/AGENT.MD index 7414768..d5869d4 100644 --- a/AGENT.MD +++ b/AGENT.MD @@ -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 diff --git a/README.md b/README.md index c7f8a49..c8138cc 100644 --- a/README.md +++ b/README.md @@ -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 -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. diff --git a/package-lock.json b/package-lock.json index cdcb4eb..86c9d18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" }, diff --git a/package.json b/package.json index 11bdd73..67274c3 100644 --- a/package.json +++ b/package.json @@ -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" } -} +} \ No newline at end of file diff --git a/src/config/index.js b/src/config/index.js index cb2de1b..0f15ad4 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -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; diff --git a/src/index.js b/src/index.js index b81ba89..5c64f14 100644 --- a/src/index.js +++ b/src/index.js @@ -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); }); diff --git a/src/mm.js b/src/mm.js new file mode 100644 index 0000000..2feef7b --- /dev/null +++ b/src/mm.js @@ -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); diff --git a/src/services/client.js b/src/services/client.js index 5d18068..7af4041 100644 --- a/src/services/client.js +++ b/src/services/client.js @@ -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)); } diff --git a/src/services/ctf.js b/src/services/ctf.js new file mode 100644 index 0000000..ee2a6fd --- /dev/null +++ b/src/services/ctf.js @@ -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)`); + } +} diff --git a/src/services/executor.js b/src/services/executor.js index 5e803c1..026bbf2 100644 --- a/src/services/executor.js +++ b/src/services/executor.js @@ -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); + } } } diff --git a/src/services/mmDetector.js b/src/services/mmDetector.js new file mode 100644 index 0000000..9bc3cd9 --- /dev/null +++ b/src/services/mmDetector.js @@ -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; + } +} diff --git a/src/services/mmExecutor.js b/src/services/mmExecutor.js new file mode 100644 index 0000000..6436397 --- /dev/null +++ b/src/services/mmExecutor.js @@ -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); +} diff --git a/src/services/redeemer.js b/src/services/redeemer.js index 86c5e8f..02ed359 100644 --- a/src/services/redeemer.js +++ b/src/services/redeemer.js @@ -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); } } } diff --git a/src/services/sniperDetector.js b/src/services/sniperDetector.js new file mode 100644 index 0000000..61a53b8 --- /dev/null +++ b/src/services/sniperDetector.js @@ -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; + } +} diff --git a/src/services/sniperExecutor.js b/src/services/sniperExecutor.js new file mode 100644 index 0000000..e8a5e43 --- /dev/null +++ b/src/services/sniperExecutor.js @@ -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}`); + } + } +} diff --git a/src/services/watcher.js b/src/services/watcher.js index 80729a4..368039d 100644 --- a/src/services/watcher.js +++ b/src/services/watcher.js @@ -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 */ diff --git a/src/services/wsWatcher.js b/src/services/wsWatcher.js new file mode 100644 index 0000000..dbf0b09 --- /dev/null +++ b/src/services/wsWatcher.js @@ -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'); +} diff --git a/src/sniper.js b/src/sniper.js new file mode 100644 index 0000000..d8b7cc8 --- /dev/null +++ b/src/sniper.js @@ -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); diff --git a/src/ui/dashboard.js b/src/ui/dashboard.js new file mode 100644 index 0000000..ae003c0 --- /dev/null +++ b/src/ui/dashboard.js @@ -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; +} diff --git a/src/utils/logger.js b/src/utils/logger.js index d515e48..54df2e0 100644 --- a/src/utils/logger.js +++ b/src/utils/logger.js @@ -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; diff --git a/src/utils/simStats.js b/src/utils/simStats.js new file mode 100644 index 0000000..50b1b32 --- /dev/null +++ b/src/utils/simStats.js @@ -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()); +}