Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c156a68fdc | ||
|
|
5f4aa04d13 | ||
|
|
930dffafbd | ||
|
|
9a89efb071 | ||
|
|
23bb74127c | ||
|
|
991fd6caa4 |
@@ -160,6 +160,33 @@ SNIPER_SCHEDULE_ETH=11:40-15:40,16:40-19:40
|
|||||||
SNIPER_SCHEDULE_SOL=09:40-12:40,21:40-23:40
|
SNIPER_SCHEDULE_SOL=09:40-12:40,21:40-23:40
|
||||||
SNIPER_SCHEDULE_XRP=18:40-20:40,08:40-09:50
|
SNIPER_SCHEDULE_XRP=18:40-20:40,08:40-09:50
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# MARKET MAKER v2 (maker.js / npm run maker-sim)
|
||||||
|
# Buy Low, Sell High — no splitPosition.
|
||||||
|
# Places limit BUY on UP+DOWN at low price, sells at target when filled.
|
||||||
|
# Only one side will fill — the other gets cancelled.
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# Comma-separated assets
|
||||||
|
MAKER_ASSETS=btc,eth,sol,xrp
|
||||||
|
|
||||||
|
# Market duration: comma-separated, e.g. "5m" or "5m,15m" for both
|
||||||
|
MAKER_DURATION=5m,15m
|
||||||
|
|
||||||
|
# Limit BUY price (e.g. 0.02 = 2 cents per share)
|
||||||
|
MAKER_BUY_PRICE=0.02
|
||||||
|
|
||||||
|
# Limit SELL price (e.g. 0.03 = 3 cents per share)
|
||||||
|
MAKER_SELL_PRICE=0.03
|
||||||
|
|
||||||
|
# Shares per side (e.g. 50 shares × 0.02 = $1.00 cost per side)
|
||||||
|
MAKER_TRADE_SIZE=50
|
||||||
|
|
||||||
|
# How often to poll for new markets (seconds)
|
||||||
|
MAKER_POLL_INTERVAL=10
|
||||||
|
|
||||||
|
# How fast to check order fill status (milliseconds)
|
||||||
|
MAKER_MONITOR_MS=2000
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# PROXY (Polymarket API only, NOT Polygon RPC)
|
# PROXY (Polymarket API only, NOT Polygon RPC)
|
||||||
# Supports HTTP/HTTPS/SOCKS5 proxies
|
# Supports HTTP/HTTPS/SOCKS5 proxies
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ Developer and AI agent reference for understanding, extending, and maintaining t
|
|||||||
| Bot | Entry Point | Command | Purpose |
|
| Bot | Entry Point | Command | Purpose |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Copy Trade | `src/index.js` | `npm start` | Mirror trades from a target trader wallet |
|
| Copy Trade | `src/index.js` | `npm start` | Mirror trades from a target trader wallet |
|
||||||
| Market Maker | `src/mm.js` | `npm run mm` | Provide liquidity on 5m/15m binary markets |
|
| Market Maker v1 | `src/mm.js` | `npm run mm` | Provide liquidity via splitPosition on 5m/15m markets |
|
||||||
|
| Market Maker v2 | `src/maker.js` | `npm run maker` | Buy low, sell high — pure orderbook maker (no splitPosition) |
|
||||||
| Orderbook Sniper | `src/sniper.js` | `npm run sniper` | Place low-price GTC orders to catch panic dumps |
|
| Orderbook Sniper | `src/sniper.js` | `npm run sniper` | Place low-price GTC orders to catch panic dumps |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -91,9 +92,10 @@ All files use **ES Modules** (`import`/`export`). Do not use `require()`.
|
|||||||
|
|
||||||
- Loads all settings from `.env` via `dotenv`
|
- Loads all settings from `.env` via `dotenv`
|
||||||
- Parses and validates required fields per bot type
|
- Parses and validates required fields per bot type
|
||||||
- Exports two validator functions:
|
- Exports three validator functions:
|
||||||
- `validateCopyTradeConfig()` — for `src/index.js`
|
- `validateCopyTradeConfig()` — for `src/index.js`
|
||||||
- `validateMMConfig()` — for `src/mm.js` and `src/sniper.js`
|
- `validateMMConfig()` — for `src/mm.js` and `src/sniper.js`
|
||||||
|
- `validateMakerConfig()` — for `src/maker.js`
|
||||||
- Key exported fields: `privateKey`, `proxyWallet`, `traderAddress`, `dryRun`, `mmTradeSize`, etc.
|
- Key exported fields: `privateKey`, `proxyWallet`, `traderAddress`, `dryRun`, `mmTradeSize`, etc.
|
||||||
|
|
||||||
### Client (`src/services/client.js`)
|
### Client (`src/services/client.js`)
|
||||||
@@ -171,6 +173,23 @@ Used exclusively by the Market Maker bot. Interacts with the Gnosis Safe proxy w
|
|||||||
- `cutLoss(...)` — Cancels remaining orders; merges back or market-sells residual tokens
|
- `cutLoss(...)` — Cancels remaining orders; merges back or market-sells residual tokens
|
||||||
- `attemptRecoveryBuy(...)` — Optional directional bet post cut-loss (controlled by `MM_RECOVERY_BUY`)
|
- `attemptRecoveryBuy(...)` — Optional directional bet post cut-loss (controlled by `MM_RECOVERY_BUY`)
|
||||||
|
|
||||||
|
### Maker Detector (`src/services/makerDetector.js`)
|
||||||
|
|
||||||
|
- Slot-based detection for the Maker v2 bot (buy low, sell high)
|
||||||
|
- Supports **multiple durations** simultaneously (e.g. `MAKER_DURATION=5m,15m`)
|
||||||
|
- Polls all `asset × duration` combinations in parallel
|
||||||
|
- Each detected market includes a `duration` field for concurrent queue management
|
||||||
|
- Same slug format as other bots: `{asset}-updown-{duration}-{timestamp}`
|
||||||
|
|
||||||
|
### Maker Executor (`src/services/makerExecutor.js`)
|
||||||
|
|
||||||
|
- `executeMakerStrategy(market)` — Full flow: place limit BUY on UP+DOWN → monitor fills → place limit SELL → monitor sells
|
||||||
|
- **CL at 10s before close:** Cancels unfilled buy orders, places sells for any filled positions (retry 3x for settlement delays)
|
||||||
|
- **Sell phase:** Sell orders stay live until market close (no early cancellation)
|
||||||
|
- **Multi-market concurrent:** Each position keyed by `conditionId`, queue keyed by `${asset}-${duration}`
|
||||||
|
- Simulation mode with full P&L tracking (win/loss/skip stats)
|
||||||
|
- `placeLimitSellWithRetry()` — Retries sell placement 3x with increasing delay for on-chain settlement
|
||||||
|
|
||||||
### Sniper Detector (`src/services/sniperDetector.js`)
|
### Sniper Detector (`src/services/sniperDetector.js`)
|
||||||
|
|
||||||
- Scans for 5-minute markets for a configurable list of assets
|
- Scans for 5-minute markets for a configurable list of assets
|
||||||
|
|||||||
@@ -34,13 +34,23 @@
|
|||||||
- **Per-Market Queue** — Concurrent events for the same market are serialized to prevent duplicate buys
|
- **Per-Market Queue** — Concurrent events for the same market are serialized to prevent duplicate buys
|
||||||
- **Dry Run Mode** — Simulate the full flow without placing real orders
|
- **Dry Run Mode** — Simulate the full flow without placing real orders
|
||||||
|
|
||||||
### Market Maker Bot
|
### Market Maker v1 Bot (Split Position)
|
||||||
- **Automated Liquidity** — Splits USDC into YES+NO tokens and places limit sells on both sides at $0.50 entry
|
- **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
|
- **Cut-Loss Protection** — Merges unsold tokens back to USDC before market close
|
||||||
- **Recovery Buy** — Optional directional bet after a cut-loss triggers
|
- **Recovery Buy** — Optional directional bet after a cut-loss triggers
|
||||||
- **Multi-Asset** — Supports BTC, ETH, SOL, and any 5m/15m Polymarket market
|
- **Multi-Asset** — Supports BTC, ETH, SOL, and any 5m/15m Polymarket market
|
||||||
- **Simulation Mode** — Full dry-run with P&L tracking
|
- **Simulation Mode** — Full dry-run with P&L tracking
|
||||||
|
|
||||||
|
### Market Maker v2 Bot (Buy Low, Sell High)
|
||||||
|
- **Pure Orderbook** — Places limit BUY on both UP+DOWN at low price (e.g. 2c), sells at higher price (e.g. 3c) when filled. No splitPosition needed
|
||||||
|
- **Cut-Loss at 10s** — Cancels unfilled buy orders 10 seconds before market close; sells positions as much as possible
|
||||||
|
- **Sell Retry 3x** — Retries sell placement with delay for on-chain token settlement
|
||||||
|
- **Multi-Asset** — Supports BTC, ETH, SOL, XRP simultaneously
|
||||||
|
- **Multi-Duration** — Run 5m and 15m markets concurrently (`MAKER_DURATION=5m,15m`)
|
||||||
|
- **Concurrent Markets** — Each asset+duration combination runs independently (e.g. BTC/5m and BTC/15m don't block each other)
|
||||||
|
- **Simulation Mode** — Full dry-run with win/loss/skip tracking and P&L stats
|
||||||
|
- **Proxy Support** — All API calls go through `PROXY_URL` if configured
|
||||||
|
|
||||||
### Orderbook Sniper Bot
|
### Orderbook Sniper Bot
|
||||||
- **3-Tier Strategy** — Places GTC BUY orders at 3c, 2c, and 1c with weighted sizing (20%/30%/50%)
|
- **3-Tier Strategy** — Places GTC BUY orders at 3c, 2c, and 1c with weighted sizing (20%/30%/50%)
|
||||||
- **Multi-Asset** — Targets ETH, SOL, XRP, and more simultaneously
|
- **Multi-Asset** — Targets ETH, SOL, XRP, and more simultaneously
|
||||||
@@ -138,6 +148,20 @@ Leave these blank to have the client auto-derive credentials from your private k
|
|||||||
| `MM_RECOVERY_THRESHOLD` | Minimum dominant-side price to qualify for recovery | `0.70` |
|
| `MM_RECOVERY_THRESHOLD` | Minimum dominant-side price to qualify for recovery | `0.70` |
|
||||||
| `MM_RECOVERY_SIZE` | USDC for recovery buy (0 = use `MM_TRADE_SIZE`) | `0` |
|
| `MM_RECOVERY_SIZE` | USDC for recovery buy (0 = use `MM_TRADE_SIZE`) | `0` |
|
||||||
|
|
||||||
|
### Market Maker v2 (Maker) Settings
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|---|---|---|
|
||||||
|
| `MAKER_ASSETS` | Comma-separated assets (e.g. `btc,eth,sol,xrp`) | `btc` |
|
||||||
|
| `MAKER_DURATION` | Comma-separated durations (e.g. `5m` or `5m,15m`) | `5m` |
|
||||||
|
| `MAKER_BUY_PRICE` | Limit BUY price per share (e.g. `0.02` = 2c) | `0.02` |
|
||||||
|
| `MAKER_SELL_PRICE` | Limit SELL price per share (e.g. `0.03` = 3c) | `0.03` |
|
||||||
|
| `MAKER_TRADE_SIZE` | Shares per side (e.g. `50` × $0.02 = $1.00/side) | `50` |
|
||||||
|
| `MAKER_POLL_INTERVAL` | Seconds between new market polls | `10` |
|
||||||
|
| `MAKER_MONITOR_MS` | Milliseconds between order fill checks | `2000` |
|
||||||
|
|
||||||
|
**Cut-Loss:** At 10 seconds before market close, unfilled buy orders are cancelled. Any filled positions get sell orders placed (retry 3x). Sell orders stay live until market close.
|
||||||
|
|
||||||
### Orderbook Sniper Settings
|
### Orderbook Sniper Settings
|
||||||
|
|
||||||
**3-Tier Strategy:** Places orders at 3 price levels with weighted sizing
|
**3-Tier Strategy:** Places orders at 3 price levels with weighted sizing
|
||||||
@@ -179,6 +203,11 @@ npm run mm # live trading
|
|||||||
npm run mm-sim # simulation (DRY_RUN=true)
|
npm run mm-sim # simulation (DRY_RUN=true)
|
||||||
npm run mm-dev # simulation + auto-reload
|
npm run mm-dev # simulation + auto-reload
|
||||||
|
|
||||||
|
# Market Maker v2 (Maker) Bot
|
||||||
|
npm run maker # live trading
|
||||||
|
npm run maker-sim # simulation
|
||||||
|
npm run maker-dev # simulation + auto-reload
|
||||||
|
|
||||||
# Orderbook Sniper Bot
|
# Orderbook Sniper Bot
|
||||||
npm run sniper # live trading
|
npm run sniper # live trading
|
||||||
npm run sniper-sim # simulation
|
npm run sniper-sim # simulation
|
||||||
@@ -195,10 +224,15 @@ npm run bot # live trading
|
|||||||
npm run bot-sim # simulation
|
npm run bot-sim # simulation
|
||||||
npm run bot-dev # simulation + auto-reload
|
npm run bot-dev # simulation + auto-reload
|
||||||
|
|
||||||
# Market Maker Bot
|
# Market Maker v1 Bot
|
||||||
npm run mm-bot # live trading
|
npm run mm-bot # live trading
|
||||||
npm run mm-bot-sim # simulation
|
npm run mm-bot-sim # simulation
|
||||||
npm run mm-bot-dev # simulation + auto-reload
|
npm run mm-bot-dev # simulation + auto-reload
|
||||||
|
|
||||||
|
# Market Maker v2 (Maker) Bot
|
||||||
|
npm run maker-bot # live trading
|
||||||
|
npm run maker-bot-sim # simulation
|
||||||
|
npm run maker-bot-dev # simulation + auto-reload
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Always test with `DRY_RUN=true` (or `*-sim` scripts) first** before committing real funds.
|
> **Always test with `DRY_RUN=true` (or `*-sim` scripts) first** before committing real funds.
|
||||||
@@ -288,7 +322,7 @@ Redeemer loop (every REDEEM_INTERVAL seconds)
|
|||||||
→ Check on-chain payout → redeemPositions via Gnosis Safe
|
→ Check on-chain payout → redeemPositions via Gnosis Safe
|
||||||
```
|
```
|
||||||
|
|
||||||
### Market Maker Flow
|
### Market Maker v1 Flow (Split Position)
|
||||||
|
|
||||||
```
|
```
|
||||||
New Market Detected
|
New Market Detected
|
||||||
@@ -311,6 +345,37 @@ Collect Cancel orders → Merge YES+NO back to USDC
|
|||||||
profit (recovery buy optional)
|
profit (recovery buy optional)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Market Maker v2 Flow (Buy Low, Sell High)
|
||||||
|
|
||||||
|
```
|
||||||
|
Detector polls all asset × duration combos (e.g. BTC/5m, ETH/15m)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Place limit BUY on UP + DOWN @ MAKER_BUY_PRICE (e.g. $0.02)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Monitor both sides concurrently
|
||||||
|
│
|
||||||
|
┌────┴─────────────────┐
|
||||||
|
│ │
|
||||||
|
One side fills CL at 10s before close
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
Cancel other buy Cancel unfilled buys
|
||||||
|
Place SELL @ 3c Place SELL for any fills (retry 3x)
|
||||||
|
│ │
|
||||||
|
└──────┬───────────────┘
|
||||||
|
▼
|
||||||
|
Monitor sells until market close
|
||||||
|
│
|
||||||
|
┌────┴────┐
|
||||||
|
│ │
|
||||||
|
Sell fills Market closes
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
WIN $ Tokens resolve on-chain
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
@@ -320,8 +385,10 @@ polymarket-terminal/
|
|||||||
├── src/
|
├── src/
|
||||||
│ ├── index.js — Copy trade bot (TUI)
|
│ ├── index.js — Copy trade bot (TUI)
|
||||||
│ ├── bot.js — Copy trade bot (plain log / PM2)
|
│ ├── bot.js — Copy trade bot (plain log / PM2)
|
||||||
│ ├── mm.js — Market maker bot (TUI)
|
│ ├── mm.js — Market maker v1 bot (TUI)
|
||||||
│ ├── mm-bot.js — Market maker bot (plain log / PM2)
|
│ ├── mm-bot.js — Market maker v1 bot (plain log / PM2)
|
||||||
|
│ ├── maker.js — Market maker v2 bot (TUI)
|
||||||
|
│ ├── maker-bot.js — Market maker v2 bot (plain log / PM2)
|
||||||
│ ├── sniper.js — Orderbook sniper bot
|
│ ├── sniper.js — Orderbook sniper bot
|
||||||
│ │
|
│ │
|
||||||
│ ├── config/
|
│ ├── config/
|
||||||
@@ -336,8 +403,11 @@ polymarket-terminal/
|
|||||||
│ │ ├── autoSell.js — Auto limit-sell placement
|
│ │ ├── autoSell.js — Auto limit-sell placement
|
||||||
│ │ ├── redeemer.js — Market resolution check & CTF redemption
|
│ │ ├── redeemer.js — Market resolution check & CTF redemption
|
||||||
│ │ ├── ctf.js — On-chain CTF contract interactions
|
│ │ ├── ctf.js — On-chain CTF contract interactions
|
||||||
│ │ ├── mmDetector.js — Market detection for market maker
|
│ │ ├── mmDetector.js — Market detection for MM v1
|
||||||
│ │ ├── mmExecutor.js — Market maker strategy execution
|
│ │ ├── mmExecutor.js — MM v1 strategy execution
|
||||||
|
│ │ ├── makerDetector.js — Market detection for Maker v2 (multi-duration)
|
||||||
|
│ │ ├── makerExecutor.js — Maker v2 strategy execution
|
||||||
|
│ │ ├── makerWs.js — Orderbook WebSocket for Maker v2
|
||||||
│ │ ├── sniperDetector.js — Market detection for sniper
|
│ │ ├── sniperDetector.js — Market detection for sniper
|
||||||
│ │ └── sniperExecutor.js — Orderbook sniper order placement
|
│ │ └── sniperExecutor.js — Orderbook sniper order placement
|
||||||
│ │
|
│ │
|
||||||
|
|||||||
+7
-1
@@ -21,7 +21,13 @@
|
|||||||
"sniper-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/sniper.js",
|
"sniper-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/sniper.js",
|
||||||
"sniper-tui": "DRY_RUN=false node src/sniper-tui.js",
|
"sniper-tui": "DRY_RUN=false node src/sniper-tui.js",
|
||||||
"sniper-tui-sim": "DRY_RUN=true node src/sniper-tui.js",
|
"sniper-tui-sim": "DRY_RUN=true node src/sniper-tui.js",
|
||||||
"sniper-tui-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/sniper-tui.js"
|
"sniper-tui-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/sniper-tui.js",
|
||||||
|
"maker": "DRY_RUN=false node src/maker.js",
|
||||||
|
"maker-sim": "DRY_RUN=true node src/maker.js",
|
||||||
|
"maker-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/maker.js",
|
||||||
|
"maker-bot": "node src/maker-bot.js",
|
||||||
|
"maker-bot-sim": "DRY_RUN=true node src/maker-bot.js",
|
||||||
|
"maker-bot-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/maker-bot.js"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"polymarket",
|
"polymarket",
|
||||||
|
|||||||
@@ -103,6 +103,20 @@ const config = {
|
|||||||
return schedule;
|
return schedule;
|
||||||
})(),
|
})(),
|
||||||
|
|
||||||
|
// ── Market Maker v2 (Buy Low, Sell High) ──────────────────────
|
||||||
|
// Places limit BUY on UP+DOWN at low price, sells at higher price when filled.
|
||||||
|
// No splitPosition — pure orderbook strategy.
|
||||||
|
makerAssets: (process.env.MAKER_ASSETS || 'btc')
|
||||||
|
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean),
|
||||||
|
makerDurations: (process.env.MAKER_DURATION || '5m')
|
||||||
|
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean),
|
||||||
|
makerBuyPrice: parseFloat(process.env.MAKER_BUY_PRICE || '0.02'),
|
||||||
|
makerSellPrice: parseFloat(process.env.MAKER_SELL_PRICE || '0.03'),
|
||||||
|
makerTradeSize: parseFloat(process.env.MAKER_TRADE_SIZE || '50'), // shares per side
|
||||||
|
makerSimBalance: parseFloat(process.env.MAKER_SIM_BALANCE || '50'), // starting sim balance
|
||||||
|
makerPollInterval: parseInt(process.env.MAKER_POLL_INTERVAL || '10', 10) * 1000,
|
||||||
|
makerMonitorMs: parseInt(process.env.MAKER_MONITOR_MS || '2000', 10), // how fast to poll order status
|
||||||
|
|
||||||
// ── Proxy (Polymarket API only, NOT Polygon RPC) ──────────────
|
// ── Proxy (Polymarket API only, NOT Polygon RPC) ──────────────
|
||||||
// Supports HTTP/HTTPS. Example: http://user:pass@host:port
|
// Supports HTTP/HTTPS. Example: http://user:pass@host:port
|
||||||
proxyUrl: process.env.PROXY_URL || '',
|
proxyUrl: process.env.PROXY_URL || '',
|
||||||
@@ -135,4 +149,19 @@ export function validateMMConfig() {
|
|||||||
throw new Error('MM_SELL_PRICE must be between 0 and 1');
|
throw new Error('MM_SELL_PRICE must be between 0 and 1');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validation for maker bot (buy low, sell high)
|
||||||
|
export function validateMakerConfig() {
|
||||||
|
const required = ['privateKey', 'proxyWallet'];
|
||||||
|
const missing = required.filter((key) => !config[key]);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new Error(`Missing required config: ${missing.join(', ')}. Check your .env file.`);
|
||||||
|
}
|
||||||
|
if (config.makerBuyPrice <= 0 || config.makerBuyPrice >= 1)
|
||||||
|
throw new Error('MAKER_BUY_PRICE must be between 0 and 1');
|
||||||
|
if (config.makerSellPrice <= config.makerBuyPrice)
|
||||||
|
throw new Error('MAKER_SELL_PRICE must be greater than MAKER_BUY_PRICE');
|
||||||
|
if (config.makerTradeSize < 5)
|
||||||
|
throw new Error('MAKER_TRADE_SIZE must be >= 5 (Polymarket minimum order size)');
|
||||||
|
}
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* maker-bot.js — Buy Low, Sell High Market Maker, PM2 / VPS entry point (no TUI)
|
||||||
|
*
|
||||||
|
* Plain-text stdout output, compatible with:
|
||||||
|
* pm2 start ecosystem.config.cjs --only polymarket-maker
|
||||||
|
* pm2 logs polymarket-maker
|
||||||
|
*/
|
||||||
|
|
||||||
|
import './utils/proxy-patch.cjs';
|
||||||
|
|
||||||
|
import { validateMakerConfig } from './config/index.js';
|
||||||
|
import config from './config/index.js';
|
||||||
|
import logger from './utils/logger.js';
|
||||||
|
import { initClient, getUsdcBalance } from './services/client.js';
|
||||||
|
import { startMakerDetector, stopMakerDetector } from './services/makerDetector.js';
|
||||||
|
import { executeMakerStrategy, getActiveMakerPositions, getSimStats } from './services/makerExecutor.js';
|
||||||
|
import { OrderbookWs } from './services/makerWs.js';
|
||||||
|
|
||||||
|
logger.interceptConsole();
|
||||||
|
|
||||||
|
// ── Validate config ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
try {
|
||||||
|
validateMakerConfig();
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Config error: ${err.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Init CLOB client ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
try {
|
||||||
|
await initClient();
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Client init error: ${err.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── WebSocket orderbook (for sim mode) ───────────────────────────────────────
|
||||||
|
|
||||||
|
const orderbookWs = new OrderbookWs();
|
||||||
|
|
||||||
|
// ── Periodic status log ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function printStatus() {
|
||||||
|
try {
|
||||||
|
let balanceStr = 'SIM';
|
||||||
|
if (!config.dryRun) {
|
||||||
|
try { balanceStr = `$${(await getUsdcBalance()).toFixed(2)} USDC`; } catch { balanceStr = 'N/A'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const positions = getActiveMakerPositions();
|
||||||
|
const mode = config.dryRun ? 'SIMULATION' : 'LIVE';
|
||||||
|
|
||||||
|
if (config.dryRun) {
|
||||||
|
const s = getSimStats();
|
||||||
|
const winRate = s.wins + s.losses > 0 ? ((s.wins / (s.wins + s.losses)) * 100).toFixed(1) : '0.0';
|
||||||
|
const pnlSign = s.cumulativePnl >= 0 ? '+' : '';
|
||||||
|
logger.info(`--- MAKER [SIM] | $${s.balance.toFixed(2)} (${pnlSign}$${s.cumulativePnl.toFixed(4)}) | W:${s.wins} L:${s.losses} S:${s.skips} Win%:${winRate}% | Active: ${positions.length} ---`);
|
||||||
|
} else {
|
||||||
|
logger.info(`--- MAKER Status [${mode}] | Balance: ${balanceStr} | Active: ${positions.length} ---`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const pos of positions) {
|
||||||
|
const assetTag = pos.asset ? `[${pos.asset.toUpperCase()}] ` : '';
|
||||||
|
const label = pos.question.substring(0, 50);
|
||||||
|
const msLeft = new Date(pos.endTime).getTime() - Date.now();
|
||||||
|
const secsLeft = Math.max(0, Math.round(msLeft / 1000));
|
||||||
|
const timeStr = secsLeft > 60 ? `${Math.floor(secsLeft / 60)}m${secsLeft % 60}s` : `${secsLeft}s`;
|
||||||
|
const pnl = pos.totalRevenue - pos.totalCost;
|
||||||
|
const sign = pnl >= 0 ? '+' : '';
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
` ${assetTag}${label} | ${pos.status} | ${timeStr} left` +
|
||||||
|
` | UP: ${pos.up.buyFilled.toFixed(1)}sh bought, ${pos.up.totalSellFilled.toFixed(1)}sh sold` +
|
||||||
|
` | DOWN: ${pos.down.buyFilled.toFixed(1)}sh bought, ${pos.down.totalSellFilled.toFixed(1)}sh sold` +
|
||||||
|
` | P&L: ${sign}$${pnl.toFixed(4)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Orderbook snapshot
|
||||||
|
for (const [label, tokenId] of [['UP', pos.up.tokenId], ['DOWN', pos.down.tokenId]]) {
|
||||||
|
const bestBid = orderbookWs.getBestBid(tokenId);
|
||||||
|
const bestAsk = orderbookWs.getBestAsk(tokenId);
|
||||||
|
if (bestBid || bestAsk) {
|
||||||
|
const book = orderbookWs.getBook(tokenId);
|
||||||
|
const bidDepth = book.bids.slice(0, 3).map(b => `${b.price.toFixed(2)}×${b.size.toFixed(0)}`).join(' ');
|
||||||
|
const askDepth = book.asks.slice(0, 3).map(a => `${a.price.toFixed(2)}×${a.size.toFixed(0)}`).join(' ');
|
||||||
|
logger.info(` ${label}: [${bidDepth}] | [${askDepth}]`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`Status check error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Market handler with per-asset queue ──────────────────────────────────────
|
||||||
|
|
||||||
|
const pendingByAsset = new Map();
|
||||||
|
|
||||||
|
function slotKey(market) {
|
||||||
|
return `${market.asset}-${market.duration || '5m'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runStrategy(market) {
|
||||||
|
const key = slotKey(market);
|
||||||
|
const tag = `${market.asset?.toUpperCase()}/${market.duration || '5m'}`;
|
||||||
|
|
||||||
|
if (config.dryRun) {
|
||||||
|
orderbookWs.subscribe(market.conditionId, [market.yesTokenId, market.noTokenId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await executeMakerStrategy(market);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`MAKER strategy error (${tag}): ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const queued = pendingByAsset.get(key);
|
||||||
|
if (queued) {
|
||||||
|
pendingByAsset.delete(key);
|
||||||
|
const secsLeft = Math.round((new Date(queued.endTime).getTime() - Date.now()) / 1000);
|
||||||
|
|
||||||
|
if (secsLeft > 30) {
|
||||||
|
logger.success(`MAKER[${tag}]: executing queued market (${secsLeft}s left)`);
|
||||||
|
runStrategy(queued);
|
||||||
|
} else {
|
||||||
|
logger.warn(`MAKER[${tag}]: queued market expired (${secsLeft}s left)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleNewMarket(market) {
|
||||||
|
const key = slotKey(market);
|
||||||
|
const tag = `${market.asset?.toUpperCase()}/${market.duration || '5m'}`;
|
||||||
|
const active = getActiveMakerPositions();
|
||||||
|
const isSlotBusy = active.some((p) => p.asset === market.asset && p.duration === (market.duration || '5m'));
|
||||||
|
|
||||||
|
if (isSlotBusy) {
|
||||||
|
pendingByAsset.set(key, market);
|
||||||
|
logger.warn(`MAKER[${tag}]: queued — will enter after current position clears`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
runStrategy(market);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Timers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const statusTimer = setInterval(printStatus, 60_000);
|
||||||
|
|
||||||
|
// ── Graceful shutdown ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function shutdown() {
|
||||||
|
logger.warn('MAKER: shutting down...');
|
||||||
|
stopMakerDetector();
|
||||||
|
orderbookWs.shutdown();
|
||||||
|
clearInterval(statusTimer);
|
||||||
|
setTimeout(() => process.exit(0), 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
|
||||||
|
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const mode = config.dryRun ? 'SIMULATION' : 'LIVE';
|
||||||
|
const costPerSide = config.makerTradeSize * config.makerBuyPrice;
|
||||||
|
const profitPerCycle = (config.makerSellPrice - config.makerBuyPrice) * config.makerTradeSize;
|
||||||
|
|
||||||
|
logger.info(`=== Market Maker v2 [${mode}] ===`);
|
||||||
|
logger.info(`Assets : ${config.makerAssets.join(', ').toUpperCase()}`);
|
||||||
|
logger.info(`Duration : ${config.makerDurations.join(', ')}`);
|
||||||
|
logger.info(`Buy @ : $${config.makerBuyPrice} per share`);
|
||||||
|
logger.info(`Sell @ : $${config.makerSellPrice} per share`);
|
||||||
|
logger.info(`Size : ${config.makerTradeSize} shares/side`);
|
||||||
|
logger.info(`Cost/side : $${costPerSide.toFixed(2)}`);
|
||||||
|
logger.info(`Profit : $${profitPerCycle.toFixed(2)} per cycle`);
|
||||||
|
logger.info(`CL : cancel buys at 10s, sell until close`);
|
||||||
|
logger.info('==========================================');
|
||||||
|
|
||||||
|
startMakerDetector(handleNewMarket);
|
||||||
|
logger.success(`MAKER bot started — watching for ${config.makerDurations.join('+')} ${config.makerAssets.join('/')} markets...`);
|
||||||
+281
@@ -0,0 +1,281 @@
|
|||||||
|
/**
|
||||||
|
* maker.js
|
||||||
|
* TUI version — Buy Low, Sell High Market Maker (blessed dashboard).
|
||||||
|
*
|
||||||
|
* Strategy: Place limit BUY on UP+DOWN at low price, sell at target when filled.
|
||||||
|
* No splitPosition — pure orderbook-based market making.
|
||||||
|
*
|
||||||
|
* Run with: npm run maker (live)
|
||||||
|
* npm run maker-sim (simulation with real orderbook via WebSocket)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import './utils/proxy-patch.cjs';
|
||||||
|
|
||||||
|
import { validateMakerConfig } from './config/index.js';
|
||||||
|
import config from './config/index.js';
|
||||||
|
import logger from './utils/logger.js';
|
||||||
|
import { initClient, getUsdcBalance } from './services/client.js';
|
||||||
|
import { initDashboard, appendLog, updateStatus, isDashboardActive } from './ui/dashboard.js';
|
||||||
|
import { startMakerDetector, stopMakerDetector } from './services/makerDetector.js';
|
||||||
|
import { executeMakerStrategy, getActiveMakerPositions, getSimStats } from './services/makerExecutor.js';
|
||||||
|
import { OrderbookWs } from './services/makerWs.js';
|
||||||
|
|
||||||
|
// ── Validate config ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
try {
|
||||||
|
validateMakerConfig();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Config error: ${err.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Init TUI ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
initDashboard();
|
||||||
|
logger.setOutput(appendLog);
|
||||||
|
|
||||||
|
// ── Init CLOB client ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
try {
|
||||||
|
await initClient();
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Client init error: ${err.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── WebSocket orderbook (for sim visualization) ──────────────────────────────
|
||||||
|
|
||||||
|
const orderbookWs = new OrderbookWs();
|
||||||
|
let activeWsTokens = { up: null, down: null };
|
||||||
|
|
||||||
|
// ── Status panel refresh ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function buildStatusContent() {
|
||||||
|
const lines = [];
|
||||||
|
|
||||||
|
// Balance + Sim Stats
|
||||||
|
if (config.dryRun) {
|
||||||
|
const s = getSimStats();
|
||||||
|
const pnlColor = s.cumulativePnl >= 0 ? 'green' : 'red';
|
||||||
|
const winRate = s.wins + s.losses > 0 ? ((s.wins / (s.wins + s.losses)) * 100).toFixed(1) : '0.0';
|
||||||
|
const pnlSign = s.cumulativePnl >= 0 ? '+' : '';
|
||||||
|
|
||||||
|
lines.push('{bold}SIMULATION{/bold}');
|
||||||
|
lines.push(` Balance : {green-fg}$${s.balance.toFixed(2)}{/green-fg} (start: $${s.startBalance.toFixed(2)})`);
|
||||||
|
lines.push(` PnL : {${pnlColor}-fg}${pnlSign}$${s.cumulativePnl.toFixed(4)}{/${pnlColor}-fg}`);
|
||||||
|
lines.push(` Trades : ${s.totalTrades} total`);
|
||||||
|
lines.push(` {green-fg}WIN ${s.wins}x{/green-fg} | {red-fg}LOSS ${s.losses}x{/red-fg} | {gray-fg}SKIP ${s.skips}x{/gray-fg}`);
|
||||||
|
lines.push(` Win% : ${winRate}%`);
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
// Recent trade history
|
||||||
|
if (s.history.length > 0) {
|
||||||
|
lines.push('{bold}TRADE HISTORY{/bold}');
|
||||||
|
const recent = s.history.slice(-8);
|
||||||
|
for (const h of recent) {
|
||||||
|
const rColor = h.result === 'win' ? 'green' : h.result === 'loss' ? 'red' : 'gray';
|
||||||
|
const pSign = h.pnl >= 0 ? '+' : '';
|
||||||
|
lines.push(` {gray-fg}${h.time}{/gray-fg} {${rColor}-fg}${h.result.toUpperCase().padEnd(4)}{/${rColor}-fg} ${(h.side || '-').padEnd(4)} {${rColor}-fg}${pSign}$${h.pnl.toFixed(4)}{/${rColor}-fg} → $${h.balance.toFixed(2)}`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let balance = '?';
|
||||||
|
try { balance = (await getUsdcBalance()).toFixed(2); } catch { /* ignore */ }
|
||||||
|
lines.push('{bold}BALANCE{/bold}');
|
||||||
|
lines.push(` USDC.e: {green-fg}$${balance}{/green-fg}`);
|
||||||
|
lines.push('');
|
||||||
|
lines.push('{bold}MODE{/bold}');
|
||||||
|
lines.push(' {green-fg}LIVE{/green-fg}');
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maker Config
|
||||||
|
lines.push('{bold}MAKER CONFIG{/bold}');
|
||||||
|
lines.push(` Assets : ${config.makerAssets.join(', ').toUpperCase()}`);
|
||||||
|
lines.push(` Duration : ${config.makerDurations.join(', ')}`);
|
||||||
|
lines.push(` Buy @ : $${config.makerBuyPrice} per share`);
|
||||||
|
lines.push(` Sell @ : $${config.makerSellPrice} per share`);
|
||||||
|
lines.push(` Size : ${config.makerTradeSize} shares/side`);
|
||||||
|
lines.push(` Cost/side: $${(config.makerTradeSize * config.makerBuyPrice).toFixed(2)}`);
|
||||||
|
lines.push(` Profit : $${((config.makerSellPrice - config.makerBuyPrice) * config.makerTradeSize).toFixed(2)}/cycle`);
|
||||||
|
lines.push(` CL : cancel buys at ${config.makerCLSeconds || 10}s, sell until close`);
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
// Active positions
|
||||||
|
const positions = getActiveMakerPositions();
|
||||||
|
lines.push(`{bold}ACTIVE POSITIONS (${positions.length}){/bold}`);
|
||||||
|
|
||||||
|
if (positions.length === 0) {
|
||||||
|
lines.push(' {gray-fg}Waiting for market...{/gray-fg}');
|
||||||
|
} else {
|
||||||
|
for (const pos of positions) {
|
||||||
|
const assetTag = pos.asset ? `[${pos.asset.toUpperCase()}/${pos.duration || '5m'}] ` : '';
|
||||||
|
const label = pos.question.substring(0, 32);
|
||||||
|
const msLeft = new Date(pos.endTime).getTime() - Date.now();
|
||||||
|
const secsLeft = Math.max(0, Math.round(msLeft / 1000));
|
||||||
|
const timeStr = secsLeft > 60
|
||||||
|
? `${Math.floor(secsLeft / 60)}m${secsLeft % 60}s`
|
||||||
|
: `{red-fg}${secsLeft}s{/red-fg}`;
|
||||||
|
|
||||||
|
lines.push(` {cyan-fg}${assetTag}${label}{/cyan-fg}`);
|
||||||
|
lines.push(` Status : ${pos.status} | Time left: ${timeStr}`);
|
||||||
|
|
||||||
|
// UP side
|
||||||
|
const upFill = pos.up.buyFilled > 0
|
||||||
|
? `{green-fg}BOUGHT ${pos.up.buyFilled.toFixed(1)}sh{/green-fg}`
|
||||||
|
: '{gray-fg}waiting...{/gray-fg}';
|
||||||
|
const upSold = pos.up.totalSellFilled > 0
|
||||||
|
? ` → {green-fg}SOLD ${pos.up.totalSellFilled.toFixed(1)}sh{/green-fg}`
|
||||||
|
: pos.up.sellOrders.length > 0 ? ' → {yellow-fg}selling...{/yellow-fg}' : '';
|
||||||
|
lines.push(` UP ${upFill}${upSold}`);
|
||||||
|
|
||||||
|
// DOWN side
|
||||||
|
const downFill = pos.down.buyFilled > 0
|
||||||
|
? `{green-fg}BOUGHT ${pos.down.buyFilled.toFixed(1)}sh{/green-fg}`
|
||||||
|
: '{gray-fg}waiting...{/gray-fg}';
|
||||||
|
const downSold = pos.down.totalSellFilled > 0
|
||||||
|
? ` → {green-fg}SOLD ${pos.down.totalSellFilled.toFixed(1)}sh{/green-fg}`
|
||||||
|
: pos.down.sellOrders.length > 0 ? ' → {yellow-fg}selling...{/yellow-fg}' : '';
|
||||||
|
lines.push(` DOWN ${downFill}${downSold}`);
|
||||||
|
|
||||||
|
// P&L
|
||||||
|
const pnl = pos.totalRevenue - pos.totalCost;
|
||||||
|
const pnlColor = pnl >= 0 ? 'green' : 'red';
|
||||||
|
lines.push(` P&L: {${pnlColor}-fg}$${pnl.toFixed(4)}{/${pnlColor}-fg}`);
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Orderbook display (always show when tokens are active)
|
||||||
|
if (activeWsTokens.up) {
|
||||||
|
lines.push('{bold}LIVE ORDERBOOK{/bold}');
|
||||||
|
|
||||||
|
for (const [label, tokenId] of [['UP', activeWsTokens.up], ['DOWN', activeWsTokens.down]]) {
|
||||||
|
if (!tokenId) continue;
|
||||||
|
const book = orderbookWs.getBook(tokenId);
|
||||||
|
const bestBid = orderbookWs.getBestBid(tokenId);
|
||||||
|
const bestAsk = orderbookWs.getBestAsk(tokenId);
|
||||||
|
const mid = bestBid && bestAsk ? ((bestBid + bestAsk) / 2) : 0;
|
||||||
|
|
||||||
|
lines.push(` {cyan-fg}${label}{/cyan-fg} mid: $${mid.toFixed(3)} | bid: $${bestBid.toFixed(2)} ask: $${bestAsk.toFixed(2)}`);
|
||||||
|
|
||||||
|
// Top 5 asks (reversed so lowest is closest to spread)
|
||||||
|
const topAsks = book.asks.slice(0, 5).reverse();
|
||||||
|
for (const ask of topAsks) {
|
||||||
|
const bar = '█'.repeat(Math.min(10, Math.round(ask.size / 100)));
|
||||||
|
lines.push(` {red-fg}$${ask.price.toFixed(2)} ${ask.size.toFixed(0).padStart(7)} ${bar}{/red-fg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spread line
|
||||||
|
if (bestBid && bestAsk) {
|
||||||
|
const spread = bestAsk - bestBid;
|
||||||
|
lines.push(` {yellow-fg}── spread $${spread.toFixed(2)} ──{/yellow-fg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top 5 bids
|
||||||
|
const topBids = book.bids.slice(0, 5);
|
||||||
|
for (const bid of topBids) {
|
||||||
|
const bar = '█'.repeat(Math.min(10, Math.round(bid.size / 100)));
|
||||||
|
lines.push(` {green-fg}$${bid.price.toFixed(2)} ${bid.size.toFixed(0).padStart(7)} ${bar}{/green-fg}`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '\n' + lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
let refreshTimer = null;
|
||||||
|
|
||||||
|
function startRefresh() {
|
||||||
|
refreshTimer = setInterval(async () => {
|
||||||
|
if (!isDashboardActive()) return;
|
||||||
|
updateStatus(await buildStatusContent());
|
||||||
|
}, 2000);
|
||||||
|
buildStatusContent().then(updateStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Market handler with per-asset queue ──────────────────────────────────────
|
||||||
|
|
||||||
|
const pendingByAsset = new Map();
|
||||||
|
|
||||||
|
function slotKey(market) {
|
||||||
|
return `${market.asset}-${market.duration || '5m'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runStrategy(market) {
|
||||||
|
const key = slotKey(market);
|
||||||
|
const tag = `${market.asset?.toUpperCase()}/${market.duration || '5m'}`;
|
||||||
|
|
||||||
|
// Connect WebSocket for orderbook visualization in sim mode
|
||||||
|
if (config.dryRun) {
|
||||||
|
activeWsTokens = { up: market.yesTokenId, down: market.noTokenId };
|
||||||
|
orderbookWs.subscribe(market.conditionId, [market.yesTokenId, market.noTokenId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await executeMakerStrategy(market);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`MAKER strategy error (${tag}): ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disconnect WS after strategy ends
|
||||||
|
if (config.dryRun) {
|
||||||
|
activeWsTokens = { up: null, down: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process queued market
|
||||||
|
const queued = pendingByAsset.get(key);
|
||||||
|
if (queued) {
|
||||||
|
pendingByAsset.delete(key);
|
||||||
|
const endMs = new Date(queued.endTime).getTime();
|
||||||
|
const secsLeft = Math.round((endMs - Date.now()) / 1000);
|
||||||
|
|
||||||
|
if (secsLeft > 30) {
|
||||||
|
logger.success(`MAKER[${tag}]: executing queued market (${secsLeft}s left)`);
|
||||||
|
runStrategy(queued);
|
||||||
|
} else {
|
||||||
|
logger.warn(`MAKER[${tag}]: queued market expired (${secsLeft}s left)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleNewMarket(market) {
|
||||||
|
const key = slotKey(market);
|
||||||
|
const tag = `${market.asset?.toUpperCase()}/${market.duration || '5m'}`;
|
||||||
|
const active = getActiveMakerPositions();
|
||||||
|
const isSlotBusy = active.some((p) => p.asset === market.asset && p.duration === (market.duration || '5m'));
|
||||||
|
|
||||||
|
if (isSlotBusy) {
|
||||||
|
pendingByAsset.set(key, market);
|
||||||
|
logger.warn(`MAKER[${tag}]: queued — will enter after current position clears`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
runStrategy(market);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Graceful shutdown ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function shutdown() {
|
||||||
|
logger.warn('MAKER: shutting down...');
|
||||||
|
stopMakerDetector();
|
||||||
|
orderbookWs.shutdown();
|
||||||
|
if (refreshTimer) clearInterval(refreshTimer);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
|
||||||
|
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const costPerSide = config.makerTradeSize * config.makerBuyPrice;
|
||||||
|
const profitPerCycle = (config.makerSellPrice - config.makerBuyPrice) * config.makerTradeSize;
|
||||||
|
logger.info(`MAKER starting — ${config.dryRun ? 'SIMULATION' : 'LIVE'}`);
|
||||||
|
logger.info(`Assets: ${config.makerAssets.join(', ').toUpperCase()} | Durations: ${config.makerDurations.join(', ')} | BUY @ $${config.makerBuyPrice} → SELL @ $${config.makerSellPrice}`);
|
||||||
|
logger.info(`Size: ${config.makerTradeSize} sh/side | Cost: $${costPerSide.toFixed(2)}/side | Profit: $${profitPerCycle.toFixed(2)}/cycle`);
|
||||||
|
|
||||||
|
startRefresh();
|
||||||
|
startMakerDetector(handleNewMarket);
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
/**
|
||||||
|
* makerDetector.js
|
||||||
|
* Detects upcoming markets for the Maker strategy (buy low, sell high).
|
||||||
|
* Supports multiple assets AND multiple durations (e.g. 5m,15m).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import config from '../config/index.js';
|
||||||
|
import logger from '../utils/logger.js';
|
||||||
|
import { proxyFetch } from '../utils/proxy.js';
|
||||||
|
|
||||||
|
const DURATION_SECS = { '5m': 300, '15m': 900 };
|
||||||
|
|
||||||
|
let pollTimer = null;
|
||||||
|
let onMarketCb = null;
|
||||||
|
const seenKeys = new Set();
|
||||||
|
|
||||||
|
function slotSec(duration) {
|
||||||
|
return DURATION_SECS[duration] || 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentSlot(duration) {
|
||||||
|
const sec = slotSec(duration);
|
||||||
|
return Math.floor(Date.now() / 1000 / sec) * sec;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextSlot(duration) {
|
||||||
|
return currentSlot(duration) + slotSec(duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchBySlug(asset, duration, slotTimestamp) {
|
||||||
|
const slug = `${asset}-updown-${duration}-${slotTimestamp}`;
|
||||||
|
try {
|
||||||
|
const resp = await proxyFetch(`${config.gammaHost}/markets/slug/${slug}`);
|
||||||
|
if (!resp.ok) return null;
|
||||||
|
const data = await resp.json();
|
||||||
|
return data?.conditionId ? data : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractMarketData(market, asset, duration) {
|
||||||
|
const conditionId = market.conditionId || market.condition_id || '';
|
||||||
|
if (!conditionId) return null;
|
||||||
|
|
||||||
|
let tokenIds = market.clobTokenIds ?? market.clob_token_ids;
|
||||||
|
if (typeof tokenIds === 'string') {
|
||||||
|
try { tokenIds = JSON.parse(tokenIds); } catch { tokenIds = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
let yesTokenId, noTokenId;
|
||||||
|
if (Array.isArray(tokenIds) && tokenIds.length >= 2) {
|
||||||
|
[yesTokenId, noTokenId] = tokenIds;
|
||||||
|
} else if (Array.isArray(market.tokens) && market.tokens.length >= 2) {
|
||||||
|
yesTokenId = market.tokens[0]?.token_id ?? market.tokens[0]?.tokenId;
|
||||||
|
noTokenId = market.tokens[1]?.token_id ?? market.tokens[1]?.tokenId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!yesTokenId || !noTokenId) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
asset,
|
||||||
|
duration,
|
||||||
|
conditionId,
|
||||||
|
question: market.question || market.title || '',
|
||||||
|
endTime: market.endDate || market.end_date_iso || market.endDateIso,
|
||||||
|
eventStartTime: market.eventStartTime || market.event_start_time,
|
||||||
|
yesTokenId: String(yesTokenId),
|
||||||
|
noTokenId: String(noTokenId),
|
||||||
|
negRisk: market.negRisk ?? market.neg_risk ?? false,
|
||||||
|
tickSize: String(market.orderPriceMinTickSize ?? market.minimum_tick_size ?? market.minimumTickSize ?? '0.01'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scheduleAsset(asset, duration, slotTimestamp) {
|
||||||
|
const key = `${asset}-${duration}-${slotTimestamp}`;
|
||||||
|
if (seenKeys.has(key)) return;
|
||||||
|
|
||||||
|
const market = await fetchBySlug(asset, duration, slotTimestamp);
|
||||||
|
if (!market) return;
|
||||||
|
|
||||||
|
const data = extractMarketData(market, asset, duration);
|
||||||
|
if (!data) {
|
||||||
|
logger.warn(`MAKER: skipping ${asset.toUpperCase()} ${duration} slot ${slotTimestamp} — missing token IDs`);
|
||||||
|
seenKeys.add(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
seenKeys.add(key);
|
||||||
|
|
||||||
|
const openAt = data.eventStartTime ? new Date(data.eventStartTime).getTime() : slotTimestamp * 1000;
|
||||||
|
const elapsedSec = Math.round((Date.now() - openAt) / 1000);
|
||||||
|
if (elapsedSec > 15) {
|
||||||
|
logger.info(`MAKER: ${asset.toUpperCase()} ${duration} next slot already ${elapsedSec}s old — skipping`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const secsUntilOpen = Math.round((openAt - Date.now()) / 1000);
|
||||||
|
if (secsUntilOpen > 0) {
|
||||||
|
logger.success(`MAKER: ${asset.toUpperCase()} ${duration} found "${data.question.slice(0, 40)}" — placing orders (${secsUntilOpen}s before open)`);
|
||||||
|
} else {
|
||||||
|
logger.success(`MAKER: ${asset.toUpperCase()} ${duration} found "${data.question.slice(0, 40)}" — placing orders now`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onMarketCb) onMarketCb(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function poll() {
|
||||||
|
try {
|
||||||
|
const tasks = [];
|
||||||
|
for (const duration of config.makerDurations) {
|
||||||
|
const next = nextSlot(duration);
|
||||||
|
for (const asset of config.makerAssets) {
|
||||||
|
tasks.push(scheduleAsset(asset, duration, next));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(tasks);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('MAKER detector poll error:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startMakerDetector(onNewMarket) {
|
||||||
|
onMarketCb = onNewMarket;
|
||||||
|
seenKeys.clear();
|
||||||
|
|
||||||
|
poll();
|
||||||
|
pollTimer = setInterval(poll, config.makerPollInterval);
|
||||||
|
|
||||||
|
const durStr = config.makerDurations.join(', ');
|
||||||
|
for (const duration of config.makerDurations) {
|
||||||
|
const ns = nextSlot(duration);
|
||||||
|
const secsUntil = ns - Math.floor(Date.now() / 1000);
|
||||||
|
logger.info(`MAKER detector — ${duration}: next slot *-updown-${duration}-${ns} (opens in ${secsUntil}s)`);
|
||||||
|
}
|
||||||
|
logger.info(`MAKER detector started — assets: ${config.makerAssets.join(', ').toUpperCase()} | durations: ${durStr}`);
|
||||||
|
logger.info(`Strategy: BUY @ $${config.makerBuyPrice} → SELL @ $${config.makerSellPrice} | ${config.makerTradeSize} shares/side`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopMakerDetector() {
|
||||||
|
if (pollTimer) {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,525 @@
|
|||||||
|
/**
|
||||||
|
* makerExecutor.js
|
||||||
|
* Buy Low, Sell High Market Maker — no splitPosition.
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. Ensure ERC1155 exchange approval (one-time, needed to sell tokens)
|
||||||
|
* 2. Place concurrent limit BUY on UP + DOWN at makerBuyPrice (e.g. 2c)
|
||||||
|
* 3. Monitor both orders in parallel (multi-thread style)
|
||||||
|
* 4. When one side fills (even partial):
|
||||||
|
* a. Wait briefly for on-chain settlement
|
||||||
|
* b. Place limit SELL for filled shares at makerSellPrice (e.g. 3c)
|
||||||
|
* c. Cancel the other side's buy order
|
||||||
|
* 5. Partial fills → partial sells placed immediately
|
||||||
|
* 6. CL at 10s before close: cancel unfilled buy orders
|
||||||
|
* 7. Place sells for any filled positions (retry 3x if settlement pending)
|
||||||
|
* 8. Monitor sell orders until filled or market close
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Side, OrderType } from '@polymarket/clob-client';
|
||||||
|
import config from '../config/index.js';
|
||||||
|
import { getClient } from './client.js';
|
||||||
|
import { ensureExchangeApproval } from './ctf.js';
|
||||||
|
import logger from '../utils/logger.js';
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
const CL_SECONDS = 10; // cancel unfilled sells 10s before market close
|
||||||
|
const SELL_DELAY_MS = 2000; // wait for on-chain settlement before placing sell
|
||||||
|
const MAX_SELL_RETRIES = 3;
|
||||||
|
const MIN_ORDER_SIZE = 5; // Polymarket minimum order size
|
||||||
|
|
||||||
|
// In-memory store of active maker positions
|
||||||
|
const activePositions = new Map();
|
||||||
|
|
||||||
|
export function getActiveMakerPositions() {
|
||||||
|
return Array.from(activePositions.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Simulation stats ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const simStats = {
|
||||||
|
startBalance: config.makerSimBalance,
|
||||||
|
balance: config.makerSimBalance,
|
||||||
|
wins: 0, // sell filled → realized profit
|
||||||
|
losses: 0, // buy filled, sell NOT filled → loss = buy cost
|
||||||
|
skips: 0, // no fills at all → $0
|
||||||
|
totalTrades: 0,
|
||||||
|
cumulativePnl: 0,
|
||||||
|
history: [], // [{ time, side, result, pnl, balance }]
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getSimStats() {
|
||||||
|
return { ...simStats, history: [...simStats.history] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordTrade(result, side, pnl) {
|
||||||
|
simStats.totalTrades++;
|
||||||
|
simStats.cumulativePnl += pnl;
|
||||||
|
simStats.balance += pnl;
|
||||||
|
|
||||||
|
if (result === 'win') simStats.wins++;
|
||||||
|
else if (result === 'loss') simStats.losses++;
|
||||||
|
else simStats.skips++;
|
||||||
|
|
||||||
|
simStats.history.push({
|
||||||
|
time: new Date().toISOString().replace('T', ' ').substring(11, 19),
|
||||||
|
side: side || '-',
|
||||||
|
result,
|
||||||
|
pnl,
|
||||||
|
balance: simStats.balance,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (simStats.history.length > 50) simStats.history.splice(0, simStats.history.length - 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Approval tracking ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let approvalChecked = false;
|
||||||
|
|
||||||
|
async function ensureApproval(negRisk) {
|
||||||
|
if (config.dryRun || approvalChecked) return;
|
||||||
|
try {
|
||||||
|
await ensureExchangeApproval(negRisk);
|
||||||
|
approvalChecked = true;
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`MAKER: exchange approval failed — ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Order helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function placeLimitBuy(tokenId, shares, price, tickSize, negRisk) {
|
||||||
|
const size = Math.max(MIN_ORDER_SIZE, Math.floor(shares));
|
||||||
|
if (config.dryRun) {
|
||||||
|
return { success: true, orderId: `sim-buy-${Date.now()}-${tokenId.slice(-6)}`, size };
|
||||||
|
}
|
||||||
|
const client = getClient();
|
||||||
|
try {
|
||||||
|
const res = await client.createAndPostOrder(
|
||||||
|
{ tokenID: tokenId, side: Side.BUY, price, size },
|
||||||
|
{ tickSize, negRisk },
|
||||||
|
OrderType.GTC,
|
||||||
|
);
|
||||||
|
if (!res?.success) return { success: false };
|
||||||
|
return { success: true, orderId: res.orderID, size };
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('MAKER limit buy error:', err.message);
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function placeLimitSellWithRetry(tokenId, shares, price, tickSize, negRisk, tag) {
|
||||||
|
const size = Math.max(MIN_ORDER_SIZE, Math.floor(shares));
|
||||||
|
if (config.dryRun) {
|
||||||
|
return { success: true, orderId: `sim-sell-${Date.now()}-${tokenId.slice(-6)}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = getClient();
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= MAX_SELL_RETRIES; attempt++) {
|
||||||
|
try {
|
||||||
|
const res = await client.createAndPostOrder(
|
||||||
|
{ tokenID: tokenId, side: Side.SELL, price, size },
|
||||||
|
{ tickSize, negRisk },
|
||||||
|
OrderType.GTC,
|
||||||
|
);
|
||||||
|
if (res?.success) {
|
||||||
|
return { success: true, orderId: res.orderID };
|
||||||
|
}
|
||||||
|
|
||||||
|
const errMsg = res?.errorMsg || 'unknown';
|
||||||
|
logger.warn(`MAKER${tag}: sell attempt ${attempt}/${MAX_SELL_RETRIES} failed: ${errMsg}`);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`MAKER${tag}: sell attempt ${attempt}/${MAX_SELL_RETRIES} error: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt < MAX_SELL_RETRIES) {
|
||||||
|
// Wait longer each retry — tokens might not have settled yet
|
||||||
|
const delay = SELL_DELAY_MS * attempt;
|
||||||
|
logger.info(`MAKER${tag}: waiting ${delay / 1000}s for on-chain settlement before retry...`);
|
||||||
|
await sleep(delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelOrder(orderId) {
|
||||||
|
if (config.dryRun || !orderId || orderId.startsWith('sim-')) return true;
|
||||||
|
try {
|
||||||
|
await getClient().cancelOrder({ orderID: orderId });
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('MAKER cancel order error:', err.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Order status ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function getOrderFill(orderId) {
|
||||||
|
if (!orderId || orderId.startsWith('sim-')) {
|
||||||
|
return { matched: 0, status: 'LIVE', fullyFilled: false };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const order = await getClient().getOrder(orderId);
|
||||||
|
if (!order) return { matched: 0, status: 'UNKNOWN', fullyFilled: false };
|
||||||
|
const matched = parseFloat(order.size_matched || '0');
|
||||||
|
return {
|
||||||
|
matched,
|
||||||
|
status: order.status,
|
||||||
|
fullyFilled: order.status === 'MATCHED',
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { matched: 0, status: 'ERROR', fullyFilled: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulation: check if market price would fill our order
|
||||||
|
async function simCheckFill(tokenId, side, price) {
|
||||||
|
try {
|
||||||
|
const mp = await getClient().getMidpoint(tokenId);
|
||||||
|
const midPrice = parseFloat(mp?.mid ?? mp ?? '0') || 0;
|
||||||
|
if (side === 'buy' && midPrice <= price) return midPrice;
|
||||||
|
if (side === 'sell' && midPrice >= price) return midPrice;
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Core strategy ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function executeMakerStrategy(market) {
|
||||||
|
const { asset, duration, conditionId, question, endTime, yesTokenId, noTokenId, negRisk, tickSize } = market;
|
||||||
|
const tag = asset ? `[${asset.toUpperCase()}/${duration || '5m'}]` : '';
|
||||||
|
const label = question.substring(0, 40);
|
||||||
|
const sim = config.dryRun ? '[SIM] ' : '';
|
||||||
|
const { makerBuyPrice, makerSellPrice, makerTradeSize, makerMonitorMs } = config;
|
||||||
|
|
||||||
|
// Check sim balance
|
||||||
|
const costPerSide = makerTradeSize * makerBuyPrice;
|
||||||
|
if (config.dryRun && simStats.balance < costPerSide) {
|
||||||
|
logger.warn(`MAKER${tag}: ${sim}insufficient sim balance $${simStats.balance.toFixed(2)} (need $${costPerSide.toFixed(2)}) — skipping`);
|
||||||
|
recordTrade('skip', null, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`MAKER${tag}: ${sim}entering — ${label}`);
|
||||||
|
logger.info(`MAKER${tag}: BUY @ $${makerBuyPrice} → SELL @ $${makerSellPrice} | ${makerTradeSize} shares/side | cost $${costPerSide.toFixed(2)}`);
|
||||||
|
|
||||||
|
// ── 0. Ensure ERC1155 exchange approval (one-time) ───────────
|
||||||
|
await ensureApproval(negRisk);
|
||||||
|
|
||||||
|
// ── 1. Place BUY UP + DOWN concurrently ──────────────────────
|
||||||
|
logger.trade(`MAKER${tag}: ${sim}placing BUY UP + DOWN @ $${makerBuyPrice}`);
|
||||||
|
|
||||||
|
const [upBuy, downBuy] = await Promise.all([
|
||||||
|
placeLimitBuy(yesTokenId, makerTradeSize, makerBuyPrice, tickSize, negRisk),
|
||||||
|
placeLimitBuy(noTokenId, makerTradeSize, makerBuyPrice, tickSize, negRisk),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!upBuy.success && !downBuy.success) {
|
||||||
|
logger.error(`MAKER${tag}: both buy orders failed — aborting`);
|
||||||
|
recordTrade('skip', null, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (upBuy.success) logger.trade(`MAKER${tag}: ${sim}UP BUY placed | order ${upBuy.orderId}`);
|
||||||
|
if (downBuy.success) logger.trade(`MAKER${tag}: ${sim}DOWN BUY placed | order ${downBuy.orderId}`);
|
||||||
|
|
||||||
|
// ── 2. Build position state ──────────────────────────────────
|
||||||
|
const pos = {
|
||||||
|
asset: asset || 'btc',
|
||||||
|
duration: duration || '5m',
|
||||||
|
conditionId,
|
||||||
|
question,
|
||||||
|
endTime,
|
||||||
|
tickSize,
|
||||||
|
negRisk,
|
||||||
|
status: 'buying',
|
||||||
|
enteredAt: new Date().toISOString(),
|
||||||
|
up: {
|
||||||
|
tokenId: yesTokenId,
|
||||||
|
buyOrderId: upBuy.success ? upBuy.orderId : null,
|
||||||
|
buyFilled: 0,
|
||||||
|
sellOrders: [],
|
||||||
|
totalSellFilled: 0,
|
||||||
|
cancelled: !upBuy.success,
|
||||||
|
},
|
||||||
|
down: {
|
||||||
|
tokenId: noTokenId,
|
||||||
|
buyOrderId: downBuy.success ? downBuy.orderId : null,
|
||||||
|
buyFilled: 0,
|
||||||
|
sellOrders: [],
|
||||||
|
totalSellFilled: 0,
|
||||||
|
cancelled: !downBuy.success,
|
||||||
|
},
|
||||||
|
winner: null,
|
||||||
|
totalCost: 0,
|
||||||
|
totalRevenue: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
activePositions.set(conditionId, pos);
|
||||||
|
|
||||||
|
// ── 3. Monitor buy → sell (concurrent) ───────────────────────
|
||||||
|
try {
|
||||||
|
await monitorBuyPhase(pos, tag, sim);
|
||||||
|
await monitorSellPhase(pos, tag, sim);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`MAKER${tag}: strategy error — ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Final result + sim stats ─────────────────────────────────
|
||||||
|
const pnl = pos.totalRevenue - pos.totalCost;
|
||||||
|
const winnerSide = pos.winner?.toUpperCase() || '-';
|
||||||
|
|
||||||
|
if (pos.status === 'done' && pos.totalRevenue > 0) {
|
||||||
|
// WIN: sell filled
|
||||||
|
recordTrade('win', winnerSide, pnl);
|
||||||
|
logger.money(`MAKER${tag}: ${sim}WIN | ${winnerSide} | cost $${pos.totalCost.toFixed(4)} → revenue $${pos.totalRevenue.toFixed(4)} | P&L +$${pnl.toFixed(4)}`);
|
||||||
|
} else if (pos.totalCost > 0) {
|
||||||
|
// LOSS: buy filled but sell didn't fill (expired-holding)
|
||||||
|
recordTrade('loss', winnerSide, -pos.totalCost);
|
||||||
|
logger.warn(`MAKER${tag}: ${sim}LOSS | ${winnerSide} | cost $${pos.totalCost.toFixed(4)} (sell not filled, held to expiry)`);
|
||||||
|
} else {
|
||||||
|
// SKIP: nothing filled
|
||||||
|
recordTrade('skip', null, 0);
|
||||||
|
logger.info(`MAKER${tag}: ${sim}SKIP | no fills, $0 cost`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log running stats
|
||||||
|
const s = simStats;
|
||||||
|
const winRate = s.wins + s.losses > 0 ? ((s.wins / (s.wins + s.losses)) * 100).toFixed(1) : '0.0';
|
||||||
|
logger.info(`MAKER${tag}: ${sim}STATS | W:${s.wins} L:${s.losses} S:${s.skips} | Win%: ${winRate}% | PnL: $${s.cumulativePnl.toFixed(4)} | Balance: $${s.balance.toFixed(2)}`);
|
||||||
|
|
||||||
|
activePositions.delete(conditionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Buy phase: monitor both sides concurrently ───────────────────────────────
|
||||||
|
|
||||||
|
async function monitorBuyPhase(pos, tag, sim) {
|
||||||
|
const { makerBuyPrice, makerSellPrice, makerMonitorMs } = config;
|
||||||
|
|
||||||
|
const monitorSide = async (sideKey) => {
|
||||||
|
const side = pos[sideKey];
|
||||||
|
const otherKey = sideKey === 'up' ? 'down' : 'up';
|
||||||
|
const sideName = sideKey.toUpperCase();
|
||||||
|
|
||||||
|
if (!side.buyOrderId) return;
|
||||||
|
|
||||||
|
while (!pos.winner) {
|
||||||
|
const msLeft = new Date(pos.endTime).getTime() - Date.now();
|
||||||
|
// CL: cancel unfilled buy at 10s before market close
|
||||||
|
if (msLeft <= CL_SECONDS * 1000) {
|
||||||
|
if (side.buyOrderId && !side.cancelled) {
|
||||||
|
logger.warn(`MAKER${tag}: CL ${CL_SECONDS}s — cancelling ${sideName} buy`);
|
||||||
|
await cancelOrder(side.buyOrderId);
|
||||||
|
side.cancelled = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check fill
|
||||||
|
let fill;
|
||||||
|
if (config.dryRun) {
|
||||||
|
const hitPrice = await simCheckFill(side.tokenId, 'buy', makerBuyPrice);
|
||||||
|
if (hitPrice !== null) {
|
||||||
|
fill = { matched: config.makerTradeSize, fullyFilled: true };
|
||||||
|
} else {
|
||||||
|
fill = { matched: 0, fullyFilled: false };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fill = await getOrderFill(side.buyOrderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// New fills detected → wait for settlement, then place sell
|
||||||
|
const newFill = fill.matched - side.buyFilled;
|
||||||
|
if (newFill > 0) {
|
||||||
|
side.buyFilled = fill.matched;
|
||||||
|
pos.totalCost += newFill * makerBuyPrice;
|
||||||
|
|
||||||
|
logger.money(`MAKER${tag}: ${sim}${sideName} BUY filled ${newFill.toFixed(2)} shares @ $${makerBuyPrice} (total: ${side.buyFilled.toFixed(2)}/${config.makerTradeSize})`);
|
||||||
|
|
||||||
|
// Check total unsold shares — only place sell if >= MIN_ORDER_SIZE
|
||||||
|
const soldShares = side.sellOrders.reduce((sum, so) => sum + so.shares, 0);
|
||||||
|
const unsold = side.buyFilled - soldShares;
|
||||||
|
|
||||||
|
if (unsold >= MIN_ORDER_SIZE) {
|
||||||
|
// Wait for on-chain token settlement before placing sell
|
||||||
|
if (!config.dryRun) {
|
||||||
|
logger.info(`MAKER${tag}: waiting ${SELL_DELAY_MS / 1000}s for on-chain settlement...`);
|
||||||
|
await sleep(SELL_DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sellResult = await placeLimitSellWithRetry(
|
||||||
|
side.tokenId, unsold, makerSellPrice,
|
||||||
|
pos.tickSize, pos.negRisk, tag,
|
||||||
|
);
|
||||||
|
if (sellResult.success) {
|
||||||
|
side.sellOrders.push({
|
||||||
|
orderId: sellResult.orderId,
|
||||||
|
shares: unsold,
|
||||||
|
filled: false,
|
||||||
|
fillPrice: null,
|
||||||
|
});
|
||||||
|
logger.trade(`MAKER${tag}: ${sim}${sideName} SELL placed ${unsold.toFixed(2)} shares @ $${makerSellPrice}`);
|
||||||
|
} else {
|
||||||
|
logger.error(`MAKER${tag}: ${sideName} SELL failed after ${MAX_SELL_RETRIES} retries — tokens held to resolution`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.info(`MAKER${tag}: ${sideName} unsold ${unsold.toFixed(2)} shares < ${MIN_ORDER_SIZE} min — waiting for more fills`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fully filled → winner
|
||||||
|
if (fill.fullyFilled) {
|
||||||
|
pos.winner = sideKey;
|
||||||
|
pos.status = 'selling';
|
||||||
|
logger.success(`MAKER${tag}: ${sim}${sideName} fully filled! Cancelling ${otherKey.toUpperCase()} buy...`);
|
||||||
|
|
||||||
|
const other = pos[otherKey];
|
||||||
|
if (other.buyOrderId && !other.cancelled) {
|
||||||
|
await cancelOrder(other.buyOrderId);
|
||||||
|
other.cancelled = true;
|
||||||
|
logger.info(`MAKER${tag}: ${otherKey.toUpperCase()} buy cancelled`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sleep(makerMonitorMs);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await Promise.race([
|
||||||
|
monitorSide('up'),
|
||||||
|
monitorSide('down'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Cleanup: cancel any remaining unfilled buy orders
|
||||||
|
for (const key of ['up', 'down']) {
|
||||||
|
const s = pos[key];
|
||||||
|
if (s.buyOrderId && !s.cancelled) {
|
||||||
|
await cancelOrder(s.buyOrderId);
|
||||||
|
s.cancelled = true;
|
||||||
|
logger.info(`MAKER${tag}: cancelled ${key.toUpperCase()} buy order`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Place sells for filled buys that don't have sell orders yet
|
||||||
|
for (const key of ['up', 'down']) {
|
||||||
|
const s = pos[key];
|
||||||
|
const soldShares = s.sellOrders.reduce((sum, so) => sum + so.shares, 0);
|
||||||
|
const unsold = s.buyFilled - soldShares;
|
||||||
|
if (unsold >= MIN_ORDER_SIZE) {
|
||||||
|
logger.info(`MAKER${tag}: placing sell for ${key.toUpperCase()} ${unsold.toFixed(2)} unsold shares`);
|
||||||
|
if (!config.dryRun) {
|
||||||
|
logger.info(`MAKER${tag}: waiting ${SELL_DELAY_MS / 1000}s for on-chain settlement...`);
|
||||||
|
await sleep(SELL_DELAY_MS);
|
||||||
|
}
|
||||||
|
const sellResult = await placeLimitSellWithRetry(
|
||||||
|
s.tokenId, unsold, makerSellPrice,
|
||||||
|
pos.tickSize, pos.negRisk, tag,
|
||||||
|
);
|
||||||
|
if (sellResult.success) {
|
||||||
|
s.sellOrders.push({
|
||||||
|
orderId: sellResult.orderId,
|
||||||
|
shares: unsold,
|
||||||
|
filled: false,
|
||||||
|
fillPrice: null,
|
||||||
|
});
|
||||||
|
logger.trade(`MAKER${tag}: ${sim}${key.toUpperCase()} SELL placed ${unsold.toFixed(2)} shares @ $${makerSellPrice}`);
|
||||||
|
} else {
|
||||||
|
logger.error(`MAKER${tag}: ${key.toUpperCase()} SELL failed after ${MAX_SELL_RETRIES} retries — tokens held to resolution`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pos.winner) {
|
||||||
|
const anyFill = pos.up.buyFilled > 0 || pos.down.buyFilled > 0;
|
||||||
|
if (anyFill) {
|
||||||
|
pos.winner = pos.up.buyFilled >= pos.down.buyFilled ? 'up' : 'down';
|
||||||
|
pos.status = 'selling';
|
||||||
|
logger.info(`MAKER${tag}: partial fill — monitoring sell for ${pos.winner.toUpperCase()} ${pos[pos.winner].buyFilled.toFixed(2)} shares`);
|
||||||
|
} else {
|
||||||
|
pos.status = 'done';
|
||||||
|
logger.info(`MAKER${tag}: no fills — buy orders expired naturally, $0 loss`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sell phase: monitor sell orders until market close ────────────────────────
|
||||||
|
|
||||||
|
async function monitorSellPhase(pos, tag, sim) {
|
||||||
|
if (pos.status === 'done') return;
|
||||||
|
|
||||||
|
const { makerSellPrice, makerMonitorMs } = config;
|
||||||
|
const winnerKey = pos.winner;
|
||||||
|
if (!winnerKey) return;
|
||||||
|
|
||||||
|
const side = pos[winnerKey];
|
||||||
|
const sideName = winnerKey.toUpperCase();
|
||||||
|
|
||||||
|
if (side.sellOrders.length === 0) {
|
||||||
|
// Sell placement failed — tokens held to resolution
|
||||||
|
pos.status = 'expired-holding';
|
||||||
|
logger.warn(`MAKER${tag}: no sell orders placed — held to resolution`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`MAKER${tag}: monitoring ${side.sellOrders.length} sell order(s) for ${sideName}`);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const msLeft = new Date(pos.endTime).getTime() - Date.now();
|
||||||
|
|
||||||
|
// Market closed — unfilled sells resolve on-chain
|
||||||
|
if (msLeft <= 0) {
|
||||||
|
pos.status = side.totalSellFilled > 0 ? 'done' : 'expired-holding';
|
||||||
|
if (pos.status === 'expired-holding') {
|
||||||
|
logger.warn(`MAKER${tag}: market closed — unfilled sells held to resolution`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check all sell orders concurrently
|
||||||
|
const checks = await Promise.all(
|
||||||
|
side.sellOrders.map(async (so) => {
|
||||||
|
if (so.filled) return true;
|
||||||
|
|
||||||
|
let filled = false;
|
||||||
|
if (config.dryRun) {
|
||||||
|
const hitPrice = await simCheckFill(side.tokenId, 'sell', makerSellPrice);
|
||||||
|
if (hitPrice !== null) {
|
||||||
|
filled = true;
|
||||||
|
so.fillPrice = hitPrice;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const fill = await getOrderFill(so.orderId);
|
||||||
|
if (fill.fullyFilled || fill.matched >= so.shares * 0.99) {
|
||||||
|
filled = true;
|
||||||
|
so.fillPrice = makerSellPrice;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filled) {
|
||||||
|
so.filled = true;
|
||||||
|
side.totalSellFilled += so.shares;
|
||||||
|
pos.totalRevenue += so.shares * (so.fillPrice || makerSellPrice);
|
||||||
|
logger.money(`MAKER${tag}: ${sim}${sideName} SELL filled ${so.shares.toFixed(2)} shares @ $${(so.fillPrice || makerSellPrice).toFixed(3)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return so.filled;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// All sells filled → done
|
||||||
|
if (checks.every(Boolean) && side.sellOrders.length > 0) {
|
||||||
|
pos.status = 'done';
|
||||||
|
logger.success(`MAKER${tag}: ${sim}all sells filled!`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sleep(makerMonitorMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
/**
|
||||||
|
* makerWs.js
|
||||||
|
* WebSocket client for Polymarket CLOB orderbook + trade data.
|
||||||
|
* Used by the simulation to display real-time orderbook and simulate fills.
|
||||||
|
*
|
||||||
|
* Message formats from CLOB WS:
|
||||||
|
* 1. Book snapshot (initial): [{asset_id, bids, asks, timestamp, hash}] (array, no event_type)
|
||||||
|
* 2. price_change: {event_type:"price_change", price_changes:[{asset_id, price, size, side, best_bid, best_ask}]}
|
||||||
|
* 3. last_trade_price: {event_type:"last_trade_price", asset_id, price}
|
||||||
|
*/
|
||||||
|
|
||||||
|
import WebSocket from 'ws';
|
||||||
|
import logger from '../utils/logger.js';
|
||||||
|
|
||||||
|
const WS_URL = 'wss://ws-subscriptions-clob.polymarket.com/ws/market';
|
||||||
|
const PING_INTERVAL = 30_000;
|
||||||
|
const RECONNECT_DELAY = 3000;
|
||||||
|
const MAX_RECONNECT_DELAY = 30_000;
|
||||||
|
|
||||||
|
export class OrderbookWs {
|
||||||
|
constructor() {
|
||||||
|
this.ws = null;
|
||||||
|
this.pingTimer = null;
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
this.reconnectDelay = RECONNECT_DELAY;
|
||||||
|
this.isShutdown = false;
|
||||||
|
|
||||||
|
// Subscribed assets
|
||||||
|
this.assetIds = [];
|
||||||
|
this.conditionId = null;
|
||||||
|
|
||||||
|
// Orderbook state per asset: Map<price, size>
|
||||||
|
this.bids = new Map(); // assetId → Map<price, size>
|
||||||
|
this.asks = new Map(); // assetId → Map<price, size>
|
||||||
|
|
||||||
|
// Best bid/ask per asset (from price_change events)
|
||||||
|
this.bestBid = new Map(); // assetId → number
|
||||||
|
this.bestAsk = new Map(); // assetId → number
|
||||||
|
|
||||||
|
// Recent trades per asset
|
||||||
|
this.trades = new Map(); // assetId → [{ price, side, size, timestamp }]
|
||||||
|
|
||||||
|
// Last trade price per asset
|
||||||
|
this.lastPrice = new Map(); // assetId → number
|
||||||
|
|
||||||
|
// Callbacks
|
||||||
|
this.onBookUpdate = null;
|
||||||
|
this.onTradeUpdate = null;
|
||||||
|
this.onPriceUpdate = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(conditionId, assetIds) {
|
||||||
|
// Shutdown existing connection if any
|
||||||
|
if (this.ws) {
|
||||||
|
this.cleanup(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.conditionId = conditionId;
|
||||||
|
this.assetIds = assetIds;
|
||||||
|
this.isShutdown = false;
|
||||||
|
|
||||||
|
for (const id of assetIds) {
|
||||||
|
this.bids.set(id, new Map());
|
||||||
|
this.asks.set(id, new Map());
|
||||||
|
this.trades.set(id, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
if (this.isShutdown) return;
|
||||||
|
|
||||||
|
this.ws = new WebSocket(WS_URL);
|
||||||
|
|
||||||
|
this.ws.on('open', () => {
|
||||||
|
logger.info('MAKER WS: connected to orderbook feed');
|
||||||
|
this.reconnectDelay = RECONNECT_DELAY;
|
||||||
|
|
||||||
|
// Subscribe to all assets in one message
|
||||||
|
const msg = {
|
||||||
|
auth: {},
|
||||||
|
type: 'subscribe',
|
||||||
|
markets: [],
|
||||||
|
assets_ids: this.assetIds,
|
||||||
|
channels: ['book'],
|
||||||
|
};
|
||||||
|
this.ws.send(JSON.stringify(msg));
|
||||||
|
|
||||||
|
this.startPing();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ws.on('message', (raw) => {
|
||||||
|
this.handleMessage(raw);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ws.on('ping', () => {
|
||||||
|
this.ws?.pong();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ws.on('close', (code, reason) => {
|
||||||
|
logger.warn(`MAKER WS: disconnected (${code})`);
|
||||||
|
this.cleanup(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ws.on('error', (err) => {
|
||||||
|
logger.warn(`MAKER WS error: ${err.message}`);
|
||||||
|
this.cleanup(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMessage(raw) {
|
||||||
|
let msg;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(raw.toString());
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial book snapshot comes as an array [{...}]
|
||||||
|
if (Array.isArray(msg)) {
|
||||||
|
for (const evt of msg) {
|
||||||
|
if (evt.asset_id && evt.bids) {
|
||||||
|
this.handleBookSnapshot(evt.asset_id, evt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subsequent messages are objects with event_type
|
||||||
|
switch (msg.event_type) {
|
||||||
|
case 'book':
|
||||||
|
if (msg.asset_id) {
|
||||||
|
this.handleBookSnapshot(msg.asset_id, msg);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'price_change':
|
||||||
|
this.handlePriceChange(msg);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'last_trade_price':
|
||||||
|
if (msg.asset_id) {
|
||||||
|
this.handleLastPrice(msg.asset_id, msg);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Full book updates without event_type (non-array single object)
|
||||||
|
if (msg.asset_id && msg.bids) {
|
||||||
|
this.handleBookSnapshot(msg.asset_id, msg);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleBookSnapshot(assetId, evt) {
|
||||||
|
if (!this.assetIds.includes(assetId)) return;
|
||||||
|
|
||||||
|
// Replace entire book for this asset
|
||||||
|
const bidMap = new Map();
|
||||||
|
for (const b of (evt.bids || [])) {
|
||||||
|
const price = parseFloat(b.price);
|
||||||
|
const size = parseFloat(b.size);
|
||||||
|
if (size > 0) bidMap.set(price, size);
|
||||||
|
}
|
||||||
|
this.bids.set(assetId, bidMap);
|
||||||
|
|
||||||
|
const askMap = new Map();
|
||||||
|
for (const a of (evt.asks || [])) {
|
||||||
|
const price = parseFloat(a.price);
|
||||||
|
const size = parseFloat(a.size);
|
||||||
|
if (size > 0) askMap.set(price, size);
|
||||||
|
}
|
||||||
|
this.asks.set(assetId, askMap);
|
||||||
|
|
||||||
|
if (this.onBookUpdate) {
|
||||||
|
this.onBookUpdate(assetId, this.getBook(assetId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePriceChange(msg) {
|
||||||
|
const changes = msg.price_changes || [];
|
||||||
|
|
||||||
|
for (const change of changes) {
|
||||||
|
const assetId = change.asset_id;
|
||||||
|
if (!assetId || !this.assetIds.includes(assetId)) continue;
|
||||||
|
|
||||||
|
const price = parseFloat(change.price);
|
||||||
|
const size = parseFloat(change.size);
|
||||||
|
const side = change.side; // "BUY" = bid, "SELL" = ask
|
||||||
|
|
||||||
|
if (side === 'BUY') {
|
||||||
|
const bidMap = this.bids.get(assetId);
|
||||||
|
if (bidMap) {
|
||||||
|
if (size > 0) {
|
||||||
|
bidMap.set(price, size);
|
||||||
|
} else {
|
||||||
|
bidMap.delete(price); // size 0 = remove level
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (side === 'SELL') {
|
||||||
|
const askMap = this.asks.get(assetId);
|
||||||
|
if (askMap) {
|
||||||
|
if (size > 0) {
|
||||||
|
askMap.set(price, size);
|
||||||
|
} else {
|
||||||
|
askMap.delete(price);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update best bid/ask from the event
|
||||||
|
if (change.best_bid) this.bestBid.set(assetId, parseFloat(change.best_bid));
|
||||||
|
if (change.best_ask) this.bestAsk.set(assetId, parseFloat(change.best_ask));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify for each affected asset
|
||||||
|
const affectedAssets = new Set(changes.map(c => c.asset_id).filter(id => this.assetIds.includes(id)));
|
||||||
|
for (const assetId of affectedAssets) {
|
||||||
|
if (this.onBookUpdate) {
|
||||||
|
this.onBookUpdate(assetId, this.getBook(assetId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleLastPrice(assetId, evt) {
|
||||||
|
if (!this.assetIds.includes(assetId)) return;
|
||||||
|
const price = parseFloat(evt.price || '0');
|
||||||
|
this.lastPrice.set(assetId, price);
|
||||||
|
|
||||||
|
if (this.onPriceUpdate) {
|
||||||
|
this.onPriceUpdate(assetId, price);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get sorted orderbook for an asset
|
||||||
|
*/
|
||||||
|
getBook(assetId) {
|
||||||
|
const bidMap = this.bids.get(assetId) || new Map();
|
||||||
|
const askMap = this.asks.get(assetId) || new Map();
|
||||||
|
|
||||||
|
const bids = Array.from(bidMap.entries())
|
||||||
|
.map(([price, size]) => ({ price, size }))
|
||||||
|
.sort((a, b) => b.price - a.price);
|
||||||
|
|
||||||
|
const asks = Array.from(askMap.entries())
|
||||||
|
.map(([price, size]) => ({ price, size }))
|
||||||
|
.sort((a, b) => a.price - b.price);
|
||||||
|
|
||||||
|
return { bids, asks };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a simulated order would fill based on current orderbook.
|
||||||
|
*/
|
||||||
|
checkSimFill(assetId, side, price, size) {
|
||||||
|
const book = this.getBook(assetId);
|
||||||
|
|
||||||
|
if (side === 'buy') {
|
||||||
|
const eligible = book.asks.filter((a) => a.price <= price);
|
||||||
|
if (eligible.length === 0) return null;
|
||||||
|
|
||||||
|
let filled = 0;
|
||||||
|
let totalCost = 0;
|
||||||
|
for (const ask of eligible) {
|
||||||
|
const take = Math.min(ask.size, size - filled);
|
||||||
|
filled += take;
|
||||||
|
totalCost += take * ask.price;
|
||||||
|
if (filled >= size) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filled > 0) {
|
||||||
|
return { filled: Math.min(filled, size), avgPrice: totalCost / filled };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const eligible = book.bids.filter((b) => b.price >= price);
|
||||||
|
if (eligible.length === 0) return null;
|
||||||
|
|
||||||
|
let filled = 0;
|
||||||
|
let totalRevenue = 0;
|
||||||
|
for (const bid of eligible) {
|
||||||
|
const take = Math.min(bid.size, size - filled);
|
||||||
|
filled += take;
|
||||||
|
totalRevenue += take * bid.price;
|
||||||
|
if (filled >= size) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filled > 0) {
|
||||||
|
return { filled: Math.min(filled, size), avgPrice: totalRevenue / filled };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getLastPrice(assetId) {
|
||||||
|
return this.lastPrice.get(assetId) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
getBestBid(assetId) {
|
||||||
|
// Try from price_change data first, fallback to computed from book
|
||||||
|
const cached = this.bestBid.get(assetId);
|
||||||
|
if (cached) return cached;
|
||||||
|
const book = this.getBook(assetId);
|
||||||
|
return book.bids[0]?.price || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
getBestAsk(assetId) {
|
||||||
|
const cached = this.bestAsk.get(assetId);
|
||||||
|
if (cached) return cached;
|
||||||
|
const book = this.getBook(assetId);
|
||||||
|
return book.asks[0]?.price || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
getRecentTrades(assetId, limit = 10) {
|
||||||
|
const trades = this.trades.get(assetId) || [];
|
||||||
|
return trades.slice(-limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
startPing() {
|
||||||
|
this.stopPing();
|
||||||
|
this.pingTimer = setInterval(() => {
|
||||||
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||||
|
this.ws.ping(); // Use proper WebSocket ping frames
|
||||||
|
}
|
||||||
|
}, PING_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
stopPing() {
|
||||||
|
if (this.pingTimer) {
|
||||||
|
clearInterval(this.pingTimer);
|
||||||
|
this.pingTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup(reconnect = true) {
|
||||||
|
this.stopPing();
|
||||||
|
if (this.reconnectTimer) {
|
||||||
|
clearTimeout(this.reconnectTimer);
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
}
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.removeAllListeners();
|
||||||
|
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
|
||||||
|
this.ws.terminate();
|
||||||
|
}
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
if (reconnect && !this.isShutdown) {
|
||||||
|
this.reconnectTimer = setTimeout(() => {
|
||||||
|
this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_RECONNECT_DELAY);
|
||||||
|
this.connect();
|
||||||
|
}, this.reconnectDelay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
shutdown() {
|
||||||
|
this.isShutdown = true;
|
||||||
|
this.cleanup(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user