PDB_VWAP代理设置完成

This commit is contained in:
2026-07-26 22:56:35 +08:00
commit 4cc94bf316
64 changed files with 27067 additions and 0 deletions
+34
View File
@@ -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=
+40
View File
@@ -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
+287
View File
@@ -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` ~150210, `no_entry_before_end_sec` ~90120.
- **15** — slug pattern `btc-updown-15m-<unix_start>` (900s window). Example strategy: `min_elapsed_sec` ~480530, `no_entry_before_end_sec` ~300335.
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 &lt; 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
}
```
+271
View File
@@ -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": []
}
]
+9
View File
@@ -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)
+216
View File
@@ -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())
+554
View File
@@ -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&amp;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, 01):
- **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 **favorites** 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 &gt; 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 &lt; 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.750.88 |
| 2 | `elapsed_sec ≥ min_elapsed_sec` | e.g. ≥ 530 |
| 3 | `min_deviation_pct < deviation < max_deviation_pct` | e.g. 3% &lt; dev &lt; 100% |
| 4 | Momentum **not** `None` and **> 5%** | fixed in code |
| 5 | `time_left > no_entry_before_end_sec` | e.g. &gt; 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&amp;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&amp;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&amp;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 finders 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