PDB_VWAP代理设置完成
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
# ==================================================
|
||||
# BTC 15-min Live Trading Bot - Environment Variables
|
||||
# ==================================================
|
||||
# Copy this file to .env and fill in your credentials:
|
||||
# cp .env.example .env
|
||||
#
|
||||
# NEVER commit .env to git!
|
||||
|
||||
# ── WALLET (REQUIRED) ──────────────────────────────
|
||||
# Your Polygon wallet private key (with 0x prefix)
|
||||
PRIVATE_KEY=0x_your_private_key_here
|
||||
|
||||
# Proxy wallet address (only if using Gnosis Safe / SIGNATURE_TYPE=1 or 2)
|
||||
FUNDER_ADDRESS=
|
||||
SIGNATURE_TYPE=0
|
||||
|
||||
# ── POLYMARKET API (REQUIRED) ──────────────────────
|
||||
# Generate at: https://clob.polymarket.com
|
||||
POLY_API_KEY=
|
||||
POLY_API_SECRET=
|
||||
POLY_API_PASSPHRASE=
|
||||
|
||||
# ── POLYGON NETWORK ────────────────────────────────
|
||||
# Default public RPC works, but Alchemy/Infura is recommended for reliability
|
||||
RPC_URL=https://polygon-rpc.com
|
||||
CHAIN_ID=137
|
||||
|
||||
# ── POLYMARKET CLOB ────────────────────────────────
|
||||
CLOB_HOST=https://clob.polymarket.com
|
||||
|
||||
# ── TELEGRAM (OPTIONAL) ───────────────────────────
|
||||
# Create a bot via @BotFather, get chat ID via @userinfobot
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
TELEGRAM_CHAT_ID=
|
||||
@@ -0,0 +1,40 @@
|
||||
# Secrets
|
||||
.env
|
||||
config.json
|
||||
!config.example.json
|
||||
|
||||
# Logs and runtime data
|
||||
logs/
|
||||
logs_v1/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
*.egg
|
||||
|
||||
# Virtual environment
|
||||
venv/
|
||||
.venv/
|
||||
env/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.cursor/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Backtest / analysis data
|
||||
backtest_data/
|
||||
|
||||
# Charts and images (generated at runtime)
|
||||
*.png
|
||||
@@ -0,0 +1,287 @@
|
||||
# Configuration Guide
|
||||
|
||||
Part of the **[PolyBullLabs Polymarket suite](https://github.com/PolyBullLabs/polymakret-5min-15min-1hour-arbitrage-bot)** · [@terauss](https://t.me/terauss) · [Suite README (all bots)](../README.md)
|
||||
|
||||
This file explains **every parameter** in `config.json`. The bot is highly configurable -- you can fine-tune the strategy, risk, execution speed, hedging, and notifications without touching any code.
|
||||
|
||||
---
|
||||
|
||||
## market -- Which Polymarket window to trade
|
||||
|
||||
```
|
||||
"interval_minutes": 5
|
||||
```
|
||||
|
||||
**BTC up/down market length on Polymarket.** Must be **5** or **15**.
|
||||
|
||||
- **5** — slug pattern `btc-updown-5m-<unix_start>` (300s window). Example strategy: `min_elapsed_sec` ~150–210, `no_entry_before_end_sec` ~90–120.
|
||||
- **15** — slug pattern `btc-updown-15m-<unix_start>` (900s window). Example strategy: `min_elapsed_sec` ~480–530, `no_entry_before_end_sec` ~300–335.
|
||||
|
||||
The bot aligns Chainlink anchor resets, market discovery, and elapsed-time logic to this interval. **Always** set `min_elapsed_sec` and `no_entry_before_end_sec` so they fit inside the market length (e.g. for 5m, `min_elapsed_sec` must be < 300).
|
||||
|
||||
---
|
||||
|
||||
## simulation -- Paper trading (no real money)
|
||||
|
||||
```
|
||||
"enabled": false
|
||||
```
|
||||
|
||||
**When `true`:** the bot still connects to market WebSockets, RTDS Chainlink, runs the dashboard, and evaluates the same entry rules. **No** orders are sent to Polymarket, **no** User WebSocket for fills, **no** auto-redeemer loop. Entries are logged as instant hypothetical fills at **best ask + `entry.price_offset`**, subject to `max_entry_price` and the same contract sizing as live. Optional hedge is simulated as a placed GTD with id `SIM-HEDGE` (no on-chain or CLOB effect).
|
||||
|
||||
```
|
||||
"separate_trading_log": true,
|
||||
"trading_log_path": "logs/trading_log_sim.json"
|
||||
```
|
||||
|
||||
If `separate_trading_log` is **true**, simulated P&L and trades are written only to `trading_log_path`, so your live `logs/trading_log.json` stays untouched. Set to **false** to append simulation results to the same file as live (not recommended if you also run live).
|
||||
|
||||
**Trading history for analysis (simulation only):**
|
||||
|
||||
- `history_csv_path` — Append-only CSV with **OPEN** rows (each simulated entry) and **CLOSE** rows (each resolved position). CLOSE rows include **trade_pnl_usd**, **cumulative_pnl_usd** after that trade, **win_rate_pct**, and **total_closed_trades**. Open in Excel / pandas.
|
||||
- `history_jsonl_path` — One JSON object per line (`type`: `open` or `close`) for streaming tools. Set to `""` to disable.
|
||||
- `history_summary_path` — Rewritten after every close: rolling **summary** (total PnL, wins/losses, win rate, best/worst trade) plus the full **trades** array (same data as `trading_log_path`, convenient for a single analysis file).
|
||||
|
||||
The main `trading_log_path` JSON also includes a **`summary`** block (totals and win rate) on each save, for both live and sim logs.
|
||||
|
||||
**Credentials:** With `simulation.enabled` true, `PRIVATE_KEY` and Polymarket API keys in `.env` are **not** required. You can still set Telegram tokens for notifications.
|
||||
|
||||
---
|
||||
|
||||
## strategy -- When to enter a trade
|
||||
|
||||
These parameters control **which signals the bot acts on**. Think of them as filters: a trade is only placed when ALL conditions pass simultaneously.
|
||||
|
||||
```
|
||||
"min_price": 0.75
|
||||
```
|
||||
**Minimum token price to enter.** The bot only buys tokens priced at or above this value. Lower prices mean higher potential profit but lower probability of winning. At $0.75, you need a 75% win rate to break even. Range: 0.50 - 0.95. Start with 0.75.
|
||||
|
||||
```
|
||||
"max_price": 0.88
|
||||
```
|
||||
**Maximum token price to enter.** The bot rejects tokens priced above this. Higher prices mean higher probability but tiny profit margin. At $0.88, you profit only $0.12 per contract on a win but lose $0.88 on a loss (need 88% win rate). Range: 0.80 - 0.95. Start with 0.88.
|
||||
|
||||
```
|
||||
"min_elapsed_sec": 530
|
||||
```
|
||||
**Minimum seconds elapsed since market opened before allowing entry.** Each market lasts 900 seconds (15 min). This prevents entering too early when the market direction is unclear. At 530, the bot waits ~8.8 minutes. Range: 300 - 800. Higher = safer but fewer opportunities.
|
||||
|
||||
```
|
||||
"min_deviation_pct": 3
|
||||
```
|
||||
**Minimum VWAP deviation (%) to trigger a signal.** VWAP = volume-weighted average price. Deviation measures how far the current price has moved from this average. A deviation of 3% means the token price is 3% above its recent average, indicating strong directional movement. Range: 0 - 15. Set to 0 to disable this filter. Higher = stricter, fewer trades.
|
||||
|
||||
```
|
||||
"max_deviation_pct": 100
|
||||
```
|
||||
**Maximum VWAP deviation (%) allowed.** Rejects signals where deviation is abnormally high (potential spike/manipulation). Set to 100 to effectively disable the upper bound. To cap, try 15-25. Range: must be greater than min_deviation_pct.
|
||||
|
||||
```
|
||||
"no_entry_before_end_sec": 335
|
||||
```
|
||||
**Stop entering trades if fewer than this many seconds remain.** At 335, the bot stops entering after ~9 min 25 sec (with 5 min 35 sec left). This protects against entering too late when there is not enough time for the position to be meaningful. Range: 60 - 500.
|
||||
|
||||
> **Entry window example:** With min_elapsed_sec=530 and no_entry_before_end_sec=335, the bot can only enter between 530s and 565s elapsed -- a 35-second window each market.
|
||||
|
||||
```
|
||||
"momentum_window_sec": 60
|
||||
```
|
||||
**Lookback window for momentum calculation (seconds).** Momentum compares current price to the price N seconds ago. At 60, it asks: "Is the price higher than 1 minute ago?" Shorter windows = more reactive, noisier. Longer = smoother, slower to react. Range: 15 - 300.
|
||||
|
||||
```
|
||||
"vwap_window_sec": 30
|
||||
```
|
||||
**Lookback window for VWAP calculation (seconds).** Only trades from the last N seconds are used to compute VWAP. Shorter = more responsive to recent trades. Longer = smoother average. Range: 10 - 120.
|
||||
|
||||
```
|
||||
"win_rate_csv": "data/win_rate.csv"
|
||||
```
|
||||
**Path to the historical win rate table.** A CSV file containing win probabilities by price range and time bin. The bot uses this to display win rate on the dashboard. Generally no need to change this unless you build your own win rate data.
|
||||
|
||||
---
|
||||
|
||||
## entry -- How to execute orders
|
||||
|
||||
These parameters control **order mechanics**: how much to bet, how aggressively to fill, and what to do on failure.
|
||||
|
||||
```
|
||||
"bet_amount_usd": 5
|
||||
```
|
||||
**How much USD to risk per trade.** The bot divides this by the entry price to get the number of contracts. Example: $5 at price $0.80 = 6 contracts. Start small ($1-5) while learning. Scale up only after consistent results. Range: 1 - any amount you are comfortable losing.
|
||||
|
||||
```
|
||||
"price_offset": 0.02
|
||||
```
|
||||
**Price offset added to best bid for FAK orders.** FAK (Fill-And-Kill) orders must cross the spread to fill immediately. An offset of 0.02 means: if best bid is $0.80, the order is placed at $0.82. Higher offset = more aggressive fill but worse entry price. Range: 0.01 - 0.05.
|
||||
|
||||
```
|
||||
"order_type": "FAK"
|
||||
```
|
||||
**Order type for entry.** FAK = Fill-And-Kill. The order fills immediately at the specified price or gets cancelled. This is the recommended type for fast-moving 15-minute markets. Alternative: "GTC" (Good-Till-Cancel) which stays on the book.
|
||||
|
||||
```
|
||||
"max_retries": 3
|
||||
```
|
||||
**How many times to retry a failed order.** If the first attempt fails (rejected, no fill), the bot retries up to this many times. Range: 1 - 10. More retries = better chance of filling but uses more time.
|
||||
|
||||
```
|
||||
"retry_delay_ms": 300
|
||||
```
|
||||
**Milliseconds to wait between retry attempts.** Range: 100 - 2000. Shorter = faster retries. Don't set too low or you may hit rate limits.
|
||||
|
||||
```
|
||||
"fill_timeout_ms": 1000
|
||||
```
|
||||
**How long to wait for fill confirmation (ms).** After placing a FAK order, the bot waits this long for a WebSocket fill message. If no fill arrives in time, it enters recovery mode. Range: 500 - 5000.
|
||||
|
||||
```
|
||||
"min_contracts": 5
|
||||
```
|
||||
**Minimum number of contracts per order.** Polymarket requires at least 5 contracts. If bet_amount_usd / price results in fewer than 5 contracts, the order is skipped. Generally no need to change.
|
||||
|
||||
```
|
||||
"min_order_usd": 1
|
||||
```
|
||||
**Minimum order value in USD.** Orders below this value are skipped. Generally no need to change.
|
||||
|
||||
```
|
||||
"max_entry_price": 0.88
|
||||
```
|
||||
**Hard price ceiling for entry.** Even if the signal says BUY, the order is rejected if the execution price exceeds this. Acts as a safety net. Should be equal to or less than strategy.max_price.
|
||||
|
||||
```
|
||||
"ws_recovery_timeout_sec": 10
|
||||
```
|
||||
**Timeout for WebSocket fill recovery (seconds).** When an order times out, the bot checks the User WebSocket for fills. This is how long it waits during recovery. Range: 5 - 30.
|
||||
|
||||
---
|
||||
|
||||
## hedge -- Automatic hedging (advanced)
|
||||
|
||||
Hedging places a cheap order on the **opposite** token after entry. If your main trade loses, the hedge may fill and partially offset the loss.
|
||||
|
||||
```
|
||||
"enabled": false
|
||||
```
|
||||
**Enable or disable automatic hedging.** Set to true to activate. When enabled, after each entry the bot places a GTD order on the opposite token. Recommended to leave false until you understand the mechanics.
|
||||
|
||||
```
|
||||
"hedge_price": 0.02
|
||||
```
|
||||
**Price to place the hedge order at.** The hedge buys the opposite token at this price. At $0.02, you pay $0.02 per contract. If your main trade loses, the opposite token resolves to $1.00, netting $0.98 per contract. The hedge only fills if the market strongly moves in your favor (opposite token drops to $0.02). Range: 0.01 - 0.10.
|
||||
|
||||
```
|
||||
"order_type": "GTD"
|
||||
```
|
||||
**Hedge order type.** GTD = Good-Till-Date. A limit order that sits on the book until it fills or expires. Expires in 1 hour (market resolves in 15 minutes). No need to change.
|
||||
|
||||
```
|
||||
"max_retries": 3
|
||||
```
|
||||
**Retry count for hedge order placement.** If hedge placement fails, retry up to this many times.
|
||||
|
||||
```
|
||||
"retry_delay_ms": 1000
|
||||
```
|
||||
**Delay between hedge retry attempts (ms).** Hedge placement is less time-critical than entry, so a longer delay is fine.
|
||||
|
||||
---
|
||||
|
||||
## redeem -- Automatic on-chain redemption
|
||||
|
||||
After a market resolves, winning positions must be redeemed on the blockchain to collect your payout.
|
||||
|
||||
```
|
||||
"enabled": true
|
||||
```
|
||||
**Enable automatic redemption.** When true, the bot periodically checks for resolved positions and redeems them. If false, you must redeem manually on polymarket.com. Recommended: true.
|
||||
|
||||
```
|
||||
"interval_seconds": 180
|
||||
```
|
||||
**How often to check for redeemable positions (seconds).** Every 180 seconds (3 minutes), the bot scans for positions that can be redeemed. Range: 60 - 600. Lower = more frequent checks but more API calls.
|
||||
|
||||
```
|
||||
"auto_confirm": true
|
||||
```
|
||||
**Automatically confirm redemptions.** When true, redemptions happen without manual approval. When false, redemptions are logged but not executed.
|
||||
|
||||
---
|
||||
|
||||
## telegram -- Notifications
|
||||
|
||||
Optional Telegram integration for trade alerts and equity charts. Requires TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID in .env.
|
||||
|
||||
```
|
||||
"enabled": false
|
||||
```
|
||||
**Enable Telegram notifications.** Set to true after configuring bot token and chat ID in .env. You will receive messages on trade entry, market end results, and periodic equity charts.
|
||||
|
||||
```
|
||||
"chart_every_n_trades": 5
|
||||
```
|
||||
**Send an equity chart every N trades.** After every 5 trades (by default), the bot generates a P&L chart and sends it to your Telegram. Range: 1 - 50.
|
||||
|
||||
---
|
||||
|
||||
## logging -- Log settings
|
||||
|
||||
```
|
||||
"level": "INFO"
|
||||
```
|
||||
**Logging verbosity.** Options: "DEBUG" (very verbose), "INFO" (normal), "WARNING" (errors only). Use DEBUG for troubleshooting, INFO for normal operation.
|
||||
|
||||
```
|
||||
"file_rotation_hours": 3
|
||||
```
|
||||
**Rotate log files every N hours.** Prevents log files from growing indefinitely. Range: 1 - 24.
|
||||
|
||||
---
|
||||
|
||||
## Quick Presets
|
||||
|
||||
### Conservative (low risk, fewer trades)
|
||||
```json
|
||||
{
|
||||
"strategy": {
|
||||
"min_price": 0.80,
|
||||
"max_price": 0.85,
|
||||
"min_elapsed_sec": 600,
|
||||
"min_deviation_pct": 5,
|
||||
"no_entry_before_end_sec": 300
|
||||
},
|
||||
"entry": { "bet_amount_usd": 2 },
|
||||
"hedge": { "enabled": true }
|
||||
}
|
||||
```
|
||||
|
||||
### Moderate (balanced)
|
||||
```json
|
||||
{
|
||||
"strategy": {
|
||||
"min_price": 0.75,
|
||||
"max_price": 0.88,
|
||||
"min_elapsed_sec": 530,
|
||||
"min_deviation_pct": 3,
|
||||
"no_entry_before_end_sec": 335
|
||||
},
|
||||
"entry": { "bet_amount_usd": 10 },
|
||||
"hedge": { "enabled": true }
|
||||
}
|
||||
```
|
||||
|
||||
### Aggressive (more trades, higher risk)
|
||||
```json
|
||||
{
|
||||
"strategy": {
|
||||
"min_price": 0.70,
|
||||
"max_price": 0.90,
|
||||
"min_elapsed_sec": 480,
|
||||
"min_deviation_pct": 0,
|
||||
"no_entry_before_end_sec": 120
|
||||
},
|
||||
"entry": { "bet_amount_usd": 50 },
|
||||
"hedge": { "enabled": false }
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,856 @@
|
||||
# BTC 15-Minute Live Trading Bot - Complete Logic Documentation
|
||||
|
||||
**Suite:** [PolyBullLabs — polymakret-5min-15min-1hour-arbitrage-bot](https://github.com/PolyBullLabs/polymakret-5min-15min-1hour-arbitrage-bot) · [@terauss](https://t.me/terauss) · [README](README.md) · [CONFIG.md](CONFIG.md)
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [System Overview](#1-system-overview)
|
||||
2. [Market Structure](#2-market-structure)
|
||||
3. [Data Acquisition Layer](#3-data-acquisition-layer)
|
||||
4. [Indicator Calculations (Formulas)](#4-indicator-calculations-formulas)
|
||||
5. [Signal Generation Engine](#5-signal-generation-engine)
|
||||
6. [Order Execution Pipeline](#6-order-execution-pipeline)
|
||||
7. [Hedge Mechanism](#7-hedge-mechanism)
|
||||
8. [Position Lifecycle and PnL Accounting](#8-position-lifecycle-and-pnl-accounting)
|
||||
9. [Drawdown Tracking](#9-drawdown-tracking)
|
||||
10. [Chainlink BTC/USD Oracle Integration](#10-chainlink-btcusd-oracle-integration)
|
||||
11. [Auto-Redemption System](#11-auto-redemption-system)
|
||||
12. [Configuration Reference](#12-configuration-reference)
|
||||
13. [Fault Tolerance and Recovery](#13-fault-tolerance-and-recovery)
|
||||
14. [File and Log Architecture](#14-file-and-log-architecture)
|
||||
|
||||
---
|
||||
|
||||
## 1. System Overview
|
||||
|
||||
The bot trades **Polymarket BTC Up/Down 15-minute binary markets**. Each market resolves to either "UP" (BTC price rose) or "DOWN" (BTC price fell) over a 15-minute window aligned to epoch boundaries (multiples of 900 seconds).
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
LiveTradingBot
|
||||
+----------------------------------------------------------+
|
||||
| |
|
||||
| +----------+ +--------------+ +------------------+ |
|
||||
| | Market | | WebSocket | | Chainlink RTDS | |
|
||||
| | Finder | | Client | | Price Client | |
|
||||
| | (HTTP) | | (wss://) | | (wss://) | |
|
||||
| +----+-----+ +------+-------+ +--------+---------+ |
|
||||
| | | | |
|
||||
| v v v |
|
||||
| +-------------------------------------------------+ |
|
||||
| | MarketState (shared) | |
|
||||
| | up_token, down_token, btc_price, end_time | |
|
||||
| +------------------------+------------------------+ |
|
||||
| | |
|
||||
| +-------------------+-------------------+ |
|
||||
| v v v |
|
||||
| +----------+ +---------------+ +------------+ |
|
||||
| |Dashboard | | Signal | | Order | |
|
||||
| | (Rich) | | Generator | | Executor | |
|
||||
| +----------+ +-------+-------+ +------+-----+ |
|
||||
| | | |
|
||||
| v v |
|
||||
| +----------------+ +--------------+ |
|
||||
| | TradingStats | | HedgeManager | |
|
||||
| | (Position/PnL) | | (GTD orders) | |
|
||||
| +----------------+ +--------------+ |
|
||||
| |
|
||||
| +--------------+ +------------------+ |
|
||||
| | AutoRedeemer | | TelegramNotifier | |
|
||||
| | (background) | | (alerts/charts) | |
|
||||
| +--------------+ +------------------+ |
|
||||
+----------------------------------------------------------+
|
||||
```
|
||||
|
||||
### Main Loop (simplified)
|
||||
|
||||
```
|
||||
while running:
|
||||
market = find_active_btc_15m_market() # HTTP -> Gamma API
|
||||
subscribe_websocket(market.tokens) # wss:// -> live prices
|
||||
|
||||
while market.is_active:
|
||||
update_indicators() # every 250ms
|
||||
signal = evaluate_strategy() # check all conditions
|
||||
|
||||
if signal == BUY:
|
||||
execute_entry(signal) # FAK order
|
||||
place_hedge() # GTD order (opposite token)
|
||||
|
||||
track_drawdown() # update min_price_seen
|
||||
|
||||
if time_left <= 10s:
|
||||
close_position() # record PnL
|
||||
break
|
||||
|
||||
wait_for_next_market() # ~5-30 seconds
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Market Structure
|
||||
|
||||
### Polymarket BTC Up/Down 15-Min Markets
|
||||
|
||||
Each market is a **binary outcome** contract:
|
||||
|
||||
- **UP token**: Pays $1.00 if BTC price is higher at market end vs. start. Otherwise $0.
|
||||
- **DOWN token**: Pays $1.00 if BTC price is lower at market end vs. start. Otherwise $0.
|
||||
|
||||
Tokens trade between $0.01 and $0.99. At any time:
|
||||
|
||||
```
|
||||
P_UP + P_DOWN ~ 1.00
|
||||
```
|
||||
|
||||
### Market Timing
|
||||
|
||||
Markets are aligned to 15-minute epoch boundaries:
|
||||
|
||||
```
|
||||
T_start = floor(T_now / 900) * 900
|
||||
T_end = T_start + 900
|
||||
```
|
||||
|
||||
Market slug format: `btc-updown-15m-{T_start}`
|
||||
|
||||
Example: `btc-updown-15m-1770831900` starts at Unix timestamp 1770831900.
|
||||
|
||||
### Market Discovery
|
||||
|
||||
The bot searches the Gamma API for active markets using offsets from the current 15-minute window:
|
||||
|
||||
```python
|
||||
for offset in [0, 900, -900, 1800]:
|
||||
target_ts = current_window + offset
|
||||
slug = f"btc-updown-15m-{target_ts}"
|
||||
# Query: GET /markets?slug={slug}&active=true&closed=false
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Data Acquisition Layer
|
||||
|
||||
### 3.1 Market Data WebSocket
|
||||
|
||||
**URL**: `wss://ws-subscriptions-clob.polymarket.com/ws/market`
|
||||
|
||||
Subscribes to both UP and DOWN token IDs. Processes three event types:
|
||||
|
||||
#### `last_trade_price` - Trade Execution
|
||||
|
||||
Each trade is stored as `Trade(timestamp, price, size, side)` in a deque per token.
|
||||
|
||||
Tracked aggregates:
|
||||
- `trade_count`: Total number of trades
|
||||
- `volume_total`: Total contract volume
|
||||
- `volume_buy`: Volume from buy-side
|
||||
- `volume_sell`: Volume from sell-side
|
||||
|
||||
#### `price_change` - Best Bid/Ask Updates
|
||||
|
||||
Updates `best_bid` and `best_ask` for each token.
|
||||
|
||||
#### `book` - Order Book Snapshots
|
||||
|
||||
Parses bids and asks arrays, extracts top-of-book:
|
||||
- `best_bid`, `best_bid_size`
|
||||
- `best_ask`, `best_ask_size`
|
||||
|
||||
Spread:
|
||||
|
||||
```
|
||||
Spread = P_ask - P_bid
|
||||
```
|
||||
|
||||
### 3.2 User WebSocket (Order Tracking)
|
||||
|
||||
**URL**: Polymarket User Channel (authenticated via API credentials)
|
||||
|
||||
Tracks order lifecycle: `PLACEMENT -> MATCHED -> MINED -> CONFIRMED`
|
||||
|
||||
Used for:
|
||||
- Fill confirmation after entry orders
|
||||
- Hedge fill detection
|
||||
- Timeout recovery (checking if order filled despite timeout)
|
||||
|
||||
### 3.3 Chainlink BTC/USD Price Stream
|
||||
|
||||
**URL**: `wss://ws-live-data.polymarket.com` (Polymarket RTDS)
|
||||
|
||||
**Topic**: `crypto_prices_chainlink` filtered for `btc/usd` symbol.
|
||||
|
||||
See Section 10 for full details.
|
||||
|
||||
---
|
||||
|
||||
## 4. Indicator Calculations (Formulas)
|
||||
|
||||
All indicators are calculated from the live trade stream. Each token (UP and DOWN) has its own independent indicator set.
|
||||
|
||||
### 4.1 VWAP (Volume-Weighted Average Price)
|
||||
|
||||
VWAP over a configurable time window W (default 30 seconds):
|
||||
|
||||
```
|
||||
SUM(P_i * V_i) for all trades where (T_now - T_i) <= W
|
||||
VWAP_W = -------------------------
|
||||
SUM(V_i)
|
||||
```
|
||||
|
||||
Where:
|
||||
- `P_i` = price of trade i
|
||||
- `V_i` = size (volume) of trade i
|
||||
- `trades(W)` = set of trades within the last W seconds
|
||||
|
||||
Returns 0.0 if no trades in window.
|
||||
|
||||
### 4.2 Deviation from VWAP
|
||||
|
||||
Percentage deviation of the current last-trade price from VWAP:
|
||||
|
||||
```
|
||||
P_last - VWAP
|
||||
D = ---------------------- * 100%
|
||||
VWAP
|
||||
```
|
||||
|
||||
Where:
|
||||
- `D > 0`: Price is **above** VWAP (bullish pressure)
|
||||
- `D < 0`: Price is **below** VWAP (bearish pressure)
|
||||
- `D = 0`: Price equals VWAP
|
||||
|
||||
### 4.3 Momentum
|
||||
|
||||
Momentum compares the current price to the average price W seconds ago (default 60s), using a band of +/-1.5 seconds to smooth:
|
||||
|
||||
```
|
||||
P_ago = mean({P_i where T_now - W - d <= T_i <= T_now - W + d})
|
||||
|
||||
P_last - P_ago
|
||||
M = ------------------------- * 100%
|
||||
P_ago
|
||||
```
|
||||
|
||||
Where:
|
||||
- `W` = momentum_window_sec (default 60)
|
||||
- `d` = averaging band (1.5 seconds)
|
||||
- Returns `None` if no trades exist in the band window
|
||||
|
||||
Interpretation:
|
||||
- `M > 0`: Price has risen over the window (positive momentum)
|
||||
- `M < 0`: Price has fallen (negative momentum)
|
||||
|
||||
### 4.4 Z-Score
|
||||
|
||||
Statistical z-score of the current price relative to recent trade prices over a 5-second window:
|
||||
|
||||
```
|
||||
P_last - mean(prices_5s)
|
||||
z = ----------------------------
|
||||
stdev(prices_5s)
|
||||
```
|
||||
|
||||
Where:
|
||||
- `mean(prices_5s)` = arithmetic mean of all trade prices in last 5 seconds
|
||||
- `stdev(prices_5s)` = standard deviation, with minimum floor of 0.001
|
||||
|
||||
Interpretation:
|
||||
- `z > 2`: Price significantly above recent mean (overbought short-term)
|
||||
- `z < -2`: Price significantly below recent mean (oversold short-term)
|
||||
|
||||
### 4.5 Win Rate Lookup
|
||||
|
||||
Historical win rates are stored in `data/win_rate.csv` as a 10x15 matrix:
|
||||
|
||||
| Price Range | min_0 | min_1 | ... | min_14 |
|
||||
|-------------|--------|--------|-----|--------|
|
||||
| 0.50-0.54 | 52.2% | 50.2% | ... | 52.7% |
|
||||
| 0.75-0.79 | 71.1% | 76.4% | ... | 75.0% |
|
||||
| 0.85-0.89 | 68.0% | 73.0% | ... | 93.3% |
|
||||
| 0.95-0.99 | 63.2% | 68.2% | ... | 100% |
|
||||
|
||||
**Time bin** calculation:
|
||||
|
||||
```
|
||||
bin = floor(14 - T_remaining / 60)
|
||||
```
|
||||
|
||||
Where `bin` is in [0, 14], with bin 0 = first minute, bin 14 = last minute.
|
||||
|
||||
**Lookup**: Given favorite token price P_fav and time bin, the table returns the historical win probability.
|
||||
|
||||
---
|
||||
|
||||
## 5. Signal Generation Engine
|
||||
|
||||
The signal generator runs inside `Dashboard.create_strategy_panel()`, evaluated every 250ms (4 Hz refresh).
|
||||
|
||||
### 5.1 Favorite Token Selection
|
||||
|
||||
The "favorite" is the token with the higher last-trade price:
|
||||
|
||||
```
|
||||
| UP if P_UP > P_DOWN
|
||||
favorite = |
|
||||
| DOWN otherwise
|
||||
```
|
||||
|
||||
The favorite's indicators are used for signal evaluation:
|
||||
- `P_fav` = favorite price
|
||||
- `D_fav` = favorite deviation from VWAP
|
||||
- `M_fav` = favorite momentum
|
||||
|
||||
### 5.2 Entry Conditions (ALL must be true)
|
||||
|
||||
| # | Condition | Formula | Config Parameter |
|
||||
|----|-------------------------|--------------------------------|---------------------------|
|
||||
| 1 | Price in range | P_min <= P_fav <= P_max | min_price, max_price |
|
||||
| 2 | Sufficient time elapsed | T_elapsed >= T_min_elapsed | min_elapsed_sec |
|
||||
| 3 | Deviation in range | D_min < D_fav < D_max | min/max_deviation_pct |
|
||||
| 4 | Positive momentum | M_fav > 0 | - |
|
||||
| 5 | Not too close to end | T_remaining > T_no_entry | no_entry_before_end_sec |
|
||||
|
||||
Where:
|
||||
- `T_elapsed = 900 - T_remaining`
|
||||
- `T_remaining = T_end - T_now`
|
||||
|
||||
### 5.3 Signal States
|
||||
|
||||
```
|
||||
if T_remaining <= T_no_entry:
|
||||
-> NO ENTRY (cutoff reached, no further entry this market)
|
||||
|
||||
elif ALL 5 conditions TRUE:
|
||||
-> BUY {UP|DOWN} (signal triggers execute_entry)
|
||||
|
||||
elif P_fav >= 0.70 AND T_elapsed >= T_min_elapsed:
|
||||
if M_fav <= 0: -> ALMOST (need Mom>0%)
|
||||
if D_fav >= D_max: -> ALMOST (Dev too high)
|
||||
else: -> ALMOST (need dev)
|
||||
|
||||
else:
|
||||
-> WAIT (with specific reason: elapsed/price/dev/mom)
|
||||
```
|
||||
|
||||
### 5.4 Signal Flow
|
||||
|
||||
```
|
||||
Dashboard.create_strategy_panel()
|
||||
|
|
||||
+-- Evaluates conditions every 250ms
|
||||
+-- If BUY: sets self.last_signal = "BUY_UP" or "BUY_DOWN"
|
||||
|
|
||||
v
|
||||
Main loop (run_session)
|
||||
|
|
||||
+-- Reads self.dashboard.last_signal
|
||||
+-- Clears signal (one-shot)
|
||||
+-- Creates asyncio task: _safe_execute_entry(signal)
|
||||
|
|
||||
v
|
||||
execute_entry("BUY_UP" or "BUY_DOWN")
|
||||
```
|
||||
|
||||
**Important**: Only ONE entry per market. `can_enter()` returns False once a position is recorded OR entry is blocked.
|
||||
|
||||
---
|
||||
|
||||
## 6. Order Execution Pipeline
|
||||
|
||||
### 6.1 Pre-Execution Guards
|
||||
|
||||
Before placing any order, three guards are checked:
|
||||
|
||||
1. **Position check**: `stats.can_enter()` - no existing position, not closed this market, not blocked
|
||||
2. **Time cutoff**: `T_remaining > T_no_entry`
|
||||
3. **Token data available**: Both UP and DOWN tokens must have data
|
||||
|
||||
### 6.2 Order Configuration
|
||||
|
||||
| Parameter | Value | Description |
|
||||
|------------------|---------|------------------------------------------|
|
||||
| bet_amount_usd | 50 | USD to risk per trade |
|
||||
| price_offset | 0.02 | Added to best bid for aggressive fill |
|
||||
| order_type | FAK | Fill-And-Kill (immediate or cancel) |
|
||||
| max_retries | 3 | Retry count on failure |
|
||||
| retry_delay_ms | 300 | Delay between retries |
|
||||
| fill_timeout_ms | 1000 | Max wait for fill confirmation |
|
||||
| min_contracts | 5 | Polymarket minimum |
|
||||
| max_entry_price | 0.88 | Hard price ceiling |
|
||||
|
||||
### 6.3 Contract Calculation
|
||||
|
||||
```
|
||||
contracts = floor(bet_amount_usd / P_entry)
|
||||
```
|
||||
|
||||
Where `P_entry = min(P_best_ask, P_max_entry)`
|
||||
|
||||
The order is placed at:
|
||||
|
||||
```
|
||||
P_order = P_best_bid + price_offset
|
||||
```
|
||||
|
||||
### 6.4 FAK Order Flow
|
||||
|
||||
```
|
||||
1. Fetch best_bid from orderbook
|
||||
2. Calculate: P_order = best_bid + price_offset
|
||||
3. Validate: P_order <= max_entry_price
|
||||
4. Place FAK BUY order
|
||||
5. Wait fill_timeout_ms for fill confirmation via WebSocket
|
||||
6. If filled: record position -> place hedge -> done
|
||||
7. If timeout: enter recovery mode (see Section 13)
|
||||
8. If rejected: retry up to max_retries
|
||||
```
|
||||
|
||||
### 6.5 Signal Logging
|
||||
|
||||
At the moment of execution, a comprehensive snapshot is logged to `signals.log`:
|
||||
|
||||
- Timestamp, market slug, signal direction, token
|
||||
- Elapsed/remaining time, time bin
|
||||
- For each token (UP and DOWN):
|
||||
- LAST, BID, ASK prices
|
||||
- VWAP, Deviation, Z-Score, Momentum
|
||||
- Trade count, Total/Buy/Sell volume
|
||||
- Win rate lookup value
|
||||
- Strategy config parameters
|
||||
- Chainlink BTC/USD price, anchor, and deviation
|
||||
|
||||
---
|
||||
|
||||
## 7. Hedge Mechanism
|
||||
|
||||
### 7.1 Purpose
|
||||
|
||||
After buying the favorite token (e.g., UP at $0.85), the bot places a **hedge order** on the **opposite token** (DOWN) at a very low price ($0.02).
|
||||
|
||||
If the trade loses (UP resolves to $0), the hedge may fill, providing the opposite token at $0.02 which resolves to $1.00 -- a $0.98 profit per contract that partially offsets the loss.
|
||||
|
||||
### 7.2 Hedge PnL Math
|
||||
|
||||
**Without hedge** (unhedged loss):
|
||||
|
||||
```
|
||||
PnL_loss = -C * P_entry
|
||||
```
|
||||
|
||||
Where C = contracts, P_entry = entry price.
|
||||
|
||||
**With hedge** (if hedge fills before resolution):
|
||||
|
||||
```
|
||||
PnL_hedged_loss = -C * P_entry + C_hedge * (1.00 - P_hedge)
|
||||
```
|
||||
|
||||
With P_hedge = 0.02:
|
||||
|
||||
```
|
||||
PnL_hedged_loss = -C * P_entry + C_hedge * 0.98
|
||||
```
|
||||
|
||||
### 7.3 Hedge Order Type
|
||||
|
||||
- **GTD (Good-Till-Date)**: Limit order that stays on the book until expiry
|
||||
- Placed at `hedge_price` ($0.02) on the opposite token
|
||||
- Expires in 1 hour (market resolves in <=15 minutes)
|
||||
- Only fills if opposite token price drops to $0.02 (i.e., our side is winning strongly)
|
||||
|
||||
### 7.4 Hedge Fill Tracking
|
||||
|
||||
The User WebSocket monitors for fill events matching the hedge order ID:
|
||||
|
||||
```
|
||||
on_trade(data):
|
||||
if data.order_id == hedge_order_id AND status == "MATCHED":
|
||||
hedge_mgr.on_hedge_fill(size, price)
|
||||
if hedge_mgr.is_fully_hedged:
|
||||
stats.record_hedge(contracts, price)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Position Lifecycle and PnL Accounting
|
||||
|
||||
### 8.1 Position States
|
||||
|
||||
```
|
||||
NO POSITION OPEN POSITION
|
||||
+----------+ execute_entry +--------------+
|
||||
| can_enter | ---------------> | LONG UP |
|
||||
| = true | | or DOWN |
|
||||
+----------+ | |
|
||||
| entry_price |
|
||||
| contracts |
|
||||
| hedged? |
|
||||
+------+-------+
|
||||
|
|
||||
check_market_end (T_left <= 10s)
|
||||
|
|
||||
v
|
||||
+--------------+
|
||||
| CLOSED |
|
||||
| TradeRecord |
|
||||
| (PnL, DD) |
|
||||
+--------------+
|
||||
```
|
||||
|
||||
### 8.2 PnL Calculation
|
||||
|
||||
At market end (10 seconds before expiry), the bot reads the final token price.
|
||||
|
||||
**Win condition**: `P_final >= 0.70`
|
||||
|
||||
**Win PnL** (token resolves to ~$1.00):
|
||||
|
||||
```
|
||||
PnL_win = C * 1.00 - C * P_entry = C * (1 - P_entry)
|
||||
```
|
||||
|
||||
**Loss PnL** (token resolves to ~$0.00):
|
||||
|
||||
```
|
||||
PnL_loss = 0 - C * P_entry = -C * P_entry
|
||||
```
|
||||
|
||||
**Examples** with C = 64 contracts:
|
||||
|
||||
| Entry Price | Win PnL | Loss PnL |
|
||||
|-------------|----------|-----------|
|
||||
| $0.75 | +$16.00 | -$48.00 |
|
||||
| $0.81 | +$12.16 | -$51.84 |
|
||||
| $0.88 | +$7.68 | -$56.32 |
|
||||
|
||||
### 8.3 Win Rate and Session Statistics
|
||||
|
||||
```
|
||||
Win Rate = W / (W + L) * 100%
|
||||
|
||||
Total PnL = SUM(PnL_i) for all trades i = 1..N
|
||||
|
||||
Avg Win = SUM(PnL_w) / count(wins)
|
||||
|
||||
Avg Loss = SUM(PnL_l) / count(losses)
|
||||
```
|
||||
|
||||
### 8.4 Break-Even Win Rate
|
||||
|
||||
For a given entry price P, the minimum win rate needed to break even:
|
||||
|
||||
```
|
||||
WR_breakeven = P / 1.00 = P
|
||||
```
|
||||
|
||||
| Entry Price | Break-Even WR |
|
||||
|-------------|---------------|
|
||||
| $0.75 | 75% |
|
||||
| $0.80 | 80% |
|
||||
| $0.85 | 85% |
|
||||
| $0.88 | 88% |
|
||||
|
||||
This is why the win rate CSV is critical -- the bot only enters when historical win rate exceeds the break-even threshold for the given price and time bin.
|
||||
|
||||
---
|
||||
|
||||
## 9. Drawdown Tracking
|
||||
|
||||
### 9.1 Per-Trade Drawdown
|
||||
|
||||
After entry, the bot tracks the minimum price seen every 250ms:
|
||||
|
||||
```
|
||||
P_min = min(P_min, P_current) # updated every 250ms
|
||||
```
|
||||
|
||||
Initialized at entry: `P_min = P_entry`
|
||||
|
||||
At position close, drawdown is calculated:
|
||||
|
||||
**Absolute drawdown**:
|
||||
|
||||
```
|
||||
DD_abs = max(0, P_entry - P_min)
|
||||
```
|
||||
|
||||
**Percentage drawdown**:
|
||||
|
||||
```
|
||||
DD_pct = (DD_abs / P_entry) * 100%
|
||||
```
|
||||
|
||||
**Dollar drawdown** (total exposure):
|
||||
|
||||
```
|
||||
DD_usd = DD_abs * C
|
||||
```
|
||||
|
||||
### 9.2 Logging
|
||||
|
||||
At market end, logged to `signals.log`:
|
||||
|
||||
```
|
||||
Max Drawdown: -0.0500 (-6.17%)
|
||||
Max DD ($): -$3.20 (min price: 0.7600)
|
||||
```
|
||||
|
||||
### 9.3 Live Dashboard
|
||||
|
||||
While position is open, the dashboard shows real-time drawdown:
|
||||
|
||||
```
|
||||
LONG UP @ 0.810 (64 contracts)
|
||||
Unrealized: +$3.84 (price: 0.870)
|
||||
Max DD: -$1.92 (-3.7%) (low: 0.780)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Chainlink BTC/USD Oracle Integration
|
||||
|
||||
### 10.1 Purpose
|
||||
|
||||
The Chainlink price feed provides the **actual BTC/USD price** used by Polymarket to resolve markets. The bot tracks this independently for:
|
||||
|
||||
1. **Dashboard display**: Shows real-time BTC price and deviation from market start
|
||||
2. **Signal logging**: Records BTC deviation at the moment of each trade entry
|
||||
3. **Analysis**: Understanding how BTC price movement correlates with market outcomes
|
||||
|
||||
### 10.2 Connection
|
||||
|
||||
```
|
||||
URL: wss://ws-live-data.polymarket.com
|
||||
Topic: crypto_prices_chainlink
|
||||
Symbol: btc/usd (filtered in code)
|
||||
```
|
||||
|
||||
### 10.3 Anchor Price and Deviation
|
||||
|
||||
At each 15-minute boundary, the **anchor price** is captured as the first tick of the new window:
|
||||
|
||||
```
|
||||
Window = floor(T_chainlink / 900) * 900
|
||||
```
|
||||
|
||||
When Window changes (new 15-minute period), the first tick's price becomes the anchor:
|
||||
|
||||
```
|
||||
P_anchor = price of first tick where Window(T_tick) != Window_previous
|
||||
```
|
||||
|
||||
**BTC Deviation**:
|
||||
|
||||
```
|
||||
Delta_abs = P_current - P_anchor
|
||||
|
||||
Delta_pct = (Delta_abs / P_anchor) * 100%
|
||||
```
|
||||
|
||||
### 10.4 Calibration Logging
|
||||
|
||||
For calibration purposes, every tick within [-15s, +5s] of a 15-minute boundary is logged:
|
||||
|
||||
```
|
||||
BTC_TICK 16:59:59.000 (local 17:00:00.653) $69,481.26 [-1.000s before 17:00:00]
|
||||
BTC_TICK 17:00:00.000 (local 17:00:01.578) $69,483.32 [+0.000s after 17:00:00]
|
||||
```
|
||||
|
||||
Fields:
|
||||
- **Chainlink timestamp**: From the oracle data (millisecond precision)
|
||||
- **Local timestamp**: Server clock time when message was processed
|
||||
- **Price**: BTC/USD price from Chainlink
|
||||
- **Offset**: Seconds before/after the 15-minute boundary
|
||||
|
||||
### 10.5 Watchdog
|
||||
|
||||
If no Chainlink messages are received for 30 seconds, the watchdog forces a WebSocket reconnection:
|
||||
|
||||
```python
|
||||
if time.time() - last_msg_time > DATA_TIMEOUT: # 30 seconds
|
||||
ws.close() # Triggers reconnection in connect() loop
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Auto-Redemption System
|
||||
|
||||
### 11.1 Purpose
|
||||
|
||||
After a market resolves, winning positions must be **redeemed** on-chain to collect the $1.00 payout per contract.
|
||||
|
||||
### 11.2 Flow
|
||||
|
||||
```
|
||||
Every 180 seconds:
|
||||
1. Fetch all positions from Polymarket Data API
|
||||
2. Categorize: active, pending, redeemable
|
||||
3. For each redeemable position:
|
||||
a. Check oracle resolution (payoutDenominator)
|
||||
b. Submit redemption transaction on Polygon
|
||||
c. Wait for confirmation
|
||||
```
|
||||
|
||||
### 11.3 Implementation Details
|
||||
|
||||
- Runs as a background asyncio task
|
||||
- File lock prevents concurrent redemptions
|
||||
- Supports both EOA (direct) and Gnosis Safe (proxy) wallets
|
||||
- Blockchain transactions require POL (MATIC) for gas fees
|
||||
- Runs in a dedicated thread pool to avoid blocking the main event loop
|
||||
|
||||
---
|
||||
|
||||
## 12. Configuration Reference
|
||||
|
||||
### Strategy Parameters
|
||||
|
||||
| Parameter | config.json | Dataclass Default | Description |
|
||||
|---------------------|-------------|-------------------|-----------------------------------|
|
||||
| min_price | 0.75 | 0.65 | Min favorite token price to enter |
|
||||
| max_price | 0.88 | 0.91 | Max favorite token price to enter |
|
||||
| min_elapsed_sec | 500 | 480 | Min seconds since market start |
|
||||
| min_deviation_pct | 0 | 5.0 | Min VWAP deviation (%) |
|
||||
| max_deviation_pct | 100 | 100.0 | Max VWAP deviation (%) |
|
||||
| no_entry_before_end | 335 | 90 | Min seconds remaining for entry |
|
||||
| momentum_window_sec | 60 | 120 | Momentum lookback window |
|
||||
| vwap_window_sec | 30 | 30 | VWAP calculation window |
|
||||
|
||||
> **Note**: "config.json" = active value. "Dataclass Default" = fallback if field is missing from JSON.
|
||||
|
||||
### Timing Constraints Visualization
|
||||
|
||||
```
|
||||
Market: 900 seconds (15 minutes)
|
||||
|
||||
0s ----------- 500s ---- 565s ----------- 900s
|
||||
| | | |
|
||||
| NO ENTRY | ENTRY | NO ENTRY |
|
||||
| (too early) | WINDOW | (too late) |
|
||||
| | | |
|
||||
<-min_elapsed-> | | |
|
||||
| <---335s cutoff-->|
|
||||
| | |
|
||||
<-- 65s -->
|
||||
allowed
|
||||
```
|
||||
|
||||
Entry is allowed when:
|
||||
- `T_elapsed >= 500` seconds AND
|
||||
- `T_remaining > 335` seconds
|
||||
|
||||
This creates a **65-second entry window** (from 500s to 565s elapsed).
|
||||
|
||||
---
|
||||
|
||||
## 13. Fault Tolerance and Recovery
|
||||
|
||||
### 13.1 Order Timeout Recovery
|
||||
|
||||
When a FAK order times out (no fill confirmation within fill_timeout_ms):
|
||||
|
||||
```
|
||||
1. Check User WebSocket for recent fills on the token
|
||||
2. Wait up to ws_recovery_timeout_sec (10s)
|
||||
3. If fills found:
|
||||
-> RECOVERY: Record position from WS fill data
|
||||
-> Place hedge as normal
|
||||
4. If no fills found:
|
||||
-> Block entry for rest of market (prevent duplicates)
|
||||
-> Log: "Network timeout - no fill detected"
|
||||
```
|
||||
|
||||
### 13.2 Entry Blocking
|
||||
|
||||
After any failed entry attempt, `stats.block_entry()` prevents further attempts on the same market. This avoids:
|
||||
- Duplicate orders from timeout+retry
|
||||
- Repeated failures hitting rate limits
|
||||
|
||||
Reset on new market: `entry_blocked = False`
|
||||
|
||||
### 13.3 WebSocket Reconnection
|
||||
|
||||
**Market Data WebSocket**: On ConnectionClosed, reconnects after 2 seconds. On any other exception, reconnects after 5 seconds.
|
||||
|
||||
**Chainlink RTDS WebSocket**: Same reconnection logic plus a 30-second **watchdog** that detects silent disconnections (TCP alive but no data flowing).
|
||||
|
||||
### 13.4 Config Validation
|
||||
|
||||
At startup, `validate_config()` checks:
|
||||
- Private key exists and starts with "0x"
|
||||
- API credentials are set
|
||||
- `min_price < max_price`
|
||||
- `max_entry_price <= max_price`
|
||||
- `max_deviation_pct > min_deviation_pct`
|
||||
|
||||
Bot refuses to start if any validation fails.
|
||||
|
||||
---
|
||||
|
||||
## 14. File and Log Architecture
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
btc_15m_live/
|
||||
+-- main.py # Main bot (2000+ lines, all core logic)
|
||||
+-- config.json # Runtime configuration
|
||||
+-- .env # Secrets (API keys, private key)
|
||||
+-- chart_pnl.py # PnL chart generator
|
||||
+-- PROJECT_LOGIC.md # This document
|
||||
+-- data/
|
||||
| +-- win_rate.csv # Historical win rate matrix (10x15)
|
||||
+-- logs/
|
||||
| +-- bot.log # Main application log
|
||||
| +-- signals.log # Trade signal snapshots
|
||||
| +-- orders.log # Order execution details
|
||||
| +-- trading_log.json # Trade history (JSON persistence)
|
||||
| +-- api_activity.json # API call log
|
||||
| +-- pnl_chart.png # Generated PnL chart
|
||||
| +-- equity_chart.png # Equity curve chart
|
||||
+-- src/
|
||||
+-- config_loader.py # Configuration loading & validation
|
||||
+-- order_executor.py # FAK order execution with retry
|
||||
+-- hedge_manager.py # GTD hedge order management
|
||||
+-- market_finder.py # Gamma API market discovery
|
||||
+-- position_tracker.py # Position & PnL tracking
|
||||
+-- auto_redeemer.py # On-chain position redemption
|
||||
+-- telegram_notifier.py # Telegram alerts & charts
|
||||
+-- user_websocket.py # User channel WebSocket
|
||||
+-- websocket_client.py # Market data WebSocket
|
||||
+-- signal_generator.py # (Legacy, unused)
|
||||
+-- realtime_dashboard.py # (Legacy, unused)
|
||||
```
|
||||
|
||||
### Log Contents
|
||||
|
||||
| Log File | Contents |
|
||||
|--------------------|---------------------------------------------------------------------|
|
||||
| bot.log | All events: connections, market changes, errors, BTC ticks, anchors |
|
||||
| signals.log | Full indicator snapshot at each trade + market end with PnL and DD |
|
||||
| orders.log | Detailed order execution: prices, retries, fills, rejections |
|
||||
| trading_log.json | Persistent trade array with entry/exit, PnL, drawdown, win/loss |
|
||||
|
||||
### trading_log.json Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"trades": [
|
||||
{
|
||||
"market_slug": "btc-updown-15m-1770831900",
|
||||
"token_name": "UP",
|
||||
"entry_price": 0.81,
|
||||
"exit_price": 0.03,
|
||||
"contracts": 64,
|
||||
"pnl": -51.84,
|
||||
"won": false,
|
||||
"timestamp": 1770832790.165,
|
||||
"max_drawdown_abs": 0.05,
|
||||
"max_drawdown_pct": 6.17
|
||||
}
|
||||
],
|
||||
"markets_seen": 27
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,271 @@
|
||||
# BTC Binary — VWAP & Momentum Bot
|
||||
|
||||
Automated trading bot for **Polymarket BTC Up/Down** binary markets (**5- or 15-minute** windows; set `market.interval_minutes` in `config.json`). It streams the CLOB via WebSocket, computes **VWAP**, **deviation**, **momentum**, and **z-score** on the **favorite** side, and fires **Fill-And-Kill (FAK)** entries when **all** conditions align. Optional **Good-Till-Date (GTD)** limits on the opposite token act as a **partial hedge** (advanced; off by default).
|
||||
|
||||
**Suite:** This bot is part of the [PolyBullLabs Polymarket suite](../README.md). **Repository:** [github.com/PolyBullLabs/polymakret-5min-15min-1hour-arbitrage-bot](https://github.com/PolyBullLabs/polymakret-5min-15min-1hour-arbitrage-bot.git) · **Telegram:** [@terauss](https://t.me/terauss)
|
||||
|
||||
---
|
||||
|
||||
## Why this strategy can work (and what breaks it)
|
||||
|
||||
**Idea:** Near the end of a short binary window, the market often **prices one side as favorite** (higher last price). The bot does **not** buy blindly: it waits for **(a)** favorite price in a **tunable band**, **(b)** a **late** entry slice, **(c)** price **stretched above short-horizon VWAP** (`min_deviation_pct`), and **(d)** **positive momentum**—roughly, **crowd consensus plus recent upward flow** on that token.
|
||||
|
||||
**Profit source (when it exists):** If the **true** chance of the favorite winning **exceeds** the **entry price** (e.g. pay $0.80 when win probability is sustainably >80%), **expected value** can be positive. The indicators are a **filter** to reduce entries where the book is **choppy or mean-reverting** against the favorite.
|
||||
|
||||
**Risk:** Binary markets can **gap** or **flip** into the close. **Break-even win rate ≈ entry price** before fees. **Slippage**, **partial fills**, and **oracle resolution** details can erode edge. **Start small**; use **`simulation`** in config when available.
|
||||
|
||||
**Good fit:** You want **BTC only**, **transparent math** (see [PROJECT_LOGIC.md](PROJECT_LOGIC.md)), and a **Rich** terminal dashboard. **Poor fit:** You need multi-asset from one process—use **Meridian** (`up-down-spread-bot`) in the same suite.
|
||||
|
||||
---
|
||||
|
||||
## What This Bot Does
|
||||
|
||||
On each interval (e.g. every 5 or 15 minutes, depending on config), Polymarket opens a market asking whether BTC will finish up or down for that window. Two tokens are available:
|
||||
|
||||
- **UP token** pays $1.00 if BTC rises, $0.00 if it falls
|
||||
- **DOWN token** pays $1.00 if BTC falls, $0.00 if it rises
|
||||
|
||||
The bot identifies the "favorite" (the token with higher probability), waits for specific technical conditions to align, then buys it. If the prediction is correct, the token resolves to $1.00 for a profit. If wrong, it resolves to $0.00 for a loss.
|
||||
|
||||
### Key Features
|
||||
|
||||
- Real-time terminal dashboard with Rich library (order book, indicators, signals, position, P&L)
|
||||
- VWAP-based signal generation with deviation and momentum filters
|
||||
- Historical win rate filtering by price range and time bin
|
||||
- FAK order execution with retry logic and WebSocket fill confirmation
|
||||
- Optional hedging via GTD orders on the opposite token at $0.02
|
||||
- Timeout recovery: detects fills via User WebSocket even after network timeouts
|
||||
- Chainlink BTC/USD oracle tracking: real-time BTC price and deviation from market start
|
||||
- Auto-redemption of winning positions on-chain
|
||||
- Telegram notifications with trade alerts and equity charts
|
||||
- Per-trade drawdown tracking with logging
|
||||
- Persistent trade history in JSON format (survives restarts)
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
btc-binary-VWAP-Momentum-bot/
|
||||
|-- main.py # Main bot: dashboard, signals, execution, all core logic
|
||||
|-- config.json # Trading parameters (strategy, entry, hedge, etc.)
|
||||
|-- .env.example # Environment variables template (copy to .env)
|
||||
|-- requirements.txt # Python dependencies
|
||||
|-- chart_pnl.py # P&L chart generator (run separately)
|
||||
|-- CONFIG.md # Full config.json reference
|
||||
|-- PROJECT_LOGIC.md # Detailed technical documentation with formulas
|
||||
|-- docs/
|
||||
| +-- README.md # Step-by-step beginner guide (Windows + Linux)
|
||||
|-- data/
|
||||
| +-- win_rate.csv # Historical win rate matrix (price ranges x per-minute bins; 5m uses first 5 bins)
|
||||
+-- src/
|
||||
|-- __init__.py
|
||||
|-- config_loader.py # Loads config.json + .env, validates settings
|
||||
|-- order_executor.py # FAK order placement with retry logic
|
||||
|-- hedge_manager.py # GTD hedge order management
|
||||
|-- market_finder.py # Discovers active markets via Gamma API
|
||||
|-- position_tracker.py # Position and P&L tracking
|
||||
|-- auto_redeemer.py # On-chain redemption of resolved positions
|
||||
|-- telegram_notifier.py# Telegram alerts and chart sending
|
||||
|-- user_websocket.py # User channel WebSocket (order/fill tracking)
|
||||
+-- websocket_client.py # Market data WebSocket (prices, trades, book)
|
||||
```
|
||||
|
||||
## Installation (From Scratch on a Clean Machine)
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Linux server (Ubuntu 22.04+ recommended) or macOS
|
||||
- Python 3.11+
|
||||
- Polymarket account with funded USDC balance (on Polygon), POL for gas fees, and API credentials
|
||||
- Private key of your trading wallet
|
||||
|
||||
### Step 1: System Setup
|
||||
|
||||
```bash
|
||||
sudo apt update && sudo apt upgrade -y
|
||||
sudo apt install -y python3 python3-pip python3-venv git
|
||||
python3 --version
|
||||
```
|
||||
|
||||
### Step 2: Clone the Repository
|
||||
|
||||
```bash
|
||||
cd ~
|
||||
git clone https://github.com/PolyBullLabs/polymakret-5min-15min-1hour-arbitrage-bot.git
|
||||
cd polymakret-5min-15min-1hour-arbitrage-bot/btc-binary-VWAP-Momentum-bot
|
||||
```
|
||||
|
||||
### Step 3: Create Virtual Environment
|
||||
|
||||
```bash
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
```
|
||||
|
||||
### Step 4: Install Dependencies
|
||||
|
||||
```bash
|
||||
pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### Step 5: Configure Environment Variables
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
Fill in your credentials:
|
||||
|
||||
| Variable | Required | Description |
|
||||
|---|---|---|
|
||||
| PRIVATE_KEY | Yes | Polygon wallet private key (0x...) |
|
||||
| FUNDER_ADDRESS | If proxy | Gnosis Safe address (if using proxy wallet) |
|
||||
| SIGNATURE_TYPE | If proxy | 0=EOA, 1=Poly Proxy, 2=Gnosis Safe |
|
||||
| POLY_API_KEY | Yes | Polymarket CLOB API key |
|
||||
| POLY_API_SECRET | Yes | Polymarket CLOB API secret |
|
||||
| POLY_API_PASSPHRASE | Yes | Polymarket CLOB API passphrase |
|
||||
| RPC_URL | Recommended | Alchemy/Infura Polygon RPC (default: public RPC) |
|
||||
| TELEGRAM_BOT_TOKEN | Optional | Telegram bot token from @BotFather |
|
||||
| TELEGRAM_CHAT_ID | Optional | Your Telegram user/chat ID |
|
||||
|
||||
**How to get Polymarket API credentials:**
|
||||
1. Go to https://polymarket.com and connect your wallet
|
||||
2. Navigate to your account settings
|
||||
3. Generate API credentials (key, secret, passphrase)
|
||||
4. These are used for L2 authentication on the CLOB
|
||||
|
||||
### Step 6: Configure Trading Parameters
|
||||
|
||||
```bash
|
||||
nano config.json
|
||||
```
|
||||
|
||||
See the Configuration section below for parameter descriptions.
|
||||
|
||||
### Step 7: Create Logs Directory
|
||||
|
||||
```bash
|
||||
mkdir -p logs
|
||||
```
|
||||
|
||||
### Step 8: Run the Bot
|
||||
|
||||
```bash
|
||||
source venv/bin/activate
|
||||
python3 main.py
|
||||
```
|
||||
|
||||
### Step 9: Run in Background (Production)
|
||||
|
||||
```bash
|
||||
sudo apt install -y tmux
|
||||
tmux new -s bot
|
||||
|
||||
# Inside tmux:
|
||||
source venv/bin/activate
|
||||
python3 main.py
|
||||
|
||||
# Detach: Ctrl+B then D
|
||||
# Reattach: tmux attach -t bot
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The bot is **highly configurable** -- every aspect of the strategy, risk management, execution, hedging, and notifications can be fine-tuned through `config.json` without touching any code. You can adjust the entry window, price filters, indicator sensitivity, bet sizing, and more to match your risk tolerance and trading style.
|
||||
|
||||
**For a complete parameter-by-parameter guide with explanations, examples, and ready-made presets (Conservative / Moderate / Aggressive), see [CONFIG.md](CONFIG.md).**
|
||||
|
||||
Quick overview of the most important settings:
|
||||
|
||||
| Parameter | Default | What it controls |
|
||||
|---|---|---|
|
||||
| `strategy.min_price` | 0.75 | Minimum token price to enter (lower = riskier, more profit) |
|
||||
| `strategy.max_price` | 0.88 | Maximum token price to enter (higher = safer, less profit) |
|
||||
| `strategy.min_elapsed_sec` | 530 | Wait this many seconds before entering |
|
||||
| `strategy.min_deviation_pct` | 3 | Minimum VWAP deviation to trigger signal |
|
||||
| `strategy.no_entry_before_end_sec` | 335 | Stop entering with this many seconds left |
|
||||
| `entry.bet_amount_usd` | 5 | USD per trade (start small!) |
|
||||
| `entry.max_entry_price` | 0.88 | Hard price ceiling for safety |
|
||||
| `hedge.enabled` | false | Automatic hedging on opposite token |
|
||||
| `telegram.enabled` | false | Trade notifications via Telegram |
|
||||
| `web_dashboard.enabled` | false | Local web UI (same live data as the terminal; JSON at `/api/state`) |
|
||||
|
||||
When `web_dashboard.enabled` is true, open **http://127.0.0.1:8765/** (or your `host`/`port`) in a browser on the same machine. Defaults bind to localhost only; do not expose the port publicly without authentication.
|
||||
|
||||
## How the Strategy Works
|
||||
|
||||
### Signal Generation
|
||||
|
||||
The bot evaluates 5 conditions every 250ms. ALL must be true to trigger a BUY:
|
||||
|
||||
1. **Price in range**: min_price <= favorite_price <= max_price
|
||||
2. **Time elapsed**: elapsed_seconds >= min_elapsed_sec
|
||||
3. **VWAP deviation**: min_deviation_pct < deviation < max_deviation_pct
|
||||
4. **Positive momentum**: momentum > 0%
|
||||
5. **Time remaining**: seconds_left > no_entry_before_end_sec
|
||||
|
||||
### Indicators
|
||||
|
||||
- **VWAP** (Volume-Weighted Average Price): SUM(price * volume) / SUM(volume) over the last N seconds
|
||||
- **Deviation**: (last_price - VWAP) / VWAP * 100% -- how far price moved from its average
|
||||
- **Momentum**: (price_now - price_Ns_ago) / price_Ns_ago * 100% -- direction of price movement
|
||||
- **Z-Score**: (price - mean) / stdev over the last 5 seconds -- statistical outlier detection
|
||||
|
||||
### Execution Flow
|
||||
|
||||
```
|
||||
Signal detected
|
||||
-> FAK order placed
|
||||
-> Fill confirmed via WebSocket
|
||||
-> Position recorded
|
||||
-> Hedge placed (if enabled)
|
||||
-> Drawdown tracked every 250ms
|
||||
-> Market ends (10s before expiry)
|
||||
-> Position resolved, P&L recorded
|
||||
-> Winning positions auto-redeemed on-chain
|
||||
```
|
||||
|
||||
### Risk
|
||||
|
||||
Higher entry prices mean higher risk. The break-even win rate equals the entry price:
|
||||
|
||||
- Entry at $0.75 needs 75% win rate to break even
|
||||
- Entry at $0.85 needs 85% win rate to break even
|
||||
- Entry at $0.88 needs 88% win rate to break even
|
||||
|
||||
Start with small bet_amount_usd ($1-5) until you understand the behavior.
|
||||
|
||||
## Logs
|
||||
|
||||
The bot creates a logs/ directory with:
|
||||
|
||||
| File | Description |
|
||||
|---|---|
|
||||
| bot.log | Main application log (connections, errors, BTC price ticks) |
|
||||
| signals.log | Full indicator snapshot at each trade entry and market end |
|
||||
| orders.log | Detailed order execution log (prices, retries, fills) |
|
||||
| hedges.log | Hedge order placement and fill tracking |
|
||||
| trading_log.json | Persistent trade history (survives restarts) |
|
||||
|
||||
## Generating Charts
|
||||
|
||||
After accumulating trades, generate a P&L chart:
|
||||
|
||||
```bash
|
||||
source venv/bin/activate
|
||||
python3 chart_pnl.py
|
||||
# Output: logs/pnl_chart.png
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
For a deep technical dive including all formulas, architecture diagrams, and the complete signal generation logic, see [PROJECT_LOGIC.md](PROJECT_LOGIC.md).
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This software is provided **for educational and research purposes only**. Trading on prediction markets involves **substantial risk**; you may **lose your entire stake**. **No performance is guaranteed.** The authors and contributors are **not** responsible for financial losses, bugs, or exchange rule changes. Use **simulation** where offered, keep **API keys and private keys** secret, and **never** trade with capital you cannot afford to lose. For **extended quant strategies** (Kelly, Monte Carlo, advanced TA, sizing systems), see the [repository README](../README.md) and contact [@terauss](https://t.me/terauss).
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,13 @@
|
||||
[
|
||||
{
|
||||
"id": "0xbb57ccf585",
|
||||
"slug": "will-bitcoin-hit-1m-before-gta-vi-872-424",
|
||||
"title": "Will bitcoin hit $1m before GTA VI?",
|
||||
"active": true,
|
||||
"closed": false,
|
||||
"neg_risk": null,
|
||||
"start": "2025-05-02T15:48:17.361Z",
|
||||
"end": "2026-07-31T12:00:00Z",
|
||||
"outcomes": []
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
import os
|
||||
p = r"c:\Users\Administrator\Desktop\polymarket-5min-15min-1hour-arbitrage-trading-bot\btc-binary-VWAP-Momentum-bot\logs\bot.log"
|
||||
if os.path.exists(p):
|
||||
with open(p, "r", encoding="utf-8", errors="replace") as f:
|
||||
lines = f.readlines()
|
||||
for line in lines[-40:]:
|
||||
print(line.rstrip())
|
||||
else:
|
||||
print("NOT FOUND:", p)
|
||||
@@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate a modern, beautiful P&L chart from trading_log.json
|
||||
"""
|
||||
import json
|
||||
import numpy as np
|
||||
import matplotlib
|
||||
matplotlib.use('Agg')
|
||||
import matplotlib.pyplot as plt
|
||||
import matplotlib.ticker as mticker
|
||||
from matplotlib.patches import FancyBboxPatch
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# ─── Load data ──────────────────────────────────────────────
|
||||
data = json.load(open(Path(__file__).parent / "logs" / "trading_log.json"))
|
||||
trades = data["trades"]
|
||||
markets_seen = data.get("markets_seen", 0)
|
||||
|
||||
if not trades:
|
||||
print("No trades found.")
|
||||
exit()
|
||||
|
||||
# ─── Prepare arrays ─────────────────────────────────────────
|
||||
timestamps = [datetime.fromtimestamp(t["timestamp"], tz=timezone.utc) for t in trades]
|
||||
pnls = [t["pnl"] for t in trades]
|
||||
cumulative = np.cumsum(pnls)
|
||||
won = [t["won"] for t in trades]
|
||||
tokens = [t["token_name"] for t in trades]
|
||||
entries = [t["entry_price"] for t in trades]
|
||||
exits = [t["exit_price"] for t in trades]
|
||||
contracts = [t["contracts"] for t in trades]
|
||||
labels = [f"#{i+1}" for i in range(len(trades))]
|
||||
|
||||
wins = sum(won)
|
||||
losses = len(won) - wins
|
||||
win_rate = wins / len(won) * 100 if won else 0
|
||||
total_pnl = sum(pnls)
|
||||
total_won_pnl = sum(p for p, w in zip(pnls, won) if w)
|
||||
total_lost_pnl = sum(p for p, w in zip(pnls, won) if not w)
|
||||
avg_win = total_won_pnl / wins if wins else 0
|
||||
avg_loss = total_lost_pnl / losses if losses else 0
|
||||
total_volume = sum(e * c for e, c in zip(entries, contracts))
|
||||
best_trade = max(pnls)
|
||||
worst_trade = min(pnls)
|
||||
|
||||
# ─── Dark theme ──────────────────────────────────────────────
|
||||
BG = '#0d1117'
|
||||
CARD_BG = '#161b22'
|
||||
TEXT = '#e6edf3'
|
||||
TEXT_DIM = '#8b949e'
|
||||
GREEN = '#3fb950'
|
||||
RED = '#f85149'
|
||||
BLUE = '#58a6ff'
|
||||
PURPLE = '#bc8cff'
|
||||
ORANGE = '#d29922'
|
||||
GRID = '#21262d'
|
||||
ACCENT = '#1f6feb'
|
||||
|
||||
plt.rcParams.update({
|
||||
'figure.facecolor': BG,
|
||||
'axes.facecolor': CARD_BG,
|
||||
'axes.edgecolor': GRID,
|
||||
'axes.labelcolor': TEXT,
|
||||
'text.color': TEXT,
|
||||
'xtick.color': TEXT_DIM,
|
||||
'ytick.color': TEXT_DIM,
|
||||
'grid.color': GRID,
|
||||
'grid.alpha': 0.5,
|
||||
'font.family': 'monospace',
|
||||
'font.size': 11,
|
||||
})
|
||||
|
||||
fig = plt.figure(figsize=(16, 10))
|
||||
fig.patch.set_facecolor(BG)
|
||||
|
||||
# ─── Layout: top stats bar, main chart, bottom bars ──────────
|
||||
gs = fig.add_gridspec(3, 1, height_ratios=[0.8, 3, 2], hspace=0.35,
|
||||
left=0.08, right=0.95, top=0.92, bottom=0.06)
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Title
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
pnl_color = GREEN if total_pnl >= 0 else RED
|
||||
pnl_sign = "+" if total_pnl >= 0 else ""
|
||||
fig.text(0.08, 0.96, "BTC 15m Live Trading", fontsize=22, fontweight='bold',
|
||||
color=TEXT, ha='left', va='center')
|
||||
fig.text(0.08, 0.935, f"Session Performance • {len(trades)} trades • {markets_seen} markets observed",
|
||||
fontsize=10, color=TEXT_DIM, ha='left', va='center')
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Stats cards (top row)
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
ax_stats = fig.add_subplot(gs[0])
|
||||
ax_stats.set_xlim(0, 10)
|
||||
ax_stats.set_ylim(0, 1)
|
||||
ax_stats.axis('off')
|
||||
|
||||
cards = [
|
||||
("Total P&L", f"{pnl_sign}${total_pnl:.2f}", pnl_color),
|
||||
("Win Rate", f"{win_rate:.1f}%", GREEN if win_rate >= 50 else RED),
|
||||
("Wins / Losses", f"{wins}W / {losses}L", BLUE),
|
||||
("Avg Win", f"+${avg_win:.2f}", GREEN),
|
||||
("Avg Loss", f"${avg_loss:.2f}", RED),
|
||||
("Best Trade", f"+${best_trade:.2f}", GREEN),
|
||||
("Worst Trade", f"${worst_trade:.2f}", RED),
|
||||
("Volume", f"${total_volume:.0f}", PURPLE),
|
||||
]
|
||||
|
||||
card_w = 10 / len(cards)
|
||||
for i, (label, value, color) in enumerate(cards):
|
||||
cx = i * card_w + card_w / 2
|
||||
# Card background
|
||||
rect = FancyBboxPatch((i * card_w + 0.08, 0.05), card_w - 0.16, 0.9,
|
||||
boxstyle="round,pad=0.05", facecolor=BG,
|
||||
edgecolor=GRID, linewidth=1.2,
|
||||
transform=ax_stats.transData)
|
||||
ax_stats.add_patch(rect)
|
||||
# Value
|
||||
ax_stats.text(cx, 0.6, value, fontsize=13, fontweight='bold',
|
||||
color=color, ha='center', va='center')
|
||||
# Label
|
||||
ax_stats.text(cx, 0.25, label, fontsize=8, color=TEXT_DIM,
|
||||
ha='center', va='center')
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Cumulative P&L line chart (main)
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
ax1 = fig.add_subplot(gs[1])
|
||||
|
||||
x = np.arange(len(trades))
|
||||
cum_with_zero = np.insert(cumulative, 0, 0)
|
||||
x_with_zero = np.arange(-1, len(trades)) + 1
|
||||
|
||||
# Fill area under curve
|
||||
for i in range(len(cum_with_zero) - 1):
|
||||
y0, y1 = cum_with_zero[i], cum_with_zero[i + 1]
|
||||
color = GREEN if y1 >= 0 else RED
|
||||
ax1.fill_between([i, i + 1], [y0, y1], alpha=0.08, color=color, zorder=1)
|
||||
|
||||
# Main line with gradient effect
|
||||
ax1.plot(range(len(cum_with_zero)), cum_with_zero, color=BLUE, linewidth=2.5,
|
||||
zorder=3, solid_capstyle='round')
|
||||
|
||||
# Scatter points: green=win, red=loss
|
||||
for i, (pnl, w) in enumerate(zip(pnls, won)):
|
||||
c = GREEN if w else RED
|
||||
marker = '▲' if w else '▼'
|
||||
size = 100 if w else 120
|
||||
ax1.scatter(i + 1, cumulative[i], color=c, s=size, zorder=5,
|
||||
edgecolors='white', linewidths=0.5, marker='o')
|
||||
# P&L annotation
|
||||
offset = 8 if pnl >= 0 else -14
|
||||
sign = "+" if pnl >= 0 else ""
|
||||
ax1.annotate(f"{sign}${pnl:.2f}", (i + 1, cumulative[i]),
|
||||
textcoords="offset points", xytext=(0, offset),
|
||||
fontsize=8, fontweight='bold', color=c, ha='center', zorder=6)
|
||||
|
||||
# Zero line
|
||||
ax1.axhline(y=0, color=TEXT_DIM, linewidth=0.8, linestyle='--', alpha=0.5, zorder=2)
|
||||
|
||||
# Style
|
||||
ax1.set_xlim(-0.3, len(trades) + 0.3)
|
||||
y_margin = max(abs(cumulative.max()), abs(cumulative.min())) * 0.3
|
||||
ax1.set_ylim(cumulative.min() - y_margin, cumulative.max() + y_margin)
|
||||
ax1.set_ylabel("Cumulative P&L ($)", fontsize=11, fontweight='bold')
|
||||
ax1.set_xticks(range(len(cum_with_zero)))
|
||||
ax1.set_xticklabels(["Start"] + labels)
|
||||
ax1.yaxis.set_major_formatter(mticker.FormatStrFormatter('$%.1f'))
|
||||
ax1.grid(True, alpha=0.3)
|
||||
ax1.set_title("Equity Curve", fontsize=13, fontweight='bold', color=TEXT, pad=10, loc='left')
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Per-trade P&L bars (bottom)
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
ax2 = fig.add_subplot(gs[2])
|
||||
|
||||
bar_colors = [GREEN if w else RED for w in won]
|
||||
bars = ax2.bar(x, pnls, color=bar_colors, width=0.6, edgecolor=[
|
||||
GREEN if w else RED for w in won
|
||||
], linewidth=0.8, alpha=0.85, zorder=3)
|
||||
|
||||
# Add glow effect
|
||||
for i, (bar, w) in enumerate(zip(bars, won)):
|
||||
c = GREEN if w else RED
|
||||
ax2.bar(i, pnls[i], color=c, width=0.7, alpha=0.15, zorder=2)
|
||||
|
||||
# Labels on bars
|
||||
for i, (pnl, w, tok, ct) in enumerate(zip(pnls, won, tokens, contracts)):
|
||||
sign = "+" if pnl >= 0 else ""
|
||||
y_off = pnl + (1.5 if pnl >= 0 else -2.5)
|
||||
ax2.text(i, y_off, f"{sign}${pnl:.2f}", fontsize=9, fontweight='bold',
|
||||
color=bar_colors[i], ha='center', va='bottom' if pnl >= 0 else 'top')
|
||||
# Token label below bar
|
||||
ax2.text(i, -0.5 if pnl >= 0 else 0.5,
|
||||
f"{tok}\n{ct}ct", fontsize=7, color=TEXT_DIM,
|
||||
ha='center', va='top' if pnl >= 0 else 'bottom')
|
||||
|
||||
ax2.axhline(y=0, color=TEXT_DIM, linewidth=0.8, linestyle='-', alpha=0.4, zorder=1)
|
||||
ax2.set_xlim(-0.7, len(trades) - 0.3)
|
||||
ax2.set_xticks(x)
|
||||
ax2.set_xticklabels(labels)
|
||||
ax2.yaxis.set_major_formatter(mticker.FormatStrFormatter('$%.0f'))
|
||||
ax2.grid(True, axis='y', alpha=0.3)
|
||||
ax2.set_ylabel("Trade P&L ($)", fontsize=11, fontweight='bold')
|
||||
ax2.set_title("Individual Trades", fontsize=13, fontweight='bold', color=TEXT, pad=10, loc='left')
|
||||
|
||||
# ─── Watermark ───────────────────────────────────────────────
|
||||
fig.text(0.95, 0.96, datetime.now().strftime("%Y-%m-%d %H:%M UTC"),
|
||||
fontsize=9, color=TEXT_DIM, ha='right', va='center')
|
||||
|
||||
# ─── Save ────────────────────────────────────────────────────
|
||||
out_path = Path(__file__).parent / "logs" / "pnl_chart.png"
|
||||
fig.savefig(out_path, dpi=180, facecolor=BG, bbox_inches='tight')
|
||||
plt.close()
|
||||
print(f"Chart saved: {out_path}")
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Quick diagnostics: proxy tunnel + RTDS / market WS connect test."""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(Path(__file__).parent / ".env")
|
||||
|
||||
from src.proxy_util import PROXY_URL, apply_proxy_env, ws_connect, ws_connect_kwargs
|
||||
|
||||
apply_proxy_env()
|
||||
|
||||
import websockets
|
||||
|
||||
print("=" * 70)
|
||||
print("DIAGNOSTIC: WebSocket proxy tunnel")
|
||||
print("=" * 70)
|
||||
print(f" websockets : {getattr(websockets, '__version__', '?')}")
|
||||
print(f" proxy : {PROXY_URL or '(none)'}")
|
||||
print()
|
||||
|
||||
|
||||
async def _probe(name: str, url: str, subscribe: dict | None = None) -> bool:
|
||||
print(f"[{name}] {url}")
|
||||
try:
|
||||
async with ws_connect(url, **ws_connect_kwargs(), open_timeout=15) as ws:
|
||||
print(f" connected")
|
||||
if subscribe is not None:
|
||||
await ws.send(json.dumps(subscribe))
|
||||
msg = await asyncio.wait_for(ws.recv(), timeout=15)
|
||||
print(f" first msg: {str(msg)[:120]!r}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f" FAIL {type(e).__name__}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
rtds_ok = await _probe(
|
||||
"RTDS",
|
||||
"wss://ws-live-data.polymarket.com",
|
||||
{
|
||||
"action": "subscribe",
|
||||
"subscriptions": [
|
||||
{"topic": "crypto_prices_chainlink", "type": "*", "filters": ""}
|
||||
],
|
||||
},
|
||||
)
|
||||
mkt_ok = await _probe(
|
||||
"MARKET",
|
||||
"wss://ws-subscriptions-clob.polymarket.com/ws/market",
|
||||
)
|
||||
print()
|
||||
if rtds_ok and mkt_ok:
|
||||
print("OK — both WebSockets work through proxy tunnel")
|
||||
return 0
|
||||
print("FAILED — check Clash HTTP port and .env HTTP_PROXY")
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(asyncio.run(main()))
|
||||
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"_docs": "Full parameter guide: see CONFIG.md",
|
||||
|
||||
"market": {
|
||||
"_comment": "5 or 15 — slug btc-updown-5m-<epoch> or btc-updown-15m-<epoch>",
|
||||
"interval_minutes": 5
|
||||
},
|
||||
|
||||
"strategy": {
|
||||
"min_price": 0.75,
|
||||
"max_price": 0.88,
|
||||
"_comment_5m": "Below min_elapsed / no_entry_cutoff are scaled for 5m (300s). For 15m, raise e.g. min_elapsed_sec 530, no_entry_before_end_sec 335.",
|
||||
"min_elapsed_sec": 180,
|
||||
"min_deviation_pct": 3,
|
||||
"max_deviation_pct": 100,
|
||||
"no_entry_before_end_sec": 110,
|
||||
"momentum_window_sec": 60,
|
||||
"vwap_window_sec": 30,
|
||||
"win_rate_csv": "data/win_rate.csv"
|
||||
},
|
||||
|
||||
"entry": {
|
||||
"bet_amount_usd": 1,
|
||||
"price_offset": 0.02,
|
||||
"order_type": "FAK",
|
||||
"max_retries": 3,
|
||||
"retry_delay_ms": 300,
|
||||
"fill_timeout_ms": 1000,
|
||||
"min_contracts": 5,
|
||||
"min_order_usd": 1,
|
||||
"max_entry_price": 0.88,
|
||||
"ws_recovery_timeout_sec": 10
|
||||
},
|
||||
|
||||
"hedge": {
|
||||
"enabled": true,
|
||||
"hedge_price": 0.02,
|
||||
"order_type": "GTD",
|
||||
"max_retries": 3,
|
||||
"retry_delay_ms": 1000
|
||||
},
|
||||
|
||||
"redeem": {
|
||||
"enabled": true,
|
||||
"interval_seconds": 180,
|
||||
"auto_confirm": true
|
||||
},
|
||||
|
||||
"simulation": {
|
||||
"_comment": "Paper trading: live market data, no CLOB orders or redeemer. API keys optional when enabled.",
|
||||
"enabled": true,
|
||||
"separate_trading_log": true,
|
||||
"trading_log_path": "logs/trading_log_sim.json",
|
||||
"history_csv_path": "logs/simulation_trades.csv",
|
||||
"history_jsonl_path": "logs/simulation_history.jsonl",
|
||||
"history_summary_path": "logs/simulation_summary.json"
|
||||
},
|
||||
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"chart_every_n_trades": 5
|
||||
},
|
||||
|
||||
"web_dashboard": {
|
||||
"_comment": "Open http://127.0.0.1:PORT/ (not https). If the page fails, avoid typing only localhost (IPv6). Use 0.0.0.0 to listen on all IPv4 interfaces.",
|
||||
"enabled": true,
|
||||
"host": "127.0.0.1",
|
||||
"port": 8765
|
||||
},
|
||||
|
||||
"logging": {
|
||||
"level": "INFO",
|
||||
"file_rotation_hours": 3
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Diagnostic script: list all active BTC binary markets on Polymarket Gamma API.
|
||||
Usage: python debug_list_markets.py
|
||||
"""
|
||||
import os
|
||||
import asyncio
|
||||
import json
|
||||
import aiohttp
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
def _normalize_proxy(raw: str) -> str:
|
||||
raw = (raw or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
if raw.endswith("/"):
|
||||
raw = raw[:-1]
|
||||
if raw.startswith(("http://", "https://", "socks://", "socks4://", "socks5://")):
|
||||
return raw
|
||||
return "http://" + raw
|
||||
|
||||
_PROXY_URL = _normalize_proxy(
|
||||
os.getenv("HTTPS_PROXY")
|
||||
or os.getenv("HTTP_PROXY")
|
||||
or os.getenv("http_proxy")
|
||||
or os.getenv("https_proxy")
|
||||
or ""
|
||||
)
|
||||
|
||||
GAMMA = "https://gamma-api.polymarket.com"
|
||||
|
||||
|
||||
async def main():
|
||||
print(f"[proxy] Using: {_PROXY_URL or 'NONE'}")
|
||||
|
||||
timeout = aiohttp.ClientTimeout(total=45, connect=15)
|
||||
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
|
||||
# Try 3 common queries to find BTC markets
|
||||
queries = [
|
||||
# Query 1: active markets with "btc" in slug
|
||||
{"slug": "btc", "active": "true", "closed": "false", "limit": 50},
|
||||
# Query 2: active markets with "Bitcoin" in question/title
|
||||
{"title": "Bitcoin", "active": "true", "closed": "false", "limit": 50},
|
||||
# Query 3: closed=false, order by newest, with tags
|
||||
{"closed": "false", "limit": 100, "order": "end_date", "ascending": "false"},
|
||||
]
|
||||
|
||||
seen = set()
|
||||
all_markets = []
|
||||
|
||||
for qi, params in enumerate(queries, 1):
|
||||
print(f"\n--- Query {qi}: {list(params.keys())} ---")
|
||||
try:
|
||||
async with session.get(
|
||||
f"{GAMMA}/markets",
|
||||
params=params,
|
||||
proxy=_PROXY_URL or None,
|
||||
) as resp:
|
||||
print(f"HTTP {resp.status}")
|
||||
if resp.status != 200:
|
||||
print(await resp.text())
|
||||
continue
|
||||
markets = await resp.json()
|
||||
print(f"Got {len(markets)} markets in response")
|
||||
except Exception as e:
|
||||
print(f"Request FAILED: {type(e).__name__}: {e}")
|
||||
continue
|
||||
|
||||
for m in markets:
|
||||
condition_id = m.get("conditionId") or m.get("id")
|
||||
if not condition_id or condition_id in seen:
|
||||
continue
|
||||
seen.add(condition_id)
|
||||
title = (m.get("question") or m.get("title") or "").strip()
|
||||
slug = (m.get("slug") or "").strip()
|
||||
# Only keep markets mentioning BTC/bitcoin
|
||||
text = (title + " " + slug).lower()
|
||||
if "btc" not in text and "bitcoin" not in text:
|
||||
continue
|
||||
end = m.get("endDate") or m.get("end_date_iso") or ""
|
||||
start = m.get("startDate") or m.get("start_date_iso") or ""
|
||||
active = m.get("active")
|
||||
closed = m.get("closed")
|
||||
neg_risk = m.get("negativeRisk")
|
||||
tokens = m.get("tokens") or []
|
||||
outcomes = [
|
||||
(t.get("outcome") or t.get("name") or "?")[:6] for t in tokens
|
||||
]
|
||||
all_markets.append({
|
||||
"id": condition_id[:12],
|
||||
"slug": slug[:80],
|
||||
"title": title[:120],
|
||||
"active": active,
|
||||
"closed": closed,
|
||||
"neg_risk": neg_risk,
|
||||
"start": str(start)[:25],
|
||||
"end": str(end)[:25],
|
||||
"outcomes": outcomes,
|
||||
})
|
||||
|
||||
print("\n" + "=" * 120)
|
||||
print(f"Found {len(all_markets)} BTC-related markets")
|
||||
print("=" * 120)
|
||||
for i, m in enumerate(all_markets, 1):
|
||||
print(f"\n#{i} id={m['id']} active={m['active']} closed={m['closed']} negRisk={m['neg_risk']}")
|
||||
print(f" slug: {m['slug']}")
|
||||
print(f" title: {m['title']}")
|
||||
print(f" outcomes: {m['outcomes']}")
|
||||
print(f" start: {m['start']}")
|
||||
print(f" end: {m['end']}")
|
||||
|
||||
# Save for later
|
||||
with open("_debug_btc_markets.json", "w", encoding="utf-8") as f:
|
||||
json.dump(all_markets, f, ensure_ascii=False, indent=2)
|
||||
print(f"\nSaved full list to _debug_btc_markets.json")
|
||||
|
||||
# Also dump slugs for regex analysis
|
||||
print("\n--- All slugs (for regex tuning) ---")
|
||||
for m in all_markets:
|
||||
print(m["slug"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,554 @@
|
||||
# BTC 15-Minute Polymarket Bot — Full Beginner Guide
|
||||
|
||||
**Suite:** [PolyBullLabs — polymakret-5min-15min-1hour-arbitrage-bot](https://github.com/PolyBullLabs/polymakret-5min-15min-1hour-arbitrage-bot) · **Telegram:** [@terauss](https://t.me/terauss) · **Parent overview:** [`../README.md`](../README.md)
|
||||
|
||||
This document walks you from **zero to running**, explains the **trading strategy** with **numbers**, and lists **every important parameter** with **examples**.
|
||||
Shorter references: [`CONFIG.md`](../CONFIG.md) (parameter list), [`PROJECT_LOGIC.md`](../PROJECT_LOGIC.md) (implementation detail).
|
||||
|
||||
---
|
||||
|
||||
## Table of contents
|
||||
|
||||
1. [What you are trading](#1-what-you-are-trading)
|
||||
2. [Trading strategy (logic + formulas)](#2-trading-strategy-logic--formulas)
|
||||
3. [Prerequisites checklist](#3-prerequisites-checklist)
|
||||
4. [Environment setup — Windows](#4-environment-setup--windows)
|
||||
5. [Environment setup — Linux / macOS](#5-environment-setup--linux--macos)
|
||||
6. [Get the project and install dependencies](#6-get-the-project-and-install-dependencies)
|
||||
7. [Configure `.env` (secrets)](#7-configure-env-secrets)
|
||||
8. [Configure `config.json` (strategy and execution)](#8-configure-configjson-strategy-and-execution)
|
||||
9. [Run the bot](#9-run-the-bot)
|
||||
10. [Optional: Telegram](#10-optional-telegram)
|
||||
11. [Optional: P&L chart](#11-optional-pnl-chart)
|
||||
12. [Logs and files](#12-logs-and-files)
|
||||
13. [Troubleshooting](#13-troubleshooting)
|
||||
14. [Risk summary](#14-risk-summary)
|
||||
|
||||
---
|
||||
|
||||
## 1. What you are trading
|
||||
|
||||
### 1.1 The market
|
||||
|
||||
Polymarket lists **15-minute** BTC markets (slug pattern like `btc-updown-15m-<timestamp>`). Each market has two outcome tokens:
|
||||
|
||||
| Token | Pays if |
|
||||
|--------|---------|
|
||||
| **UP** | BTC finishes the window **above** the reference (market rules on Polymarket define the exact oracle) |
|
||||
| **DOWN** | BTC finishes **below** |
|
||||
|
||||
In practice the bot reads **live token prices** from Polymarket (not a manual prediction). It buys the **favorite** — whichever side has the **higher** last traded price.
|
||||
|
||||
### 1.2 Payout math (simplified)
|
||||
|
||||
If you buy **N** contracts at price **P** (in dollars per contract, 0–1):
|
||||
|
||||
- **Cost** ≈ **N × P**
|
||||
- If your side **wins**, each contract is worth **$1** → payout **N × $1**
|
||||
- **Profit before fees** ≈ **N × (1 − P)** on a win; on a loss you lose the cost.
|
||||
|
||||
**Example (numbers only):**
|
||||
|
||||
- Buy **6** UP @ **$0.82** → cost **6 × 0.82 = $4.92**
|
||||
- If UP wins → value **6 × $1 = $6.00** → gross profit **$6.00 − $4.92 = $1.08**
|
||||
|
||||
The bot does **not** guarantee profit; it automates entries when **its rules** are satisfied.
|
||||
|
||||
---
|
||||
|
||||
## 2. Trading strategy (logic + formulas)
|
||||
|
||||
### 2.1 Favorite
|
||||
|
||||
The bot compares **UP** and **DOWN** `last_price` (from the market WebSocket). The **favorite** is the side with the **higher** price. All deviation and momentum calculations below use the **favorite’s** trade history and price.
|
||||
|
||||
### 2.2 VWAP (volume-weighted average price)
|
||||
|
||||
Over the last **`vwap_window_sec`** seconds (e.g. **30**), take all trades on that token, then:
|
||||
|
||||
\[
|
||||
\text{VWAP} = \frac{\sum (\text{price} \times \text{size})}{\sum \text{size}}
|
||||
\]
|
||||
|
||||
**Example**
|
||||
|
||||
| Time | Price | Size |
|
||||
|------|-------|------|
|
||||
| T1 | 0.78 | 10 |
|
||||
| T2 | 0.79 | 5 |
|
||||
|
||||
\[
|
||||
\text{VWAP} = \frac{0.78 \times 10 + 0.79 \times 5}{10 + 5} = \frac{11.75}{15} \approx 0.7833
|
||||
\]
|
||||
|
||||
### 2.3 Deviation (%)
|
||||
|
||||
Compare **last** traded price to VWAP:
|
||||
|
||||
\[
|
||||
\text{Deviation (\%)} = \frac{\text{last\_price} - \text{VWAP}}{\text{VWAP}} \times 100
|
||||
\]
|
||||
|
||||
**Example**
|
||||
|
||||
- Last price **0.82**, VWAP **0.78**
|
||||
- Deviation = \((0.82 - 0.78) / 0.78 × 100 ≈ 5.13\%\)
|
||||
|
||||
The bot requires deviation **strictly greater than** `min_deviation_pct` and **strictly less than** `max_deviation_pct` (see [§8.1](#81-strategy-block)).
|
||||
|
||||
### 2.4 Momentum (%)
|
||||
|
||||
Momentum uses a lookback of **`momentum_window_sec`** (e.g. **60**). The code takes trades whose timestamps fall in a **small band** around “now − 60s”, averages their prices, then compares **current** last price to that average:
|
||||
|
||||
\[
|
||||
\text{Momentum (\%)} = \frac{\text{last\_price} - \text{avg\_price\_ago}}{\text{avg\_price\_ago}} \times 100
|
||||
\]
|
||||
|
||||
If there are no trades in that window, momentum is **missing** (`None`) and the signal **cannot** fire.
|
||||
|
||||
**Important:** In code, momentum must be **> 5%** (not configurable in `config.json` today). So `momentum_window_sec` changes *how* momentum is measured, not the **5%** threshold.
|
||||
|
||||
**Example**
|
||||
|
||||
- Average price ~60s ago: **0.77**
|
||||
- Current last price: **0.82**
|
||||
- Momentum = \((0.82 - 0.77) / 0.77 × 100 ≈ 6.5\%\) → **passes** the > 5% rule
|
||||
|
||||
### 2.5 Time window for entries (15 minutes = 900 seconds)
|
||||
|
||||
Each market lasts **900 seconds** from start to end.
|
||||
|
||||
- `min_elapsed_sec` — do **not** enter until at least this many seconds **after** the market started.
|
||||
Elapsed = **900 − time_left** (seconds).
|
||||
|
||||
- `no_entry_before_end_sec` — do **not** enter if **time_left** ≤ this value (too close to expiry).
|
||||
|
||||
**Worked example** (matches `CONFIG.md`):
|
||||
|
||||
- `min_elapsed_sec = 530` → need elapsed **≥ 530**
|
||||
- `no_entry_before_end_sec = 335` → need time_left **> 335** → elapsed **< 565**
|
||||
|
||||
So entries are only possible when **530 ≤ elapsed < 565** → about **35 seconds** per market (if all other filters pass).
|
||||
|
||||
| Variable | Value |
|
||||
|----------|--------|
|
||||
| `min_elapsed_sec` | 530 |
|
||||
| `no_entry_before_end_sec` | 335 |
|
||||
| Allowed elapsed | 530 … 564 |
|
||||
| Allowed time_left | 336 … 370 |
|
||||
|
||||
If you widen the window (e.g. lower `min_elapsed_sec` or raise `no_entry_before_end_sec`), you get **more** opportunities and usually **more** risk.
|
||||
|
||||
### 2.6 Win rate table (`data/win_rate.csv`)
|
||||
|
||||
Rows are **price bands** (e.g. `0.75-0.79`), columns are **minutes** (`min_0` … `min_14`). The dashboard uses this to **display** a historical win rate for the current favorite price and time bin. It does **not** by itself block a trade in the main signal logic (the hard filters are price, time, deviation, momentum).
|
||||
|
||||
### 2.7 Entry checklist (all must pass)
|
||||
|
||||
| # | Rule | Typical config |
|
||||
|---|------|----------------|
|
||||
| 1 | Favorite price in `[min_price, max_price]` | e.g. 0.75–0.88 |
|
||||
| 2 | `elapsed_sec ≥ min_elapsed_sec` | e.g. ≥ 530 |
|
||||
| 3 | `min_deviation_pct < deviation < max_deviation_pct` | e.g. 3% < dev < 100% |
|
||||
| 4 | Momentum **not** `None` and **> 5%** | fixed in code |
|
||||
| 5 | `time_left > no_entry_before_end_sec` | e.g. > 335 |
|
||||
|
||||
### 2.8 After a buy
|
||||
|
||||
1. **FAK** order: buy up to your size; unfilled part is cancelled.
|
||||
2. Optional **hedge** (if enabled): **GTD** limit on the **opposite** token at `hedge_price` (often **0.02**).
|
||||
3. Near market end, the bot **closes** the internal position for P&L tracking using last prices.
|
||||
4. **Auto-redeem** (if enabled) periodically redeems winning positions on Polygon.
|
||||
|
||||
---
|
||||
|
||||
## 3. Prerequisites checklist
|
||||
|
||||
| Item | Why |
|
||||
|------|-----|
|
||||
| **Python 3.11+** (3.12 is fine) | Runs the bot |
|
||||
| **pip / venv** | Install packages in isolation |
|
||||
| **Polymarket account + USDC on Polygon** | Trading collateral |
|
||||
| **Small amount of POL (MATIC)** | Gas for on-chain redemptions (if you use auto-redeem) |
|
||||
| **CLOB API credentials** | key, secret, passphrase from Polymarket |
|
||||
| **Wallet private key** (`0x…`) | Signs orders and redeem txs; **never share** |
|
||||
|
||||
---
|
||||
|
||||
## 4. Environment setup — Windows
|
||||
|
||||
### 4.1 Install Python
|
||||
|
||||
1. Download the installer from [https://www.python.org/downloads/](https://www.python.org/downloads/) (Windows 64-bit).
|
||||
2. Run it. **Enable “Add Python to PATH”** (important).
|
||||
3. Close and reopen **PowerShell** or **Command Prompt**.
|
||||
|
||||
### 4.2 Verify
|
||||
|
||||
```powershell
|
||||
python --version
|
||||
pip --version
|
||||
```
|
||||
|
||||
You should see Python 3.11+ and pip. If `python` is not found, try `py` (Windows launcher):
|
||||
|
||||
```powershell
|
||||
py --version
|
||||
```
|
||||
|
||||
### 4.3 Git Bash / `sudo` / `apt`
|
||||
|
||||
This project is **not** installed with `sudo apt` on Windows. Use **Python for Windows** or **WSL** (Ubuntu) if you want Linux-style commands.
|
||||
|
||||
### 4.4 Execution policy (PowerShell venv)
|
||||
|
||||
If activation fails with “running scripts is disabled”:
|
||||
|
||||
```powershell
|
||||
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
||||
```
|
||||
|
||||
Then try `.\venv\Scripts\Activate.ps1` again.
|
||||
|
||||
---
|
||||
|
||||
## 5. Environment setup — Linux / macOS
|
||||
|
||||
### 5.1 Linux (Debian/Ubuntu example)
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y python3 python3-pip python3-venv git
|
||||
python3 --version
|
||||
```
|
||||
|
||||
### 5.2 macOS
|
||||
|
||||
Install Python 3 from [python.org](https://www.python.org/downloads/) or `brew install python`. Then:
|
||||
|
||||
```bash
|
||||
python3 --version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Get the project and install dependencies
|
||||
|
||||
### 6.1 Go to the project folder
|
||||
|
||||
If you already have the folder (`btc-binary-VWAP-Momentum-bot`), **cd** into it:
|
||||
|
||||
```bash
|
||||
cd "path/to/polymakret-5min-15min-1hour-arbitrage-bot/btc-binary-VWAP-Momentum-bot"
|
||||
```
|
||||
|
||||
If you clone from git:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/PolyBullLabs/polymakret-5min-15min-1hour-arbitrage-bot.git
|
||||
cd polymakret-5min-15min-1hour-arbitrage-bot/btc-binary-VWAP-Momentum-bot
|
||||
```
|
||||
|
||||
### 6.2 Create and activate a virtual environment
|
||||
|
||||
**Windows (PowerShell)**
|
||||
|
||||
```powershell
|
||||
python -m venv venv
|
||||
.\venv\Scripts\Activate.ps1
|
||||
```
|
||||
|
||||
**Linux / macOS**
|
||||
|
||||
```bash
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
```
|
||||
|
||||
Your prompt should show `(venv)`.
|
||||
|
||||
### 6.3 Install Python packages
|
||||
|
||||
```bash
|
||||
pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Wait until it finishes without errors.
|
||||
|
||||
### 6.4 Quick sanity check
|
||||
|
||||
```bash
|
||||
python -c "import rich, aiohttp, websockets; print('OK')"
|
||||
```
|
||||
|
||||
If you see `OK`, dependencies are installed.
|
||||
|
||||
---
|
||||
|
||||
## 7. Configure `.env` (secrets)
|
||||
|
||||
### 7.1 Create `.env` from the example
|
||||
|
||||
**Windows**
|
||||
|
||||
```powershell
|
||||
copy .env.example .env
|
||||
```
|
||||
|
||||
**Linux / macOS**
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
### 7.2 Fill each variable
|
||||
|
||||
| Variable | Required | Example / notes |
|
||||
|----------|----------|-----------------|
|
||||
| `PRIVATE_KEY` | **Yes** | `0x` + 64 hex chars. **Never** commit or share. |
|
||||
| `SIGNATURE_TYPE` | **Yes** | `0` = EOA (normal wallet). `1` or `2` = proxy / magic — see Polymarket docs. |
|
||||
| `FUNDER_ADDRESS` | If proxy | Your Polymarket proxy wallet address when `SIGNATURE_TYPE` is 1 or 2. |
|
||||
| `POLY_API_KEY` | **Yes** | From CLOB API. |
|
||||
| `POLY_API_SECRET` | **Yes** | From CLOB API. |
|
||||
| `POLY_API_PASSPHRASE` | **Yes** | From CLOB API. |
|
||||
| `RPC_URL` | Optional | Default `https://polygon-rpc.com`, Alchemy/Infura recommended for production. |
|
||||
| `CHAIN_ID` | Optional | `137` for Polygon mainnet. |
|
||||
| `CLOB_HOST` | Optional | Usually `https://clob.polymarket.com`. |
|
||||
| `TELEGRAM_BOT_TOKEN` | Optional | From @BotFather. |
|
||||
| `TELEGRAM_CHAT_ID` | Optional | Your numeric chat id (e.g. from @userinfobot). |
|
||||
|
||||
### 7.3 Where to get API keys
|
||||
|
||||
- Log in to Polymarket, open the **CLOB API** / developer settings, and create **API credentials** (key, secret, passphrase).
|
||||
- Official URL referenced in the repo: [https://clob.polymarket.com](https://clob.polymarket.com)
|
||||
|
||||
### 7.4 Example `.env` shape (fake values)
|
||||
|
||||
```env
|
||||
PRIVATE_KEY=0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
SIGNATURE_TYPE=0
|
||||
FUNDER_ADDRESS=
|
||||
|
||||
POLY_API_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
POLY_API_SECRET=your_secret_here
|
||||
POLY_API_PASSPHRASE=your_passphrase_here
|
||||
|
||||
RPC_URL=https://polygon-rpc.com
|
||||
CHAIN_ID=137
|
||||
CLOB_HOST=https://clob.polymarket.com
|
||||
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
TELEGRAM_CHAT_ID=
|
||||
```
|
||||
|
||||
Save the file. **Confirm `.env` is gitignored** (do not commit).
|
||||
|
||||
---
|
||||
|
||||
## 8. Configure `config.json` (strategy and execution)
|
||||
|
||||
Edit **`config.json`** in the project root. Below: **what each block does**, **recommended ranges**, and **numeric examples**.
|
||||
|
||||
### 8.1 `strategy` block
|
||||
|
||||
| Parameter | Meaning | Example |
|
||||
|-----------|---------|---------|
|
||||
| `min_price` | Minimum favorite token price to allow entry | `0.75` — ignore favorites below $0.75 |
|
||||
| `max_price` | Maximum favorite token price | `0.88` — do not buy above $0.88 |
|
||||
| `min_elapsed_sec` | Seconds after market open before entry | `530` — wait ~8.8 min |
|
||||
| `min_deviation_pct` | Deviation must be **>** this | `3` — need more than 3% above VWAP |
|
||||
| `max_deviation_pct` | Deviation must be **<** this | `100` — effectively no upper cap |
|
||||
| `no_entry_before_end_sec` | Stop entering if `time_left ≤` this | `335` — no new entries in last ~5.6 min |
|
||||
| `momentum_window_sec` | Seconds of history for momentum | `60` — compare to ~1 minute ago |
|
||||
| `vwap_window_sec` | Seconds of trades for VWAP | `30` — short-term average |
|
||||
| `win_rate_csv` | Path to CSV for dashboard win rate | `"data/win_rate.csv"` |
|
||||
|
||||
**Deviation example**
|
||||
|
||||
- VWAP (30s) = **0.78**, last = **0.80** → deviation ≈ **2.56%** → fails if `min_deviation_pct` is **3**
|
||||
- Last = **0.81** → deviation ≈ **3.85%** → passes if `min_deviation_pct` is **3**
|
||||
|
||||
### 8.2 `entry` block
|
||||
|
||||
| Parameter | Meaning | Example |
|
||||
|-----------|---------|---------|
|
||||
| `bet_amount_usd` | Target spend per entry (subject to sizing rules) | `5` → roughly $5 notional |
|
||||
| `price_offset` | Added to price when placing FAK (more aggressive fill) | `0.02` → pay up to +$0.02 vs reference |
|
||||
| `order_type` | Entry type | `"FAK"` (fill and kill) |
|
||||
| `max_retries` | Retries if order does not complete as expected | `3` |
|
||||
| `retry_delay_ms` | Pause between retries | `300` |
|
||||
| `fill_timeout_ms` | Used in executor / fill logic | `1000` |
|
||||
| `min_contracts` | Polymarket minimum is often 5 | `5` |
|
||||
| `min_order_usd` | Minimum order size in USD | `1` |
|
||||
| `max_entry_price` | Hard cap on execution price | `0.88` — should align with `strategy.max_price` |
|
||||
| `ws_recovery_timeout_sec` | After HTTP timeout, how long to watch User WS for fills | `10` |
|
||||
|
||||
**Sizing example**
|
||||
|
||||
- `bet_amount_usd = 5`, best ask ≈ **0.80** → rough contracts = floor(5 / 0.80) = **6** (subject to mins and API).
|
||||
|
||||
### 8.3 `hedge` block
|
||||
|
||||
| Parameter | Meaning | Example |
|
||||
|-----------|---------|---------|
|
||||
| `enabled` | `true` / `false` | `false` for beginners |
|
||||
| `hedge_price` | Limit price for opposite token | `0.02` |
|
||||
| `order_type` | Usually `"GTD"` | passive limit |
|
||||
| `max_retries` | Placement retries | `3` |
|
||||
| `retry_delay_ms` | Delay between retries | `1000` |
|
||||
|
||||
**Hedge intuition (not financial advice)**
|
||||
After a long on UP, a **cheap** limit order on DOWN can act as a partial hedge if the market moves so that DOWN trades near your limit. **Costs and risks** are real; start with `enabled: false` until you understand fills.
|
||||
|
||||
### 8.4 `redeem` block
|
||||
|
||||
| Parameter | Meaning | Example |
|
||||
|-----------|---------|---------|
|
||||
| `enabled` | Run periodic on-chain redemption | `true` |
|
||||
| `interval_seconds` | Seconds between scans | `180` |
|
||||
| `auto_confirm` | Confirm in code path | `true` |
|
||||
|
||||
**Note:** On **Windows**, some Unix-only locking in redeem may fail; **Linux** or **WSL** is safer for production.
|
||||
|
||||
### 8.5 `telegram` block
|
||||
|
||||
| Parameter | Meaning |
|
||||
|-----------|---------|
|
||||
| `enabled` | `true` to send Telegram messages |
|
||||
| `chart_every_n_trades` | Intended for periodic equity charts (see `TelegramNotifier.send_equity_chart`); **may not be wired** in `main.py` in all versions — check the code if you rely on auto-charts |
|
||||
|
||||
Tokens and chat id still come from **`.env`**.
|
||||
|
||||
### 8.6 `logging` block in `config.json`
|
||||
|
||||
The repo may include a `logging` section for documentation. **Current `main.py` sets logging in code** (e.g. `logs/bot.log`, `INFO` level). Do not assume `config.json` logging keys change behavior unless you wire them in code.
|
||||
|
||||
### 8.7 Preset ideas (copy-paste starting points)
|
||||
|
||||
**Conservative (fewer trades, tighter band)**
|
||||
|
||||
```json
|
||||
"strategy": {
|
||||
"min_price": 0.80,
|
||||
"max_price": 0.85,
|
||||
"min_elapsed_sec": 600,
|
||||
"min_deviation_pct": 5,
|
||||
"max_deviation_pct": 100,
|
||||
"no_entry_before_end_sec": 300,
|
||||
"momentum_window_sec": 60,
|
||||
"vwap_window_sec": 30,
|
||||
"win_rate_csv": "data/win_rate.csv"
|
||||
},
|
||||
"entry": { "bet_amount_usd": 2 },
|
||||
"hedge": { "enabled": false }
|
||||
```
|
||||
|
||||
**Aggressive (more trades — higher risk)**
|
||||
|
||||
```json
|
||||
"strategy": {
|
||||
"min_price": 0.70,
|
||||
"max_price": 0.90,
|
||||
"min_elapsed_sec": 400,
|
||||
"min_deviation_pct": 0,
|
||||
"max_deviation_pct": 100,
|
||||
"no_entry_before_end_sec": 120,
|
||||
"momentum_window_sec": 60,
|
||||
"vwap_window_sec": 30,
|
||||
"win_rate_csv": "data/win_rate.csv"
|
||||
},
|
||||
"entry": { "bet_amount_usd": 5 },
|
||||
"hedge": { "enabled": false }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Run the bot
|
||||
|
||||
1. Activate **venv** (see [§6.2](#62-create-and-activate-a-virtual-environment)).
|
||||
2. Ensure `.env` and `config.json` are saved.
|
||||
3. From the **project root** (folder containing `main.py`):
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
### 9.1 What you should see
|
||||
|
||||
- Startup messages (config summary, CLOB init).
|
||||
- A **live Rich dashboard**: timer, UP/DOWN token panels, indicators, **Strategy** line, P&L.
|
||||
- When a **BUY UP** / **BUY DOWN** signal is valid, the bot fires an entry (real money if your keys are live).
|
||||
|
||||
### 9.2 Stop the bot
|
||||
|
||||
Press **Ctrl+C** in the terminal. On Windows, Unix signal handlers may be limited; **Ctrl+C** still stops the process.
|
||||
|
||||
### 9.3 First-time recommendation
|
||||
|
||||
- Set **`bet_amount_usd`** small.
|
||||
- Set **`hedge.enabled`** to **`false`** until you understand behavior.
|
||||
- Watch **`logs/`** while the market runs.
|
||||
|
||||
---
|
||||
|
||||
## 10. Optional: Telegram
|
||||
|
||||
1. **@BotFather** → `/newbot` → copy **token** → `TELEGRAM_BOT_TOKEN` in `.env`.
|
||||
2. **@userinfobot** → `/start` → copy **Id** → `TELEGRAM_CHAT_ID`.
|
||||
3. Open your bot in Telegram and tap **Start** (required).
|
||||
4. In `config.json`, set `"telegram": { "enabled": true, ... }`.
|
||||
|
||||
---
|
||||
|
||||
## 11. Optional: P&L chart
|
||||
|
||||
After you have trades in **`logs/trading_log.json`**:
|
||||
|
||||
```bash
|
||||
python chart_pnl.py
|
||||
```
|
||||
|
||||
Output image: **`logs/pnl_chart.png`** (see `chart_pnl.py`).
|
||||
|
||||
---
|
||||
|
||||
## 12. Logs and files
|
||||
|
||||
| File / folder | Content |
|
||||
|----------------|---------|
|
||||
| `logs/bot.log` | General bot log |
|
||||
| `logs/orders.log` | Order execution detail |
|
||||
| `logs/hedges.log` | Hedge-related logs |
|
||||
| `logs/signals.log` | Signal snapshots |
|
||||
| `logs/trading_log.json` | Persisted trades + stats |
|
||||
| `logs/pnl_chart.png` | Generated by `chart_pnl.py` |
|
||||
|
||||
---
|
||||
|
||||
## 13. Troubleshooting
|
||||
|
||||
| Problem | What to try |
|
||||
|--------|-------------|
|
||||
| `python` not found (Windows) | Reinstall Python with **Add to PATH**, or use `py -m venv venv` |
|
||||
| `NotImplementedError` on `add_signal_handler` | Already fixed on Windows in `main.py` — use latest code |
|
||||
| Config errors on startup | Read the printed message; usually missing `PRIVATE_KEY` or API fields |
|
||||
| `python` works but imports fail | Activate **venv** and run `pip install -r requirements.txt` again |
|
||||
| No trades for a long time | Strategy window is narrow (see [§2.5](#25-time-window-for-entries-15-minutes--900-seconds)); or market never satisfies all filters |
|
||||
| Redeem errors on Windows | Prefer **WSL** or **Linux** for auto-redeem; or disable `redeem.enabled` and redeem manually on Polymarket |
|
||||
| Telegram not sending | Bot token + chat id + user pressed **Start** on bot; `enabled: true` |
|
||||
|
||||
---
|
||||
|
||||
## 14. Risk summary
|
||||
|
||||
- **Real money** — you can lose your stake.
|
||||
- **No strategy edge is guaranteed** — this bot automates rules.
|
||||
- **Fees, slippage, and failed orders** happen.
|
||||
- **Protect your private key** — treat `.env` like a password.
|
||||
|
||||
For **multi-asset late-entry** trading, see **Meridian** (`up-down-spread-bot`) in the same repository. For **PTB / oracle-diff** rules and a web dashboard, see `5min-15min-PTB-bot`. Extended **quant** offerings (Kelly, Monte Carlo, advanced TA, sizing systems) are described in the [repository root README](https://github.com/PolyBullLabs/polymakret-5min-15min-1hour-arbitrage-bot) — contact [@terauss](https://t.me/terauss).
|
||||
|
||||
---
|
||||
|
||||
*For a single-page parameter list, see [`CONFIG.md`](../CONFIG.md). For internals, see [`PROJECT_LOGIC.md`](../PROJECT_LOGIC.md).*
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
# BTC 15-min Live Trading Bot - Dependencies
|
||||
|
||||
# Polymarket SDK
|
||||
py-clob-client>=0.16.0
|
||||
|
||||
# Web3 for blockchain interactions
|
||||
web3>=6.0.0
|
||||
eth-account>=0.10.0
|
||||
|
||||
# Async HTTP/WebSocket
|
||||
aiohttp>=3.9.0
|
||||
# websockets 13.x has NO native proxy= support; bot uses HTTP CONNECT tunnel
|
||||
# in src/proxy_util.py (sock=). Do not pass proxy=/trust_env= to connect().
|
||||
websockets>=13.0
|
||||
|
||||
# Telegram
|
||||
python-telegram-bot>=20.0
|
||||
|
||||
# Data processing
|
||||
pandas>=2.0.0
|
||||
numpy>=1.24.0
|
||||
|
||||
# Visualization (for equity charts)
|
||||
matplotlib>=3.8.0
|
||||
|
||||
# Terminal UI (main.py dashboard)
|
||||
rich>=13.0.0
|
||||
|
||||
# Config
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
# Web dashboard (optional; enable in config.json web_dashboard.enabled)
|
||||
fastapi>=0.115.0
|
||||
uvicorn[standard]>=0.32.0
|
||||
|
||||
# Misc
|
||||
requests>=2.31.0
|
||||
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
BTC 15-min Live Trading Bot
|
||||
|
||||
Modules:
|
||||
- market_finder: Find active BTC 15-min markets
|
||||
- signal_generator: Generate entry signals (VWAP, Deviation, WinRate)
|
||||
- order_executor: Execute FAK orders with retry logic
|
||||
- hedge_manager: Hedge positions at 0.99
|
||||
- position_tracker: Track positions and P&L
|
||||
- auto_redeemer: Automatic redemption every 3 minutes
|
||||
- websocket_client: Market + User WebSocket channels
|
||||
- telegram_notifier: Telegram notifications + charts
|
||||
- config_loader: Load configuration
|
||||
"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
@@ -0,0 +1,623 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Async Auto-Redeemer
|
||||
|
||||
Runs every 3 minutes in background, checks for redeemable positions
|
||||
and automatically redeems them.
|
||||
|
||||
Fully async - does not block main event loop.
|
||||
"""
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
from typing import Optional, Tuple, List, Dict, Any
|
||||
|
||||
import aiohttp
|
||||
from web3 import Web3
|
||||
from eth_account import Account
|
||||
|
||||
from src.proxy_util import apply_proxy_env, aiohttp_proxy
|
||||
|
||||
apply_proxy_env()
|
||||
_PROXY_URL = aiohttp_proxy() or ""
|
||||
|
||||
logger = logging.getLogger("btc_live.redeemer")
|
||||
|
||||
# Dedicated thread pool for web3 operations to avoid blocking main thread pool
|
||||
_WEB3_EXECUTOR = ThreadPoolExecutor(max_workers=2, thread_name_prefix="web3_redeemer")
|
||||
|
||||
# Contract addresses
|
||||
USDC_ADDRESS = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
|
||||
CTF_ADDRESS = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"
|
||||
NEG_RISK_ADAPTER = "0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296"
|
||||
|
||||
DATA_API = "https://data-api.polymarket.com"
|
||||
|
||||
CTF_ABI = json.loads('''[
|
||||
{
|
||||
"inputs": [
|
||||
{"internalType": "address", "name": "account", "type": "address"},
|
||||
{"internalType": "uint256", "name": "id", "type": "uint256"}
|
||||
],
|
||||
"name": "balanceOf",
|
||||
"outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}],
|
||||
"stateMutability": "view",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"inputs": [
|
||||
{"internalType": "bytes32", "name": "conditionId", "type": "bytes32"}
|
||||
],
|
||||
"name": "payoutDenominator",
|
||||
"outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}],
|
||||
"stateMutability": "view",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"inputs": [
|
||||
{"internalType": "address", "name": "collateralToken", "type": "address"},
|
||||
{"internalType": "bytes32", "name": "parentCollectionId", "type": "bytes32"},
|
||||
{"internalType": "bytes32", "name": "conditionId", "type": "bytes32"},
|
||||
{"internalType": "uint256[]", "name": "indexSets", "type": "uint256[]"}
|
||||
],
|
||||
"name": "redeemPositions",
|
||||
"outputs": [],
|
||||
"stateMutability": "nonpayable",
|
||||
"type": "function"
|
||||
}
|
||||
]''')
|
||||
|
||||
NEG_RISK_ABI = json.loads('''[
|
||||
{
|
||||
"inputs": [
|
||||
{"internalType": "bytes32", "name": "conditionId", "type": "bytes32"},
|
||||
{"internalType": "uint256[]", "name": "amounts", "type": "uint256[]"}
|
||||
],
|
||||
"name": "redeemPositions",
|
||||
"outputs": [],
|
||||
"stateMutability": "nonpayable",
|
||||
"type": "function"
|
||||
}
|
||||
]''')
|
||||
|
||||
GNOSIS_SAFE_ABI = json.loads('''[
|
||||
{
|
||||
"inputs": [
|
||||
{"name": "to", "type": "address"},
|
||||
{"name": "value", "type": "uint256"},
|
||||
{"name": "data", "type": "bytes"},
|
||||
{"name": "operation", "type": "uint8"},
|
||||
{"name": "safeTxGas", "type": "uint256"},
|
||||
{"name": "baseGas", "type": "uint256"},
|
||||
{"name": "gasPrice", "type": "uint256"},
|
||||
{"name": "gasToken", "type": "address"},
|
||||
{"name": "refundReceiver", "type": "address"},
|
||||
{"name": "signatures", "type": "bytes"}
|
||||
],
|
||||
"name": "execTransaction",
|
||||
"outputs": [{"name": "success", "type": "bool"}],
|
||||
"stateMutability": "payable",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"inputs": [],
|
||||
"name": "nonce",
|
||||
"outputs": [{"name": "", "type": "uint256"}],
|
||||
"stateMutability": "view",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"inputs": [
|
||||
{"name": "to", "type": "address"},
|
||||
{"name": "value", "type": "uint256"},
|
||||
{"name": "data", "type": "bytes"},
|
||||
{"name": "operation", "type": "uint8"},
|
||||
{"name": "safeTxGas", "type": "uint256"},
|
||||
{"name": "baseGas", "type": "uint256"},
|
||||
{"name": "gasPrice", "type": "uint256"},
|
||||
{"name": "gasToken", "type": "address"},
|
||||
{"name": "refundReceiver", "type": "address"},
|
||||
{"name": "_nonce", "type": "uint256"}
|
||||
],
|
||||
"name": "getTransactionHash",
|
||||
"outputs": [{"name": "", "type": "bytes32"}],
|
||||
"stateMutability": "view",
|
||||
"type": "function"
|
||||
}
|
||||
]''')
|
||||
|
||||
|
||||
class AsyncAutoRedeemer:
|
||||
"""
|
||||
Async auto-redeemer that runs in background every N minutes.
|
||||
|
||||
Features:
|
||||
- Fully async (aiohttp for API, asyncio.to_thread for web3)
|
||||
- File lock to prevent concurrent redemptions
|
||||
- Supports both EOA and Proxy (Gnosis Safe) wallets
|
||||
- Telegram notifications on successful redeem
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
private_key: str,
|
||||
rpc_url: str,
|
||||
funder_address: Optional[str] = None,
|
||||
signature_type: int = 0,
|
||||
interval_seconds: int = 180, # 3 minutes
|
||||
telegram_notifier: Optional[Any] = None
|
||||
):
|
||||
self.private_key = private_key
|
||||
self.rpc_url = rpc_url
|
||||
self.funder_address = funder_address
|
||||
self.signature_type = signature_type
|
||||
self.interval = interval_seconds
|
||||
self.telegram = telegram_notifier
|
||||
|
||||
# Web3 setup
|
||||
self.w3 = Web3(Web3.HTTPProvider(rpc_url))
|
||||
|
||||
# Add POA middleware for Polygon
|
||||
from web3.middleware import ExtraDataToPOAMiddleware
|
||||
self.w3.middleware_onion.inject(ExtraDataToPOAMiddleware, layer=0)
|
||||
|
||||
# Account
|
||||
self.account = Account.from_key(private_key)
|
||||
self.signer_address = self.account.address
|
||||
|
||||
# Wallet to check for positions
|
||||
if signature_type in [1, 2] and funder_address:
|
||||
self.wallet_address = funder_address
|
||||
else:
|
||||
self.wallet_address = self.signer_address
|
||||
|
||||
# Contracts
|
||||
self.ctf = self.w3.eth.contract(
|
||||
address=Web3.to_checksum_address(CTF_ADDRESS),
|
||||
abi=CTF_ABI
|
||||
)
|
||||
|
||||
# Stats
|
||||
self.total_redeemed = 0
|
||||
self.total_value = 0.0
|
||||
self._running = False
|
||||
self._lock_fd = None
|
||||
|
||||
# Semaphore to limit concurrent web3 operations (prevents thread pool saturation)
|
||||
self._redeem_semaphore = asyncio.Semaphore(1) # Only 1 redemption at a time
|
||||
|
||||
async def _fetch_positions(self) -> Tuple[List[Dict], List[Dict], List[Dict]]:
|
||||
"""Fetch all positions from Polymarket Data API (async)."""
|
||||
active = []
|
||||
pending = []
|
||||
redeemable = []
|
||||
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=30, connect=10)
|
||||
async with aiohttp.ClientSession(
|
||||
timeout=timeout, trust_env=True
|
||||
) as session:
|
||||
url = f"{DATA_API}/positions"
|
||||
params = {
|
||||
"user": self.wallet_address,
|
||||
"limit": 500,
|
||||
"sizeThreshold": 0.01
|
||||
}
|
||||
|
||||
async with session.get(
|
||||
url, params=params, proxy=_PROXY_URL or None
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
logger.error(f"Data API returned {resp.status}")
|
||||
return active, pending, redeemable
|
||||
|
||||
positions = await resp.json()
|
||||
|
||||
if not positions:
|
||||
return active, pending, redeemable
|
||||
|
||||
# Group by conditionId
|
||||
positions_by_condition = {}
|
||||
for pos in positions:
|
||||
condition_id = pos.get("conditionId")
|
||||
if not condition_id:
|
||||
continue
|
||||
|
||||
if condition_id not in positions_by_condition:
|
||||
positions_by_condition[condition_id] = {
|
||||
"slug": pos.get("slug", "unknown"),
|
||||
"title": pos.get("title", "Unknown Market"),
|
||||
"condition_id": condition_id,
|
||||
"neg_risk": pos.get("negativeRisk", False),
|
||||
"end_date": pos.get("endDate"),
|
||||
"redeemable": pos.get("redeemable", False),
|
||||
"outcomes": {}
|
||||
}
|
||||
|
||||
outcome = pos.get("outcome", "")
|
||||
positions_by_condition[condition_id]["outcomes"][outcome] = {
|
||||
"asset": pos.get("asset"),
|
||||
"size": int(float(pos.get("size", 0)) * 1e6),
|
||||
"cur_price": pos.get("curPrice", 0),
|
||||
}
|
||||
|
||||
# Categorize
|
||||
import time
|
||||
now = int(time.time())
|
||||
|
||||
for condition_id, pos_data in positions_by_condition.items():
|
||||
outcomes = pos_data["outcomes"]
|
||||
|
||||
up_data = outcomes.get("Up") or outcomes.get("YES") or outcomes.get("Higher")
|
||||
down_data = outcomes.get("Down") or outcomes.get("NO") or outcomes.get("Lower")
|
||||
|
||||
if not up_data and not down_data:
|
||||
outcome_list = list(outcomes.values())
|
||||
up_data = outcome_list[0] if len(outcome_list) > 0 else None
|
||||
down_data = outcome_list[1] if len(outcome_list) > 1 else None
|
||||
|
||||
up_balance = up_data.get("size", 0) if up_data else 0
|
||||
down_balance = down_data.get("size", 0) if down_data else 0
|
||||
|
||||
if up_balance == 0 and down_balance == 0:
|
||||
continue
|
||||
|
||||
position_data = {
|
||||
"slug": pos_data["slug"],
|
||||
"title": pos_data["title"],
|
||||
"condition_id": condition_id,
|
||||
"up_token_id": up_data.get("asset") if up_data else None,
|
||||
"down_token_id": down_data.get("asset") if down_data else None,
|
||||
"up_balance": up_balance,
|
||||
"down_balance": down_balance,
|
||||
"neg_risk": pos_data["neg_risk"],
|
||||
}
|
||||
|
||||
end_date = pos_data.get("end_date")
|
||||
is_closed = False
|
||||
if end_date:
|
||||
try:
|
||||
end_timestamp = datetime.fromisoformat(
|
||||
end_date.replace('Z', '+00:00')
|
||||
).timestamp()
|
||||
is_closed = now >= end_timestamp
|
||||
except:
|
||||
pass
|
||||
|
||||
if pos_data["redeemable"]:
|
||||
redeemable.append(position_data)
|
||||
elif is_closed:
|
||||
pending.append(position_data)
|
||||
else:
|
||||
active.append(position_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching positions: {e}")
|
||||
|
||||
return active, pending, redeemable
|
||||
|
||||
def _check_oracle_resolution(self, condition_id: str) -> bool:
|
||||
"""Check if oracle has resolved (sync, runs in thread)."""
|
||||
try:
|
||||
condition_bytes = Web3.to_bytes(hexstr=condition_id)
|
||||
payout_denom = self.ctf.functions.payoutDenominator(condition_bytes).call()
|
||||
return payout_denom > 0
|
||||
except Exception as e:
|
||||
logger.error(f"Oracle check error: {e}")
|
||||
return False
|
||||
|
||||
def _redeem_position_sync(self, position: Dict) -> bool:
|
||||
"""Redeem a single position (sync, runs in thread)."""
|
||||
import fcntl
|
||||
import time
|
||||
|
||||
condition_id = position["condition_id"]
|
||||
up_balance = position["up_balance"]
|
||||
down_balance = position["down_balance"]
|
||||
is_neg_risk = position.get("neg_risk", False)
|
||||
|
||||
logger.info(f"Redeeming: {position['slug']}")
|
||||
|
||||
# Check oracle first
|
||||
if not self._check_oracle_resolution(condition_id):
|
||||
logger.warning(f"Skipping {position['slug']} - oracle not resolved")
|
||||
return False
|
||||
|
||||
# File lock
|
||||
lock_file = "/tmp/btc_live_redeem.lock"
|
||||
try:
|
||||
self._lock_fd = open(lock_file, 'w')
|
||||
fcntl.flock(self._lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except (IOError, OSError):
|
||||
logger.warning("Another redeem in progress, skipping")
|
||||
return False
|
||||
|
||||
try:
|
||||
use_proxy = self.signature_type in [1, 2] and self.funder_address
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
if use_proxy:
|
||||
# Gnosis Safe proxy wallet
|
||||
return self._redeem_via_safe(condition_id, up_balance, down_balance, is_neg_risk)
|
||||
else:
|
||||
# Direct EOA
|
||||
return self._redeem_direct(condition_id, up_balance, down_balance, is_neg_risk)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Redeem error: {e}")
|
||||
return False
|
||||
finally:
|
||||
if self._lock_fd:
|
||||
try:
|
||||
fcntl.flock(self._lock_fd, fcntl.LOCK_UN)
|
||||
self._lock_fd.close()
|
||||
except:
|
||||
pass
|
||||
self._lock_fd = None
|
||||
|
||||
def _redeem_direct(
|
||||
self,
|
||||
condition_id: str,
|
||||
up_balance: int,
|
||||
down_balance: int,
|
||||
is_neg_risk: bool
|
||||
) -> bool:
|
||||
"""Direct EOA redeem."""
|
||||
import time
|
||||
|
||||
nonce = self.w3.eth.get_transaction_count(self.signer_address)
|
||||
time.sleep(0.3)
|
||||
gas_price = self.w3.eth.gas_price
|
||||
|
||||
if is_neg_risk:
|
||||
adapter = self.w3.eth.contract(
|
||||
address=Web3.to_checksum_address(NEG_RISK_ADAPTER),
|
||||
abi=NEG_RISK_ABI
|
||||
)
|
||||
tx = adapter.functions.redeemPositions(
|
||||
Web3.to_bytes(hexstr=condition_id),
|
||||
[up_balance, down_balance]
|
||||
).build_transaction({
|
||||
"chainId": 137,
|
||||
"from": self.signer_address,
|
||||
"nonce": nonce,
|
||||
"gas": 500000,
|
||||
"gasPrice": int(gas_price * 1.2),
|
||||
})
|
||||
else:
|
||||
tx = self.ctf.functions.redeemPositions(
|
||||
Web3.to_checksum_address(USDC_ADDRESS),
|
||||
bytes(32),
|
||||
Web3.to_bytes(hexstr=condition_id),
|
||||
[1, 2]
|
||||
).build_transaction({
|
||||
"chainId": 137,
|
||||
"from": self.signer_address,
|
||||
"nonce": nonce,
|
||||
"gas": 500000,
|
||||
"gasPrice": int(gas_price * 1.2),
|
||||
})
|
||||
|
||||
signed_tx = self.w3.eth.account.sign_transaction(tx, self.private_key)
|
||||
tx_hash = self.w3.eth.send_raw_transaction(signed_tx.raw_transaction)
|
||||
|
||||
logger.info(f"TX sent: {tx_hash.hex()}")
|
||||
|
||||
receipt = self.w3.eth.wait_for_transaction_receipt(tx_hash, timeout=120)
|
||||
|
||||
if receipt.get("status") == 1:
|
||||
logger.info(f"Redeem successful! Gas: {receipt.get('gasUsed')}")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"TX reverted: {tx_hash.hex()}")
|
||||
return False
|
||||
|
||||
def _redeem_via_safe(
|
||||
self,
|
||||
condition_id: str,
|
||||
up_balance: int,
|
||||
down_balance: int,
|
||||
is_neg_risk: bool
|
||||
) -> bool:
|
||||
"""Redeem via Gnosis Safe proxy wallet."""
|
||||
import time
|
||||
|
||||
safe_address = Web3.to_checksum_address(self.funder_address)
|
||||
safe = self.w3.eth.contract(address=safe_address, abi=GNOSIS_SAFE_ABI)
|
||||
|
||||
# Build inner redeem call
|
||||
if is_neg_risk:
|
||||
adapter = self.w3.eth.contract(
|
||||
address=Web3.to_checksum_address(NEG_RISK_ADAPTER),
|
||||
abi=NEG_RISK_ABI
|
||||
)
|
||||
temp_tx = adapter.functions.redeemPositions(
|
||||
Web3.to_bytes(hexstr=condition_id),
|
||||
[up_balance, down_balance]
|
||||
).build_transaction({"from": safe_address})
|
||||
redeem_data = temp_tx['data']
|
||||
target_contract = NEG_RISK_ADAPTER
|
||||
else:
|
||||
temp_tx = self.ctf.functions.redeemPositions(
|
||||
Web3.to_checksum_address(USDC_ADDRESS),
|
||||
bytes(32),
|
||||
Web3.to_bytes(hexstr=condition_id),
|
||||
[1, 2]
|
||||
).build_transaction({"from": safe_address})
|
||||
redeem_data = temp_tx['data']
|
||||
target_contract = CTF_ADDRESS
|
||||
|
||||
time.sleep(0.5)
|
||||
eoa_nonce = self.w3.eth.get_transaction_count(self.signer_address)
|
||||
time.sleep(0.3)
|
||||
gas_price = self.w3.eth.gas_price
|
||||
safe_nonce = safe.functions.nonce().call()
|
||||
|
||||
# Safe TX params
|
||||
to = Web3.to_checksum_address(target_contract)
|
||||
value = 0
|
||||
data = redeem_data
|
||||
operation = 0
|
||||
safeTxGas = 0
|
||||
baseGas = 0
|
||||
gasPrice_safe = 0
|
||||
gasToken = "0x0000000000000000000000000000000000000000"
|
||||
refundReceiver = "0x0000000000000000000000000000000000000000"
|
||||
|
||||
# Get TX hash to sign
|
||||
tx_hash_to_sign = safe.functions.getTransactionHash(
|
||||
to, value, data, operation,
|
||||
safeTxGas, baseGas, gasPrice_safe,
|
||||
gasToken, refundReceiver, safe_nonce
|
||||
).call()
|
||||
|
||||
# Sign
|
||||
signed_msg = self.account.unsafe_sign_hash(tx_hash_to_sign)
|
||||
r = signed_msg.r.to_bytes(32, byteorder='big')
|
||||
s = signed_msg.s.to_bytes(32, byteorder='big')
|
||||
v = signed_msg.v
|
||||
signature = r + s + bytes([v])
|
||||
|
||||
# Build execTransaction
|
||||
tx = safe.functions.execTransaction(
|
||||
to, value, data, operation,
|
||||
safeTxGas, baseGas, gasPrice_safe,
|
||||
gasToken, refundReceiver, signature
|
||||
).build_transaction({
|
||||
"chainId": 137,
|
||||
"from": self.signer_address,
|
||||
"nonce": eoa_nonce,
|
||||
"gas": 1000000,
|
||||
"gasPrice": int(gas_price * 1.2),
|
||||
})
|
||||
|
||||
time.sleep(0.5)
|
||||
signed_tx = self.w3.eth.account.sign_transaction(tx, self.private_key)
|
||||
tx_hash = self.w3.eth.send_raw_transaction(signed_tx.raw_transaction)
|
||||
|
||||
logger.info(f"Safe TX sent: {tx_hash.hex()}")
|
||||
|
||||
receipt = self.w3.eth.wait_for_transaction_receipt(tx_hash, timeout=120)
|
||||
|
||||
if receipt.get("status") == 1:
|
||||
logger.info(f"Safe redeem successful! Gas: {receipt.get('gasUsed')}")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Safe TX reverted: {tx_hash.hex()}")
|
||||
return False
|
||||
|
||||
async def redeem_all(self) -> Tuple[int, float]:
|
||||
"""
|
||||
Check and redeem all redeemable positions.
|
||||
|
||||
Returns:
|
||||
Tuple of (redeemed_count, total_value_usd)
|
||||
"""
|
||||
logger.info("Starting auto-redeem check...")
|
||||
|
||||
active, pending, redeemable = await self._fetch_positions()
|
||||
|
||||
logger.info(f"Found: {len(active)} active, {len(pending)} pending, {len(redeemable)} redeemable")
|
||||
|
||||
if not redeemable:
|
||||
return 0, 0.0
|
||||
|
||||
redeemed_count = 0
|
||||
total_value = 0.0
|
||||
|
||||
for position in redeemable:
|
||||
# Use semaphore to limit concurrent redemptions
|
||||
# Use dedicated thread pool to avoid blocking main pool
|
||||
async with self._redeem_semaphore:
|
||||
loop = asyncio.get_event_loop()
|
||||
success = await loop.run_in_executor(
|
||||
_WEB3_EXECUTOR,
|
||||
self._redeem_position_sync,
|
||||
position
|
||||
)
|
||||
|
||||
if success:
|
||||
redeemed_count += 1
|
||||
value = (position["up_balance"] + position["down_balance"]) / 1e6
|
||||
total_value += value
|
||||
|
||||
self.total_redeemed += 1
|
||||
self.total_value += value
|
||||
|
||||
# Telegram notification
|
||||
if self.telegram:
|
||||
try:
|
||||
await self.telegram.send_message(
|
||||
f"💰 Redeemed: {position['slug']}\n"
|
||||
f"Value: ${value:.2f} USDC"
|
||||
)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Pause between redemptions
|
||||
await asyncio.sleep(2)
|
||||
|
||||
logger.info(f"Redeemed {redeemed_count}/{len(redeemable)}, value: ${total_value:.2f}")
|
||||
|
||||
return redeemed_count, total_value
|
||||
|
||||
async def run_loop(self):
|
||||
"""
|
||||
Main loop - runs every N seconds.
|
||||
Fully async, never blocks.
|
||||
"""
|
||||
self._running = True
|
||||
logger.info(f"Auto-redeemer started (interval: {self.interval}s)")
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
await asyncio.sleep(self.interval)
|
||||
|
||||
redeemed, value = await self.redeem_all()
|
||||
|
||||
if redeemed > 0:
|
||||
logger.info(f"Auto-redeemed {redeemed} positions, ${value:.2f}")
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Auto-redeemer cancelled")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Auto-redeem loop error: {e}")
|
||||
# Continue running despite errors
|
||||
await asyncio.sleep(10)
|
||||
|
||||
logger.info("Auto-redeemer stopped")
|
||||
|
||||
def stop(self):
|
||||
"""Stop the redeemer loop."""
|
||||
self._running = False
|
||||
|
||||
@staticmethod
|
||||
def shutdown_executor():
|
||||
"""Shutdown the dedicated thread pool on application exit."""
|
||||
global _WEB3_EXECUTOR
|
||||
if _WEB3_EXECUTOR:
|
||||
_WEB3_EXECUTOR.shutdown(wait=False)
|
||||
logger.info("Web3 executor shut down")
|
||||
|
||||
|
||||
async def create_auto_redeemer(config: Dict) -> AsyncAutoRedeemer:
|
||||
"""
|
||||
Factory function to create redeemer from config.
|
||||
|
||||
Args:
|
||||
config: Dict with keys: private_key, rpc_url, funder_address, signature_type
|
||||
"""
|
||||
return AsyncAutoRedeemer(
|
||||
private_key=config.get("private_key"),
|
||||
rpc_url=config.get("rpc_url", "https://polygon-rpc.com"),
|
||||
funder_address=config.get("funder_address"),
|
||||
signature_type=config.get("signature_type", 0),
|
||||
interval_seconds=config.get("redeem_interval", 180),
|
||||
telegram_notifier=config.get("telegram_notifier")
|
||||
)
|
||||
@@ -0,0 +1,329 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Configuration Loader
|
||||
|
||||
Loads settings from config.json and .env file.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional
|
||||
from dataclasses import dataclass
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load .env from project root
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
load_dotenv(PROJECT_ROOT / ".env")
|
||||
|
||||
|
||||
@dataclass
|
||||
class MarketConfig:
|
||||
"""Which Polymarket BTC up/down interval to trade (slug: btc-updown-{5|15}m-<epoch>)."""
|
||||
interval_minutes: int = 15
|
||||
|
||||
@property
|
||||
def duration_sec(self) -> int:
|
||||
return self.interval_minutes * 60
|
||||
|
||||
@property
|
||||
def slug_infix(self) -> str:
|
||||
"""e.g. '5m' or '15m' for btc-updown-5m-..."""
|
||||
return f"{self.interval_minutes}m"
|
||||
|
||||
|
||||
@dataclass
|
||||
class StrategyConfig:
|
||||
"""Strategy parameters."""
|
||||
min_price: float = 0.65
|
||||
max_price: float = 0.91
|
||||
min_elapsed_sec: int = 480
|
||||
min_deviation_pct: float = 5.0
|
||||
max_deviation_pct: float = 100.0
|
||||
no_entry_before_end_sec: int = 90
|
||||
momentum_window_sec: int = 120
|
||||
vwap_window_sec: int = 30
|
||||
win_rate_csv: str = "data/win_rate.csv"
|
||||
|
||||
|
||||
@dataclass
|
||||
class EntryConfig:
|
||||
"""Entry execution parameters."""
|
||||
bet_amount_usd: float = 10.0
|
||||
price_offset: float = 0.01
|
||||
order_type: str = "FAK"
|
||||
max_retries: int = 5
|
||||
retry_delay_ms: int = 300
|
||||
fill_timeout_ms: int = 2000
|
||||
min_contracts: int = 5
|
||||
min_order_usd: float = 1.0
|
||||
max_entry_price: float = 0.91
|
||||
ws_recovery_timeout_sec: int = 10
|
||||
|
||||
|
||||
@dataclass
|
||||
class HedgeConfig:
|
||||
"""Hedge execution parameters."""
|
||||
enabled: bool = True
|
||||
hedge_price: float = 0.02
|
||||
order_type: str = "GTD"
|
||||
max_retries: int = 3
|
||||
retry_delay_ms: int = 1000
|
||||
|
||||
|
||||
@dataclass
|
||||
class RedeemConfig:
|
||||
"""Auto-redeem parameters."""
|
||||
enabled: bool = True
|
||||
interval_seconds: int = 180
|
||||
auto_confirm: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class TelegramConfig:
|
||||
"""Telegram notification parameters."""
|
||||
enabled: bool = True
|
||||
bot_token: str = ""
|
||||
chat_id: str = ""
|
||||
chart_every_n_trades: int = 10
|
||||
|
||||
|
||||
@dataclass
|
||||
class SimulationConfig:
|
||||
"""
|
||||
Paper-trading mode: same WebSockets, signals, and dashboard; no real orders or redeemer.
|
||||
When enabled, API keys and private key are optional (not validated).
|
||||
"""
|
||||
enabled: bool = False
|
||||
separate_trading_log: bool = True
|
||||
trading_log_path: str = "logs/trading_log_sim.json"
|
||||
# Analysis exports (OPEN/CLOSE rows, cumulative PnL). Set jsonl path to "" to disable JSONL.
|
||||
history_csv_path: str = "logs/simulation_trades.csv"
|
||||
history_jsonl_path: str = "logs/simulation_history.jsonl"
|
||||
history_summary_path: str = "logs/simulation_summary.json"
|
||||
|
||||
|
||||
@dataclass
|
||||
class WebDashboardConfig:
|
||||
"""Optional local web UI (FastAPI). Bind to 127.0.0.1 unless you trust your network."""
|
||||
enabled: bool = False
|
||||
host: str = "127.0.0.1"
|
||||
port: int = 8765
|
||||
|
||||
|
||||
@dataclass
|
||||
class PolymarketConfig:
|
||||
"""Polymarket API credentials."""
|
||||
private_key: str = ""
|
||||
funder_address: str = ""
|
||||
signature_type: int = 0
|
||||
rpc_url: str = "https://polygon-rpc.com"
|
||||
chain_id: int = 137
|
||||
clob_host: str = "https://clob.polymarket.com"
|
||||
api_key: str = ""
|
||||
api_secret: str = ""
|
||||
api_passphrase: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
"""Main configuration."""
|
||||
market: MarketConfig
|
||||
simulation: SimulationConfig
|
||||
strategy: StrategyConfig
|
||||
entry: EntryConfig
|
||||
hedge: HedgeConfig
|
||||
redeem: RedeemConfig
|
||||
telegram: TelegramConfig
|
||||
web_dashboard: WebDashboardConfig
|
||||
polymarket: PolymarketConfig
|
||||
|
||||
|
||||
def load_config(config_path: Optional[str] = None) -> Config:
|
||||
"""
|
||||
Load configuration from JSON file and environment variables.
|
||||
|
||||
Args:
|
||||
config_path: Path to config.json (default: PROJECT_ROOT/config.json)
|
||||
|
||||
Returns:
|
||||
Config object with all settings
|
||||
"""
|
||||
if config_path is None:
|
||||
config_path = PROJECT_ROOT / "config.json"
|
||||
|
||||
# Load JSON config
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Market interval (5 or 15 minutes)
|
||||
market_data = data.get("market", {})
|
||||
market = MarketConfig(
|
||||
interval_minutes=int(market_data.get("interval_minutes", 15)),
|
||||
)
|
||||
|
||||
sim_data = data.get("simulation", {})
|
||||
simulation = SimulationConfig(
|
||||
enabled=bool(sim_data.get("enabled", False)),
|
||||
separate_trading_log=bool(sim_data.get("separate_trading_log", True)),
|
||||
trading_log_path=str(sim_data.get("trading_log_path", "logs/trading_log_sim.json")),
|
||||
history_csv_path=str(sim_data.get("history_csv_path", "logs/simulation_trades.csv")),
|
||||
history_jsonl_path=str(sim_data.get("history_jsonl_path", "logs/simulation_history.jsonl")),
|
||||
history_summary_path=str(sim_data.get("history_summary_path", "logs/simulation_summary.json")),
|
||||
)
|
||||
|
||||
# Strategy
|
||||
strategy_data = data.get("strategy", {})
|
||||
strategy = StrategyConfig(
|
||||
min_price=strategy_data.get("min_price", 0.65),
|
||||
max_price=strategy_data.get("max_price", 0.91),
|
||||
min_elapsed_sec=strategy_data.get("min_elapsed_sec", 480),
|
||||
min_deviation_pct=strategy_data.get("min_deviation_pct", 5.0),
|
||||
max_deviation_pct=strategy_data.get("max_deviation_pct", 100.0),
|
||||
no_entry_before_end_sec=strategy_data.get("no_entry_before_end_sec", 90),
|
||||
momentum_window_sec=strategy_data.get("momentum_window_sec", 120),
|
||||
vwap_window_sec=strategy_data.get("vwap_window_sec", 30),
|
||||
win_rate_csv=strategy_data.get("win_rate_csv", "data/win_rate.csv"),
|
||||
)
|
||||
|
||||
# Entry
|
||||
entry_data = data.get("entry", {})
|
||||
entry = EntryConfig(
|
||||
bet_amount_usd=entry_data.get("bet_amount_usd", 10.0),
|
||||
price_offset=entry_data.get("price_offset", 0.01),
|
||||
order_type=entry_data.get("order_type", "FAK"),
|
||||
max_retries=entry_data.get("max_retries", 5),
|
||||
retry_delay_ms=entry_data.get("retry_delay_ms", 300),
|
||||
fill_timeout_ms=entry_data.get("fill_timeout_ms", 2000),
|
||||
min_contracts=entry_data.get("min_contracts", 5),
|
||||
min_order_usd=entry_data.get("min_order_usd", 1.0),
|
||||
max_entry_price=entry_data.get("max_entry_price", 0.91),
|
||||
ws_recovery_timeout_sec=entry_data.get("ws_recovery_timeout_sec", 10),
|
||||
)
|
||||
|
||||
# Hedge
|
||||
hedge_data = data.get("hedge", {})
|
||||
hedge = HedgeConfig(
|
||||
enabled=hedge_data.get("enabled", True),
|
||||
hedge_price=hedge_data.get("hedge_price", 0.02),
|
||||
order_type=hedge_data.get("order_type", "GTD"),
|
||||
max_retries=hedge_data.get("max_retries", 3),
|
||||
retry_delay_ms=hedge_data.get("retry_delay_ms", 1000),
|
||||
)
|
||||
|
||||
# Redeem
|
||||
redeem_data = data.get("redeem", {})
|
||||
redeem = RedeemConfig(
|
||||
enabled=redeem_data.get("enabled", True),
|
||||
interval_seconds=redeem_data.get("interval_seconds", 180),
|
||||
auto_confirm=redeem_data.get("auto_confirm", True),
|
||||
)
|
||||
|
||||
# Telegram (merge JSON + env)
|
||||
telegram_data = data.get("telegram", {})
|
||||
telegram = TelegramConfig(
|
||||
enabled=telegram_data.get("enabled", True),
|
||||
bot_token=os.getenv("TELEGRAM_BOT_TOKEN", ""),
|
||||
chat_id=os.getenv("TELEGRAM_CHAT_ID", ""),
|
||||
chart_every_n_trades=telegram_data.get("chart_every_n_trades", 10),
|
||||
)
|
||||
|
||||
web_data = data.get("web_dashboard", {})
|
||||
web_dashboard = WebDashboardConfig(
|
||||
enabled=bool(web_data.get("enabled", False)),
|
||||
host=str(web_data.get("host", "127.0.0.1")),
|
||||
port=int(web_data.get("port", 8765)),
|
||||
)
|
||||
|
||||
# Polymarket (from env only - secrets)
|
||||
polymarket = PolymarketConfig(
|
||||
private_key=os.getenv("PRIVATE_KEY", ""),
|
||||
funder_address=os.getenv("FUNDER_ADDRESS", ""),
|
||||
signature_type=int(os.getenv("SIGNATURE_TYPE", "0")),
|
||||
rpc_url=os.getenv("RPC_URL", "https://polygon-rpc.com"),
|
||||
chain_id=int(os.getenv("CHAIN_ID", "137")),
|
||||
clob_host=os.getenv("CLOB_HOST", "https://clob.polymarket.com"),
|
||||
api_key=os.getenv("POLY_API_KEY", ""),
|
||||
api_secret=os.getenv("POLY_API_SECRET", ""),
|
||||
api_passphrase=os.getenv("POLY_API_PASSPHRASE", ""),
|
||||
)
|
||||
|
||||
return Config(
|
||||
market=market,
|
||||
simulation=simulation,
|
||||
strategy=strategy,
|
||||
entry=entry,
|
||||
hedge=hedge,
|
||||
redeem=redeem,
|
||||
telegram=telegram,
|
||||
web_dashboard=web_dashboard,
|
||||
polymarket=polymarket,
|
||||
)
|
||||
|
||||
|
||||
def validate_config(config: Config) -> list:
|
||||
"""
|
||||
Validate configuration.
|
||||
|
||||
Returns:
|
||||
List of error messages (empty if valid)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
if config.market.interval_minutes not in (5, 15):
|
||||
errors.append(
|
||||
'market.interval_minutes must be 5 or 15 (Polymarket BTC up/down markets)'
|
||||
)
|
||||
|
||||
dur = config.market.duration_sec
|
||||
if config.strategy.min_elapsed_sec >= dur:
|
||||
errors.append(
|
||||
f"strategy.min_elapsed_sec ({config.strategy.min_elapsed_sec}s) must be less than "
|
||||
f"market duration ({dur}s for {config.market.interval_minutes}m)"
|
||||
)
|
||||
if config.strategy.no_entry_before_end_sec >= dur:
|
||||
errors.append(
|
||||
f"strategy.no_entry_before_end_sec ({config.strategy.no_entry_before_end_sec}s) "
|
||||
f"must be less than market duration ({dur}s)"
|
||||
)
|
||||
|
||||
live_trading = not config.simulation.enabled
|
||||
|
||||
if live_trading:
|
||||
# Required: private key
|
||||
if not config.polymarket.private_key:
|
||||
errors.append("PRIVATE_KEY not set in .env")
|
||||
elif not config.polymarket.private_key.startswith("0x"):
|
||||
errors.append("PRIVATE_KEY must start with 0x")
|
||||
|
||||
# Proxy wallet check
|
||||
if config.polymarket.signature_type in [1, 2]:
|
||||
if not config.polymarket.funder_address:
|
||||
errors.append(f"SIGNATURE_TYPE={config.polymarket.signature_type} requires FUNDER_ADDRESS")
|
||||
|
||||
# API credentials
|
||||
if not config.polymarket.api_key:
|
||||
errors.append("POLY_API_KEY not set")
|
||||
if not config.polymarket.api_secret:
|
||||
errors.append("POLY_API_SECRET not set")
|
||||
if not config.polymarket.api_passphrase:
|
||||
errors.append("POLY_API_PASSPHRASE not set")
|
||||
|
||||
# Strategy bounds
|
||||
if config.strategy.min_price >= config.strategy.max_price:
|
||||
errors.append("min_price must be less than max_price")
|
||||
|
||||
if config.entry.max_entry_price > config.strategy.max_price:
|
||||
errors.append("max_entry_price should not exceed strategy max_price")
|
||||
|
||||
if config.strategy.max_deviation_pct <= config.strategy.min_deviation_pct:
|
||||
errors.append(
|
||||
f"max_deviation_pct ({config.strategy.max_deviation_pct}) "
|
||||
f"must be greater than min_deviation_pct ({config.strategy.min_deviation_pct})"
|
||||
)
|
||||
|
||||
if config.web_dashboard.enabled:
|
||||
if not (1 <= config.web_dashboard.port <= 65535):
|
||||
errors.append("web_dashboard.port must be between 1 and 65535")
|
||||
|
||||
return errors
|
||||
@@ -0,0 +1,319 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Hedge Manager (GTD)
|
||||
|
||||
Places a passive GTD limit order on the opposite leg immediately after entry.
|
||||
The order sits on the book and fills automatically when price reaches hedge_price.
|
||||
|
||||
No trigger monitoring needed — the CLOB handles execution.
|
||||
Order auto-cancels when the market resolves.
|
||||
|
||||
Features:
|
||||
- GTD limit order on opposite token
|
||||
- Exact contract count matching main position
|
||||
- Duplicate protection via hedge_order_placed flag
|
||||
- Fill tracking via WebSocket user channel
|
||||
- Telegram notifications for placement and fills
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
logger = logging.getLogger("btc_live.hedge")
|
||||
|
||||
# Separate logger for detailed hedge tracking
|
||||
hedge_logger = logging.getLogger("btc_live.hedges")
|
||||
hedge_logger.setLevel(logging.DEBUG)
|
||||
|
||||
|
||||
@dataclass
|
||||
class HedgeConfig:
|
||||
"""Configuration for hedging."""
|
||||
enabled: bool = True
|
||||
hedge_price: float = 0.02
|
||||
order_type: str = "GTD"
|
||||
max_retries: int = 3
|
||||
retry_delay_ms: int = 1000
|
||||
simulation_mode: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class HedgeResult:
|
||||
"""Result of hedge order placement."""
|
||||
success: bool
|
||||
order_id: str = ""
|
||||
contracts: int = 0
|
||||
price: float = 0.0
|
||||
attempts: int = 0
|
||||
error: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class HedgePosition:
|
||||
"""Tracks hedge state for a position."""
|
||||
opposite_token_id: str
|
||||
contracts: int
|
||||
hedge_order_placed: bool = False
|
||||
hedge_order_id: str = ""
|
||||
hedge_contracts_filled: int = 0
|
||||
hedged: bool = False # True when fully filled
|
||||
|
||||
|
||||
class HedgeManager:
|
||||
"""
|
||||
Manages position hedging via GTD limit orders.
|
||||
|
||||
After entry is confirmed, places a GTD BUY order on the opposite token
|
||||
at hedge_price (e.g. $0.02). The order sits passively on the book.
|
||||
|
||||
When our side reaches ~$0.98, the opposite side drops to ~$0.02
|
||||
and our hedge order fills automatically — locking in profit.
|
||||
"""
|
||||
|
||||
def __init__(self, order_executor: Any, config: HedgeConfig):
|
||||
self.executor = order_executor
|
||||
self.config = config
|
||||
self._position: Optional[HedgePosition] = None
|
||||
|
||||
# Stats
|
||||
self.hedges_placed = 0
|
||||
self.hedges_filled = 0
|
||||
|
||||
def set_position(self, opposite_token_id: str, contracts: int):
|
||||
"""
|
||||
Set the position to hedge (called after entry is confirmed).
|
||||
|
||||
Args:
|
||||
opposite_token_id: Token ID of the opposite leg
|
||||
contracts: Exact number of contracts from main entry
|
||||
"""
|
||||
self._position = HedgePosition(
|
||||
opposite_token_id=opposite_token_id,
|
||||
contracts=contracts
|
||||
)
|
||||
|
||||
hedge_logger.info("=" * 50)
|
||||
hedge_logger.info("HEDGE POSITION SET")
|
||||
hedge_logger.info(f" Opposite Token: {opposite_token_id[:30]}...")
|
||||
hedge_logger.info(f" Contracts: {contracts}")
|
||||
hedge_logger.info(f" Hedge Price: ${self.config.hedge_price}")
|
||||
hedge_logger.info(f" Hedge Cost: ${contracts * self.config.hedge_price:.2f}")
|
||||
hedge_logger.info(f" Enabled: {self.config.enabled}")
|
||||
hedge_logger.info(f" Simulation: {self.config.simulation_mode}")
|
||||
hedge_logger.info("=" * 50)
|
||||
|
||||
logger.info(
|
||||
f"Hedge position set: {contracts} contracts, "
|
||||
f"will hedge @ ${self.config.hedge_price}"
|
||||
)
|
||||
|
||||
async def place_gtd_hedge(self) -> HedgeResult:
|
||||
"""
|
||||
Place GTD hedge order on the opposite token.
|
||||
|
||||
Called once after entry is confirmed. Retries up to max_retries
|
||||
only if API explicitly rejects (success=False).
|
||||
|
||||
CRITICAL: Only places ONE order. Flag hedge_order_placed prevents duplicates.
|
||||
|
||||
Returns:
|
||||
HedgeResult with placement details
|
||||
"""
|
||||
if not self.config.enabled:
|
||||
return HedgeResult(success=False, error="Hedge disabled")
|
||||
|
||||
if not self._position:
|
||||
return HedgeResult(success=False, error="No position set")
|
||||
|
||||
pos = self._position
|
||||
|
||||
# DUPLICATE PROTECTION
|
||||
if pos.hedge_order_placed:
|
||||
hedge_logger.warning("HEDGE ALREADY PLACED - skipping")
|
||||
return HedgeResult(
|
||||
success=True,
|
||||
order_id=pos.hedge_order_id,
|
||||
contracts=pos.contracts,
|
||||
price=self.config.hedge_price,
|
||||
error="Already placed"
|
||||
)
|
||||
|
||||
if self.config.simulation_mode:
|
||||
hedge_logger.info("=" * 60)
|
||||
hedge_logger.info("SIMULATION: GTD hedge (no order sent)")
|
||||
oid = "SIM-HEDGE"
|
||||
pos.hedge_order_placed = True
|
||||
pos.hedge_order_id = oid
|
||||
self.hedges_placed += 1
|
||||
hedge_logger.info(f" Order ID: {oid}")
|
||||
hedge_logger.info("=" * 60)
|
||||
logger.info(f"Simulation hedge: {pos.contracts} @ ${self.config.hedge_price}")
|
||||
return HedgeResult(
|
||||
success=True,
|
||||
order_id=oid,
|
||||
contracts=pos.contracts,
|
||||
price=self.config.hedge_price,
|
||||
attempts=1,
|
||||
)
|
||||
|
||||
hedge_logger.info("=" * 60)
|
||||
hedge_logger.info("PLACING GTD HEDGE ORDER")
|
||||
hedge_logger.info(f" Token: {pos.opposite_token_id[:30]}...")
|
||||
hedge_logger.info(f" Size: {pos.contracts} contracts")
|
||||
hedge_logger.info(f" Price: ${self.config.hedge_price}")
|
||||
hedge_logger.info(f" Cost: ${pos.contracts * self.config.hedge_price:.2f}")
|
||||
hedge_logger.info(f" Type: GTD")
|
||||
hedge_logger.info(f" Max Retries: {self.config.max_retries}")
|
||||
hedge_logger.info("-" * 40)
|
||||
|
||||
from py_clob_client.clob_types import OrderArgs, OrderType
|
||||
from py_clob_client.order_builder.constants import BUY
|
||||
|
||||
last_error = ""
|
||||
expiration = str(int(time.time()) + 3600) # 1 hour, market resolves before this
|
||||
|
||||
for attempt in range(1, self.config.max_retries + 1):
|
||||
hedge_logger.info(f"ATTEMPT {attempt}/{self.config.max_retries}")
|
||||
|
||||
try:
|
||||
# Create signed order
|
||||
signed_order = await asyncio.to_thread(
|
||||
self.executor._client.create_order,
|
||||
OrderArgs(
|
||||
price=self.config.hedge_price,
|
||||
size=pos.contracts,
|
||||
side=BUY,
|
||||
token_id=pos.opposite_token_id,
|
||||
expiration=expiration
|
||||
)
|
||||
)
|
||||
|
||||
# Post as GTD
|
||||
response = await asyncio.to_thread(
|
||||
self.executor._client.post_order,
|
||||
signed_order,
|
||||
OrderType.GTD
|
||||
)
|
||||
|
||||
# Parse response
|
||||
if isinstance(response, dict):
|
||||
success = response.get("success", False)
|
||||
order_id = response.get("orderID", "")
|
||||
status = response.get("status", "")
|
||||
error_msg = response.get("errorMsg", "")
|
||||
else:
|
||||
success = getattr(response, 'success', False)
|
||||
order_id = getattr(response, 'orderID', "")
|
||||
status = getattr(response, 'status', "")
|
||||
error_msg = getattr(response, 'errorMsg', "")
|
||||
|
||||
hedge_logger.info(f" Response: success={success}, status={status}, orderID={order_id[:30] if order_id else 'N/A'}")
|
||||
|
||||
if success and order_id:
|
||||
# ORDER PLACED SUCCESSFULLY
|
||||
pos.hedge_order_placed = True
|
||||
pos.hedge_order_id = order_id
|
||||
self.hedges_placed += 1
|
||||
|
||||
hedge_logger.info(f" ✅ GTD HEDGE ORDER PLACED")
|
||||
hedge_logger.info(f" Order ID: {order_id}")
|
||||
hedge_logger.info(f" Status: {status}")
|
||||
|
||||
logger.info(f"GTD hedge placed: {pos.contracts} @ ${self.config.hedge_price}, ID: {order_id[:20]}...")
|
||||
|
||||
return HedgeResult(
|
||||
success=True,
|
||||
order_id=order_id,
|
||||
contracts=pos.contracts,
|
||||
price=self.config.hedge_price,
|
||||
attempts=attempt
|
||||
)
|
||||
else:
|
||||
# API explicitly rejected — can retry
|
||||
last_error = error_msg or "Order rejected"
|
||||
hedge_logger.warning(f" ❌ Rejected: {last_error}")
|
||||
logger.warning(f"Hedge attempt {attempt} rejected: {last_error}")
|
||||
|
||||
if attempt < self.config.max_retries:
|
||||
await asyncio.sleep(self.config.retry_delay_ms / 1000)
|
||||
|
||||
except Exception as e:
|
||||
last_error = str(e)
|
||||
hedge_logger.error(f" ❌ Exception: {last_error}")
|
||||
logger.error(f"Hedge attempt {attempt} error: {last_error}")
|
||||
|
||||
if attempt < self.config.max_retries:
|
||||
await asyncio.sleep(self.config.retry_delay_ms / 1000)
|
||||
|
||||
# All attempts failed
|
||||
hedge_logger.error(f"HEDGE FAILED after {self.config.max_retries} attempts: {last_error}")
|
||||
logger.error(f"Hedge failed: {last_error}")
|
||||
|
||||
return HedgeResult(
|
||||
success=False,
|
||||
attempts=self.config.max_retries,
|
||||
error=last_error
|
||||
)
|
||||
|
||||
def on_hedge_fill(self, size: int, price: float):
|
||||
"""
|
||||
Called when WebSocket reports a fill on our hedge order.
|
||||
|
||||
Args:
|
||||
size: Number of contracts filled
|
||||
price: Fill price
|
||||
"""
|
||||
if not self._position:
|
||||
return
|
||||
|
||||
pos = self._position
|
||||
pos.hedge_contracts_filled += size
|
||||
|
||||
hedge_logger.info(f"HEDGE FILL: +{size} contracts @ ${price:.4f}")
|
||||
hedge_logger.info(f" Total filled: {pos.hedge_contracts_filled}/{pos.contracts}")
|
||||
|
||||
if pos.hedge_contracts_filled >= pos.contracts:
|
||||
pos.hedged = True
|
||||
self.hedges_filled += 1
|
||||
hedge_logger.info(f" ✅ FULLY HEDGED")
|
||||
logger.info(f"Position fully hedged: {pos.hedge_contracts_filled} contracts")
|
||||
else:
|
||||
logger.info(f"Hedge partial fill: {pos.hedge_contracts_filled}/{pos.contracts}")
|
||||
|
||||
@property
|
||||
def hedge_order_id(self) -> Optional[str]:
|
||||
"""Get the current hedge order ID."""
|
||||
if self._position and self._position.hedge_order_id:
|
||||
return self._position.hedge_order_id
|
||||
return None
|
||||
|
||||
@property
|
||||
def is_hedged(self) -> bool:
|
||||
"""Check if position is fully hedged."""
|
||||
return self._position.hedged if self._position else False
|
||||
|
||||
@property
|
||||
def hedge_order_placed(self) -> bool:
|
||||
"""Check if hedge order has been placed."""
|
||||
return self._position.hedge_order_placed if self._position else False
|
||||
|
||||
def clear(self):
|
||||
"""Clear hedge state (called on market change)."""
|
||||
self._position = None
|
||||
|
||||
def get_stats(self) -> Dict:
|
||||
"""Get hedge statistics."""
|
||||
pos = self._position
|
||||
return {
|
||||
"hedges_placed": self.hedges_placed,
|
||||
"hedges_filled": self.hedges_filled,
|
||||
"current_order_id": pos.hedge_order_id if pos else "",
|
||||
"current_filled": pos.hedge_contracts_filled if pos else 0,
|
||||
"current_total": pos.contracts if pos else 0,
|
||||
"is_hedged": pos.hedged if pos else False,
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Market Finder
|
||||
|
||||
Searches for active BTC 5- or 15-minute up/down markets on Polymarket.
|
||||
Features:
|
||||
- Async HTTP with retry logic
|
||||
- Caching to reduce API calls
|
||||
- Automatic market lifecycle detection
|
||||
- Robust error handling
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional, List, Dict, Any, Tuple
|
||||
|
||||
import aiohttp
|
||||
|
||||
from src.proxy_util import apply_proxy_env, aiohttp_proxy
|
||||
|
||||
apply_proxy_env()
|
||||
_PROXY_URL = aiohttp_proxy() or ""
|
||||
|
||||
logger = logging.getLogger("btc_live.market_finder")
|
||||
|
||||
GAMMA_API = "https://gamma-api.polymarket.com"
|
||||
CLOB_API = "https://clob.polymarket.com"
|
||||
|
||||
def _btc_slug_pattern(interval_minutes: int) -> re.Pattern:
|
||||
# Support multiple slug formats:
|
||||
# - btc-updown-5m-1752345600 (original)
|
||||
# - btc-up-or-down-5m-1752345600 (newer format)
|
||||
# - btc-5m-up-down-1752345600 (older)
|
||||
# - btc-updown-0726-5m-... (with date)
|
||||
return re.compile(rf"btc-.*(up|down).*{int(interval_minutes)}m.*")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Market:
|
||||
"""Represents a BTC up/down interval market (5m or 15m slug)."""
|
||||
|
||||
id: str
|
||||
slug: str
|
||||
question: str
|
||||
condition_id: str
|
||||
|
||||
# Token IDs
|
||||
up_token_id: str
|
||||
down_token_id: str
|
||||
|
||||
# Timing
|
||||
start_time: datetime
|
||||
end_time: datetime
|
||||
|
||||
# State
|
||||
active: bool = True
|
||||
closed: bool = False
|
||||
accepting_orders: bool = True
|
||||
|
||||
# Prices (updated from WebSocket)
|
||||
up_price: float = 0.5
|
||||
down_price: float = 0.5
|
||||
best_bid: float = 0.0
|
||||
best_ask: float = 0.0
|
||||
|
||||
# Metadata
|
||||
volume: float = 0.0
|
||||
liquidity: float = 0.0
|
||||
|
||||
def time_remaining_seconds(self) -> float:
|
||||
"""Get seconds until market ends."""
|
||||
now = datetime.now(timezone.utc)
|
||||
delta = self.end_time - now
|
||||
return max(0, delta.total_seconds())
|
||||
|
||||
def time_elapsed_seconds(self) -> float:
|
||||
"""Get seconds since market started."""
|
||||
now = datetime.now(timezone.utc)
|
||||
delta = now - self.start_time
|
||||
return max(0, delta.total_seconds())
|
||||
|
||||
def minutes_remaining(self) -> float:
|
||||
"""Get minutes until market ends."""
|
||||
return self.time_remaining_seconds() / 60
|
||||
|
||||
def minutes_elapsed(self) -> float:
|
||||
"""Get minutes since market started."""
|
||||
return self.time_elapsed_seconds() / 60
|
||||
|
||||
def is_tradeable(self) -> bool:
|
||||
"""Check if market is currently tradeable."""
|
||||
return (
|
||||
self.active and
|
||||
not self.closed and
|
||||
self.accepting_orders and
|
||||
self.time_remaining_seconds() > 0
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"slug": self.slug,
|
||||
"question": self.question,
|
||||
"condition_id": self.condition_id,
|
||||
"up_token_id": self.up_token_id,
|
||||
"down_token_id": self.down_token_id,
|
||||
"start_time": self.start_time.isoformat(),
|
||||
"end_time": self.end_time.isoformat(),
|
||||
"active": self.active,
|
||||
"closed": self.closed,
|
||||
"up_price": self.up_price,
|
||||
"down_price": self.down_price,
|
||||
}
|
||||
|
||||
|
||||
class MarketFinder:
|
||||
"""
|
||||
Finds and tracks BTC up/down markets for a chosen interval (5 or 15 minutes).
|
||||
|
||||
Features:
|
||||
- Async HTTP requests with exponential backoff
|
||||
- Market caching
|
||||
- Automatic refresh
|
||||
- Error recovery
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
refresh_interval: float = 30.0,
|
||||
max_retries: int = 3,
|
||||
retry_delay: float = 2.0,
|
||||
interval_minutes: int = 15,
|
||||
):
|
||||
self.refresh_interval = refresh_interval
|
||||
self.max_retries = max_retries
|
||||
self.retry_delay = retry_delay
|
||||
self.interval_minutes = int(interval_minutes) if int(interval_minutes) in (5, 15) else 15
|
||||
self._slug_pattern = _btc_slug_pattern(self.interval_minutes)
|
||||
|
||||
self._session: Optional[aiohttp.ClientSession] = None
|
||||
self._current_market: Optional[Market] = None
|
||||
self._market_history: List[str] = [] # List of processed market slugs
|
||||
self._last_refresh: Optional[datetime] = None
|
||||
self._running = False
|
||||
|
||||
# Callbacks
|
||||
self._on_new_market_callbacks: List[callable] = []
|
||||
self._on_market_end_callbacks: List[callable] = []
|
||||
|
||||
async def _get_session(self) -> aiohttp.ClientSession:
|
||||
"""Get or create HTTP session (proxy-aware via env)."""
|
||||
if self._session is None or self._session.closed:
|
||||
timeout = aiohttp.ClientTimeout(total=30, connect=10)
|
||||
self._session = aiohttp.ClientSession(timeout=timeout, trust_env=True)
|
||||
return self._session
|
||||
|
||||
async def _request_with_retry(
|
||||
self,
|
||||
url: str,
|
||||
params: Optional[Dict] = None,
|
||||
method: str = "GET"
|
||||
) -> Optional[Dict]:
|
||||
"""
|
||||
Make HTTP request with retry logic.
|
||||
|
||||
Args:
|
||||
url: Request URL
|
||||
params: Query parameters
|
||||
method: HTTP method
|
||||
|
||||
Returns:
|
||||
JSON response or None on failure
|
||||
"""
|
||||
session = await self._get_session()
|
||||
last_error = None
|
||||
|
||||
for attempt in range(self.max_retries):
|
||||
try:
|
||||
async with session.request(
|
||||
method, url, params=params, proxy=_PROXY_URL or None
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
return await resp.json()
|
||||
elif resp.status == 429:
|
||||
# Rate limited - wait longer
|
||||
wait_time = self.retry_delay * (2 ** attempt) * 2
|
||||
logger.warning(f"Rate limited, waiting {wait_time:.1f}s")
|
||||
await asyncio.sleep(wait_time)
|
||||
continue
|
||||
elif resp.status >= 500:
|
||||
# Server error - retry
|
||||
logger.warning(f"Server error {resp.status}, retrying...")
|
||||
await asyncio.sleep(self.retry_delay * (2 ** attempt))
|
||||
continue
|
||||
else:
|
||||
logger.error(f"HTTP {resp.status}: {await resp.text()}")
|
||||
return None
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(f"Request timeout (attempt {attempt + 1}/{self.max_retries})")
|
||||
last_error = "timeout"
|
||||
except aiohttp.ClientError as e:
|
||||
logger.warning(f"Client error: {e} (attempt {attempt + 1}/{self.max_retries})")
|
||||
last_error = str(e)
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error: {e}")
|
||||
last_error = str(e)
|
||||
|
||||
if attempt < self.max_retries - 1:
|
||||
await asyncio.sleep(self.retry_delay * (2 ** attempt))
|
||||
|
||||
logger.error(f"Request failed after {self.max_retries} attempts: {last_error}")
|
||||
return None
|
||||
|
||||
def _parse_market(self, data: Dict) -> Optional[Market]:
|
||||
"""
|
||||
Parse market data from Gamma API response.
|
||||
|
||||
Args:
|
||||
data: Raw market data from API
|
||||
|
||||
Returns:
|
||||
Market object or None if parsing fails
|
||||
"""
|
||||
try:
|
||||
slug = data.get("slug", "")
|
||||
|
||||
# Check if it's a BTC up/down market for our interval
|
||||
match = self._slug_pattern.match(slug)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
# Parse token IDs
|
||||
clob_token_ids = data.get("clobTokenIds", "[]")
|
||||
if isinstance(clob_token_ids, str):
|
||||
clob_token_ids = json.loads(clob_token_ids)
|
||||
|
||||
if len(clob_token_ids) < 2:
|
||||
logger.warning(f"Market {slug} has insufficient token IDs")
|
||||
return None
|
||||
|
||||
# Parse outcomes to match tokens
|
||||
outcomes = data.get("outcomes", "[]")
|
||||
if isinstance(outcomes, str):
|
||||
outcomes = json.loads(outcomes)
|
||||
|
||||
# Determine Up/Down token indices
|
||||
up_idx, down_idx = 0, 1
|
||||
for i, outcome in enumerate(outcomes):
|
||||
if outcome.lower() == "up":
|
||||
up_idx = i
|
||||
elif outcome.lower() == "down":
|
||||
down_idx = i
|
||||
|
||||
# Parse times
|
||||
end_date_str = data.get("endDate", "")
|
||||
start_time_str = data.get("eventStartTime") or data.get("startDate", "")
|
||||
|
||||
if not end_date_str:
|
||||
logger.warning(f"Market {slug} has no end date")
|
||||
return None
|
||||
|
||||
# Parse ISO dates
|
||||
end_time = datetime.fromisoformat(end_date_str.replace("Z", "+00:00"))
|
||||
|
||||
if start_time_str:
|
||||
start_time = datetime.fromisoformat(start_time_str.replace("Z", "+00:00"))
|
||||
else:
|
||||
start_time = end_time - timedelta(minutes=self.interval_minutes)
|
||||
|
||||
# Parse prices
|
||||
outcome_prices = data.get("outcomePrices", "[]")
|
||||
if isinstance(outcome_prices, str):
|
||||
outcome_prices = json.loads(outcome_prices)
|
||||
|
||||
up_price = float(outcome_prices[up_idx]) if len(outcome_prices) > up_idx else 0.5
|
||||
down_price = float(outcome_prices[down_idx]) if len(outcome_prices) > down_idx else 0.5
|
||||
|
||||
return Market(
|
||||
id=data.get("id", ""),
|
||||
slug=slug,
|
||||
question=data.get("question", ""),
|
||||
condition_id=data.get("conditionId", ""),
|
||||
up_token_id=clob_token_ids[up_idx],
|
||||
down_token_id=clob_token_ids[down_idx],
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
active=data.get("active", True),
|
||||
closed=data.get("closed", False),
|
||||
accepting_orders=data.get("acceptingOrders", True),
|
||||
up_price=up_price,
|
||||
down_price=down_price,
|
||||
best_bid=float(data.get("bestBid", 0) or 0),
|
||||
best_ask=float(data.get("bestAsk", 0) or 0),
|
||||
volume=float(data.get("volume", 0) or 0),
|
||||
liquidity=float(data.get("liquidity", 0) or 0),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing market: {e}")
|
||||
return None
|
||||
|
||||
async def find_active_market(self) -> Optional[Market]:
|
||||
"""
|
||||
Find the currently active BTC up/down market for this finder’s interval.
|
||||
|
||||
Returns:
|
||||
Active Market or None if not found
|
||||
"""
|
||||
slug_part = f"btc-updown-{self.interval_minutes}m"
|
||||
logger.debug("Searching for active %s market...", slug_part)
|
||||
|
||||
# Search Gamma API
|
||||
url = f"{GAMMA_API}/markets"
|
||||
params = {
|
||||
"slug_contains": slug_part,
|
||||
"active": "true",
|
||||
"closed": "false",
|
||||
"limit": 10,
|
||||
"order": "endDate",
|
||||
"ascending": "true"
|
||||
}
|
||||
|
||||
data = await self._request_with_retry(url, params)
|
||||
|
||||
if not data:
|
||||
logger.warning("No response from Gamma API")
|
||||
return None
|
||||
|
||||
# Handle both list and single object responses
|
||||
markets_list = data if isinstance(data, list) else [data]
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
best_market: Optional[Market] = None
|
||||
|
||||
for market_data in markets_list:
|
||||
market = self._parse_market(market_data)
|
||||
|
||||
if market is None:
|
||||
continue
|
||||
|
||||
# Skip already processed markets
|
||||
if market.slug in self._market_history:
|
||||
continue
|
||||
|
||||
# Check if market is currently tradeable
|
||||
if not market.is_tradeable():
|
||||
continue
|
||||
|
||||
# Check if market has started
|
||||
if market.start_time > now:
|
||||
continue
|
||||
|
||||
# Prefer market with most time remaining
|
||||
if best_market is None or market.time_remaining_seconds() > best_market.time_remaining_seconds():
|
||||
best_market = market
|
||||
|
||||
if best_market:
|
||||
logger.info(
|
||||
f"Found active market: {best_market.slug} "
|
||||
f"({best_market.minutes_remaining():.1f} min remaining)"
|
||||
)
|
||||
|
||||
return best_market
|
||||
|
||||
async def refresh(self) -> Optional[Market]:
|
||||
"""
|
||||
Refresh market status.
|
||||
|
||||
Checks if current market is still active, or finds a new one.
|
||||
|
||||
Returns:
|
||||
Current active market or None
|
||||
"""
|
||||
self._last_refresh = datetime.now(timezone.utc)
|
||||
|
||||
# Check if current market has ended
|
||||
if self._current_market:
|
||||
if self._current_market.time_remaining_seconds() <= 0:
|
||||
logger.info(f"Market {self._current_market.slug} has ended")
|
||||
|
||||
# Mark as processed
|
||||
self._market_history.append(self._current_market.slug)
|
||||
|
||||
# Trigger callbacks
|
||||
for callback in self._on_market_end_callbacks:
|
||||
try:
|
||||
if asyncio.iscoroutinefunction(callback):
|
||||
await callback(self._current_market)
|
||||
else:
|
||||
callback(self._current_market)
|
||||
except Exception as e:
|
||||
logger.error(f"Market end callback error: {e}")
|
||||
|
||||
self._current_market = None
|
||||
|
||||
# Find new market if needed
|
||||
if self._current_market is None:
|
||||
new_market = await self.find_active_market()
|
||||
|
||||
if new_market:
|
||||
self._current_market = new_market
|
||||
|
||||
# Trigger callbacks
|
||||
for callback in self._on_new_market_callbacks:
|
||||
try:
|
||||
if asyncio.iscoroutinefunction(callback):
|
||||
await callback(new_market)
|
||||
else:
|
||||
callback(new_market)
|
||||
except Exception as e:
|
||||
logger.error(f"New market callback error: {e}")
|
||||
|
||||
return self._current_market
|
||||
|
||||
def on_new_market(self, callback: callable):
|
||||
"""Register callback for new market discovery."""
|
||||
self._on_new_market_callbacks.append(callback)
|
||||
|
||||
def on_market_end(self, callback: callable):
|
||||
"""Register callback for market end."""
|
||||
self._on_market_end_callbacks.append(callback)
|
||||
|
||||
@property
|
||||
def current_market(self) -> Optional[Market]:
|
||||
"""Get current market."""
|
||||
return self._current_market
|
||||
|
||||
async def run_loop(self):
|
||||
"""
|
||||
Main loop - continuously searches for markets.
|
||||
"""
|
||||
self._running = True
|
||||
logger.info("Market finder started")
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
await self.refresh()
|
||||
|
||||
# Adjust sleep based on market state
|
||||
if self._current_market:
|
||||
remaining = self._current_market.time_remaining_seconds()
|
||||
|
||||
if remaining < 60:
|
||||
# Market ending soon - check frequently
|
||||
await asyncio.sleep(5)
|
||||
elif remaining < 300:
|
||||
# Less than 5 min - moderate frequency
|
||||
await asyncio.sleep(15)
|
||||
else:
|
||||
await asyncio.sleep(self.refresh_interval)
|
||||
else:
|
||||
# No active market - search more frequently
|
||||
await asyncio.sleep(10)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Market finder cancelled")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Market finder error: {e}")
|
||||
await asyncio.sleep(self.retry_delay)
|
||||
|
||||
# Cleanup
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
|
||||
logger.info("Market finder stopped")
|
||||
|
||||
def stop(self):
|
||||
"""Stop the market finder."""
|
||||
self._running = False
|
||||
|
||||
async def close(self):
|
||||
"""Close resources."""
|
||||
self.stop()
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
@@ -0,0 +1,837 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Order Executor
|
||||
|
||||
Executes FAK (Fill-And-Kill) orders with retry logic.
|
||||
|
||||
Features:
|
||||
- FAK orders for immediate fills
|
||||
- Configurable retry attempts
|
||||
- Price tracking to avoid overpaying
|
||||
- Contract counting to prevent overbuying
|
||||
- WebSocket fill monitoring
|
||||
- Detailed logging for analysis
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
import json
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any, Tuple, List
|
||||
|
||||
from py_clob_client.client import ClobClient
|
||||
from py_clob_client.clob_types import OrderArgs, ApiCreds, OrderType
|
||||
from py_clob_client.order_builder.constants import BUY
|
||||
|
||||
logger = logging.getLogger("btc_live.executor")
|
||||
|
||||
# Separate logger for detailed order tracking
|
||||
order_logger = logging.getLogger("btc_live.orders")
|
||||
order_logger.setLevel(logging.DEBUG)
|
||||
|
||||
# Polymarket minimums
|
||||
MIN_ORDER_USD = 1.0
|
||||
MIN_CONTRACTS = 5
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrderResult:
|
||||
"""Result of an order execution attempt."""
|
||||
success: bool
|
||||
order_id: str = ""
|
||||
contracts_filled: int = 0
|
||||
avg_price: float = 0.0
|
||||
total_cost: float = 0.0
|
||||
attempts: int = 0
|
||||
error: str = ""
|
||||
timestamp: datetime = field(default_factory=datetime.now)
|
||||
was_timeout: bool = False # True если был сетевой таймаут (status unknown)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExecutionConfig:
|
||||
"""Configuration for order execution."""
|
||||
bet_amount_usd: float = 10.0
|
||||
price_offset: float = 0.01
|
||||
max_retries: int = 5
|
||||
retry_delay_ms: int = 300
|
||||
fill_timeout_ms: int = 2000
|
||||
min_contracts: int = 5
|
||||
min_order_usd: float = 1.0
|
||||
max_entry_price: float = 0.91
|
||||
|
||||
|
||||
class OrderExecutor:
|
||||
"""
|
||||
Executes orders with retry logic.
|
||||
|
||||
Flow:
|
||||
1. Get best BID price
|
||||
2. Place FAK order at BID + offset
|
||||
3. Wait for fill via WebSocket
|
||||
4. If partial/unfilled, retry with updated price
|
||||
5. Track total contracts to prevent overbuying
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
private_key: str,
|
||||
api_key: str,
|
||||
api_secret: str,
|
||||
api_passphrase: str,
|
||||
clob_host: str = "https://clob.polymarket.com",
|
||||
chain_id: int = 137,
|
||||
signature_type: int = 0,
|
||||
funder_address: Optional[str] = None,
|
||||
user_ws: Optional[Any] = None, # UserWebSocket for fill tracking
|
||||
simulation_mode: bool = False,
|
||||
):
|
||||
self.private_key = private_key
|
||||
self.api_key = api_key
|
||||
self.api_secret = api_secret
|
||||
self.api_passphrase = api_passphrase
|
||||
self.clob_host = clob_host
|
||||
self.chain_id = chain_id
|
||||
self.signature_type = signature_type
|
||||
self.funder_address = funder_address
|
||||
self.user_ws = user_ws
|
||||
self.simulation_mode = simulation_mode
|
||||
|
||||
# Initialize client
|
||||
self._client: Optional[ClobClient] = None
|
||||
self._initialized = False
|
||||
|
||||
# Stats
|
||||
self.orders_placed = 0
|
||||
self.orders_filled = 0
|
||||
self.total_contracts = 0
|
||||
self.total_spent = 0.0
|
||||
|
||||
async def initialize(self) -> bool:
|
||||
"""Initialize the CLOB client."""
|
||||
try:
|
||||
logger.info("Initializing CLOB client...")
|
||||
order_logger.info("=" * 60)
|
||||
order_logger.info("CLOB CLIENT INITIALIZATION")
|
||||
order_logger.info(f" Host: {self.clob_host}")
|
||||
order_logger.info(f" Chain ID: {self.chain_id}")
|
||||
order_logger.info(f" Signature Type: {self.signature_type}")
|
||||
order_logger.info(f" Funder Address: {self.funder_address}")
|
||||
order_logger.info(f" API Key: {self.api_key[:8]}...")
|
||||
|
||||
self._client = ClobClient(
|
||||
host=self.clob_host,
|
||||
key=self.private_key,
|
||||
chain_id=self.chain_id,
|
||||
signature_type=self.signature_type,
|
||||
funder=self.funder_address
|
||||
)
|
||||
|
||||
# Set API credentials
|
||||
api_creds = ApiCreds(
|
||||
api_key=self.api_key,
|
||||
api_secret=self.api_secret,
|
||||
api_passphrase=self.api_passphrase
|
||||
)
|
||||
self._client.set_api_creds(api_creds)
|
||||
|
||||
self._initialized = True
|
||||
logger.info("CLOB client initialized")
|
||||
order_logger.info("CLOB CLIENT INITIALIZED SUCCESSFULLY")
|
||||
order_logger.info("=" * 60)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"CLOB client init error: {e}")
|
||||
order_logger.error(f"CLOB CLIENT INIT FAILED: {e}")
|
||||
return False
|
||||
|
||||
def _calculate_contracts(self, amount_usd: float, price: float) -> int:
|
||||
"""
|
||||
Calculate number of contracts for given amount.
|
||||
|
||||
Args:
|
||||
amount_usd: Amount in USD to spend
|
||||
price: Price per contract
|
||||
|
||||
Returns:
|
||||
Number of contracts (minimum MIN_CONTRACTS)
|
||||
"""
|
||||
if price <= 0:
|
||||
return MIN_CONTRACTS
|
||||
|
||||
contracts = int(amount_usd / price)
|
||||
return max(contracts, MIN_CONTRACTS)
|
||||
|
||||
def _validate_order_size(self, contracts: int, price: float) -> Tuple[int, bool]:
|
||||
"""
|
||||
Validate and adjust order size to meet minimums.
|
||||
|
||||
Args:
|
||||
contracts: Desired number of contracts
|
||||
price: Price per contract
|
||||
|
||||
Returns:
|
||||
Tuple of (adjusted_contracts, is_valid)
|
||||
"""
|
||||
order_value = contracts * price
|
||||
|
||||
# Must be at least MIN_CONTRACTS
|
||||
if contracts < MIN_CONTRACTS:
|
||||
contracts = MIN_CONTRACTS
|
||||
|
||||
# Must be at least MIN_ORDER_USD
|
||||
if order_value < MIN_ORDER_USD:
|
||||
contracts = math.ceil(MIN_ORDER_USD / price)
|
||||
|
||||
return contracts, True
|
||||
|
||||
def _simulate_fill(
|
||||
self,
|
||||
config: ExecutionConfig,
|
||||
websocket_price: float,
|
||||
) -> OrderResult:
|
||||
"""
|
||||
Instant hypothetical fill at limit (WS ask + offset), same sizing rules as live.
|
||||
"""
|
||||
initial_price = websocket_price
|
||||
order_price = initial_price + config.price_offset
|
||||
if order_price > config.max_entry_price:
|
||||
order_logger.warning(
|
||||
f"SIMULATION: price {order_price:.4f} > max_entry {config.max_entry_price:.4f}"
|
||||
)
|
||||
return OrderResult(success=False, error="Price exceeded max entry")
|
||||
|
||||
contracts_needed = self._calculate_contracts(config.bet_amount_usd, initial_price)
|
||||
order_size, _ = self._validate_order_size(contracts_needed, order_price)
|
||||
total_cost = order_size * order_price
|
||||
oid = f"SIM-{uuid.uuid4().hex[:12]}"
|
||||
order_logger.info("=" * 60)
|
||||
order_logger.info("SIMULATION ENTRY (no CLOB order sent)")
|
||||
order_logger.info(f" Hypothetical fill: {order_size} @ {order_price:.4f} cost ${total_cost:.2f}")
|
||||
order_logger.info(f" Order ID: {oid}")
|
||||
order_logger.info("=" * 60)
|
||||
logger.info(f"Simulation fill: {order_size} contracts @ {order_price:.4f}")
|
||||
return OrderResult(
|
||||
success=True,
|
||||
order_id=oid,
|
||||
contracts_filled=order_size,
|
||||
avg_price=order_price,
|
||||
total_cost=total_cost,
|
||||
attempts=1,
|
||||
error="",
|
||||
)
|
||||
|
||||
async def get_best_bid(self, token_id: str) -> Optional[float]:
|
||||
"""
|
||||
Get best BID price for token.
|
||||
|
||||
Args:
|
||||
token_id: Token to get price for
|
||||
|
||||
Returns:
|
||||
Best bid price or None
|
||||
"""
|
||||
if not self._client:
|
||||
order_logger.warning("get_best_bid: Client not initialized")
|
||||
return None
|
||||
|
||||
start_time = time.time()
|
||||
try:
|
||||
# Use CLOB API to get orderbook
|
||||
book = await asyncio.to_thread(
|
||||
self._client.get_order_book,
|
||||
token_id
|
||||
)
|
||||
|
||||
elapsed = (time.time() - start_time) * 1000
|
||||
|
||||
# Handle OrderBookSummary object from py-clob-client
|
||||
bids = None
|
||||
|
||||
# Try object attribute access first
|
||||
if hasattr(book, 'bids'):
|
||||
bids = book.bids
|
||||
# Try dict-like access
|
||||
elif isinstance(book, dict):
|
||||
bids = book.get("bids", [])
|
||||
|
||||
# Convert bids to list if needed
|
||||
if bids is None:
|
||||
bids = []
|
||||
|
||||
if bids:
|
||||
# Handle OrderSummary objects or dicts
|
||||
first_bid = bids[0]
|
||||
if hasattr(first_bid, 'price'):
|
||||
best_bid = float(first_bid.price)
|
||||
elif isinstance(first_bid, dict):
|
||||
best_bid = float(first_bid.get("price", 0))
|
||||
else:
|
||||
# Try direct conversion
|
||||
best_bid = float(first_bid)
|
||||
|
||||
order_logger.debug(
|
||||
f"ORDERBOOK: token={token_id[:20]}... | "
|
||||
f"best_bid={best_bid:.4f} | bids={len(bids)} | "
|
||||
f"latency={elapsed:.0f}ms"
|
||||
)
|
||||
return best_bid
|
||||
|
||||
order_logger.warning(f"ORDERBOOK: No bids for token={token_id[:20]}...")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting best bid: {e}")
|
||||
order_logger.error(f"ORDERBOOK ERROR: {e} | book_type={type(book).__name__}")
|
||||
return None
|
||||
|
||||
async def get_best_ask(self, token_id: str) -> Optional[float]:
|
||||
"""
|
||||
Get best ASK price for token.
|
||||
|
||||
Args:
|
||||
token_id: Token to get price for
|
||||
|
||||
Returns:
|
||||
Best ask price or None
|
||||
"""
|
||||
if not self._client:
|
||||
order_logger.warning("get_best_ask: Client not initialized")
|
||||
return None
|
||||
|
||||
start_time = time.time()
|
||||
try:
|
||||
# Use CLOB API to get orderbook
|
||||
book = await asyncio.to_thread(
|
||||
self._client.get_order_book,
|
||||
token_id
|
||||
)
|
||||
|
||||
elapsed = (time.time() - start_time) * 1000
|
||||
|
||||
# Handle OrderBookSummary object from py-clob-client
|
||||
asks = None
|
||||
|
||||
# Try object attribute access first
|
||||
if hasattr(book, 'asks'):
|
||||
asks = book.asks
|
||||
# Try dict-like access
|
||||
elif isinstance(book, dict):
|
||||
asks = book.get("asks", [])
|
||||
|
||||
# Convert asks to list if needed
|
||||
if asks is None:
|
||||
asks = []
|
||||
|
||||
if asks:
|
||||
# Handle OrderSummary objects or dicts
|
||||
first_ask = asks[0]
|
||||
if hasattr(first_ask, 'price'):
|
||||
best_ask = float(first_ask.price)
|
||||
elif isinstance(first_ask, dict):
|
||||
best_ask = float(first_ask.get("price", 0))
|
||||
else:
|
||||
# Try direct conversion
|
||||
best_ask = float(first_ask)
|
||||
|
||||
order_logger.debug(
|
||||
f"ORDERBOOK ASK: token={token_id[:20]}... | "
|
||||
f"best_ask={best_ask:.4f} | asks={len(asks)} | "
|
||||
f"latency={elapsed:.0f}ms"
|
||||
)
|
||||
return best_ask
|
||||
|
||||
order_logger.warning(f"ORDERBOOK: No asks for token={token_id[:20]}...")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting best ask: {e}")
|
||||
order_logger.error(f"ORDERBOOK ASK ERROR: {e} | book_type={type(book).__name__}")
|
||||
return None
|
||||
|
||||
async def place_fak_order(
|
||||
self,
|
||||
token_id: str,
|
||||
price: float,
|
||||
size: int
|
||||
) -> Tuple[bool, str, Dict]:
|
||||
"""
|
||||
Place a FAK (Fill-And-Kill) order.
|
||||
|
||||
Args:
|
||||
token_id: Token to buy
|
||||
price: Order price
|
||||
size: Number of contracts
|
||||
|
||||
Returns:
|
||||
Tuple of (success, order_id, response)
|
||||
"""
|
||||
if not self._client:
|
||||
order_logger.error("PLACE_ORDER: Client not initialized")
|
||||
return False, "", {"error": "Client not initialized"}
|
||||
|
||||
order_value = size * price
|
||||
order_logger.info("-" * 50)
|
||||
order_logger.info(f"PLACING ORDER")
|
||||
order_logger.info(f" Token: {token_id[:30]}...")
|
||||
order_logger.info(f" Side: BUY")
|
||||
order_logger.info(f" Price: {price:.4f}")
|
||||
order_logger.info(f" Size: {size} contracts")
|
||||
order_logger.info(f" Value: ${order_value:.2f}")
|
||||
order_logger.info(f" Type: FAK (Fill-And-Kill)")
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# Create order
|
||||
sign_start = time.time()
|
||||
signed_order = await asyncio.to_thread(
|
||||
self._client.create_order,
|
||||
OrderArgs(
|
||||
price=price,
|
||||
size=size,
|
||||
side=BUY,
|
||||
token_id=token_id
|
||||
)
|
||||
)
|
||||
sign_elapsed = (time.time() - sign_start) * 1000
|
||||
order_logger.debug(f" Order signed in {sign_elapsed:.0f}ms")
|
||||
|
||||
# Post FAK order (Fill-And-Kill: fill what you can, cancel rest)
|
||||
post_start = time.time()
|
||||
response = await asyncio.to_thread(
|
||||
self._client.post_order,
|
||||
signed_order,
|
||||
OrderType.FAK
|
||||
)
|
||||
post_elapsed = (time.time() - post_start) * 1000
|
||||
total_elapsed = (time.time() - start_time) * 1000
|
||||
|
||||
# Handle response
|
||||
if isinstance(response, dict):
|
||||
success = response.get("success", False)
|
||||
order_id = response.get("orderID", "")
|
||||
status = response.get("status", "")
|
||||
error_msg = response.get("errorMsg", "")
|
||||
taking_amount = response.get("takingAmount", "")
|
||||
making_amount = response.get("makingAmount", "")
|
||||
else:
|
||||
success = getattr(response, 'success', False)
|
||||
order_id = getattr(response, 'orderID', "")
|
||||
status = getattr(response, 'status', "")
|
||||
error_msg = getattr(response, 'errorMsg', "")
|
||||
taking_amount = getattr(response, 'takingAmount', "")
|
||||
making_amount = getattr(response, 'makingAmount', "")
|
||||
|
||||
self.orders_placed += 1
|
||||
|
||||
order_logger.info(f"ORDER RESPONSE:")
|
||||
order_logger.info(f" Success: {success}")
|
||||
order_logger.info(f" Order ID: {order_id[:40] if order_id else 'N/A'}...")
|
||||
order_logger.info(f" Status: {status}")
|
||||
if taking_amount:
|
||||
order_logger.info(f" Taking Amount: {taking_amount}")
|
||||
if making_amount:
|
||||
order_logger.info(f" Making Amount: {making_amount}")
|
||||
if error_msg:
|
||||
order_logger.warning(f" Error: {error_msg}")
|
||||
order_logger.info(f" Latency: sign={sign_elapsed:.0f}ms, post={post_elapsed:.0f}ms, total={total_elapsed:.0f}ms")
|
||||
order_logger.info("-" * 50)
|
||||
|
||||
logger.info(f"Order placed: {success}, ID: {order_id[:20] if order_id else 'N/A'}...")
|
||||
|
||||
return success, order_id, response if isinstance(response, dict) else {"success": success, "orderID": order_id, "status": status}
|
||||
|
||||
except Exception as e:
|
||||
elapsed = (time.time() - start_time) * 1000
|
||||
logger.error(f"Order placement error: {e}")
|
||||
order_logger.error(f"ORDER FAILED: {e}")
|
||||
order_logger.error(f" Elapsed: {elapsed:.0f}ms")
|
||||
order_logger.info("-" * 50)
|
||||
return False, "", {"error": str(e)}
|
||||
|
||||
async def cancel_order(self, order_id: str) -> bool:
|
||||
"""Cancel an order."""
|
||||
if not self._client:
|
||||
return False
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
self._client.cancel,
|
||||
order_id
|
||||
)
|
||||
logger.info(f"Order cancelled: {order_id[:20]}...")
|
||||
order_logger.info(f" ✅ Cancelled order: {order_id[:30]}...")
|
||||
return True
|
||||
except Exception as e:
|
||||
# Ордер мог уже исполниться или не существует - это нормально
|
||||
logger.debug(f"Cancel order note: {e}")
|
||||
order_logger.debug(f" Cancel note: {e}")
|
||||
return False
|
||||
|
||||
async def cancel_orders(self, order_ids: List[str]) -> Dict[str, bool]:
|
||||
"""
|
||||
Cancel multiple orders.
|
||||
Returns dict of order_id -> cancelled (True/False)
|
||||
"""
|
||||
if not self._client or not order_ids:
|
||||
return {}
|
||||
|
||||
results = {}
|
||||
order_logger.info(f" Cancelling {len(order_ids)} previous order(s)...")
|
||||
|
||||
try:
|
||||
# Используем batch cancel если доступен
|
||||
resp = await asyncio.to_thread(
|
||||
self._client.cancel_orders,
|
||||
order_ids
|
||||
)
|
||||
|
||||
# Парсим ответ
|
||||
cancelled = resp.get('canceled', []) if isinstance(resp, dict) else []
|
||||
not_cancelled = resp.get('not_canceled', {}) if isinstance(resp, dict) else {}
|
||||
|
||||
for oid in order_ids:
|
||||
if oid in cancelled:
|
||||
results[oid] = True
|
||||
order_logger.info(f" ✅ Cancelled: {oid[:25]}...")
|
||||
else:
|
||||
results[oid] = False
|
||||
reason = not_cancelled.get(oid, "unknown/already filled")
|
||||
order_logger.info(f" ⚠️ Not cancelled: {oid[:25]}... ({reason})")
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Batch cancel error: {e}")
|
||||
order_logger.warning(f" Batch cancel failed: {e}, trying individual cancels...")
|
||||
|
||||
# Fallback: отменяем по одному
|
||||
for oid in order_ids:
|
||||
results[oid] = await self.cancel_order(oid)
|
||||
|
||||
return results
|
||||
|
||||
async def get_order_fills(self, order_id: str) -> int:
|
||||
"""
|
||||
Check how many contracts were filled for an order.
|
||||
Returns number of contracts filled (0 if not filled or error).
|
||||
"""
|
||||
if not self._client:
|
||||
return 0
|
||||
|
||||
try:
|
||||
# Пробуем получить ордер через API
|
||||
order = await asyncio.to_thread(
|
||||
self._client.get_order,
|
||||
order_id
|
||||
)
|
||||
|
||||
if order:
|
||||
size_matched = getattr(order, 'size_matched', None) or order.get('size_matched', 0)
|
||||
filled = int(float(size_matched)) if size_matched else 0
|
||||
order_logger.info(f" Order {order_id[:20]}... filled: {filled} contracts")
|
||||
return filled
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Get order fills error: {e}")
|
||||
order_logger.debug(f" Could not get fills for {order_id[:20]}...: {e}")
|
||||
|
||||
return 0
|
||||
|
||||
async def wait_for_fill(
|
||||
self,
|
||||
order_id: str,
|
||||
timeout_ms: int = 2000
|
||||
) -> Tuple[int, float]:
|
||||
"""
|
||||
Wait for order fill via WebSocket.
|
||||
|
||||
Args:
|
||||
order_id: Order to wait for
|
||||
timeout_ms: Timeout in milliseconds
|
||||
|
||||
Returns:
|
||||
Tuple of (contracts_filled, avg_price)
|
||||
"""
|
||||
if self.user_ws:
|
||||
try:
|
||||
order = await self.user_ws.wait_for_fill(
|
||||
order_id,
|
||||
timeout=timeout_ms / 1000
|
||||
)
|
||||
|
||||
if order:
|
||||
filled = int(order.size_matched)
|
||||
price = order.price
|
||||
return filled, price
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Wait for fill error: {e}")
|
||||
|
||||
# Fallback - assume order didn't fill
|
||||
return 0, 0.0
|
||||
|
||||
async def execute_entry(
|
||||
self,
|
||||
token_id: str,
|
||||
config: ExecutionConfig,
|
||||
websocket_price: Optional[float] = None
|
||||
) -> OrderResult:
|
||||
"""
|
||||
Execute entry order with retry logic.
|
||||
|
||||
Args:
|
||||
token_id: Token to buy
|
||||
config: Execution configuration
|
||||
websocket_price: Current price from WebSocket (ASK for buying)
|
||||
|
||||
Returns:
|
||||
OrderResult with execution details
|
||||
"""
|
||||
if self.simulation_mode:
|
||||
if not websocket_price:
|
||||
return OrderResult(success=False, error="Could not get price")
|
||||
return self._simulate_fill(config, websocket_price)
|
||||
|
||||
entry_start = time.time()
|
||||
|
||||
order_logger.info("=" * 60)
|
||||
order_logger.info("ENTRY EXECUTION STARTED")
|
||||
order_logger.info(f" Timestamp: {datetime.now().isoformat()}")
|
||||
order_logger.info(f" Token: {token_id[:40]}...")
|
||||
order_logger.info(f" Budget: ${config.bet_amount_usd}")
|
||||
order_logger.info(f" Price Offset: {config.price_offset}")
|
||||
order_logger.info(f" Max Retries: {config.max_retries}")
|
||||
order_logger.info(f" Max Entry Price: {config.max_entry_price}")
|
||||
order_logger.info(f" WebSocket Price: {websocket_price}")
|
||||
|
||||
if not self._initialized:
|
||||
order_logger.warning(" Client not initialized, initializing...")
|
||||
if not await self.initialize():
|
||||
order_logger.error("ENTRY FAILED: Could not initialize client")
|
||||
return OrderResult(success=False, error="Failed to initialize")
|
||||
|
||||
# Calculate contracts needed - use WebSocket price
|
||||
initial_price = websocket_price
|
||||
if not initial_price:
|
||||
order_logger.error("ENTRY FAILED: Could not get initial price")
|
||||
return OrderResult(success=False, error="Could not get price")
|
||||
|
||||
contracts_needed = self._calculate_contracts(config.bet_amount_usd, initial_price)
|
||||
contracts_bought = 0
|
||||
total_cost = 0.0
|
||||
attempt = 0
|
||||
last_error = ""
|
||||
fills_log = []
|
||||
|
||||
order_logger.info(f" Initial Price: {initial_price:.4f}")
|
||||
order_logger.info(f" Contracts Needed: {contracts_needed}")
|
||||
order_logger.info(f" Estimated Cost: ${contracts_needed * initial_price:.2f}")
|
||||
order_logger.info("-" * 40)
|
||||
|
||||
logger.info(
|
||||
f"Starting entry: need {contracts_needed} contracts, "
|
||||
f"budget ${config.bet_amount_usd}"
|
||||
)
|
||||
|
||||
# Calculate order price once
|
||||
order_price = initial_price + config.price_offset
|
||||
|
||||
# Check max price limit
|
||||
if order_price > config.max_entry_price:
|
||||
order_logger.warning(
|
||||
f" PRICE LIMIT: {order_price:.4f} > max {config.max_entry_price:.4f}"
|
||||
)
|
||||
return OrderResult(success=False, error="Price exceeded max entry")
|
||||
|
||||
order_logger.info(f" Order Price: {order_price:.4f} (price + {config.price_offset})")
|
||||
|
||||
# Список всех размещённых order_id для отслеживания через WebSocket
|
||||
placed_order_ids = []
|
||||
|
||||
while contracts_bought < contracts_needed and attempt < config.max_retries:
|
||||
attempt += 1
|
||||
attempt_start = time.time()
|
||||
|
||||
order_logger.info(f"ATTEMPT {attempt}/{config.max_retries}")
|
||||
|
||||
# Рассчитываем ОСТАВШЕЕСЯ количество контрактов
|
||||
# (FAK ордера сразу возвращают takingAmount, так что contracts_bought уже актуален)
|
||||
remaining = contracts_needed - contracts_bought
|
||||
|
||||
# Если уже купили достаточно - выходим
|
||||
if remaining <= 0:
|
||||
order_logger.info(f" ✅ Already filled {contracts_bought}/{contracts_needed} - no retry needed")
|
||||
break
|
||||
|
||||
order_size, _ = self._validate_order_size(remaining, order_price)
|
||||
|
||||
order_logger.info(f" Contracts bought so far: {contracts_bought}")
|
||||
order_logger.info(f" Remaining needed: {remaining}")
|
||||
order_logger.info(f" Order size: {order_size}")
|
||||
|
||||
logger.info(f"Attempt {attempt}: placing {order_size} contracts @ {order_price:.2f}")
|
||||
|
||||
# Place order
|
||||
success, order_id, response = await self.place_fak_order(
|
||||
token_id,
|
||||
order_price,
|
||||
order_size
|
||||
)
|
||||
|
||||
# Запоминаем order_id для отслеживания
|
||||
if order_id:
|
||||
placed_order_ids.append(order_id)
|
||||
order_logger.info(f" Order ID: {order_id[:30]}...")
|
||||
|
||||
# ============================================================
|
||||
# ЖЕЛЕЗНОЕ ПРАВИЛО: Если не знаем исполнился ли ордер - STOP
|
||||
# Retry ТОЛЬКО если точно знаем результат из API ответа
|
||||
# ============================================================
|
||||
|
||||
if not success:
|
||||
error_msg = response.get("errorMsg", "") or response.get("error", "")
|
||||
|
||||
# ============================================================
|
||||
# ЖЕЛЕЗНОЕ ПРАВИЛО v2: Определяем ТОЧНО был ли таймаут
|
||||
# status_code=None в ошибке = сетевой таймаут = НЕ ЗНАЕМ РЕЗУЛЬТАТ
|
||||
# status_code=400/etc = API ответил чётко = ордер НЕ исполнился
|
||||
# ============================================================
|
||||
|
||||
is_network_timeout = False
|
||||
|
||||
# Проверяем status_code в ошибке PolyApiException
|
||||
if "status_code=None" in error_msg:
|
||||
is_network_timeout = True
|
||||
elif "Request exception" in error_msg and "status_code" not in error_msg:
|
||||
is_network_timeout = True
|
||||
elif "timed out" in error_msg.lower() and "status_code=4" not in error_msg:
|
||||
is_network_timeout = True
|
||||
|
||||
if is_network_timeout:
|
||||
last_error = f"🛑 STOP: Network timeout (status_code=None) - order status UNKNOWN. No retry."
|
||||
order_logger.error(f" {last_error}")
|
||||
logger.error(last_error)
|
||||
|
||||
entry_elapsed = (time.time() - entry_start) * 1000
|
||||
order_logger.info(f" Total execution time: {entry_elapsed:.0f}ms")
|
||||
order_logger.info("=" * 60)
|
||||
order_logger.info("ENTRY EXECUTION COMPLETE (TIMEOUT)")
|
||||
order_logger.info(f" Success: False")
|
||||
order_logger.info(f" Contracts Filled: {contracts_bought}/{contracts_needed}")
|
||||
order_logger.info(f" Error: {last_error}")
|
||||
order_logger.info(f" Fills: {json.dumps(fills_log)}")
|
||||
order_logger.info("=" * 60)
|
||||
|
||||
# Возвращаем с флагом таймаута - main.py должен заблокировать повторные попытки!
|
||||
return OrderResult(
|
||||
success=False,
|
||||
contracts_filled=contracts_bought,
|
||||
avg_price=total_cost / contracts_bought if contracts_bought > 0 else 0,
|
||||
total_cost=total_cost,
|
||||
attempts=attempt,
|
||||
error=last_error,
|
||||
was_timeout=True # КРИТИЧНО: флаг таймаута для main.py
|
||||
)
|
||||
|
||||
# Чёткий отказ API (status_code=400, etc) = ордер НЕ исполнился = можно retry
|
||||
last_error = error_msg or "Order failed"
|
||||
order_logger.warning(f" Order rejected (API): {last_error}")
|
||||
await asyncio.sleep(config.retry_delay_ms / 1000)
|
||||
continue
|
||||
|
||||
# API ответил успешно - знаем точный результат
|
||||
status = response.get("status", "")
|
||||
|
||||
if status == "matched":
|
||||
# Ордер исполнен - берём количество из ответа
|
||||
api_taking = response.get("takingAmount", "")
|
||||
filled = int(float(api_taking)) if api_taking else 0
|
||||
|
||||
if filled > order_size:
|
||||
order_logger.warning(f" ⚠️ OVERFILL: got {filled}, ordered {order_size}")
|
||||
logger.warning(f"Entry overfill: {filled} > {order_size}")
|
||||
|
||||
fill_price = order_price
|
||||
|
||||
contracts_bought += filled
|
||||
total_cost += filled * fill_price
|
||||
self.orders_filled += 1
|
||||
self.total_contracts += filled
|
||||
self.total_spent += filled * fill_price
|
||||
|
||||
fills_log.append({
|
||||
"attempt": attempt,
|
||||
"filled": filled,
|
||||
"price": fill_price,
|
||||
"order_id": order_id[:20] if order_id else "N/A",
|
||||
"source": "api_response",
|
||||
"timestamp": datetime.now().isoformat()
|
||||
})
|
||||
|
||||
order_logger.info(f" ✅ FILLED: {filled} contracts @ {fill_price:.4f}")
|
||||
order_logger.info(f" Progress: {contracts_bought}/{contracts_needed} ({contracts_bought/contracts_needed*100:.1f}%)")
|
||||
|
||||
logger.info(f"Filled: {filled} @ {fill_price:.2f} (total: {contracts_bought}/{contracts_needed})")
|
||||
else:
|
||||
order_logger.info(f" Status: {status} (not matched)")
|
||||
logger.info("No fill at this price level")
|
||||
|
||||
attempt_elapsed = (time.time() - attempt_start) * 1000
|
||||
order_logger.info(f" Attempt time: {attempt_elapsed:.0f}ms")
|
||||
|
||||
# Короткая пауза перед следующей попыткой
|
||||
if contracts_bought < contracts_needed:
|
||||
await asyncio.sleep(config.retry_delay_ms / 1000)
|
||||
|
||||
entry_elapsed = (time.time() - entry_start) * 1000
|
||||
order_logger.info(f" Total execution time: {entry_elapsed:.0f}ms")
|
||||
|
||||
# Calculate result
|
||||
avg_price = total_cost / contracts_bought if contracts_bought > 0 else 0
|
||||
entry_elapsed = (time.time() - entry_start) * 1000
|
||||
|
||||
result = OrderResult(
|
||||
success=contracts_bought > 0,
|
||||
contracts_filled=contracts_bought,
|
||||
avg_price=avg_price,
|
||||
total_cost=total_cost,
|
||||
attempts=attempt,
|
||||
error=last_error if contracts_bought == 0 else ""
|
||||
)
|
||||
|
||||
order_logger.info("=" * 60)
|
||||
order_logger.info("ENTRY EXECUTION COMPLETE")
|
||||
order_logger.info(f" Success: {result.success}")
|
||||
order_logger.info(f" Contracts Filled: {result.contracts_filled}/{contracts_needed}")
|
||||
order_logger.info(f" Average Price: {result.avg_price:.4f}")
|
||||
order_logger.info(f" Total Cost: ${result.total_cost:.2f}")
|
||||
order_logger.info(f" Attempts: {result.attempts}")
|
||||
order_logger.info(f" Total Time: {entry_elapsed:.0f}ms")
|
||||
if result.error:
|
||||
order_logger.info(f" Error: {result.error}")
|
||||
order_logger.info(f" Fills: {json.dumps(fills_log)}")
|
||||
order_logger.info("=" * 60)
|
||||
|
||||
logger.info(
|
||||
f"Entry complete: {result.contracts_filled} contracts, "
|
||||
f"${result.total_cost:.2f}, {result.attempts} attempts"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def get_stats(self) -> Dict:
|
||||
"""Get executor statistics."""
|
||||
return {
|
||||
"orders_placed": self.orders_placed,
|
||||
"orders_filled": self.orders_filled,
|
||||
"total_contracts": self.total_contracts,
|
||||
"total_spent": self.total_spent,
|
||||
"avg_price": self.total_spent / self.total_contracts if self.total_contracts > 0 else 0
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Position Tracker
|
||||
|
||||
Tracks all positions and calculates P&L.
|
||||
|
||||
Features:
|
||||
- Trade history logging
|
||||
- P&L calculation
|
||||
- Win/loss statistics
|
||||
- Equity curve tracking
|
||||
- Persistent state
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, List, Any
|
||||
|
||||
logger = logging.getLogger("btc_live.tracker")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Trade:
|
||||
"""Represents a completed trade."""
|
||||
id: str
|
||||
market_slug: str
|
||||
side: str # "UP" or "DOWN"
|
||||
|
||||
# Entry
|
||||
entry_price: float
|
||||
entry_contracts: int
|
||||
entry_cost: float
|
||||
entry_time: datetime
|
||||
|
||||
# Hedge (optional)
|
||||
hedged: bool = False
|
||||
hedge_contracts: int = 0
|
||||
hedge_price: float = 0.0
|
||||
hedge_cost: float = 0.0
|
||||
|
||||
# Exit
|
||||
winner: str = "" # "UP" or "DOWN"
|
||||
exit_time: Optional[datetime] = None
|
||||
|
||||
# P&L
|
||||
pnl: float = 0.0
|
||||
pnl_pct: float = 0.0
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"market_slug": self.market_slug,
|
||||
"side": self.side,
|
||||
"entry_price": self.entry_price,
|
||||
"entry_contracts": self.entry_contracts,
|
||||
"entry_cost": self.entry_cost,
|
||||
"entry_time": self.entry_time.isoformat() if self.entry_time else None,
|
||||
"hedged": self.hedged,
|
||||
"hedge_contracts": self.hedge_contracts,
|
||||
"hedge_price": self.hedge_price,
|
||||
"hedge_cost": self.hedge_cost,
|
||||
"winner": self.winner,
|
||||
"exit_time": self.exit_time.isoformat() if self.exit_time else None,
|
||||
"pnl": self.pnl,
|
||||
"pnl_pct": self.pnl_pct
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict) -> "Trade":
|
||||
entry_time = data.get("entry_time")
|
||||
if entry_time and isinstance(entry_time, str):
|
||||
entry_time = datetime.fromisoformat(entry_time)
|
||||
|
||||
exit_time = data.get("exit_time")
|
||||
if exit_time and isinstance(exit_time, str):
|
||||
exit_time = datetime.fromisoformat(exit_time)
|
||||
|
||||
return cls(
|
||||
id=data.get("id", ""),
|
||||
market_slug=data.get("market_slug", ""),
|
||||
side=data.get("side", ""),
|
||||
entry_price=data.get("entry_price", 0),
|
||||
entry_contracts=data.get("entry_contracts", 0),
|
||||
entry_cost=data.get("entry_cost", 0),
|
||||
entry_time=entry_time or datetime.now(),
|
||||
hedged=data.get("hedged", False),
|
||||
hedge_contracts=data.get("hedge_contracts", 0),
|
||||
hedge_price=data.get("hedge_price", 0),
|
||||
hedge_cost=data.get("hedge_cost", 0),
|
||||
winner=data.get("winner", ""),
|
||||
exit_time=exit_time,
|
||||
pnl=data.get("pnl", 0),
|
||||
pnl_pct=data.get("pnl_pct", 0)
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Stats:
|
||||
"""Trading statistics."""
|
||||
total_trades: int = 0
|
||||
wins: int = 0
|
||||
losses: int = 0
|
||||
total_pnl: float = 0.0
|
||||
max_drawdown: float = 0.0
|
||||
win_rate: float = 0.0
|
||||
avg_win: float = 0.0
|
||||
avg_loss: float = 0.0
|
||||
profit_factor: float = 0.0
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class PositionTracker:
|
||||
"""
|
||||
Tracks positions and calculates P&L.
|
||||
|
||||
Features:
|
||||
- Active position tracking
|
||||
- Trade history with JSONL persistence
|
||||
- P&L calculations
|
||||
- Win/loss statistics
|
||||
- Equity curve for charting
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
trades_file: str = "logs/trades.jsonl",
|
||||
state_file: str = "logs/state.json"
|
||||
):
|
||||
self.trades_file = Path(trades_file)
|
||||
self.state_file = Path(state_file)
|
||||
|
||||
# Ensure directories exist
|
||||
self.trades_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Current state
|
||||
self._active_trade: Optional[Trade] = None
|
||||
self._trades: List[Trade] = []
|
||||
self._equity_curve: List[float] = [0.0]
|
||||
|
||||
# Stats
|
||||
self._stats = Stats()
|
||||
|
||||
# Load existing state
|
||||
self._load_state()
|
||||
|
||||
def _load_state(self):
|
||||
"""Load state from files."""
|
||||
# Load trades history
|
||||
if self.trades_file.exists():
|
||||
try:
|
||||
with open(self.trades_file, 'r') as f:
|
||||
for line in f:
|
||||
if line.strip():
|
||||
data = json.loads(line)
|
||||
trade = Trade.from_dict(data)
|
||||
self._trades.append(trade)
|
||||
|
||||
logger.info(f"Loaded {len(self._trades)} trades from history")
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading trades: {e}")
|
||||
|
||||
# Load state
|
||||
if self.state_file.exists():
|
||||
try:
|
||||
with open(self.state_file, 'r') as f:
|
||||
state = json.load(f)
|
||||
|
||||
# Restore active trade
|
||||
if state.get("active_trade"):
|
||||
self._active_trade = Trade.from_dict(state["active_trade"])
|
||||
|
||||
# Restore equity curve
|
||||
self._equity_curve = state.get("equity_curve", [0.0])
|
||||
|
||||
# Restore stats
|
||||
stats_data = state.get("stats", {})
|
||||
self._stats = Stats(**stats_data)
|
||||
|
||||
logger.info("State restored")
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading state: {e}")
|
||||
|
||||
# Recalculate stats from trades
|
||||
self._recalculate_stats()
|
||||
|
||||
def _save_state(self):
|
||||
"""Save current state to file."""
|
||||
try:
|
||||
state = {
|
||||
"active_trade": self._active_trade.to_dict() if self._active_trade else None,
|
||||
"equity_curve": self._equity_curve[-100:], # Keep last 100 points
|
||||
"stats": self._stats.to_dict(),
|
||||
"last_update": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
with open(self.state_file, 'w') as f:
|
||||
json.dump(state, f, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving state: {e}")
|
||||
|
||||
def _append_trade(self, trade: Trade):
|
||||
"""Append trade to history file."""
|
||||
try:
|
||||
with open(self.trades_file, 'a') as f:
|
||||
f.write(json.dumps(trade.to_dict()) + '\n')
|
||||
except Exception as e:
|
||||
logger.error(f"Error appending trade: {e}")
|
||||
|
||||
def _recalculate_stats(self):
|
||||
"""Recalculate statistics from trade history."""
|
||||
if not self._trades:
|
||||
return
|
||||
|
||||
completed = [t for t in self._trades if t.winner]
|
||||
|
||||
wins = [t for t in completed if t.pnl > 0]
|
||||
losses = [t for t in completed if t.pnl <= 0]
|
||||
|
||||
self._stats.total_trades = len(completed)
|
||||
self._stats.wins = len(wins)
|
||||
self._stats.losses = len(losses)
|
||||
self._stats.total_pnl = sum(t.pnl for t in completed)
|
||||
|
||||
if self._stats.total_trades > 0:
|
||||
self._stats.win_rate = self._stats.wins / self._stats.total_trades
|
||||
|
||||
if wins:
|
||||
self._stats.avg_win = sum(t.pnl for t in wins) / len(wins)
|
||||
|
||||
if losses:
|
||||
self._stats.avg_loss = abs(sum(t.pnl for t in losses) / len(losses))
|
||||
|
||||
# Profit factor
|
||||
total_wins = sum(t.pnl for t in wins)
|
||||
total_losses = abs(sum(t.pnl for t in losses))
|
||||
if total_losses > 0:
|
||||
self._stats.profit_factor = total_wins / total_losses
|
||||
|
||||
# Max drawdown
|
||||
equity = 0
|
||||
peak = 0
|
||||
max_dd = 0
|
||||
for t in completed:
|
||||
equity += t.pnl
|
||||
peak = max(peak, equity)
|
||||
dd = peak - equity
|
||||
max_dd = max(max_dd, dd)
|
||||
self._stats.max_drawdown = max_dd
|
||||
|
||||
# Rebuild equity curve
|
||||
self._equity_curve = [0.0]
|
||||
equity = 0
|
||||
for t in completed:
|
||||
equity += t.pnl
|
||||
self._equity_curve.append(equity)
|
||||
|
||||
def open_trade(
|
||||
self,
|
||||
trade_id: str,
|
||||
market_slug: str,
|
||||
side: str,
|
||||
entry_price: float,
|
||||
entry_contracts: int,
|
||||
entry_cost: float
|
||||
):
|
||||
"""
|
||||
Open a new trade.
|
||||
|
||||
Args:
|
||||
trade_id: Unique trade identifier
|
||||
market_slug: Market slug
|
||||
side: "UP" or "DOWN"
|
||||
entry_price: Average entry price
|
||||
entry_contracts: Number of contracts
|
||||
entry_cost: Total entry cost
|
||||
"""
|
||||
self._active_trade = Trade(
|
||||
id=trade_id,
|
||||
market_slug=market_slug,
|
||||
side=side,
|
||||
entry_price=entry_price,
|
||||
entry_contracts=entry_contracts,
|
||||
entry_cost=entry_cost,
|
||||
entry_time=datetime.now()
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Trade opened: {side} {entry_contracts} @ {entry_price:.2f} "
|
||||
f"(cost: ${entry_cost:.2f})"
|
||||
)
|
||||
|
||||
self._save_state()
|
||||
|
||||
def update_hedge(
|
||||
self,
|
||||
hedge_contracts: int,
|
||||
hedge_price: float,
|
||||
hedge_cost: float
|
||||
):
|
||||
"""Update trade with hedge information."""
|
||||
if not self._active_trade:
|
||||
logger.warning("No active trade to update hedge")
|
||||
return
|
||||
|
||||
self._active_trade.hedged = True
|
||||
self._active_trade.hedge_contracts = hedge_contracts
|
||||
self._active_trade.hedge_price = hedge_price
|
||||
self._active_trade.hedge_cost = hedge_cost
|
||||
|
||||
logger.info(
|
||||
f"Hedge added: {hedge_contracts} @ {hedge_price:.3f} "
|
||||
f"(cost: ${hedge_cost:.2f})"
|
||||
)
|
||||
|
||||
self._save_state()
|
||||
|
||||
def close_trade(self, winner: str):
|
||||
"""
|
||||
Close the active trade with result.
|
||||
|
||||
Args:
|
||||
winner: Winning side ("UP" or "DOWN")
|
||||
"""
|
||||
if not self._active_trade:
|
||||
logger.warning("No active trade to close")
|
||||
return
|
||||
|
||||
trade = self._active_trade
|
||||
trade.winner = winner
|
||||
trade.exit_time = datetime.now()
|
||||
|
||||
# Calculate P&L
|
||||
if trade.hedged:
|
||||
# Hedged trade - profit is locked
|
||||
# If our side won: we get entry_contracts * 1.0
|
||||
# If our side lost: hedge pays out
|
||||
if trade.side == winner:
|
||||
# Win - collect main position
|
||||
payout = trade.entry_contracts * 1.0
|
||||
cost = trade.entry_cost + trade.hedge_cost
|
||||
trade.pnl = payout - cost
|
||||
else:
|
||||
# Lose - hedge pays out
|
||||
hedge_payout = trade.hedge_contracts * 1.0
|
||||
cost = trade.entry_cost + trade.hedge_cost
|
||||
trade.pnl = hedge_payout - cost
|
||||
else:
|
||||
# Unhedged trade
|
||||
if trade.side == winner:
|
||||
# Win - collect full payout
|
||||
payout = trade.entry_contracts * 1.0
|
||||
trade.pnl = payout - trade.entry_cost
|
||||
else:
|
||||
# Lose - lose entry cost
|
||||
trade.pnl = -trade.entry_cost
|
||||
|
||||
# Calculate percentage
|
||||
total_cost = trade.entry_cost + trade.hedge_cost
|
||||
if total_cost > 0:
|
||||
trade.pnl_pct = (trade.pnl / total_cost) * 100
|
||||
|
||||
# Add to history
|
||||
self._trades.append(trade)
|
||||
self._append_trade(trade)
|
||||
|
||||
# Update equity curve
|
||||
self._equity_curve.append(self._equity_curve[-1] + trade.pnl)
|
||||
|
||||
# Recalculate stats
|
||||
self._recalculate_stats()
|
||||
|
||||
# Clear active trade
|
||||
self._active_trade = None
|
||||
|
||||
logger.info(
|
||||
f"Trade closed: {winner} won, P&L: ${trade.pnl:.2f} ({trade.pnl_pct:.1f}%)"
|
||||
)
|
||||
|
||||
self._save_state()
|
||||
|
||||
return trade
|
||||
|
||||
@property
|
||||
def active_trade(self) -> Optional[Trade]:
|
||||
return self._active_trade
|
||||
|
||||
@property
|
||||
def trades(self) -> List[Trade]:
|
||||
return self._trades
|
||||
|
||||
@property
|
||||
def stats(self) -> Stats:
|
||||
return self._stats
|
||||
|
||||
@property
|
||||
def equity_curve(self) -> List[float]:
|
||||
return self._equity_curve
|
||||
|
||||
@property
|
||||
def total_pnl(self) -> float:
|
||||
return self._stats.total_pnl
|
||||
|
||||
@property
|
||||
def win_rate(self) -> float:
|
||||
return self._stats.win_rate
|
||||
|
||||
def get_summary(self) -> Dict:
|
||||
"""Get trading summary."""
|
||||
return {
|
||||
"total_trades": self._stats.total_trades,
|
||||
"wins": self._stats.wins,
|
||||
"losses": self._stats.losses,
|
||||
"win_rate": f"{self._stats.win_rate:.1%}",
|
||||
"total_pnl": f"${self._stats.total_pnl:.2f}",
|
||||
"avg_win": f"${self._stats.avg_win:.2f}",
|
||||
"avg_loss": f"${self._stats.avg_loss:.2f}",
|
||||
"profit_factor": f"{self._stats.profit_factor:.2f}",
|
||||
"max_drawdown": f"${self._stats.max_drawdown:.2f}",
|
||||
"active_trade": self._active_trade is not None
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
"""HTTP/HTTPS proxy helpers for aiohttp + websockets.
|
||||
|
||||
websockets 13.x has NO native proxy support: proxy=/trust_env= are forwarded to
|
||||
asyncio.create_connection() and raise TypeError. We tunnel via HTTP CONNECT and
|
||||
pass the resulting socket with sock=.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import os
|
||||
import socket
|
||||
from typing import Any, Dict, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import websockets
|
||||
|
||||
_ENV_KEYS = ("HTTPS_PROXY", "HTTP_PROXY", "https_proxy", "http_proxy")
|
||||
|
||||
|
||||
def normalize_proxy(raw: str) -> str:
|
||||
raw = (raw or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
if raw.endswith("/"):
|
||||
raw = raw[:-1]
|
||||
if raw.startswith(("http://", "https://", "socks://", "socks4://", "socks5://")):
|
||||
return raw
|
||||
return "http://" + raw
|
||||
|
||||
|
||||
def get_proxy_url() -> str:
|
||||
for key in _ENV_KEYS:
|
||||
val = os.getenv(key)
|
||||
if val:
|
||||
return normalize_proxy(val)
|
||||
return ""
|
||||
|
||||
|
||||
def apply_proxy_env(proxy_url: str | None = None) -> str:
|
||||
"""Normalize proxy and write back to env for trust_env consumers (aiohttp/requests)."""
|
||||
url = normalize_proxy(proxy_url if proxy_url is not None else get_proxy_url())
|
||||
if url:
|
||||
for key in _ENV_KEYS:
|
||||
os.environ[key] = url
|
||||
return url
|
||||
|
||||
|
||||
PROXY_URL = apply_proxy_env()
|
||||
|
||||
|
||||
def open_proxy_socket(proxy_url: str, host: str, port: int, timeout: float = 15.0) -> socket.socket:
|
||||
"""Blocking HTTP CONNECT through an HTTP proxy. Returns non-blocking plain socket."""
|
||||
if proxy_url.lower().startswith("socks"):
|
||||
raise OSError(
|
||||
f"SOCKS proxy not supported for WebSocket ({proxy_url}). "
|
||||
"Use Clash HTTP port (e.g. http://127.0.0.1:7890)."
|
||||
)
|
||||
p = urlparse(proxy_url)
|
||||
ph, pp = p.hostname, p.port or 8080
|
||||
if not ph:
|
||||
raise OSError(f"invalid proxy url: {proxy_url}")
|
||||
|
||||
raw = socket.create_connection((ph, pp), timeout=timeout)
|
||||
try:
|
||||
req = (
|
||||
f"CONNECT {host}:{port} HTTP/1.1\r\n"
|
||||
f"Host: {host}:{port}\r\n"
|
||||
f"Proxy-Connection: keep-alive\r\n"
|
||||
)
|
||||
if p.username is not None:
|
||||
token = base64.b64encode(
|
||||
f"{p.username}:{p.password or ''}".encode()
|
||||
).decode()
|
||||
req += f"Proxy-Authorization: Basic {token}\r\n"
|
||||
req += "\r\n"
|
||||
raw.sendall(req.encode())
|
||||
|
||||
raw.settimeout(timeout)
|
||||
buf = b""
|
||||
while b"\r\n\r\n" not in buf:
|
||||
chunk = raw.recv(4096)
|
||||
if not chunk:
|
||||
raise OSError("proxy closed during CONNECT")
|
||||
buf += chunk
|
||||
if len(buf) > 65536:
|
||||
raise OSError("proxy CONNECT response too large")
|
||||
|
||||
status = buf.split(b"\r\n", 1)[0].decode("latin1", "replace")
|
||||
if "200" not in status:
|
||||
raise OSError(f"proxy CONNECT failed: {status}")
|
||||
|
||||
raw.settimeout(None)
|
||||
raw.setblocking(False)
|
||||
return raw
|
||||
except Exception:
|
||||
try:
|
||||
raw.close()
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def _target_from_ws_url(url: str) -> tuple[str, int]:
|
||||
p = urlparse(url)
|
||||
host = p.hostname
|
||||
if not host:
|
||||
raise ValueError(f"invalid websocket url: {url}")
|
||||
if p.port:
|
||||
return host, p.port
|
||||
return host, 443 if p.scheme == "wss" else 80
|
||||
|
||||
|
||||
class ws_connect:
|
||||
"""Drop-in async context manager replacing websockets.connect, with HTTP proxy tunnel."""
|
||||
|
||||
def __init__(self, url: str, **kwargs: Any):
|
||||
self.url = url
|
||||
self.kwargs = dict(kwargs)
|
||||
self._cm = None
|
||||
self._sock: Optional[socket.socket] = None
|
||||
|
||||
async def __aenter__(self):
|
||||
kwargs = dict(self.kwargs)
|
||||
proxy = get_proxy_url()
|
||||
# Strip native proxy kwargs — websockets 13 forwards them to create_connection and crashes
|
||||
kwargs.pop("proxy", None)
|
||||
kwargs.pop("trust_env", None)
|
||||
|
||||
if proxy:
|
||||
host, port = _target_from_ws_url(self.url)
|
||||
try:
|
||||
self._sock = await asyncio.to_thread(
|
||||
open_proxy_socket, proxy, host, port, 15.0
|
||||
)
|
||||
except Exception as e:
|
||||
raise ConnectionError(
|
||||
f"WS proxy tunnel {proxy} -> {host}:{port} failed: {type(e).__name__}: {e}"
|
||||
) from e
|
||||
kwargs["sock"] = self._sock
|
||||
kwargs.setdefault("server_hostname", host)
|
||||
|
||||
self._cm = websockets.connect(self.url, **kwargs)
|
||||
try:
|
||||
return await self._cm.__aenter__()
|
||||
except Exception:
|
||||
self._close_sock()
|
||||
raise
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
try:
|
||||
if self._cm is not None:
|
||||
return await self._cm.__aexit__(*args)
|
||||
finally:
|
||||
self._close_sock()
|
||||
self._cm = None
|
||||
|
||||
def _close_sock(self):
|
||||
sock = self._sock
|
||||
self._sock = None
|
||||
if sock is not None:
|
||||
try:
|
||||
sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def ws_connect_kwargs(ping_interval: int = 20, ping_timeout: int = 10) -> Dict[str, Any]:
|
||||
"""Base kwargs only — never includes proxy=/trust_env= (unsafe on websockets 13)."""
|
||||
return {"ping_interval": ping_interval, "ping_timeout": ping_timeout}
|
||||
|
||||
|
||||
def aiohttp_proxy() -> Optional[str]:
|
||||
"""Explicit proxy URL for aiohttp request kwargs (None if unset)."""
|
||||
return get_proxy_url() or None
|
||||
@@ -0,0 +1,209 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Append-only simulation trading history for analysis (CSV, JSONL, summary JSON).
|
||||
|
||||
Used only when config.simulation.enabled is True.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import asdict
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger("btc_live.simulation_history")
|
||||
|
||||
CSV_COLUMNS = [
|
||||
"event",
|
||||
"time_utc",
|
||||
"unix_ts",
|
||||
"market_slug",
|
||||
"side",
|
||||
"contracts",
|
||||
"entry_price",
|
||||
"exit_price",
|
||||
"entry_cost_usd",
|
||||
"trade_pnl_usd",
|
||||
"cumulative_pnl_usd",
|
||||
"won",
|
||||
"trade_number",
|
||||
"total_closed_trades",
|
||||
"win_rate_pct",
|
||||
"max_dd_abs",
|
||||
"max_dd_pct",
|
||||
"hedged",
|
||||
]
|
||||
|
||||
|
||||
def _iso(ts: Optional[float] = None) -> str:
|
||||
t = ts if ts is not None else time.time()
|
||||
return datetime.fromtimestamp(t, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
class SimulationHistoryLogger:
|
||||
"""
|
||||
Logs each simulated OPEN and CLOSE with per-trade PnL and cumulative realized PnL.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
csv_path: str = "logs/simulation_trades.csv",
|
||||
jsonl_path: Optional[str] = "logs/simulation_history.jsonl",
|
||||
summary_path: str = "logs/simulation_summary.json",
|
||||
):
|
||||
self.csv_path = Path(csv_path) if (csv_path or "").strip() else None
|
||||
jp = (jsonl_path or "").strip()
|
||||
self.jsonl_path = Path(jp) if jp else None
|
||||
self.summary_path = Path(summary_path) if (summary_path or "").strip() else None
|
||||
self._csv_header_written = (
|
||||
bool(self.csv_path and self.csv_path.exists() and self.csv_path.stat().st_size > 0)
|
||||
)
|
||||
|
||||
def _append_csv_row(self, row: Dict[str, Any]) -> None:
|
||||
if not self.csv_path:
|
||||
return
|
||||
self.csv_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
write_header = not self._csv_header_written
|
||||
with open(self.csv_path, "a", newline="", encoding="utf-8") as f:
|
||||
w = csv.DictWriter(f, fieldnames=CSV_COLUMNS, extrasaction="ignore")
|
||||
if write_header:
|
||||
w.writeheader()
|
||||
self._csv_header_written = True
|
||||
w.writerow({k: row.get(k, "") for k in CSV_COLUMNS})
|
||||
|
||||
def _append_jsonl(self, obj: Dict[str, Any]) -> None:
|
||||
if not self.jsonl_path:
|
||||
return
|
||||
self.jsonl_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.jsonl_path, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(obj, ensure_ascii=False) + "\n")
|
||||
|
||||
def log_open(
|
||||
self,
|
||||
*,
|
||||
market_slug: str,
|
||||
token_name: str,
|
||||
contracts: int,
|
||||
avg_price: float,
|
||||
total_cost: float,
|
||||
cumulative_realized_pnl: float,
|
||||
hedged: bool,
|
||||
trade_number: int,
|
||||
) -> None:
|
||||
"""trade_number = count of closed trades + 1 (this open is the Nth position)."""
|
||||
ts = time.time()
|
||||
row = {
|
||||
"event": "OPEN",
|
||||
"time_utc": _iso(ts),
|
||||
"unix_ts": f"{ts:.3f}",
|
||||
"market_slug": market_slug,
|
||||
"side": token_name,
|
||||
"contracts": contracts,
|
||||
"entry_price": f"{avg_price:.6f}",
|
||||
"exit_price": "",
|
||||
"entry_cost_usd": f"{total_cost:.4f}",
|
||||
"trade_pnl_usd": "",
|
||||
"cumulative_pnl_usd": f"{cumulative_realized_pnl:.4f}",
|
||||
"won": "",
|
||||
"trade_number": trade_number,
|
||||
"total_closed_trades": "",
|
||||
"win_rate_pct": "",
|
||||
"max_dd_abs": "",
|
||||
"max_dd_pct": "",
|
||||
"hedged": hedged,
|
||||
}
|
||||
self._append_csv_row(row)
|
||||
self._append_jsonl(
|
||||
{
|
||||
"type": "open",
|
||||
"time_utc": row["time_utc"],
|
||||
"unix_ts": ts,
|
||||
"market_slug": market_slug,
|
||||
"side": token_name,
|
||||
"contracts": contracts,
|
||||
"avg_price": avg_price,
|
||||
"entry_cost_usd": total_cost,
|
||||
"cumulative_realized_pnl_usd": cumulative_realized_pnl,
|
||||
"hedged": hedged,
|
||||
"trade_number": trade_number,
|
||||
}
|
||||
)
|
||||
logger.info(
|
||||
f"[SIM] OPEN {token_name} x{contracts} @ {avg_price:.4f} cost=${total_cost:.2f} | "
|
||||
f"realized PnL so far ${cumulative_realized_pnl:+.4f}"
|
||||
)
|
||||
|
||||
def log_close(
|
||||
self,
|
||||
record: Any, # TradeRecord-like
|
||||
*,
|
||||
cumulative_pnl: float,
|
||||
total_closed: int,
|
||||
win_rate_pct: float,
|
||||
hedged: bool,
|
||||
) -> None:
|
||||
ts = getattr(record, "timestamp", None) or time.time()
|
||||
row = {
|
||||
"event": "CLOSE",
|
||||
"time_utc": _iso(ts),
|
||||
"unix_ts": f"{ts:.3f}",
|
||||
"market_slug": record.market_slug,
|
||||
"side": record.token_name,
|
||||
"contracts": record.contracts,
|
||||
"entry_price": f"{record.entry_price:.6f}",
|
||||
"exit_price": f"{record.exit_price:.6f}",
|
||||
"entry_cost_usd": f"{record.contracts * record.entry_price:.4f}",
|
||||
"trade_pnl_usd": f"{record.pnl:+.4f}",
|
||||
"cumulative_pnl_usd": f"{cumulative_pnl:+.4f}",
|
||||
"won": record.won,
|
||||
"trade_number": total_closed,
|
||||
"total_closed_trades": total_closed,
|
||||
"win_rate_pct": f"{win_rate_pct:.2f}",
|
||||
"max_dd_abs": f"{record.max_drawdown_abs:.6f}",
|
||||
"max_dd_pct": f"{record.max_drawdown_pct:.2f}",
|
||||
"hedged": hedged,
|
||||
}
|
||||
self._append_csv_row(row)
|
||||
self._append_jsonl(
|
||||
{
|
||||
"type": "close",
|
||||
"time_utc": row["time_utc"],
|
||||
"unix_ts": ts,
|
||||
"market_slug": record.market_slug,
|
||||
"side": record.token_name,
|
||||
"contracts": record.contracts,
|
||||
"entry_price": record.entry_price,
|
||||
"exit_price": record.exit_price,
|
||||
"trade_pnl_usd": record.pnl,
|
||||
"cumulative_pnl_usd": cumulative_pnl,
|
||||
"won": record.won,
|
||||
"trade_number": total_closed,
|
||||
"total_closed_trades": total_closed,
|
||||
"win_rate_pct": win_rate_pct,
|
||||
"max_drawdown_abs": record.max_drawdown_abs,
|
||||
"max_drawdown_pct": record.max_drawdown_pct,
|
||||
"hedged": hedged,
|
||||
}
|
||||
)
|
||||
logger.info(
|
||||
f"[SIM] CLOSE #{total_closed} {record.token_name} PnL ${record.pnl:+.4f} | "
|
||||
f"cumulative ${cumulative_pnl:+.4f} | WR {win_rate_pct:.1f}% ({total_closed} trades)"
|
||||
)
|
||||
|
||||
def write_summary(self, trades_as_dicts: List[Dict[str, Any]], summary: Dict[str, Any]) -> None:
|
||||
"""Full snapshot for quick analysis (includes all closed trades)."""
|
||||
if not self.summary_path:
|
||||
return
|
||||
self.summary_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out = {
|
||||
"updated_at_utc": _iso(),
|
||||
**summary,
|
||||
"trades": trades_as_dicts,
|
||||
}
|
||||
with open(self.summary_path, "w", encoding="utf-8") as f:
|
||||
json.dump(out, f, indent=2)
|
||||
@@ -0,0 +1,306 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Telegram Notifier
|
||||
|
||||
Sends notifications and charts to Telegram.
|
||||
|
||||
Features:
|
||||
- Async message sending
|
||||
- Rate limiting
|
||||
- Equity curve chart generation
|
||||
- Message queue with retry
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Dict, Any
|
||||
from queue import Queue, Empty
|
||||
from threading import Thread
|
||||
|
||||
import aiohttp
|
||||
|
||||
from src.proxy_util import apply_proxy_env, aiohttp_proxy
|
||||
|
||||
apply_proxy_env()
|
||||
_PROXY_URL = aiohttp_proxy()
|
||||
|
||||
logger = logging.getLogger("btc_live.telegram")
|
||||
|
||||
|
||||
class TelegramNotifier:
|
||||
"""
|
||||
Async Telegram notification sender.
|
||||
|
||||
Features:
|
||||
- Non-blocking message sending
|
||||
- Rate limiting (5 msg/sec max)
|
||||
- Image/chart sending
|
||||
- Graceful error handling
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bot_token: str,
|
||||
chat_id: str,
|
||||
rate_limit: float = 5.0,
|
||||
enabled: bool = True
|
||||
):
|
||||
self.bot_token = bot_token
|
||||
self.chat_id = chat_id
|
||||
self.rate_limit = rate_limit
|
||||
self.min_interval = 1.0 / rate_limit
|
||||
self.enabled = enabled and bool(bot_token and chat_id)
|
||||
|
||||
self._last_send_time = 0.0
|
||||
self._session: Optional[aiohttp.ClientSession] = None
|
||||
|
||||
# Stats
|
||||
self.messages_sent = 0
|
||||
self.errors_count = 0
|
||||
|
||||
if not self.enabled:
|
||||
logger.warning("Telegram notifications disabled")
|
||||
|
||||
async def _get_session(self) -> aiohttp.ClientSession:
|
||||
"""Get or create HTTP session (proxy-aware via env)."""
|
||||
if self._session is None or self._session.closed:
|
||||
timeout = aiohttp.ClientTimeout(total=10)
|
||||
self._session = aiohttp.ClientSession(timeout=timeout, trust_env=True)
|
||||
return self._session
|
||||
|
||||
async def _rate_limit(self):
|
||||
"""Apply rate limiting."""
|
||||
import time
|
||||
now = time.time()
|
||||
elapsed = now - self._last_send_time
|
||||
if elapsed < self.min_interval:
|
||||
await asyncio.sleep(self.min_interval - elapsed)
|
||||
self._last_send_time = time.time()
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
text: str,
|
||||
parse_mode: str = "HTML"
|
||||
) -> bool:
|
||||
"""
|
||||
Send a text message.
|
||||
|
||||
Args:
|
||||
text: Message text
|
||||
parse_mode: "HTML" or "Markdown"
|
||||
|
||||
Returns:
|
||||
True if sent successfully
|
||||
"""
|
||||
if not self.enabled:
|
||||
logger.debug(f"Telegram disabled, would send: {text[:50]}...")
|
||||
return True
|
||||
|
||||
await self._rate_limit()
|
||||
|
||||
try:
|
||||
session = await self._get_session()
|
||||
url = f"https://api.telegram.org/bot{self.bot_token}/sendMessage"
|
||||
|
||||
payload = {
|
||||
"chat_id": self.chat_id,
|
||||
"text": text,
|
||||
"parse_mode": parse_mode
|
||||
}
|
||||
|
||||
async with session.post(url, json=payload, proxy=_PROXY_URL) as resp:
|
||||
if resp.status == 200:
|
||||
self.messages_sent += 1
|
||||
logger.debug(f"Telegram sent: {text[:50]}...")
|
||||
return True
|
||||
else:
|
||||
error = await resp.text()
|
||||
logger.error(f"Telegram error {resp.status}: {error}")
|
||||
self.errors_count += 1
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Telegram send error: {e}")
|
||||
self.errors_count += 1
|
||||
return False
|
||||
|
||||
async def send_photo(
|
||||
self,
|
||||
photo: bytes,
|
||||
caption: str = ""
|
||||
) -> bool:
|
||||
"""
|
||||
Send a photo.
|
||||
|
||||
Args:
|
||||
photo: Photo bytes
|
||||
caption: Optional caption
|
||||
|
||||
Returns:
|
||||
True if sent successfully
|
||||
"""
|
||||
if not self.enabled:
|
||||
return True
|
||||
|
||||
await self._rate_limit()
|
||||
|
||||
try:
|
||||
session = await self._get_session()
|
||||
url = f"https://api.telegram.org/bot{self.bot_token}/sendPhoto"
|
||||
|
||||
data = aiohttp.FormData()
|
||||
data.add_field('chat_id', self.chat_id)
|
||||
data.add_field('photo', photo, filename='chart.png')
|
||||
if caption:
|
||||
data.add_field('caption', caption)
|
||||
|
||||
async with session.post(url, data=data, proxy=_PROXY_URL) as resp:
|
||||
if resp.status == 200:
|
||||
self.messages_sent += 1
|
||||
logger.debug("Telegram photo sent")
|
||||
return True
|
||||
else:
|
||||
error = await resp.text()
|
||||
logger.error(f"Telegram photo error {resp.status}: {error}")
|
||||
self.errors_count += 1
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Telegram photo error: {e}")
|
||||
self.errors_count += 1
|
||||
return False
|
||||
|
||||
async def notify_entry(
|
||||
self,
|
||||
side: str,
|
||||
price: float,
|
||||
contracts: int,
|
||||
cost: float,
|
||||
retries: int,
|
||||
interval_minutes: int = 15,
|
||||
simulation: bool = False,
|
||||
):
|
||||
"""Send entry notification."""
|
||||
mode = "🎮 <b>[SIMULATION]</b>\n" if simulation else ""
|
||||
text = (
|
||||
f"{mode}"
|
||||
f"🟢 <b>ENTRY</b>\n"
|
||||
f"📊 BTC {interval_minutes}min - {side}\n"
|
||||
f"💰 ${cost:.2f} @ {price:.2f}\n"
|
||||
f"📦 {contracts} contracts\n"
|
||||
f"🔄 {retries} retries"
|
||||
)
|
||||
await self.send_message(text)
|
||||
|
||||
async def notify_hedge(
|
||||
self,
|
||||
contracts: int,
|
||||
price: float,
|
||||
cost: float
|
||||
):
|
||||
"""Send hedge notification."""
|
||||
text = (
|
||||
f"🛡 <b>HEDGE</b>\n"
|
||||
f"📦 {contracts} contracts @ ${price:.3f}\n"
|
||||
f"💰 Cost: ${cost:.2f}\n"
|
||||
f"✅ Position protected"
|
||||
)
|
||||
await self.send_message(text)
|
||||
|
||||
async def notify_market_end(
|
||||
self,
|
||||
winner: str,
|
||||
pnl: float,
|
||||
total_pnl: float,
|
||||
win_rate: float
|
||||
):
|
||||
"""Send market end notification."""
|
||||
emoji = "🎯" if pnl > 0 else "❌"
|
||||
pnl_sign = "+" if pnl > 0 else ""
|
||||
|
||||
text = (
|
||||
f"🏁 <b>MARKET RESOLVED</b>\n"
|
||||
f"{emoji} Winner: <b>{winner}</b>\n"
|
||||
f"💵 P&L: {pnl_sign}${pnl:.2f}\n"
|
||||
f"📈 Total: ${total_pnl:.2f}\n"
|
||||
f"📊 Win rate: {win_rate:.1%}"
|
||||
)
|
||||
await self.send_message(text)
|
||||
|
||||
async def send_equity_chart(
|
||||
self,
|
||||
equity_curve: List[float],
|
||||
title: str = "Equity Curve"
|
||||
):
|
||||
"""
|
||||
Generate and send equity curve chart.
|
||||
|
||||
Args:
|
||||
equity_curve: List of equity values
|
||||
title: Chart title
|
||||
"""
|
||||
try:
|
||||
import matplotlib
|
||||
matplotlib.use('Agg')
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
fig, ax = plt.subplots(figsize=(10, 6))
|
||||
|
||||
# Plot equity curve
|
||||
ax.plot(equity_curve, linewidth=2, color='#2196F3')
|
||||
ax.fill_between(
|
||||
range(len(equity_curve)),
|
||||
equity_curve,
|
||||
alpha=0.3,
|
||||
color='#2196F3'
|
||||
)
|
||||
|
||||
# Styling
|
||||
ax.set_title(title, fontsize=14, fontweight='bold')
|
||||
ax.set_xlabel('Trade #', fontsize=12)
|
||||
ax.set_ylabel('Equity ($)', fontsize=12)
|
||||
ax.grid(True, alpha=0.3)
|
||||
ax.axhline(y=0, color='gray', linestyle='--', alpha=0.5)
|
||||
|
||||
# Add current value annotation
|
||||
if equity_curve:
|
||||
final_value = equity_curve[-1]
|
||||
ax.annotate(
|
||||
f'${final_value:.2f}',
|
||||
xy=(len(equity_curve)-1, final_value),
|
||||
fontsize=11,
|
||||
fontweight='bold'
|
||||
)
|
||||
|
||||
plt.tight_layout()
|
||||
|
||||
# Save to bytes
|
||||
buf = io.BytesIO()
|
||||
plt.savefig(buf, format='png', dpi=100)
|
||||
buf.seek(0)
|
||||
plt.close(fig)
|
||||
|
||||
# Send
|
||||
caption = f"📊 {title}\nTrades: {len(equity_curve)-1} | Final: ${equity_curve[-1]:.2f}"
|
||||
await self.send_photo(buf.getvalue(), caption)
|
||||
|
||||
except ImportError:
|
||||
logger.warning("matplotlib not available for charts")
|
||||
except Exception as e:
|
||||
logger.error(f"Chart generation error: {e}")
|
||||
|
||||
async def close(self):
|
||||
"""Close the session."""
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
|
||||
def get_stats(self) -> Dict:
|
||||
"""Get notifier statistics."""
|
||||
return {
|
||||
"enabled": self.enabled,
|
||||
"messages_sent": self.messages_sent,
|
||||
"errors_count": self.errors_count
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
User WebSocket Client
|
||||
|
||||
Subscribes to Polymarket User Channel for order/trade tracking.
|
||||
Used to verify order execution before retry.
|
||||
|
||||
Docs: https://docs.polymarket.com/developers/CLOB/websocket/user-channel
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import websockets
|
||||
from typing import Optional, Dict, Callable, Any
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
from src.proxy_util import apply_proxy_env, ws_connect, ws_connect_kwargs as _ws_connect_kwargs
|
||||
|
||||
apply_proxy_env()
|
||||
|
||||
logger = logging.getLogger("btc_live.user_ws")
|
||||
|
||||
WS_URL = "wss://ws-subscriptions-clob.polymarket.com/ws/user"
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrderStatus:
|
||||
"""Tracks order status from WebSocket."""
|
||||
order_id: str
|
||||
asset_id: str
|
||||
side: str
|
||||
price: float
|
||||
original_size: int
|
||||
size_matched: int = 0
|
||||
status: str = "PENDING" # PENDING, PLACED, MATCHED, CANCELLED
|
||||
trades: list = field(default_factory=list)
|
||||
timestamp: datetime = field(default_factory=datetime.now)
|
||||
|
||||
|
||||
class UserWebSocket:
|
||||
"""
|
||||
WebSocket client for User Channel.
|
||||
|
||||
Tracks order placements and fills in real-time.
|
||||
"""
|
||||
|
||||
def __init__(self, api_key: str, api_secret: str = "", api_passphrase: str = ""):
|
||||
self.api_key = api_key
|
||||
self.api_secret = api_secret
|
||||
self.api_passphrase = api_passphrase
|
||||
self._ws = None
|
||||
self._ws_cm = None
|
||||
self._connected = False
|
||||
self._running = False
|
||||
self._orders: Dict[str, OrderStatus] = {}
|
||||
|
||||
# Callbacks
|
||||
self._on_trade: Optional[Callable] = None
|
||||
self._on_order: Optional[Callable] = None
|
||||
|
||||
async def connect(self):
|
||||
"""Connect to User Channel WebSocket."""
|
||||
try:
|
||||
self._running = True
|
||||
|
||||
logger.info(f"Connecting to User WebSocket at {WS_URL}...")
|
||||
print(f" Connecting to {WS_URL}...")
|
||||
|
||||
async with ws_connect(
|
||||
WS_URL,
|
||||
**_ws_connect_kwargs(ping_interval=30, ping_timeout=10)
|
||||
) as ws:
|
||||
self._ws = ws
|
||||
self._connected = True
|
||||
logger.info("User WebSocket connected")
|
||||
print(" WebSocket connection established")
|
||||
|
||||
# Subscribe to user channel with auth object
|
||||
subscribe_msg = {
|
||||
"type": "user",
|
||||
"auth": {
|
||||
"apiKey": self.api_key,
|
||||
"secret": self.api_secret,
|
||||
"passphrase": self.api_passphrase
|
||||
}
|
||||
}
|
||||
await ws.send(json.dumps(subscribe_msg))
|
||||
logger.info("Sent subscription message")
|
||||
print(" Sent subscription message")
|
||||
|
||||
# Wait for response
|
||||
try:
|
||||
first_msg = await asyncio.wait_for(ws.recv(), timeout=5)
|
||||
logger.info(f"First message: {first_msg[:200]}")
|
||||
print(f" First response: {first_msg[:100]}...")
|
||||
await self._process_message(first_msg)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("No initial response from WebSocket")
|
||||
print(" No initial response (timeout)")
|
||||
|
||||
# Listen for messages
|
||||
while self._running:
|
||||
try:
|
||||
msg = await asyncio.wait_for(ws.recv(), timeout=60)
|
||||
logger.debug(f"WS message: {msg[:100]}")
|
||||
await self._process_message(msg)
|
||||
except asyncio.TimeoutError:
|
||||
# Send ping to keep connection alive
|
||||
try:
|
||||
await ws.ping()
|
||||
except Exception:
|
||||
break
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
logger.warning("User WebSocket connection closed")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Error receiving message: {e}")
|
||||
break
|
||||
|
||||
self._connected = False
|
||||
logger.info("User WebSocket disconnected")
|
||||
print(" WebSocket disconnected")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"User WebSocket connection error: {e}")
|
||||
print(f" Connection error: {e}")
|
||||
self._connected = False
|
||||
raise
|
||||
|
||||
async def _process_message(self, msg: str):
|
||||
"""Process an incoming WebSocket message."""
|
||||
try:
|
||||
data = json.loads(msg)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"Invalid JSON: {msg[:100]}")
|
||||
return
|
||||
|
||||
msg_type = data.get("type", "")
|
||||
|
||||
# Order updates
|
||||
if msg_type in ("PLACEMENT", "UPDATE", "CANCELLATION"):
|
||||
order_id = data.get("id", "")
|
||||
if order_id and order_id in self._orders:
|
||||
order = self._orders[order_id]
|
||||
order.status = msg_type
|
||||
order.size_matched = int(float(data.get("size_matched", 0)))
|
||||
|
||||
if msg_type == "MATCHED":
|
||||
order.status = "MATCHED"
|
||||
order.trades.append(data)
|
||||
|
||||
if self._on_order:
|
||||
if asyncio.iscoroutinefunction(self._on_order):
|
||||
await self._on_order(order)
|
||||
else:
|
||||
self._on_order(order)
|
||||
|
||||
# Trade updates
|
||||
elif msg_type == "TRADE":
|
||||
if self._on_trade:
|
||||
if asyncio.iscoroutinefunction(self._on_trade):
|
||||
await self._on_trade(data)
|
||||
else:
|
||||
self._on_trade(data)
|
||||
|
||||
# Initial subscription response
|
||||
elif msg_type in ("subscribed", "OK", "ok"):
|
||||
logger.info(f"Subscription confirmed: {msg[:150]}")
|
||||
|
||||
else:
|
||||
logger.debug(f"Unhandled msg type={msg_type}: {msg[:100]}")
|
||||
|
||||
def register_order(self, order_id: str, asset_id: str, side: str, price: float, size: int):
|
||||
"""Register an order we're tracking."""
|
||||
self._orders[order_id] = OrderStatus(
|
||||
order_id=order_id,
|
||||
asset_id=asset_id,
|
||||
side=side,
|
||||
price=price,
|
||||
original_size=size
|
||||
)
|
||||
|
||||
def get_order_status(self, order_id: str) -> Optional[OrderStatus]:
|
||||
"""Get current status for an order."""
|
||||
return self._orders.get(order_id)
|
||||
|
||||
async def wait_for_order_match(self, order_id: str, timeout: float = 10.0) -> bool:
|
||||
"""Wait until an order is matched or timeout."""
|
||||
start = asyncio.get_event_loop().time()
|
||||
while asyncio.get_event_loop().time() - start < timeout:
|
||||
status = self._orders.get(order_id)
|
||||
if status and (status.status == "MATCHED" or status.size_matched >= status.original_size):
|
||||
return True
|
||||
await asyncio.sleep(0.2)
|
||||
return False
|
||||
|
||||
async def wait_for_order_place(self, order_id: str, timeout: float = 5.0) -> bool:
|
||||
"""Wait until an order placement is confirmed or timeout."""
|
||||
start = asyncio.get_event_loop().time()
|
||||
while asyncio.get_event_loop().time() - start < timeout:
|
||||
status = self._orders.get(order_id)
|
||||
if status and status.status in ("PLACED", "UPDATE", "MATCHED"):
|
||||
return True
|
||||
await asyncio.sleep(0.15)
|
||||
return False
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._connected and self._ws is not None
|
||||
|
||||
async def close(self):
|
||||
"""Close the WebSocket connection."""
|
||||
self._running = False
|
||||
ws = self._ws
|
||||
cm = self._ws_cm
|
||||
self._ws = None
|
||||
self._ws_cm = None
|
||||
if ws and not getattr(ws, "closed", True):
|
||||
try:
|
||||
await ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
if cm is not None:
|
||||
try:
|
||||
await cm.__aexit__(None, None, None)
|
||||
except Exception:
|
||||
pass
|
||||
self._connected = False
|
||||
@@ -0,0 +1,276 @@
|
||||
"""
|
||||
Local web dashboard: FastAPI + single-page UI, JSON at /api/state.
|
||||
Runs in a daemon thread; state is updated from the bot's main loop.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, Response
|
||||
import uvicorn
|
||||
|
||||
logger = logging.getLogger("btc_live")
|
||||
|
||||
_HTML = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>BTC Live Bot</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d1117; --panel: #161b22; --border: #30363d;
|
||||
--text: #e6edf3; --muted: #8b949e; --green: #3fb950; --red: #f85149;
|
||||
--yellow: #d29922; --blue: #58a6ff; --violet: #a371f7;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: ui-sans-serif, system-ui, sans-serif; background: var(--bg); color: var(--text);
|
||||
margin: 0; padding: 1rem; line-height: 1.45; }
|
||||
h1 { font-size: 1.1rem; font-weight: 600; margin: 0 0 0.75rem; }
|
||||
.meta { color: var(--muted); font-size: 0.85rem; margin-bottom: 1rem; }
|
||||
.grid { display: grid; gap: 0.75rem; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); }
|
||||
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 0.85rem; }
|
||||
.card h2 { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted);
|
||||
margin: 0 0 0.5rem; }
|
||||
.row { display: flex; justify-content: space-between; gap: 0.5rem; font-size: 0.9rem; }
|
||||
.sig { font-size: 1rem; font-weight: 600; }
|
||||
.sig.wait { color: var(--yellow); }
|
||||
.sig.buy { color: var(--green); }
|
||||
.sig.block { color: var(--red); }
|
||||
.mono { font-family: ui-monospace, monospace; font-size: 0.82rem; }
|
||||
.btc { border-color: #d29922; }
|
||||
footer { margin-top: 1rem; color: var(--muted); font-size: 0.75rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>BTC up/down — live</h1>
|
||||
<div class="meta" id="meta">Loading…</div>
|
||||
<div class="grid">
|
||||
<div class="card"><h2>Session</h2><div id="session" class="mono"></div></div>
|
||||
<div class="card"><h2>Strategy</h2><div id="strategy"></div></div>
|
||||
<div class="card"><h2>UP</h2><div id="up" class="mono"></div></div>
|
||||
<div class="card"><h2>DOWN</h2><div id="down" class="mono"></div></div>
|
||||
<div class="card btc"><h2>BTC / USD (Chainlink)</h2><div id="btc" class="mono"></div></div>
|
||||
<div class="card"><h2>Trading</h2><div id="trading" class="mono"></div></div>
|
||||
</div>
|
||||
<footer>Refreshes every second · <span id="err"></span></footer>
|
||||
<script>
|
||||
/* No optional chaining (?.) — must run in older browsers / Edge legacy. */
|
||||
function esc(s) {
|
||||
if (s === null || s === undefined) return "";
|
||||
var el = document.createElement("div");
|
||||
el.textContent = String(s);
|
||||
return el.innerHTML;
|
||||
}
|
||||
function sigClass(t) {
|
||||
if (!t) return "wait";
|
||||
if (t.indexOf("BUY") >= 0) return "buy";
|
||||
/* Do not use \\uD83D\\uDEAB here: Python treats \\u.... in the template as escapes and emits invalid UTF-8 surrogates. */
|
||||
if (t.indexOf("NO ENTRY") >= 0) return "block";
|
||||
return "wait";
|
||||
}
|
||||
function numFmt(n, dec) {
|
||||
if (n === null || n === undefined || typeof n !== "number" || isNaN(n)) return "\u2014";
|
||||
return n.toFixed(dec);
|
||||
}
|
||||
function tick() {
|
||||
var errEl = document.getElementById("err");
|
||||
var r = new XMLHttpRequest();
|
||||
r.open("GET", "/api/state", true);
|
||||
r.onreadystatechange = function () {
|
||||
if (r.readyState !== 4) return;
|
||||
try {
|
||||
if (r.status !== 200) throw new Error("HTTP " + r.status);
|
||||
var d = JSON.parse(r.responseText);
|
||||
errEl.textContent = "";
|
||||
var hdr = d.header || {};
|
||||
var slug = hdr.slug != null ? String(hdr.slug) : "\u2014";
|
||||
var ts = "";
|
||||
if (d.ts) ts = new Date(d.ts * 1000).toISOString();
|
||||
document.getElementById("meta").innerHTML = esc(slug) + " \u00b7 " + esc(ts);
|
||||
document.getElementById("session").innerHTML = [
|
||||
"Timer: " + (hdr.time_left_sec != null ? esc(Math.floor(hdr.time_left_sec) + "s left") : "\u2014"),
|
||||
"WS: " + (hdr.ws_connected ? "live" : "disconnected"),
|
||||
"Mode: " + (hdr.simulation ? "simulation" : "real"),
|
||||
].join("<br/>");
|
||||
var st = d.strategy || {};
|
||||
var sig = st.signal_text || "\u2014";
|
||||
function chk(x) { return x === true ? "\u2713" : x === false ? "\u2717" : "\u2014"; }
|
||||
var ck = st.checks || {};
|
||||
document.getElementById("strategy").innerHTML =
|
||||
'<div class="sig ' + sigClass(sig) + '">' + esc(sig) + "</div>" +
|
||||
'<div class="mono" style="margin-top:0.4rem">' +
|
||||
"Fav: " + esc(st.favorite) + " \u00b7 WR: " + esc(st.win_rate_str) + "<br/>" +
|
||||
"Checks: P=" + chk(ck.price) + " T=" + chk(ck.time) + " D=" + chk(ck.dev) +
|
||||
" M=" + chk(ck.mom) + " cutoff=" + chk(ck.time_cutoff) +
|
||||
"</div>";
|
||||
function book(x, id) {
|
||||
var el = document.getElementById(id);
|
||||
if (!x) { el.textContent = "No data"; return; }
|
||||
var bk = x.book || {};
|
||||
var ind = x.indicators || {};
|
||||
el.innerHTML = [
|
||||
"Last " + esc(bk.last_price),
|
||||
"Bid " + esc(bk.best_bid) + " / Ask " + esc(bk.best_ask),
|
||||
"VWAP " + numFmt(ind.vwap, 4) +
|
||||
" \u00b7 Dev " + (ind.deviation_pct != null ? numFmt(ind.deviation_pct, 2) + "%" : "\u2014"),
|
||||
"Z " + numFmt(ind.zscore, 2) +
|
||||
" \u00b7 Mom " + (ind.momentum_pct != null ? numFmt(ind.momentum_pct, 2) + "%" : "\u2014"),
|
||||
"Vol " + (bk.volume_total != null ? esc(Math.round(bk.volume_total)) : "\u2014"),
|
||||
].join("<br/>");
|
||||
}
|
||||
book(d.up, "up");
|
||||
book(d.down, "down");
|
||||
var b = d.btc || {};
|
||||
var btcEl = document.getElementById("btc");
|
||||
if (b.btc_current_price > 0) {
|
||||
btcEl.innerHTML = [
|
||||
"$" + esc(numFmt(b.btc_current_price, 2)),
|
||||
"Anchor $" + (b.btc_anchor_price > 0 ? esc(numFmt(b.btc_anchor_price, 2)) : "\u2014"),
|
||||
esc(b.deviation_line || ""),
|
||||
"Feed: " + (b.btc_connected ? "ok" : "off") +
|
||||
(b.fresh_sec != null ? " \u00b7 " + Math.floor(b.fresh_sec) + "s" : ""),
|
||||
].join("<br/>");
|
||||
} else {
|
||||
btcEl.textContent = "Waiting for Chainlink\u2026";
|
||||
}
|
||||
var tr = d.trading || {};
|
||||
var tHtml = "Markets " + esc(tr.markets_seen) + " \u00b7 Trades " + esc(tr.trade_count) +
|
||||
" \u00b7 PnL $" + (tr.total_pnl != null ? numFmt(tr.total_pnl, 2) : "\u2014") + "<br/>";
|
||||
if (tr.position) {
|
||||
var p = tr.position;
|
||||
tHtml += "LONG " + esc(p.token_name) + " @ " + esc(p.entry_price) +
|
||||
" \u00d7" + esc(p.contracts) + (p.hedged ? " hedged" : "") + "<br/>";
|
||||
tHtml += "Unreal $" + (p.unrealized_pnl != null ? numFmt(p.unrealized_pnl, 2) : "\u2014") + "<br/>";
|
||||
} else {
|
||||
tHtml += "No open position<br/>";
|
||||
}
|
||||
if (tr.recent_trades && tr.recent_trades.length) {
|
||||
var lines = [];
|
||||
for (var i = 0; i < tr.recent_trades.length; i++) {
|
||||
lines.push(esc(tr.recent_trades[i].line));
|
||||
}
|
||||
tHtml += "<br/>Recent:<br/>" + lines.join("<br/>");
|
||||
}
|
||||
document.getElementById("trading").innerHTML = tHtml;
|
||||
} catch (e) {
|
||||
errEl.textContent = "Poll error: " + (e && e.message ? e.message : e);
|
||||
}
|
||||
};
|
||||
r.onerror = function () {
|
||||
errEl.textContent = "Network error (is the bot running?)";
|
||||
};
|
||||
r.send();
|
||||
}
|
||||
tick();
|
||||
setInterval(tick, 1000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def _sanitize_for_json(obj: Any) -> Any:
|
||||
"""
|
||||
Starlette JSONResponse serializes with allow_nan=False; NaN/Inf break the ASGI handler.
|
||||
"""
|
||||
if obj is None:
|
||||
return None
|
||||
if isinstance(obj, bool):
|
||||
return obj
|
||||
if isinstance(obj, int) and not isinstance(obj, bool):
|
||||
return obj
|
||||
if isinstance(obj, float):
|
||||
if math.isnan(obj) or math.isinf(obj):
|
||||
return None
|
||||
return obj
|
||||
if isinstance(obj, str):
|
||||
return obj
|
||||
if isinstance(obj, dict):
|
||||
return {k: _sanitize_for_json(v) for k, v in obj.items()}
|
||||
if isinstance(obj, (list, tuple)):
|
||||
return [_sanitize_for_json(v) for v in obj]
|
||||
return obj
|
||||
|
||||
|
||||
class WebSnapshotHolder:
|
||||
"""Thread-safe snapshot for /api/state."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._data: Dict[str, Any] = {"status": "starting"}
|
||||
|
||||
def set(self, data: Dict[str, Any]) -> None:
|
||||
with self._lock:
|
||||
self._data = dict(data)
|
||||
|
||||
def get(self) -> Dict[str, Any]:
|
||||
with self._lock:
|
||||
return dict(self._data)
|
||||
|
||||
|
||||
def build_app(holder: WebSnapshotHolder) -> FastAPI:
|
||||
app = FastAPI(title="BTC Live Bot", docs_url=None, redoc_url=None)
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index() -> str:
|
||||
return _HTML
|
||||
|
||||
@app.get("/favicon.ico", include_in_schema=False)
|
||||
async def favicon() -> Response:
|
||||
return Response(status_code=204)
|
||||
|
||||
@app.get("/api/state")
|
||||
async def api_state():
|
||||
return JSONResponse(_sanitize_for_json(holder.get()))
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def _client_probe_address(bind_host: str) -> str:
|
||||
"""Address to test with socket.connect(); 0.0.0.0 / :: are not valid client targets."""
|
||||
if bind_host in ("0.0.0.0", ""):
|
||||
return "127.0.0.1"
|
||||
if bind_host in ("::", "[::]"):
|
||||
return "::1"
|
||||
return bind_host
|
||||
|
||||
|
||||
def start_web_dashboard(host: str, port: int, holder: WebSnapshotHolder) -> bool:
|
||||
"""
|
||||
Start uvicorn in a daemon thread. Returns True if the port accepts connections
|
||||
shortly after start (False if bind failed or port is in use).
|
||||
"""
|
||||
app = build_app(holder)
|
||||
|
||||
def run() -> None:
|
||||
try:
|
||||
uvicorn.run(
|
||||
app,
|
||||
host=host,
|
||||
port=port,
|
||||
log_level="warning",
|
||||
access_log=False,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Web dashboard: uvicorn exited with an error")
|
||||
|
||||
t = threading.Thread(target=run, name="web-dashboard", daemon=True)
|
||||
t.start()
|
||||
|
||||
probe = _client_probe_address(host)
|
||||
for _ in range(60):
|
||||
time.sleep(0.1)
|
||||
try:
|
||||
with socket.create_connection((probe, port), timeout=0.4):
|
||||
return True
|
||||
except OSError:
|
||||
continue
|
||||
return False
|
||||
@@ -0,0 +1,345 @@
|
||||
#!/usr/bin/env python3
|
||||
"""WebSocket Client for Market + User channels."""
|
||||
import asyncio, json, logging, time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Optional, Dict, List, Callable, Set
|
||||
import websockets
|
||||
from websockets.exceptions import ConnectionClosed
|
||||
|
||||
from src.proxy_util import apply_proxy_env, ws_connect, ws_connect_kwargs as _ws_connect_kwargs
|
||||
|
||||
apply_proxy_env()
|
||||
|
||||
logger = logging.getLogger("btc_live.websocket")
|
||||
MARKET_WS_URL = "wss://ws-subscriptions-clob.polymarket.com/ws/market"
|
||||
USER_WS_URL = "wss://ws-subscriptions-clob.polymarket.com/ws/user"
|
||||
|
||||
|
||||
class ConnectionState(Enum):
|
||||
DISCONNECTED = "disconnected"
|
||||
CONNECTING = "connecting"
|
||||
CONNECTED = "connected"
|
||||
CLOSED = "closed"
|
||||
|
||||
@dataclass
|
||||
class TradeEvent:
|
||||
token_id: str; price: float; size: float; side: str
|
||||
timestamp: datetime = field(default_factory=datetime.now)
|
||||
|
||||
@dataclass
|
||||
class PriceUpdate:
|
||||
token_id: str; best_bid: float; best_ask: float
|
||||
timestamp: datetime = field(default_factory=datetime.now)
|
||||
|
||||
@dataclass
|
||||
class OrderUpdate:
|
||||
order_id: str
|
||||
asset_id: str
|
||||
side: str
|
||||
price: float
|
||||
original_size: float
|
||||
size_matched: float
|
||||
event_type: str
|
||||
status: str
|
||||
timestamp: datetime = field(default_factory=datetime.now)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TradeUpdate:
|
||||
trade_id: str
|
||||
asset_id: str
|
||||
price: float
|
||||
size: float
|
||||
side: str
|
||||
status: str
|
||||
taker_order_id: str = ""
|
||||
timestamp: datetime = field(default_factory=datetime.now)
|
||||
|
||||
|
||||
class MarketWebSocket:
|
||||
"""WebSocket client for Market channel."""
|
||||
|
||||
def __init__(self, on_trade=None, on_price=None, reconnect_delay=1.0, max_reconnect_delay=60.0):
|
||||
self.on_trade = on_trade
|
||||
self.on_price = on_price
|
||||
self.reconnect_delay = reconnect_delay
|
||||
self.max_reconnect_delay = max_reconnect_delay
|
||||
self._ws = None
|
||||
self._state = ConnectionState.DISCONNECTED
|
||||
self._subscribed_tokens: Set[str] = set()
|
||||
self._running = False
|
||||
self._reconnect_count = 0
|
||||
self.messages_received = 0
|
||||
self.trades_received = 0
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._state == ConnectionState.CONNECTED
|
||||
|
||||
async def connect(self, token_ids: List[str]) -> bool:
|
||||
if not token_ids:
|
||||
return False
|
||||
self._subscribed_tokens = set(token_ids)
|
||||
self._state = ConnectionState.CONNECTING
|
||||
try:
|
||||
# Use async with pattern to get a real AsyncContextManager from websockets.connect.
|
||||
# We exit the context once connected to keep the ws object alive for recv loop.
|
||||
ws_cm = ws_connect(MARKET_WS_URL, **_ws_connect_kwargs())
|
||||
self._ws = await asyncio.wait_for(
|
||||
ws_cm.__aenter__(),
|
||||
timeout=30
|
||||
)
|
||||
# Register for cleanup — we own the lifetime now
|
||||
self._ws_cm = ws_cm
|
||||
await self._ws.send(json.dumps({"type": "market", "assets_ids": list(token_ids)}))
|
||||
self._state = ConnectionState.CONNECTED
|
||||
self._reconnect_count = 0
|
||||
logger.info(f"Market WS connected, subscribed to {len(token_ids)} tokens")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Market WS connect error: {e}")
|
||||
self._state = ConnectionState.DISCONNECTED
|
||||
return False
|
||||
|
||||
async def _process_message(self, data):
|
||||
try:
|
||||
items = data if isinstance(data, list) else [data]
|
||||
for item in items:
|
||||
event_type = item.get("event_type", "")
|
||||
if event_type == "last_trade_price":
|
||||
trade = TradeEvent(
|
||||
token_id=item.get("asset_id", ""),
|
||||
price=float(item.get("price", 0)),
|
||||
size=float(item.get("size", 0)),
|
||||
side=item.get("side", ""),
|
||||
)
|
||||
self.trades_received += 1
|
||||
if self.on_trade:
|
||||
if asyncio.iscoroutinefunction(self.on_trade):
|
||||
await self.on_trade(trade)
|
||||
else:
|
||||
self.on_trade(trade)
|
||||
elif event_type == "best_bid_ask":
|
||||
update = PriceUpdate(
|
||||
token_id=item.get("asset_id", ""),
|
||||
best_bid=float(item.get("best_bid", 0) or 0),
|
||||
best_ask=float(item.get("best_ask", 0) or 0),
|
||||
)
|
||||
if self.on_price:
|
||||
if asyncio.iscoroutinefunction(self.on_price):
|
||||
await self.on_price(update)
|
||||
else:
|
||||
self.on_price(update)
|
||||
except Exception as e:
|
||||
logger.error(f"Process error: {e}")
|
||||
|
||||
async def _receive_loop(self):
|
||||
while self._running and self._ws:
|
||||
try:
|
||||
msg = await asyncio.wait_for(self._ws.recv(), timeout=30)
|
||||
self.messages_received += 1
|
||||
await self._process_message(json.loads(msg))
|
||||
except asyncio.TimeoutError:
|
||||
if self._ws and getattr(self._ws, "open", True):
|
||||
try:
|
||||
await asyncio.wait_for(self._ws.ping(), timeout=5)
|
||||
except Exception:
|
||||
break
|
||||
else:
|
||||
break
|
||||
except ConnectionClosed:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Receive error: {e}")
|
||||
break
|
||||
|
||||
async def run_loop(self, token_ids: List[str]):
|
||||
self._running = True
|
||||
self._subscribed_tokens = set(token_ids)
|
||||
while self._running:
|
||||
try:
|
||||
if not await self.connect(list(self._subscribed_tokens)):
|
||||
delay = min(self.reconnect_delay * (2 ** self._reconnect_count), self.max_reconnect_delay)
|
||||
await asyncio.sleep(delay)
|
||||
self._reconnect_count += 1
|
||||
continue
|
||||
await self._receive_loop()
|
||||
if self._running:
|
||||
delay = min(self.reconnect_delay * (2 ** self._reconnect_count), self.max_reconnect_delay)
|
||||
await asyncio.sleep(delay)
|
||||
self._reconnect_count += 1
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Loop error: {e}")
|
||||
await asyncio.sleep(self.reconnect_delay)
|
||||
await self.close()
|
||||
|
||||
async def close(self):
|
||||
self._running = False
|
||||
self._state = ConnectionState.CLOSED
|
||||
ws = self._ws
|
||||
cm = getattr(self, "_ws_cm", None)
|
||||
self._ws = None
|
||||
self._ws_cm = None
|
||||
if ws and not getattr(ws, "closed", True):
|
||||
try:
|
||||
await ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
if cm is not None:
|
||||
try:
|
||||
await cm.__aexit__(None, None, None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def stop(self):
|
||||
self._running = False
|
||||
|
||||
|
||||
class UserWebSocket:
|
||||
"""WebSocket client for User channel with auth."""
|
||||
|
||||
def __init__(self, api_key, api_secret, api_passphrase, on_order=None, on_trade=None):
|
||||
self.api_key = api_key
|
||||
self.api_secret = api_secret
|
||||
self.api_passphrase = api_passphrase
|
||||
self.on_order = on_order
|
||||
self.on_trade = on_trade
|
||||
self._ws = None
|
||||
self._ws_cm = None
|
||||
self._state = ConnectionState.DISCONNECTED
|
||||
self._running = False
|
||||
self._reconnect_count = 0
|
||||
self._pending_orders: Dict[str, OrderUpdate] = {}
|
||||
self.messages_received = 0
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._state == ConnectionState.CONNECTED
|
||||
|
||||
async def connect(self) -> bool:
|
||||
self._state = ConnectionState.CONNECTING
|
||||
try:
|
||||
ws_cm = ws_connect(USER_WS_URL, **_ws_connect_kwargs(ping_interval=30, ping_timeout=10))
|
||||
self._ws = await asyncio.wait_for(
|
||||
ws_cm.__aenter__(),
|
||||
timeout=30
|
||||
)
|
||||
self._ws_cm = ws_cm
|
||||
msg = {
|
||||
"type": "user",
|
||||
"markets": [],
|
||||
"auth": {"apikey": self.api_key, "secret": self.api_secret, "passphrase": self.api_passphrase}
|
||||
}
|
||||
await self._ws.send(json.dumps(msg))
|
||||
self._state = ConnectionState.CONNECTED
|
||||
self._reconnect_count = 0
|
||||
logger.info("User WS connected")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"User WS connect error: {e}")
|
||||
self._state = ConnectionState.DISCONNECTED
|
||||
return False
|
||||
|
||||
async def _process_message(self, data: Dict):
|
||||
try:
|
||||
event_type = data.get("event_type", "")
|
||||
msg_type = data.get("type", "")
|
||||
if event_type == "order" or msg_type in ("PLACEMENT", "UPDATE", "CANCELLATION"):
|
||||
order = OrderUpdate(
|
||||
order_id=data.get("id", ""),
|
||||
asset_id=data.get("asset_id", ""),
|
||||
side=data.get("side", ""),
|
||||
price=float(data.get("price", 0)),
|
||||
original_size=float(data.get("original_size", 0)),
|
||||
size_matched=float(data.get("size_matched", 0)),
|
||||
event_type=data.get("type", msg_type),
|
||||
status=data.get("status", ""),
|
||||
)
|
||||
self._pending_orders[order.order_id] = order
|
||||
if self.on_order:
|
||||
if asyncio.iscoroutinefunction(self.on_order):
|
||||
await self.on_order(order)
|
||||
else:
|
||||
self.on_order(order)
|
||||
elif event_type == "trade" or msg_type == "TRADE":
|
||||
trade = TradeUpdate(
|
||||
trade_id=data.get("id", ""),
|
||||
asset_id=data.get("asset_id", ""),
|
||||
price=float(data.get("price", 0)),
|
||||
size=float(data.get("size", 0)),
|
||||
side=data.get("side", ""),
|
||||
status=data.get("status", ""),
|
||||
taker_order_id=data.get("taker_order_id", ""),
|
||||
)
|
||||
if self.on_trade:
|
||||
if asyncio.iscoroutinefunction(self.on_trade):
|
||||
await self.on_trade(trade)
|
||||
else:
|
||||
self.on_trade(trade)
|
||||
except Exception as e:
|
||||
logger.error(f"User msg process error: {e}")
|
||||
|
||||
async def _receive_loop(self):
|
||||
while self._running and self._ws:
|
||||
try:
|
||||
msg = await asyncio.wait_for(self._ws.recv(), timeout=60)
|
||||
self.messages_received += 1
|
||||
await self._process_message(json.loads(msg))
|
||||
except asyncio.TimeoutError:
|
||||
if self._ws and getattr(self._ws, "open", True):
|
||||
try:
|
||||
await asyncio.wait_for(self._ws.ping(), timeout=5)
|
||||
except Exception:
|
||||
break
|
||||
else:
|
||||
break
|
||||
except ConnectionClosed:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"User recv error: {e}")
|
||||
break
|
||||
|
||||
async def run_loop(self):
|
||||
self._running = True
|
||||
delay = 1.0
|
||||
while self._running:
|
||||
try:
|
||||
if not await self.connect():
|
||||
await asyncio.sleep(delay)
|
||||
delay = min(delay * 1.5, 60.0)
|
||||
continue
|
||||
delay = 1.0
|
||||
await self._receive_loop()
|
||||
if self._running:
|
||||
await asyncio.sleep(2.0)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"User WS loop error: {e}")
|
||||
await asyncio.sleep(2.0)
|
||||
await self.close()
|
||||
|
||||
async def close(self):
|
||||
self._running = False
|
||||
self._state = ConnectionState.CLOSED
|
||||
ws = self._ws
|
||||
cm = self._ws_cm
|
||||
self._ws = None
|
||||
self._ws_cm = None
|
||||
if ws and not getattr(ws, "closed", True):
|
||||
try:
|
||||
await ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
if cm is not None:
|
||||
try:
|
||||
await cm.__aexit__(None, None, None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def stop(self):
|
||||
self._running = False
|
||||
Reference in New Issue
Block a user