PDB_VWAP代理设置完成
This commit is contained in:
+112
@@ -0,0 +1,112 @@
|
||||
# =============================================================================
|
||||
# Meridian / Polymarket trading bot — git ignore
|
||||
# =============================================================================
|
||||
|
||||
# =============================================================================
|
||||
# SENSITIVE DATA - NEVER COMMIT!
|
||||
# =============================================================================
|
||||
.env
|
||||
*.env
|
||||
!.env.example
|
||||
config/config.json
|
||||
!config/config.example.json
|
||||
|
||||
# =============================================================================
|
||||
# Python Virtual Environment
|
||||
# =============================================================================
|
||||
venv/
|
||||
.venv/
|
||||
env/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# =============================================================================
|
||||
# Python Cache
|
||||
# =============================================================================
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# =============================================================================
|
||||
# Logs and Data
|
||||
# =============================================================================
|
||||
logs/
|
||||
logs_detailed/
|
||||
*.log
|
||||
*.jsonl
|
||||
*.csv
|
||||
|
||||
# =============================================================================
|
||||
# Backup Files
|
||||
# =============================================================================
|
||||
*.backup
|
||||
*.backup2
|
||||
*.bak
|
||||
*.before_*
|
||||
*.broken
|
||||
*_backup_*.tar.gz
|
||||
|
||||
# =============================================================================
|
||||
# Analysis Files
|
||||
# =============================================================================
|
||||
analysis_*.md
|
||||
trade_analysis_*.csv
|
||||
*_ANALYSIS*.md
|
||||
*_FIX*.md
|
||||
*_REPORT*.md
|
||||
*_CHANGES*.md
|
||||
|
||||
# =============================================================================
|
||||
# IDE / Editor
|
||||
# =============================================================================
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.project
|
||||
.pydevproject
|
||||
.settings/
|
||||
|
||||
# =============================================================================
|
||||
# OS Files
|
||||
# =============================================================================
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# =============================================================================
|
||||
# Temporary Files
|
||||
# =============================================================================
|
||||
*.tmp
|
||||
*.temp
|
||||
/tmp/
|
||||
|
||||
# =============================================================================
|
||||
# Charts and Images
|
||||
# =============================================================================
|
||||
*.png
|
||||
*.jpg
|
||||
*.jpeg
|
||||
@@ -0,0 +1,38 @@
|
||||
# Secrets
|
||||
config.env
|
||||
!.env.example
|
||||
|
||||
# Logs and runtime data
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# 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
|
||||
|
||||
# Charts and images
|
||||
*.png
|
||||
*.jpg
|
||||
*.jpeg
|
||||
@@ -0,0 +1,137 @@
|
||||
# Polymarket BTC Auto-Trader — PTB, Diff & Probability (5m / 15m)
|
||||
|
||||
A Python bot for Polymarket **BTC Up/Down** markets in **5-minute** or **15-minute** windows. It merges **Binance** and **Polymarket CLOB** WebSockets, compares **live BTC** to Polymarket’s **price-to-beat (PTB)** for the active event, and can **auto-buy** when **time**, **dollar gap**, and **implied probability** rules align. After a fill, it supports **probability-based take-profit and stop-loss**, optional **auto-redeem** (Builder relayer flow), and a **browser dashboard**.
|
||||
|
||||
| Resource | Link |
|
||||
|----------|------|
|
||||
| **Suite overview** | [Repository README](../README.md) |
|
||||
| **GitHub** | [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)
|
||||
|
||||
**Economic story:** Each market defines a **reference path** for BTC (PTB) over the window. **Chainlink** (or the bot’s configured spot reference) and **outcome token prices** should **co-move** as the window ages. If **spot** is **far on one side** of PTB **late** in the window while the **UP or DOWN token** is still **cheap enough**, a rule-based buy can capture **misalignment** between **physical gap** and **implied probability**.
|
||||
|
||||
**What actually makes money:** Positive expectancy only appears if, **after fees and slippage**, wins and sizes combine so that **average PnL per trade > 0**. That requires either **calibrated triggers** (your `CONDITION_*` bands) or **good exit discipline** (TP/SL). **Simulation** helps estimate behavior; **live** liquidity differs.
|
||||
|
||||
**Failure modes:** **Feed lag** (Binance vs Chainlink vs Polymarket), **stale books**, **PTB definition** nuances (`variant=fifteen` alignment with the site), **partial fills**, and **regime change** (trending vs chop). **Always** start with `SIMULATION_MODE=true`.
|
||||
|
||||
---
|
||||
|
||||
## Risk management
|
||||
|
||||
| Layer | What it does |
|
||||
|--------|----------------|
|
||||
| **`SIMULATION_MODE`** | Runs rules with **instant** simulated fills at book prices—**no** CLOB orders, **no** auto-redeem. Cumulative simulated PnL in `state.json`. |
|
||||
| **`AUTO_TRADE`** | Master switch for **live** order placement (still respect simulation). |
|
||||
| **`TRADE_AMOUNT`** | Caps **USDC per buy**—start minimal. |
|
||||
| **TP / SL** | After entry, **take-profit** and **stop-loss** are tracked in **probability** (0–1) space on the position token—see `config.env` comments. |
|
||||
| **`MARKET_DATA_MAX_LAG_SEC`** | Skips or guards actions when data is **too stale**. |
|
||||
| **Builder keys** | Optional keys for **auto-redeem**—treat like secrets. |
|
||||
|
||||
**Operational:** Use a **dedicated wallet**, **private RPC**, and **proxy** if your network blocks or throttles Polymarket.
|
||||
|
||||
---
|
||||
|
||||
## When to use this bot
|
||||
|
||||
| Use this bot when… | Consider another suite bot when… |
|
||||
|--------------------|----------------------------------|
|
||||
| You care about **PTB vs BTC** and **explicit trigger rows** (`CONDITION_1` … `CONDITION_4`) | You want **multi-asset** late consensus → **Meridian** (`up-down-spread-bot`) |
|
||||
| You want a **web dashboard** at `http://localhost:5080` (default) | You want a **Rich terminal** + **VWAP/momentum** → `btc-binary-VWAP-Momentum-bot` |
|
||||
| You will **paper** first with `SIMULATION_MODE=true` | You need **only** redeem / manual tools—trim features accordingly |
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **Live prices:** Binance and Polymarket CLOB for BTC and outcome tokens.
|
||||
- **Auto trading:** Up to **four** configurable trigger rule groups; **any** matching rule can fire a buy.
|
||||
- **TP / SL:** Probability-based take-profit and stop-loss after a fill.
|
||||
- **Auto redeem:** Optional Polymarket **Builder** relayer flow for winning positions.
|
||||
- **Dashboard:** Browser UI for balance, positions, history, logs, and **5m / 15m** toggle.
|
||||
- **Structured logging:** `TRADING_ANALYSIS_LOG` (default `trading_analysis.jsonl`) — JSON Lines with `schema_version: 2` for research and replay.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Python** 3.8+ recommended.
|
||||
- **Dependencies:** `pip install -r requirements.txt` (includes `py-clob-client`; redeem path uses `web3` / builder libraries as applicable).
|
||||
|
||||
---
|
||||
|
||||
## Configuration (`config.env`)
|
||||
|
||||
### Wallet & network
|
||||
|
||||
- `PRIVATE_KEY` — signing key (**never** commit).
|
||||
- `FUNDER_ADDRESS` — proxy / funder when using signature type 1.
|
||||
- `POLYGON_RPC_URL` — Polygon RPC (**private** endpoint recommended).
|
||||
- `SIGNATURE_TYPE` — e.g. `1` = Gnosis Safe, `2` = EOA (see your setup).
|
||||
|
||||
### Proxy (optional)
|
||||
|
||||
- `HTTP_PROXY` / `HTTPS_PROXY` — e.g. `http://host:port` or `http://user:pass@host:port`.
|
||||
|
||||
### Trading
|
||||
|
||||
- `BTC_MARKET_MINUTES` — `5` or `15` (which Polymarket BTC window). PTB uses Polymarket’s crypto-price API with event start/end from Gamma (`variant=fifteen` for both intervals so PTB matches the site).
|
||||
- `AUTO_TRADE` — `true` / `false` (live orders; ignored when simulation handles placement).
|
||||
- `SIMULATION_MODE` — `true` / `false`. Paper mode: **no** CLOB orders / auto-redeem; PnL tracked in `state.json`.
|
||||
- `TRADING_ANALYSIS_LOG` — optional path; default `trading_analysis.jsonl` next to `polymarket_auto_trade.py`. Relative paths resolve from that script’s directory. Each line is JSON with stable keys: `slug`, `shares_type` (UP/DOWN), `share_price`, `share_amount`, `ptb`, `btc_price`, `difference` (BTC−PTB USD), `status`, `take_profit` / `stop_loss`, `time`, `pnl_trade_usd`, `pnl_total_usd`, `simulation`, etc.
|
||||
- `TRADE_AMOUNT` — USDC per buy.
|
||||
|
||||
### Triggers
|
||||
|
||||
- `CONDITION_1_*` … `CONDITION_4_*` — time window, min/max diff vs PTB, probability bands for UP/DOWN (see inline comments in `config.env`).
|
||||
|
||||
### Risk & loop
|
||||
|
||||
- `STOP_LOSS_PROB_PCT`, `TAKE_PROFIT_RR`, `TAKE_PROFIT_CAP`, `MARKET_DATA_MAX_LAG_SEC`, `LOOP_INTERVAL_SEC`, `BUY_RETRY_STEP`, etc.
|
||||
- `CHECK_INTERVAL` — auxiliary check interval where used.
|
||||
- Builder API keys for auto-redeem: `POLY_BUILDER_API_KEY`, `POLY_BUILDER_SECRET`, `POLY_BUILDER_PASSPHRASE`.
|
||||
|
||||
---
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
python polymarket_auto_trade.py
|
||||
```
|
||||
|
||||
Use the dashboard **Market 5m / 15m** toggle or `BTC_MARKET_MINUTES` in `config.env` to switch horizons.
|
||||
|
||||
---
|
||||
|
||||
## Web dashboard
|
||||
|
||||
Default: **http://localhost:5080** (or `http://<your-ip>:5080` if bound externally).
|
||||
|
||||
Includes balances, live prices, manual trading panel, history, round summary, and log stream.
|
||||
|
||||
---
|
||||
|
||||
## Project layout (essential)
|
||||
|
||||
| Path | Role |
|
||||
|------|------|
|
||||
| `polymarket_auto_trade.py` | Main loop, feeds, rules, orders, dashboard server |
|
||||
| `config.env` | Secrets and trading switches (**gitignore** in your fork) |
|
||||
| `static/dashboard.html` | Dashboard UI |
|
||||
| `state.json` | Persisted runtime / simulation PnL state |
|
||||
| `trading_analysis.jsonl` | Append-only analysis log (optional path) |
|
||||
|
||||
---
|
||||
|
||||
## Extended strategies (contact)
|
||||
|
||||
This folder ships the **PTB / diff / probability** design. **Separate professional offerings** from the same author include advanced **risk and sizing** (martingale, anti-martingale, Fibonacci), **TA** (RSI, MACD, Bollinger Bands), and **quant** tooling (Bayesian belief updates, edge vs market, spread modeling, Avellaneda–Stoikov-style inventory skew, Kelly / fractional Kelly, Monte Carlo). These are **not** all included here—reach out on **[Telegram @terauss](https://t.me/terauss)**.
|
||||
|
||||
---
|
||||
|
||||
## Disclaimer
|
||||
|
||||
**Educational and research use only.** You are solely responsible for trading outcomes. **No warranty.** Prediction markets can **zero** your position. Never share **private keys** or **API secrets**. See the [repository README](../README.md) for the full three-bot map and risk overview.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
py-clob-client
|
||||
requests
|
||||
python-dotenv
|
||||
websocket-client
|
||||
web3
|
||||
py-builder-relayer-client
|
||||
py-builder-signing-sdk
|
||||
flask
|
||||
@@ -0,0 +1,151 @@
|
||||
{
|
||||
"trade_history": [
|
||||
{
|
||||
"time": "2026-03-29 14:21:47",
|
||||
"slug": "btc-updown-5m-1774808400",
|
||||
"action": "BUY",
|
||||
"side": "UP",
|
||||
"price": 0.58,
|
||||
"amount": 1.0,
|
||||
"shares": 1.7241379310344829,
|
||||
"order_size_usdc": 1.0,
|
||||
"order_id": "SIM-1774808507264",
|
||||
"status": "filled",
|
||||
"reason": "R1: time\u2264250s & diff\u2265$2.0 (UP 58%)",
|
||||
"diff": 3.7776531228155363,
|
||||
"btc": 66385.59701,
|
||||
"ptb": 66381.81935687718,
|
||||
"btc_minus_ptb": 3.7776531228155363,
|
||||
"remaining_sec": 192,
|
||||
"cumulative_realized_pnl_usd": 0.0
|
||||
},
|
||||
{
|
||||
"time": "2026-03-29 14:22:11",
|
||||
"slug": "btc-updown-5m-1774808400",
|
||||
"action": "SELL",
|
||||
"side": "UP",
|
||||
"price": 0.47,
|
||||
"entry_price": 0.58,
|
||||
"shares": 1.7241379310344829,
|
||||
"amount": 0.8103448275862069,
|
||||
"order_id": "SIM-SL-1774808531288",
|
||||
"status": "filled",
|
||||
"reason": "stop_loss",
|
||||
"diff": 4.20064312282193,
|
||||
"btc": 66386.02,
|
||||
"ptb": 66381.81935687718,
|
||||
"btc_minus_ptb": 4.20064312282193,
|
||||
"remaining_sec": 168,
|
||||
"realized_pnl_usd": -0.1896551724137931,
|
||||
"cumulative_realized_pnl_usd": -0.1896551724137931
|
||||
},
|
||||
{
|
||||
"time": "2026-03-29 14:25:49",
|
||||
"slug": "btc-updown-5m-1774808700",
|
||||
"action": "BUY",
|
||||
"side": "UP",
|
||||
"price": 0.7,
|
||||
"amount": 1.0,
|
||||
"shares": 1.4285714285714286,
|
||||
"order_size_usdc": 1.0,
|
||||
"order_id": "SIM-1774808749599",
|
||||
"status": "filled",
|
||||
"reason": "R1: time\u2264250s & diff\u2265$2.0 (UP 70%)",
|
||||
"diff": 15.79691042448394,
|
||||
"btc": 66350.59428,
|
||||
"ptb": 66334.79736957552,
|
||||
"btc_minus_ptb": 15.79691042448394,
|
||||
"remaining_sec": 250,
|
||||
"cumulative_realized_pnl_usd": -0.1896551724137931
|
||||
},
|
||||
{
|
||||
"time": "2026-03-29 14:25:53",
|
||||
"slug": "btc-updown-5m-1774808700",
|
||||
"action": "SELL",
|
||||
"side": "UP",
|
||||
"price": 0.58,
|
||||
"entry_price": 0.7,
|
||||
"shares": 1.4285714285714286,
|
||||
"amount": 0.8285714285714285,
|
||||
"order_id": "SIM-SL-1774808753604",
|
||||
"status": "filled",
|
||||
"reason": "stop_loss",
|
||||
"diff": 25.680379454090144,
|
||||
"btc": 66360.47774902961,
|
||||
"ptb": 66334.79736957552,
|
||||
"btc_minus_ptb": 25.680379454090144,
|
||||
"remaining_sec": 246,
|
||||
"realized_pnl_usd": -0.17142857142857143,
|
||||
"cumulative_realized_pnl_usd": -0.3610837438423645
|
||||
},
|
||||
{
|
||||
"time": "2026-03-29 14:25:55",
|
||||
"slug": "btc-updown-5m-1774808700",
|
||||
"action": "BUY",
|
||||
"side": "UP",
|
||||
"price": 0.64,
|
||||
"amount": 1.0,
|
||||
"shares": 1.5625,
|
||||
"order_size_usdc": 1.0,
|
||||
"order_id": "SIM-1774808755608",
|
||||
"status": "filled",
|
||||
"reason": "R1: time\u2264250s & diff\u2265$2.0 (UP 64%)",
|
||||
"diff": 25.680379454090144,
|
||||
"btc": 66360.47774902961,
|
||||
"ptb": 66334.79736957552,
|
||||
"btc_minus_ptb": 25.680379454090144,
|
||||
"remaining_sec": 244,
|
||||
"cumulative_realized_pnl_usd": -0.3610837438423645
|
||||
},
|
||||
{
|
||||
"time": "2026-03-29 14:26:07",
|
||||
"slug": "btc-updown-5m-1774808700",
|
||||
"action": "SELL",
|
||||
"side": "UP",
|
||||
"price": 0.736,
|
||||
"entry_price": 0.64,
|
||||
"shares": 1.5625,
|
||||
"amount": 1.15,
|
||||
"order_id": "SIM-TP-1774808767621",
|
||||
"status": "filled",
|
||||
"reason": "take_profit",
|
||||
"diff": 23.69635015782842,
|
||||
"btc": 66358.49371973335,
|
||||
"ptb": 66334.79736957552,
|
||||
"btc_minus_ptb": 23.69635015782842,
|
||||
"remaining_sec": 232,
|
||||
"realized_pnl_usd": 0.14999999999999997,
|
||||
"cumulative_realized_pnl_usd": -0.21108374384236456
|
||||
},
|
||||
{
|
||||
"time": "2026-03-29 14:30:50",
|
||||
"slug": "btc-updown-5m-1774809000",
|
||||
"action": "BUY",
|
||||
"side": "UP",
|
||||
"price": 0.7,
|
||||
"amount": 1.0,
|
||||
"shares": 1.4285714285714286,
|
||||
"order_size_usdc": 1.0,
|
||||
"order_id": "SIM-1774809050943",
|
||||
"status": "filled",
|
||||
"reason": "R1: time\u2264250s & diff\u2265$2.0 (UP 70%)",
|
||||
"diff": 14.872846938334987,
|
||||
"btc": 66354.75284693834,
|
||||
"ptb": 66339.88,
|
||||
"btc_minus_ptb": 14.872846938334987,
|
||||
"remaining_sec": 249,
|
||||
"cumulative_realized_pnl_usd": -0.21108374384236456
|
||||
}
|
||||
],
|
||||
"ptb": null,
|
||||
"chainlink": 64407.885,
|
||||
"binance": 64462.0,
|
||||
"up_price": 0.505,
|
||||
"down_price": 0.495,
|
||||
"last_update": "2026-07-26T10:00:06.038987",
|
||||
"cumulative_realized_pnl": -0.21108374384236456,
|
||||
"pending_order": {},
|
||||
"position": {},
|
||||
"last_order": {},
|
||||
"take_profit_order": {}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,403 @@
|
||||
# Polymarket 5min, 15min, 1hour Trading Bot — Polymarket Arbitrage Trading Bot & Automation Toolkit
|
||||
|
||||
**Languages / 语言:** English (this page, top to bottom) plus a **[Simplified Chinese section (简体中文)](#zh-cn)** at the end. / 页面主体为英文,文末提供**简体中文**完整说明与表格。
|
||||
|
||||
---
|
||||
|
||||
**Polymarket trading bot** tooling for short-horizon crypto **Up / Down** markets: this repo ships **three** production-style Python **Polymarket bot** implementations you can run, study, and extend. Whether you search for a **polymarket arbitrage trading bot**, a **prediction market bot**, or a **Polymarket API bot** for automated execution, you get readable strategies, WebSocket market data, and CLOB-style order flow patterns built for speed and reliability in research and live-like testing.
|
||||
|
||||
This suite is **educational and experimental**. Use **simulation / dry-run** first, size small, and own your risk.
|
||||
|
||||
**(中文摘要)** 本仓库提供三个可运行的 Python **Polymarket 短线(5m/15m)Up/Down** 机器人实现,含 WebSocket、下单与风控范式;仅供**学习与研究**,请优先**模拟 / 小资金**并自担风险。完整安装、机器人对照表与策略目录见文末 [简体中文](#zh-cn)。
|
||||
|
||||
**Contact (Telegram)** — [@terauss](https://t.me/terauss) · **联系(Telegram)** — [@terauss](https://t.me/terauss)
|
||||
|
||||
This repository is hosted under **[PolyBullLabs](https://github.com/PolyBullLabs)** ([polymarket-5min-15min-1hour-arbitrage-trading-bot](https://github.com/PolyBullLabs/polymarket-5min-15min-1hour-arbitrage-trading-bot)). The org also publishes **Rust** Polymarket bots on **5m**, **15m**, and **1h** horizons with **different strategies** than this Python suite—see the table below.
|
||||
|
||||
|
||||
|
||||
**Proof**
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
<img width="1420" height="875" alt="5min-3-6-1" src="https://github.com/user-attachments/assets/51ff0a72-2d97-4c38-b703-22dc2c4cf7a6" />
|
||||
<img width="1307" height="781" alt="5min-3-6" src="https://github.com/user-attachments/assets/e73b00a1-5893-4d0e-94ae-6086ba81340c" />
|
||||
|
||||
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/29ae399d-6ee1-455a-8caf-e30deef7eae7
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/f17f4012-557d-4e58-ad82-6b705cdbecc0
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/0e9bfcf2-950a-41bc-8318-a20f8de78866
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
## Additional strategy catalog
|
||||
|
||||
The strategies below are **examples of directions** PolyBull Labs builds or discusses as **separate offerings**, custom deployments, or research tracks. They are **not guaranteed** to exist as public drop-ins in this repository—availability, implementation language, and terms are handled individually on **[Telegram @terauss](https://t.me/terauss)**.
|
||||
|
||||
下表为**策略方向示例**(可定制开发或单独交付),**不一定**以本仓库公开子目录形式提供;是否落地、技术栈与商务条款请在 **[Telegram @terauss](https://t.me/terauss)** 沟通。
|
||||
|
||||
| Strategy (EN) | 策略(中文) | Idea (EN) | 思路(中文) |
|
||||
|----------------|-------------|-----------|-------------|
|
||||
| **1c buy** | **1 美分买入** | Seek **ultra-cheap** bids (near **$0.01**) when microstructure or book updates create dislocations; **high variance**, needs **hard notional caps** and kill switches. | 在盘口/流动性短暂失衡时捕捉**极低价**(约 **1 美分**)一侧;**方差极大**,必须配合**严格资金上限**与熔断。 |
|
||||
| **99c sniper** | **99 美分狙击** | Target **near-resolution** asks around **$0.99** when you believe the outcome is effectively settled but liquidity still prints; **tail risk** if the market flips. | 在临近结算、认为结果已高度确定时参与**约 99 美分**一侧;若结果反转则**尾部风险**显著。 |
|
||||
| **Low-side dual reversion** | **弱势双边均值回归** | Work **both** underdog sides when prices are **compressed**, betting on **mean reversion** or late **volatility expansion** with paired risk controls. | 在两侧价格**受压**时,对**弱势/低价**双边做**均值回归**或博弈尾盘**波动放大**,需配对风控。 |
|
||||
| **Pre-order market** | **预挂单 / 盘前布局** | Place **limits ahead** of the active window (or refresh ladders early) to **shape queue position** before competing flow arrives. | 在窗口正式激烈博弈**之前**挂出限价或刷新阶梯,以**排队与价位**占据主动。 |
|
||||
| **Cross-market bot** | **跨市场机器人** | Link **two or more** related markets (same asset, different horizons, or correlated events) for **hedge**, **spread**, or **arbitrage-style** books. | 将**多个相关市场**(同资产不同周期、或相关事件)联动,做**对冲、价差或类套利**组合。 |
|
||||
| **Martingale & anti-martingale @ ~45c** | **约 45¢ 马丁 / 反马丁** | Around **mid prices (~45¢)**, either **add on adverse moves** (martingale-style, very dangerous) or **pyramid into strength / cut into weakness** (anti-martingale); must be **regime-gated**. | 在**中段价位(约 45 美分)**附近,按规则做**亏损加仓(马丁,极高风险)**或**顺势加码 / 逆势减仓(反马丁)**;必须有**行情过滤**与**爆仓防护**。 |
|
||||
| **Fibonacci strategy bot** | **斐波那契策略机器人** | Size entries or grids using **Fibonacci retracement/extension** levels relative to a swing anchor; combines **TA structure** with prediction-market **binary payoffs**. | 以波段锚点计算**斐波那契回撤/扩展**位,用于**分批建仓或网格**;将**技术结构**与二元**盈亏不对称**结合。 |
|
||||
| **Binary momentum (MACD, RSI, VWAP)** | **二元动量(MACD / RSI / VWAP)** | **Momentum** stack on binary Up/Down: **MACD** for trend impulse, **RSI** for stretch / mean-revert filter, **VWAP** for intraday fair value—similar spirit to the shipped VWAP bot but **multi-indicator**. | 在二元 Up/Down 上叠加**动量**:**MACD** 看趋势动能,**RSI** 看过热/回调过滤,**VWAP** 看日内公允;理念接近本仓库 VWAP 机器人但为**多指标融合**。 |
|
||||
| **Dump-hedge** | **急跌对冲** | Detect a **sharp dump**, leg in, then **hedge** the other side when **combined pair cost** clears your edge threshold (see also the **Rust 15m** repo in [Related Rust Polymarket bots](#related-rust-polymarket-bots-poly-tutor)). | 识别**急跌**后先进一侧,再在**组合成本**达标时**对冲**另一侧锁定结构(亦可对照上文 **Rust 15m** 仓库的 **dump-and-hedge** 思路)。 |
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## Why traders and developers use this Polymarket bot suite
|
||||
|
||||
- **Automation-first:** Each **automated trading bot** targets repeatable rules instead of manual clicking—ideal if you want a **crypto trading bot** workflow on a **prediction market bot** stack.
|
||||
- **Execution-aware design:** FAK/FOK patterns, retries, caps, and dashboards where applicable—built for traders who care about **latency, fills, and guardrails**.
|
||||
- **Three philosophies:** Microstructure + VWAP (BTC), multi-asset late consensus (BTC/ETH/SOL/XRP), and oracle-vs-strike (PTB)—so you can compare approaches in one **Polymarket trading bot** codebase.
|
||||
- **Developer-friendly:** Clear layout (one Python project per bot at the repo root), per-bot READMEs, and env-driven config—extend signals or wire your own **arbitrage bot** and **Polymarket API bot** integrations.
|
||||
|
||||
If you want **additional strategies**, **custom deployment**, or **professional risk and sizing** beyond this public suite, reach out on **[Telegram @terauss](https://t.me/terauss)**. For **bots oriented toward live profitability**—more advanced signals, sizing, and execution—contact the same channel; availability and terms are discussed individually.
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
## Screenshots & demo
|
||||
|
||||
Visual references for dashboards and flows (replace or extend with your own recordings as the **Polymarket arbitrage trading bot** suite evolves).
|
||||
|
||||
### VWAP / momentum (BTC binary bot)
|
||||
|
||||
https://github.com/user-attachments/assets/4c528411-6d88-4843-adf8-26c26f63288e
|
||||
|
||||
<img width="1224" height="487" alt="Polymarket trading bot VWAP momentum dashboard" src="https://github.com/user-attachments/assets/450211b1-531f-4abc-aaf0-3d7ab28937d2" />
|
||||
|
||||
<img width="1204" height="702" alt="Polymarket bot terminal UI" src="https://github.com/user-attachments/assets/250c75e5-93ea-4e04-9d29-9912a93deced" />
|
||||
|
||||
### Meridian / late consensus (`late_v3`)
|
||||
|
||||
https://github.com/user-attachments/assets/c811e320-3a0a-4cbe-9cec-8b7a42c0cf6d
|
||||
|
||||
<img width="1159" height="522" alt="Polymarket arbitrage trading bot Meridian spread view" src="https://github.com/user-attachments/assets/e60063c1-67a4-4298-b72f-063ac2bfb94d" />
|
||||
|
||||
<img width="1240" height="899" alt="Prediction market bot multi-asset dashboard" src="https://github.com/user-attachments/assets/158b038c-2952-4fb6-9278-f3d6dfd1afe6" />
|
||||
|
||||
### PTB bot (5m / 15m)
|
||||
|
||||
https://github.com/user-attachments/assets/bfdf7590-5458-4883-88cf-b8343a316f6f
|
||||
|
||||
<img width="1369" height="914" alt="Polymarket API bot PTB web dashboard" src="https://github.com/user-attachments/assets/1c1e654b-e79f-4f3f-b159-14681c07ac6c" />
|
||||
|
||||
<img width="1359" height="906" alt="Automated trading bot PTB controls" src="https://github.com/user-attachments/assets/3291ca28-51a5-45e2-983f-748bd6bcbb76" />
|
||||
|
||||
---
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [Related Rust Polymarket bots (Poly-Tutor)](#related-rust-polymarket-bots-poly-tutor)
|
||||
- [Features](#features)
|
||||
- [How it works](#how-it-works)
|
||||
- [Bots in this repository](#bots-in-this-repository)
|
||||
- [Installation](#installation)
|
||||
- [Usage](#usage)
|
||||
- [Configuration](#configuration)
|
||||
- [Screenshots & demo](#screenshots--demo)
|
||||
- [Risk management snapshot](#risk-management-snapshot)
|
||||
- [Which Polymarket bot should I run?](#which-polymarket-bot-should-i-run)
|
||||
- [Roadmap](#roadmap)
|
||||
- [FAQ](#faq)
|
||||
- [Additional strategy catalog](#additional-strategy-catalog)
|
||||
- [Extended strategies (separate offerings)](#extended-strategies-separate-offerings)
|
||||
- [Disclaimer](#disclaimer)
|
||||
- [License](#license)
|
||||
- [Contact](#contact)
|
||||
- [Simplified Chinese (简体中文)](#zh-cn)
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **Multi-strategy Polymarket trading bot collection** — VWAP/momentum, late-window consensus with structured exits, and PTB-driven triggers.
|
||||
- **Polymarket API bot patterns** — REST + WebSocket usage, order execution helpers, and configs suitable for paper and small live tests.
|
||||
- **Risk controls** — dry run / simulation modes, investment caps, stop-loss and flip-stop (where implemented), entry windows, and spread or confidence gates.
|
||||
- **Dashboards & UX** — Rich terminal UI (VWAP bot), web dashboards (PTB and Meridian paths), logging for operational review.
|
||||
- **Educational depth** — Mechanics explained (why entries can work and how they can fail); aimed at serious traders and builders, not hype.
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
1. **Connect** — Configure wallet/API credentials and Polymarket-compatible endpoints per the bot README (never commit secrets).
|
||||
2. **Select a market window** — Short-horizon **5m / 15m** crypto Up/Down markets; parameters differ by **polymarket bot** (BTC-only vs multi-asset).
|
||||
3. **Ingest & signal** — Live quotes and/or oracle-aligned inputs feed rules (VWAP deviation, late-book skew, PTB vs spot distance, etc.).
|
||||
4. **Execute with guardrails** — Orders respect caps, max prices, simulation flags, and stop logic—treat this as an **automated trading bot** with explicit failure modes, not magic alpha.
|
||||
|
||||
None of this is investment advice; it is **mechanics** and software behavior.
|
||||
|
||||
---
|
||||
|
||||
## Bots in this repository
|
||||
|
||||
All runnable bots live as **top-level folders** next to this README. Each has its own **README**, `requirements.txt`, and configuration (`.env` / `config.json` / `config.env`).
|
||||
|
||||
| Directory | Focus | Markets | Core idea |
|
||||
|-----------|--------|---------|-----------|
|
||||
| [`btc-binary-VWAP-Momentum-bot/`](btc-binary-VWAP-Momentum-bot/) | VWAP, deviation, momentum, z-score | BTC **5m** or **15m** | Enter the **favorite** when price has **pulled above VWAP** with **positive momentum** in a **late, narrow window**—filtering for “consensus + short-term continuation.” |
|
||||
| [`up-down-spread-bot/`](up-down-spread-bot/) (**Meridian**) | Late Entry V3 (`late_v3`) | BTC, ETH, SOL, XRP — **5m** or **15m** | In the **last minutes**, buy the side the book **already favors**, if **spread** and **confidence** (ask skew) pass checks; **stop-loss** and **flip-stop** cut bad paths before expiry. |
|
||||
| [`5min-15min-PTB-bot/`](5min-15min-PTB-bot/) | PTB diff + probability triggers | BTC **5m** or **15m** | Compare **live BTC** to Polymarket’s **price-to-beat (PTB)**; fire when **time**, **dollar diff**, and **implied probability** align; manage risk with **take-profit / stop-loss** on token prices. |
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
git clone https://github.com/PolyBullLabs/polymarket-5min-15min-1hour-arbitrage-trading-bot.git
|
||||
cd polymarket-5min-15min-1hour-arbitrage-trading-bot
|
||||
```
|
||||
|
||||
Then enter the bot you want and create a virtual environment:
|
||||
|
||||
```bash
|
||||
cd btc-binary-VWAP-Momentum-bot # or: up-down-spread-bot / 5min-15min-PTB-bot
|
||||
python -m venv .venv
|
||||
# Windows: .venv\Scripts\activate
|
||||
# macOS/Linux: source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Follow the **README inside that bot** for exact entrypoints, dependencies, and any extra setup (dashboards, env files).
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
1. Pick a bot folder at the repo root and open its README (e.g. [`btc-binary-VWAP-Momentum-bot/README.md`](btc-binary-VWAP-Momentum-bot/README.md)).
|
||||
2. Copy example config / `.env` as documented; set **`SIMULATION_MODE`** or **dry run** when offered.
|
||||
3. Run the documented command (often `python main.py` or the bot-specific script—see each README).
|
||||
4. Monitor logs and dashboards; scale size only after you trust behavior end-to-end.
|
||||
|
||||
**Operational hygiene:** dedicated wallet, **never commit keys**, private **RPC** where relevant, monitor **logs**, start with **minimum size**.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Configuration is **per bot**:
|
||||
|
||||
- **Environment variables** — API keys, RPC URLs, Telegram hooks, simulation flags (see each bot’s `.env.example` or README).
|
||||
- **JSON / config files** — Market choice, windows, price bands, bet sizing, stop-loss and flip-stop parameters.
|
||||
- **Runtime flags** — Some bots expose CLI flags (e.g. web dashboard); check the bot README.
|
||||
|
||||
You can run more than one **polymarket trading bot** only if you understand **collateral**, **nonce / rate limits**, and **position overlap**—typically use **separate wallets** or **non-overlapping** markets.
|
||||
|
||||
|
||||
|
||||
## Risk management snapshot
|
||||
|
||||
| Bot | Primary levers |
|
||||
|-----|----------------|
|
||||
| **VWAP / momentum** | Price band (`min_price` / `max_price`), **narrow entry window**, **bet size**, optional **hedge** (opposite-side GTD), **FAK** execution with retries, **max entry price** cap. |
|
||||
| **Meridian** | **Dry run**, **max order / total investment**, **entry window**, **confidence** and **spread** gates, **stop-loss**, **flip-stop**, **entry frequency**, **FAK / FOK** execution behavior. |
|
||||
| **PTB bot** | **Simulation mode**, **per-trade USDC**, **TP/SL** on probability, **trigger** windows, **market lag** limits, **loop** cadence. |
|
||||
|
||||
---
|
||||
|
||||
## Which Polymarket bot should I run?
|
||||
|
||||
| Situation | Sensible starting point |
|
||||
|-----------|-------------------------|
|
||||
| You want **one asset (BTC)** and **indicator-style** rules with a **terminal dashboard** | `btc-binary-VWAP-Momentum-bot` |
|
||||
| You want **several coins** from **one wallet** and **late-window consensus** with **structured exits** | `up-down-spread-bot` (Meridian) |
|
||||
| You care about **PTB vs Chainlink BTC** and **rule-based** triggers with a **web dashboard** | `5min-15min-PTB-bot` |
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
## Related Rust Polymarket bots (Poly-Tutor)
|
||||
|
||||
This repository is the **Python** toolkit (VWAP, Meridian, PTB). The same org maintains separate repos optimized for **pair-cost / hedging**, **dump-and-hedge**, and **hourly pre-limit + merge** flows.
|
||||
|
||||
| Timeframe | Repository | Language | Strategy (summary) |
|
||||
|-----------|------------|----------|-------------------|
|
||||
| **5 min** | [**5min-btc-polymarket-trading-bot**](https://github.com/Poly-Tutor/5min-btc-polymarket-trading-bot) | Rust | **BTC 5m** Up/Down: lock when **combined cost per pair** stays under your cap (e.g. Up + Down < $1), plus **hedging**, expansion when the opposite side rises, **ride-the-winner**, and PnL rebalance. |
|
||||
| **15 min** | [**Polymarket-15min-arbitrage-bot**](https://github.com/Poly-Tutor/Polymarket-15min-arbitrage-bot) | Rust | **15m** Up/Down (BTC, ETH, SOL, XRP): **dump-and-hedge**—detect a sharp drop, leg in, then **hedge** when pair cost meets targets; optional production CLOB mode. |
|
||||
| **1 hour** | [**1hour-crypto-polymarket-trading-bot**](https://github.com/Poly-Tutor/1hour-crypto-polymarket-trading-bot) | Rust | **Hourly** Up/Down (BTC, ETH, SOL, XRP, Eastern Time): **limit buys** on both sides before the hour, optional **merge** for a small locked edge, **risk exit** if only one side fills. |
|
||||
|
||||
Clone URLs:
|
||||
|
||||
- `git clone https://github.com/Poly-Tutor/5min-btc-polymarket-trading-bot.git`
|
||||
- `git clone https://github.com/Poly-Tutor/Polymarket-15min-arbitrage-bot.git`
|
||||
- `git clone https://github.com/Poly-Tutor/1hour-crypto-polymarket-trading-bot.git`
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
- Broader **paper-trading** defaults and clearer “first run” checklists per bot.
|
||||
- Additional **observability** (structured logs, optional metrics hooks) for production-minded users of this **crypto trading bot** toolkit.
|
||||
- Documentation cross-links and version pins where it helps reproducibility.
|
||||
- Community-driven examples (strategies as plugins) where it fits the architecture.
|
||||
|
||||
---
|
||||
|
||||
## FAQ
|
||||
|
||||
### What is a Polymarket trading bot?
|
||||
|
||||
A **Polymarket trading bot** is software that connects to Polymarket’s APIs, reads market data, and places or manages orders according to rules—so you can automate entries, exits, and risk limits instead of trading only by hand.
|
||||
|
||||
### Is this a polymarket arbitrage trading bot?
|
||||
|
||||
This repository is framed as **polymarket arbitrage trading bot** *tooling*: strategies that seek **edge** in short-horizon Up/Down markets using execution and timing rules. It is **not** a guarantee of arbitrage in the strict sense on every tick—**slippage, fees, and adverse selection** apply.
|
||||
|
||||
### How is this different from a generic crypto trading bot?
|
||||
|
||||
A **crypto trading bot** on CEXes trades spot or perps; this suite is built for **prediction market** mechanics on Polymarket (binary outcomes, CLOB, resolution). The overlap is **automation and risk controls**; the market model is different.
|
||||
|
||||
### Do I need a Polymarket API bot setup?
|
||||
|
||||
Yes, for serious automation you should plan for API keys, signing, and rate-limit-aware clients. These bots follow **Polymarket API bot** patterns (REST/WebSocket, order helpers)—see each bot’s README for specifics.
|
||||
|
||||
### Can I use this as a fully automated trading bot out of the box?
|
||||
|
||||
You can run the code and automation paths, but you must **configure** markets, sizes, and risk. Start with **simulation / dry-run**, validate fills and logs, then scale. **No automated trading bot** removes market or operational risk.
|
||||
|
||||
### Is this prediction market bot software safe?
|
||||
|
||||
Software has bugs; markets have tail risk. Use small size, isolated wallets, and monitoring. Past backtests or demos **do not** predict live results.
|
||||
|
||||
### Who is this for?
|
||||
|
||||
**Developers** who want to learn or extend a **polymarket bot**, and **traders** who accept that **no bot guarantees profit** and who will test responsibly.
|
||||
|
||||
### Where do I get help or custom strategies?
|
||||
|
||||
For **custom deployment**, **additional strategies**, or **professional risk frameworks**, contact **[Telegram @terauss](https://t.me/terauss)**.
|
||||
|
||||
---
|
||||
|
||||
## Additional strategy catalog
|
||||
|
||||
The strategies below are **examples of directions** PolyBull Labs builds or discusses as **separate offerings**, custom deployments, or research tracks. They are **not guaranteed** to exist as public drop-ins in this repository—availability, implementation language, and terms are handled individually on **[Telegram @terauss](https://t.me/terauss)**.
|
||||
|
||||
下表为**策略方向示例**(可定制开发或单独交付),**不一定**以本仓库公开子目录形式提供;是否落地、技术栈与商务条款请在 **[Telegram @terauss](https://t.me/terauss)** 沟通。
|
||||
|
||||
| Strategy (EN) | 策略(中文) | Idea (EN) | 思路(中文) |
|
||||
|----------------|-------------|-----------|-------------|
|
||||
| **1c buy** | **1 美分买入** | Seek **ultra-cheap** bids (near **$0.01**) when microstructure or book updates create dislocations; **high variance**, needs **hard notional caps** and kill switches. | 在盘口/流动性短暂失衡时捕捉**极低价**(约 **1 美分**)一侧;**方差极大**,必须配合**严格资金上限**与熔断。 |
|
||||
| **99c sniper** | **99 美分狙击** | Target **near-resolution** asks around **$0.99** when you believe the outcome is effectively settled but liquidity still prints; **tail risk** if the market flips. | 在临近结算、认为结果已高度确定时参与**约 99 美分**一侧;若结果反转则**尾部风险**显著。 |
|
||||
| **Low-side dual reversion** | **弱势双边均值回归** | Work **both** underdog sides when prices are **compressed**, betting on **mean reversion** or late **volatility expansion** with paired risk controls. | 在两侧价格**受压**时,对**弱势/低价**双边做**均值回归**或博弈尾盘**波动放大**,需配对风控。 |
|
||||
| **Pre-order market** | **预挂单 / 盘前布局** | Place **limits ahead** of the active window (or refresh ladders early) to **shape queue position** before competing flow arrives. | 在窗口正式激烈博弈**之前**挂出限价或刷新阶梯,以**排队与价位**占据主动。 |
|
||||
| **Cross-market bot** | **跨市场机器人** | Link **two or more** related markets (same asset, different horizons, or correlated events) for **hedge**, **spread**, or **arbitrage-style** books. | 将**多个相关市场**(同资产不同周期、或相关事件)联动,做**对冲、价差或类套利**组合。 |
|
||||
| **Martingale & anti-martingale @ ~45c** | **约 45¢ 马丁 / 反马丁** | Around **mid prices (~45¢)**, either **add on adverse moves** (martingale-style, very dangerous) or **pyramid into strength / cut into weakness** (anti-martingale); must be **regime-gated**. | 在**中段价位(约 45 美分)**附近,按规则做**亏损加仓(马丁,极高风险)**或**顺势加码 / 逆势减仓(反马丁)**;必须有**行情过滤**与**爆仓防护**。 |
|
||||
| **Fibonacci strategy bot** | **斐波那契策略机器人** | Size entries or grids using **Fibonacci retracement/extension** levels relative to a swing anchor; combines **TA structure** with prediction-market **binary payoffs**. | 以波段锚点计算**斐波那契回撤/扩展**位,用于**分批建仓或网格**;将**技术结构**与二元**盈亏不对称**结合。 |
|
||||
| **Binary momentum (MACD, RSI, VWAP)** | **二元动量(MACD / RSI / VWAP)** | **Momentum** stack on binary Up/Down: **MACD** for trend impulse, **RSI** for stretch / mean-revert filter, **VWAP** for intraday fair value—similar spirit to the shipped VWAP bot but **multi-indicator**. | 在二元 Up/Down 上叠加**动量**:**MACD** 看趋势动能,**RSI** 看过热/回调过滤,**VWAP** 看日内公允;理念接近本仓库 VWAP 机器人但为**多指标融合**。 |
|
||||
| **Dump-hedge** | **急跌对冲** | Detect a **sharp dump**, leg in, then **hedge** the other side when **combined pair cost** clears your edge threshold (see also the **Rust 15m** repo in [Related Rust Polymarket bots](#related-rust-polymarket-bots-poly-tutor)). | 识别**急跌**后先进一侧,再在**组合成本**达标时**对冲**另一侧锁定结构(亦可对照上文 **Rust 15m** 仓库的 **dump-and-hedge** 思路)。 |
|
||||
|
||||
---
|
||||
|
||||
## Extended strategies (separate offerings)
|
||||
|
||||
Beyond these three open folders, **PolyBull Labs** works on advanced quant-style ideas (sizing sequences, TA combinations, execution-aware models, inventory/skew concepts, Monte Carlo for drawdown, etc.). These are **not** all shipped as drop-in modules here. The **[strategy catalog](#additional-strategy-catalog)** above summarizes **frequently requested** tracks in **English and Chinese**. For **access, customization, or collaboration**, use **[Telegram: @terauss](https://t.me/terauss)**.
|
||||
|
||||
---
|
||||
|
||||
## Disclaimer
|
||||
|
||||
Trading prediction markets can result in **total loss** of capital deployed. Everything here is provided **for education and experimentation**. Authors and contributors are **not** responsible for trading losses, bugs, exchange or protocol rule changes, or regulatory issues in your jurisdiction.
|
||||
|
||||
**Not financial advice.** **No warranty.** Use **simulation / dry-run** until you trust the full stack.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Individual bots may ship their own **LICENSE**; where none is specified, treat usage as **at your own risk**.
|
||||
|
||||
---
|
||||
|
||||
## Contact
|
||||
|
||||
- **Telegram:** [@terauss](https://t.me/terauss)
|
||||
|
||||
---
|
||||
|
||||
<a id="zh-cn"></a>
|
||||
|
||||
## 简体中文(Simplified Chinese)
|
||||
|
||||
### 项目简介
|
||||
|
||||
本仓库是 **PolyBullLabs** 旗下的 **Polymarket 5 分钟 / 15 分钟 / 1 小时**相关 **Python 交易机器人**套件(公开部分为 **三个** 可运行子项目),面向加密 **Up/Down** 短期预测市场:含 REST/WebSocket、下单与风控范式,适合学习、二次开发与**小资金模拟**。**不构成投资建议**;请务必先 **dry-run / 模拟**,并自行承担全部风险。
|
||||
|
||||
- **仓库地址:** [github.com/PolyBullLabs/polymarket-5min-15min-1hour-arbitrage-trading-bot](https://github.com/PolyBullLabs/polymarket-5min-15min-1hour-arbitrage-trading-bot)
|
||||
- **联系:** [Telegram @terauss](https://t.me/terauss)
|
||||
|
||||
### 本仓库内的机器人(公开目录)
|
||||
|
||||
| 目录 | 侧重点 | 市场 | 核心思路 |
|
||||
|------|--------|------|----------|
|
||||
| [`btc-binary-VWAP-Momentum-bot/`](btc-binary-VWAP-Momentum-bot/) | VWAP、偏离、动量、z-score | BTC **5m** 或 **15m** | 在**较晚的窄窗口**内,当价格**站上 VWAP** 且**动量为正**时倾向**强势侧**;强调“共识 + 短线延续”过滤。 |
|
||||
| [`up-down-spread-bot/`](up-down-spread-bot/)(**Meridian**) | Late Entry V3(`late_v3`) | BTC、ETH、SOL、XRP — **5m** 或 **15m** | **尾盘**若点差与**置信度(卖盘偏斜等)**过关,则跟随订单簿已偏向的一侧;**止损 / 翻转止损**在到期前截断劣路径。 |
|
||||
| [`5min-15min-PTB-bot/`](5min-15min-PTB-bot/) | PTB 差值 + 概率触发 | BTC **5m** 或 **15m** | 对比**链上/现货 BTC** 与 Polymarket **PTB(price-to-beat)**;在**时间、价差、隐含概率**共振时触发;可用 **TP/SL** 管理仓位。 |
|
||||
|
||||
### 附加策略目录(与上表一致 · EN/中文)
|
||||
|
||||
与英文章节 **[Additional strategy catalog](#additional-strategy-catalog)** 相同,便于中文读者直接阅读:
|
||||
|
||||
| Strategy (EN) | 策略(中文) | Idea (EN) | 思路(中文) |
|
||||
|----------------|-------------|-----------|-------------|
|
||||
| **1c buy** | **1 美分买入** | Seek **ultra-cheap** bids (near **$0.01**) when microstructure or book updates create dislocations; **high variance**, needs **hard notional caps** and kill switches. | 在盘口/流动性短暂失衡时捕捉**极低价**(约 **1 美分**)一侧;**方差极大**,必须配合**严格资金上限**与熔断。 |
|
||||
| **99c sniper** | **99 美分狙击** | Target **near-resolution** asks around **$0.99** when you believe the outcome is effectively settled but liquidity still prints; **tail risk** if the market flips. | 在临近结算、认为结果已高度确定时参与**约 99 美分**一侧;若结果反转则**尾部风险**显著。 |
|
||||
| **Low-side dual reversion** | **弱势双边均值回归** | Work **both** underdog sides when prices are **compressed**, betting on **mean reversion** or late **volatility expansion** with paired risk controls. | 在两侧价格**受压**时,对**弱势/低价**双边做**均值回归**或博弈尾盘**波动放大**,需配对风控。 |
|
||||
| **Pre-order market** | **预挂单 / 盘前布局** | Place **limits ahead** of the active window (or refresh ladders early) to **shape queue position** before competing flow arrives. | 在窗口正式激烈博弈**之前**挂出限价或刷新阶梯,以**排队与价位**占据主动。 |
|
||||
| **Cross-market bot** | **跨市场机器人** | Link **two or more** related markets (same asset, different horizons, or correlated events) for **hedge**, **spread**, or **arbitrage-style** books. | 将**多个相关市场**(同资产不同周期、或相关事件)联动,做**对冲、价差或类套利**组合。 |
|
||||
| **Martingale & anti-martingale @ ~45c** | **约 45¢ 马丁 / 反马丁** | Around **mid prices (~45¢)**, either **add on adverse moves** (martingale-style, very dangerous) or **pyramid into strength / cut into weakness** (anti-martingale); must be **regime-gated**. | 在**中段价位(约 45 美分)**附近,按规则做**亏损加仓(马丁,极高风险)**或**顺势加码 / 逆势减仓(反马丁)**;必须有**行情过滤**与**爆仓防护**。 |
|
||||
| **Fibonacci strategy bot** | **斐波那契策略机器人** | Size entries or grids using **Fibonacci retracement/extension** levels relative to a swing anchor; combines **TA structure** with prediction-market **binary payoffs**. | 以波段锚点计算**斐波那契回撤/扩展**位,用于**分批建仓或网格**;将**技术结构**与二元**盈亏不对称**结合。 |
|
||||
| **Binary momentum (MACD, RSI, VWAP)** | **二元动量(MACD / RSI / VWAP)** | **Momentum** stack on binary Up/Down: **MACD** for trend impulse, **RSI** for stretch / mean-revert filter, **VWAP** for intraday fair value—similar spirit to the shipped VWAP bot but **multi-indicator**. | 在二元 Up/Down 上叠加**动量**:**MACD** 看趋势动能,**RSI** 看过热/回调过滤,**VWAP** 看日内公允;理念接近本仓库 VWAP 机器人但为**多指标融合**。 |
|
||||
| **Dump-hedge** | **急跌对冲** | Detect a **sharp dump**, leg in, then **hedge** the other side when **combined pair cost** clears your edge threshold (see also the **Rust 15m** repo in [Related Rust Polymarket bots](#related-rust-polymarket-bots-poly-tutor)). | 识别**急跌**后先进一侧,再在**组合成本**达标时**对冲**另一侧锁定结构(亦可对照英文区 **Rust 15m** 仓库的 **dump-and-hedge** 描述)。 |
|
||||
|
||||
### 克隆与安装(摘要)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/PolyBullLabs/polymarket-5min-15min-1hour-arbitrage-trading-bot.git
|
||||
cd polymarket-5min-15min-1hour-arbitrage-trading-bot
|
||||
cd btc-binary-VWAP-Momentum-bot # 或: up-down-spread-bot / 5min-15min-PTB-bot
|
||||
python -m venv .venv
|
||||
# Windows: .venv\Scripts\activate
|
||||
# macOS/Linux: source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
各子目录另有 **README** 与示例环境变量,请**勿将密钥提交到 Git**。
|
||||
|
||||
### 相关 Rust 仓库(Poly-Tutor)
|
||||
|
||||
英文主文档见 **[Related Rust Polymarket bots (Poly-Tutor)](#related-rust-polymarket-bots-poly-tutor)**:含 **5m / 15m / 1h** 的 **pair-cost、dump-hedge、限价+merge** 等 Rust 实现链接与 `git clone` 命令。
|
||||
|
||||
### 免责声明(中文)
|
||||
|
||||
预测市场交易可能导致**本金全部亏损**。本仓库内容仅供**教育与实验**;作者与贡献者**不对**因使用代码产生的交易亏损、程序错误、交易所或协议规则变更、以及您所在司法辖区的合规问题承担责任。**非投资建议。无担保。** 在充分理解行为与风险前,请持续使用**模拟 / 小资金**。
|
||||
|
||||
### 联系(中文)
|
||||
|
||||
- **Telegram:** [@terauss](https://t.me/terauss)
|
||||
|
||||
---
|
||||
|
||||
**If this Polymarket trading bot toolkit is useful, please star the repo** to improve discoverability for other builders and traders—and open issues if you want to contribute improvements to docs or code.
|
||||
|
||||
**若本仓库对您有帮助,欢迎点 Star**,方便其他开发者与交易者发现本项目;也欢迎通过 Issue 贡献文档与代码改进。
|
||||
@@ -0,0 +1,34 @@
|
||||
# ==================================================
|
||||
# BTC 15-min Live Trading Bot - Environment Variables
|
||||
# ==================================================
|
||||
# Copy this file to .env and fill in your credentials:
|
||||
# cp .env.example .env
|
||||
#
|
||||
# NEVER commit .env to git!
|
||||
|
||||
# ── WALLET (REQUIRED) ──────────────────────────────
|
||||
# Your Polygon wallet private key (with 0x prefix)
|
||||
PRIVATE_KEY=0x_your_private_key_here
|
||||
|
||||
# Proxy wallet address (only if using Gnosis Safe / SIGNATURE_TYPE=1 or 2)
|
||||
FUNDER_ADDRESS=
|
||||
SIGNATURE_TYPE=0
|
||||
|
||||
# ── POLYMARKET API (REQUIRED) ──────────────────────
|
||||
# Generate at: https://clob.polymarket.com
|
||||
POLY_API_KEY=
|
||||
POLY_API_SECRET=
|
||||
POLY_API_PASSPHRASE=
|
||||
|
||||
# ── POLYGON NETWORK ────────────────────────────────
|
||||
# Default public RPC works, but Alchemy/Infura is recommended for reliability
|
||||
RPC_URL=https://polygon-rpc.com
|
||||
CHAIN_ID=137
|
||||
|
||||
# ── POLYMARKET CLOB ────────────────────────────────
|
||||
CLOB_HOST=https://clob.polymarket.com
|
||||
|
||||
# ── TELEGRAM (OPTIONAL) ───────────────────────────
|
||||
# Create a bot via @BotFather, get chat ID via @userinfobot
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
TELEGRAM_CHAT_ID=
|
||||
@@ -0,0 +1,40 @@
|
||||
# Secrets
|
||||
.env
|
||||
config.json
|
||||
!config.example.json
|
||||
|
||||
# Logs and runtime data
|
||||
logs/
|
||||
logs_v1/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
*.egg
|
||||
|
||||
# Virtual environment
|
||||
venv/
|
||||
.venv/
|
||||
env/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.cursor/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Backtest / analysis data
|
||||
backtest_data/
|
||||
|
||||
# Charts and images (generated at runtime)
|
||||
*.png
|
||||
@@ -0,0 +1,287 @@
|
||||
# Configuration Guide
|
||||
|
||||
Part of the **[PolyBullLabs Polymarket suite](https://github.com/PolyBullLabs/polymakret-5min-15min-1hour-arbitrage-bot)** · [@terauss](https://t.me/terauss) · [Suite README (all bots)](../README.md)
|
||||
|
||||
This file explains **every parameter** in `config.json`. The bot is highly configurable -- you can fine-tune the strategy, risk, execution speed, hedging, and notifications without touching any code.
|
||||
|
||||
---
|
||||
|
||||
## market -- Which Polymarket window to trade
|
||||
|
||||
```
|
||||
"interval_minutes": 5
|
||||
```
|
||||
|
||||
**BTC up/down market length on Polymarket.** Must be **5** or **15**.
|
||||
|
||||
- **5** — slug pattern `btc-updown-5m-<unix_start>` (300s window). Example strategy: `min_elapsed_sec` ~150–210, `no_entry_before_end_sec` ~90–120.
|
||||
- **15** — slug pattern `btc-updown-15m-<unix_start>` (900s window). Example strategy: `min_elapsed_sec` ~480–530, `no_entry_before_end_sec` ~300–335.
|
||||
|
||||
The bot aligns Chainlink anchor resets, market discovery, and elapsed-time logic to this interval. **Always** set `min_elapsed_sec` and `no_entry_before_end_sec` so they fit inside the market length (e.g. for 5m, `min_elapsed_sec` must be < 300).
|
||||
|
||||
---
|
||||
|
||||
## simulation -- Paper trading (no real money)
|
||||
|
||||
```
|
||||
"enabled": false
|
||||
```
|
||||
|
||||
**When `true`:** the bot still connects to market WebSockets, RTDS Chainlink, runs the dashboard, and evaluates the same entry rules. **No** orders are sent to Polymarket, **no** User WebSocket for fills, **no** auto-redeemer loop. Entries are logged as instant hypothetical fills at **best ask + `entry.price_offset`**, subject to `max_entry_price` and the same contract sizing as live. Optional hedge is simulated as a placed GTD with id `SIM-HEDGE` (no on-chain or CLOB effect).
|
||||
|
||||
```
|
||||
"separate_trading_log": true,
|
||||
"trading_log_path": "logs/trading_log_sim.json"
|
||||
```
|
||||
|
||||
If `separate_trading_log` is **true**, simulated P&L and trades are written only to `trading_log_path`, so your live `logs/trading_log.json` stays untouched. Set to **false** to append simulation results to the same file as live (not recommended if you also run live).
|
||||
|
||||
**Trading history for analysis (simulation only):**
|
||||
|
||||
- `history_csv_path` — Append-only CSV with **OPEN** rows (each simulated entry) and **CLOSE** rows (each resolved position). CLOSE rows include **trade_pnl_usd**, **cumulative_pnl_usd** after that trade, **win_rate_pct**, and **total_closed_trades**. Open in Excel / pandas.
|
||||
- `history_jsonl_path` — One JSON object per line (`type`: `open` or `close`) for streaming tools. Set to `""` to disable.
|
||||
- `history_summary_path` — Rewritten after every close: rolling **summary** (total PnL, wins/losses, win rate, best/worst trade) plus the full **trades** array (same data as `trading_log_path`, convenient for a single analysis file).
|
||||
|
||||
The main `trading_log_path` JSON also includes a **`summary`** block (totals and win rate) on each save, for both live and sim logs.
|
||||
|
||||
**Credentials:** With `simulation.enabled` true, `PRIVATE_KEY` and Polymarket API keys in `.env` are **not** required. You can still set Telegram tokens for notifications.
|
||||
|
||||
---
|
||||
|
||||
## strategy -- When to enter a trade
|
||||
|
||||
These parameters control **which signals the bot acts on**. Think of them as filters: a trade is only placed when ALL conditions pass simultaneously.
|
||||
|
||||
```
|
||||
"min_price": 0.75
|
||||
```
|
||||
**Minimum token price to enter.** The bot only buys tokens priced at or above this value. Lower prices mean higher potential profit but lower probability of winning. At $0.75, you need a 75% win rate to break even. Range: 0.50 - 0.95. Start with 0.75.
|
||||
|
||||
```
|
||||
"max_price": 0.88
|
||||
```
|
||||
**Maximum token price to enter.** The bot rejects tokens priced above this. Higher prices mean higher probability but tiny profit margin. At $0.88, you profit only $0.12 per contract on a win but lose $0.88 on a loss (need 88% win rate). Range: 0.80 - 0.95. Start with 0.88.
|
||||
|
||||
```
|
||||
"min_elapsed_sec": 530
|
||||
```
|
||||
**Minimum seconds elapsed since market opened before allowing entry.** Each market lasts 900 seconds (15 min). This prevents entering too early when the market direction is unclear. At 530, the bot waits ~8.8 minutes. Range: 300 - 800. Higher = safer but fewer opportunities.
|
||||
|
||||
```
|
||||
"min_deviation_pct": 3
|
||||
```
|
||||
**Minimum VWAP deviation (%) to trigger a signal.** VWAP = volume-weighted average price. Deviation measures how far the current price has moved from this average. A deviation of 3% means the token price is 3% above its recent average, indicating strong directional movement. Range: 0 - 15. Set to 0 to disable this filter. Higher = stricter, fewer trades.
|
||||
|
||||
```
|
||||
"max_deviation_pct": 100
|
||||
```
|
||||
**Maximum VWAP deviation (%) allowed.** Rejects signals where deviation is abnormally high (potential spike/manipulation). Set to 100 to effectively disable the upper bound. To cap, try 15-25. Range: must be greater than min_deviation_pct.
|
||||
|
||||
```
|
||||
"no_entry_before_end_sec": 335
|
||||
```
|
||||
**Stop entering trades if fewer than this many seconds remain.** At 335, the bot stops entering after ~9 min 25 sec (with 5 min 35 sec left). This protects against entering too late when there is not enough time for the position to be meaningful. Range: 60 - 500.
|
||||
|
||||
> **Entry window example:** With min_elapsed_sec=530 and no_entry_before_end_sec=335, the bot can only enter between 530s and 565s elapsed -- a 35-second window each market.
|
||||
|
||||
```
|
||||
"momentum_window_sec": 60
|
||||
```
|
||||
**Lookback window for momentum calculation (seconds).** Momentum compares current price to the price N seconds ago. At 60, it asks: "Is the price higher than 1 minute ago?" Shorter windows = more reactive, noisier. Longer = smoother, slower to react. Range: 15 - 300.
|
||||
|
||||
```
|
||||
"vwap_window_sec": 30
|
||||
```
|
||||
**Lookback window for VWAP calculation (seconds).** Only trades from the last N seconds are used to compute VWAP. Shorter = more responsive to recent trades. Longer = smoother average. Range: 10 - 120.
|
||||
|
||||
```
|
||||
"win_rate_csv": "data/win_rate.csv"
|
||||
```
|
||||
**Path to the historical win rate table.** A CSV file containing win probabilities by price range and time bin. The bot uses this to display win rate on the dashboard. Generally no need to change this unless you build your own win rate data.
|
||||
|
||||
---
|
||||
|
||||
## entry -- How to execute orders
|
||||
|
||||
These parameters control **order mechanics**: how much to bet, how aggressively to fill, and what to do on failure.
|
||||
|
||||
```
|
||||
"bet_amount_usd": 5
|
||||
```
|
||||
**How much USD to risk per trade.** The bot divides this by the entry price to get the number of contracts. Example: $5 at price $0.80 = 6 contracts. Start small ($1-5) while learning. Scale up only after consistent results. Range: 1 - any amount you are comfortable losing.
|
||||
|
||||
```
|
||||
"price_offset": 0.02
|
||||
```
|
||||
**Price offset added to best bid for FAK orders.** FAK (Fill-And-Kill) orders must cross the spread to fill immediately. An offset of 0.02 means: if best bid is $0.80, the order is placed at $0.82. Higher offset = more aggressive fill but worse entry price. Range: 0.01 - 0.05.
|
||||
|
||||
```
|
||||
"order_type": "FAK"
|
||||
```
|
||||
**Order type for entry.** FAK = Fill-And-Kill. The order fills immediately at the specified price or gets cancelled. This is the recommended type for fast-moving 15-minute markets. Alternative: "GTC" (Good-Till-Cancel) which stays on the book.
|
||||
|
||||
```
|
||||
"max_retries": 3
|
||||
```
|
||||
**How many times to retry a failed order.** If the first attempt fails (rejected, no fill), the bot retries up to this many times. Range: 1 - 10. More retries = better chance of filling but uses more time.
|
||||
|
||||
```
|
||||
"retry_delay_ms": 300
|
||||
```
|
||||
**Milliseconds to wait between retry attempts.** Range: 100 - 2000. Shorter = faster retries. Don't set too low or you may hit rate limits.
|
||||
|
||||
```
|
||||
"fill_timeout_ms": 1000
|
||||
```
|
||||
**How long to wait for fill confirmation (ms).** After placing a FAK order, the bot waits this long for a WebSocket fill message. If no fill arrives in time, it enters recovery mode. Range: 500 - 5000.
|
||||
|
||||
```
|
||||
"min_contracts": 5
|
||||
```
|
||||
**Minimum number of contracts per order.** Polymarket requires at least 5 contracts. If bet_amount_usd / price results in fewer than 5 contracts, the order is skipped. Generally no need to change.
|
||||
|
||||
```
|
||||
"min_order_usd": 1
|
||||
```
|
||||
**Minimum order value in USD.** Orders below this value are skipped. Generally no need to change.
|
||||
|
||||
```
|
||||
"max_entry_price": 0.88
|
||||
```
|
||||
**Hard price ceiling for entry.** Even if the signal says BUY, the order is rejected if the execution price exceeds this. Acts as a safety net. Should be equal to or less than strategy.max_price.
|
||||
|
||||
```
|
||||
"ws_recovery_timeout_sec": 10
|
||||
```
|
||||
**Timeout for WebSocket fill recovery (seconds).** When an order times out, the bot checks the User WebSocket for fills. This is how long it waits during recovery. Range: 5 - 30.
|
||||
|
||||
---
|
||||
|
||||
## hedge -- Automatic hedging (advanced)
|
||||
|
||||
Hedging places a cheap order on the **opposite** token after entry. If your main trade loses, the hedge may fill and partially offset the loss.
|
||||
|
||||
```
|
||||
"enabled": false
|
||||
```
|
||||
**Enable or disable automatic hedging.** Set to true to activate. When enabled, after each entry the bot places a GTD order on the opposite token. Recommended to leave false until you understand the mechanics.
|
||||
|
||||
```
|
||||
"hedge_price": 0.02
|
||||
```
|
||||
**Price to place the hedge order at.** The hedge buys the opposite token at this price. At $0.02, you pay $0.02 per contract. If your main trade loses, the opposite token resolves to $1.00, netting $0.98 per contract. The hedge only fills if the market strongly moves in your favor (opposite token drops to $0.02). Range: 0.01 - 0.10.
|
||||
|
||||
```
|
||||
"order_type": "GTD"
|
||||
```
|
||||
**Hedge order type.** GTD = Good-Till-Date. A limit order that sits on the book until it fills or expires. Expires in 1 hour (market resolves in 15 minutes). No need to change.
|
||||
|
||||
```
|
||||
"max_retries": 3
|
||||
```
|
||||
**Retry count for hedge order placement.** If hedge placement fails, retry up to this many times.
|
||||
|
||||
```
|
||||
"retry_delay_ms": 1000
|
||||
```
|
||||
**Delay between hedge retry attempts (ms).** Hedge placement is less time-critical than entry, so a longer delay is fine.
|
||||
|
||||
---
|
||||
|
||||
## redeem -- Automatic on-chain redemption
|
||||
|
||||
After a market resolves, winning positions must be redeemed on the blockchain to collect your payout.
|
||||
|
||||
```
|
||||
"enabled": true
|
||||
```
|
||||
**Enable automatic redemption.** When true, the bot periodically checks for resolved positions and redeems them. If false, you must redeem manually on polymarket.com. Recommended: true.
|
||||
|
||||
```
|
||||
"interval_seconds": 180
|
||||
```
|
||||
**How often to check for redeemable positions (seconds).** Every 180 seconds (3 minutes), the bot scans for positions that can be redeemed. Range: 60 - 600. Lower = more frequent checks but more API calls.
|
||||
|
||||
```
|
||||
"auto_confirm": true
|
||||
```
|
||||
**Automatically confirm redemptions.** When true, redemptions happen without manual approval. When false, redemptions are logged but not executed.
|
||||
|
||||
---
|
||||
|
||||
## telegram -- Notifications
|
||||
|
||||
Optional Telegram integration for trade alerts and equity charts. Requires TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID in .env.
|
||||
|
||||
```
|
||||
"enabled": false
|
||||
```
|
||||
**Enable Telegram notifications.** Set to true after configuring bot token and chat ID in .env. You will receive messages on trade entry, market end results, and periodic equity charts.
|
||||
|
||||
```
|
||||
"chart_every_n_trades": 5
|
||||
```
|
||||
**Send an equity chart every N trades.** After every 5 trades (by default), the bot generates a P&L chart and sends it to your Telegram. Range: 1 - 50.
|
||||
|
||||
---
|
||||
|
||||
## logging -- Log settings
|
||||
|
||||
```
|
||||
"level": "INFO"
|
||||
```
|
||||
**Logging verbosity.** Options: "DEBUG" (very verbose), "INFO" (normal), "WARNING" (errors only). Use DEBUG for troubleshooting, INFO for normal operation.
|
||||
|
||||
```
|
||||
"file_rotation_hours": 3
|
||||
```
|
||||
**Rotate log files every N hours.** Prevents log files from growing indefinitely. Range: 1 - 24.
|
||||
|
||||
---
|
||||
|
||||
## Quick Presets
|
||||
|
||||
### Conservative (low risk, fewer trades)
|
||||
```json
|
||||
{
|
||||
"strategy": {
|
||||
"min_price": 0.80,
|
||||
"max_price": 0.85,
|
||||
"min_elapsed_sec": 600,
|
||||
"min_deviation_pct": 5,
|
||||
"no_entry_before_end_sec": 300
|
||||
},
|
||||
"entry": { "bet_amount_usd": 2 },
|
||||
"hedge": { "enabled": true }
|
||||
}
|
||||
```
|
||||
|
||||
### Moderate (balanced)
|
||||
```json
|
||||
{
|
||||
"strategy": {
|
||||
"min_price": 0.75,
|
||||
"max_price": 0.88,
|
||||
"min_elapsed_sec": 530,
|
||||
"min_deviation_pct": 3,
|
||||
"no_entry_before_end_sec": 335
|
||||
},
|
||||
"entry": { "bet_amount_usd": 10 },
|
||||
"hedge": { "enabled": true }
|
||||
}
|
||||
```
|
||||
|
||||
### Aggressive (more trades, higher risk)
|
||||
```json
|
||||
{
|
||||
"strategy": {
|
||||
"min_price": 0.70,
|
||||
"max_price": 0.90,
|
||||
"min_elapsed_sec": 480,
|
||||
"min_deviation_pct": 0,
|
||||
"no_entry_before_end_sec": 120
|
||||
},
|
||||
"entry": { "bet_amount_usd": 50 },
|
||||
"hedge": { "enabled": false }
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,856 @@
|
||||
# BTC 15-Minute Live Trading Bot - Complete Logic Documentation
|
||||
|
||||
**Suite:** [PolyBullLabs — polymakret-5min-15min-1hour-arbitrage-bot](https://github.com/PolyBullLabs/polymakret-5min-15min-1hour-arbitrage-bot) · [@terauss](https://t.me/terauss) · [README](README.md) · [CONFIG.md](CONFIG.md)
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [System Overview](#1-system-overview)
|
||||
2. [Market Structure](#2-market-structure)
|
||||
3. [Data Acquisition Layer](#3-data-acquisition-layer)
|
||||
4. [Indicator Calculations (Formulas)](#4-indicator-calculations-formulas)
|
||||
5. [Signal Generation Engine](#5-signal-generation-engine)
|
||||
6. [Order Execution Pipeline](#6-order-execution-pipeline)
|
||||
7. [Hedge Mechanism](#7-hedge-mechanism)
|
||||
8. [Position Lifecycle and PnL Accounting](#8-position-lifecycle-and-pnl-accounting)
|
||||
9. [Drawdown Tracking](#9-drawdown-tracking)
|
||||
10. [Chainlink BTC/USD Oracle Integration](#10-chainlink-btcusd-oracle-integration)
|
||||
11. [Auto-Redemption System](#11-auto-redemption-system)
|
||||
12. [Configuration Reference](#12-configuration-reference)
|
||||
13. [Fault Tolerance and Recovery](#13-fault-tolerance-and-recovery)
|
||||
14. [File and Log Architecture](#14-file-and-log-architecture)
|
||||
|
||||
---
|
||||
|
||||
## 1. System Overview
|
||||
|
||||
The bot trades **Polymarket BTC Up/Down 15-minute binary markets**. Each market resolves to either "UP" (BTC price rose) or "DOWN" (BTC price fell) over a 15-minute window aligned to epoch boundaries (multiples of 900 seconds).
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
LiveTradingBot
|
||||
+----------------------------------------------------------+
|
||||
| |
|
||||
| +----------+ +--------------+ +------------------+ |
|
||||
| | Market | | WebSocket | | Chainlink RTDS | |
|
||||
| | Finder | | Client | | Price Client | |
|
||||
| | (HTTP) | | (wss://) | | (wss://) | |
|
||||
| +----+-----+ +------+-------+ +--------+---------+ |
|
||||
| | | | |
|
||||
| v v v |
|
||||
| +-------------------------------------------------+ |
|
||||
| | MarketState (shared) | |
|
||||
| | up_token, down_token, btc_price, end_time | |
|
||||
| +------------------------+------------------------+ |
|
||||
| | |
|
||||
| +-------------------+-------------------+ |
|
||||
| v v v |
|
||||
| +----------+ +---------------+ +------------+ |
|
||||
| |Dashboard | | Signal | | Order | |
|
||||
| | (Rich) | | Generator | | Executor | |
|
||||
| +----------+ +-------+-------+ +------+-----+ |
|
||||
| | | |
|
||||
| v v |
|
||||
| +----------------+ +--------------+ |
|
||||
| | TradingStats | | HedgeManager | |
|
||||
| | (Position/PnL) | | (GTD orders) | |
|
||||
| +----------------+ +--------------+ |
|
||||
| |
|
||||
| +--------------+ +------------------+ |
|
||||
| | AutoRedeemer | | TelegramNotifier | |
|
||||
| | (background) | | (alerts/charts) | |
|
||||
| +--------------+ +------------------+ |
|
||||
+----------------------------------------------------------+
|
||||
```
|
||||
|
||||
### Main Loop (simplified)
|
||||
|
||||
```
|
||||
while running:
|
||||
market = find_active_btc_15m_market() # HTTP -> Gamma API
|
||||
subscribe_websocket(market.tokens) # wss:// -> live prices
|
||||
|
||||
while market.is_active:
|
||||
update_indicators() # every 250ms
|
||||
signal = evaluate_strategy() # check all conditions
|
||||
|
||||
if signal == BUY:
|
||||
execute_entry(signal) # FAK order
|
||||
place_hedge() # GTD order (opposite token)
|
||||
|
||||
track_drawdown() # update min_price_seen
|
||||
|
||||
if time_left <= 10s:
|
||||
close_position() # record PnL
|
||||
break
|
||||
|
||||
wait_for_next_market() # ~5-30 seconds
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Market Structure
|
||||
|
||||
### Polymarket BTC Up/Down 15-Min Markets
|
||||
|
||||
Each market is a **binary outcome** contract:
|
||||
|
||||
- **UP token**: Pays $1.00 if BTC price is higher at market end vs. start. Otherwise $0.
|
||||
- **DOWN token**: Pays $1.00 if BTC price is lower at market end vs. start. Otherwise $0.
|
||||
|
||||
Tokens trade between $0.01 and $0.99. At any time:
|
||||
|
||||
```
|
||||
P_UP + P_DOWN ~ 1.00
|
||||
```
|
||||
|
||||
### Market Timing
|
||||
|
||||
Markets are aligned to 15-minute epoch boundaries:
|
||||
|
||||
```
|
||||
T_start = floor(T_now / 900) * 900
|
||||
T_end = T_start + 900
|
||||
```
|
||||
|
||||
Market slug format: `btc-updown-15m-{T_start}`
|
||||
|
||||
Example: `btc-updown-15m-1770831900` starts at Unix timestamp 1770831900.
|
||||
|
||||
### Market Discovery
|
||||
|
||||
The bot searches the Gamma API for active markets using offsets from the current 15-minute window:
|
||||
|
||||
```python
|
||||
for offset in [0, 900, -900, 1800]:
|
||||
target_ts = current_window + offset
|
||||
slug = f"btc-updown-15m-{target_ts}"
|
||||
# Query: GET /markets?slug={slug}&active=true&closed=false
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Data Acquisition Layer
|
||||
|
||||
### 3.1 Market Data WebSocket
|
||||
|
||||
**URL**: `wss://ws-subscriptions-clob.polymarket.com/ws/market`
|
||||
|
||||
Subscribes to both UP and DOWN token IDs. Processes three event types:
|
||||
|
||||
#### `last_trade_price` - Trade Execution
|
||||
|
||||
Each trade is stored as `Trade(timestamp, price, size, side)` in a deque per token.
|
||||
|
||||
Tracked aggregates:
|
||||
- `trade_count`: Total number of trades
|
||||
- `volume_total`: Total contract volume
|
||||
- `volume_buy`: Volume from buy-side
|
||||
- `volume_sell`: Volume from sell-side
|
||||
|
||||
#### `price_change` - Best Bid/Ask Updates
|
||||
|
||||
Updates `best_bid` and `best_ask` for each token.
|
||||
|
||||
#### `book` - Order Book Snapshots
|
||||
|
||||
Parses bids and asks arrays, extracts top-of-book:
|
||||
- `best_bid`, `best_bid_size`
|
||||
- `best_ask`, `best_ask_size`
|
||||
|
||||
Spread:
|
||||
|
||||
```
|
||||
Spread = P_ask - P_bid
|
||||
```
|
||||
|
||||
### 3.2 User WebSocket (Order Tracking)
|
||||
|
||||
**URL**: Polymarket User Channel (authenticated via API credentials)
|
||||
|
||||
Tracks order lifecycle: `PLACEMENT -> MATCHED -> MINED -> CONFIRMED`
|
||||
|
||||
Used for:
|
||||
- Fill confirmation after entry orders
|
||||
- Hedge fill detection
|
||||
- Timeout recovery (checking if order filled despite timeout)
|
||||
|
||||
### 3.3 Chainlink BTC/USD Price Stream
|
||||
|
||||
**URL**: `wss://ws-live-data.polymarket.com` (Polymarket RTDS)
|
||||
|
||||
**Topic**: `crypto_prices_chainlink` filtered for `btc/usd` symbol.
|
||||
|
||||
See Section 10 for full details.
|
||||
|
||||
---
|
||||
|
||||
## 4. Indicator Calculations (Formulas)
|
||||
|
||||
All indicators are calculated from the live trade stream. Each token (UP and DOWN) has its own independent indicator set.
|
||||
|
||||
### 4.1 VWAP (Volume-Weighted Average Price)
|
||||
|
||||
VWAP over a configurable time window W (default 30 seconds):
|
||||
|
||||
```
|
||||
SUM(P_i * V_i) for all trades where (T_now - T_i) <= W
|
||||
VWAP_W = -------------------------
|
||||
SUM(V_i)
|
||||
```
|
||||
|
||||
Where:
|
||||
- `P_i` = price of trade i
|
||||
- `V_i` = size (volume) of trade i
|
||||
- `trades(W)` = set of trades within the last W seconds
|
||||
|
||||
Returns 0.0 if no trades in window.
|
||||
|
||||
### 4.2 Deviation from VWAP
|
||||
|
||||
Percentage deviation of the current last-trade price from VWAP:
|
||||
|
||||
```
|
||||
P_last - VWAP
|
||||
D = ---------------------- * 100%
|
||||
VWAP
|
||||
```
|
||||
|
||||
Where:
|
||||
- `D > 0`: Price is **above** VWAP (bullish pressure)
|
||||
- `D < 0`: Price is **below** VWAP (bearish pressure)
|
||||
- `D = 0`: Price equals VWAP
|
||||
|
||||
### 4.3 Momentum
|
||||
|
||||
Momentum compares the current price to the average price W seconds ago (default 60s), using a band of +/-1.5 seconds to smooth:
|
||||
|
||||
```
|
||||
P_ago = mean({P_i where T_now - W - d <= T_i <= T_now - W + d})
|
||||
|
||||
P_last - P_ago
|
||||
M = ------------------------- * 100%
|
||||
P_ago
|
||||
```
|
||||
|
||||
Where:
|
||||
- `W` = momentum_window_sec (default 60)
|
||||
- `d` = averaging band (1.5 seconds)
|
||||
- Returns `None` if no trades exist in the band window
|
||||
|
||||
Interpretation:
|
||||
- `M > 0`: Price has risen over the window (positive momentum)
|
||||
- `M < 0`: Price has fallen (negative momentum)
|
||||
|
||||
### 4.4 Z-Score
|
||||
|
||||
Statistical z-score of the current price relative to recent trade prices over a 5-second window:
|
||||
|
||||
```
|
||||
P_last - mean(prices_5s)
|
||||
z = ----------------------------
|
||||
stdev(prices_5s)
|
||||
```
|
||||
|
||||
Where:
|
||||
- `mean(prices_5s)` = arithmetic mean of all trade prices in last 5 seconds
|
||||
- `stdev(prices_5s)` = standard deviation, with minimum floor of 0.001
|
||||
|
||||
Interpretation:
|
||||
- `z > 2`: Price significantly above recent mean (overbought short-term)
|
||||
- `z < -2`: Price significantly below recent mean (oversold short-term)
|
||||
|
||||
### 4.5 Win Rate Lookup
|
||||
|
||||
Historical win rates are stored in `data/win_rate.csv` as a 10x15 matrix:
|
||||
|
||||
| Price Range | min_0 | min_1 | ... | min_14 |
|
||||
|-------------|--------|--------|-----|--------|
|
||||
| 0.50-0.54 | 52.2% | 50.2% | ... | 52.7% |
|
||||
| 0.75-0.79 | 71.1% | 76.4% | ... | 75.0% |
|
||||
| 0.85-0.89 | 68.0% | 73.0% | ... | 93.3% |
|
||||
| 0.95-0.99 | 63.2% | 68.2% | ... | 100% |
|
||||
|
||||
**Time bin** calculation:
|
||||
|
||||
```
|
||||
bin = floor(14 - T_remaining / 60)
|
||||
```
|
||||
|
||||
Where `bin` is in [0, 14], with bin 0 = first minute, bin 14 = last minute.
|
||||
|
||||
**Lookup**: Given favorite token price P_fav and time bin, the table returns the historical win probability.
|
||||
|
||||
---
|
||||
|
||||
## 5. Signal Generation Engine
|
||||
|
||||
The signal generator runs inside `Dashboard.create_strategy_panel()`, evaluated every 250ms (4 Hz refresh).
|
||||
|
||||
### 5.1 Favorite Token Selection
|
||||
|
||||
The "favorite" is the token with the higher last-trade price:
|
||||
|
||||
```
|
||||
| UP if P_UP > P_DOWN
|
||||
favorite = |
|
||||
| DOWN otherwise
|
||||
```
|
||||
|
||||
The favorite's indicators are used for signal evaluation:
|
||||
- `P_fav` = favorite price
|
||||
- `D_fav` = favorite deviation from VWAP
|
||||
- `M_fav` = favorite momentum
|
||||
|
||||
### 5.2 Entry Conditions (ALL must be true)
|
||||
|
||||
| # | Condition | Formula | Config Parameter |
|
||||
|----|-------------------------|--------------------------------|---------------------------|
|
||||
| 1 | Price in range | P_min <= P_fav <= P_max | min_price, max_price |
|
||||
| 2 | Sufficient time elapsed | T_elapsed >= T_min_elapsed | min_elapsed_sec |
|
||||
| 3 | Deviation in range | D_min < D_fav < D_max | min/max_deviation_pct |
|
||||
| 4 | Positive momentum | M_fav > 0 | - |
|
||||
| 5 | Not too close to end | T_remaining > T_no_entry | no_entry_before_end_sec |
|
||||
|
||||
Where:
|
||||
- `T_elapsed = 900 - T_remaining`
|
||||
- `T_remaining = T_end - T_now`
|
||||
|
||||
### 5.3 Signal States
|
||||
|
||||
```
|
||||
if T_remaining <= T_no_entry:
|
||||
-> NO ENTRY (cutoff reached, no further entry this market)
|
||||
|
||||
elif ALL 5 conditions TRUE:
|
||||
-> BUY {UP|DOWN} (signal triggers execute_entry)
|
||||
|
||||
elif P_fav >= 0.70 AND T_elapsed >= T_min_elapsed:
|
||||
if M_fav <= 0: -> ALMOST (need Mom>0%)
|
||||
if D_fav >= D_max: -> ALMOST (Dev too high)
|
||||
else: -> ALMOST (need dev)
|
||||
|
||||
else:
|
||||
-> WAIT (with specific reason: elapsed/price/dev/mom)
|
||||
```
|
||||
|
||||
### 5.4 Signal Flow
|
||||
|
||||
```
|
||||
Dashboard.create_strategy_panel()
|
||||
|
|
||||
+-- Evaluates conditions every 250ms
|
||||
+-- If BUY: sets self.last_signal = "BUY_UP" or "BUY_DOWN"
|
||||
|
|
||||
v
|
||||
Main loop (run_session)
|
||||
|
|
||||
+-- Reads self.dashboard.last_signal
|
||||
+-- Clears signal (one-shot)
|
||||
+-- Creates asyncio task: _safe_execute_entry(signal)
|
||||
|
|
||||
v
|
||||
execute_entry("BUY_UP" or "BUY_DOWN")
|
||||
```
|
||||
|
||||
**Important**: Only ONE entry per market. `can_enter()` returns False once a position is recorded OR entry is blocked.
|
||||
|
||||
---
|
||||
|
||||
## 6. Order Execution Pipeline
|
||||
|
||||
### 6.1 Pre-Execution Guards
|
||||
|
||||
Before placing any order, three guards are checked:
|
||||
|
||||
1. **Position check**: `stats.can_enter()` - no existing position, not closed this market, not blocked
|
||||
2. **Time cutoff**: `T_remaining > T_no_entry`
|
||||
3. **Token data available**: Both UP and DOWN tokens must have data
|
||||
|
||||
### 6.2 Order Configuration
|
||||
|
||||
| Parameter | Value | Description |
|
||||
|------------------|---------|------------------------------------------|
|
||||
| bet_amount_usd | 50 | USD to risk per trade |
|
||||
| price_offset | 0.02 | Added to best bid for aggressive fill |
|
||||
| order_type | FAK | Fill-And-Kill (immediate or cancel) |
|
||||
| max_retries | 3 | Retry count on failure |
|
||||
| retry_delay_ms | 300 | Delay between retries |
|
||||
| fill_timeout_ms | 1000 | Max wait for fill confirmation |
|
||||
| min_contracts | 5 | Polymarket minimum |
|
||||
| max_entry_price | 0.88 | Hard price ceiling |
|
||||
|
||||
### 6.3 Contract Calculation
|
||||
|
||||
```
|
||||
contracts = floor(bet_amount_usd / P_entry)
|
||||
```
|
||||
|
||||
Where `P_entry = min(P_best_ask, P_max_entry)`
|
||||
|
||||
The order is placed at:
|
||||
|
||||
```
|
||||
P_order = P_best_bid + price_offset
|
||||
```
|
||||
|
||||
### 6.4 FAK Order Flow
|
||||
|
||||
```
|
||||
1. Fetch best_bid from orderbook
|
||||
2. Calculate: P_order = best_bid + price_offset
|
||||
3. Validate: P_order <= max_entry_price
|
||||
4. Place FAK BUY order
|
||||
5. Wait fill_timeout_ms for fill confirmation via WebSocket
|
||||
6. If filled: record position -> place hedge -> done
|
||||
7. If timeout: enter recovery mode (see Section 13)
|
||||
8. If rejected: retry up to max_retries
|
||||
```
|
||||
|
||||
### 6.5 Signal Logging
|
||||
|
||||
At the moment of execution, a comprehensive snapshot is logged to `signals.log`:
|
||||
|
||||
- Timestamp, market slug, signal direction, token
|
||||
- Elapsed/remaining time, time bin
|
||||
- For each token (UP and DOWN):
|
||||
- LAST, BID, ASK prices
|
||||
- VWAP, Deviation, Z-Score, Momentum
|
||||
- Trade count, Total/Buy/Sell volume
|
||||
- Win rate lookup value
|
||||
- Strategy config parameters
|
||||
- Chainlink BTC/USD price, anchor, and deviation
|
||||
|
||||
---
|
||||
|
||||
## 7. Hedge Mechanism
|
||||
|
||||
### 7.1 Purpose
|
||||
|
||||
After buying the favorite token (e.g., UP at $0.85), the bot places a **hedge order** on the **opposite token** (DOWN) at a very low price ($0.02).
|
||||
|
||||
If the trade loses (UP resolves to $0), the hedge may fill, providing the opposite token at $0.02 which resolves to $1.00 -- a $0.98 profit per contract that partially offsets the loss.
|
||||
|
||||
### 7.2 Hedge PnL Math
|
||||
|
||||
**Without hedge** (unhedged loss):
|
||||
|
||||
```
|
||||
PnL_loss = -C * P_entry
|
||||
```
|
||||
|
||||
Where C = contracts, P_entry = entry price.
|
||||
|
||||
**With hedge** (if hedge fills before resolution):
|
||||
|
||||
```
|
||||
PnL_hedged_loss = -C * P_entry + C_hedge * (1.00 - P_hedge)
|
||||
```
|
||||
|
||||
With P_hedge = 0.02:
|
||||
|
||||
```
|
||||
PnL_hedged_loss = -C * P_entry + C_hedge * 0.98
|
||||
```
|
||||
|
||||
### 7.3 Hedge Order Type
|
||||
|
||||
- **GTD (Good-Till-Date)**: Limit order that stays on the book until expiry
|
||||
- Placed at `hedge_price` ($0.02) on the opposite token
|
||||
- Expires in 1 hour (market resolves in <=15 minutes)
|
||||
- Only fills if opposite token price drops to $0.02 (i.e., our side is winning strongly)
|
||||
|
||||
### 7.4 Hedge Fill Tracking
|
||||
|
||||
The User WebSocket monitors for fill events matching the hedge order ID:
|
||||
|
||||
```
|
||||
on_trade(data):
|
||||
if data.order_id == hedge_order_id AND status == "MATCHED":
|
||||
hedge_mgr.on_hedge_fill(size, price)
|
||||
if hedge_mgr.is_fully_hedged:
|
||||
stats.record_hedge(contracts, price)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Position Lifecycle and PnL Accounting
|
||||
|
||||
### 8.1 Position States
|
||||
|
||||
```
|
||||
NO POSITION OPEN POSITION
|
||||
+----------+ execute_entry +--------------+
|
||||
| can_enter | ---------------> | LONG UP |
|
||||
| = true | | or DOWN |
|
||||
+----------+ | |
|
||||
| entry_price |
|
||||
| contracts |
|
||||
| hedged? |
|
||||
+------+-------+
|
||||
|
|
||||
check_market_end (T_left <= 10s)
|
||||
|
|
||||
v
|
||||
+--------------+
|
||||
| CLOSED |
|
||||
| TradeRecord |
|
||||
| (PnL, DD) |
|
||||
+--------------+
|
||||
```
|
||||
|
||||
### 8.2 PnL Calculation
|
||||
|
||||
At market end (10 seconds before expiry), the bot reads the final token price.
|
||||
|
||||
**Win condition**: `P_final >= 0.70`
|
||||
|
||||
**Win PnL** (token resolves to ~$1.00):
|
||||
|
||||
```
|
||||
PnL_win = C * 1.00 - C * P_entry = C * (1 - P_entry)
|
||||
```
|
||||
|
||||
**Loss PnL** (token resolves to ~$0.00):
|
||||
|
||||
```
|
||||
PnL_loss = 0 - C * P_entry = -C * P_entry
|
||||
```
|
||||
|
||||
**Examples** with C = 64 contracts:
|
||||
|
||||
| Entry Price | Win PnL | Loss PnL |
|
||||
|-------------|----------|-----------|
|
||||
| $0.75 | +$16.00 | -$48.00 |
|
||||
| $0.81 | +$12.16 | -$51.84 |
|
||||
| $0.88 | +$7.68 | -$56.32 |
|
||||
|
||||
### 8.3 Win Rate and Session Statistics
|
||||
|
||||
```
|
||||
Win Rate = W / (W + L) * 100%
|
||||
|
||||
Total PnL = SUM(PnL_i) for all trades i = 1..N
|
||||
|
||||
Avg Win = SUM(PnL_w) / count(wins)
|
||||
|
||||
Avg Loss = SUM(PnL_l) / count(losses)
|
||||
```
|
||||
|
||||
### 8.4 Break-Even Win Rate
|
||||
|
||||
For a given entry price P, the minimum win rate needed to break even:
|
||||
|
||||
```
|
||||
WR_breakeven = P / 1.00 = P
|
||||
```
|
||||
|
||||
| Entry Price | Break-Even WR |
|
||||
|-------------|---------------|
|
||||
| $0.75 | 75% |
|
||||
| $0.80 | 80% |
|
||||
| $0.85 | 85% |
|
||||
| $0.88 | 88% |
|
||||
|
||||
This is why the win rate CSV is critical -- the bot only enters when historical win rate exceeds the break-even threshold for the given price and time bin.
|
||||
|
||||
---
|
||||
|
||||
## 9. Drawdown Tracking
|
||||
|
||||
### 9.1 Per-Trade Drawdown
|
||||
|
||||
After entry, the bot tracks the minimum price seen every 250ms:
|
||||
|
||||
```
|
||||
P_min = min(P_min, P_current) # updated every 250ms
|
||||
```
|
||||
|
||||
Initialized at entry: `P_min = P_entry`
|
||||
|
||||
At position close, drawdown is calculated:
|
||||
|
||||
**Absolute drawdown**:
|
||||
|
||||
```
|
||||
DD_abs = max(0, P_entry - P_min)
|
||||
```
|
||||
|
||||
**Percentage drawdown**:
|
||||
|
||||
```
|
||||
DD_pct = (DD_abs / P_entry) * 100%
|
||||
```
|
||||
|
||||
**Dollar drawdown** (total exposure):
|
||||
|
||||
```
|
||||
DD_usd = DD_abs * C
|
||||
```
|
||||
|
||||
### 9.2 Logging
|
||||
|
||||
At market end, logged to `signals.log`:
|
||||
|
||||
```
|
||||
Max Drawdown: -0.0500 (-6.17%)
|
||||
Max DD ($): -$3.20 (min price: 0.7600)
|
||||
```
|
||||
|
||||
### 9.3 Live Dashboard
|
||||
|
||||
While position is open, the dashboard shows real-time drawdown:
|
||||
|
||||
```
|
||||
LONG UP @ 0.810 (64 contracts)
|
||||
Unrealized: +$3.84 (price: 0.870)
|
||||
Max DD: -$1.92 (-3.7%) (low: 0.780)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Chainlink BTC/USD Oracle Integration
|
||||
|
||||
### 10.1 Purpose
|
||||
|
||||
The Chainlink price feed provides the **actual BTC/USD price** used by Polymarket to resolve markets. The bot tracks this independently for:
|
||||
|
||||
1. **Dashboard display**: Shows real-time BTC price and deviation from market start
|
||||
2. **Signal logging**: Records BTC deviation at the moment of each trade entry
|
||||
3. **Analysis**: Understanding how BTC price movement correlates with market outcomes
|
||||
|
||||
### 10.2 Connection
|
||||
|
||||
```
|
||||
URL: wss://ws-live-data.polymarket.com
|
||||
Topic: crypto_prices_chainlink
|
||||
Symbol: btc/usd (filtered in code)
|
||||
```
|
||||
|
||||
### 10.3 Anchor Price and Deviation
|
||||
|
||||
At each 15-minute boundary, the **anchor price** is captured as the first tick of the new window:
|
||||
|
||||
```
|
||||
Window = floor(T_chainlink / 900) * 900
|
||||
```
|
||||
|
||||
When Window changes (new 15-minute period), the first tick's price becomes the anchor:
|
||||
|
||||
```
|
||||
P_anchor = price of first tick where Window(T_tick) != Window_previous
|
||||
```
|
||||
|
||||
**BTC Deviation**:
|
||||
|
||||
```
|
||||
Delta_abs = P_current - P_anchor
|
||||
|
||||
Delta_pct = (Delta_abs / P_anchor) * 100%
|
||||
```
|
||||
|
||||
### 10.4 Calibration Logging
|
||||
|
||||
For calibration purposes, every tick within [-15s, +5s] of a 15-minute boundary is logged:
|
||||
|
||||
```
|
||||
BTC_TICK 16:59:59.000 (local 17:00:00.653) $69,481.26 [-1.000s before 17:00:00]
|
||||
BTC_TICK 17:00:00.000 (local 17:00:01.578) $69,483.32 [+0.000s after 17:00:00]
|
||||
```
|
||||
|
||||
Fields:
|
||||
- **Chainlink timestamp**: From the oracle data (millisecond precision)
|
||||
- **Local timestamp**: Server clock time when message was processed
|
||||
- **Price**: BTC/USD price from Chainlink
|
||||
- **Offset**: Seconds before/after the 15-minute boundary
|
||||
|
||||
### 10.5 Watchdog
|
||||
|
||||
If no Chainlink messages are received for 30 seconds, the watchdog forces a WebSocket reconnection:
|
||||
|
||||
```python
|
||||
if time.time() - last_msg_time > DATA_TIMEOUT: # 30 seconds
|
||||
ws.close() # Triggers reconnection in connect() loop
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Auto-Redemption System
|
||||
|
||||
### 11.1 Purpose
|
||||
|
||||
After a market resolves, winning positions must be **redeemed** on-chain to collect the $1.00 payout per contract.
|
||||
|
||||
### 11.2 Flow
|
||||
|
||||
```
|
||||
Every 180 seconds:
|
||||
1. Fetch all positions from Polymarket Data API
|
||||
2. Categorize: active, pending, redeemable
|
||||
3. For each redeemable position:
|
||||
a. Check oracle resolution (payoutDenominator)
|
||||
b. Submit redemption transaction on Polygon
|
||||
c. Wait for confirmation
|
||||
```
|
||||
|
||||
### 11.3 Implementation Details
|
||||
|
||||
- Runs as a background asyncio task
|
||||
- File lock prevents concurrent redemptions
|
||||
- Supports both EOA (direct) and Gnosis Safe (proxy) wallets
|
||||
- Blockchain transactions require POL (MATIC) for gas fees
|
||||
- Runs in a dedicated thread pool to avoid blocking the main event loop
|
||||
|
||||
---
|
||||
|
||||
## 12. Configuration Reference
|
||||
|
||||
### Strategy Parameters
|
||||
|
||||
| Parameter | config.json | Dataclass Default | Description |
|
||||
|---------------------|-------------|-------------------|-----------------------------------|
|
||||
| min_price | 0.75 | 0.65 | Min favorite token price to enter |
|
||||
| max_price | 0.88 | 0.91 | Max favorite token price to enter |
|
||||
| min_elapsed_sec | 500 | 480 | Min seconds since market start |
|
||||
| min_deviation_pct | 0 | 5.0 | Min VWAP deviation (%) |
|
||||
| max_deviation_pct | 100 | 100.0 | Max VWAP deviation (%) |
|
||||
| no_entry_before_end | 335 | 90 | Min seconds remaining for entry |
|
||||
| momentum_window_sec | 60 | 120 | Momentum lookback window |
|
||||
| vwap_window_sec | 30 | 30 | VWAP calculation window |
|
||||
|
||||
> **Note**: "config.json" = active value. "Dataclass Default" = fallback if field is missing from JSON.
|
||||
|
||||
### Timing Constraints Visualization
|
||||
|
||||
```
|
||||
Market: 900 seconds (15 minutes)
|
||||
|
||||
0s ----------- 500s ---- 565s ----------- 900s
|
||||
| | | |
|
||||
| NO ENTRY | ENTRY | NO ENTRY |
|
||||
| (too early) | WINDOW | (too late) |
|
||||
| | | |
|
||||
<-min_elapsed-> | | |
|
||||
| <---335s cutoff-->|
|
||||
| | |
|
||||
<-- 65s -->
|
||||
allowed
|
||||
```
|
||||
|
||||
Entry is allowed when:
|
||||
- `T_elapsed >= 500` seconds AND
|
||||
- `T_remaining > 335` seconds
|
||||
|
||||
This creates a **65-second entry window** (from 500s to 565s elapsed).
|
||||
|
||||
---
|
||||
|
||||
## 13. Fault Tolerance and Recovery
|
||||
|
||||
### 13.1 Order Timeout Recovery
|
||||
|
||||
When a FAK order times out (no fill confirmation within fill_timeout_ms):
|
||||
|
||||
```
|
||||
1. Check User WebSocket for recent fills on the token
|
||||
2. Wait up to ws_recovery_timeout_sec (10s)
|
||||
3. If fills found:
|
||||
-> RECOVERY: Record position from WS fill data
|
||||
-> Place hedge as normal
|
||||
4. If no fills found:
|
||||
-> Block entry for rest of market (prevent duplicates)
|
||||
-> Log: "Network timeout - no fill detected"
|
||||
```
|
||||
|
||||
### 13.2 Entry Blocking
|
||||
|
||||
After any failed entry attempt, `stats.block_entry()` prevents further attempts on the same market. This avoids:
|
||||
- Duplicate orders from timeout+retry
|
||||
- Repeated failures hitting rate limits
|
||||
|
||||
Reset on new market: `entry_blocked = False`
|
||||
|
||||
### 13.3 WebSocket Reconnection
|
||||
|
||||
**Market Data WebSocket**: On ConnectionClosed, reconnects after 2 seconds. On any other exception, reconnects after 5 seconds.
|
||||
|
||||
**Chainlink RTDS WebSocket**: Same reconnection logic plus a 30-second **watchdog** that detects silent disconnections (TCP alive but no data flowing).
|
||||
|
||||
### 13.4 Config Validation
|
||||
|
||||
At startup, `validate_config()` checks:
|
||||
- Private key exists and starts with "0x"
|
||||
- API credentials are set
|
||||
- `min_price < max_price`
|
||||
- `max_entry_price <= max_price`
|
||||
- `max_deviation_pct > min_deviation_pct`
|
||||
|
||||
Bot refuses to start if any validation fails.
|
||||
|
||||
---
|
||||
|
||||
## 14. File and Log Architecture
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
btc_15m_live/
|
||||
+-- main.py # Main bot (2000+ lines, all core logic)
|
||||
+-- config.json # Runtime configuration
|
||||
+-- .env # Secrets (API keys, private key)
|
||||
+-- chart_pnl.py # PnL chart generator
|
||||
+-- PROJECT_LOGIC.md # This document
|
||||
+-- data/
|
||||
| +-- win_rate.csv # Historical win rate matrix (10x15)
|
||||
+-- logs/
|
||||
| +-- bot.log # Main application log
|
||||
| +-- signals.log # Trade signal snapshots
|
||||
| +-- orders.log # Order execution details
|
||||
| +-- trading_log.json # Trade history (JSON persistence)
|
||||
| +-- api_activity.json # API call log
|
||||
| +-- pnl_chart.png # Generated PnL chart
|
||||
| +-- equity_chart.png # Equity curve chart
|
||||
+-- src/
|
||||
+-- config_loader.py # Configuration loading & validation
|
||||
+-- order_executor.py # FAK order execution with retry
|
||||
+-- hedge_manager.py # GTD hedge order management
|
||||
+-- market_finder.py # Gamma API market discovery
|
||||
+-- position_tracker.py # Position & PnL tracking
|
||||
+-- auto_redeemer.py # On-chain position redemption
|
||||
+-- telegram_notifier.py # Telegram alerts & charts
|
||||
+-- user_websocket.py # User channel WebSocket
|
||||
+-- websocket_client.py # Market data WebSocket
|
||||
+-- signal_generator.py # (Legacy, unused)
|
||||
+-- realtime_dashboard.py # (Legacy, unused)
|
||||
```
|
||||
|
||||
### Log Contents
|
||||
|
||||
| Log File | Contents |
|
||||
|--------------------|---------------------------------------------------------------------|
|
||||
| bot.log | All events: connections, market changes, errors, BTC ticks, anchors |
|
||||
| signals.log | Full indicator snapshot at each trade + market end with PnL and DD |
|
||||
| orders.log | Detailed order execution: prices, retries, fills, rejections |
|
||||
| trading_log.json | Persistent trade array with entry/exit, PnL, drawdown, win/loss |
|
||||
|
||||
### trading_log.json Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"trades": [
|
||||
{
|
||||
"market_slug": "btc-updown-15m-1770831900",
|
||||
"token_name": "UP",
|
||||
"entry_price": 0.81,
|
||||
"exit_price": 0.03,
|
||||
"contracts": 64,
|
||||
"pnl": -51.84,
|
||||
"won": false,
|
||||
"timestamp": 1770832790.165,
|
||||
"max_drawdown_abs": 0.05,
|
||||
"max_drawdown_pct": 6.17
|
||||
}
|
||||
],
|
||||
"markets_seen": 27
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,271 @@
|
||||
# BTC Binary — VWAP & Momentum Bot
|
||||
|
||||
Automated trading bot for **Polymarket BTC Up/Down** binary markets (**5- or 15-minute** windows; set `market.interval_minutes` in `config.json`). It streams the CLOB via WebSocket, computes **VWAP**, **deviation**, **momentum**, and **z-score** on the **favorite** side, and fires **Fill-And-Kill (FAK)** entries when **all** conditions align. Optional **Good-Till-Date (GTD)** limits on the opposite token act as a **partial hedge** (advanced; off by default).
|
||||
|
||||
**Suite:** This bot is part of the [PolyBullLabs Polymarket suite](../README.md). **Repository:** [github.com/PolyBullLabs/polymakret-5min-15min-1hour-arbitrage-bot](https://github.com/PolyBullLabs/polymakret-5min-15min-1hour-arbitrage-bot.git) · **Telegram:** [@terauss](https://t.me/terauss)
|
||||
|
||||
---
|
||||
|
||||
## Why this strategy can work (and what breaks it)
|
||||
|
||||
**Idea:** Near the end of a short binary window, the market often **prices one side as favorite** (higher last price). The bot does **not** buy blindly: it waits for **(a)** favorite price in a **tunable band**, **(b)** a **late** entry slice, **(c)** price **stretched above short-horizon VWAP** (`min_deviation_pct`), and **(d)** **positive momentum**—roughly, **crowd consensus plus recent upward flow** on that token.
|
||||
|
||||
**Profit source (when it exists):** If the **true** chance of the favorite winning **exceeds** the **entry price** (e.g. pay $0.80 when win probability is sustainably >80%), **expected value** can be positive. The indicators are a **filter** to reduce entries where the book is **choppy or mean-reverting** against the favorite.
|
||||
|
||||
**Risk:** Binary markets can **gap** or **flip** into the close. **Break-even win rate ≈ entry price** before fees. **Slippage**, **partial fills**, and **oracle resolution** details can erode edge. **Start small**; use **`simulation`** in config when available.
|
||||
|
||||
**Good fit:** You want **BTC only**, **transparent math** (see [PROJECT_LOGIC.md](PROJECT_LOGIC.md)), and a **Rich** terminal dashboard. **Poor fit:** You need multi-asset from one process—use **Meridian** (`up-down-spread-bot`) in the same suite.
|
||||
|
||||
---
|
||||
|
||||
## What This Bot Does
|
||||
|
||||
On each interval (e.g. every 5 or 15 minutes, depending on config), Polymarket opens a market asking whether BTC will finish up or down for that window. Two tokens are available:
|
||||
|
||||
- **UP token** pays $1.00 if BTC rises, $0.00 if it falls
|
||||
- **DOWN token** pays $1.00 if BTC falls, $0.00 if it rises
|
||||
|
||||
The bot identifies the "favorite" (the token with higher probability), waits for specific technical conditions to align, then buys it. If the prediction is correct, the token resolves to $1.00 for a profit. If wrong, it resolves to $0.00 for a loss.
|
||||
|
||||
### Key Features
|
||||
|
||||
- Real-time terminal dashboard with Rich library (order book, indicators, signals, position, P&L)
|
||||
- VWAP-based signal generation with deviation and momentum filters
|
||||
- Historical win rate filtering by price range and time bin
|
||||
- FAK order execution with retry logic and WebSocket fill confirmation
|
||||
- Optional hedging via GTD orders on the opposite token at $0.02
|
||||
- Timeout recovery: detects fills via User WebSocket even after network timeouts
|
||||
- Chainlink BTC/USD oracle tracking: real-time BTC price and deviation from market start
|
||||
- Auto-redemption of winning positions on-chain
|
||||
- Telegram notifications with trade alerts and equity charts
|
||||
- Per-trade drawdown tracking with logging
|
||||
- Persistent trade history in JSON format (survives restarts)
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
btc-binary-VWAP-Momentum-bot/
|
||||
|-- main.py # Main bot: dashboard, signals, execution, all core logic
|
||||
|-- config.json # Trading parameters (strategy, entry, hedge, etc.)
|
||||
|-- .env.example # Environment variables template (copy to .env)
|
||||
|-- requirements.txt # Python dependencies
|
||||
|-- chart_pnl.py # P&L chart generator (run separately)
|
||||
|-- CONFIG.md # Full config.json reference
|
||||
|-- PROJECT_LOGIC.md # Detailed technical documentation with formulas
|
||||
|-- docs/
|
||||
| +-- README.md # Step-by-step beginner guide (Windows + Linux)
|
||||
|-- data/
|
||||
| +-- win_rate.csv # Historical win rate matrix (price ranges x per-minute bins; 5m uses first 5 bins)
|
||||
+-- src/
|
||||
|-- __init__.py
|
||||
|-- config_loader.py # Loads config.json + .env, validates settings
|
||||
|-- order_executor.py # FAK order placement with retry logic
|
||||
|-- hedge_manager.py # GTD hedge order management
|
||||
|-- market_finder.py # Discovers active markets via Gamma API
|
||||
|-- position_tracker.py # Position and P&L tracking
|
||||
|-- auto_redeemer.py # On-chain redemption of resolved positions
|
||||
|-- telegram_notifier.py# Telegram alerts and chart sending
|
||||
|-- user_websocket.py # User channel WebSocket (order/fill tracking)
|
||||
+-- websocket_client.py # Market data WebSocket (prices, trades, book)
|
||||
```
|
||||
|
||||
## Installation (From Scratch on a Clean Machine)
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Linux server (Ubuntu 22.04+ recommended) or macOS
|
||||
- Python 3.11+
|
||||
- Polymarket account with funded USDC balance (on Polygon), POL for gas fees, and API credentials
|
||||
- Private key of your trading wallet
|
||||
|
||||
### Step 1: System Setup
|
||||
|
||||
```bash
|
||||
sudo apt update && sudo apt upgrade -y
|
||||
sudo apt install -y python3 python3-pip python3-venv git
|
||||
python3 --version
|
||||
```
|
||||
|
||||
### Step 2: Clone the Repository
|
||||
|
||||
```bash
|
||||
cd ~
|
||||
git clone https://github.com/PolyBullLabs/polymakret-5min-15min-1hour-arbitrage-bot.git
|
||||
cd polymakret-5min-15min-1hour-arbitrage-bot/btc-binary-VWAP-Momentum-bot
|
||||
```
|
||||
|
||||
### Step 3: Create Virtual Environment
|
||||
|
||||
```bash
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
```
|
||||
|
||||
### Step 4: Install Dependencies
|
||||
|
||||
```bash
|
||||
pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### Step 5: Configure Environment Variables
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
Fill in your credentials:
|
||||
|
||||
| Variable | Required | Description |
|
||||
|---|---|---|
|
||||
| PRIVATE_KEY | Yes | Polygon wallet private key (0x...) |
|
||||
| FUNDER_ADDRESS | If proxy | Gnosis Safe address (if using proxy wallet) |
|
||||
| SIGNATURE_TYPE | If proxy | 0=EOA, 1=Poly Proxy, 2=Gnosis Safe |
|
||||
| POLY_API_KEY | Yes | Polymarket CLOB API key |
|
||||
| POLY_API_SECRET | Yes | Polymarket CLOB API secret |
|
||||
| POLY_API_PASSPHRASE | Yes | Polymarket CLOB API passphrase |
|
||||
| RPC_URL | Recommended | Alchemy/Infura Polygon RPC (default: public RPC) |
|
||||
| TELEGRAM_BOT_TOKEN | Optional | Telegram bot token from @BotFather |
|
||||
| TELEGRAM_CHAT_ID | Optional | Your Telegram user/chat ID |
|
||||
|
||||
**How to get Polymarket API credentials:**
|
||||
1. Go to https://polymarket.com and connect your wallet
|
||||
2. Navigate to your account settings
|
||||
3. Generate API credentials (key, secret, passphrase)
|
||||
4. These are used for L2 authentication on the CLOB
|
||||
|
||||
### Step 6: Configure Trading Parameters
|
||||
|
||||
```bash
|
||||
nano config.json
|
||||
```
|
||||
|
||||
See the Configuration section below for parameter descriptions.
|
||||
|
||||
### Step 7: Create Logs Directory
|
||||
|
||||
```bash
|
||||
mkdir -p logs
|
||||
```
|
||||
|
||||
### Step 8: Run the Bot
|
||||
|
||||
```bash
|
||||
source venv/bin/activate
|
||||
python3 main.py
|
||||
```
|
||||
|
||||
### Step 9: Run in Background (Production)
|
||||
|
||||
```bash
|
||||
sudo apt install -y tmux
|
||||
tmux new -s bot
|
||||
|
||||
# Inside tmux:
|
||||
source venv/bin/activate
|
||||
python3 main.py
|
||||
|
||||
# Detach: Ctrl+B then D
|
||||
# Reattach: tmux attach -t bot
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The bot is **highly configurable** -- every aspect of the strategy, risk management, execution, hedging, and notifications can be fine-tuned through `config.json` without touching any code. You can adjust the entry window, price filters, indicator sensitivity, bet sizing, and more to match your risk tolerance and trading style.
|
||||
|
||||
**For a complete parameter-by-parameter guide with explanations, examples, and ready-made presets (Conservative / Moderate / Aggressive), see [CONFIG.md](CONFIG.md).**
|
||||
|
||||
Quick overview of the most important settings:
|
||||
|
||||
| Parameter | Default | What it controls |
|
||||
|---|---|---|
|
||||
| `strategy.min_price` | 0.75 | Minimum token price to enter (lower = riskier, more profit) |
|
||||
| `strategy.max_price` | 0.88 | Maximum token price to enter (higher = safer, less profit) |
|
||||
| `strategy.min_elapsed_sec` | 530 | Wait this many seconds before entering |
|
||||
| `strategy.min_deviation_pct` | 3 | Minimum VWAP deviation to trigger signal |
|
||||
| `strategy.no_entry_before_end_sec` | 335 | Stop entering with this many seconds left |
|
||||
| `entry.bet_amount_usd` | 5 | USD per trade (start small!) |
|
||||
| `entry.max_entry_price` | 0.88 | Hard price ceiling for safety |
|
||||
| `hedge.enabled` | false | Automatic hedging on opposite token |
|
||||
| `telegram.enabled` | false | Trade notifications via Telegram |
|
||||
| `web_dashboard.enabled` | false | Local web UI (same live data as the terminal; JSON at `/api/state`) |
|
||||
|
||||
When `web_dashboard.enabled` is true, open **http://127.0.0.1:8765/** (or your `host`/`port`) in a browser on the same machine. Defaults bind to localhost only; do not expose the port publicly without authentication.
|
||||
|
||||
## How the Strategy Works
|
||||
|
||||
### Signal Generation
|
||||
|
||||
The bot evaluates 5 conditions every 250ms. ALL must be true to trigger a BUY:
|
||||
|
||||
1. **Price in range**: min_price <= favorite_price <= max_price
|
||||
2. **Time elapsed**: elapsed_seconds >= min_elapsed_sec
|
||||
3. **VWAP deviation**: min_deviation_pct < deviation < max_deviation_pct
|
||||
4. **Positive momentum**: momentum > 0%
|
||||
5. **Time remaining**: seconds_left > no_entry_before_end_sec
|
||||
|
||||
### Indicators
|
||||
|
||||
- **VWAP** (Volume-Weighted Average Price): SUM(price * volume) / SUM(volume) over the last N seconds
|
||||
- **Deviation**: (last_price - VWAP) / VWAP * 100% -- how far price moved from its average
|
||||
- **Momentum**: (price_now - price_Ns_ago) / price_Ns_ago * 100% -- direction of price movement
|
||||
- **Z-Score**: (price - mean) / stdev over the last 5 seconds -- statistical outlier detection
|
||||
|
||||
### Execution Flow
|
||||
|
||||
```
|
||||
Signal detected
|
||||
-> FAK order placed
|
||||
-> Fill confirmed via WebSocket
|
||||
-> Position recorded
|
||||
-> Hedge placed (if enabled)
|
||||
-> Drawdown tracked every 250ms
|
||||
-> Market ends (10s before expiry)
|
||||
-> Position resolved, P&L recorded
|
||||
-> Winning positions auto-redeemed on-chain
|
||||
```
|
||||
|
||||
### Risk
|
||||
|
||||
Higher entry prices mean higher risk. The break-even win rate equals the entry price:
|
||||
|
||||
- Entry at $0.75 needs 75% win rate to break even
|
||||
- Entry at $0.85 needs 85% win rate to break even
|
||||
- Entry at $0.88 needs 88% win rate to break even
|
||||
|
||||
Start with small bet_amount_usd ($1-5) until you understand the behavior.
|
||||
|
||||
## Logs
|
||||
|
||||
The bot creates a logs/ directory with:
|
||||
|
||||
| File | Description |
|
||||
|---|---|
|
||||
| bot.log | Main application log (connections, errors, BTC price ticks) |
|
||||
| signals.log | Full indicator snapshot at each trade entry and market end |
|
||||
| orders.log | Detailed order execution log (prices, retries, fills) |
|
||||
| hedges.log | Hedge order placement and fill tracking |
|
||||
| trading_log.json | Persistent trade history (survives restarts) |
|
||||
|
||||
## Generating Charts
|
||||
|
||||
After accumulating trades, generate a P&L chart:
|
||||
|
||||
```bash
|
||||
source venv/bin/activate
|
||||
python3 chart_pnl.py
|
||||
# Output: logs/pnl_chart.png
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
For a deep technical dive including all formulas, architecture diagrams, and the complete signal generation logic, see [PROJECT_LOGIC.md](PROJECT_LOGIC.md).
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This software is provided **for educational and research purposes only**. Trading on prediction markets involves **substantial risk**; you may **lose your entire stake**. **No performance is guaranteed.** The authors and contributors are **not** responsible for financial losses, bugs, or exchange rule changes. Use **simulation** where offered, keep **API keys and private keys** secret, and **never** trade with capital you cannot afford to lose. For **extended quant strategies** (Kelly, Monte Carlo, advanced TA, sizing systems), see the [repository README](../README.md) and contact [@terauss](https://t.me/terauss).
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,13 @@
|
||||
[
|
||||
{
|
||||
"id": "0xbb57ccf585",
|
||||
"slug": "will-bitcoin-hit-1m-before-gta-vi-872-424",
|
||||
"title": "Will bitcoin hit $1m before GTA VI?",
|
||||
"active": true,
|
||||
"closed": false,
|
||||
"neg_risk": null,
|
||||
"start": "2025-05-02T15:48:17.361Z",
|
||||
"end": "2026-07-31T12:00:00Z",
|
||||
"outcomes": []
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
import os
|
||||
p = r"c:\Users\Administrator\Desktop\polymarket-5min-15min-1hour-arbitrage-trading-bot\btc-binary-VWAP-Momentum-bot\logs\bot.log"
|
||||
if os.path.exists(p):
|
||||
with open(p, "r", encoding="utf-8", errors="replace") as f:
|
||||
lines = f.readlines()
|
||||
for line in lines[-40:]:
|
||||
print(line.rstrip())
|
||||
else:
|
||||
print("NOT FOUND:", p)
|
||||
@@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate a modern, beautiful P&L chart from trading_log.json
|
||||
"""
|
||||
import json
|
||||
import numpy as np
|
||||
import matplotlib
|
||||
matplotlib.use('Agg')
|
||||
import matplotlib.pyplot as plt
|
||||
import matplotlib.ticker as mticker
|
||||
from matplotlib.patches import FancyBboxPatch
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# ─── Load data ──────────────────────────────────────────────
|
||||
data = json.load(open(Path(__file__).parent / "logs" / "trading_log.json"))
|
||||
trades = data["trades"]
|
||||
markets_seen = data.get("markets_seen", 0)
|
||||
|
||||
if not trades:
|
||||
print("No trades found.")
|
||||
exit()
|
||||
|
||||
# ─── Prepare arrays ─────────────────────────────────────────
|
||||
timestamps = [datetime.fromtimestamp(t["timestamp"], tz=timezone.utc) for t in trades]
|
||||
pnls = [t["pnl"] for t in trades]
|
||||
cumulative = np.cumsum(pnls)
|
||||
won = [t["won"] for t in trades]
|
||||
tokens = [t["token_name"] for t in trades]
|
||||
entries = [t["entry_price"] for t in trades]
|
||||
exits = [t["exit_price"] for t in trades]
|
||||
contracts = [t["contracts"] for t in trades]
|
||||
labels = [f"#{i+1}" for i in range(len(trades))]
|
||||
|
||||
wins = sum(won)
|
||||
losses = len(won) - wins
|
||||
win_rate = wins / len(won) * 100 if won else 0
|
||||
total_pnl = sum(pnls)
|
||||
total_won_pnl = sum(p for p, w in zip(pnls, won) if w)
|
||||
total_lost_pnl = sum(p for p, w in zip(pnls, won) if not w)
|
||||
avg_win = total_won_pnl / wins if wins else 0
|
||||
avg_loss = total_lost_pnl / losses if losses else 0
|
||||
total_volume = sum(e * c for e, c in zip(entries, contracts))
|
||||
best_trade = max(pnls)
|
||||
worst_trade = min(pnls)
|
||||
|
||||
# ─── Dark theme ──────────────────────────────────────────────
|
||||
BG = '#0d1117'
|
||||
CARD_BG = '#161b22'
|
||||
TEXT = '#e6edf3'
|
||||
TEXT_DIM = '#8b949e'
|
||||
GREEN = '#3fb950'
|
||||
RED = '#f85149'
|
||||
BLUE = '#58a6ff'
|
||||
PURPLE = '#bc8cff'
|
||||
ORANGE = '#d29922'
|
||||
GRID = '#21262d'
|
||||
ACCENT = '#1f6feb'
|
||||
|
||||
plt.rcParams.update({
|
||||
'figure.facecolor': BG,
|
||||
'axes.facecolor': CARD_BG,
|
||||
'axes.edgecolor': GRID,
|
||||
'axes.labelcolor': TEXT,
|
||||
'text.color': TEXT,
|
||||
'xtick.color': TEXT_DIM,
|
||||
'ytick.color': TEXT_DIM,
|
||||
'grid.color': GRID,
|
||||
'grid.alpha': 0.5,
|
||||
'font.family': 'monospace',
|
||||
'font.size': 11,
|
||||
})
|
||||
|
||||
fig = plt.figure(figsize=(16, 10))
|
||||
fig.patch.set_facecolor(BG)
|
||||
|
||||
# ─── Layout: top stats bar, main chart, bottom bars ──────────
|
||||
gs = fig.add_gridspec(3, 1, height_ratios=[0.8, 3, 2], hspace=0.35,
|
||||
left=0.08, right=0.95, top=0.92, bottom=0.06)
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Title
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
pnl_color = GREEN if total_pnl >= 0 else RED
|
||||
pnl_sign = "+" if total_pnl >= 0 else ""
|
||||
fig.text(0.08, 0.96, "BTC 15m Live Trading", fontsize=22, fontweight='bold',
|
||||
color=TEXT, ha='left', va='center')
|
||||
fig.text(0.08, 0.935, f"Session Performance • {len(trades)} trades • {markets_seen} markets observed",
|
||||
fontsize=10, color=TEXT_DIM, ha='left', va='center')
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Stats cards (top row)
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
ax_stats = fig.add_subplot(gs[0])
|
||||
ax_stats.set_xlim(0, 10)
|
||||
ax_stats.set_ylim(0, 1)
|
||||
ax_stats.axis('off')
|
||||
|
||||
cards = [
|
||||
("Total P&L", f"{pnl_sign}${total_pnl:.2f}", pnl_color),
|
||||
("Win Rate", f"{win_rate:.1f}%", GREEN if win_rate >= 50 else RED),
|
||||
("Wins / Losses", f"{wins}W / {losses}L", BLUE),
|
||||
("Avg Win", f"+${avg_win:.2f}", GREEN),
|
||||
("Avg Loss", f"${avg_loss:.2f}", RED),
|
||||
("Best Trade", f"+${best_trade:.2f}", GREEN),
|
||||
("Worst Trade", f"${worst_trade:.2f}", RED),
|
||||
("Volume", f"${total_volume:.0f}", PURPLE),
|
||||
]
|
||||
|
||||
card_w = 10 / len(cards)
|
||||
for i, (label, value, color) in enumerate(cards):
|
||||
cx = i * card_w + card_w / 2
|
||||
# Card background
|
||||
rect = FancyBboxPatch((i * card_w + 0.08, 0.05), card_w - 0.16, 0.9,
|
||||
boxstyle="round,pad=0.05", facecolor=BG,
|
||||
edgecolor=GRID, linewidth=1.2,
|
||||
transform=ax_stats.transData)
|
||||
ax_stats.add_patch(rect)
|
||||
# Value
|
||||
ax_stats.text(cx, 0.6, value, fontsize=13, fontweight='bold',
|
||||
color=color, ha='center', va='center')
|
||||
# Label
|
||||
ax_stats.text(cx, 0.25, label, fontsize=8, color=TEXT_DIM,
|
||||
ha='center', va='center')
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Cumulative P&L line chart (main)
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
ax1 = fig.add_subplot(gs[1])
|
||||
|
||||
x = np.arange(len(trades))
|
||||
cum_with_zero = np.insert(cumulative, 0, 0)
|
||||
x_with_zero = np.arange(-1, len(trades)) + 1
|
||||
|
||||
# Fill area under curve
|
||||
for i in range(len(cum_with_zero) - 1):
|
||||
y0, y1 = cum_with_zero[i], cum_with_zero[i + 1]
|
||||
color = GREEN if y1 >= 0 else RED
|
||||
ax1.fill_between([i, i + 1], [y0, y1], alpha=0.08, color=color, zorder=1)
|
||||
|
||||
# Main line with gradient effect
|
||||
ax1.plot(range(len(cum_with_zero)), cum_with_zero, color=BLUE, linewidth=2.5,
|
||||
zorder=3, solid_capstyle='round')
|
||||
|
||||
# Scatter points: green=win, red=loss
|
||||
for i, (pnl, w) in enumerate(zip(pnls, won)):
|
||||
c = GREEN if w else RED
|
||||
marker = '▲' if w else '▼'
|
||||
size = 100 if w else 120
|
||||
ax1.scatter(i + 1, cumulative[i], color=c, s=size, zorder=5,
|
||||
edgecolors='white', linewidths=0.5, marker='o')
|
||||
# P&L annotation
|
||||
offset = 8 if pnl >= 0 else -14
|
||||
sign = "+" if pnl >= 0 else ""
|
||||
ax1.annotate(f"{sign}${pnl:.2f}", (i + 1, cumulative[i]),
|
||||
textcoords="offset points", xytext=(0, offset),
|
||||
fontsize=8, fontweight='bold', color=c, ha='center', zorder=6)
|
||||
|
||||
# Zero line
|
||||
ax1.axhline(y=0, color=TEXT_DIM, linewidth=0.8, linestyle='--', alpha=0.5, zorder=2)
|
||||
|
||||
# Style
|
||||
ax1.set_xlim(-0.3, len(trades) + 0.3)
|
||||
y_margin = max(abs(cumulative.max()), abs(cumulative.min())) * 0.3
|
||||
ax1.set_ylim(cumulative.min() - y_margin, cumulative.max() + y_margin)
|
||||
ax1.set_ylabel("Cumulative P&L ($)", fontsize=11, fontweight='bold')
|
||||
ax1.set_xticks(range(len(cum_with_zero)))
|
||||
ax1.set_xticklabels(["Start"] + labels)
|
||||
ax1.yaxis.set_major_formatter(mticker.FormatStrFormatter('$%.1f'))
|
||||
ax1.grid(True, alpha=0.3)
|
||||
ax1.set_title("Equity Curve", fontsize=13, fontweight='bold', color=TEXT, pad=10, loc='left')
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Per-trade P&L bars (bottom)
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
ax2 = fig.add_subplot(gs[2])
|
||||
|
||||
bar_colors = [GREEN if w else RED for w in won]
|
||||
bars = ax2.bar(x, pnls, color=bar_colors, width=0.6, edgecolor=[
|
||||
GREEN if w else RED for w in won
|
||||
], linewidth=0.8, alpha=0.85, zorder=3)
|
||||
|
||||
# Add glow effect
|
||||
for i, (bar, w) in enumerate(zip(bars, won)):
|
||||
c = GREEN if w else RED
|
||||
ax2.bar(i, pnls[i], color=c, width=0.7, alpha=0.15, zorder=2)
|
||||
|
||||
# Labels on bars
|
||||
for i, (pnl, w, tok, ct) in enumerate(zip(pnls, won, tokens, contracts)):
|
||||
sign = "+" if pnl >= 0 else ""
|
||||
y_off = pnl + (1.5 if pnl >= 0 else -2.5)
|
||||
ax2.text(i, y_off, f"{sign}${pnl:.2f}", fontsize=9, fontweight='bold',
|
||||
color=bar_colors[i], ha='center', va='bottom' if pnl >= 0 else 'top')
|
||||
# Token label below bar
|
||||
ax2.text(i, -0.5 if pnl >= 0 else 0.5,
|
||||
f"{tok}\n{ct}ct", fontsize=7, color=TEXT_DIM,
|
||||
ha='center', va='top' if pnl >= 0 else 'bottom')
|
||||
|
||||
ax2.axhline(y=0, color=TEXT_DIM, linewidth=0.8, linestyle='-', alpha=0.4, zorder=1)
|
||||
ax2.set_xlim(-0.7, len(trades) - 0.3)
|
||||
ax2.set_xticks(x)
|
||||
ax2.set_xticklabels(labels)
|
||||
ax2.yaxis.set_major_formatter(mticker.FormatStrFormatter('$%.0f'))
|
||||
ax2.grid(True, axis='y', alpha=0.3)
|
||||
ax2.set_ylabel("Trade P&L ($)", fontsize=11, fontweight='bold')
|
||||
ax2.set_title("Individual Trades", fontsize=13, fontweight='bold', color=TEXT, pad=10, loc='left')
|
||||
|
||||
# ─── Watermark ───────────────────────────────────────────────
|
||||
fig.text(0.95, 0.96, datetime.now().strftime("%Y-%m-%d %H:%M UTC"),
|
||||
fontsize=9, color=TEXT_DIM, ha='right', va='center')
|
||||
|
||||
# ─── Save ────────────────────────────────────────────────────
|
||||
out_path = Path(__file__).parent / "logs" / "pnl_chart.png"
|
||||
fig.savefig(out_path, dpi=180, facecolor=BG, bbox_inches='tight')
|
||||
plt.close()
|
||||
print(f"Chart saved: {out_path}")
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Quick diagnostics: proxy tunnel + RTDS / market WS connect test."""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(Path(__file__).parent / ".env")
|
||||
|
||||
from src.proxy_util import PROXY_URL, apply_proxy_env, ws_connect, ws_connect_kwargs
|
||||
|
||||
apply_proxy_env()
|
||||
|
||||
import websockets
|
||||
|
||||
print("=" * 70)
|
||||
print("DIAGNOSTIC: WebSocket proxy tunnel")
|
||||
print("=" * 70)
|
||||
print(f" websockets : {getattr(websockets, '__version__', '?')}")
|
||||
print(f" proxy : {PROXY_URL or '(none)'}")
|
||||
print()
|
||||
|
||||
|
||||
async def _probe(name: str, url: str, subscribe: dict | None = None) -> bool:
|
||||
print(f"[{name}] {url}")
|
||||
try:
|
||||
async with ws_connect(url, **ws_connect_kwargs(), open_timeout=15) as ws:
|
||||
print(f" connected")
|
||||
if subscribe is not None:
|
||||
await ws.send(json.dumps(subscribe))
|
||||
msg = await asyncio.wait_for(ws.recv(), timeout=15)
|
||||
print(f" first msg: {str(msg)[:120]!r}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f" FAIL {type(e).__name__}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
rtds_ok = await _probe(
|
||||
"RTDS",
|
||||
"wss://ws-live-data.polymarket.com",
|
||||
{
|
||||
"action": "subscribe",
|
||||
"subscriptions": [
|
||||
{"topic": "crypto_prices_chainlink", "type": "*", "filters": ""}
|
||||
],
|
||||
},
|
||||
)
|
||||
mkt_ok = await _probe(
|
||||
"MARKET",
|
||||
"wss://ws-subscriptions-clob.polymarket.com/ws/market",
|
||||
)
|
||||
print()
|
||||
if rtds_ok and mkt_ok:
|
||||
print("OK — both WebSockets work through proxy tunnel")
|
||||
return 0
|
||||
print("FAILED — check Clash HTTP port and .env HTTP_PROXY")
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(asyncio.run(main()))
|
||||
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"_docs": "Full parameter guide: see CONFIG.md",
|
||||
|
||||
"market": {
|
||||
"_comment": "5 or 15 — slug btc-updown-5m-<epoch> or btc-updown-15m-<epoch>",
|
||||
"interval_minutes": 5
|
||||
},
|
||||
|
||||
"strategy": {
|
||||
"min_price": 0.75,
|
||||
"max_price": 0.88,
|
||||
"_comment_5m": "Below min_elapsed / no_entry_cutoff are scaled for 5m (300s). For 15m, raise e.g. min_elapsed_sec 530, no_entry_before_end_sec 335.",
|
||||
"min_elapsed_sec": 180,
|
||||
"min_deviation_pct": 3,
|
||||
"max_deviation_pct": 100,
|
||||
"no_entry_before_end_sec": 110,
|
||||
"momentum_window_sec": 60,
|
||||
"vwap_window_sec": 30,
|
||||
"win_rate_csv": "data/win_rate.csv"
|
||||
},
|
||||
|
||||
"entry": {
|
||||
"bet_amount_usd": 1,
|
||||
"price_offset": 0.02,
|
||||
"order_type": "FAK",
|
||||
"max_retries": 3,
|
||||
"retry_delay_ms": 300,
|
||||
"fill_timeout_ms": 1000,
|
||||
"min_contracts": 5,
|
||||
"min_order_usd": 1,
|
||||
"max_entry_price": 0.88,
|
||||
"ws_recovery_timeout_sec": 10
|
||||
},
|
||||
|
||||
"hedge": {
|
||||
"enabled": true,
|
||||
"hedge_price": 0.02,
|
||||
"order_type": "GTD",
|
||||
"max_retries": 3,
|
||||
"retry_delay_ms": 1000
|
||||
},
|
||||
|
||||
"redeem": {
|
||||
"enabled": true,
|
||||
"interval_seconds": 180,
|
||||
"auto_confirm": true
|
||||
},
|
||||
|
||||
"simulation": {
|
||||
"_comment": "Paper trading: live market data, no CLOB orders or redeemer. API keys optional when enabled.",
|
||||
"enabled": true,
|
||||
"separate_trading_log": true,
|
||||
"trading_log_path": "logs/trading_log_sim.json",
|
||||
"history_csv_path": "logs/simulation_trades.csv",
|
||||
"history_jsonl_path": "logs/simulation_history.jsonl",
|
||||
"history_summary_path": "logs/simulation_summary.json"
|
||||
},
|
||||
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"chart_every_n_trades": 5
|
||||
},
|
||||
|
||||
"web_dashboard": {
|
||||
"_comment": "Open http://127.0.0.1:PORT/ (not https). If the page fails, avoid typing only localhost (IPv6). Use 0.0.0.0 to listen on all IPv4 interfaces.",
|
||||
"enabled": true,
|
||||
"host": "127.0.0.1",
|
||||
"port": 8765
|
||||
},
|
||||
|
||||
"logging": {
|
||||
"level": "INFO",
|
||||
"file_rotation_hours": 3
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Diagnostic script: list all active BTC binary markets on Polymarket Gamma API.
|
||||
Usage: python debug_list_markets.py
|
||||
"""
|
||||
import os
|
||||
import asyncio
|
||||
import json
|
||||
import aiohttp
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
def _normalize_proxy(raw: str) -> str:
|
||||
raw = (raw or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
if raw.endswith("/"):
|
||||
raw = raw[:-1]
|
||||
if raw.startswith(("http://", "https://", "socks://", "socks4://", "socks5://")):
|
||||
return raw
|
||||
return "http://" + raw
|
||||
|
||||
_PROXY_URL = _normalize_proxy(
|
||||
os.getenv("HTTPS_PROXY")
|
||||
or os.getenv("HTTP_PROXY")
|
||||
or os.getenv("http_proxy")
|
||||
or os.getenv("https_proxy")
|
||||
or ""
|
||||
)
|
||||
|
||||
GAMMA = "https://gamma-api.polymarket.com"
|
||||
|
||||
|
||||
async def main():
|
||||
print(f"[proxy] Using: {_PROXY_URL or 'NONE'}")
|
||||
|
||||
timeout = aiohttp.ClientTimeout(total=45, connect=15)
|
||||
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
|
||||
# Try 3 common queries to find BTC markets
|
||||
queries = [
|
||||
# Query 1: active markets with "btc" in slug
|
||||
{"slug": "btc", "active": "true", "closed": "false", "limit": 50},
|
||||
# Query 2: active markets with "Bitcoin" in question/title
|
||||
{"title": "Bitcoin", "active": "true", "closed": "false", "limit": 50},
|
||||
# Query 3: closed=false, order by newest, with tags
|
||||
{"closed": "false", "limit": 100, "order": "end_date", "ascending": "false"},
|
||||
]
|
||||
|
||||
seen = set()
|
||||
all_markets = []
|
||||
|
||||
for qi, params in enumerate(queries, 1):
|
||||
print(f"\n--- Query {qi}: {list(params.keys())} ---")
|
||||
try:
|
||||
async with session.get(
|
||||
f"{GAMMA}/markets",
|
||||
params=params,
|
||||
proxy=_PROXY_URL or None,
|
||||
) as resp:
|
||||
print(f"HTTP {resp.status}")
|
||||
if resp.status != 200:
|
||||
print(await resp.text())
|
||||
continue
|
||||
markets = await resp.json()
|
||||
print(f"Got {len(markets)} markets in response")
|
||||
except Exception as e:
|
||||
print(f"Request FAILED: {type(e).__name__}: {e}")
|
||||
continue
|
||||
|
||||
for m in markets:
|
||||
condition_id = m.get("conditionId") or m.get("id")
|
||||
if not condition_id or condition_id in seen:
|
||||
continue
|
||||
seen.add(condition_id)
|
||||
title = (m.get("question") or m.get("title") or "").strip()
|
||||
slug = (m.get("slug") or "").strip()
|
||||
# Only keep markets mentioning BTC/bitcoin
|
||||
text = (title + " " + slug).lower()
|
||||
if "btc" not in text and "bitcoin" not in text:
|
||||
continue
|
||||
end = m.get("endDate") or m.get("end_date_iso") or ""
|
||||
start = m.get("startDate") or m.get("start_date_iso") or ""
|
||||
active = m.get("active")
|
||||
closed = m.get("closed")
|
||||
neg_risk = m.get("negativeRisk")
|
||||
tokens = m.get("tokens") or []
|
||||
outcomes = [
|
||||
(t.get("outcome") or t.get("name") or "?")[:6] for t in tokens
|
||||
]
|
||||
all_markets.append({
|
||||
"id": condition_id[:12],
|
||||
"slug": slug[:80],
|
||||
"title": title[:120],
|
||||
"active": active,
|
||||
"closed": closed,
|
||||
"neg_risk": neg_risk,
|
||||
"start": str(start)[:25],
|
||||
"end": str(end)[:25],
|
||||
"outcomes": outcomes,
|
||||
})
|
||||
|
||||
print("\n" + "=" * 120)
|
||||
print(f"Found {len(all_markets)} BTC-related markets")
|
||||
print("=" * 120)
|
||||
for i, m in enumerate(all_markets, 1):
|
||||
print(f"\n#{i} id={m['id']} active={m['active']} closed={m['closed']} negRisk={m['neg_risk']}")
|
||||
print(f" slug: {m['slug']}")
|
||||
print(f" title: {m['title']}")
|
||||
print(f" outcomes: {m['outcomes']}")
|
||||
print(f" start: {m['start']}")
|
||||
print(f" end: {m['end']}")
|
||||
|
||||
# Save for later
|
||||
with open("_debug_btc_markets.json", "w", encoding="utf-8") as f:
|
||||
json.dump(all_markets, f, ensure_ascii=False, indent=2)
|
||||
print(f"\nSaved full list to _debug_btc_markets.json")
|
||||
|
||||
# Also dump slugs for regex analysis
|
||||
print("\n--- All slugs (for regex tuning) ---")
|
||||
for m in all_markets:
|
||||
print(m["slug"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,554 @@
|
||||
# BTC 15-Minute Polymarket Bot — Full Beginner Guide
|
||||
|
||||
**Suite:** [PolyBullLabs — polymakret-5min-15min-1hour-arbitrage-bot](https://github.com/PolyBullLabs/polymakret-5min-15min-1hour-arbitrage-bot) · **Telegram:** [@terauss](https://t.me/terauss) · **Parent overview:** [`../README.md`](../README.md)
|
||||
|
||||
This document walks you from **zero to running**, explains the **trading strategy** with **numbers**, and lists **every important parameter** with **examples**.
|
||||
Shorter references: [`CONFIG.md`](../CONFIG.md) (parameter list), [`PROJECT_LOGIC.md`](../PROJECT_LOGIC.md) (implementation detail).
|
||||
|
||||
---
|
||||
|
||||
## Table of contents
|
||||
|
||||
1. [What you are trading](#1-what-you-are-trading)
|
||||
2. [Trading strategy (logic + formulas)](#2-trading-strategy-logic--formulas)
|
||||
3. [Prerequisites checklist](#3-prerequisites-checklist)
|
||||
4. [Environment setup — Windows](#4-environment-setup--windows)
|
||||
5. [Environment setup — Linux / macOS](#5-environment-setup--linux--macos)
|
||||
6. [Get the project and install dependencies](#6-get-the-project-and-install-dependencies)
|
||||
7. [Configure `.env` (secrets)](#7-configure-env-secrets)
|
||||
8. [Configure `config.json` (strategy and execution)](#8-configure-configjson-strategy-and-execution)
|
||||
9. [Run the bot](#9-run-the-bot)
|
||||
10. [Optional: Telegram](#10-optional-telegram)
|
||||
11. [Optional: P&L chart](#11-optional-pnl-chart)
|
||||
12. [Logs and files](#12-logs-and-files)
|
||||
13. [Troubleshooting](#13-troubleshooting)
|
||||
14. [Risk summary](#14-risk-summary)
|
||||
|
||||
---
|
||||
|
||||
## 1. What you are trading
|
||||
|
||||
### 1.1 The market
|
||||
|
||||
Polymarket lists **15-minute** BTC markets (slug pattern like `btc-updown-15m-<timestamp>`). Each market has two outcome tokens:
|
||||
|
||||
| Token | Pays if |
|
||||
|--------|---------|
|
||||
| **UP** | BTC finishes the window **above** the reference (market rules on Polymarket define the exact oracle) |
|
||||
| **DOWN** | BTC finishes **below** |
|
||||
|
||||
In practice the bot reads **live token prices** from Polymarket (not a manual prediction). It buys the **favorite** — whichever side has the **higher** last traded price.
|
||||
|
||||
### 1.2 Payout math (simplified)
|
||||
|
||||
If you buy **N** contracts at price **P** (in dollars per contract, 0–1):
|
||||
|
||||
- **Cost** ≈ **N × P**
|
||||
- If your side **wins**, each contract is worth **$1** → payout **N × $1**
|
||||
- **Profit before fees** ≈ **N × (1 − P)** on a win; on a loss you lose the cost.
|
||||
|
||||
**Example (numbers only):**
|
||||
|
||||
- Buy **6** UP @ **$0.82** → cost **6 × 0.82 = $4.92**
|
||||
- If UP wins → value **6 × $1 = $6.00** → gross profit **$6.00 − $4.92 = $1.08**
|
||||
|
||||
The bot does **not** guarantee profit; it automates entries when **its rules** are satisfied.
|
||||
|
||||
---
|
||||
|
||||
## 2. Trading strategy (logic + formulas)
|
||||
|
||||
### 2.1 Favorite
|
||||
|
||||
The bot compares **UP** and **DOWN** `last_price` (from the market WebSocket). The **favorite** is the side with the **higher** price. All deviation and momentum calculations below use the **favorite’s** trade history and price.
|
||||
|
||||
### 2.2 VWAP (volume-weighted average price)
|
||||
|
||||
Over the last **`vwap_window_sec`** seconds (e.g. **30**), take all trades on that token, then:
|
||||
|
||||
\[
|
||||
\text{VWAP} = \frac{\sum (\text{price} \times \text{size})}{\sum \text{size}}
|
||||
\]
|
||||
|
||||
**Example**
|
||||
|
||||
| Time | Price | Size |
|
||||
|------|-------|------|
|
||||
| T1 | 0.78 | 10 |
|
||||
| T2 | 0.79 | 5 |
|
||||
|
||||
\[
|
||||
\text{VWAP} = \frac{0.78 \times 10 + 0.79 \times 5}{10 + 5} = \frac{11.75}{15} \approx 0.7833
|
||||
\]
|
||||
|
||||
### 2.3 Deviation (%)
|
||||
|
||||
Compare **last** traded price to VWAP:
|
||||
|
||||
\[
|
||||
\text{Deviation (\%)} = \frac{\text{last\_price} - \text{VWAP}}{\text{VWAP}} \times 100
|
||||
\]
|
||||
|
||||
**Example**
|
||||
|
||||
- Last price **0.82**, VWAP **0.78**
|
||||
- Deviation = \((0.82 - 0.78) / 0.78 × 100 ≈ 5.13\%\)
|
||||
|
||||
The bot requires deviation **strictly greater than** `min_deviation_pct` and **strictly less than** `max_deviation_pct` (see [§8.1](#81-strategy-block)).
|
||||
|
||||
### 2.4 Momentum (%)
|
||||
|
||||
Momentum uses a lookback of **`momentum_window_sec`** (e.g. **60**). The code takes trades whose timestamps fall in a **small band** around “now − 60s”, averages their prices, then compares **current** last price to that average:
|
||||
|
||||
\[
|
||||
\text{Momentum (\%)} = \frac{\text{last\_price} - \text{avg\_price\_ago}}{\text{avg\_price\_ago}} \times 100
|
||||
\]
|
||||
|
||||
If there are no trades in that window, momentum is **missing** (`None`) and the signal **cannot** fire.
|
||||
|
||||
**Important:** In code, momentum must be **> 5%** (not configurable in `config.json` today). So `momentum_window_sec` changes *how* momentum is measured, not the **5%** threshold.
|
||||
|
||||
**Example**
|
||||
|
||||
- Average price ~60s ago: **0.77**
|
||||
- Current last price: **0.82**
|
||||
- Momentum = \((0.82 - 0.77) / 0.77 × 100 ≈ 6.5\%\) → **passes** the > 5% rule
|
||||
|
||||
### 2.5 Time window for entries (15 minutes = 900 seconds)
|
||||
|
||||
Each market lasts **900 seconds** from start to end.
|
||||
|
||||
- `min_elapsed_sec` — do **not** enter until at least this many seconds **after** the market started.
|
||||
Elapsed = **900 − time_left** (seconds).
|
||||
|
||||
- `no_entry_before_end_sec` — do **not** enter if **time_left** ≤ this value (too close to expiry).
|
||||
|
||||
**Worked example** (matches `CONFIG.md`):
|
||||
|
||||
- `min_elapsed_sec = 530` → need elapsed **≥ 530**
|
||||
- `no_entry_before_end_sec = 335` → need time_left **> 335** → elapsed **< 565**
|
||||
|
||||
So entries are only possible when **530 ≤ elapsed < 565** → about **35 seconds** per market (if all other filters pass).
|
||||
|
||||
| Variable | Value |
|
||||
|----------|--------|
|
||||
| `min_elapsed_sec` | 530 |
|
||||
| `no_entry_before_end_sec` | 335 |
|
||||
| Allowed elapsed | 530 … 564 |
|
||||
| Allowed time_left | 336 … 370 |
|
||||
|
||||
If you widen the window (e.g. lower `min_elapsed_sec` or raise `no_entry_before_end_sec`), you get **more** opportunities and usually **more** risk.
|
||||
|
||||
### 2.6 Win rate table (`data/win_rate.csv`)
|
||||
|
||||
Rows are **price bands** (e.g. `0.75-0.79`), columns are **minutes** (`min_0` … `min_14`). The dashboard uses this to **display** a historical win rate for the current favorite price and time bin. It does **not** by itself block a trade in the main signal logic (the hard filters are price, time, deviation, momentum).
|
||||
|
||||
### 2.7 Entry checklist (all must pass)
|
||||
|
||||
| # | Rule | Typical config |
|
||||
|---|------|----------------|
|
||||
| 1 | Favorite price in `[min_price, max_price]` | e.g. 0.75–0.88 |
|
||||
| 2 | `elapsed_sec ≥ min_elapsed_sec` | e.g. ≥ 530 |
|
||||
| 3 | `min_deviation_pct < deviation < max_deviation_pct` | e.g. 3% < dev < 100% |
|
||||
| 4 | Momentum **not** `None` and **> 5%** | fixed in code |
|
||||
| 5 | `time_left > no_entry_before_end_sec` | e.g. > 335 |
|
||||
|
||||
### 2.8 After a buy
|
||||
|
||||
1. **FAK** order: buy up to your size; unfilled part is cancelled.
|
||||
2. Optional **hedge** (if enabled): **GTD** limit on the **opposite** token at `hedge_price` (often **0.02**).
|
||||
3. Near market end, the bot **closes** the internal position for P&L tracking using last prices.
|
||||
4. **Auto-redeem** (if enabled) periodically redeems winning positions on Polygon.
|
||||
|
||||
---
|
||||
|
||||
## 3. Prerequisites checklist
|
||||
|
||||
| Item | Why |
|
||||
|------|-----|
|
||||
| **Python 3.11+** (3.12 is fine) | Runs the bot |
|
||||
| **pip / venv** | Install packages in isolation |
|
||||
| **Polymarket account + USDC on Polygon** | Trading collateral |
|
||||
| **Small amount of POL (MATIC)** | Gas for on-chain redemptions (if you use auto-redeem) |
|
||||
| **CLOB API credentials** | key, secret, passphrase from Polymarket |
|
||||
| **Wallet private key** (`0x…`) | Signs orders and redeem txs; **never share** |
|
||||
|
||||
---
|
||||
|
||||
## 4. Environment setup — Windows
|
||||
|
||||
### 4.1 Install Python
|
||||
|
||||
1. Download the installer from [https://www.python.org/downloads/](https://www.python.org/downloads/) (Windows 64-bit).
|
||||
2. Run it. **Enable “Add Python to PATH”** (important).
|
||||
3. Close and reopen **PowerShell** or **Command Prompt**.
|
||||
|
||||
### 4.2 Verify
|
||||
|
||||
```powershell
|
||||
python --version
|
||||
pip --version
|
||||
```
|
||||
|
||||
You should see Python 3.11+ and pip. If `python` is not found, try `py` (Windows launcher):
|
||||
|
||||
```powershell
|
||||
py --version
|
||||
```
|
||||
|
||||
### 4.3 Git Bash / `sudo` / `apt`
|
||||
|
||||
This project is **not** installed with `sudo apt` on Windows. Use **Python for Windows** or **WSL** (Ubuntu) if you want Linux-style commands.
|
||||
|
||||
### 4.4 Execution policy (PowerShell venv)
|
||||
|
||||
If activation fails with “running scripts is disabled”:
|
||||
|
||||
```powershell
|
||||
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
||||
```
|
||||
|
||||
Then try `.\venv\Scripts\Activate.ps1` again.
|
||||
|
||||
---
|
||||
|
||||
## 5. Environment setup — Linux / macOS
|
||||
|
||||
### 5.1 Linux (Debian/Ubuntu example)
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y python3 python3-pip python3-venv git
|
||||
python3 --version
|
||||
```
|
||||
|
||||
### 5.2 macOS
|
||||
|
||||
Install Python 3 from [python.org](https://www.python.org/downloads/) or `brew install python`. Then:
|
||||
|
||||
```bash
|
||||
python3 --version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Get the project and install dependencies
|
||||
|
||||
### 6.1 Go to the project folder
|
||||
|
||||
If you already have the folder (`btc-binary-VWAP-Momentum-bot`), **cd** into it:
|
||||
|
||||
```bash
|
||||
cd "path/to/polymakret-5min-15min-1hour-arbitrage-bot/btc-binary-VWAP-Momentum-bot"
|
||||
```
|
||||
|
||||
If you clone from git:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/PolyBullLabs/polymakret-5min-15min-1hour-arbitrage-bot.git
|
||||
cd polymakret-5min-15min-1hour-arbitrage-bot/btc-binary-VWAP-Momentum-bot
|
||||
```
|
||||
|
||||
### 6.2 Create and activate a virtual environment
|
||||
|
||||
**Windows (PowerShell)**
|
||||
|
||||
```powershell
|
||||
python -m venv venv
|
||||
.\venv\Scripts\Activate.ps1
|
||||
```
|
||||
|
||||
**Linux / macOS**
|
||||
|
||||
```bash
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
```
|
||||
|
||||
Your prompt should show `(venv)`.
|
||||
|
||||
### 6.3 Install Python packages
|
||||
|
||||
```bash
|
||||
pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Wait until it finishes without errors.
|
||||
|
||||
### 6.4 Quick sanity check
|
||||
|
||||
```bash
|
||||
python -c "import rich, aiohttp, websockets; print('OK')"
|
||||
```
|
||||
|
||||
If you see `OK`, dependencies are installed.
|
||||
|
||||
---
|
||||
|
||||
## 7. Configure `.env` (secrets)
|
||||
|
||||
### 7.1 Create `.env` from the example
|
||||
|
||||
**Windows**
|
||||
|
||||
```powershell
|
||||
copy .env.example .env
|
||||
```
|
||||
|
||||
**Linux / macOS**
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
### 7.2 Fill each variable
|
||||
|
||||
| Variable | Required | Example / notes |
|
||||
|----------|----------|-----------------|
|
||||
| `PRIVATE_KEY` | **Yes** | `0x` + 64 hex chars. **Never** commit or share. |
|
||||
| `SIGNATURE_TYPE` | **Yes** | `0` = EOA (normal wallet). `1` or `2` = proxy / magic — see Polymarket docs. |
|
||||
| `FUNDER_ADDRESS` | If proxy | Your Polymarket proxy wallet address when `SIGNATURE_TYPE` is 1 or 2. |
|
||||
| `POLY_API_KEY` | **Yes** | From CLOB API. |
|
||||
| `POLY_API_SECRET` | **Yes** | From CLOB API. |
|
||||
| `POLY_API_PASSPHRASE` | **Yes** | From CLOB API. |
|
||||
| `RPC_URL` | Optional | Default `https://polygon-rpc.com`, Alchemy/Infura recommended for production. |
|
||||
| `CHAIN_ID` | Optional | `137` for Polygon mainnet. |
|
||||
| `CLOB_HOST` | Optional | Usually `https://clob.polymarket.com`. |
|
||||
| `TELEGRAM_BOT_TOKEN` | Optional | From @BotFather. |
|
||||
| `TELEGRAM_CHAT_ID` | Optional | Your numeric chat id (e.g. from @userinfobot). |
|
||||
|
||||
### 7.3 Where to get API keys
|
||||
|
||||
- Log in to Polymarket, open the **CLOB API** / developer settings, and create **API credentials** (key, secret, passphrase).
|
||||
- Official URL referenced in the repo: [https://clob.polymarket.com](https://clob.polymarket.com)
|
||||
|
||||
### 7.4 Example `.env` shape (fake values)
|
||||
|
||||
```env
|
||||
PRIVATE_KEY=0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
SIGNATURE_TYPE=0
|
||||
FUNDER_ADDRESS=
|
||||
|
||||
POLY_API_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
POLY_API_SECRET=your_secret_here
|
||||
POLY_API_PASSPHRASE=your_passphrase_here
|
||||
|
||||
RPC_URL=https://polygon-rpc.com
|
||||
CHAIN_ID=137
|
||||
CLOB_HOST=https://clob.polymarket.com
|
||||
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
TELEGRAM_CHAT_ID=
|
||||
```
|
||||
|
||||
Save the file. **Confirm `.env` is gitignored** (do not commit).
|
||||
|
||||
---
|
||||
|
||||
## 8. Configure `config.json` (strategy and execution)
|
||||
|
||||
Edit **`config.json`** in the project root. Below: **what each block does**, **recommended ranges**, and **numeric examples**.
|
||||
|
||||
### 8.1 `strategy` block
|
||||
|
||||
| Parameter | Meaning | Example |
|
||||
|-----------|---------|---------|
|
||||
| `min_price` | Minimum favorite token price to allow entry | `0.75` — ignore favorites below $0.75 |
|
||||
| `max_price` | Maximum favorite token price | `0.88` — do not buy above $0.88 |
|
||||
| `min_elapsed_sec` | Seconds after market open before entry | `530` — wait ~8.8 min |
|
||||
| `min_deviation_pct` | Deviation must be **>** this | `3` — need more than 3% above VWAP |
|
||||
| `max_deviation_pct` | Deviation must be **<** this | `100` — effectively no upper cap |
|
||||
| `no_entry_before_end_sec` | Stop entering if `time_left ≤` this | `335` — no new entries in last ~5.6 min |
|
||||
| `momentum_window_sec` | Seconds of history for momentum | `60` — compare to ~1 minute ago |
|
||||
| `vwap_window_sec` | Seconds of trades for VWAP | `30` — short-term average |
|
||||
| `win_rate_csv` | Path to CSV for dashboard win rate | `"data/win_rate.csv"` |
|
||||
|
||||
**Deviation example**
|
||||
|
||||
- VWAP (30s) = **0.78**, last = **0.80** → deviation ≈ **2.56%** → fails if `min_deviation_pct` is **3**
|
||||
- Last = **0.81** → deviation ≈ **3.85%** → passes if `min_deviation_pct` is **3**
|
||||
|
||||
### 8.2 `entry` block
|
||||
|
||||
| Parameter | Meaning | Example |
|
||||
|-----------|---------|---------|
|
||||
| `bet_amount_usd` | Target spend per entry (subject to sizing rules) | `5` → roughly $5 notional |
|
||||
| `price_offset` | Added to price when placing FAK (more aggressive fill) | `0.02` → pay up to +$0.02 vs reference |
|
||||
| `order_type` | Entry type | `"FAK"` (fill and kill) |
|
||||
| `max_retries` | Retries if order does not complete as expected | `3` |
|
||||
| `retry_delay_ms` | Pause between retries | `300` |
|
||||
| `fill_timeout_ms` | Used in executor / fill logic | `1000` |
|
||||
| `min_contracts` | Polymarket minimum is often 5 | `5` |
|
||||
| `min_order_usd` | Minimum order size in USD | `1` |
|
||||
| `max_entry_price` | Hard cap on execution price | `0.88` — should align with `strategy.max_price` |
|
||||
| `ws_recovery_timeout_sec` | After HTTP timeout, how long to watch User WS for fills | `10` |
|
||||
|
||||
**Sizing example**
|
||||
|
||||
- `bet_amount_usd = 5`, best ask ≈ **0.80** → rough contracts = floor(5 / 0.80) = **6** (subject to mins and API).
|
||||
|
||||
### 8.3 `hedge` block
|
||||
|
||||
| Parameter | Meaning | Example |
|
||||
|-----------|---------|---------|
|
||||
| `enabled` | `true` / `false` | `false` for beginners |
|
||||
| `hedge_price` | Limit price for opposite token | `0.02` |
|
||||
| `order_type` | Usually `"GTD"` | passive limit |
|
||||
| `max_retries` | Placement retries | `3` |
|
||||
| `retry_delay_ms` | Delay between retries | `1000` |
|
||||
|
||||
**Hedge intuition (not financial advice)**
|
||||
After a long on UP, a **cheap** limit order on DOWN can act as a partial hedge if the market moves so that DOWN trades near your limit. **Costs and risks** are real; start with `enabled: false` until you understand fills.
|
||||
|
||||
### 8.4 `redeem` block
|
||||
|
||||
| Parameter | Meaning | Example |
|
||||
|-----------|---------|---------|
|
||||
| `enabled` | Run periodic on-chain redemption | `true` |
|
||||
| `interval_seconds` | Seconds between scans | `180` |
|
||||
| `auto_confirm` | Confirm in code path | `true` |
|
||||
|
||||
**Note:** On **Windows**, some Unix-only locking in redeem may fail; **Linux** or **WSL** is safer for production.
|
||||
|
||||
### 8.5 `telegram` block
|
||||
|
||||
| Parameter | Meaning |
|
||||
|-----------|---------|
|
||||
| `enabled` | `true` to send Telegram messages |
|
||||
| `chart_every_n_trades` | Intended for periodic equity charts (see `TelegramNotifier.send_equity_chart`); **may not be wired** in `main.py` in all versions — check the code if you rely on auto-charts |
|
||||
|
||||
Tokens and chat id still come from **`.env`**.
|
||||
|
||||
### 8.6 `logging` block in `config.json`
|
||||
|
||||
The repo may include a `logging` section for documentation. **Current `main.py` sets logging in code** (e.g. `logs/bot.log`, `INFO` level). Do not assume `config.json` logging keys change behavior unless you wire them in code.
|
||||
|
||||
### 8.7 Preset ideas (copy-paste starting points)
|
||||
|
||||
**Conservative (fewer trades, tighter band)**
|
||||
|
||||
```json
|
||||
"strategy": {
|
||||
"min_price": 0.80,
|
||||
"max_price": 0.85,
|
||||
"min_elapsed_sec": 600,
|
||||
"min_deviation_pct": 5,
|
||||
"max_deviation_pct": 100,
|
||||
"no_entry_before_end_sec": 300,
|
||||
"momentum_window_sec": 60,
|
||||
"vwap_window_sec": 30,
|
||||
"win_rate_csv": "data/win_rate.csv"
|
||||
},
|
||||
"entry": { "bet_amount_usd": 2 },
|
||||
"hedge": { "enabled": false }
|
||||
```
|
||||
|
||||
**Aggressive (more trades — higher risk)**
|
||||
|
||||
```json
|
||||
"strategy": {
|
||||
"min_price": 0.70,
|
||||
"max_price": 0.90,
|
||||
"min_elapsed_sec": 400,
|
||||
"min_deviation_pct": 0,
|
||||
"max_deviation_pct": 100,
|
||||
"no_entry_before_end_sec": 120,
|
||||
"momentum_window_sec": 60,
|
||||
"vwap_window_sec": 30,
|
||||
"win_rate_csv": "data/win_rate.csv"
|
||||
},
|
||||
"entry": { "bet_amount_usd": 5 },
|
||||
"hedge": { "enabled": false }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Run the bot
|
||||
|
||||
1. Activate **venv** (see [§6.2](#62-create-and-activate-a-virtual-environment)).
|
||||
2. Ensure `.env` and `config.json` are saved.
|
||||
3. From the **project root** (folder containing `main.py`):
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
### 9.1 What you should see
|
||||
|
||||
- Startup messages (config summary, CLOB init).
|
||||
- A **live Rich dashboard**: timer, UP/DOWN token panels, indicators, **Strategy** line, P&L.
|
||||
- When a **BUY UP** / **BUY DOWN** signal is valid, the bot fires an entry (real money if your keys are live).
|
||||
|
||||
### 9.2 Stop the bot
|
||||
|
||||
Press **Ctrl+C** in the terminal. On Windows, Unix signal handlers may be limited; **Ctrl+C** still stops the process.
|
||||
|
||||
### 9.3 First-time recommendation
|
||||
|
||||
- Set **`bet_amount_usd`** small.
|
||||
- Set **`hedge.enabled`** to **`false`** until you understand behavior.
|
||||
- Watch **`logs/`** while the market runs.
|
||||
|
||||
---
|
||||
|
||||
## 10. Optional: Telegram
|
||||
|
||||
1. **@BotFather** → `/newbot` → copy **token** → `TELEGRAM_BOT_TOKEN` in `.env`.
|
||||
2. **@userinfobot** → `/start` → copy **Id** → `TELEGRAM_CHAT_ID`.
|
||||
3. Open your bot in Telegram and tap **Start** (required).
|
||||
4. In `config.json`, set `"telegram": { "enabled": true, ... }`.
|
||||
|
||||
---
|
||||
|
||||
## 11. Optional: P&L chart
|
||||
|
||||
After you have trades in **`logs/trading_log.json`**:
|
||||
|
||||
```bash
|
||||
python chart_pnl.py
|
||||
```
|
||||
|
||||
Output image: **`logs/pnl_chart.png`** (see `chart_pnl.py`).
|
||||
|
||||
---
|
||||
|
||||
## 12. Logs and files
|
||||
|
||||
| File / folder | Content |
|
||||
|----------------|---------|
|
||||
| `logs/bot.log` | General bot log |
|
||||
| `logs/orders.log` | Order execution detail |
|
||||
| `logs/hedges.log` | Hedge-related logs |
|
||||
| `logs/signals.log` | Signal snapshots |
|
||||
| `logs/trading_log.json` | Persisted trades + stats |
|
||||
| `logs/pnl_chart.png` | Generated by `chart_pnl.py` |
|
||||
|
||||
---
|
||||
|
||||
## 13. Troubleshooting
|
||||
|
||||
| Problem | What to try |
|
||||
|--------|-------------|
|
||||
| `python` not found (Windows) | Reinstall Python with **Add to PATH**, or use `py -m venv venv` |
|
||||
| `NotImplementedError` on `add_signal_handler` | Already fixed on Windows in `main.py` — use latest code |
|
||||
| Config errors on startup | Read the printed message; usually missing `PRIVATE_KEY` or API fields |
|
||||
| `python` works but imports fail | Activate **venv** and run `pip install -r requirements.txt` again |
|
||||
| No trades for a long time | Strategy window is narrow (see [§2.5](#25-time-window-for-entries-15-minutes--900-seconds)); or market never satisfies all filters |
|
||||
| Redeem errors on Windows | Prefer **WSL** or **Linux** for auto-redeem; or disable `redeem.enabled` and redeem manually on Polymarket |
|
||||
| Telegram not sending | Bot token + chat id + user pressed **Start** on bot; `enabled: true` |
|
||||
|
||||
---
|
||||
|
||||
## 14. Risk summary
|
||||
|
||||
- **Real money** — you can lose your stake.
|
||||
- **No strategy edge is guaranteed** — this bot automates rules.
|
||||
- **Fees, slippage, and failed orders** happen.
|
||||
- **Protect your private key** — treat `.env` like a password.
|
||||
|
||||
For **multi-asset late-entry** trading, see **Meridian** (`up-down-spread-bot`) in the same repository. For **PTB / oracle-diff** rules and a web dashboard, see `5min-15min-PTB-bot`. Extended **quant** offerings (Kelly, Monte Carlo, advanced TA, sizing systems) are described in the [repository root README](https://github.com/PolyBullLabs/polymakret-5min-15min-1hour-arbitrage-bot) — contact [@terauss](https://t.me/terauss).
|
||||
|
||||
---
|
||||
|
||||
*For a single-page parameter list, see [`CONFIG.md`](../CONFIG.md). For internals, see [`PROJECT_LOGIC.md`](../PROJECT_LOGIC.md).*
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
# BTC 15-min Live Trading Bot - Dependencies
|
||||
|
||||
# Polymarket SDK
|
||||
py-clob-client>=0.16.0
|
||||
|
||||
# Web3 for blockchain interactions
|
||||
web3>=6.0.0
|
||||
eth-account>=0.10.0
|
||||
|
||||
# Async HTTP/WebSocket
|
||||
aiohttp>=3.9.0
|
||||
# websockets 13.x has NO native proxy= support; bot uses HTTP CONNECT tunnel
|
||||
# in src/proxy_util.py (sock=). Do not pass proxy=/trust_env= to connect().
|
||||
websockets>=13.0
|
||||
|
||||
# Telegram
|
||||
python-telegram-bot>=20.0
|
||||
|
||||
# Data processing
|
||||
pandas>=2.0.0
|
||||
numpy>=1.24.0
|
||||
|
||||
# Visualization (for equity charts)
|
||||
matplotlib>=3.8.0
|
||||
|
||||
# Terminal UI (main.py dashboard)
|
||||
rich>=13.0.0
|
||||
|
||||
# Config
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
# Web dashboard (optional; enable in config.json web_dashboard.enabled)
|
||||
fastapi>=0.115.0
|
||||
uvicorn[standard]>=0.32.0
|
||||
|
||||
# Misc
|
||||
requests>=2.31.0
|
||||
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
BTC 15-min Live Trading Bot
|
||||
|
||||
Modules:
|
||||
- market_finder: Find active BTC 15-min markets
|
||||
- signal_generator: Generate entry signals (VWAP, Deviation, WinRate)
|
||||
- order_executor: Execute FAK orders with retry logic
|
||||
- hedge_manager: Hedge positions at 0.99
|
||||
- position_tracker: Track positions and P&L
|
||||
- auto_redeemer: Automatic redemption every 3 minutes
|
||||
- websocket_client: Market + User WebSocket channels
|
||||
- telegram_notifier: Telegram notifications + charts
|
||||
- config_loader: Load configuration
|
||||
"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
@@ -0,0 +1,623 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Async Auto-Redeemer
|
||||
|
||||
Runs every 3 minutes in background, checks for redeemable positions
|
||||
and automatically redeems them.
|
||||
|
||||
Fully async - does not block main event loop.
|
||||
"""
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
from typing import Optional, Tuple, List, Dict, Any
|
||||
|
||||
import aiohttp
|
||||
from web3 import Web3
|
||||
from eth_account import Account
|
||||
|
||||
from src.proxy_util import apply_proxy_env, aiohttp_proxy
|
||||
|
||||
apply_proxy_env()
|
||||
_PROXY_URL = aiohttp_proxy() or ""
|
||||
|
||||
logger = logging.getLogger("btc_live.redeemer")
|
||||
|
||||
# Dedicated thread pool for web3 operations to avoid blocking main thread pool
|
||||
_WEB3_EXECUTOR = ThreadPoolExecutor(max_workers=2, thread_name_prefix="web3_redeemer")
|
||||
|
||||
# Contract addresses
|
||||
USDC_ADDRESS = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
|
||||
CTF_ADDRESS = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"
|
||||
NEG_RISK_ADAPTER = "0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296"
|
||||
|
||||
DATA_API = "https://data-api.polymarket.com"
|
||||
|
||||
CTF_ABI = json.loads('''[
|
||||
{
|
||||
"inputs": [
|
||||
{"internalType": "address", "name": "account", "type": "address"},
|
||||
{"internalType": "uint256", "name": "id", "type": "uint256"}
|
||||
],
|
||||
"name": "balanceOf",
|
||||
"outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}],
|
||||
"stateMutability": "view",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"inputs": [
|
||||
{"internalType": "bytes32", "name": "conditionId", "type": "bytes32"}
|
||||
],
|
||||
"name": "payoutDenominator",
|
||||
"outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}],
|
||||
"stateMutability": "view",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"inputs": [
|
||||
{"internalType": "address", "name": "collateralToken", "type": "address"},
|
||||
{"internalType": "bytes32", "name": "parentCollectionId", "type": "bytes32"},
|
||||
{"internalType": "bytes32", "name": "conditionId", "type": "bytes32"},
|
||||
{"internalType": "uint256[]", "name": "indexSets", "type": "uint256[]"}
|
||||
],
|
||||
"name": "redeemPositions",
|
||||
"outputs": [],
|
||||
"stateMutability": "nonpayable",
|
||||
"type": "function"
|
||||
}
|
||||
]''')
|
||||
|
||||
NEG_RISK_ABI = json.loads('''[
|
||||
{
|
||||
"inputs": [
|
||||
{"internalType": "bytes32", "name": "conditionId", "type": "bytes32"},
|
||||
{"internalType": "uint256[]", "name": "amounts", "type": "uint256[]"}
|
||||
],
|
||||
"name": "redeemPositions",
|
||||
"outputs": [],
|
||||
"stateMutability": "nonpayable",
|
||||
"type": "function"
|
||||
}
|
||||
]''')
|
||||
|
||||
GNOSIS_SAFE_ABI = json.loads('''[
|
||||
{
|
||||
"inputs": [
|
||||
{"name": "to", "type": "address"},
|
||||
{"name": "value", "type": "uint256"},
|
||||
{"name": "data", "type": "bytes"},
|
||||
{"name": "operation", "type": "uint8"},
|
||||
{"name": "safeTxGas", "type": "uint256"},
|
||||
{"name": "baseGas", "type": "uint256"},
|
||||
{"name": "gasPrice", "type": "uint256"},
|
||||
{"name": "gasToken", "type": "address"},
|
||||
{"name": "refundReceiver", "type": "address"},
|
||||
{"name": "signatures", "type": "bytes"}
|
||||
],
|
||||
"name": "execTransaction",
|
||||
"outputs": [{"name": "success", "type": "bool"}],
|
||||
"stateMutability": "payable",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"inputs": [],
|
||||
"name": "nonce",
|
||||
"outputs": [{"name": "", "type": "uint256"}],
|
||||
"stateMutability": "view",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"inputs": [
|
||||
{"name": "to", "type": "address"},
|
||||
{"name": "value", "type": "uint256"},
|
||||
{"name": "data", "type": "bytes"},
|
||||
{"name": "operation", "type": "uint8"},
|
||||
{"name": "safeTxGas", "type": "uint256"},
|
||||
{"name": "baseGas", "type": "uint256"},
|
||||
{"name": "gasPrice", "type": "uint256"},
|
||||
{"name": "gasToken", "type": "address"},
|
||||
{"name": "refundReceiver", "type": "address"},
|
||||
{"name": "_nonce", "type": "uint256"}
|
||||
],
|
||||
"name": "getTransactionHash",
|
||||
"outputs": [{"name": "", "type": "bytes32"}],
|
||||
"stateMutability": "view",
|
||||
"type": "function"
|
||||
}
|
||||
]''')
|
||||
|
||||
|
||||
class AsyncAutoRedeemer:
|
||||
"""
|
||||
Async auto-redeemer that runs in background every N minutes.
|
||||
|
||||
Features:
|
||||
- Fully async (aiohttp for API, asyncio.to_thread for web3)
|
||||
- File lock to prevent concurrent redemptions
|
||||
- Supports both EOA and Proxy (Gnosis Safe) wallets
|
||||
- Telegram notifications on successful redeem
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
private_key: str,
|
||||
rpc_url: str,
|
||||
funder_address: Optional[str] = None,
|
||||
signature_type: int = 0,
|
||||
interval_seconds: int = 180, # 3 minutes
|
||||
telegram_notifier: Optional[Any] = None
|
||||
):
|
||||
self.private_key = private_key
|
||||
self.rpc_url = rpc_url
|
||||
self.funder_address = funder_address
|
||||
self.signature_type = signature_type
|
||||
self.interval = interval_seconds
|
||||
self.telegram = telegram_notifier
|
||||
|
||||
# Web3 setup
|
||||
self.w3 = Web3(Web3.HTTPProvider(rpc_url))
|
||||
|
||||
# Add POA middleware for Polygon
|
||||
from web3.middleware import ExtraDataToPOAMiddleware
|
||||
self.w3.middleware_onion.inject(ExtraDataToPOAMiddleware, layer=0)
|
||||
|
||||
# Account
|
||||
self.account = Account.from_key(private_key)
|
||||
self.signer_address = self.account.address
|
||||
|
||||
# Wallet to check for positions
|
||||
if signature_type in [1, 2] and funder_address:
|
||||
self.wallet_address = funder_address
|
||||
else:
|
||||
self.wallet_address = self.signer_address
|
||||
|
||||
# Contracts
|
||||
self.ctf = self.w3.eth.contract(
|
||||
address=Web3.to_checksum_address(CTF_ADDRESS),
|
||||
abi=CTF_ABI
|
||||
)
|
||||
|
||||
# Stats
|
||||
self.total_redeemed = 0
|
||||
self.total_value = 0.0
|
||||
self._running = False
|
||||
self._lock_fd = None
|
||||
|
||||
# Semaphore to limit concurrent web3 operations (prevents thread pool saturation)
|
||||
self._redeem_semaphore = asyncio.Semaphore(1) # Only 1 redemption at a time
|
||||
|
||||
async def _fetch_positions(self) -> Tuple[List[Dict], List[Dict], List[Dict]]:
|
||||
"""Fetch all positions from Polymarket Data API (async)."""
|
||||
active = []
|
||||
pending = []
|
||||
redeemable = []
|
||||
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=30, connect=10)
|
||||
async with aiohttp.ClientSession(
|
||||
timeout=timeout, trust_env=True
|
||||
) as session:
|
||||
url = f"{DATA_API}/positions"
|
||||
params = {
|
||||
"user": self.wallet_address,
|
||||
"limit": 500,
|
||||
"sizeThreshold": 0.01
|
||||
}
|
||||
|
||||
async with session.get(
|
||||
url, params=params, proxy=_PROXY_URL or None
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
logger.error(f"Data API returned {resp.status}")
|
||||
return active, pending, redeemable
|
||||
|
||||
positions = await resp.json()
|
||||
|
||||
if not positions:
|
||||
return active, pending, redeemable
|
||||
|
||||
# Group by conditionId
|
||||
positions_by_condition = {}
|
||||
for pos in positions:
|
||||
condition_id = pos.get("conditionId")
|
||||
if not condition_id:
|
||||
continue
|
||||
|
||||
if condition_id not in positions_by_condition:
|
||||
positions_by_condition[condition_id] = {
|
||||
"slug": pos.get("slug", "unknown"),
|
||||
"title": pos.get("title", "Unknown Market"),
|
||||
"condition_id": condition_id,
|
||||
"neg_risk": pos.get("negativeRisk", False),
|
||||
"end_date": pos.get("endDate"),
|
||||
"redeemable": pos.get("redeemable", False),
|
||||
"outcomes": {}
|
||||
}
|
||||
|
||||
outcome = pos.get("outcome", "")
|
||||
positions_by_condition[condition_id]["outcomes"][outcome] = {
|
||||
"asset": pos.get("asset"),
|
||||
"size": int(float(pos.get("size", 0)) * 1e6),
|
||||
"cur_price": pos.get("curPrice", 0),
|
||||
}
|
||||
|
||||
# Categorize
|
||||
import time
|
||||
now = int(time.time())
|
||||
|
||||
for condition_id, pos_data in positions_by_condition.items():
|
||||
outcomes = pos_data["outcomes"]
|
||||
|
||||
up_data = outcomes.get("Up") or outcomes.get("YES") or outcomes.get("Higher")
|
||||
down_data = outcomes.get("Down") or outcomes.get("NO") or outcomes.get("Lower")
|
||||
|
||||
if not up_data and not down_data:
|
||||
outcome_list = list(outcomes.values())
|
||||
up_data = outcome_list[0] if len(outcome_list) > 0 else None
|
||||
down_data = outcome_list[1] if len(outcome_list) > 1 else None
|
||||
|
||||
up_balance = up_data.get("size", 0) if up_data else 0
|
||||
down_balance = down_data.get("size", 0) if down_data else 0
|
||||
|
||||
if up_balance == 0 and down_balance == 0:
|
||||
continue
|
||||
|
||||
position_data = {
|
||||
"slug": pos_data["slug"],
|
||||
"title": pos_data["title"],
|
||||
"condition_id": condition_id,
|
||||
"up_token_id": up_data.get("asset") if up_data else None,
|
||||
"down_token_id": down_data.get("asset") if down_data else None,
|
||||
"up_balance": up_balance,
|
||||
"down_balance": down_balance,
|
||||
"neg_risk": pos_data["neg_risk"],
|
||||
}
|
||||
|
||||
end_date = pos_data.get("end_date")
|
||||
is_closed = False
|
||||
if end_date:
|
||||
try:
|
||||
end_timestamp = datetime.fromisoformat(
|
||||
end_date.replace('Z', '+00:00')
|
||||
).timestamp()
|
||||
is_closed = now >= end_timestamp
|
||||
except:
|
||||
pass
|
||||
|
||||
if pos_data["redeemable"]:
|
||||
redeemable.append(position_data)
|
||||
elif is_closed:
|
||||
pending.append(position_data)
|
||||
else:
|
||||
active.append(position_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching positions: {e}")
|
||||
|
||||
return active, pending, redeemable
|
||||
|
||||
def _check_oracle_resolution(self, condition_id: str) -> bool:
|
||||
"""Check if oracle has resolved (sync, runs in thread)."""
|
||||
try:
|
||||
condition_bytes = Web3.to_bytes(hexstr=condition_id)
|
||||
payout_denom = self.ctf.functions.payoutDenominator(condition_bytes).call()
|
||||
return payout_denom > 0
|
||||
except Exception as e:
|
||||
logger.error(f"Oracle check error: {e}")
|
||||
return False
|
||||
|
||||
def _redeem_position_sync(self, position: Dict) -> bool:
|
||||
"""Redeem a single position (sync, runs in thread)."""
|
||||
import fcntl
|
||||
import time
|
||||
|
||||
condition_id = position["condition_id"]
|
||||
up_balance = position["up_balance"]
|
||||
down_balance = position["down_balance"]
|
||||
is_neg_risk = position.get("neg_risk", False)
|
||||
|
||||
logger.info(f"Redeeming: {position['slug']}")
|
||||
|
||||
# Check oracle first
|
||||
if not self._check_oracle_resolution(condition_id):
|
||||
logger.warning(f"Skipping {position['slug']} - oracle not resolved")
|
||||
return False
|
||||
|
||||
# File lock
|
||||
lock_file = "/tmp/btc_live_redeem.lock"
|
||||
try:
|
||||
self._lock_fd = open(lock_file, 'w')
|
||||
fcntl.flock(self._lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except (IOError, OSError):
|
||||
logger.warning("Another redeem in progress, skipping")
|
||||
return False
|
||||
|
||||
try:
|
||||
use_proxy = self.signature_type in [1, 2] and self.funder_address
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
if use_proxy:
|
||||
# Gnosis Safe proxy wallet
|
||||
return self._redeem_via_safe(condition_id, up_balance, down_balance, is_neg_risk)
|
||||
else:
|
||||
# Direct EOA
|
||||
return self._redeem_direct(condition_id, up_balance, down_balance, is_neg_risk)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Redeem error: {e}")
|
||||
return False
|
||||
finally:
|
||||
if self._lock_fd:
|
||||
try:
|
||||
fcntl.flock(self._lock_fd, fcntl.LOCK_UN)
|
||||
self._lock_fd.close()
|
||||
except:
|
||||
pass
|
||||
self._lock_fd = None
|
||||
|
||||
def _redeem_direct(
|
||||
self,
|
||||
condition_id: str,
|
||||
up_balance: int,
|
||||
down_balance: int,
|
||||
is_neg_risk: bool
|
||||
) -> bool:
|
||||
"""Direct EOA redeem."""
|
||||
import time
|
||||
|
||||
nonce = self.w3.eth.get_transaction_count(self.signer_address)
|
||||
time.sleep(0.3)
|
||||
gas_price = self.w3.eth.gas_price
|
||||
|
||||
if is_neg_risk:
|
||||
adapter = self.w3.eth.contract(
|
||||
address=Web3.to_checksum_address(NEG_RISK_ADAPTER),
|
||||
abi=NEG_RISK_ABI
|
||||
)
|
||||
tx = adapter.functions.redeemPositions(
|
||||
Web3.to_bytes(hexstr=condition_id),
|
||||
[up_balance, down_balance]
|
||||
).build_transaction({
|
||||
"chainId": 137,
|
||||
"from": self.signer_address,
|
||||
"nonce": nonce,
|
||||
"gas": 500000,
|
||||
"gasPrice": int(gas_price * 1.2),
|
||||
})
|
||||
else:
|
||||
tx = self.ctf.functions.redeemPositions(
|
||||
Web3.to_checksum_address(USDC_ADDRESS),
|
||||
bytes(32),
|
||||
Web3.to_bytes(hexstr=condition_id),
|
||||
[1, 2]
|
||||
).build_transaction({
|
||||
"chainId": 137,
|
||||
"from": self.signer_address,
|
||||
"nonce": nonce,
|
||||
"gas": 500000,
|
||||
"gasPrice": int(gas_price * 1.2),
|
||||
})
|
||||
|
||||
signed_tx = self.w3.eth.account.sign_transaction(tx, self.private_key)
|
||||
tx_hash = self.w3.eth.send_raw_transaction(signed_tx.raw_transaction)
|
||||
|
||||
logger.info(f"TX sent: {tx_hash.hex()}")
|
||||
|
||||
receipt = self.w3.eth.wait_for_transaction_receipt(tx_hash, timeout=120)
|
||||
|
||||
if receipt.get("status") == 1:
|
||||
logger.info(f"Redeem successful! Gas: {receipt.get('gasUsed')}")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"TX reverted: {tx_hash.hex()}")
|
||||
return False
|
||||
|
||||
def _redeem_via_safe(
|
||||
self,
|
||||
condition_id: str,
|
||||
up_balance: int,
|
||||
down_balance: int,
|
||||
is_neg_risk: bool
|
||||
) -> bool:
|
||||
"""Redeem via Gnosis Safe proxy wallet."""
|
||||
import time
|
||||
|
||||
safe_address = Web3.to_checksum_address(self.funder_address)
|
||||
safe = self.w3.eth.contract(address=safe_address, abi=GNOSIS_SAFE_ABI)
|
||||
|
||||
# Build inner redeem call
|
||||
if is_neg_risk:
|
||||
adapter = self.w3.eth.contract(
|
||||
address=Web3.to_checksum_address(NEG_RISK_ADAPTER),
|
||||
abi=NEG_RISK_ABI
|
||||
)
|
||||
temp_tx = adapter.functions.redeemPositions(
|
||||
Web3.to_bytes(hexstr=condition_id),
|
||||
[up_balance, down_balance]
|
||||
).build_transaction({"from": safe_address})
|
||||
redeem_data = temp_tx['data']
|
||||
target_contract = NEG_RISK_ADAPTER
|
||||
else:
|
||||
temp_tx = self.ctf.functions.redeemPositions(
|
||||
Web3.to_checksum_address(USDC_ADDRESS),
|
||||
bytes(32),
|
||||
Web3.to_bytes(hexstr=condition_id),
|
||||
[1, 2]
|
||||
).build_transaction({"from": safe_address})
|
||||
redeem_data = temp_tx['data']
|
||||
target_contract = CTF_ADDRESS
|
||||
|
||||
time.sleep(0.5)
|
||||
eoa_nonce = self.w3.eth.get_transaction_count(self.signer_address)
|
||||
time.sleep(0.3)
|
||||
gas_price = self.w3.eth.gas_price
|
||||
safe_nonce = safe.functions.nonce().call()
|
||||
|
||||
# Safe TX params
|
||||
to = Web3.to_checksum_address(target_contract)
|
||||
value = 0
|
||||
data = redeem_data
|
||||
operation = 0
|
||||
safeTxGas = 0
|
||||
baseGas = 0
|
||||
gasPrice_safe = 0
|
||||
gasToken = "0x0000000000000000000000000000000000000000"
|
||||
refundReceiver = "0x0000000000000000000000000000000000000000"
|
||||
|
||||
# Get TX hash to sign
|
||||
tx_hash_to_sign = safe.functions.getTransactionHash(
|
||||
to, value, data, operation,
|
||||
safeTxGas, baseGas, gasPrice_safe,
|
||||
gasToken, refundReceiver, safe_nonce
|
||||
).call()
|
||||
|
||||
# Sign
|
||||
signed_msg = self.account.unsafe_sign_hash(tx_hash_to_sign)
|
||||
r = signed_msg.r.to_bytes(32, byteorder='big')
|
||||
s = signed_msg.s.to_bytes(32, byteorder='big')
|
||||
v = signed_msg.v
|
||||
signature = r + s + bytes([v])
|
||||
|
||||
# Build execTransaction
|
||||
tx = safe.functions.execTransaction(
|
||||
to, value, data, operation,
|
||||
safeTxGas, baseGas, gasPrice_safe,
|
||||
gasToken, refundReceiver, signature
|
||||
).build_transaction({
|
||||
"chainId": 137,
|
||||
"from": self.signer_address,
|
||||
"nonce": eoa_nonce,
|
||||
"gas": 1000000,
|
||||
"gasPrice": int(gas_price * 1.2),
|
||||
})
|
||||
|
||||
time.sleep(0.5)
|
||||
signed_tx = self.w3.eth.account.sign_transaction(tx, self.private_key)
|
||||
tx_hash = self.w3.eth.send_raw_transaction(signed_tx.raw_transaction)
|
||||
|
||||
logger.info(f"Safe TX sent: {tx_hash.hex()}")
|
||||
|
||||
receipt = self.w3.eth.wait_for_transaction_receipt(tx_hash, timeout=120)
|
||||
|
||||
if receipt.get("status") == 1:
|
||||
logger.info(f"Safe redeem successful! Gas: {receipt.get('gasUsed')}")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Safe TX reverted: {tx_hash.hex()}")
|
||||
return False
|
||||
|
||||
async def redeem_all(self) -> Tuple[int, float]:
|
||||
"""
|
||||
Check and redeem all redeemable positions.
|
||||
|
||||
Returns:
|
||||
Tuple of (redeemed_count, total_value_usd)
|
||||
"""
|
||||
logger.info("Starting auto-redeem check...")
|
||||
|
||||
active, pending, redeemable = await self._fetch_positions()
|
||||
|
||||
logger.info(f"Found: {len(active)} active, {len(pending)} pending, {len(redeemable)} redeemable")
|
||||
|
||||
if not redeemable:
|
||||
return 0, 0.0
|
||||
|
||||
redeemed_count = 0
|
||||
total_value = 0.0
|
||||
|
||||
for position in redeemable:
|
||||
# Use semaphore to limit concurrent redemptions
|
||||
# Use dedicated thread pool to avoid blocking main pool
|
||||
async with self._redeem_semaphore:
|
||||
loop = asyncio.get_event_loop()
|
||||
success = await loop.run_in_executor(
|
||||
_WEB3_EXECUTOR,
|
||||
self._redeem_position_sync,
|
||||
position
|
||||
)
|
||||
|
||||
if success:
|
||||
redeemed_count += 1
|
||||
value = (position["up_balance"] + position["down_balance"]) / 1e6
|
||||
total_value += value
|
||||
|
||||
self.total_redeemed += 1
|
||||
self.total_value += value
|
||||
|
||||
# Telegram notification
|
||||
if self.telegram:
|
||||
try:
|
||||
await self.telegram.send_message(
|
||||
f"💰 Redeemed: {position['slug']}\n"
|
||||
f"Value: ${value:.2f} USDC"
|
||||
)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Pause between redemptions
|
||||
await asyncio.sleep(2)
|
||||
|
||||
logger.info(f"Redeemed {redeemed_count}/{len(redeemable)}, value: ${total_value:.2f}")
|
||||
|
||||
return redeemed_count, total_value
|
||||
|
||||
async def run_loop(self):
|
||||
"""
|
||||
Main loop - runs every N seconds.
|
||||
Fully async, never blocks.
|
||||
"""
|
||||
self._running = True
|
||||
logger.info(f"Auto-redeemer started (interval: {self.interval}s)")
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
await asyncio.sleep(self.interval)
|
||||
|
||||
redeemed, value = await self.redeem_all()
|
||||
|
||||
if redeemed > 0:
|
||||
logger.info(f"Auto-redeemed {redeemed} positions, ${value:.2f}")
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Auto-redeemer cancelled")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Auto-redeem loop error: {e}")
|
||||
# Continue running despite errors
|
||||
await asyncio.sleep(10)
|
||||
|
||||
logger.info("Auto-redeemer stopped")
|
||||
|
||||
def stop(self):
|
||||
"""Stop the redeemer loop."""
|
||||
self._running = False
|
||||
|
||||
@staticmethod
|
||||
def shutdown_executor():
|
||||
"""Shutdown the dedicated thread pool on application exit."""
|
||||
global _WEB3_EXECUTOR
|
||||
if _WEB3_EXECUTOR:
|
||||
_WEB3_EXECUTOR.shutdown(wait=False)
|
||||
logger.info("Web3 executor shut down")
|
||||
|
||||
|
||||
async def create_auto_redeemer(config: Dict) -> AsyncAutoRedeemer:
|
||||
"""
|
||||
Factory function to create redeemer from config.
|
||||
|
||||
Args:
|
||||
config: Dict with keys: private_key, rpc_url, funder_address, signature_type
|
||||
"""
|
||||
return AsyncAutoRedeemer(
|
||||
private_key=config.get("private_key"),
|
||||
rpc_url=config.get("rpc_url", "https://polygon-rpc.com"),
|
||||
funder_address=config.get("funder_address"),
|
||||
signature_type=config.get("signature_type", 0),
|
||||
interval_seconds=config.get("redeem_interval", 180),
|
||||
telegram_notifier=config.get("telegram_notifier")
|
||||
)
|
||||
@@ -0,0 +1,329 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Configuration Loader
|
||||
|
||||
Loads settings from config.json and .env file.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional
|
||||
from dataclasses import dataclass
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load .env from project root
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
load_dotenv(PROJECT_ROOT / ".env")
|
||||
|
||||
|
||||
@dataclass
|
||||
class MarketConfig:
|
||||
"""Which Polymarket BTC up/down interval to trade (slug: btc-updown-{5|15}m-<epoch>)."""
|
||||
interval_minutes: int = 15
|
||||
|
||||
@property
|
||||
def duration_sec(self) -> int:
|
||||
return self.interval_minutes * 60
|
||||
|
||||
@property
|
||||
def slug_infix(self) -> str:
|
||||
"""e.g. '5m' or '15m' for btc-updown-5m-..."""
|
||||
return f"{self.interval_minutes}m"
|
||||
|
||||
|
||||
@dataclass
|
||||
class StrategyConfig:
|
||||
"""Strategy parameters."""
|
||||
min_price: float = 0.65
|
||||
max_price: float = 0.91
|
||||
min_elapsed_sec: int = 480
|
||||
min_deviation_pct: float = 5.0
|
||||
max_deviation_pct: float = 100.0
|
||||
no_entry_before_end_sec: int = 90
|
||||
momentum_window_sec: int = 120
|
||||
vwap_window_sec: int = 30
|
||||
win_rate_csv: str = "data/win_rate.csv"
|
||||
|
||||
|
||||
@dataclass
|
||||
class EntryConfig:
|
||||
"""Entry execution parameters."""
|
||||
bet_amount_usd: float = 10.0
|
||||
price_offset: float = 0.01
|
||||
order_type: str = "FAK"
|
||||
max_retries: int = 5
|
||||
retry_delay_ms: int = 300
|
||||
fill_timeout_ms: int = 2000
|
||||
min_contracts: int = 5
|
||||
min_order_usd: float = 1.0
|
||||
max_entry_price: float = 0.91
|
||||
ws_recovery_timeout_sec: int = 10
|
||||
|
||||
|
||||
@dataclass
|
||||
class HedgeConfig:
|
||||
"""Hedge execution parameters."""
|
||||
enabled: bool = True
|
||||
hedge_price: float = 0.02
|
||||
order_type: str = "GTD"
|
||||
max_retries: int = 3
|
||||
retry_delay_ms: int = 1000
|
||||
|
||||
|
||||
@dataclass
|
||||
class RedeemConfig:
|
||||
"""Auto-redeem parameters."""
|
||||
enabled: bool = True
|
||||
interval_seconds: int = 180
|
||||
auto_confirm: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class TelegramConfig:
|
||||
"""Telegram notification parameters."""
|
||||
enabled: bool = True
|
||||
bot_token: str = ""
|
||||
chat_id: str = ""
|
||||
chart_every_n_trades: int = 10
|
||||
|
||||
|
||||
@dataclass
|
||||
class SimulationConfig:
|
||||
"""
|
||||
Paper-trading mode: same WebSockets, signals, and dashboard; no real orders or redeemer.
|
||||
When enabled, API keys and private key are optional (not validated).
|
||||
"""
|
||||
enabled: bool = False
|
||||
separate_trading_log: bool = True
|
||||
trading_log_path: str = "logs/trading_log_sim.json"
|
||||
# Analysis exports (OPEN/CLOSE rows, cumulative PnL). Set jsonl path to "" to disable JSONL.
|
||||
history_csv_path: str = "logs/simulation_trades.csv"
|
||||
history_jsonl_path: str = "logs/simulation_history.jsonl"
|
||||
history_summary_path: str = "logs/simulation_summary.json"
|
||||
|
||||
|
||||
@dataclass
|
||||
class WebDashboardConfig:
|
||||
"""Optional local web UI (FastAPI). Bind to 127.0.0.1 unless you trust your network."""
|
||||
enabled: bool = False
|
||||
host: str = "127.0.0.1"
|
||||
port: int = 8765
|
||||
|
||||
|
||||
@dataclass
|
||||
class PolymarketConfig:
|
||||
"""Polymarket API credentials."""
|
||||
private_key: str = ""
|
||||
funder_address: str = ""
|
||||
signature_type: int = 0
|
||||
rpc_url: str = "https://polygon-rpc.com"
|
||||
chain_id: int = 137
|
||||
clob_host: str = "https://clob.polymarket.com"
|
||||
api_key: str = ""
|
||||
api_secret: str = ""
|
||||
api_passphrase: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
"""Main configuration."""
|
||||
market: MarketConfig
|
||||
simulation: SimulationConfig
|
||||
strategy: StrategyConfig
|
||||
entry: EntryConfig
|
||||
hedge: HedgeConfig
|
||||
redeem: RedeemConfig
|
||||
telegram: TelegramConfig
|
||||
web_dashboard: WebDashboardConfig
|
||||
polymarket: PolymarketConfig
|
||||
|
||||
|
||||
def load_config(config_path: Optional[str] = None) -> Config:
|
||||
"""
|
||||
Load configuration from JSON file and environment variables.
|
||||
|
||||
Args:
|
||||
config_path: Path to config.json (default: PROJECT_ROOT/config.json)
|
||||
|
||||
Returns:
|
||||
Config object with all settings
|
||||
"""
|
||||
if config_path is None:
|
||||
config_path = PROJECT_ROOT / "config.json"
|
||||
|
||||
# Load JSON config
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Market interval (5 or 15 minutes)
|
||||
market_data = data.get("market", {})
|
||||
market = MarketConfig(
|
||||
interval_minutes=int(market_data.get("interval_minutes", 15)),
|
||||
)
|
||||
|
||||
sim_data = data.get("simulation", {})
|
||||
simulation = SimulationConfig(
|
||||
enabled=bool(sim_data.get("enabled", False)),
|
||||
separate_trading_log=bool(sim_data.get("separate_trading_log", True)),
|
||||
trading_log_path=str(sim_data.get("trading_log_path", "logs/trading_log_sim.json")),
|
||||
history_csv_path=str(sim_data.get("history_csv_path", "logs/simulation_trades.csv")),
|
||||
history_jsonl_path=str(sim_data.get("history_jsonl_path", "logs/simulation_history.jsonl")),
|
||||
history_summary_path=str(sim_data.get("history_summary_path", "logs/simulation_summary.json")),
|
||||
)
|
||||
|
||||
# Strategy
|
||||
strategy_data = data.get("strategy", {})
|
||||
strategy = StrategyConfig(
|
||||
min_price=strategy_data.get("min_price", 0.65),
|
||||
max_price=strategy_data.get("max_price", 0.91),
|
||||
min_elapsed_sec=strategy_data.get("min_elapsed_sec", 480),
|
||||
min_deviation_pct=strategy_data.get("min_deviation_pct", 5.0),
|
||||
max_deviation_pct=strategy_data.get("max_deviation_pct", 100.0),
|
||||
no_entry_before_end_sec=strategy_data.get("no_entry_before_end_sec", 90),
|
||||
momentum_window_sec=strategy_data.get("momentum_window_sec", 120),
|
||||
vwap_window_sec=strategy_data.get("vwap_window_sec", 30),
|
||||
win_rate_csv=strategy_data.get("win_rate_csv", "data/win_rate.csv"),
|
||||
)
|
||||
|
||||
# Entry
|
||||
entry_data = data.get("entry", {})
|
||||
entry = EntryConfig(
|
||||
bet_amount_usd=entry_data.get("bet_amount_usd", 10.0),
|
||||
price_offset=entry_data.get("price_offset", 0.01),
|
||||
order_type=entry_data.get("order_type", "FAK"),
|
||||
max_retries=entry_data.get("max_retries", 5),
|
||||
retry_delay_ms=entry_data.get("retry_delay_ms", 300),
|
||||
fill_timeout_ms=entry_data.get("fill_timeout_ms", 2000),
|
||||
min_contracts=entry_data.get("min_contracts", 5),
|
||||
min_order_usd=entry_data.get("min_order_usd", 1.0),
|
||||
max_entry_price=entry_data.get("max_entry_price", 0.91),
|
||||
ws_recovery_timeout_sec=entry_data.get("ws_recovery_timeout_sec", 10),
|
||||
)
|
||||
|
||||
# Hedge
|
||||
hedge_data = data.get("hedge", {})
|
||||
hedge = HedgeConfig(
|
||||
enabled=hedge_data.get("enabled", True),
|
||||
hedge_price=hedge_data.get("hedge_price", 0.02),
|
||||
order_type=hedge_data.get("order_type", "GTD"),
|
||||
max_retries=hedge_data.get("max_retries", 3),
|
||||
retry_delay_ms=hedge_data.get("retry_delay_ms", 1000),
|
||||
)
|
||||
|
||||
# Redeem
|
||||
redeem_data = data.get("redeem", {})
|
||||
redeem = RedeemConfig(
|
||||
enabled=redeem_data.get("enabled", True),
|
||||
interval_seconds=redeem_data.get("interval_seconds", 180),
|
||||
auto_confirm=redeem_data.get("auto_confirm", True),
|
||||
)
|
||||
|
||||
# Telegram (merge JSON + env)
|
||||
telegram_data = data.get("telegram", {})
|
||||
telegram = TelegramConfig(
|
||||
enabled=telegram_data.get("enabled", True),
|
||||
bot_token=os.getenv("TELEGRAM_BOT_TOKEN", ""),
|
||||
chat_id=os.getenv("TELEGRAM_CHAT_ID", ""),
|
||||
chart_every_n_trades=telegram_data.get("chart_every_n_trades", 10),
|
||||
)
|
||||
|
||||
web_data = data.get("web_dashboard", {})
|
||||
web_dashboard = WebDashboardConfig(
|
||||
enabled=bool(web_data.get("enabled", False)),
|
||||
host=str(web_data.get("host", "127.0.0.1")),
|
||||
port=int(web_data.get("port", 8765)),
|
||||
)
|
||||
|
||||
# Polymarket (from env only - secrets)
|
||||
polymarket = PolymarketConfig(
|
||||
private_key=os.getenv("PRIVATE_KEY", ""),
|
||||
funder_address=os.getenv("FUNDER_ADDRESS", ""),
|
||||
signature_type=int(os.getenv("SIGNATURE_TYPE", "0")),
|
||||
rpc_url=os.getenv("RPC_URL", "https://polygon-rpc.com"),
|
||||
chain_id=int(os.getenv("CHAIN_ID", "137")),
|
||||
clob_host=os.getenv("CLOB_HOST", "https://clob.polymarket.com"),
|
||||
api_key=os.getenv("POLY_API_KEY", ""),
|
||||
api_secret=os.getenv("POLY_API_SECRET", ""),
|
||||
api_passphrase=os.getenv("POLY_API_PASSPHRASE", ""),
|
||||
)
|
||||
|
||||
return Config(
|
||||
market=market,
|
||||
simulation=simulation,
|
||||
strategy=strategy,
|
||||
entry=entry,
|
||||
hedge=hedge,
|
||||
redeem=redeem,
|
||||
telegram=telegram,
|
||||
web_dashboard=web_dashboard,
|
||||
polymarket=polymarket,
|
||||
)
|
||||
|
||||
|
||||
def validate_config(config: Config) -> list:
|
||||
"""
|
||||
Validate configuration.
|
||||
|
||||
Returns:
|
||||
List of error messages (empty if valid)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
if config.market.interval_minutes not in (5, 15):
|
||||
errors.append(
|
||||
'market.interval_minutes must be 5 or 15 (Polymarket BTC up/down markets)'
|
||||
)
|
||||
|
||||
dur = config.market.duration_sec
|
||||
if config.strategy.min_elapsed_sec >= dur:
|
||||
errors.append(
|
||||
f"strategy.min_elapsed_sec ({config.strategy.min_elapsed_sec}s) must be less than "
|
||||
f"market duration ({dur}s for {config.market.interval_minutes}m)"
|
||||
)
|
||||
if config.strategy.no_entry_before_end_sec >= dur:
|
||||
errors.append(
|
||||
f"strategy.no_entry_before_end_sec ({config.strategy.no_entry_before_end_sec}s) "
|
||||
f"must be less than market duration ({dur}s)"
|
||||
)
|
||||
|
||||
live_trading = not config.simulation.enabled
|
||||
|
||||
if live_trading:
|
||||
# Required: private key
|
||||
if not config.polymarket.private_key:
|
||||
errors.append("PRIVATE_KEY not set in .env")
|
||||
elif not config.polymarket.private_key.startswith("0x"):
|
||||
errors.append("PRIVATE_KEY must start with 0x")
|
||||
|
||||
# Proxy wallet check
|
||||
if config.polymarket.signature_type in [1, 2]:
|
||||
if not config.polymarket.funder_address:
|
||||
errors.append(f"SIGNATURE_TYPE={config.polymarket.signature_type} requires FUNDER_ADDRESS")
|
||||
|
||||
# API credentials
|
||||
if not config.polymarket.api_key:
|
||||
errors.append("POLY_API_KEY not set")
|
||||
if not config.polymarket.api_secret:
|
||||
errors.append("POLY_API_SECRET not set")
|
||||
if not config.polymarket.api_passphrase:
|
||||
errors.append("POLY_API_PASSPHRASE not set")
|
||||
|
||||
# Strategy bounds
|
||||
if config.strategy.min_price >= config.strategy.max_price:
|
||||
errors.append("min_price must be less than max_price")
|
||||
|
||||
if config.entry.max_entry_price > config.strategy.max_price:
|
||||
errors.append("max_entry_price should not exceed strategy max_price")
|
||||
|
||||
if config.strategy.max_deviation_pct <= config.strategy.min_deviation_pct:
|
||||
errors.append(
|
||||
f"max_deviation_pct ({config.strategy.max_deviation_pct}) "
|
||||
f"must be greater than min_deviation_pct ({config.strategy.min_deviation_pct})"
|
||||
)
|
||||
|
||||
if config.web_dashboard.enabled:
|
||||
if not (1 <= config.web_dashboard.port <= 65535):
|
||||
errors.append("web_dashboard.port must be between 1 and 65535")
|
||||
|
||||
return errors
|
||||
@@ -0,0 +1,319 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Hedge Manager (GTD)
|
||||
|
||||
Places a passive GTD limit order on the opposite leg immediately after entry.
|
||||
The order sits on the book and fills automatically when price reaches hedge_price.
|
||||
|
||||
No trigger monitoring needed — the CLOB handles execution.
|
||||
Order auto-cancels when the market resolves.
|
||||
|
||||
Features:
|
||||
- GTD limit order on opposite token
|
||||
- Exact contract count matching main position
|
||||
- Duplicate protection via hedge_order_placed flag
|
||||
- Fill tracking via WebSocket user channel
|
||||
- Telegram notifications for placement and fills
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
logger = logging.getLogger("btc_live.hedge")
|
||||
|
||||
# Separate logger for detailed hedge tracking
|
||||
hedge_logger = logging.getLogger("btc_live.hedges")
|
||||
hedge_logger.setLevel(logging.DEBUG)
|
||||
|
||||
|
||||
@dataclass
|
||||
class HedgeConfig:
|
||||
"""Configuration for hedging."""
|
||||
enabled: bool = True
|
||||
hedge_price: float = 0.02
|
||||
order_type: str = "GTD"
|
||||
max_retries: int = 3
|
||||
retry_delay_ms: int = 1000
|
||||
simulation_mode: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class HedgeResult:
|
||||
"""Result of hedge order placement."""
|
||||
success: bool
|
||||
order_id: str = ""
|
||||
contracts: int = 0
|
||||
price: float = 0.0
|
||||
attempts: int = 0
|
||||
error: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class HedgePosition:
|
||||
"""Tracks hedge state for a position."""
|
||||
opposite_token_id: str
|
||||
contracts: int
|
||||
hedge_order_placed: bool = False
|
||||
hedge_order_id: str = ""
|
||||
hedge_contracts_filled: int = 0
|
||||
hedged: bool = False # True when fully filled
|
||||
|
||||
|
||||
class HedgeManager:
|
||||
"""
|
||||
Manages position hedging via GTD limit orders.
|
||||
|
||||
After entry is confirmed, places a GTD BUY order on the opposite token
|
||||
at hedge_price (e.g. $0.02). The order sits passively on the book.
|
||||
|
||||
When our side reaches ~$0.98, the opposite side drops to ~$0.02
|
||||
and our hedge order fills automatically — locking in profit.
|
||||
"""
|
||||
|
||||
def __init__(self, order_executor: Any, config: HedgeConfig):
|
||||
self.executor = order_executor
|
||||
self.config = config
|
||||
self._position: Optional[HedgePosition] = None
|
||||
|
||||
# Stats
|
||||
self.hedges_placed = 0
|
||||
self.hedges_filled = 0
|
||||
|
||||
def set_position(self, opposite_token_id: str, contracts: int):
|
||||
"""
|
||||
Set the position to hedge (called after entry is confirmed).
|
||||
|
||||
Args:
|
||||
opposite_token_id: Token ID of the opposite leg
|
||||
contracts: Exact number of contracts from main entry
|
||||
"""
|
||||
self._position = HedgePosition(
|
||||
opposite_token_id=opposite_token_id,
|
||||
contracts=contracts
|
||||
)
|
||||
|
||||
hedge_logger.info("=" * 50)
|
||||
hedge_logger.info("HEDGE POSITION SET")
|
||||
hedge_logger.info(f" Opposite Token: {opposite_token_id[:30]}...")
|
||||
hedge_logger.info(f" Contracts: {contracts}")
|
||||
hedge_logger.info(f" Hedge Price: ${self.config.hedge_price}")
|
||||
hedge_logger.info(f" Hedge Cost: ${contracts * self.config.hedge_price:.2f}")
|
||||
hedge_logger.info(f" Enabled: {self.config.enabled}")
|
||||
hedge_logger.info(f" Simulation: {self.config.simulation_mode}")
|
||||
hedge_logger.info("=" * 50)
|
||||
|
||||
logger.info(
|
||||
f"Hedge position set: {contracts} contracts, "
|
||||
f"will hedge @ ${self.config.hedge_price}"
|
||||
)
|
||||
|
||||
async def place_gtd_hedge(self) -> HedgeResult:
|
||||
"""
|
||||
Place GTD hedge order on the opposite token.
|
||||
|
||||
Called once after entry is confirmed. Retries up to max_retries
|
||||
only if API explicitly rejects (success=False).
|
||||
|
||||
CRITICAL: Only places ONE order. Flag hedge_order_placed prevents duplicates.
|
||||
|
||||
Returns:
|
||||
HedgeResult with placement details
|
||||
"""
|
||||
if not self.config.enabled:
|
||||
return HedgeResult(success=False, error="Hedge disabled")
|
||||
|
||||
if not self._position:
|
||||
return HedgeResult(success=False, error="No position set")
|
||||
|
||||
pos = self._position
|
||||
|
||||
# DUPLICATE PROTECTION
|
||||
if pos.hedge_order_placed:
|
||||
hedge_logger.warning("HEDGE ALREADY PLACED - skipping")
|
||||
return HedgeResult(
|
||||
success=True,
|
||||
order_id=pos.hedge_order_id,
|
||||
contracts=pos.contracts,
|
||||
price=self.config.hedge_price,
|
||||
error="Already placed"
|
||||
)
|
||||
|
||||
if self.config.simulation_mode:
|
||||
hedge_logger.info("=" * 60)
|
||||
hedge_logger.info("SIMULATION: GTD hedge (no order sent)")
|
||||
oid = "SIM-HEDGE"
|
||||
pos.hedge_order_placed = True
|
||||
pos.hedge_order_id = oid
|
||||
self.hedges_placed += 1
|
||||
hedge_logger.info(f" Order ID: {oid}")
|
||||
hedge_logger.info("=" * 60)
|
||||
logger.info(f"Simulation hedge: {pos.contracts} @ ${self.config.hedge_price}")
|
||||
return HedgeResult(
|
||||
success=True,
|
||||
order_id=oid,
|
||||
contracts=pos.contracts,
|
||||
price=self.config.hedge_price,
|
||||
attempts=1,
|
||||
)
|
||||
|
||||
hedge_logger.info("=" * 60)
|
||||
hedge_logger.info("PLACING GTD HEDGE ORDER")
|
||||
hedge_logger.info(f" Token: {pos.opposite_token_id[:30]}...")
|
||||
hedge_logger.info(f" Size: {pos.contracts} contracts")
|
||||
hedge_logger.info(f" Price: ${self.config.hedge_price}")
|
||||
hedge_logger.info(f" Cost: ${pos.contracts * self.config.hedge_price:.2f}")
|
||||
hedge_logger.info(f" Type: GTD")
|
||||
hedge_logger.info(f" Max Retries: {self.config.max_retries}")
|
||||
hedge_logger.info("-" * 40)
|
||||
|
||||
from py_clob_client.clob_types import OrderArgs, OrderType
|
||||
from py_clob_client.order_builder.constants import BUY
|
||||
|
||||
last_error = ""
|
||||
expiration = str(int(time.time()) + 3600) # 1 hour, market resolves before this
|
||||
|
||||
for attempt in range(1, self.config.max_retries + 1):
|
||||
hedge_logger.info(f"ATTEMPT {attempt}/{self.config.max_retries}")
|
||||
|
||||
try:
|
||||
# Create signed order
|
||||
signed_order = await asyncio.to_thread(
|
||||
self.executor._client.create_order,
|
||||
OrderArgs(
|
||||
price=self.config.hedge_price,
|
||||
size=pos.contracts,
|
||||
side=BUY,
|
||||
token_id=pos.opposite_token_id,
|
||||
expiration=expiration
|
||||
)
|
||||
)
|
||||
|
||||
# Post as GTD
|
||||
response = await asyncio.to_thread(
|
||||
self.executor._client.post_order,
|
||||
signed_order,
|
||||
OrderType.GTD
|
||||
)
|
||||
|
||||
# Parse response
|
||||
if isinstance(response, dict):
|
||||
success = response.get("success", False)
|
||||
order_id = response.get("orderID", "")
|
||||
status = response.get("status", "")
|
||||
error_msg = response.get("errorMsg", "")
|
||||
else:
|
||||
success = getattr(response, 'success', False)
|
||||
order_id = getattr(response, 'orderID', "")
|
||||
status = getattr(response, 'status', "")
|
||||
error_msg = getattr(response, 'errorMsg', "")
|
||||
|
||||
hedge_logger.info(f" Response: success={success}, status={status}, orderID={order_id[:30] if order_id else 'N/A'}")
|
||||
|
||||
if success and order_id:
|
||||
# ORDER PLACED SUCCESSFULLY
|
||||
pos.hedge_order_placed = True
|
||||
pos.hedge_order_id = order_id
|
||||
self.hedges_placed += 1
|
||||
|
||||
hedge_logger.info(f" ✅ GTD HEDGE ORDER PLACED")
|
||||
hedge_logger.info(f" Order ID: {order_id}")
|
||||
hedge_logger.info(f" Status: {status}")
|
||||
|
||||
logger.info(f"GTD hedge placed: {pos.contracts} @ ${self.config.hedge_price}, ID: {order_id[:20]}...")
|
||||
|
||||
return HedgeResult(
|
||||
success=True,
|
||||
order_id=order_id,
|
||||
contracts=pos.contracts,
|
||||
price=self.config.hedge_price,
|
||||
attempts=attempt
|
||||
)
|
||||
else:
|
||||
# API explicitly rejected — can retry
|
||||
last_error = error_msg or "Order rejected"
|
||||
hedge_logger.warning(f" ❌ Rejected: {last_error}")
|
||||
logger.warning(f"Hedge attempt {attempt} rejected: {last_error}")
|
||||
|
||||
if attempt < self.config.max_retries:
|
||||
await asyncio.sleep(self.config.retry_delay_ms / 1000)
|
||||
|
||||
except Exception as e:
|
||||
last_error = str(e)
|
||||
hedge_logger.error(f" ❌ Exception: {last_error}")
|
||||
logger.error(f"Hedge attempt {attempt} error: {last_error}")
|
||||
|
||||
if attempt < self.config.max_retries:
|
||||
await asyncio.sleep(self.config.retry_delay_ms / 1000)
|
||||
|
||||
# All attempts failed
|
||||
hedge_logger.error(f"HEDGE FAILED after {self.config.max_retries} attempts: {last_error}")
|
||||
logger.error(f"Hedge failed: {last_error}")
|
||||
|
||||
return HedgeResult(
|
||||
success=False,
|
||||
attempts=self.config.max_retries,
|
||||
error=last_error
|
||||
)
|
||||
|
||||
def on_hedge_fill(self, size: int, price: float):
|
||||
"""
|
||||
Called when WebSocket reports a fill on our hedge order.
|
||||
|
||||
Args:
|
||||
size: Number of contracts filled
|
||||
price: Fill price
|
||||
"""
|
||||
if not self._position:
|
||||
return
|
||||
|
||||
pos = self._position
|
||||
pos.hedge_contracts_filled += size
|
||||
|
||||
hedge_logger.info(f"HEDGE FILL: +{size} contracts @ ${price:.4f}")
|
||||
hedge_logger.info(f" Total filled: {pos.hedge_contracts_filled}/{pos.contracts}")
|
||||
|
||||
if pos.hedge_contracts_filled >= pos.contracts:
|
||||
pos.hedged = True
|
||||
self.hedges_filled += 1
|
||||
hedge_logger.info(f" ✅ FULLY HEDGED")
|
||||
logger.info(f"Position fully hedged: {pos.hedge_contracts_filled} contracts")
|
||||
else:
|
||||
logger.info(f"Hedge partial fill: {pos.hedge_contracts_filled}/{pos.contracts}")
|
||||
|
||||
@property
|
||||
def hedge_order_id(self) -> Optional[str]:
|
||||
"""Get the current hedge order ID."""
|
||||
if self._position and self._position.hedge_order_id:
|
||||
return self._position.hedge_order_id
|
||||
return None
|
||||
|
||||
@property
|
||||
def is_hedged(self) -> bool:
|
||||
"""Check if position is fully hedged."""
|
||||
return self._position.hedged if self._position else False
|
||||
|
||||
@property
|
||||
def hedge_order_placed(self) -> bool:
|
||||
"""Check if hedge order has been placed."""
|
||||
return self._position.hedge_order_placed if self._position else False
|
||||
|
||||
def clear(self):
|
||||
"""Clear hedge state (called on market change)."""
|
||||
self._position = None
|
||||
|
||||
def get_stats(self) -> Dict:
|
||||
"""Get hedge statistics."""
|
||||
pos = self._position
|
||||
return {
|
||||
"hedges_placed": self.hedges_placed,
|
||||
"hedges_filled": self.hedges_filled,
|
||||
"current_order_id": pos.hedge_order_id if pos else "",
|
||||
"current_filled": pos.hedge_contracts_filled if pos else 0,
|
||||
"current_total": pos.contracts if pos else 0,
|
||||
"is_hedged": pos.hedged if pos else False,
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Market Finder
|
||||
|
||||
Searches for active BTC 5- or 15-minute up/down markets on Polymarket.
|
||||
Features:
|
||||
- Async HTTP with retry logic
|
||||
- Caching to reduce API calls
|
||||
- Automatic market lifecycle detection
|
||||
- Robust error handling
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional, List, Dict, Any, Tuple
|
||||
|
||||
import aiohttp
|
||||
|
||||
from src.proxy_util import apply_proxy_env, aiohttp_proxy
|
||||
|
||||
apply_proxy_env()
|
||||
_PROXY_URL = aiohttp_proxy() or ""
|
||||
|
||||
logger = logging.getLogger("btc_live.market_finder")
|
||||
|
||||
GAMMA_API = "https://gamma-api.polymarket.com"
|
||||
CLOB_API = "https://clob.polymarket.com"
|
||||
|
||||
def _btc_slug_pattern(interval_minutes: int) -> re.Pattern:
|
||||
# Support multiple slug formats:
|
||||
# - btc-updown-5m-1752345600 (original)
|
||||
# - btc-up-or-down-5m-1752345600 (newer format)
|
||||
# - btc-5m-up-down-1752345600 (older)
|
||||
# - btc-updown-0726-5m-... (with date)
|
||||
return re.compile(rf"btc-.*(up|down).*{int(interval_minutes)}m.*")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Market:
|
||||
"""Represents a BTC up/down interval market (5m or 15m slug)."""
|
||||
|
||||
id: str
|
||||
slug: str
|
||||
question: str
|
||||
condition_id: str
|
||||
|
||||
# Token IDs
|
||||
up_token_id: str
|
||||
down_token_id: str
|
||||
|
||||
# Timing
|
||||
start_time: datetime
|
||||
end_time: datetime
|
||||
|
||||
# State
|
||||
active: bool = True
|
||||
closed: bool = False
|
||||
accepting_orders: bool = True
|
||||
|
||||
# Prices (updated from WebSocket)
|
||||
up_price: float = 0.5
|
||||
down_price: float = 0.5
|
||||
best_bid: float = 0.0
|
||||
best_ask: float = 0.0
|
||||
|
||||
# Metadata
|
||||
volume: float = 0.0
|
||||
liquidity: float = 0.0
|
||||
|
||||
def time_remaining_seconds(self) -> float:
|
||||
"""Get seconds until market ends."""
|
||||
now = datetime.now(timezone.utc)
|
||||
delta = self.end_time - now
|
||||
return max(0, delta.total_seconds())
|
||||
|
||||
def time_elapsed_seconds(self) -> float:
|
||||
"""Get seconds since market started."""
|
||||
now = datetime.now(timezone.utc)
|
||||
delta = now - self.start_time
|
||||
return max(0, delta.total_seconds())
|
||||
|
||||
def minutes_remaining(self) -> float:
|
||||
"""Get minutes until market ends."""
|
||||
return self.time_remaining_seconds() / 60
|
||||
|
||||
def minutes_elapsed(self) -> float:
|
||||
"""Get minutes since market started."""
|
||||
return self.time_elapsed_seconds() / 60
|
||||
|
||||
def is_tradeable(self) -> bool:
|
||||
"""Check if market is currently tradeable."""
|
||||
return (
|
||||
self.active and
|
||||
not self.closed and
|
||||
self.accepting_orders and
|
||||
self.time_remaining_seconds() > 0
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"slug": self.slug,
|
||||
"question": self.question,
|
||||
"condition_id": self.condition_id,
|
||||
"up_token_id": self.up_token_id,
|
||||
"down_token_id": self.down_token_id,
|
||||
"start_time": self.start_time.isoformat(),
|
||||
"end_time": self.end_time.isoformat(),
|
||||
"active": self.active,
|
||||
"closed": self.closed,
|
||||
"up_price": self.up_price,
|
||||
"down_price": self.down_price,
|
||||
}
|
||||
|
||||
|
||||
class MarketFinder:
|
||||
"""
|
||||
Finds and tracks BTC up/down markets for a chosen interval (5 or 15 minutes).
|
||||
|
||||
Features:
|
||||
- Async HTTP requests with exponential backoff
|
||||
- Market caching
|
||||
- Automatic refresh
|
||||
- Error recovery
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
refresh_interval: float = 30.0,
|
||||
max_retries: int = 3,
|
||||
retry_delay: float = 2.0,
|
||||
interval_minutes: int = 15,
|
||||
):
|
||||
self.refresh_interval = refresh_interval
|
||||
self.max_retries = max_retries
|
||||
self.retry_delay = retry_delay
|
||||
self.interval_minutes = int(interval_minutes) if int(interval_minutes) in (5, 15) else 15
|
||||
self._slug_pattern = _btc_slug_pattern(self.interval_minutes)
|
||||
|
||||
self._session: Optional[aiohttp.ClientSession] = None
|
||||
self._current_market: Optional[Market] = None
|
||||
self._market_history: List[str] = [] # List of processed market slugs
|
||||
self._last_refresh: Optional[datetime] = None
|
||||
self._running = False
|
||||
|
||||
# Callbacks
|
||||
self._on_new_market_callbacks: List[callable] = []
|
||||
self._on_market_end_callbacks: List[callable] = []
|
||||
|
||||
async def _get_session(self) -> aiohttp.ClientSession:
|
||||
"""Get or create HTTP session (proxy-aware via env)."""
|
||||
if self._session is None or self._session.closed:
|
||||
timeout = aiohttp.ClientTimeout(total=30, connect=10)
|
||||
self._session = aiohttp.ClientSession(timeout=timeout, trust_env=True)
|
||||
return self._session
|
||||
|
||||
async def _request_with_retry(
|
||||
self,
|
||||
url: str,
|
||||
params: Optional[Dict] = None,
|
||||
method: str = "GET"
|
||||
) -> Optional[Dict]:
|
||||
"""
|
||||
Make HTTP request with retry logic.
|
||||
|
||||
Args:
|
||||
url: Request URL
|
||||
params: Query parameters
|
||||
method: HTTP method
|
||||
|
||||
Returns:
|
||||
JSON response or None on failure
|
||||
"""
|
||||
session = await self._get_session()
|
||||
last_error = None
|
||||
|
||||
for attempt in range(self.max_retries):
|
||||
try:
|
||||
async with session.request(
|
||||
method, url, params=params, proxy=_PROXY_URL or None
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
return await resp.json()
|
||||
elif resp.status == 429:
|
||||
# Rate limited - wait longer
|
||||
wait_time = self.retry_delay * (2 ** attempt) * 2
|
||||
logger.warning(f"Rate limited, waiting {wait_time:.1f}s")
|
||||
await asyncio.sleep(wait_time)
|
||||
continue
|
||||
elif resp.status >= 500:
|
||||
# Server error - retry
|
||||
logger.warning(f"Server error {resp.status}, retrying...")
|
||||
await asyncio.sleep(self.retry_delay * (2 ** attempt))
|
||||
continue
|
||||
else:
|
||||
logger.error(f"HTTP {resp.status}: {await resp.text()}")
|
||||
return None
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(f"Request timeout (attempt {attempt + 1}/{self.max_retries})")
|
||||
last_error = "timeout"
|
||||
except aiohttp.ClientError as e:
|
||||
logger.warning(f"Client error: {e} (attempt {attempt + 1}/{self.max_retries})")
|
||||
last_error = str(e)
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error: {e}")
|
||||
last_error = str(e)
|
||||
|
||||
if attempt < self.max_retries - 1:
|
||||
await asyncio.sleep(self.retry_delay * (2 ** attempt))
|
||||
|
||||
logger.error(f"Request failed after {self.max_retries} attempts: {last_error}")
|
||||
return None
|
||||
|
||||
def _parse_market(self, data: Dict) -> Optional[Market]:
|
||||
"""
|
||||
Parse market data from Gamma API response.
|
||||
|
||||
Args:
|
||||
data: Raw market data from API
|
||||
|
||||
Returns:
|
||||
Market object or None if parsing fails
|
||||
"""
|
||||
try:
|
||||
slug = data.get("slug", "")
|
||||
|
||||
# Check if it's a BTC up/down market for our interval
|
||||
match = self._slug_pattern.match(slug)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
# Parse token IDs
|
||||
clob_token_ids = data.get("clobTokenIds", "[]")
|
||||
if isinstance(clob_token_ids, str):
|
||||
clob_token_ids = json.loads(clob_token_ids)
|
||||
|
||||
if len(clob_token_ids) < 2:
|
||||
logger.warning(f"Market {slug} has insufficient token IDs")
|
||||
return None
|
||||
|
||||
# Parse outcomes to match tokens
|
||||
outcomes = data.get("outcomes", "[]")
|
||||
if isinstance(outcomes, str):
|
||||
outcomes = json.loads(outcomes)
|
||||
|
||||
# Determine Up/Down token indices
|
||||
up_idx, down_idx = 0, 1
|
||||
for i, outcome in enumerate(outcomes):
|
||||
if outcome.lower() == "up":
|
||||
up_idx = i
|
||||
elif outcome.lower() == "down":
|
||||
down_idx = i
|
||||
|
||||
# Parse times
|
||||
end_date_str = data.get("endDate", "")
|
||||
start_time_str = data.get("eventStartTime") or data.get("startDate", "")
|
||||
|
||||
if not end_date_str:
|
||||
logger.warning(f"Market {slug} has no end date")
|
||||
return None
|
||||
|
||||
# Parse ISO dates
|
||||
end_time = datetime.fromisoformat(end_date_str.replace("Z", "+00:00"))
|
||||
|
||||
if start_time_str:
|
||||
start_time = datetime.fromisoformat(start_time_str.replace("Z", "+00:00"))
|
||||
else:
|
||||
start_time = end_time - timedelta(minutes=self.interval_minutes)
|
||||
|
||||
# Parse prices
|
||||
outcome_prices = data.get("outcomePrices", "[]")
|
||||
if isinstance(outcome_prices, str):
|
||||
outcome_prices = json.loads(outcome_prices)
|
||||
|
||||
up_price = float(outcome_prices[up_idx]) if len(outcome_prices) > up_idx else 0.5
|
||||
down_price = float(outcome_prices[down_idx]) if len(outcome_prices) > down_idx else 0.5
|
||||
|
||||
return Market(
|
||||
id=data.get("id", ""),
|
||||
slug=slug,
|
||||
question=data.get("question", ""),
|
||||
condition_id=data.get("conditionId", ""),
|
||||
up_token_id=clob_token_ids[up_idx],
|
||||
down_token_id=clob_token_ids[down_idx],
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
active=data.get("active", True),
|
||||
closed=data.get("closed", False),
|
||||
accepting_orders=data.get("acceptingOrders", True),
|
||||
up_price=up_price,
|
||||
down_price=down_price,
|
||||
best_bid=float(data.get("bestBid", 0) or 0),
|
||||
best_ask=float(data.get("bestAsk", 0) or 0),
|
||||
volume=float(data.get("volume", 0) or 0),
|
||||
liquidity=float(data.get("liquidity", 0) or 0),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing market: {e}")
|
||||
return None
|
||||
|
||||
async def find_active_market(self) -> Optional[Market]:
|
||||
"""
|
||||
Find the currently active BTC up/down market for this finder’s interval.
|
||||
|
||||
Returns:
|
||||
Active Market or None if not found
|
||||
"""
|
||||
slug_part = f"btc-updown-{self.interval_minutes}m"
|
||||
logger.debug("Searching for active %s market...", slug_part)
|
||||
|
||||
# Search Gamma API
|
||||
url = f"{GAMMA_API}/markets"
|
||||
params = {
|
||||
"slug_contains": slug_part,
|
||||
"active": "true",
|
||||
"closed": "false",
|
||||
"limit": 10,
|
||||
"order": "endDate",
|
||||
"ascending": "true"
|
||||
}
|
||||
|
||||
data = await self._request_with_retry(url, params)
|
||||
|
||||
if not data:
|
||||
logger.warning("No response from Gamma API")
|
||||
return None
|
||||
|
||||
# Handle both list and single object responses
|
||||
markets_list = data if isinstance(data, list) else [data]
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
best_market: Optional[Market] = None
|
||||
|
||||
for market_data in markets_list:
|
||||
market = self._parse_market(market_data)
|
||||
|
||||
if market is None:
|
||||
continue
|
||||
|
||||
# Skip already processed markets
|
||||
if market.slug in self._market_history:
|
||||
continue
|
||||
|
||||
# Check if market is currently tradeable
|
||||
if not market.is_tradeable():
|
||||
continue
|
||||
|
||||
# Check if market has started
|
||||
if market.start_time > now:
|
||||
continue
|
||||
|
||||
# Prefer market with most time remaining
|
||||
if best_market is None or market.time_remaining_seconds() > best_market.time_remaining_seconds():
|
||||
best_market = market
|
||||
|
||||
if best_market:
|
||||
logger.info(
|
||||
f"Found active market: {best_market.slug} "
|
||||
f"({best_market.minutes_remaining():.1f} min remaining)"
|
||||
)
|
||||
|
||||
return best_market
|
||||
|
||||
async def refresh(self) -> Optional[Market]:
|
||||
"""
|
||||
Refresh market status.
|
||||
|
||||
Checks if current market is still active, or finds a new one.
|
||||
|
||||
Returns:
|
||||
Current active market or None
|
||||
"""
|
||||
self._last_refresh = datetime.now(timezone.utc)
|
||||
|
||||
# Check if current market has ended
|
||||
if self._current_market:
|
||||
if self._current_market.time_remaining_seconds() <= 0:
|
||||
logger.info(f"Market {self._current_market.slug} has ended")
|
||||
|
||||
# Mark as processed
|
||||
self._market_history.append(self._current_market.slug)
|
||||
|
||||
# Trigger callbacks
|
||||
for callback in self._on_market_end_callbacks:
|
||||
try:
|
||||
if asyncio.iscoroutinefunction(callback):
|
||||
await callback(self._current_market)
|
||||
else:
|
||||
callback(self._current_market)
|
||||
except Exception as e:
|
||||
logger.error(f"Market end callback error: {e}")
|
||||
|
||||
self._current_market = None
|
||||
|
||||
# Find new market if needed
|
||||
if self._current_market is None:
|
||||
new_market = await self.find_active_market()
|
||||
|
||||
if new_market:
|
||||
self._current_market = new_market
|
||||
|
||||
# Trigger callbacks
|
||||
for callback in self._on_new_market_callbacks:
|
||||
try:
|
||||
if asyncio.iscoroutinefunction(callback):
|
||||
await callback(new_market)
|
||||
else:
|
||||
callback(new_market)
|
||||
except Exception as e:
|
||||
logger.error(f"New market callback error: {e}")
|
||||
|
||||
return self._current_market
|
||||
|
||||
def on_new_market(self, callback: callable):
|
||||
"""Register callback for new market discovery."""
|
||||
self._on_new_market_callbacks.append(callback)
|
||||
|
||||
def on_market_end(self, callback: callable):
|
||||
"""Register callback for market end."""
|
||||
self._on_market_end_callbacks.append(callback)
|
||||
|
||||
@property
|
||||
def current_market(self) -> Optional[Market]:
|
||||
"""Get current market."""
|
||||
return self._current_market
|
||||
|
||||
async def run_loop(self):
|
||||
"""
|
||||
Main loop - continuously searches for markets.
|
||||
"""
|
||||
self._running = True
|
||||
logger.info("Market finder started")
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
await self.refresh()
|
||||
|
||||
# Adjust sleep based on market state
|
||||
if self._current_market:
|
||||
remaining = self._current_market.time_remaining_seconds()
|
||||
|
||||
if remaining < 60:
|
||||
# Market ending soon - check frequently
|
||||
await asyncio.sleep(5)
|
||||
elif remaining < 300:
|
||||
# Less than 5 min - moderate frequency
|
||||
await asyncio.sleep(15)
|
||||
else:
|
||||
await asyncio.sleep(self.refresh_interval)
|
||||
else:
|
||||
# No active market - search more frequently
|
||||
await asyncio.sleep(10)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Market finder cancelled")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Market finder error: {e}")
|
||||
await asyncio.sleep(self.retry_delay)
|
||||
|
||||
# Cleanup
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
|
||||
logger.info("Market finder stopped")
|
||||
|
||||
def stop(self):
|
||||
"""Stop the market finder."""
|
||||
self._running = False
|
||||
|
||||
async def close(self):
|
||||
"""Close resources."""
|
||||
self.stop()
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
@@ -0,0 +1,837 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Order Executor
|
||||
|
||||
Executes FAK (Fill-And-Kill) orders with retry logic.
|
||||
|
||||
Features:
|
||||
- FAK orders for immediate fills
|
||||
- Configurable retry attempts
|
||||
- Price tracking to avoid overpaying
|
||||
- Contract counting to prevent overbuying
|
||||
- WebSocket fill monitoring
|
||||
- Detailed logging for analysis
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
import json
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any, Tuple, List
|
||||
|
||||
from py_clob_client.client import ClobClient
|
||||
from py_clob_client.clob_types import OrderArgs, ApiCreds, OrderType
|
||||
from py_clob_client.order_builder.constants import BUY
|
||||
|
||||
logger = logging.getLogger("btc_live.executor")
|
||||
|
||||
# Separate logger for detailed order tracking
|
||||
order_logger = logging.getLogger("btc_live.orders")
|
||||
order_logger.setLevel(logging.DEBUG)
|
||||
|
||||
# Polymarket minimums
|
||||
MIN_ORDER_USD = 1.0
|
||||
MIN_CONTRACTS = 5
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrderResult:
|
||||
"""Result of an order execution attempt."""
|
||||
success: bool
|
||||
order_id: str = ""
|
||||
contracts_filled: int = 0
|
||||
avg_price: float = 0.0
|
||||
total_cost: float = 0.0
|
||||
attempts: int = 0
|
||||
error: str = ""
|
||||
timestamp: datetime = field(default_factory=datetime.now)
|
||||
was_timeout: bool = False # True если был сетевой таймаут (status unknown)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExecutionConfig:
|
||||
"""Configuration for order execution."""
|
||||
bet_amount_usd: float = 10.0
|
||||
price_offset: float = 0.01
|
||||
max_retries: int = 5
|
||||
retry_delay_ms: int = 300
|
||||
fill_timeout_ms: int = 2000
|
||||
min_contracts: int = 5
|
||||
min_order_usd: float = 1.0
|
||||
max_entry_price: float = 0.91
|
||||
|
||||
|
||||
class OrderExecutor:
|
||||
"""
|
||||
Executes orders with retry logic.
|
||||
|
||||
Flow:
|
||||
1. Get best BID price
|
||||
2. Place FAK order at BID + offset
|
||||
3. Wait for fill via WebSocket
|
||||
4. If partial/unfilled, retry with updated price
|
||||
5. Track total contracts to prevent overbuying
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
private_key: str,
|
||||
api_key: str,
|
||||
api_secret: str,
|
||||
api_passphrase: str,
|
||||
clob_host: str = "https://clob.polymarket.com",
|
||||
chain_id: int = 137,
|
||||
signature_type: int = 0,
|
||||
funder_address: Optional[str] = None,
|
||||
user_ws: Optional[Any] = None, # UserWebSocket for fill tracking
|
||||
simulation_mode: bool = False,
|
||||
):
|
||||
self.private_key = private_key
|
||||
self.api_key = api_key
|
||||
self.api_secret = api_secret
|
||||
self.api_passphrase = api_passphrase
|
||||
self.clob_host = clob_host
|
||||
self.chain_id = chain_id
|
||||
self.signature_type = signature_type
|
||||
self.funder_address = funder_address
|
||||
self.user_ws = user_ws
|
||||
self.simulation_mode = simulation_mode
|
||||
|
||||
# Initialize client
|
||||
self._client: Optional[ClobClient] = None
|
||||
self._initialized = False
|
||||
|
||||
# Stats
|
||||
self.orders_placed = 0
|
||||
self.orders_filled = 0
|
||||
self.total_contracts = 0
|
||||
self.total_spent = 0.0
|
||||
|
||||
async def initialize(self) -> bool:
|
||||
"""Initialize the CLOB client."""
|
||||
try:
|
||||
logger.info("Initializing CLOB client...")
|
||||
order_logger.info("=" * 60)
|
||||
order_logger.info("CLOB CLIENT INITIALIZATION")
|
||||
order_logger.info(f" Host: {self.clob_host}")
|
||||
order_logger.info(f" Chain ID: {self.chain_id}")
|
||||
order_logger.info(f" Signature Type: {self.signature_type}")
|
||||
order_logger.info(f" Funder Address: {self.funder_address}")
|
||||
order_logger.info(f" API Key: {self.api_key[:8]}...")
|
||||
|
||||
self._client = ClobClient(
|
||||
host=self.clob_host,
|
||||
key=self.private_key,
|
||||
chain_id=self.chain_id,
|
||||
signature_type=self.signature_type,
|
||||
funder=self.funder_address
|
||||
)
|
||||
|
||||
# Set API credentials
|
||||
api_creds = ApiCreds(
|
||||
api_key=self.api_key,
|
||||
api_secret=self.api_secret,
|
||||
api_passphrase=self.api_passphrase
|
||||
)
|
||||
self._client.set_api_creds(api_creds)
|
||||
|
||||
self._initialized = True
|
||||
logger.info("CLOB client initialized")
|
||||
order_logger.info("CLOB CLIENT INITIALIZED SUCCESSFULLY")
|
||||
order_logger.info("=" * 60)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"CLOB client init error: {e}")
|
||||
order_logger.error(f"CLOB CLIENT INIT FAILED: {e}")
|
||||
return False
|
||||
|
||||
def _calculate_contracts(self, amount_usd: float, price: float) -> int:
|
||||
"""
|
||||
Calculate number of contracts for given amount.
|
||||
|
||||
Args:
|
||||
amount_usd: Amount in USD to spend
|
||||
price: Price per contract
|
||||
|
||||
Returns:
|
||||
Number of contracts (minimum MIN_CONTRACTS)
|
||||
"""
|
||||
if price <= 0:
|
||||
return MIN_CONTRACTS
|
||||
|
||||
contracts = int(amount_usd / price)
|
||||
return max(contracts, MIN_CONTRACTS)
|
||||
|
||||
def _validate_order_size(self, contracts: int, price: float) -> Tuple[int, bool]:
|
||||
"""
|
||||
Validate and adjust order size to meet minimums.
|
||||
|
||||
Args:
|
||||
contracts: Desired number of contracts
|
||||
price: Price per contract
|
||||
|
||||
Returns:
|
||||
Tuple of (adjusted_contracts, is_valid)
|
||||
"""
|
||||
order_value = contracts * price
|
||||
|
||||
# Must be at least MIN_CONTRACTS
|
||||
if contracts < MIN_CONTRACTS:
|
||||
contracts = MIN_CONTRACTS
|
||||
|
||||
# Must be at least MIN_ORDER_USD
|
||||
if order_value < MIN_ORDER_USD:
|
||||
contracts = math.ceil(MIN_ORDER_USD / price)
|
||||
|
||||
return contracts, True
|
||||
|
||||
def _simulate_fill(
|
||||
self,
|
||||
config: ExecutionConfig,
|
||||
websocket_price: float,
|
||||
) -> OrderResult:
|
||||
"""
|
||||
Instant hypothetical fill at limit (WS ask + offset), same sizing rules as live.
|
||||
"""
|
||||
initial_price = websocket_price
|
||||
order_price = initial_price + config.price_offset
|
||||
if order_price > config.max_entry_price:
|
||||
order_logger.warning(
|
||||
f"SIMULATION: price {order_price:.4f} > max_entry {config.max_entry_price:.4f}"
|
||||
)
|
||||
return OrderResult(success=False, error="Price exceeded max entry")
|
||||
|
||||
contracts_needed = self._calculate_contracts(config.bet_amount_usd, initial_price)
|
||||
order_size, _ = self._validate_order_size(contracts_needed, order_price)
|
||||
total_cost = order_size * order_price
|
||||
oid = f"SIM-{uuid.uuid4().hex[:12]}"
|
||||
order_logger.info("=" * 60)
|
||||
order_logger.info("SIMULATION ENTRY (no CLOB order sent)")
|
||||
order_logger.info(f" Hypothetical fill: {order_size} @ {order_price:.4f} cost ${total_cost:.2f}")
|
||||
order_logger.info(f" Order ID: {oid}")
|
||||
order_logger.info("=" * 60)
|
||||
logger.info(f"Simulation fill: {order_size} contracts @ {order_price:.4f}")
|
||||
return OrderResult(
|
||||
success=True,
|
||||
order_id=oid,
|
||||
contracts_filled=order_size,
|
||||
avg_price=order_price,
|
||||
total_cost=total_cost,
|
||||
attempts=1,
|
||||
error="",
|
||||
)
|
||||
|
||||
async def get_best_bid(self, token_id: str) -> Optional[float]:
|
||||
"""
|
||||
Get best BID price for token.
|
||||
|
||||
Args:
|
||||
token_id: Token to get price for
|
||||
|
||||
Returns:
|
||||
Best bid price or None
|
||||
"""
|
||||
if not self._client:
|
||||
order_logger.warning("get_best_bid: Client not initialized")
|
||||
return None
|
||||
|
||||
start_time = time.time()
|
||||
try:
|
||||
# Use CLOB API to get orderbook
|
||||
book = await asyncio.to_thread(
|
||||
self._client.get_order_book,
|
||||
token_id
|
||||
)
|
||||
|
||||
elapsed = (time.time() - start_time) * 1000
|
||||
|
||||
# Handle OrderBookSummary object from py-clob-client
|
||||
bids = None
|
||||
|
||||
# Try object attribute access first
|
||||
if hasattr(book, 'bids'):
|
||||
bids = book.bids
|
||||
# Try dict-like access
|
||||
elif isinstance(book, dict):
|
||||
bids = book.get("bids", [])
|
||||
|
||||
# Convert bids to list if needed
|
||||
if bids is None:
|
||||
bids = []
|
||||
|
||||
if bids:
|
||||
# Handle OrderSummary objects or dicts
|
||||
first_bid = bids[0]
|
||||
if hasattr(first_bid, 'price'):
|
||||
best_bid = float(first_bid.price)
|
||||
elif isinstance(first_bid, dict):
|
||||
best_bid = float(first_bid.get("price", 0))
|
||||
else:
|
||||
# Try direct conversion
|
||||
best_bid = float(first_bid)
|
||||
|
||||
order_logger.debug(
|
||||
f"ORDERBOOK: token={token_id[:20]}... | "
|
||||
f"best_bid={best_bid:.4f} | bids={len(bids)} | "
|
||||
f"latency={elapsed:.0f}ms"
|
||||
)
|
||||
return best_bid
|
||||
|
||||
order_logger.warning(f"ORDERBOOK: No bids for token={token_id[:20]}...")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting best bid: {e}")
|
||||
order_logger.error(f"ORDERBOOK ERROR: {e} | book_type={type(book).__name__}")
|
||||
return None
|
||||
|
||||
async def get_best_ask(self, token_id: str) -> Optional[float]:
|
||||
"""
|
||||
Get best ASK price for token.
|
||||
|
||||
Args:
|
||||
token_id: Token to get price for
|
||||
|
||||
Returns:
|
||||
Best ask price or None
|
||||
"""
|
||||
if not self._client:
|
||||
order_logger.warning("get_best_ask: Client not initialized")
|
||||
return None
|
||||
|
||||
start_time = time.time()
|
||||
try:
|
||||
# Use CLOB API to get orderbook
|
||||
book = await asyncio.to_thread(
|
||||
self._client.get_order_book,
|
||||
token_id
|
||||
)
|
||||
|
||||
elapsed = (time.time() - start_time) * 1000
|
||||
|
||||
# Handle OrderBookSummary object from py-clob-client
|
||||
asks = None
|
||||
|
||||
# Try object attribute access first
|
||||
if hasattr(book, 'asks'):
|
||||
asks = book.asks
|
||||
# Try dict-like access
|
||||
elif isinstance(book, dict):
|
||||
asks = book.get("asks", [])
|
||||
|
||||
# Convert asks to list if needed
|
||||
if asks is None:
|
||||
asks = []
|
||||
|
||||
if asks:
|
||||
# Handle OrderSummary objects or dicts
|
||||
first_ask = asks[0]
|
||||
if hasattr(first_ask, 'price'):
|
||||
best_ask = float(first_ask.price)
|
||||
elif isinstance(first_ask, dict):
|
||||
best_ask = float(first_ask.get("price", 0))
|
||||
else:
|
||||
# Try direct conversion
|
||||
best_ask = float(first_ask)
|
||||
|
||||
order_logger.debug(
|
||||
f"ORDERBOOK ASK: token={token_id[:20]}... | "
|
||||
f"best_ask={best_ask:.4f} | asks={len(asks)} | "
|
||||
f"latency={elapsed:.0f}ms"
|
||||
)
|
||||
return best_ask
|
||||
|
||||
order_logger.warning(f"ORDERBOOK: No asks for token={token_id[:20]}...")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting best ask: {e}")
|
||||
order_logger.error(f"ORDERBOOK ASK ERROR: {e} | book_type={type(book).__name__}")
|
||||
return None
|
||||
|
||||
async def place_fak_order(
|
||||
self,
|
||||
token_id: str,
|
||||
price: float,
|
||||
size: int
|
||||
) -> Tuple[bool, str, Dict]:
|
||||
"""
|
||||
Place a FAK (Fill-And-Kill) order.
|
||||
|
||||
Args:
|
||||
token_id: Token to buy
|
||||
price: Order price
|
||||
size: Number of contracts
|
||||
|
||||
Returns:
|
||||
Tuple of (success, order_id, response)
|
||||
"""
|
||||
if not self._client:
|
||||
order_logger.error("PLACE_ORDER: Client not initialized")
|
||||
return False, "", {"error": "Client not initialized"}
|
||||
|
||||
order_value = size * price
|
||||
order_logger.info("-" * 50)
|
||||
order_logger.info(f"PLACING ORDER")
|
||||
order_logger.info(f" Token: {token_id[:30]}...")
|
||||
order_logger.info(f" Side: BUY")
|
||||
order_logger.info(f" Price: {price:.4f}")
|
||||
order_logger.info(f" Size: {size} contracts")
|
||||
order_logger.info(f" Value: ${order_value:.2f}")
|
||||
order_logger.info(f" Type: FAK (Fill-And-Kill)")
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# Create order
|
||||
sign_start = time.time()
|
||||
signed_order = await asyncio.to_thread(
|
||||
self._client.create_order,
|
||||
OrderArgs(
|
||||
price=price,
|
||||
size=size,
|
||||
side=BUY,
|
||||
token_id=token_id
|
||||
)
|
||||
)
|
||||
sign_elapsed = (time.time() - sign_start) * 1000
|
||||
order_logger.debug(f" Order signed in {sign_elapsed:.0f}ms")
|
||||
|
||||
# Post FAK order (Fill-And-Kill: fill what you can, cancel rest)
|
||||
post_start = time.time()
|
||||
response = await asyncio.to_thread(
|
||||
self._client.post_order,
|
||||
signed_order,
|
||||
OrderType.FAK
|
||||
)
|
||||
post_elapsed = (time.time() - post_start) * 1000
|
||||
total_elapsed = (time.time() - start_time) * 1000
|
||||
|
||||
# Handle response
|
||||
if isinstance(response, dict):
|
||||
success = response.get("success", False)
|
||||
order_id = response.get("orderID", "")
|
||||
status = response.get("status", "")
|
||||
error_msg = response.get("errorMsg", "")
|
||||
taking_amount = response.get("takingAmount", "")
|
||||
making_amount = response.get("makingAmount", "")
|
||||
else:
|
||||
success = getattr(response, 'success', False)
|
||||
order_id = getattr(response, 'orderID', "")
|
||||
status = getattr(response, 'status', "")
|
||||
error_msg = getattr(response, 'errorMsg', "")
|
||||
taking_amount = getattr(response, 'takingAmount', "")
|
||||
making_amount = getattr(response, 'makingAmount', "")
|
||||
|
||||
self.orders_placed += 1
|
||||
|
||||
order_logger.info(f"ORDER RESPONSE:")
|
||||
order_logger.info(f" Success: {success}")
|
||||
order_logger.info(f" Order ID: {order_id[:40] if order_id else 'N/A'}...")
|
||||
order_logger.info(f" Status: {status}")
|
||||
if taking_amount:
|
||||
order_logger.info(f" Taking Amount: {taking_amount}")
|
||||
if making_amount:
|
||||
order_logger.info(f" Making Amount: {making_amount}")
|
||||
if error_msg:
|
||||
order_logger.warning(f" Error: {error_msg}")
|
||||
order_logger.info(f" Latency: sign={sign_elapsed:.0f}ms, post={post_elapsed:.0f}ms, total={total_elapsed:.0f}ms")
|
||||
order_logger.info("-" * 50)
|
||||
|
||||
logger.info(f"Order placed: {success}, ID: {order_id[:20] if order_id else 'N/A'}...")
|
||||
|
||||
return success, order_id, response if isinstance(response, dict) else {"success": success, "orderID": order_id, "status": status}
|
||||
|
||||
except Exception as e:
|
||||
elapsed = (time.time() - start_time) * 1000
|
||||
logger.error(f"Order placement error: {e}")
|
||||
order_logger.error(f"ORDER FAILED: {e}")
|
||||
order_logger.error(f" Elapsed: {elapsed:.0f}ms")
|
||||
order_logger.info("-" * 50)
|
||||
return False, "", {"error": str(e)}
|
||||
|
||||
async def cancel_order(self, order_id: str) -> bool:
|
||||
"""Cancel an order."""
|
||||
if not self._client:
|
||||
return False
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
self._client.cancel,
|
||||
order_id
|
||||
)
|
||||
logger.info(f"Order cancelled: {order_id[:20]}...")
|
||||
order_logger.info(f" ✅ Cancelled order: {order_id[:30]}...")
|
||||
return True
|
||||
except Exception as e:
|
||||
# Ордер мог уже исполниться или не существует - это нормально
|
||||
logger.debug(f"Cancel order note: {e}")
|
||||
order_logger.debug(f" Cancel note: {e}")
|
||||
return False
|
||||
|
||||
async def cancel_orders(self, order_ids: List[str]) -> Dict[str, bool]:
|
||||
"""
|
||||
Cancel multiple orders.
|
||||
Returns dict of order_id -> cancelled (True/False)
|
||||
"""
|
||||
if not self._client or not order_ids:
|
||||
return {}
|
||||
|
||||
results = {}
|
||||
order_logger.info(f" Cancelling {len(order_ids)} previous order(s)...")
|
||||
|
||||
try:
|
||||
# Используем batch cancel если доступен
|
||||
resp = await asyncio.to_thread(
|
||||
self._client.cancel_orders,
|
||||
order_ids
|
||||
)
|
||||
|
||||
# Парсим ответ
|
||||
cancelled = resp.get('canceled', []) if isinstance(resp, dict) else []
|
||||
not_cancelled = resp.get('not_canceled', {}) if isinstance(resp, dict) else {}
|
||||
|
||||
for oid in order_ids:
|
||||
if oid in cancelled:
|
||||
results[oid] = True
|
||||
order_logger.info(f" ✅ Cancelled: {oid[:25]}...")
|
||||
else:
|
||||
results[oid] = False
|
||||
reason = not_cancelled.get(oid, "unknown/already filled")
|
||||
order_logger.info(f" ⚠️ Not cancelled: {oid[:25]}... ({reason})")
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Batch cancel error: {e}")
|
||||
order_logger.warning(f" Batch cancel failed: {e}, trying individual cancels...")
|
||||
|
||||
# Fallback: отменяем по одному
|
||||
for oid in order_ids:
|
||||
results[oid] = await self.cancel_order(oid)
|
||||
|
||||
return results
|
||||
|
||||
async def get_order_fills(self, order_id: str) -> int:
|
||||
"""
|
||||
Check how many contracts were filled for an order.
|
||||
Returns number of contracts filled (0 if not filled or error).
|
||||
"""
|
||||
if not self._client:
|
||||
return 0
|
||||
|
||||
try:
|
||||
# Пробуем получить ордер через API
|
||||
order = await asyncio.to_thread(
|
||||
self._client.get_order,
|
||||
order_id
|
||||
)
|
||||
|
||||
if order:
|
||||
size_matched = getattr(order, 'size_matched', None) or order.get('size_matched', 0)
|
||||
filled = int(float(size_matched)) if size_matched else 0
|
||||
order_logger.info(f" Order {order_id[:20]}... filled: {filled} contracts")
|
||||
return filled
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Get order fills error: {e}")
|
||||
order_logger.debug(f" Could not get fills for {order_id[:20]}...: {e}")
|
||||
|
||||
return 0
|
||||
|
||||
async def wait_for_fill(
|
||||
self,
|
||||
order_id: str,
|
||||
timeout_ms: int = 2000
|
||||
) -> Tuple[int, float]:
|
||||
"""
|
||||
Wait for order fill via WebSocket.
|
||||
|
||||
Args:
|
||||
order_id: Order to wait for
|
||||
timeout_ms: Timeout in milliseconds
|
||||
|
||||
Returns:
|
||||
Tuple of (contracts_filled, avg_price)
|
||||
"""
|
||||
if self.user_ws:
|
||||
try:
|
||||
order = await self.user_ws.wait_for_fill(
|
||||
order_id,
|
||||
timeout=timeout_ms / 1000
|
||||
)
|
||||
|
||||
if order:
|
||||
filled = int(order.size_matched)
|
||||
price = order.price
|
||||
return filled, price
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Wait for fill error: {e}")
|
||||
|
||||
# Fallback - assume order didn't fill
|
||||
return 0, 0.0
|
||||
|
||||
async def execute_entry(
|
||||
self,
|
||||
token_id: str,
|
||||
config: ExecutionConfig,
|
||||
websocket_price: Optional[float] = None
|
||||
) -> OrderResult:
|
||||
"""
|
||||
Execute entry order with retry logic.
|
||||
|
||||
Args:
|
||||
token_id: Token to buy
|
||||
config: Execution configuration
|
||||
websocket_price: Current price from WebSocket (ASK for buying)
|
||||
|
||||
Returns:
|
||||
OrderResult with execution details
|
||||
"""
|
||||
if self.simulation_mode:
|
||||
if not websocket_price:
|
||||
return OrderResult(success=False, error="Could not get price")
|
||||
return self._simulate_fill(config, websocket_price)
|
||||
|
||||
entry_start = time.time()
|
||||
|
||||
order_logger.info("=" * 60)
|
||||
order_logger.info("ENTRY EXECUTION STARTED")
|
||||
order_logger.info(f" Timestamp: {datetime.now().isoformat()}")
|
||||
order_logger.info(f" Token: {token_id[:40]}...")
|
||||
order_logger.info(f" Budget: ${config.bet_amount_usd}")
|
||||
order_logger.info(f" Price Offset: {config.price_offset}")
|
||||
order_logger.info(f" Max Retries: {config.max_retries}")
|
||||
order_logger.info(f" Max Entry Price: {config.max_entry_price}")
|
||||
order_logger.info(f" WebSocket Price: {websocket_price}")
|
||||
|
||||
if not self._initialized:
|
||||
order_logger.warning(" Client not initialized, initializing...")
|
||||
if not await self.initialize():
|
||||
order_logger.error("ENTRY FAILED: Could not initialize client")
|
||||
return OrderResult(success=False, error="Failed to initialize")
|
||||
|
||||
# Calculate contracts needed - use WebSocket price
|
||||
initial_price = websocket_price
|
||||
if not initial_price:
|
||||
order_logger.error("ENTRY FAILED: Could not get initial price")
|
||||
return OrderResult(success=False, error="Could not get price")
|
||||
|
||||
contracts_needed = self._calculate_contracts(config.bet_amount_usd, initial_price)
|
||||
contracts_bought = 0
|
||||
total_cost = 0.0
|
||||
attempt = 0
|
||||
last_error = ""
|
||||
fills_log = []
|
||||
|
||||
order_logger.info(f" Initial Price: {initial_price:.4f}")
|
||||
order_logger.info(f" Contracts Needed: {contracts_needed}")
|
||||
order_logger.info(f" Estimated Cost: ${contracts_needed * initial_price:.2f}")
|
||||
order_logger.info("-" * 40)
|
||||
|
||||
logger.info(
|
||||
f"Starting entry: need {contracts_needed} contracts, "
|
||||
f"budget ${config.bet_amount_usd}"
|
||||
)
|
||||
|
||||
# Calculate order price once
|
||||
order_price = initial_price + config.price_offset
|
||||
|
||||
# Check max price limit
|
||||
if order_price > config.max_entry_price:
|
||||
order_logger.warning(
|
||||
f" PRICE LIMIT: {order_price:.4f} > max {config.max_entry_price:.4f}"
|
||||
)
|
||||
return OrderResult(success=False, error="Price exceeded max entry")
|
||||
|
||||
order_logger.info(f" Order Price: {order_price:.4f} (price + {config.price_offset})")
|
||||
|
||||
# Список всех размещённых order_id для отслеживания через WebSocket
|
||||
placed_order_ids = []
|
||||
|
||||
while contracts_bought < contracts_needed and attempt < config.max_retries:
|
||||
attempt += 1
|
||||
attempt_start = time.time()
|
||||
|
||||
order_logger.info(f"ATTEMPT {attempt}/{config.max_retries}")
|
||||
|
||||
# Рассчитываем ОСТАВШЕЕСЯ количество контрактов
|
||||
# (FAK ордера сразу возвращают takingAmount, так что contracts_bought уже актуален)
|
||||
remaining = contracts_needed - contracts_bought
|
||||
|
||||
# Если уже купили достаточно - выходим
|
||||
if remaining <= 0:
|
||||
order_logger.info(f" ✅ Already filled {contracts_bought}/{contracts_needed} - no retry needed")
|
||||
break
|
||||
|
||||
order_size, _ = self._validate_order_size(remaining, order_price)
|
||||
|
||||
order_logger.info(f" Contracts bought so far: {contracts_bought}")
|
||||
order_logger.info(f" Remaining needed: {remaining}")
|
||||
order_logger.info(f" Order size: {order_size}")
|
||||
|
||||
logger.info(f"Attempt {attempt}: placing {order_size} contracts @ {order_price:.2f}")
|
||||
|
||||
# Place order
|
||||
success, order_id, response = await self.place_fak_order(
|
||||
token_id,
|
||||
order_price,
|
||||
order_size
|
||||
)
|
||||
|
||||
# Запоминаем order_id для отслеживания
|
||||
if order_id:
|
||||
placed_order_ids.append(order_id)
|
||||
order_logger.info(f" Order ID: {order_id[:30]}...")
|
||||
|
||||
# ============================================================
|
||||
# ЖЕЛЕЗНОЕ ПРАВИЛО: Если не знаем исполнился ли ордер - STOP
|
||||
# Retry ТОЛЬКО если точно знаем результат из API ответа
|
||||
# ============================================================
|
||||
|
||||
if not success:
|
||||
error_msg = response.get("errorMsg", "") or response.get("error", "")
|
||||
|
||||
# ============================================================
|
||||
# ЖЕЛЕЗНОЕ ПРАВИЛО v2: Определяем ТОЧНО был ли таймаут
|
||||
# status_code=None в ошибке = сетевой таймаут = НЕ ЗНАЕМ РЕЗУЛЬТАТ
|
||||
# status_code=400/etc = API ответил чётко = ордер НЕ исполнился
|
||||
# ============================================================
|
||||
|
||||
is_network_timeout = False
|
||||
|
||||
# Проверяем status_code в ошибке PolyApiException
|
||||
if "status_code=None" in error_msg:
|
||||
is_network_timeout = True
|
||||
elif "Request exception" in error_msg and "status_code" not in error_msg:
|
||||
is_network_timeout = True
|
||||
elif "timed out" in error_msg.lower() and "status_code=4" not in error_msg:
|
||||
is_network_timeout = True
|
||||
|
||||
if is_network_timeout:
|
||||
last_error = f"🛑 STOP: Network timeout (status_code=None) - order status UNKNOWN. No retry."
|
||||
order_logger.error(f" {last_error}")
|
||||
logger.error(last_error)
|
||||
|
||||
entry_elapsed = (time.time() - entry_start) * 1000
|
||||
order_logger.info(f" Total execution time: {entry_elapsed:.0f}ms")
|
||||
order_logger.info("=" * 60)
|
||||
order_logger.info("ENTRY EXECUTION COMPLETE (TIMEOUT)")
|
||||
order_logger.info(f" Success: False")
|
||||
order_logger.info(f" Contracts Filled: {contracts_bought}/{contracts_needed}")
|
||||
order_logger.info(f" Error: {last_error}")
|
||||
order_logger.info(f" Fills: {json.dumps(fills_log)}")
|
||||
order_logger.info("=" * 60)
|
||||
|
||||
# Возвращаем с флагом таймаута - main.py должен заблокировать повторные попытки!
|
||||
return OrderResult(
|
||||
success=False,
|
||||
contracts_filled=contracts_bought,
|
||||
avg_price=total_cost / contracts_bought if contracts_bought > 0 else 0,
|
||||
total_cost=total_cost,
|
||||
attempts=attempt,
|
||||
error=last_error,
|
||||
was_timeout=True # КРИТИЧНО: флаг таймаута для main.py
|
||||
)
|
||||
|
||||
# Чёткий отказ API (status_code=400, etc) = ордер НЕ исполнился = можно retry
|
||||
last_error = error_msg or "Order failed"
|
||||
order_logger.warning(f" Order rejected (API): {last_error}")
|
||||
await asyncio.sleep(config.retry_delay_ms / 1000)
|
||||
continue
|
||||
|
||||
# API ответил успешно - знаем точный результат
|
||||
status = response.get("status", "")
|
||||
|
||||
if status == "matched":
|
||||
# Ордер исполнен - берём количество из ответа
|
||||
api_taking = response.get("takingAmount", "")
|
||||
filled = int(float(api_taking)) if api_taking else 0
|
||||
|
||||
if filled > order_size:
|
||||
order_logger.warning(f" ⚠️ OVERFILL: got {filled}, ordered {order_size}")
|
||||
logger.warning(f"Entry overfill: {filled} > {order_size}")
|
||||
|
||||
fill_price = order_price
|
||||
|
||||
contracts_bought += filled
|
||||
total_cost += filled * fill_price
|
||||
self.orders_filled += 1
|
||||
self.total_contracts += filled
|
||||
self.total_spent += filled * fill_price
|
||||
|
||||
fills_log.append({
|
||||
"attempt": attempt,
|
||||
"filled": filled,
|
||||
"price": fill_price,
|
||||
"order_id": order_id[:20] if order_id else "N/A",
|
||||
"source": "api_response",
|
||||
"timestamp": datetime.now().isoformat()
|
||||
})
|
||||
|
||||
order_logger.info(f" ✅ FILLED: {filled} contracts @ {fill_price:.4f}")
|
||||
order_logger.info(f" Progress: {contracts_bought}/{contracts_needed} ({contracts_bought/contracts_needed*100:.1f}%)")
|
||||
|
||||
logger.info(f"Filled: {filled} @ {fill_price:.2f} (total: {contracts_bought}/{contracts_needed})")
|
||||
else:
|
||||
order_logger.info(f" Status: {status} (not matched)")
|
||||
logger.info("No fill at this price level")
|
||||
|
||||
attempt_elapsed = (time.time() - attempt_start) * 1000
|
||||
order_logger.info(f" Attempt time: {attempt_elapsed:.0f}ms")
|
||||
|
||||
# Короткая пауза перед следующей попыткой
|
||||
if contracts_bought < contracts_needed:
|
||||
await asyncio.sleep(config.retry_delay_ms / 1000)
|
||||
|
||||
entry_elapsed = (time.time() - entry_start) * 1000
|
||||
order_logger.info(f" Total execution time: {entry_elapsed:.0f}ms")
|
||||
|
||||
# Calculate result
|
||||
avg_price = total_cost / contracts_bought if contracts_bought > 0 else 0
|
||||
entry_elapsed = (time.time() - entry_start) * 1000
|
||||
|
||||
result = OrderResult(
|
||||
success=contracts_bought > 0,
|
||||
contracts_filled=contracts_bought,
|
||||
avg_price=avg_price,
|
||||
total_cost=total_cost,
|
||||
attempts=attempt,
|
||||
error=last_error if contracts_bought == 0 else ""
|
||||
)
|
||||
|
||||
order_logger.info("=" * 60)
|
||||
order_logger.info("ENTRY EXECUTION COMPLETE")
|
||||
order_logger.info(f" Success: {result.success}")
|
||||
order_logger.info(f" Contracts Filled: {result.contracts_filled}/{contracts_needed}")
|
||||
order_logger.info(f" Average Price: {result.avg_price:.4f}")
|
||||
order_logger.info(f" Total Cost: ${result.total_cost:.2f}")
|
||||
order_logger.info(f" Attempts: {result.attempts}")
|
||||
order_logger.info(f" Total Time: {entry_elapsed:.0f}ms")
|
||||
if result.error:
|
||||
order_logger.info(f" Error: {result.error}")
|
||||
order_logger.info(f" Fills: {json.dumps(fills_log)}")
|
||||
order_logger.info("=" * 60)
|
||||
|
||||
logger.info(
|
||||
f"Entry complete: {result.contracts_filled} contracts, "
|
||||
f"${result.total_cost:.2f}, {result.attempts} attempts"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def get_stats(self) -> Dict:
|
||||
"""Get executor statistics."""
|
||||
return {
|
||||
"orders_placed": self.orders_placed,
|
||||
"orders_filled": self.orders_filled,
|
||||
"total_contracts": self.total_contracts,
|
||||
"total_spent": self.total_spent,
|
||||
"avg_price": self.total_spent / self.total_contracts if self.total_contracts > 0 else 0
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Position Tracker
|
||||
|
||||
Tracks all positions and calculates P&L.
|
||||
|
||||
Features:
|
||||
- Trade history logging
|
||||
- P&L calculation
|
||||
- Win/loss statistics
|
||||
- Equity curve tracking
|
||||
- Persistent state
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, List, Any
|
||||
|
||||
logger = logging.getLogger("btc_live.tracker")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Trade:
|
||||
"""Represents a completed trade."""
|
||||
id: str
|
||||
market_slug: str
|
||||
side: str # "UP" or "DOWN"
|
||||
|
||||
# Entry
|
||||
entry_price: float
|
||||
entry_contracts: int
|
||||
entry_cost: float
|
||||
entry_time: datetime
|
||||
|
||||
# Hedge (optional)
|
||||
hedged: bool = False
|
||||
hedge_contracts: int = 0
|
||||
hedge_price: float = 0.0
|
||||
hedge_cost: float = 0.0
|
||||
|
||||
# Exit
|
||||
winner: str = "" # "UP" or "DOWN"
|
||||
exit_time: Optional[datetime] = None
|
||||
|
||||
# P&L
|
||||
pnl: float = 0.0
|
||||
pnl_pct: float = 0.0
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"market_slug": self.market_slug,
|
||||
"side": self.side,
|
||||
"entry_price": self.entry_price,
|
||||
"entry_contracts": self.entry_contracts,
|
||||
"entry_cost": self.entry_cost,
|
||||
"entry_time": self.entry_time.isoformat() if self.entry_time else None,
|
||||
"hedged": self.hedged,
|
||||
"hedge_contracts": self.hedge_contracts,
|
||||
"hedge_price": self.hedge_price,
|
||||
"hedge_cost": self.hedge_cost,
|
||||
"winner": self.winner,
|
||||
"exit_time": self.exit_time.isoformat() if self.exit_time else None,
|
||||
"pnl": self.pnl,
|
||||
"pnl_pct": self.pnl_pct
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict) -> "Trade":
|
||||
entry_time = data.get("entry_time")
|
||||
if entry_time and isinstance(entry_time, str):
|
||||
entry_time = datetime.fromisoformat(entry_time)
|
||||
|
||||
exit_time = data.get("exit_time")
|
||||
if exit_time and isinstance(exit_time, str):
|
||||
exit_time = datetime.fromisoformat(exit_time)
|
||||
|
||||
return cls(
|
||||
id=data.get("id", ""),
|
||||
market_slug=data.get("market_slug", ""),
|
||||
side=data.get("side", ""),
|
||||
entry_price=data.get("entry_price", 0),
|
||||
entry_contracts=data.get("entry_contracts", 0),
|
||||
entry_cost=data.get("entry_cost", 0),
|
||||
entry_time=entry_time or datetime.now(),
|
||||
hedged=data.get("hedged", False),
|
||||
hedge_contracts=data.get("hedge_contracts", 0),
|
||||
hedge_price=data.get("hedge_price", 0),
|
||||
hedge_cost=data.get("hedge_cost", 0),
|
||||
winner=data.get("winner", ""),
|
||||
exit_time=exit_time,
|
||||
pnl=data.get("pnl", 0),
|
||||
pnl_pct=data.get("pnl_pct", 0)
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Stats:
|
||||
"""Trading statistics."""
|
||||
total_trades: int = 0
|
||||
wins: int = 0
|
||||
losses: int = 0
|
||||
total_pnl: float = 0.0
|
||||
max_drawdown: float = 0.0
|
||||
win_rate: float = 0.0
|
||||
avg_win: float = 0.0
|
||||
avg_loss: float = 0.0
|
||||
profit_factor: float = 0.0
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class PositionTracker:
|
||||
"""
|
||||
Tracks positions and calculates P&L.
|
||||
|
||||
Features:
|
||||
- Active position tracking
|
||||
- Trade history with JSONL persistence
|
||||
- P&L calculations
|
||||
- Win/loss statistics
|
||||
- Equity curve for charting
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
trades_file: str = "logs/trades.jsonl",
|
||||
state_file: str = "logs/state.json"
|
||||
):
|
||||
self.trades_file = Path(trades_file)
|
||||
self.state_file = Path(state_file)
|
||||
|
||||
# Ensure directories exist
|
||||
self.trades_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Current state
|
||||
self._active_trade: Optional[Trade] = None
|
||||
self._trades: List[Trade] = []
|
||||
self._equity_curve: List[float] = [0.0]
|
||||
|
||||
# Stats
|
||||
self._stats = Stats()
|
||||
|
||||
# Load existing state
|
||||
self._load_state()
|
||||
|
||||
def _load_state(self):
|
||||
"""Load state from files."""
|
||||
# Load trades history
|
||||
if self.trades_file.exists():
|
||||
try:
|
||||
with open(self.trades_file, 'r') as f:
|
||||
for line in f:
|
||||
if line.strip():
|
||||
data = json.loads(line)
|
||||
trade = Trade.from_dict(data)
|
||||
self._trades.append(trade)
|
||||
|
||||
logger.info(f"Loaded {len(self._trades)} trades from history")
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading trades: {e}")
|
||||
|
||||
# Load state
|
||||
if self.state_file.exists():
|
||||
try:
|
||||
with open(self.state_file, 'r') as f:
|
||||
state = json.load(f)
|
||||
|
||||
# Restore active trade
|
||||
if state.get("active_trade"):
|
||||
self._active_trade = Trade.from_dict(state["active_trade"])
|
||||
|
||||
# Restore equity curve
|
||||
self._equity_curve = state.get("equity_curve", [0.0])
|
||||
|
||||
# Restore stats
|
||||
stats_data = state.get("stats", {})
|
||||
self._stats = Stats(**stats_data)
|
||||
|
||||
logger.info("State restored")
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading state: {e}")
|
||||
|
||||
# Recalculate stats from trades
|
||||
self._recalculate_stats()
|
||||
|
||||
def _save_state(self):
|
||||
"""Save current state to file."""
|
||||
try:
|
||||
state = {
|
||||
"active_trade": self._active_trade.to_dict() if self._active_trade else None,
|
||||
"equity_curve": self._equity_curve[-100:], # Keep last 100 points
|
||||
"stats": self._stats.to_dict(),
|
||||
"last_update": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
with open(self.state_file, 'w') as f:
|
||||
json.dump(state, f, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving state: {e}")
|
||||
|
||||
def _append_trade(self, trade: Trade):
|
||||
"""Append trade to history file."""
|
||||
try:
|
||||
with open(self.trades_file, 'a') as f:
|
||||
f.write(json.dumps(trade.to_dict()) + '\n')
|
||||
except Exception as e:
|
||||
logger.error(f"Error appending trade: {e}")
|
||||
|
||||
def _recalculate_stats(self):
|
||||
"""Recalculate statistics from trade history."""
|
||||
if not self._trades:
|
||||
return
|
||||
|
||||
completed = [t for t in self._trades if t.winner]
|
||||
|
||||
wins = [t for t in completed if t.pnl > 0]
|
||||
losses = [t for t in completed if t.pnl <= 0]
|
||||
|
||||
self._stats.total_trades = len(completed)
|
||||
self._stats.wins = len(wins)
|
||||
self._stats.losses = len(losses)
|
||||
self._stats.total_pnl = sum(t.pnl for t in completed)
|
||||
|
||||
if self._stats.total_trades > 0:
|
||||
self._stats.win_rate = self._stats.wins / self._stats.total_trades
|
||||
|
||||
if wins:
|
||||
self._stats.avg_win = sum(t.pnl for t in wins) / len(wins)
|
||||
|
||||
if losses:
|
||||
self._stats.avg_loss = abs(sum(t.pnl for t in losses) / len(losses))
|
||||
|
||||
# Profit factor
|
||||
total_wins = sum(t.pnl for t in wins)
|
||||
total_losses = abs(sum(t.pnl for t in losses))
|
||||
if total_losses > 0:
|
||||
self._stats.profit_factor = total_wins / total_losses
|
||||
|
||||
# Max drawdown
|
||||
equity = 0
|
||||
peak = 0
|
||||
max_dd = 0
|
||||
for t in completed:
|
||||
equity += t.pnl
|
||||
peak = max(peak, equity)
|
||||
dd = peak - equity
|
||||
max_dd = max(max_dd, dd)
|
||||
self._stats.max_drawdown = max_dd
|
||||
|
||||
# Rebuild equity curve
|
||||
self._equity_curve = [0.0]
|
||||
equity = 0
|
||||
for t in completed:
|
||||
equity += t.pnl
|
||||
self._equity_curve.append(equity)
|
||||
|
||||
def open_trade(
|
||||
self,
|
||||
trade_id: str,
|
||||
market_slug: str,
|
||||
side: str,
|
||||
entry_price: float,
|
||||
entry_contracts: int,
|
||||
entry_cost: float
|
||||
):
|
||||
"""
|
||||
Open a new trade.
|
||||
|
||||
Args:
|
||||
trade_id: Unique trade identifier
|
||||
market_slug: Market slug
|
||||
side: "UP" or "DOWN"
|
||||
entry_price: Average entry price
|
||||
entry_contracts: Number of contracts
|
||||
entry_cost: Total entry cost
|
||||
"""
|
||||
self._active_trade = Trade(
|
||||
id=trade_id,
|
||||
market_slug=market_slug,
|
||||
side=side,
|
||||
entry_price=entry_price,
|
||||
entry_contracts=entry_contracts,
|
||||
entry_cost=entry_cost,
|
||||
entry_time=datetime.now()
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Trade opened: {side} {entry_contracts} @ {entry_price:.2f} "
|
||||
f"(cost: ${entry_cost:.2f})"
|
||||
)
|
||||
|
||||
self._save_state()
|
||||
|
||||
def update_hedge(
|
||||
self,
|
||||
hedge_contracts: int,
|
||||
hedge_price: float,
|
||||
hedge_cost: float
|
||||
):
|
||||
"""Update trade with hedge information."""
|
||||
if not self._active_trade:
|
||||
logger.warning("No active trade to update hedge")
|
||||
return
|
||||
|
||||
self._active_trade.hedged = True
|
||||
self._active_trade.hedge_contracts = hedge_contracts
|
||||
self._active_trade.hedge_price = hedge_price
|
||||
self._active_trade.hedge_cost = hedge_cost
|
||||
|
||||
logger.info(
|
||||
f"Hedge added: {hedge_contracts} @ {hedge_price:.3f} "
|
||||
f"(cost: ${hedge_cost:.2f})"
|
||||
)
|
||||
|
||||
self._save_state()
|
||||
|
||||
def close_trade(self, winner: str):
|
||||
"""
|
||||
Close the active trade with result.
|
||||
|
||||
Args:
|
||||
winner: Winning side ("UP" or "DOWN")
|
||||
"""
|
||||
if not self._active_trade:
|
||||
logger.warning("No active trade to close")
|
||||
return
|
||||
|
||||
trade = self._active_trade
|
||||
trade.winner = winner
|
||||
trade.exit_time = datetime.now()
|
||||
|
||||
# Calculate P&L
|
||||
if trade.hedged:
|
||||
# Hedged trade - profit is locked
|
||||
# If our side won: we get entry_contracts * 1.0
|
||||
# If our side lost: hedge pays out
|
||||
if trade.side == winner:
|
||||
# Win - collect main position
|
||||
payout = trade.entry_contracts * 1.0
|
||||
cost = trade.entry_cost + trade.hedge_cost
|
||||
trade.pnl = payout - cost
|
||||
else:
|
||||
# Lose - hedge pays out
|
||||
hedge_payout = trade.hedge_contracts * 1.0
|
||||
cost = trade.entry_cost + trade.hedge_cost
|
||||
trade.pnl = hedge_payout - cost
|
||||
else:
|
||||
# Unhedged trade
|
||||
if trade.side == winner:
|
||||
# Win - collect full payout
|
||||
payout = trade.entry_contracts * 1.0
|
||||
trade.pnl = payout - trade.entry_cost
|
||||
else:
|
||||
# Lose - lose entry cost
|
||||
trade.pnl = -trade.entry_cost
|
||||
|
||||
# Calculate percentage
|
||||
total_cost = trade.entry_cost + trade.hedge_cost
|
||||
if total_cost > 0:
|
||||
trade.pnl_pct = (trade.pnl / total_cost) * 100
|
||||
|
||||
# Add to history
|
||||
self._trades.append(trade)
|
||||
self._append_trade(trade)
|
||||
|
||||
# Update equity curve
|
||||
self._equity_curve.append(self._equity_curve[-1] + trade.pnl)
|
||||
|
||||
# Recalculate stats
|
||||
self._recalculate_stats()
|
||||
|
||||
# Clear active trade
|
||||
self._active_trade = None
|
||||
|
||||
logger.info(
|
||||
f"Trade closed: {winner} won, P&L: ${trade.pnl:.2f} ({trade.pnl_pct:.1f}%)"
|
||||
)
|
||||
|
||||
self._save_state()
|
||||
|
||||
return trade
|
||||
|
||||
@property
|
||||
def active_trade(self) -> Optional[Trade]:
|
||||
return self._active_trade
|
||||
|
||||
@property
|
||||
def trades(self) -> List[Trade]:
|
||||
return self._trades
|
||||
|
||||
@property
|
||||
def stats(self) -> Stats:
|
||||
return self._stats
|
||||
|
||||
@property
|
||||
def equity_curve(self) -> List[float]:
|
||||
return self._equity_curve
|
||||
|
||||
@property
|
||||
def total_pnl(self) -> float:
|
||||
return self._stats.total_pnl
|
||||
|
||||
@property
|
||||
def win_rate(self) -> float:
|
||||
return self._stats.win_rate
|
||||
|
||||
def get_summary(self) -> Dict:
|
||||
"""Get trading summary."""
|
||||
return {
|
||||
"total_trades": self._stats.total_trades,
|
||||
"wins": self._stats.wins,
|
||||
"losses": self._stats.losses,
|
||||
"win_rate": f"{self._stats.win_rate:.1%}",
|
||||
"total_pnl": f"${self._stats.total_pnl:.2f}",
|
||||
"avg_win": f"${self._stats.avg_win:.2f}",
|
||||
"avg_loss": f"${self._stats.avg_loss:.2f}",
|
||||
"profit_factor": f"{self._stats.profit_factor:.2f}",
|
||||
"max_drawdown": f"${self._stats.max_drawdown:.2f}",
|
||||
"active_trade": self._active_trade is not None
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
"""HTTP/HTTPS proxy helpers for aiohttp + websockets.
|
||||
|
||||
websockets 13.x has NO native proxy support: proxy=/trust_env= are forwarded to
|
||||
asyncio.create_connection() and raise TypeError. We tunnel via HTTP CONNECT and
|
||||
pass the resulting socket with sock=.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import os
|
||||
import socket
|
||||
from typing import Any, Dict, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import websockets
|
||||
|
||||
_ENV_KEYS = ("HTTPS_PROXY", "HTTP_PROXY", "https_proxy", "http_proxy")
|
||||
|
||||
|
||||
def normalize_proxy(raw: str) -> str:
|
||||
raw = (raw or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
if raw.endswith("/"):
|
||||
raw = raw[:-1]
|
||||
if raw.startswith(("http://", "https://", "socks://", "socks4://", "socks5://")):
|
||||
return raw
|
||||
return "http://" + raw
|
||||
|
||||
|
||||
def get_proxy_url() -> str:
|
||||
for key in _ENV_KEYS:
|
||||
val = os.getenv(key)
|
||||
if val:
|
||||
return normalize_proxy(val)
|
||||
return ""
|
||||
|
||||
|
||||
def apply_proxy_env(proxy_url: str | None = None) -> str:
|
||||
"""Normalize proxy and write back to env for trust_env consumers (aiohttp/requests)."""
|
||||
url = normalize_proxy(proxy_url if proxy_url is not None else get_proxy_url())
|
||||
if url:
|
||||
for key in _ENV_KEYS:
|
||||
os.environ[key] = url
|
||||
return url
|
||||
|
||||
|
||||
PROXY_URL = apply_proxy_env()
|
||||
|
||||
|
||||
def open_proxy_socket(proxy_url: str, host: str, port: int, timeout: float = 15.0) -> socket.socket:
|
||||
"""Blocking HTTP CONNECT through an HTTP proxy. Returns non-blocking plain socket."""
|
||||
if proxy_url.lower().startswith("socks"):
|
||||
raise OSError(
|
||||
f"SOCKS proxy not supported for WebSocket ({proxy_url}). "
|
||||
"Use Clash HTTP port (e.g. http://127.0.0.1:7890)."
|
||||
)
|
||||
p = urlparse(proxy_url)
|
||||
ph, pp = p.hostname, p.port or 8080
|
||||
if not ph:
|
||||
raise OSError(f"invalid proxy url: {proxy_url}")
|
||||
|
||||
raw = socket.create_connection((ph, pp), timeout=timeout)
|
||||
try:
|
||||
req = (
|
||||
f"CONNECT {host}:{port} HTTP/1.1\r\n"
|
||||
f"Host: {host}:{port}\r\n"
|
||||
f"Proxy-Connection: keep-alive\r\n"
|
||||
)
|
||||
if p.username is not None:
|
||||
token = base64.b64encode(
|
||||
f"{p.username}:{p.password or ''}".encode()
|
||||
).decode()
|
||||
req += f"Proxy-Authorization: Basic {token}\r\n"
|
||||
req += "\r\n"
|
||||
raw.sendall(req.encode())
|
||||
|
||||
raw.settimeout(timeout)
|
||||
buf = b""
|
||||
while b"\r\n\r\n" not in buf:
|
||||
chunk = raw.recv(4096)
|
||||
if not chunk:
|
||||
raise OSError("proxy closed during CONNECT")
|
||||
buf += chunk
|
||||
if len(buf) > 65536:
|
||||
raise OSError("proxy CONNECT response too large")
|
||||
|
||||
status = buf.split(b"\r\n", 1)[0].decode("latin1", "replace")
|
||||
if "200" not in status:
|
||||
raise OSError(f"proxy CONNECT failed: {status}")
|
||||
|
||||
raw.settimeout(None)
|
||||
raw.setblocking(False)
|
||||
return raw
|
||||
except Exception:
|
||||
try:
|
||||
raw.close()
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def _target_from_ws_url(url: str) -> tuple[str, int]:
|
||||
p = urlparse(url)
|
||||
host = p.hostname
|
||||
if not host:
|
||||
raise ValueError(f"invalid websocket url: {url}")
|
||||
if p.port:
|
||||
return host, p.port
|
||||
return host, 443 if p.scheme == "wss" else 80
|
||||
|
||||
|
||||
class ws_connect:
|
||||
"""Drop-in async context manager replacing websockets.connect, with HTTP proxy tunnel."""
|
||||
|
||||
def __init__(self, url: str, **kwargs: Any):
|
||||
self.url = url
|
||||
self.kwargs = dict(kwargs)
|
||||
self._cm = None
|
||||
self._sock: Optional[socket.socket] = None
|
||||
|
||||
async def __aenter__(self):
|
||||
kwargs = dict(self.kwargs)
|
||||
proxy = get_proxy_url()
|
||||
# Strip native proxy kwargs — websockets 13 forwards them to create_connection and crashes
|
||||
kwargs.pop("proxy", None)
|
||||
kwargs.pop("trust_env", None)
|
||||
|
||||
if proxy:
|
||||
host, port = _target_from_ws_url(self.url)
|
||||
try:
|
||||
self._sock = await asyncio.to_thread(
|
||||
open_proxy_socket, proxy, host, port, 15.0
|
||||
)
|
||||
except Exception as e:
|
||||
raise ConnectionError(
|
||||
f"WS proxy tunnel {proxy} -> {host}:{port} failed: {type(e).__name__}: {e}"
|
||||
) from e
|
||||
kwargs["sock"] = self._sock
|
||||
kwargs.setdefault("server_hostname", host)
|
||||
|
||||
self._cm = websockets.connect(self.url, **kwargs)
|
||||
try:
|
||||
return await self._cm.__aenter__()
|
||||
except Exception:
|
||||
self._close_sock()
|
||||
raise
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
try:
|
||||
if self._cm is not None:
|
||||
return await self._cm.__aexit__(*args)
|
||||
finally:
|
||||
self._close_sock()
|
||||
self._cm = None
|
||||
|
||||
def _close_sock(self):
|
||||
sock = self._sock
|
||||
self._sock = None
|
||||
if sock is not None:
|
||||
try:
|
||||
sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def ws_connect_kwargs(ping_interval: int = 20, ping_timeout: int = 10) -> Dict[str, Any]:
|
||||
"""Base kwargs only — never includes proxy=/trust_env= (unsafe on websockets 13)."""
|
||||
return {"ping_interval": ping_interval, "ping_timeout": ping_timeout}
|
||||
|
||||
|
||||
def aiohttp_proxy() -> Optional[str]:
|
||||
"""Explicit proxy URL for aiohttp request kwargs (None if unset)."""
|
||||
return get_proxy_url() or None
|
||||
@@ -0,0 +1,209 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Append-only simulation trading history for analysis (CSV, JSONL, summary JSON).
|
||||
|
||||
Used only when config.simulation.enabled is True.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import asdict
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger("btc_live.simulation_history")
|
||||
|
||||
CSV_COLUMNS = [
|
||||
"event",
|
||||
"time_utc",
|
||||
"unix_ts",
|
||||
"market_slug",
|
||||
"side",
|
||||
"contracts",
|
||||
"entry_price",
|
||||
"exit_price",
|
||||
"entry_cost_usd",
|
||||
"trade_pnl_usd",
|
||||
"cumulative_pnl_usd",
|
||||
"won",
|
||||
"trade_number",
|
||||
"total_closed_trades",
|
||||
"win_rate_pct",
|
||||
"max_dd_abs",
|
||||
"max_dd_pct",
|
||||
"hedged",
|
||||
]
|
||||
|
||||
|
||||
def _iso(ts: Optional[float] = None) -> str:
|
||||
t = ts if ts is not None else time.time()
|
||||
return datetime.fromtimestamp(t, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
class SimulationHistoryLogger:
|
||||
"""
|
||||
Logs each simulated OPEN and CLOSE with per-trade PnL and cumulative realized PnL.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
csv_path: str = "logs/simulation_trades.csv",
|
||||
jsonl_path: Optional[str] = "logs/simulation_history.jsonl",
|
||||
summary_path: str = "logs/simulation_summary.json",
|
||||
):
|
||||
self.csv_path = Path(csv_path) if (csv_path or "").strip() else None
|
||||
jp = (jsonl_path or "").strip()
|
||||
self.jsonl_path = Path(jp) if jp else None
|
||||
self.summary_path = Path(summary_path) if (summary_path or "").strip() else None
|
||||
self._csv_header_written = (
|
||||
bool(self.csv_path and self.csv_path.exists() and self.csv_path.stat().st_size > 0)
|
||||
)
|
||||
|
||||
def _append_csv_row(self, row: Dict[str, Any]) -> None:
|
||||
if not self.csv_path:
|
||||
return
|
||||
self.csv_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
write_header = not self._csv_header_written
|
||||
with open(self.csv_path, "a", newline="", encoding="utf-8") as f:
|
||||
w = csv.DictWriter(f, fieldnames=CSV_COLUMNS, extrasaction="ignore")
|
||||
if write_header:
|
||||
w.writeheader()
|
||||
self._csv_header_written = True
|
||||
w.writerow({k: row.get(k, "") for k in CSV_COLUMNS})
|
||||
|
||||
def _append_jsonl(self, obj: Dict[str, Any]) -> None:
|
||||
if not self.jsonl_path:
|
||||
return
|
||||
self.jsonl_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.jsonl_path, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(obj, ensure_ascii=False) + "\n")
|
||||
|
||||
def log_open(
|
||||
self,
|
||||
*,
|
||||
market_slug: str,
|
||||
token_name: str,
|
||||
contracts: int,
|
||||
avg_price: float,
|
||||
total_cost: float,
|
||||
cumulative_realized_pnl: float,
|
||||
hedged: bool,
|
||||
trade_number: int,
|
||||
) -> None:
|
||||
"""trade_number = count of closed trades + 1 (this open is the Nth position)."""
|
||||
ts = time.time()
|
||||
row = {
|
||||
"event": "OPEN",
|
||||
"time_utc": _iso(ts),
|
||||
"unix_ts": f"{ts:.3f}",
|
||||
"market_slug": market_slug,
|
||||
"side": token_name,
|
||||
"contracts": contracts,
|
||||
"entry_price": f"{avg_price:.6f}",
|
||||
"exit_price": "",
|
||||
"entry_cost_usd": f"{total_cost:.4f}",
|
||||
"trade_pnl_usd": "",
|
||||
"cumulative_pnl_usd": f"{cumulative_realized_pnl:.4f}",
|
||||
"won": "",
|
||||
"trade_number": trade_number,
|
||||
"total_closed_trades": "",
|
||||
"win_rate_pct": "",
|
||||
"max_dd_abs": "",
|
||||
"max_dd_pct": "",
|
||||
"hedged": hedged,
|
||||
}
|
||||
self._append_csv_row(row)
|
||||
self._append_jsonl(
|
||||
{
|
||||
"type": "open",
|
||||
"time_utc": row["time_utc"],
|
||||
"unix_ts": ts,
|
||||
"market_slug": market_slug,
|
||||
"side": token_name,
|
||||
"contracts": contracts,
|
||||
"avg_price": avg_price,
|
||||
"entry_cost_usd": total_cost,
|
||||
"cumulative_realized_pnl_usd": cumulative_realized_pnl,
|
||||
"hedged": hedged,
|
||||
"trade_number": trade_number,
|
||||
}
|
||||
)
|
||||
logger.info(
|
||||
f"[SIM] OPEN {token_name} x{contracts} @ {avg_price:.4f} cost=${total_cost:.2f} | "
|
||||
f"realized PnL so far ${cumulative_realized_pnl:+.4f}"
|
||||
)
|
||||
|
||||
def log_close(
|
||||
self,
|
||||
record: Any, # TradeRecord-like
|
||||
*,
|
||||
cumulative_pnl: float,
|
||||
total_closed: int,
|
||||
win_rate_pct: float,
|
||||
hedged: bool,
|
||||
) -> None:
|
||||
ts = getattr(record, "timestamp", None) or time.time()
|
||||
row = {
|
||||
"event": "CLOSE",
|
||||
"time_utc": _iso(ts),
|
||||
"unix_ts": f"{ts:.3f}",
|
||||
"market_slug": record.market_slug,
|
||||
"side": record.token_name,
|
||||
"contracts": record.contracts,
|
||||
"entry_price": f"{record.entry_price:.6f}",
|
||||
"exit_price": f"{record.exit_price:.6f}",
|
||||
"entry_cost_usd": f"{record.contracts * record.entry_price:.4f}",
|
||||
"trade_pnl_usd": f"{record.pnl:+.4f}",
|
||||
"cumulative_pnl_usd": f"{cumulative_pnl:+.4f}",
|
||||
"won": record.won,
|
||||
"trade_number": total_closed,
|
||||
"total_closed_trades": total_closed,
|
||||
"win_rate_pct": f"{win_rate_pct:.2f}",
|
||||
"max_dd_abs": f"{record.max_drawdown_abs:.6f}",
|
||||
"max_dd_pct": f"{record.max_drawdown_pct:.2f}",
|
||||
"hedged": hedged,
|
||||
}
|
||||
self._append_csv_row(row)
|
||||
self._append_jsonl(
|
||||
{
|
||||
"type": "close",
|
||||
"time_utc": row["time_utc"],
|
||||
"unix_ts": ts,
|
||||
"market_slug": record.market_slug,
|
||||
"side": record.token_name,
|
||||
"contracts": record.contracts,
|
||||
"entry_price": record.entry_price,
|
||||
"exit_price": record.exit_price,
|
||||
"trade_pnl_usd": record.pnl,
|
||||
"cumulative_pnl_usd": cumulative_pnl,
|
||||
"won": record.won,
|
||||
"trade_number": total_closed,
|
||||
"total_closed_trades": total_closed,
|
||||
"win_rate_pct": win_rate_pct,
|
||||
"max_drawdown_abs": record.max_drawdown_abs,
|
||||
"max_drawdown_pct": record.max_drawdown_pct,
|
||||
"hedged": hedged,
|
||||
}
|
||||
)
|
||||
logger.info(
|
||||
f"[SIM] CLOSE #{total_closed} {record.token_name} PnL ${record.pnl:+.4f} | "
|
||||
f"cumulative ${cumulative_pnl:+.4f} | WR {win_rate_pct:.1f}% ({total_closed} trades)"
|
||||
)
|
||||
|
||||
def write_summary(self, trades_as_dicts: List[Dict[str, Any]], summary: Dict[str, Any]) -> None:
|
||||
"""Full snapshot for quick analysis (includes all closed trades)."""
|
||||
if not self.summary_path:
|
||||
return
|
||||
self.summary_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out = {
|
||||
"updated_at_utc": _iso(),
|
||||
**summary,
|
||||
"trades": trades_as_dicts,
|
||||
}
|
||||
with open(self.summary_path, "w", encoding="utf-8") as f:
|
||||
json.dump(out, f, indent=2)
|
||||
@@ -0,0 +1,306 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Telegram Notifier
|
||||
|
||||
Sends notifications and charts to Telegram.
|
||||
|
||||
Features:
|
||||
- Async message sending
|
||||
- Rate limiting
|
||||
- Equity curve chart generation
|
||||
- Message queue with retry
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Dict, Any
|
||||
from queue import Queue, Empty
|
||||
from threading import Thread
|
||||
|
||||
import aiohttp
|
||||
|
||||
from src.proxy_util import apply_proxy_env, aiohttp_proxy
|
||||
|
||||
apply_proxy_env()
|
||||
_PROXY_URL = aiohttp_proxy()
|
||||
|
||||
logger = logging.getLogger("btc_live.telegram")
|
||||
|
||||
|
||||
class TelegramNotifier:
|
||||
"""
|
||||
Async Telegram notification sender.
|
||||
|
||||
Features:
|
||||
- Non-blocking message sending
|
||||
- Rate limiting (5 msg/sec max)
|
||||
- Image/chart sending
|
||||
- Graceful error handling
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bot_token: str,
|
||||
chat_id: str,
|
||||
rate_limit: float = 5.0,
|
||||
enabled: bool = True
|
||||
):
|
||||
self.bot_token = bot_token
|
||||
self.chat_id = chat_id
|
||||
self.rate_limit = rate_limit
|
||||
self.min_interval = 1.0 / rate_limit
|
||||
self.enabled = enabled and bool(bot_token and chat_id)
|
||||
|
||||
self._last_send_time = 0.0
|
||||
self._session: Optional[aiohttp.ClientSession] = None
|
||||
|
||||
# Stats
|
||||
self.messages_sent = 0
|
||||
self.errors_count = 0
|
||||
|
||||
if not self.enabled:
|
||||
logger.warning("Telegram notifications disabled")
|
||||
|
||||
async def _get_session(self) -> aiohttp.ClientSession:
|
||||
"""Get or create HTTP session (proxy-aware via env)."""
|
||||
if self._session is None or self._session.closed:
|
||||
timeout = aiohttp.ClientTimeout(total=10)
|
||||
self._session = aiohttp.ClientSession(timeout=timeout, trust_env=True)
|
||||
return self._session
|
||||
|
||||
async def _rate_limit(self):
|
||||
"""Apply rate limiting."""
|
||||
import time
|
||||
now = time.time()
|
||||
elapsed = now - self._last_send_time
|
||||
if elapsed < self.min_interval:
|
||||
await asyncio.sleep(self.min_interval - elapsed)
|
||||
self._last_send_time = time.time()
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
text: str,
|
||||
parse_mode: str = "HTML"
|
||||
) -> bool:
|
||||
"""
|
||||
Send a text message.
|
||||
|
||||
Args:
|
||||
text: Message text
|
||||
parse_mode: "HTML" or "Markdown"
|
||||
|
||||
Returns:
|
||||
True if sent successfully
|
||||
"""
|
||||
if not self.enabled:
|
||||
logger.debug(f"Telegram disabled, would send: {text[:50]}...")
|
||||
return True
|
||||
|
||||
await self._rate_limit()
|
||||
|
||||
try:
|
||||
session = await self._get_session()
|
||||
url = f"https://api.telegram.org/bot{self.bot_token}/sendMessage"
|
||||
|
||||
payload = {
|
||||
"chat_id": self.chat_id,
|
||||
"text": text,
|
||||
"parse_mode": parse_mode
|
||||
}
|
||||
|
||||
async with session.post(url, json=payload, proxy=_PROXY_URL) as resp:
|
||||
if resp.status == 200:
|
||||
self.messages_sent += 1
|
||||
logger.debug(f"Telegram sent: {text[:50]}...")
|
||||
return True
|
||||
else:
|
||||
error = await resp.text()
|
||||
logger.error(f"Telegram error {resp.status}: {error}")
|
||||
self.errors_count += 1
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Telegram send error: {e}")
|
||||
self.errors_count += 1
|
||||
return False
|
||||
|
||||
async def send_photo(
|
||||
self,
|
||||
photo: bytes,
|
||||
caption: str = ""
|
||||
) -> bool:
|
||||
"""
|
||||
Send a photo.
|
||||
|
||||
Args:
|
||||
photo: Photo bytes
|
||||
caption: Optional caption
|
||||
|
||||
Returns:
|
||||
True if sent successfully
|
||||
"""
|
||||
if not self.enabled:
|
||||
return True
|
||||
|
||||
await self._rate_limit()
|
||||
|
||||
try:
|
||||
session = await self._get_session()
|
||||
url = f"https://api.telegram.org/bot{self.bot_token}/sendPhoto"
|
||||
|
||||
data = aiohttp.FormData()
|
||||
data.add_field('chat_id', self.chat_id)
|
||||
data.add_field('photo', photo, filename='chart.png')
|
||||
if caption:
|
||||
data.add_field('caption', caption)
|
||||
|
||||
async with session.post(url, data=data, proxy=_PROXY_URL) as resp:
|
||||
if resp.status == 200:
|
||||
self.messages_sent += 1
|
||||
logger.debug("Telegram photo sent")
|
||||
return True
|
||||
else:
|
||||
error = await resp.text()
|
||||
logger.error(f"Telegram photo error {resp.status}: {error}")
|
||||
self.errors_count += 1
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Telegram photo error: {e}")
|
||||
self.errors_count += 1
|
||||
return False
|
||||
|
||||
async def notify_entry(
|
||||
self,
|
||||
side: str,
|
||||
price: float,
|
||||
contracts: int,
|
||||
cost: float,
|
||||
retries: int,
|
||||
interval_minutes: int = 15,
|
||||
simulation: bool = False,
|
||||
):
|
||||
"""Send entry notification."""
|
||||
mode = "🎮 <b>[SIMULATION]</b>\n" if simulation else ""
|
||||
text = (
|
||||
f"{mode}"
|
||||
f"🟢 <b>ENTRY</b>\n"
|
||||
f"📊 BTC {interval_minutes}min - {side}\n"
|
||||
f"💰 ${cost:.2f} @ {price:.2f}\n"
|
||||
f"📦 {contracts} contracts\n"
|
||||
f"🔄 {retries} retries"
|
||||
)
|
||||
await self.send_message(text)
|
||||
|
||||
async def notify_hedge(
|
||||
self,
|
||||
contracts: int,
|
||||
price: float,
|
||||
cost: float
|
||||
):
|
||||
"""Send hedge notification."""
|
||||
text = (
|
||||
f"🛡 <b>HEDGE</b>\n"
|
||||
f"📦 {contracts} contracts @ ${price:.3f}\n"
|
||||
f"💰 Cost: ${cost:.2f}\n"
|
||||
f"✅ Position protected"
|
||||
)
|
||||
await self.send_message(text)
|
||||
|
||||
async def notify_market_end(
|
||||
self,
|
||||
winner: str,
|
||||
pnl: float,
|
||||
total_pnl: float,
|
||||
win_rate: float
|
||||
):
|
||||
"""Send market end notification."""
|
||||
emoji = "🎯" if pnl > 0 else "❌"
|
||||
pnl_sign = "+" if pnl > 0 else ""
|
||||
|
||||
text = (
|
||||
f"🏁 <b>MARKET RESOLVED</b>\n"
|
||||
f"{emoji} Winner: <b>{winner}</b>\n"
|
||||
f"💵 P&L: {pnl_sign}${pnl:.2f}\n"
|
||||
f"📈 Total: ${total_pnl:.2f}\n"
|
||||
f"📊 Win rate: {win_rate:.1%}"
|
||||
)
|
||||
await self.send_message(text)
|
||||
|
||||
async def send_equity_chart(
|
||||
self,
|
||||
equity_curve: List[float],
|
||||
title: str = "Equity Curve"
|
||||
):
|
||||
"""
|
||||
Generate and send equity curve chart.
|
||||
|
||||
Args:
|
||||
equity_curve: List of equity values
|
||||
title: Chart title
|
||||
"""
|
||||
try:
|
||||
import matplotlib
|
||||
matplotlib.use('Agg')
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
fig, ax = plt.subplots(figsize=(10, 6))
|
||||
|
||||
# Plot equity curve
|
||||
ax.plot(equity_curve, linewidth=2, color='#2196F3')
|
||||
ax.fill_between(
|
||||
range(len(equity_curve)),
|
||||
equity_curve,
|
||||
alpha=0.3,
|
||||
color='#2196F3'
|
||||
)
|
||||
|
||||
# Styling
|
||||
ax.set_title(title, fontsize=14, fontweight='bold')
|
||||
ax.set_xlabel('Trade #', fontsize=12)
|
||||
ax.set_ylabel('Equity ($)', fontsize=12)
|
||||
ax.grid(True, alpha=0.3)
|
||||
ax.axhline(y=0, color='gray', linestyle='--', alpha=0.5)
|
||||
|
||||
# Add current value annotation
|
||||
if equity_curve:
|
||||
final_value = equity_curve[-1]
|
||||
ax.annotate(
|
||||
f'${final_value:.2f}',
|
||||
xy=(len(equity_curve)-1, final_value),
|
||||
fontsize=11,
|
||||
fontweight='bold'
|
||||
)
|
||||
|
||||
plt.tight_layout()
|
||||
|
||||
# Save to bytes
|
||||
buf = io.BytesIO()
|
||||
plt.savefig(buf, format='png', dpi=100)
|
||||
buf.seek(0)
|
||||
plt.close(fig)
|
||||
|
||||
# Send
|
||||
caption = f"📊 {title}\nTrades: {len(equity_curve)-1} | Final: ${equity_curve[-1]:.2f}"
|
||||
await self.send_photo(buf.getvalue(), caption)
|
||||
|
||||
except ImportError:
|
||||
logger.warning("matplotlib not available for charts")
|
||||
except Exception as e:
|
||||
logger.error(f"Chart generation error: {e}")
|
||||
|
||||
async def close(self):
|
||||
"""Close the session."""
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
|
||||
def get_stats(self) -> Dict:
|
||||
"""Get notifier statistics."""
|
||||
return {
|
||||
"enabled": self.enabled,
|
||||
"messages_sent": self.messages_sent,
|
||||
"errors_count": self.errors_count
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
User WebSocket Client
|
||||
|
||||
Subscribes to Polymarket User Channel for order/trade tracking.
|
||||
Used to verify order execution before retry.
|
||||
|
||||
Docs: https://docs.polymarket.com/developers/CLOB/websocket/user-channel
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import websockets
|
||||
from typing import Optional, Dict, Callable, Any
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
from src.proxy_util import apply_proxy_env, ws_connect, ws_connect_kwargs as _ws_connect_kwargs
|
||||
|
||||
apply_proxy_env()
|
||||
|
||||
logger = logging.getLogger("btc_live.user_ws")
|
||||
|
||||
WS_URL = "wss://ws-subscriptions-clob.polymarket.com/ws/user"
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrderStatus:
|
||||
"""Tracks order status from WebSocket."""
|
||||
order_id: str
|
||||
asset_id: str
|
||||
side: str
|
||||
price: float
|
||||
original_size: int
|
||||
size_matched: int = 0
|
||||
status: str = "PENDING" # PENDING, PLACED, MATCHED, CANCELLED
|
||||
trades: list = field(default_factory=list)
|
||||
timestamp: datetime = field(default_factory=datetime.now)
|
||||
|
||||
|
||||
class UserWebSocket:
|
||||
"""
|
||||
WebSocket client for User Channel.
|
||||
|
||||
Tracks order placements and fills in real-time.
|
||||
"""
|
||||
|
||||
def __init__(self, api_key: str, api_secret: str = "", api_passphrase: str = ""):
|
||||
self.api_key = api_key
|
||||
self.api_secret = api_secret
|
||||
self.api_passphrase = api_passphrase
|
||||
self._ws = None
|
||||
self._ws_cm = None
|
||||
self._connected = False
|
||||
self._running = False
|
||||
self._orders: Dict[str, OrderStatus] = {}
|
||||
|
||||
# Callbacks
|
||||
self._on_trade: Optional[Callable] = None
|
||||
self._on_order: Optional[Callable] = None
|
||||
|
||||
async def connect(self):
|
||||
"""Connect to User Channel WebSocket."""
|
||||
try:
|
||||
self._running = True
|
||||
|
||||
logger.info(f"Connecting to User WebSocket at {WS_URL}...")
|
||||
print(f" Connecting to {WS_URL}...")
|
||||
|
||||
async with ws_connect(
|
||||
WS_URL,
|
||||
**_ws_connect_kwargs(ping_interval=30, ping_timeout=10)
|
||||
) as ws:
|
||||
self._ws = ws
|
||||
self._connected = True
|
||||
logger.info("User WebSocket connected")
|
||||
print(" WebSocket connection established")
|
||||
|
||||
# Subscribe to user channel with auth object
|
||||
subscribe_msg = {
|
||||
"type": "user",
|
||||
"auth": {
|
||||
"apiKey": self.api_key,
|
||||
"secret": self.api_secret,
|
||||
"passphrase": self.api_passphrase
|
||||
}
|
||||
}
|
||||
await ws.send(json.dumps(subscribe_msg))
|
||||
logger.info("Sent subscription message")
|
||||
print(" Sent subscription message")
|
||||
|
||||
# Wait for response
|
||||
try:
|
||||
first_msg = await asyncio.wait_for(ws.recv(), timeout=5)
|
||||
logger.info(f"First message: {first_msg[:200]}")
|
||||
print(f" First response: {first_msg[:100]}...")
|
||||
await self._process_message(first_msg)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("No initial response from WebSocket")
|
||||
print(" No initial response (timeout)")
|
||||
|
||||
# Listen for messages
|
||||
while self._running:
|
||||
try:
|
||||
msg = await asyncio.wait_for(ws.recv(), timeout=60)
|
||||
logger.debug(f"WS message: {msg[:100]}")
|
||||
await self._process_message(msg)
|
||||
except asyncio.TimeoutError:
|
||||
# Send ping to keep connection alive
|
||||
try:
|
||||
await ws.ping()
|
||||
except Exception:
|
||||
break
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
logger.warning("User WebSocket connection closed")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Error receiving message: {e}")
|
||||
break
|
||||
|
||||
self._connected = False
|
||||
logger.info("User WebSocket disconnected")
|
||||
print(" WebSocket disconnected")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"User WebSocket connection error: {e}")
|
||||
print(f" Connection error: {e}")
|
||||
self._connected = False
|
||||
raise
|
||||
|
||||
async def _process_message(self, msg: str):
|
||||
"""Process an incoming WebSocket message."""
|
||||
try:
|
||||
data = json.loads(msg)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"Invalid JSON: {msg[:100]}")
|
||||
return
|
||||
|
||||
msg_type = data.get("type", "")
|
||||
|
||||
# Order updates
|
||||
if msg_type in ("PLACEMENT", "UPDATE", "CANCELLATION"):
|
||||
order_id = data.get("id", "")
|
||||
if order_id and order_id in self._orders:
|
||||
order = self._orders[order_id]
|
||||
order.status = msg_type
|
||||
order.size_matched = int(float(data.get("size_matched", 0)))
|
||||
|
||||
if msg_type == "MATCHED":
|
||||
order.status = "MATCHED"
|
||||
order.trades.append(data)
|
||||
|
||||
if self._on_order:
|
||||
if asyncio.iscoroutinefunction(self._on_order):
|
||||
await self._on_order(order)
|
||||
else:
|
||||
self._on_order(order)
|
||||
|
||||
# Trade updates
|
||||
elif msg_type == "TRADE":
|
||||
if self._on_trade:
|
||||
if asyncio.iscoroutinefunction(self._on_trade):
|
||||
await self._on_trade(data)
|
||||
else:
|
||||
self._on_trade(data)
|
||||
|
||||
# Initial subscription response
|
||||
elif msg_type in ("subscribed", "OK", "ok"):
|
||||
logger.info(f"Subscription confirmed: {msg[:150]}")
|
||||
|
||||
else:
|
||||
logger.debug(f"Unhandled msg type={msg_type}: {msg[:100]}")
|
||||
|
||||
def register_order(self, order_id: str, asset_id: str, side: str, price: float, size: int):
|
||||
"""Register an order we're tracking."""
|
||||
self._orders[order_id] = OrderStatus(
|
||||
order_id=order_id,
|
||||
asset_id=asset_id,
|
||||
side=side,
|
||||
price=price,
|
||||
original_size=size
|
||||
)
|
||||
|
||||
def get_order_status(self, order_id: str) -> Optional[OrderStatus]:
|
||||
"""Get current status for an order."""
|
||||
return self._orders.get(order_id)
|
||||
|
||||
async def wait_for_order_match(self, order_id: str, timeout: float = 10.0) -> bool:
|
||||
"""Wait until an order is matched or timeout."""
|
||||
start = asyncio.get_event_loop().time()
|
||||
while asyncio.get_event_loop().time() - start < timeout:
|
||||
status = self._orders.get(order_id)
|
||||
if status and (status.status == "MATCHED" or status.size_matched >= status.original_size):
|
||||
return True
|
||||
await asyncio.sleep(0.2)
|
||||
return False
|
||||
|
||||
async def wait_for_order_place(self, order_id: str, timeout: float = 5.0) -> bool:
|
||||
"""Wait until an order placement is confirmed or timeout."""
|
||||
start = asyncio.get_event_loop().time()
|
||||
while asyncio.get_event_loop().time() - start < timeout:
|
||||
status = self._orders.get(order_id)
|
||||
if status and status.status in ("PLACED", "UPDATE", "MATCHED"):
|
||||
return True
|
||||
await asyncio.sleep(0.15)
|
||||
return False
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._connected and self._ws is not None
|
||||
|
||||
async def close(self):
|
||||
"""Close the WebSocket connection."""
|
||||
self._running = False
|
||||
ws = self._ws
|
||||
cm = self._ws_cm
|
||||
self._ws = None
|
||||
self._ws_cm = None
|
||||
if ws and not getattr(ws, "closed", True):
|
||||
try:
|
||||
await ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
if cm is not None:
|
||||
try:
|
||||
await cm.__aexit__(None, None, None)
|
||||
except Exception:
|
||||
pass
|
||||
self._connected = False
|
||||
@@ -0,0 +1,276 @@
|
||||
"""
|
||||
Local web dashboard: FastAPI + single-page UI, JSON at /api/state.
|
||||
Runs in a daemon thread; state is updated from the bot's main loop.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, Response
|
||||
import uvicorn
|
||||
|
||||
logger = logging.getLogger("btc_live")
|
||||
|
||||
_HTML = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>BTC Live Bot</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d1117; --panel: #161b22; --border: #30363d;
|
||||
--text: #e6edf3; --muted: #8b949e; --green: #3fb950; --red: #f85149;
|
||||
--yellow: #d29922; --blue: #58a6ff; --violet: #a371f7;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: ui-sans-serif, system-ui, sans-serif; background: var(--bg); color: var(--text);
|
||||
margin: 0; padding: 1rem; line-height: 1.45; }
|
||||
h1 { font-size: 1.1rem; font-weight: 600; margin: 0 0 0.75rem; }
|
||||
.meta { color: var(--muted); font-size: 0.85rem; margin-bottom: 1rem; }
|
||||
.grid { display: grid; gap: 0.75rem; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); }
|
||||
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 0.85rem; }
|
||||
.card h2 { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted);
|
||||
margin: 0 0 0.5rem; }
|
||||
.row { display: flex; justify-content: space-between; gap: 0.5rem; font-size: 0.9rem; }
|
||||
.sig { font-size: 1rem; font-weight: 600; }
|
||||
.sig.wait { color: var(--yellow); }
|
||||
.sig.buy { color: var(--green); }
|
||||
.sig.block { color: var(--red); }
|
||||
.mono { font-family: ui-monospace, monospace; font-size: 0.82rem; }
|
||||
.btc { border-color: #d29922; }
|
||||
footer { margin-top: 1rem; color: var(--muted); font-size: 0.75rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>BTC up/down — live</h1>
|
||||
<div class="meta" id="meta">Loading…</div>
|
||||
<div class="grid">
|
||||
<div class="card"><h2>Session</h2><div id="session" class="mono"></div></div>
|
||||
<div class="card"><h2>Strategy</h2><div id="strategy"></div></div>
|
||||
<div class="card"><h2>UP</h2><div id="up" class="mono"></div></div>
|
||||
<div class="card"><h2>DOWN</h2><div id="down" class="mono"></div></div>
|
||||
<div class="card btc"><h2>BTC / USD (Chainlink)</h2><div id="btc" class="mono"></div></div>
|
||||
<div class="card"><h2>Trading</h2><div id="trading" class="mono"></div></div>
|
||||
</div>
|
||||
<footer>Refreshes every second · <span id="err"></span></footer>
|
||||
<script>
|
||||
/* No optional chaining (?.) — must run in older browsers / Edge legacy. */
|
||||
function esc(s) {
|
||||
if (s === null || s === undefined) return "";
|
||||
var el = document.createElement("div");
|
||||
el.textContent = String(s);
|
||||
return el.innerHTML;
|
||||
}
|
||||
function sigClass(t) {
|
||||
if (!t) return "wait";
|
||||
if (t.indexOf("BUY") >= 0) return "buy";
|
||||
/* Do not use \\uD83D\\uDEAB here: Python treats \\u.... in the template as escapes and emits invalid UTF-8 surrogates. */
|
||||
if (t.indexOf("NO ENTRY") >= 0) return "block";
|
||||
return "wait";
|
||||
}
|
||||
function numFmt(n, dec) {
|
||||
if (n === null || n === undefined || typeof n !== "number" || isNaN(n)) return "\u2014";
|
||||
return n.toFixed(dec);
|
||||
}
|
||||
function tick() {
|
||||
var errEl = document.getElementById("err");
|
||||
var r = new XMLHttpRequest();
|
||||
r.open("GET", "/api/state", true);
|
||||
r.onreadystatechange = function () {
|
||||
if (r.readyState !== 4) return;
|
||||
try {
|
||||
if (r.status !== 200) throw new Error("HTTP " + r.status);
|
||||
var d = JSON.parse(r.responseText);
|
||||
errEl.textContent = "";
|
||||
var hdr = d.header || {};
|
||||
var slug = hdr.slug != null ? String(hdr.slug) : "\u2014";
|
||||
var ts = "";
|
||||
if (d.ts) ts = new Date(d.ts * 1000).toISOString();
|
||||
document.getElementById("meta").innerHTML = esc(slug) + " \u00b7 " + esc(ts);
|
||||
document.getElementById("session").innerHTML = [
|
||||
"Timer: " + (hdr.time_left_sec != null ? esc(Math.floor(hdr.time_left_sec) + "s left") : "\u2014"),
|
||||
"WS: " + (hdr.ws_connected ? "live" : "disconnected"),
|
||||
"Mode: " + (hdr.simulation ? "simulation" : "real"),
|
||||
].join("<br/>");
|
||||
var st = d.strategy || {};
|
||||
var sig = st.signal_text || "\u2014";
|
||||
function chk(x) { return x === true ? "\u2713" : x === false ? "\u2717" : "\u2014"; }
|
||||
var ck = st.checks || {};
|
||||
document.getElementById("strategy").innerHTML =
|
||||
'<div class="sig ' + sigClass(sig) + '">' + esc(sig) + "</div>" +
|
||||
'<div class="mono" style="margin-top:0.4rem">' +
|
||||
"Fav: " + esc(st.favorite) + " \u00b7 WR: " + esc(st.win_rate_str) + "<br/>" +
|
||||
"Checks: P=" + chk(ck.price) + " T=" + chk(ck.time) + " D=" + chk(ck.dev) +
|
||||
" M=" + chk(ck.mom) + " cutoff=" + chk(ck.time_cutoff) +
|
||||
"</div>";
|
||||
function book(x, id) {
|
||||
var el = document.getElementById(id);
|
||||
if (!x) { el.textContent = "No data"; return; }
|
||||
var bk = x.book || {};
|
||||
var ind = x.indicators || {};
|
||||
el.innerHTML = [
|
||||
"Last " + esc(bk.last_price),
|
||||
"Bid " + esc(bk.best_bid) + " / Ask " + esc(bk.best_ask),
|
||||
"VWAP " + numFmt(ind.vwap, 4) +
|
||||
" \u00b7 Dev " + (ind.deviation_pct != null ? numFmt(ind.deviation_pct, 2) + "%" : "\u2014"),
|
||||
"Z " + numFmt(ind.zscore, 2) +
|
||||
" \u00b7 Mom " + (ind.momentum_pct != null ? numFmt(ind.momentum_pct, 2) + "%" : "\u2014"),
|
||||
"Vol " + (bk.volume_total != null ? esc(Math.round(bk.volume_total)) : "\u2014"),
|
||||
].join("<br/>");
|
||||
}
|
||||
book(d.up, "up");
|
||||
book(d.down, "down");
|
||||
var b = d.btc || {};
|
||||
var btcEl = document.getElementById("btc");
|
||||
if (b.btc_current_price > 0) {
|
||||
btcEl.innerHTML = [
|
||||
"$" + esc(numFmt(b.btc_current_price, 2)),
|
||||
"Anchor $" + (b.btc_anchor_price > 0 ? esc(numFmt(b.btc_anchor_price, 2)) : "\u2014"),
|
||||
esc(b.deviation_line || ""),
|
||||
"Feed: " + (b.btc_connected ? "ok" : "off") +
|
||||
(b.fresh_sec != null ? " \u00b7 " + Math.floor(b.fresh_sec) + "s" : ""),
|
||||
].join("<br/>");
|
||||
} else {
|
||||
btcEl.textContent = "Waiting for Chainlink\u2026";
|
||||
}
|
||||
var tr = d.trading || {};
|
||||
var tHtml = "Markets " + esc(tr.markets_seen) + " \u00b7 Trades " + esc(tr.trade_count) +
|
||||
" \u00b7 PnL $" + (tr.total_pnl != null ? numFmt(tr.total_pnl, 2) : "\u2014") + "<br/>";
|
||||
if (tr.position) {
|
||||
var p = tr.position;
|
||||
tHtml += "LONG " + esc(p.token_name) + " @ " + esc(p.entry_price) +
|
||||
" \u00d7" + esc(p.contracts) + (p.hedged ? " hedged" : "") + "<br/>";
|
||||
tHtml += "Unreal $" + (p.unrealized_pnl != null ? numFmt(p.unrealized_pnl, 2) : "\u2014") + "<br/>";
|
||||
} else {
|
||||
tHtml += "No open position<br/>";
|
||||
}
|
||||
if (tr.recent_trades && tr.recent_trades.length) {
|
||||
var lines = [];
|
||||
for (var i = 0; i < tr.recent_trades.length; i++) {
|
||||
lines.push(esc(tr.recent_trades[i].line));
|
||||
}
|
||||
tHtml += "<br/>Recent:<br/>" + lines.join("<br/>");
|
||||
}
|
||||
document.getElementById("trading").innerHTML = tHtml;
|
||||
} catch (e) {
|
||||
errEl.textContent = "Poll error: " + (e && e.message ? e.message : e);
|
||||
}
|
||||
};
|
||||
r.onerror = function () {
|
||||
errEl.textContent = "Network error (is the bot running?)";
|
||||
};
|
||||
r.send();
|
||||
}
|
||||
tick();
|
||||
setInterval(tick, 1000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def _sanitize_for_json(obj: Any) -> Any:
|
||||
"""
|
||||
Starlette JSONResponse serializes with allow_nan=False; NaN/Inf break the ASGI handler.
|
||||
"""
|
||||
if obj is None:
|
||||
return None
|
||||
if isinstance(obj, bool):
|
||||
return obj
|
||||
if isinstance(obj, int) and not isinstance(obj, bool):
|
||||
return obj
|
||||
if isinstance(obj, float):
|
||||
if math.isnan(obj) or math.isinf(obj):
|
||||
return None
|
||||
return obj
|
||||
if isinstance(obj, str):
|
||||
return obj
|
||||
if isinstance(obj, dict):
|
||||
return {k: _sanitize_for_json(v) for k, v in obj.items()}
|
||||
if isinstance(obj, (list, tuple)):
|
||||
return [_sanitize_for_json(v) for v in obj]
|
||||
return obj
|
||||
|
||||
|
||||
class WebSnapshotHolder:
|
||||
"""Thread-safe snapshot for /api/state."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._data: Dict[str, Any] = {"status": "starting"}
|
||||
|
||||
def set(self, data: Dict[str, Any]) -> None:
|
||||
with self._lock:
|
||||
self._data = dict(data)
|
||||
|
||||
def get(self) -> Dict[str, Any]:
|
||||
with self._lock:
|
||||
return dict(self._data)
|
||||
|
||||
|
||||
def build_app(holder: WebSnapshotHolder) -> FastAPI:
|
||||
app = FastAPI(title="BTC Live Bot", docs_url=None, redoc_url=None)
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index() -> str:
|
||||
return _HTML
|
||||
|
||||
@app.get("/favicon.ico", include_in_schema=False)
|
||||
async def favicon() -> Response:
|
||||
return Response(status_code=204)
|
||||
|
||||
@app.get("/api/state")
|
||||
async def api_state():
|
||||
return JSONResponse(_sanitize_for_json(holder.get()))
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def _client_probe_address(bind_host: str) -> str:
|
||||
"""Address to test with socket.connect(); 0.0.0.0 / :: are not valid client targets."""
|
||||
if bind_host in ("0.0.0.0", ""):
|
||||
return "127.0.0.1"
|
||||
if bind_host in ("::", "[::]"):
|
||||
return "::1"
|
||||
return bind_host
|
||||
|
||||
|
||||
def start_web_dashboard(host: str, port: int, holder: WebSnapshotHolder) -> bool:
|
||||
"""
|
||||
Start uvicorn in a daemon thread. Returns True if the port accepts connections
|
||||
shortly after start (False if bind failed or port is in use).
|
||||
"""
|
||||
app = build_app(holder)
|
||||
|
||||
def run() -> None:
|
||||
try:
|
||||
uvicorn.run(
|
||||
app,
|
||||
host=host,
|
||||
port=port,
|
||||
log_level="warning",
|
||||
access_log=False,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Web dashboard: uvicorn exited with an error")
|
||||
|
||||
t = threading.Thread(target=run, name="web-dashboard", daemon=True)
|
||||
t.start()
|
||||
|
||||
probe = _client_probe_address(host)
|
||||
for _ in range(60):
|
||||
time.sleep(0.1)
|
||||
try:
|
||||
with socket.create_connection((probe, port), timeout=0.4):
|
||||
return True
|
||||
except OSError:
|
||||
continue
|
||||
return False
|
||||
@@ -0,0 +1,345 @@
|
||||
#!/usr/bin/env python3
|
||||
"""WebSocket Client for Market + User channels."""
|
||||
import asyncio, json, logging, time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Optional, Dict, List, Callable, Set
|
||||
import websockets
|
||||
from websockets.exceptions import ConnectionClosed
|
||||
|
||||
from src.proxy_util import apply_proxy_env, ws_connect, ws_connect_kwargs as _ws_connect_kwargs
|
||||
|
||||
apply_proxy_env()
|
||||
|
||||
logger = logging.getLogger("btc_live.websocket")
|
||||
MARKET_WS_URL = "wss://ws-subscriptions-clob.polymarket.com/ws/market"
|
||||
USER_WS_URL = "wss://ws-subscriptions-clob.polymarket.com/ws/user"
|
||||
|
||||
|
||||
class ConnectionState(Enum):
|
||||
DISCONNECTED = "disconnected"
|
||||
CONNECTING = "connecting"
|
||||
CONNECTED = "connected"
|
||||
CLOSED = "closed"
|
||||
|
||||
@dataclass
|
||||
class TradeEvent:
|
||||
token_id: str; price: float; size: float; side: str
|
||||
timestamp: datetime = field(default_factory=datetime.now)
|
||||
|
||||
@dataclass
|
||||
class PriceUpdate:
|
||||
token_id: str; best_bid: float; best_ask: float
|
||||
timestamp: datetime = field(default_factory=datetime.now)
|
||||
|
||||
@dataclass
|
||||
class OrderUpdate:
|
||||
order_id: str
|
||||
asset_id: str
|
||||
side: str
|
||||
price: float
|
||||
original_size: float
|
||||
size_matched: float
|
||||
event_type: str
|
||||
status: str
|
||||
timestamp: datetime = field(default_factory=datetime.now)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TradeUpdate:
|
||||
trade_id: str
|
||||
asset_id: str
|
||||
price: float
|
||||
size: float
|
||||
side: str
|
||||
status: str
|
||||
taker_order_id: str = ""
|
||||
timestamp: datetime = field(default_factory=datetime.now)
|
||||
|
||||
|
||||
class MarketWebSocket:
|
||||
"""WebSocket client for Market channel."""
|
||||
|
||||
def __init__(self, on_trade=None, on_price=None, reconnect_delay=1.0, max_reconnect_delay=60.0):
|
||||
self.on_trade = on_trade
|
||||
self.on_price = on_price
|
||||
self.reconnect_delay = reconnect_delay
|
||||
self.max_reconnect_delay = max_reconnect_delay
|
||||
self._ws = None
|
||||
self._state = ConnectionState.DISCONNECTED
|
||||
self._subscribed_tokens: Set[str] = set()
|
||||
self._running = False
|
||||
self._reconnect_count = 0
|
||||
self.messages_received = 0
|
||||
self.trades_received = 0
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._state == ConnectionState.CONNECTED
|
||||
|
||||
async def connect(self, token_ids: List[str]) -> bool:
|
||||
if not token_ids:
|
||||
return False
|
||||
self._subscribed_tokens = set(token_ids)
|
||||
self._state = ConnectionState.CONNECTING
|
||||
try:
|
||||
# Use async with pattern to get a real AsyncContextManager from websockets.connect.
|
||||
# We exit the context once connected to keep the ws object alive for recv loop.
|
||||
ws_cm = ws_connect(MARKET_WS_URL, **_ws_connect_kwargs())
|
||||
self._ws = await asyncio.wait_for(
|
||||
ws_cm.__aenter__(),
|
||||
timeout=30
|
||||
)
|
||||
# Register for cleanup — we own the lifetime now
|
||||
self._ws_cm = ws_cm
|
||||
await self._ws.send(json.dumps({"type": "market", "assets_ids": list(token_ids)}))
|
||||
self._state = ConnectionState.CONNECTED
|
||||
self._reconnect_count = 0
|
||||
logger.info(f"Market WS connected, subscribed to {len(token_ids)} tokens")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Market WS connect error: {e}")
|
||||
self._state = ConnectionState.DISCONNECTED
|
||||
return False
|
||||
|
||||
async def _process_message(self, data):
|
||||
try:
|
||||
items = data if isinstance(data, list) else [data]
|
||||
for item in items:
|
||||
event_type = item.get("event_type", "")
|
||||
if event_type == "last_trade_price":
|
||||
trade = TradeEvent(
|
||||
token_id=item.get("asset_id", ""),
|
||||
price=float(item.get("price", 0)),
|
||||
size=float(item.get("size", 0)),
|
||||
side=item.get("side", ""),
|
||||
)
|
||||
self.trades_received += 1
|
||||
if self.on_trade:
|
||||
if asyncio.iscoroutinefunction(self.on_trade):
|
||||
await self.on_trade(trade)
|
||||
else:
|
||||
self.on_trade(trade)
|
||||
elif event_type == "best_bid_ask":
|
||||
update = PriceUpdate(
|
||||
token_id=item.get("asset_id", ""),
|
||||
best_bid=float(item.get("best_bid", 0) or 0),
|
||||
best_ask=float(item.get("best_ask", 0) or 0),
|
||||
)
|
||||
if self.on_price:
|
||||
if asyncio.iscoroutinefunction(self.on_price):
|
||||
await self.on_price(update)
|
||||
else:
|
||||
self.on_price(update)
|
||||
except Exception as e:
|
||||
logger.error(f"Process error: {e}")
|
||||
|
||||
async def _receive_loop(self):
|
||||
while self._running and self._ws:
|
||||
try:
|
||||
msg = await asyncio.wait_for(self._ws.recv(), timeout=30)
|
||||
self.messages_received += 1
|
||||
await self._process_message(json.loads(msg))
|
||||
except asyncio.TimeoutError:
|
||||
if self._ws and getattr(self._ws, "open", True):
|
||||
try:
|
||||
await asyncio.wait_for(self._ws.ping(), timeout=5)
|
||||
except Exception:
|
||||
break
|
||||
else:
|
||||
break
|
||||
except ConnectionClosed:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Receive error: {e}")
|
||||
break
|
||||
|
||||
async def run_loop(self, token_ids: List[str]):
|
||||
self._running = True
|
||||
self._subscribed_tokens = set(token_ids)
|
||||
while self._running:
|
||||
try:
|
||||
if not await self.connect(list(self._subscribed_tokens)):
|
||||
delay = min(self.reconnect_delay * (2 ** self._reconnect_count), self.max_reconnect_delay)
|
||||
await asyncio.sleep(delay)
|
||||
self._reconnect_count += 1
|
||||
continue
|
||||
await self._receive_loop()
|
||||
if self._running:
|
||||
delay = min(self.reconnect_delay * (2 ** self._reconnect_count), self.max_reconnect_delay)
|
||||
await asyncio.sleep(delay)
|
||||
self._reconnect_count += 1
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Loop error: {e}")
|
||||
await asyncio.sleep(self.reconnect_delay)
|
||||
await self.close()
|
||||
|
||||
async def close(self):
|
||||
self._running = False
|
||||
self._state = ConnectionState.CLOSED
|
||||
ws = self._ws
|
||||
cm = getattr(self, "_ws_cm", None)
|
||||
self._ws = None
|
||||
self._ws_cm = None
|
||||
if ws and not getattr(ws, "closed", True):
|
||||
try:
|
||||
await ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
if cm is not None:
|
||||
try:
|
||||
await cm.__aexit__(None, None, None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def stop(self):
|
||||
self._running = False
|
||||
|
||||
|
||||
class UserWebSocket:
|
||||
"""WebSocket client for User channel with auth."""
|
||||
|
||||
def __init__(self, api_key, api_secret, api_passphrase, on_order=None, on_trade=None):
|
||||
self.api_key = api_key
|
||||
self.api_secret = api_secret
|
||||
self.api_passphrase = api_passphrase
|
||||
self.on_order = on_order
|
||||
self.on_trade = on_trade
|
||||
self._ws = None
|
||||
self._ws_cm = None
|
||||
self._state = ConnectionState.DISCONNECTED
|
||||
self._running = False
|
||||
self._reconnect_count = 0
|
||||
self._pending_orders: Dict[str, OrderUpdate] = {}
|
||||
self.messages_received = 0
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._state == ConnectionState.CONNECTED
|
||||
|
||||
async def connect(self) -> bool:
|
||||
self._state = ConnectionState.CONNECTING
|
||||
try:
|
||||
ws_cm = ws_connect(USER_WS_URL, **_ws_connect_kwargs(ping_interval=30, ping_timeout=10))
|
||||
self._ws = await asyncio.wait_for(
|
||||
ws_cm.__aenter__(),
|
||||
timeout=30
|
||||
)
|
||||
self._ws_cm = ws_cm
|
||||
msg = {
|
||||
"type": "user",
|
||||
"markets": [],
|
||||
"auth": {"apikey": self.api_key, "secret": self.api_secret, "passphrase": self.api_passphrase}
|
||||
}
|
||||
await self._ws.send(json.dumps(msg))
|
||||
self._state = ConnectionState.CONNECTED
|
||||
self._reconnect_count = 0
|
||||
logger.info("User WS connected")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"User WS connect error: {e}")
|
||||
self._state = ConnectionState.DISCONNECTED
|
||||
return False
|
||||
|
||||
async def _process_message(self, data: Dict):
|
||||
try:
|
||||
event_type = data.get("event_type", "")
|
||||
msg_type = data.get("type", "")
|
||||
if event_type == "order" or msg_type in ("PLACEMENT", "UPDATE", "CANCELLATION"):
|
||||
order = OrderUpdate(
|
||||
order_id=data.get("id", ""),
|
||||
asset_id=data.get("asset_id", ""),
|
||||
side=data.get("side", ""),
|
||||
price=float(data.get("price", 0)),
|
||||
original_size=float(data.get("original_size", 0)),
|
||||
size_matched=float(data.get("size_matched", 0)),
|
||||
event_type=data.get("type", msg_type),
|
||||
status=data.get("status", ""),
|
||||
)
|
||||
self._pending_orders[order.order_id] = order
|
||||
if self.on_order:
|
||||
if asyncio.iscoroutinefunction(self.on_order):
|
||||
await self.on_order(order)
|
||||
else:
|
||||
self.on_order(order)
|
||||
elif event_type == "trade" or msg_type == "TRADE":
|
||||
trade = TradeUpdate(
|
||||
trade_id=data.get("id", ""),
|
||||
asset_id=data.get("asset_id", ""),
|
||||
price=float(data.get("price", 0)),
|
||||
size=float(data.get("size", 0)),
|
||||
side=data.get("side", ""),
|
||||
status=data.get("status", ""),
|
||||
taker_order_id=data.get("taker_order_id", ""),
|
||||
)
|
||||
if self.on_trade:
|
||||
if asyncio.iscoroutinefunction(self.on_trade):
|
||||
await self.on_trade(trade)
|
||||
else:
|
||||
self.on_trade(trade)
|
||||
except Exception as e:
|
||||
logger.error(f"User msg process error: {e}")
|
||||
|
||||
async def _receive_loop(self):
|
||||
while self._running and self._ws:
|
||||
try:
|
||||
msg = await asyncio.wait_for(self._ws.recv(), timeout=60)
|
||||
self.messages_received += 1
|
||||
await self._process_message(json.loads(msg))
|
||||
except asyncio.TimeoutError:
|
||||
if self._ws and getattr(self._ws, "open", True):
|
||||
try:
|
||||
await asyncio.wait_for(self._ws.ping(), timeout=5)
|
||||
except Exception:
|
||||
break
|
||||
else:
|
||||
break
|
||||
except ConnectionClosed:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"User recv error: {e}")
|
||||
break
|
||||
|
||||
async def run_loop(self):
|
||||
self._running = True
|
||||
delay = 1.0
|
||||
while self._running:
|
||||
try:
|
||||
if not await self.connect():
|
||||
await asyncio.sleep(delay)
|
||||
delay = min(delay * 1.5, 60.0)
|
||||
continue
|
||||
delay = 1.0
|
||||
await self._receive_loop()
|
||||
if self._running:
|
||||
await asyncio.sleep(2.0)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"User WS loop error: {e}")
|
||||
await asyncio.sleep(2.0)
|
||||
await self.close()
|
||||
|
||||
async def close(self):
|
||||
self._running = False
|
||||
self._state = ConnectionState.CLOSED
|
||||
ws = self._ws
|
||||
cm = self._ws_cm
|
||||
self._ws = None
|
||||
self._ws_cm = None
|
||||
if ws and not getattr(ws, "closed", True):
|
||||
try:
|
||||
await ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
if cm is not None:
|
||||
try:
|
||||
await cm.__aexit__(None, None, None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def stop(self):
|
||||
self._running = False
|
||||
@@ -0,0 +1,81 @@
|
||||
# =============================================================================
|
||||
# Meridian — Polymarket 15m crypto desk (environment)
|
||||
# =============================================================================
|
||||
# Copy this file to .env and fill in your values:
|
||||
# cp .env.example .env
|
||||
#
|
||||
# NEVER commit .env with real keys to git!
|
||||
# =============================================================================
|
||||
|
||||
# =============================================================================
|
||||
# WALLET CREDENTIALS (REQUIRED)
|
||||
# =============================================================================
|
||||
|
||||
# Your Polygon wallet private key
|
||||
# Format: 64 hex characters with 0x prefix (66 characters total)
|
||||
#
|
||||
# How to get:
|
||||
# - MetaMask: Settings -> Security & Privacy -> Export Private Key
|
||||
# - Polymarket: polymarket.com/settings -> Export Private Key
|
||||
#
|
||||
# WARNING: Never share this key or commit it to git!
|
||||
PRIVATE_KEY=0x0000000000000000000000000000000000000000000000000000000000000000
|
||||
|
||||
# =============================================================================
|
||||
# POLYGON NETWORK (REQUIRED)
|
||||
# =============================================================================
|
||||
|
||||
# Polygon RPC endpoint
|
||||
# Free options:
|
||||
# - https://polygon-rpc.com (public, rate limited)
|
||||
# - https://rpc.ankr.com/polygon (public, rate limited)
|
||||
#
|
||||
# Recommended (private RPC):
|
||||
# - Alchemy: https://polygon-mainnet.g.alchemy.com/v2/YOUR_API_KEY
|
||||
# - Infura: https://polygon-mainnet.infura.io/v3/YOUR_PROJECT_ID
|
||||
# - QuickNode: https://your-endpoint.quiknode.pro/YOUR_KEY/
|
||||
#
|
||||
# Get free API key:
|
||||
# - Alchemy: https://www.alchemy.com/
|
||||
# - Ankr: https://www.ankr.com/rpc/
|
||||
RPC_URL=https://polygon-rpc.com
|
||||
|
||||
# Polygon chain ID (do not change)
|
||||
CHAIN_ID=137
|
||||
|
||||
# =============================================================================
|
||||
# POLYMARKET API (REQUIRED)
|
||||
# =============================================================================
|
||||
|
||||
# Polymarket CLOB API host (do not change)
|
||||
CLOB_HOST=https://clob.polymarket.com
|
||||
|
||||
# API Credentials
|
||||
# How to get:
|
||||
# 1. Go to polymarket.com/settings
|
||||
# 2. Connect your wallet
|
||||
# 3. Generate API credentials
|
||||
# 4. Copy the values here
|
||||
#
|
||||
# Or generate programmatically using py-clob-client library
|
||||
POLYMARKET_API_KEY=your_api_key_here
|
||||
POLYMARKET_API_SECRET=your_api_secret_here
|
||||
POLYMARKET_API_PASSPHRASE=your_api_passphrase_here
|
||||
|
||||
# =============================================================================
|
||||
# TELEGRAM NOTIFICATIONS (OPTIONAL)
|
||||
# =============================================================================
|
||||
|
||||
# Telegram Bot Token
|
||||
# How to get:
|
||||
# 1. Open Telegram and search for @BotFather
|
||||
# 2. Send /newbot and follow instructions
|
||||
# 3. Copy the token here
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
|
||||
# Telegram Chat ID
|
||||
# How to get:
|
||||
# 1. Open Telegram and search for @userinfobot
|
||||
# 2. Send /start
|
||||
# 3. Copy your numeric ID here
|
||||
TELEGRAM_CHAT_ID=
|
||||
@@ -0,0 +1,112 @@
|
||||
# =============================================================================
|
||||
# Meridian / Polymarket trading bot — git ignore
|
||||
# =============================================================================
|
||||
|
||||
# =============================================================================
|
||||
# SENSITIVE DATA - NEVER COMMIT!
|
||||
# =============================================================================
|
||||
.env
|
||||
*.env
|
||||
!.env.example
|
||||
config/config.json
|
||||
!config/config.example.json
|
||||
|
||||
# =============================================================================
|
||||
# Python Virtual Environment
|
||||
# =============================================================================
|
||||
venv/
|
||||
.venv/
|
||||
env/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# =============================================================================
|
||||
# Python Cache
|
||||
# =============================================================================
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# =============================================================================
|
||||
# Logs and Data
|
||||
# =============================================================================
|
||||
logs/
|
||||
logs_detailed/
|
||||
*.log
|
||||
*.jsonl
|
||||
*.csv
|
||||
|
||||
# =============================================================================
|
||||
# Backup Files
|
||||
# =============================================================================
|
||||
*.backup
|
||||
*.backup2
|
||||
*.bak
|
||||
*.before_*
|
||||
*.broken
|
||||
*_backup_*.tar.gz
|
||||
|
||||
# =============================================================================
|
||||
# Analysis Files
|
||||
# =============================================================================
|
||||
analysis_*.md
|
||||
trade_analysis_*.csv
|
||||
*_ANALYSIS*.md
|
||||
*_FIX*.md
|
||||
*_REPORT*.md
|
||||
*_CHANGES*.md
|
||||
|
||||
# =============================================================================
|
||||
# IDE / Editor
|
||||
# =============================================================================
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.project
|
||||
.pydevproject
|
||||
.settings/
|
||||
|
||||
# =============================================================================
|
||||
# OS Files
|
||||
# =============================================================================
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# =============================================================================
|
||||
# Temporary Files
|
||||
# =============================================================================
|
||||
*.tmp
|
||||
*.temp
|
||||
/tmp/
|
||||
|
||||
# =============================================================================
|
||||
# Charts and Images
|
||||
# =============================================================================
|
||||
*.png
|
||||
*.jpg
|
||||
*.jpeg
|
||||
@@ -0,0 +1,283 @@
|
||||
# Meridian — Polymarket Multi-Asset Crypto Desk
|
||||
|
||||
Automated execution for Polymarket **5- or 15-minute** crypto Up/Down markets. Runs **four parallel traders** (BTC, ETH, SOL, XRP) from **one Polygon wallet**, using **Late Entry V3** (`late_v3`): entries only in the **final minutes**, when **spread** and **confidence** (ask skew) show a **clear favorite**, with **stop-loss**, **flip-stop**, and **safety guard** limits.
|
||||
|
||||
| Resource | Link |
|
||||
|----------|------|
|
||||
| **Suite overview** | [Repository README](../README.md) |
|
||||
| **Full guide** | [docs/README.md](docs/README.md) |
|
||||
| **GitHub** | [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:** Very late in the window, token prices often **embed** a view on how the underlying will fix vs the strike. Meridian **follows the book’s favorite** (higher ask) but **filters** noise: **tight sum of asks** (liquidity sanity), **minimum skew** (`min_confidence`), and **max price** cap so you do not pay **too much** for the last bit of certainty.
|
||||
|
||||
**Profit source (when it exists):** If the **favorite wins** more often than its **entry price** implies (e.g. buy at $0.72 and wins >72% in that regime), expectancy can be positive. **Shorter exposure** vs early entry can reduce **path risk** but usually **increases** average entry price.
|
||||
|
||||
**Risk controls:** **Dry run**, **per-order and per-market caps**, **stop-loss** (fixed $ or % of stake), **flip-stop** when your side loses leadership, **entry cooldown**, and validated prices before exits (fresh book, ask sum in range). See [docs/README.md](docs/README.md) for exact formulas.
|
||||
|
||||
**When to use:** You want **several coins**, **one wallet**, **terminal + optional web dashboard**, and **explicit exit rules**. **When not to:** You need **BTC-only VWAP/momentum** filters—use `btc-binary-VWAP-Momentum-bot`—or **PTB vs spot diff** rules—use `5min-15min-PTB-bot`.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **Multi-Market Trading** — Trade 4 cryptocurrencies in parallel (BTC, ETH, SOL, XRP)
|
||||
- **Late Entry Strategy** — Enter positions in the last 4 minutes before market close
|
||||
- **Real-time WebSocket Data** — Live orderbook updates from Polymarket
|
||||
- **Automatic Redeem** — Background collection of winnings after market resolution
|
||||
- **Telegram Integration** — Commands for monitoring, charts, balance, and emergency shutdown
|
||||
- **Safety Guard** — Protection layer with order limits and emergency stop
|
||||
- **Position Tracking** — Real-time position monitoring via REST API
|
||||
- **Stop-Loss & Flip-Stop** — Configurable exit strategies per coin
|
||||
- **PnL Charts** — Visual performance tracking with matplotlib
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ MAIN TRADING LOOP │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ BTC │ │ ETH │ │ SOL │ │ XRP │ │
|
||||
│ │ Trader │ │ Trader │ │ Trader │ │ Trader │ │
|
||||
│ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │
|
||||
│ └──────────┬─┴───────────┬┘──────────┘ │
|
||||
│ ┌───┴───┐ ┌────┴────┐ │
|
||||
│ │ Order │ │ Data │ │
|
||||
│ │Executor│ │ Feed │ │
|
||||
│ └───────┘ └─────────┘ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.10 or higher
|
||||
- Polygon wallet with USDC (bridged)
|
||||
- Small amount of POL/MATIC for gas fees
|
||||
- Polymarket API credentials
|
||||
- VPN (if needed for geo-restrictions)
|
||||
|
||||
## Installation
|
||||
|
||||
### 1. Clone the Repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/PolyBullLabs/polymakret-5min-15min-1hour-arbitrage-bot.git
|
||||
cd polymakret-5min-15min-1hour-arbitrage-bot/up-down-spread-bot
|
||||
```
|
||||
|
||||
### 2. Create Virtual Environment
|
||||
|
||||
**IMPORTANT: You must use a virtual environment (venv)!**
|
||||
|
||||
```bash
|
||||
# Create venv
|
||||
python3 -m venv venv
|
||||
|
||||
# Activate venv
|
||||
# Linux/macOS:
|
||||
source venv/bin/activate
|
||||
|
||||
# Windows:
|
||||
.\venv\Scripts\activate
|
||||
```
|
||||
|
||||
### 3. Install Dependencies
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 4. Configuration
|
||||
|
||||
```bash
|
||||
# Copy configuration files
|
||||
cp .env.example .env
|
||||
cp config/config.example.json config/config.json
|
||||
|
||||
# Edit .env with your credentials
|
||||
nano .env
|
||||
|
||||
# Edit config.json for trading parameters
|
||||
nano config/config.json
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables (.env)
|
||||
|
||||
```env
|
||||
# Wallet (REQUIRED)
|
||||
PRIVATE_KEY=0x...your_private_key...
|
||||
|
||||
# Polygon Network
|
||||
RPC_URL=https://polygon-rpc.com
|
||||
CHAIN_ID=137
|
||||
|
||||
# Polymarket API (REQUIRED)
|
||||
CLOB_HOST=https://clob.polymarket.com
|
||||
POLYMARKET_API_KEY=your_api_key
|
||||
POLYMARKET_API_SECRET=your_api_secret
|
||||
POLYMARKET_API_PASSPHRASE=your_api_passphrase
|
||||
|
||||
# Telegram Notifications (optional)
|
||||
TELEGRAM_BOT_TOKEN=your_bot_token
|
||||
TELEGRAM_CHAT_ID=your_chat_id
|
||||
```
|
||||
|
||||
### Trading Configuration (config/config.json)
|
||||
|
||||
Key parameters:
|
||||
|
||||
| Section | Parameter | Description |
|
||||
|---------|-----------|-------------|
|
||||
| `safety.dry_run` | `true/false` | Enable dry run mode (no real trades) |
|
||||
| `safety.max_order_size_usd` | `150` | Maximum single order size in USD |
|
||||
| `safety.max_total_investment` | `1000` | Maximum investment per market |
|
||||
| `trading.btc/eth/sol/xrp.enabled` | `true/false` | Enable/disable specific coins |
|
||||
| `data_sources.polymarket.market_window` | `"15m"` or `"5m"` | **Which Polymarket horizon:** 15-minute or 5-minute Up/Down markets |
|
||||
| `strategy.entry_window_sec` | `240` | Entry window (last 4 minutes) |
|
||||
| `strategy.min_confidence` | `0.30` | Minimum price difference to enter |
|
||||
| `strategy.price_max` | `0.92` | Maximum entry price |
|
||||
| `exit.stop_loss.per_coin.*.value` | `-12` | Stop-loss threshold in USD |
|
||||
|
||||
## Usage
|
||||
|
||||
### Start Trading
|
||||
|
||||
```bash
|
||||
# Activate virtual environment
|
||||
source venv/bin/activate
|
||||
|
||||
# Run the trading bot
|
||||
cd src
|
||||
python3 main.py
|
||||
```
|
||||
|
||||
### Keyboard Controls
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `Q` | Quit gracefully |
|
||||
| `E` | Emergency stop (blocks all trading) |
|
||||
|
||||
### Telegram Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/chart` or `/pnl` | Generate current PnL chart |
|
||||
| `/b` or `/balance` | Show wallet balance (USDC + POL) |
|
||||
| `/t` or `/positions` | Show active positions |
|
||||
| `/r` or `/redeem` | Redeem completed markets (interactive) |
|
||||
| `/off` or `/stop` | Emergency shutdown (with confirmation) |
|
||||
| `/help` | Show all available commands |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
up-down-spread-bot/
|
||||
├── src/
|
||||
│ ├── main.py # Main entry point
|
||||
│ ├── strategy.py # Late Entry V3 strategy
|
||||
│ ├── data_feed.py # WebSocket data feeds
|
||||
│ ├── multi_trader.py # Multi-market trader manager
|
||||
│ ├── trader.py # Individual trader logic
|
||||
│ ├── order_executor.py # Order execution engine
|
||||
│ ├── position_tracker.py # Real-time position tracking
|
||||
│ ├── safety_guard.py # Safety limits and emergency stop
|
||||
│ ├── simple_redeem_collector.py # Automatic redeem collection
|
||||
│ ├── telegram_notifier.py # Telegram bot integration
|
||||
│ ├── dashboard_multi_ab.py # Terminal dashboard
|
||||
│ ├── polymarket_api.py # Polymarket API wrapper
|
||||
│ ├── pnl_chart_generator.py # PnL chart generation
|
||||
│ ├── trade_logger.py # Trade logging
|
||||
│ └── keyboard_listener.py # Keyboard input handler
|
||||
├── config/
|
||||
│ └── config.json # Trading configuration
|
||||
├── logs/ # Log files
|
||||
├── requirements.txt # Python dependencies
|
||||
├── .env # Environment variables
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Strategy: late-window entry (Late Entry V3)
|
||||
|
||||
Meridian uses the Late Entry V3 / `late_v3` entry rules:
|
||||
|
||||
1. **Entry Window**: Only enter positions in the last 4 minutes (240 seconds) before market close
|
||||
2. **Favorite Detection**: Buy the side with higher ask price (market consensus)
|
||||
3. **Confidence Filter**: Only enter when price difference exceeds 30%
|
||||
4. **Time-based Sizing**:
|
||||
- Above 180s remaining: 8 contracts
|
||||
- Above 120s remaining: 10 contracts
|
||||
- Below 120s remaining: 12 contracts
|
||||
5. **Exit Strategies**:
|
||||
- Natural close (market resolution)
|
||||
- Stop-loss (configurable per coin)
|
||||
- Flip-stop (when our position becomes underdog)
|
||||
|
||||
## Safety Features
|
||||
|
||||
- **Dry Run Mode**: Test without real trades
|
||||
- **Order Size Limits**: Maximum per-order and per-market limits
|
||||
- **Rate Limiting**: Maximum orders per minute
|
||||
- **Emergency Stop**: Keyboard shortcut to halt all trading
|
||||
- **Investment Tracking**: Per-market investment limits
|
||||
- **Position Persistence**: Save positions on shutdown
|
||||
|
||||
## Logs
|
||||
|
||||
Logs are stored in the `logs/` directory:
|
||||
|
||||
- `trades.jsonl` — All executed trades (JSON Lines format)
|
||||
- `orders.jsonl` — Order execution details
|
||||
- `safety.log` — Safety guard events
|
||||
- `session.json` — Current session state
|
||||
- `error.log` — Error messages
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Rate limit exceeded"
|
||||
|
||||
Use a private RPC endpoint:
|
||||
```env
|
||||
RPC_URL=https://polygon-mainnet.g.alchemy.com/v2/YOUR_API_KEY
|
||||
```
|
||||
|
||||
### "Invalid signature"
|
||||
|
||||
1. Check that API credentials are correct
|
||||
2. Verify the private key matches the Polymarket account
|
||||
3. Regenerate API credentials on Polymarket
|
||||
|
||||
### WebSocket connection drops
|
||||
|
||||
The bot automatically reconnects. If persistent:
|
||||
1. Check internet connection
|
||||
2. Use a VPN
|
||||
3. Change DNS to 1.1.1.1 or 8.8.8.8
|
||||
|
||||
### Positions not redeeming
|
||||
|
||||
1. Wait for oracle resolution (1-2 minutes after market close)
|
||||
2. Use `/r` command in Telegram to manually trigger
|
||||
3. Check `logs/` for error messages
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **USDC Type**: Polymarket uses USDC (Bridged), not USDC.e (Native)
|
||||
2. **Gas Fees**: Keep POL/MATIC balance for transactions
|
||||
3. **API Limits**: Public RPCs have rate limits — use private RPC for stability
|
||||
4. **Risks**: Cryptocurrency trading involves significant risks
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This software is for **educational and research purposes only**. Trading on prediction markets involves **substantial risk**; you may lose **all** funds you deploy. **Past results do not guarantee future performance.** Authors are **not** liable for losses. For **licensing**, **custom strategies**, or **advanced quant tooling** (Kelly, Monte Carlo, martingale / anti-martingale frameworks, RSI/MACD/Bollinger stacks, Bayesian edge models, etc.), contact [@terauss](https://t.me/terauss). See the [repository README](../README.md).
|
||||
@@ -0,0 +1,142 @@
|
||||
{
|
||||
"safety": {
|
||||
"dry_run": true,
|
||||
"max_order_size_usd": 150.0,
|
||||
"max_orders_per_minute": 100,
|
||||
"max_total_investment": 1000.0
|
||||
},
|
||||
"trading": {
|
||||
"btc": {
|
||||
"enabled": true,
|
||||
"reason": ""
|
||||
},
|
||||
"eth": {
|
||||
"enabled": true,
|
||||
"reason": ""
|
||||
},
|
||||
"sol": {
|
||||
"enabled": true,
|
||||
"reason": ""
|
||||
},
|
||||
"xrp": {
|
||||
"enabled": false,
|
||||
"reason": "Disabled by default - less liquid market"
|
||||
}
|
||||
},
|
||||
"strategy": {
|
||||
"name": "late_entry_v3",
|
||||
"entry_window_sec": 240,
|
||||
"entry_frequency_sec": 7,
|
||||
"min_confidence": 0.30,
|
||||
"max_spread": 1.05,
|
||||
"price_max": 0.92,
|
||||
"max_investment_per_market": 300,
|
||||
"sizing": {
|
||||
"above_180_sec": 8,
|
||||
"above_120_sec": 10,
|
||||
"below_120_sec": 12
|
||||
}
|
||||
},
|
||||
"exit": {
|
||||
"flip_stop": {
|
||||
"enabled": true,
|
||||
"price_threshold": 0.48,
|
||||
"check_realtime": true
|
||||
},
|
||||
"stop_loss": {
|
||||
"enabled": true,
|
||||
"check_realtime": true,
|
||||
"per_coin": {
|
||||
"btc": {
|
||||
"enabled": true,
|
||||
"type": "fixed",
|
||||
"value": -12.0
|
||||
},
|
||||
"eth": {
|
||||
"enabled": true,
|
||||
"type": "fixed",
|
||||
"value": -12.0
|
||||
},
|
||||
"sol": {
|
||||
"enabled": true,
|
||||
"type": "fixed",
|
||||
"value": -12.0
|
||||
},
|
||||
"xrp": {
|
||||
"enabled": true,
|
||||
"type": "fixed",
|
||||
"value": -11.0
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"execution": {
|
||||
"buy": {
|
||||
"order_type": "FAK",
|
||||
"max_fak_attempts": 3,
|
||||
"retry_delay_sec": 0.3,
|
||||
"min_order_usd": 1.0,
|
||||
"target_fill_percent": 98.0
|
||||
},
|
||||
"sell": {
|
||||
"strategy": "FOK_CHUNKED",
|
||||
"chunk_size": 50,
|
||||
"chunk_delay_sec": 0.1,
|
||||
"max_chunk_retries": 5,
|
||||
"price": 0.01,
|
||||
"min_dust_threshold": 0.1,
|
||||
"sweep_max_attempts": 3,
|
||||
"sweep_retry_delay_sec": 0.1,
|
||||
"sweep_enable_fallback": true,
|
||||
"sweep_fak_attempts": 3,
|
||||
"sweep_market_price": 0.01,
|
||||
"delayed_sweep_enabled": true,
|
||||
"delayed_sweep_delay_sec": 1,
|
||||
"delayed_sweep_min_balance": 0.1,
|
||||
"delayed_sweep_fok_attempts": 3,
|
||||
"delayed_sweep_fak_attempts": 2,
|
||||
"delayed_sweep_retry_delay_sec": 1.0
|
||||
},
|
||||
"redeem": {
|
||||
"startup_check_delay_sec": 60,
|
||||
"first_check_delay_sec": 480,
|
||||
"check_interval_sec": 300,
|
||||
"pause_between_redeems_sec": 1,
|
||||
"sizeThreshold": 0.1,
|
||||
"api_max_retries": 3,
|
||||
"api_retry_delay_sec": 60,
|
||||
"api_timeout_sec": 30,
|
||||
"gas_limit": 500000,
|
||||
"gas_price_multiplier": 1.5,
|
||||
"tx_confirmation_timeout_sec": 180
|
||||
},
|
||||
"rpc_config": {
|
||||
"endpoints": [
|
||||
"https://polygon-rpc.com"
|
||||
],
|
||||
"single_request_timeout_sec": 2,
|
||||
"parallel_timeout_sec": 3,
|
||||
"retry_attempts": 10,
|
||||
"retry_delay_sec": 0.3,
|
||||
"enable_parallel_requests": true
|
||||
}
|
||||
},
|
||||
"data_sources": {
|
||||
"polymarket": {
|
||||
"gamma_api": "https://gamma-api.polymarket.com",
|
||||
"ws_url": "wss://ws-subscriptions-clob.polymarket.com/ws/market",
|
||||
"market_window": "15m"
|
||||
}
|
||||
},
|
||||
"display": {
|
||||
"width": 160,
|
||||
"update_interval": 1
|
||||
},
|
||||
"logging": {
|
||||
"trades_file": "logs/trades.jsonl",
|
||||
"session_file": "logs/session.json"
|
||||
},
|
||||
"notifications": {
|
||||
"chart_every_n_markets": 10
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,950 @@
|
||||
# Meridian — Polymarket Multi-Asset Crypto Desk (Complete Guide)
|
||||
|
||||
**Meridian** is the product name for this Python system: it trades **Polymarket 5- or 15-minute** crypto Up/Down markets for **BTC, ETH, SOL, and XRP** in parallel, using a **late-window entry** model (implementation: **Late Entry V3** / `late_v3`).
|
||||
|
||||
| Suite | [github.com/PolyBullLabs/polymakret-5min-15min-1hour-arbitrage-bot](https://github.com/PolyBullLabs/polymakret-5min-15min-1hour-arbitrage-bot) · [@terauss](https://t.me/terauss) · [Top-level README](../../README.md) |
|
||||
|
||||
**Educational use:** This guide explains **mechanics**, **risk**, and **configuration**. It is **not** financial advice. **No edge is guaranteed.**
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [What Is This Bot?](#1-what-is-this-bot)
|
||||
2. [How Polymarket 15-Minute Markets Work](#2-how-polymarket-15-minute-markets-work)
|
||||
3. [How to Run the Bot (Step by Step)](#3-how-to-run-the-bot-step-by-step)
|
||||
4. [Strategy: Late Entry V3 (Detailed)](#4-strategy-late-entry-v3-detailed)
|
||||
5. [Exit Mechanisms (Detailed)](#5-exit-mechanisms-detailed)
|
||||
6. [Order Execution (Detailed)](#6-order-execution-detailed)
|
||||
7. [Data Feed & WebSocket](#7-data-feed--websocket)
|
||||
8. [Configuration Reference](#8-configuration-reference)
|
||||
9. [Environment Variables Reference](#9-environment-variables-reference)
|
||||
10. [Dashboard & Terminal UI](#10-dashboard--terminal-ui)
|
||||
11. [Web Dashboard (Browser)](#11-web-dashboard-browser)
|
||||
12. [Telegram Integration](#12-telegram-integration)
|
||||
13. [Safety Features](#13-safety-features)
|
||||
14. [Project Structure](#14-project-structure)
|
||||
15. [Troubleshooting](#15-troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## 1. What Is This Bot?
|
||||
|
||||
**Meridian** runs **four parallel traders** — one for each coin (BTC, ETH, SOL, XRP) — sharing a single Polygon wallet. Each trader independently watches its own Polymarket 15-minute prediction market and makes buy/sell decisions.
|
||||
|
||||
**What it trades:** Conditional outcome tokens (UP vs DOWN) on Polymarket markets with slugs like `btc-updown-15m-1711234500`.
|
||||
|
||||
**Core idea:** Wait until the last 4 minutes of a 15-minute market window, identify which side (UP or DOWN) the market favors, and buy the favorite. Then hold until the market resolves (and collect winnings) or exit early if the position turns bad.
|
||||
|
||||
---
|
||||
|
||||
## 2. How Polymarket 15-Minute Markets Work
|
||||
|
||||
Polymarket offers crypto prediction markets that resolve every 15 minutes:
|
||||
|
||||
- A new market opens every 15 minutes (aligned to epoch: 00, 15, 30, 45 past the hour).
|
||||
- Each market has two outcomes: **UP** (price goes up) and **DOWN** (price goes down).
|
||||
- Each outcome token trades between $0.00 and $1.00.
|
||||
- When the market resolves, the winning token pays **$1.00** and the losing token pays **$0.00**.
|
||||
|
||||
**Example timeline:**
|
||||
|
||||
```
|
||||
12:00 ─── Market opens: "Will BTC go up in the next 15 min?"
|
||||
UP ask: $0.50, DOWN ask: $0.50 (no consensus yet)
|
||||
...
|
||||
12:11 ─── Late Entry window starts (4 min before close)
|
||||
UP ask: $0.72, DOWN ask: $0.33 (market thinks UP)
|
||||
Bot buys 10 UP tokens at $0.72 → cost = $7.20
|
||||
...
|
||||
12:15 ─── Market resolves
|
||||
BTC went up → UP wins → 10 tokens x $1.00 = $10.00
|
||||
Profit = $10.00 - $7.20 = $2.80
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. How to Run the Bot (Step by Step)
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.10 or higher
|
||||
- A Polygon wallet with **USDC (Bridged)** — not USDC.e (Native)
|
||||
- A small amount of **POL/MATIC** for gas fees
|
||||
- Polymarket API credentials (API key, secret, passphrase)
|
||||
- A VPN if you're in a geo-restricted region
|
||||
|
||||
### Step 1: Clone the Repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/PolyBullLabs/polymakret-5min-15min-1hour-arbitrage-bot.git
|
||||
cd polymakret-5min-15min-1hour-arbitrage-bot/up-down-spread-bot
|
||||
```
|
||||
|
||||
### Step 2: Create a Virtual Environment
|
||||
|
||||
```bash
|
||||
# Create
|
||||
python -m venv venv
|
||||
|
||||
# Activate (Windows)
|
||||
.\venv\Scripts\activate
|
||||
|
||||
# Activate (Linux/macOS)
|
||||
source venv/bin/activate
|
||||
```
|
||||
|
||||
### Step 3: Install Dependencies
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### Step 4: Set Up Environment Variables
|
||||
|
||||
```bash
|
||||
# Copy the example file
|
||||
cp .env.example .env # Linux/macOS
|
||||
copy .env.example .env # Windows
|
||||
```
|
||||
|
||||
Open `.env` and fill in your credentials:
|
||||
|
||||
```env
|
||||
# REQUIRED — your Polygon wallet private key
|
||||
PRIVATE_KEY=0xYOUR_PRIVATE_KEY_HERE
|
||||
|
||||
# Polygon network
|
||||
RPC_URL=https://polygon-rpc.com
|
||||
CHAIN_ID=137
|
||||
|
||||
# REQUIRED — Polymarket API credentials
|
||||
CLOB_HOST=https://clob.polymarket.com
|
||||
POLYMARKET_API_KEY=your_api_key
|
||||
POLYMARKET_API_SECRET=your_api_secret
|
||||
POLYMARKET_API_PASSPHRASE=your_api_passphrase
|
||||
|
||||
# OPTIONAL — Telegram notifications
|
||||
TELEGRAM_BOT_TOKEN=your_bot_token
|
||||
TELEGRAM_CHAT_ID=your_chat_id
|
||||
```
|
||||
|
||||
### Step 5: Set Up Configuration
|
||||
|
||||
```bash
|
||||
# Copy the example config
|
||||
cp config/config.example.json config/config.json # Linux/macOS
|
||||
copy config\config.example.json config\config.json # Windows
|
||||
```
|
||||
|
||||
Open `config/config.json` and review/adjust settings. The most important setting for first-time users:
|
||||
|
||||
```json
|
||||
{
|
||||
"safety": {
|
||||
"dry_run": true // <-- Keep this TRUE to test without real money
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 6: Run the Bot
|
||||
|
||||
```bash
|
||||
cd src
|
||||
python main.py
|
||||
```
|
||||
|
||||
With the **browser dashboard** (settings editor + live analytics on **http://127.0.0.1:5050**):
|
||||
|
||||
```bash
|
||||
cd src
|
||||
python main.py --web
|
||||
```
|
||||
|
||||
The bot will:
|
||||
1. Validate configuration and sizing formulas
|
||||
2. Connect to Polymarket WebSocket for live orderbook data
|
||||
3. Start the dashboard in your terminal
|
||||
4. Begin monitoring markets and trading when conditions are met
|
||||
|
||||
### Step 7: Switch to Live Trading (When Ready)
|
||||
|
||||
Once you've verified the bot works correctly in dry-run mode:
|
||||
|
||||
1. Edit `config/config.json`
|
||||
2. Change `"dry_run": true` to `"dry_run": false`
|
||||
3. Restart the bot
|
||||
|
||||
---
|
||||
|
||||
## 4. Strategy: Late Entry V3 (Detailed)
|
||||
|
||||
The Late Entry V3 strategy is a **momentum-following, time-constrained** strategy. It only enters positions near market expiry, betting on the **crowd favorite** (the side the market already leans toward).
|
||||
|
||||
### Why "Late Entry"?
|
||||
|
||||
By waiting until the last 4 minutes, the bot:
|
||||
- Gets a clearer signal about which side will win (higher confidence)
|
||||
- Reduces time exposed to risk
|
||||
- Avoids paying inflated prices during the middle of the market when outcomes are uncertain
|
||||
|
||||
### Entry Decision Flow (Step by Step)
|
||||
|
||||
Here is exactly what happens every time the bot evaluates whether to buy:
|
||||
|
||||
#### Step 1: Check Time Window
|
||||
|
||||
```
|
||||
Is seconds_till_end > 0 AND seconds_till_end <= 240?
|
||||
YES → continue
|
||||
NO → skip (too early or market already closed)
|
||||
```
|
||||
|
||||
The bot only trades in the **last 240 seconds** (4 minutes) of each 15-minute window.
|
||||
|
||||
**Example:** Market closes at 12:15:00. Entry window is 12:11:00 to 12:15:00.
|
||||
|
||||
#### Step 2: Check Entry Frequency
|
||||
|
||||
```
|
||||
Has it been at least 7 seconds since last entry in this market?
|
||||
YES → continue
|
||||
NO → skip (too soon, wait)
|
||||
```
|
||||
|
||||
This prevents the bot from spamming orders. Default: one entry attempt per 7 seconds per market.
|
||||
|
||||
#### Step 3: Calculate Spread
|
||||
|
||||
```
|
||||
spread = up_ask + down_ask
|
||||
Is spread <= 1.05 AND spread > 0?
|
||||
YES → continue
|
||||
NO → skip (spread too wide, orderbook unreliable)
|
||||
```
|
||||
|
||||
**Example:**
|
||||
- UP ask = $0.72, DOWN ask = $0.30 → spread = 1.02 (good)
|
||||
- UP ask = $0.80, DOWN ask = $0.35 → spread = 1.15 (too wide, skip)
|
||||
|
||||
In a healthy market, the sum of UP and DOWN asks is close to $1.00. A spread much above $1.05 means the orderbook is thin or stale.
|
||||
|
||||
#### Step 4: Calculate Confidence
|
||||
|
||||
```
|
||||
confidence = |up_ask - down_ask|
|
||||
Is confidence >= 0.30?
|
||||
YES → continue
|
||||
NO → skip (not enough market consensus)
|
||||
```
|
||||
|
||||
**Example:**
|
||||
- UP ask = $0.72, DOWN ask = $0.30 → confidence = 0.42 (good, market clearly favors UP)
|
||||
- UP ask = $0.55, DOWN ask = $0.48 → confidence = 0.07 (too close, skip)
|
||||
|
||||
A confidence of 0.30 means the market prices one side at least 30 cents higher than the other — a strong lean.
|
||||
|
||||
#### Step 5: Identify the Favorite
|
||||
|
||||
```
|
||||
If up_ask > down_ask → favorite = UP, price = up_ask
|
||||
If down_ask > up_ask → favorite = DOWN, price = down_ask
|
||||
```
|
||||
|
||||
The bot always buys the side with the **higher ask price** — the side the market consensus believes will win.
|
||||
|
||||
#### Step 6: Check Price Cap
|
||||
|
||||
```
|
||||
Is favorite_price <= 0.92?
|
||||
YES → continue
|
||||
NO → skip (too expensive, risk/reward ratio too poor)
|
||||
```
|
||||
|
||||
**Example:**
|
||||
- UP ask = $0.88 → buy (still profitable if wins: $1.00 - $0.88 = $0.12 profit per token)
|
||||
- UP ask = $0.95 → skip (only $0.05 potential profit, not worth the risk)
|
||||
|
||||
#### Step 7: Check Investment Cap
|
||||
|
||||
```
|
||||
Is total_invested_in_this_market < $300?
|
||||
YES → continue
|
||||
NO → skip (maximum investment per market reached)
|
||||
```
|
||||
|
||||
This prevents over-concentration in a single market.
|
||||
|
||||
#### Step 8: Calculate Position Size (Contracts)
|
||||
|
||||
The number of contracts depends on how much time is left:
|
||||
|
||||
| Time Remaining | Contracts |
|
||||
|---------------|-----------|
|
||||
| > 180 seconds | 8 contracts |
|
||||
| > 120 seconds | 10 contracts |
|
||||
| <= 120 seconds | 12 contracts |
|
||||
|
||||
**Why increase size later?** Closer to expiry, the prices are more "settled" and the signal is stronger, so the bot takes larger positions.
|
||||
|
||||
**Example:**
|
||||
- 200 seconds left, UP ask = $0.72 → Buy 8 UP contracts → cost = 8 x $0.72 = $5.76
|
||||
- 100 seconds left, UP ask = $0.78 → Buy 12 UP contracts → cost = 12 x $0.78 = $9.36
|
||||
|
||||
### Complete Entry Example
|
||||
|
||||
```
|
||||
Market: btc-updown-15m-1711234500 (closes at 12:15:00)
|
||||
Time: 12:12:30 (150 seconds remaining)
|
||||
|
||||
Orderbook:
|
||||
UP ask: $0.72
|
||||
DOWN ask: $0.30
|
||||
|
||||
Step 1: 150s remaining, within [0, 240] → PASS
|
||||
Step 2: Last entry was 10s ago, >= 7s → PASS
|
||||
Step 3: Spread = 0.72 + 0.30 = 1.02 <= 1.05 → PASS
|
||||
Step 4: Confidence = |0.72 - 0.30| = 0.42 >= 0.30 → PASS
|
||||
Step 5: Favorite = UP (0.72 > 0.30), price = $0.72
|
||||
Step 6: Price $0.72 <= $0.92 → PASS
|
||||
Step 7: Total invested = $5.76 < $300 → PASS
|
||||
Step 8: 150s remaining (> 120s) → 10 contracts
|
||||
|
||||
SIGNAL: BUY 10 UP contracts at $0.72
|
||||
Cost: 10 x $0.72 = $7.20
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Exit Mechanisms (Detailed)
|
||||
|
||||
There are three ways the bot exits a position:
|
||||
|
||||
### Exit 1: Natural Resolution (Hold to Expiry)
|
||||
|
||||
This is the default. If no stop-loss or flip-stop triggers, the bot holds until the market resolves:
|
||||
|
||||
- **Winning side**: Tokens pay $1.00 each. The bot redeems them automatically.
|
||||
- **Losing side**: Tokens become worthless ($0.00).
|
||||
|
||||
**Example:**
|
||||
```
|
||||
Bought: 10 UP contracts at $0.72 → cost $7.20
|
||||
Market resolves: BTC went UP
|
||||
Payout: 10 x $1.00 = $10.00
|
||||
Profit: $10.00 - $7.20 = $2.80 (+38.9%)
|
||||
```
|
||||
|
||||
```
|
||||
Bought: 10 UP contracts at $0.72 → cost $7.20
|
||||
Market resolves: BTC went DOWN
|
||||
Payout: 10 x $0.00 = $0.00
|
||||
Loss: $0.00 - $7.20 = -$7.20 (-100%)
|
||||
```
|
||||
|
||||
### Exit 2: Per-Coin Stop-Loss
|
||||
|
||||
Each coin has its own stop-loss threshold. When the **unrealized PnL** (mark-to-market loss) reaches the threshold, the bot immediately sells.
|
||||
|
||||
**How unrealized PnL is calculated:**
|
||||
|
||||
```
|
||||
total_value = (up_shares x up_ask) + (down_shares x down_ask)
|
||||
unrealized_pnl = total_value - total_invested
|
||||
```
|
||||
|
||||
**Two stop-loss types:**
|
||||
|
||||
| Type | Config | Triggers When |
|
||||
|------|--------|---------------|
|
||||
| **Fixed** | `"type": "fixed", "value": -12.0` | `unrealized_pnl <= -$12.00` |
|
||||
| **Percent** | `"type": "percent", "value": 15` | `unrealized_pnl <= -(15% of total_invested)` |
|
||||
|
||||
**Default config:** BTC/ETH/SOL use fixed -$12, XRP uses fixed -$11.
|
||||
|
||||
**Example (Fixed Stop-Loss):**
|
||||
```
|
||||
Position: 15 UP contracts bought at avg $0.70 → total invested = $10.50
|
||||
Current: UP ask = $0.40, DOWN ask = $0.55
|
||||
Value: 15 x $0.40 + 0 x $0.55 = $6.00
|
||||
Unrealized PnL: $6.00 - $10.50 = -$4.50
|
||||
|
||||
Stop-loss threshold: -$12.00
|
||||
-$4.50 > -$12.00 → no trigger yet
|
||||
|
||||
Later... UP ask drops to $0.15:
|
||||
Value: 15 x $0.15 = $2.25
|
||||
Unrealized PnL: $2.25 - $10.50 = -$8.25
|
||||
|
||||
-$8.25 > -$12.00 → still no trigger
|
||||
|
||||
Later... UP ask drops to $0.05:
|
||||
Value: 15 x $0.05 = $0.75
|
||||
Unrealized PnL: $0.75 - $10.50 = -$9.75
|
||||
|
||||
-$9.75 > -$12.00 → still no trigger
|
||||
|
||||
(This shows that with small position sizes like $10.50,
|
||||
a $12 fixed stop-loss may never trigger before expiry)
|
||||
```
|
||||
|
||||
**Example (Percent Stop-Loss):**
|
||||
```
|
||||
Position: 20 UP contracts bought at avg $0.70 → total invested = $14.00
|
||||
Stop-loss: 15% → threshold = -(14.00 x 0.15) = -$2.10
|
||||
|
||||
UP ask drops to $0.60:
|
||||
Value: 20 x $0.60 = $12.00
|
||||
Unrealized PnL: $12.00 - $14.00 = -$2.00
|
||||
-$2.00 > -$2.10 → no trigger
|
||||
|
||||
UP ask drops to $0.59:
|
||||
Value: 20 x $0.59 = $11.80
|
||||
Unrealized PnL: $11.80 - $14.00 = -$2.20
|
||||
-$2.20 <= -$2.10 → STOP-LOSS TRIGGERED → sell all UP tokens
|
||||
```
|
||||
|
||||
### Exit 3: Flip-Stop (Price Reversal Protection)
|
||||
|
||||
The flip-stop detects when the market sentiment has **reversed** against your position. If you hold UP tokens and the UP price drops below the flip threshold, the bot sells immediately.
|
||||
|
||||
**How it works:**
|
||||
|
||||
```
|
||||
our_side = whichever side we hold more contracts of
|
||||
our_price = current ask price of our side
|
||||
|
||||
If our_price <= flip_stop_price (default $0.48) → TRIGGER
|
||||
```
|
||||
|
||||
**Why $0.48?** If UP was trading at $0.72 when you bought and now it's at $0.48, the market no longer considers UP the favorite. The sentiment has flipped.
|
||||
|
||||
**Example:**
|
||||
```
|
||||
Bought: 10 UP contracts at $0.72
|
||||
|
||||
Later: UP ask drops to $0.52, DOWN ask rises to $0.50
|
||||
Our price ($0.52) > $0.48 → no trigger
|
||||
|
||||
Later: UP ask drops to $0.47, DOWN ask rises to $0.55
|
||||
Our price ($0.47) <= $0.48 → FLIP-STOP TRIGGERED
|
||||
→ Bot sells all UP tokens immediately
|
||||
→ Saves remaining value instead of riding to $0.00
|
||||
```
|
||||
|
||||
### Price Validation Before Any Exit
|
||||
|
||||
Before checking stop-loss or flip-stop, the bot validates that prices are reliable:
|
||||
|
||||
1. Both UP and DOWN ask prices must be **fresh** (updated within last 2 seconds)
|
||||
2. UP and DOWN timestamps must be **within 2 seconds** of each other
|
||||
3. `up_ask + down_ask` must be between **$0.95 and $1.15**
|
||||
|
||||
If any check fails, exit decisions are skipped for that tick (to avoid selling on stale/bad data).
|
||||
|
||||
---
|
||||
|
||||
## 6. Order Execution (Detailed)
|
||||
|
||||
### Buying: FAK Orders (Fill-And-Kill)
|
||||
|
||||
FAK (Fill-And-Kill) orders try to fill immediately against existing orderbook liquidity. Whatever doesn't fill is cancelled.
|
||||
|
||||
**Buy flow:**
|
||||
|
||||
1. Calculate aggressive price: `ask_price x 1.05` (5% above ask for slippage tolerance)
|
||||
2. Submit FAK order for the full contract amount
|
||||
3. If partially filled, submit another FAK for the remaining amount
|
||||
4. Repeat up to 3 attempts (configurable)
|
||||
5. Stop when >= 98% of requested contracts are filled, or remaining value < $1.00
|
||||
|
||||
**Example:**
|
||||
```
|
||||
Want: 10 contracts at ask $0.72
|
||||
Aggressive price: $0.72 x 1.05 = $0.756 → rounded up to $0.76
|
||||
|
||||
Attempt 1: FAK order for 10 contracts at $0.76
|
||||
Result: 7 filled at $0.72-$0.74
|
||||
Remaining: 3 contracts
|
||||
|
||||
Attempt 2: FAK order for 3 contracts at $0.76
|
||||
Result: 3 filled at $0.73
|
||||
Remaining: 0
|
||||
|
||||
Total: 10/10 filled (100%) → done
|
||||
```
|
||||
|
||||
### Selling: FOK Chunked (Fill-Or-Kill in Chunks)
|
||||
|
||||
When the bot needs to exit (stop-loss, flip-stop, or market close), it sells in chunks:
|
||||
|
||||
1. Read actual ERC-20 token balance from the blockchain
|
||||
2. Split into chunks of 50 contracts each
|
||||
3. Each chunk: FOK order at $0.01 (sell at any price)
|
||||
4. Up to 5 retries per chunk if it fails
|
||||
5. After all chunks: sweep any remaining dust
|
||||
|
||||
**Why $0.01?** This is a "market sell" — accept any price to exit quickly. In an emergency exit, speed matters more than getting the best price.
|
||||
|
||||
**Example:**
|
||||
```
|
||||
Position: 120 UP tokens to sell
|
||||
|
||||
Chunk 1: FOK sell 50 at $0.01 → filled
|
||||
Chunk 2: FOK sell 50 at $0.01 → filled
|
||||
Chunk 3: FOK sell 20 at $0.01 → filled
|
||||
Sweep: 0 remaining → done
|
||||
|
||||
Total sold: 120 tokens
|
||||
```
|
||||
|
||||
### Dust Sweeping
|
||||
|
||||
After selling, tiny fractional balances might remain (e.g., 0.3 tokens). The bot runs a multi-stage sweep:
|
||||
|
||||
1. **FOK sweep**: Try up to 3 times
|
||||
2. **FAK fallback**: Try up to 3 times
|
||||
3. **GTC order**: Place a Good-Till-Cancelled order at $0.01
|
||||
4. **Delayed sweep**: Wait 1 second, re-check balance, try FOK/FAK again
|
||||
|
||||
### Automatic Redemption
|
||||
|
||||
After a market resolves, winning tokens can be redeemed for $1.00 each. The bot runs a background thread:
|
||||
|
||||
- **First check**: 8 minutes after startup
|
||||
- **Subsequent checks**: Every 5 minutes
|
||||
- Queries Polymarket API for redeemable positions
|
||||
- Redeems automatically with configurable gas settings
|
||||
|
||||
---
|
||||
|
||||
## 7. Data Feed & WebSocket
|
||||
|
||||
### How Market Data Flows
|
||||
|
||||
```
|
||||
Polymarket Gamma API Polymarket WebSocket
|
||||
(REST - market metadata) (Realtime orderbook)
|
||||
│ │
|
||||
▼ ▼
|
||||
_fetch_tokens() on_message()
|
||||
Gets token IDs, Parses "book" events,
|
||||
condition IDs extracts best ask/bid
|
||||
│ │
|
||||
└────────────┬─────────────────────┘
|
||||
▼
|
||||
DataFeed object
|
||||
(up_ask, down_ask, timestamps)
|
||||
│
|
||||
▼
|
||||
on_price_update callback
|
||||
(called on every orderbook update)
|
||||
│
|
||||
▼
|
||||
Strategy → Entry/Exit decisions
|
||||
```
|
||||
|
||||
### Market Slot Calculation
|
||||
|
||||
Markets align to fixed-length epoch boundaries. Length is **`data_sources.polymarket.market_interval_sec`** (default **900** = 15 minutes; **300** = 5 minutes):
|
||||
|
||||
```
|
||||
interval = 900 # or 300 for 5m
|
||||
current_time = 1711234567 (Unix timestamp)
|
||||
slot = (current_time // interval) * interval
|
||||
market_slug = "btc-updown-15m-<slot>" # or "btc-updown-5m-<slot>"
|
||||
market_end = slot + interval
|
||||
```
|
||||
|
||||
### WebSocket Reconnection
|
||||
|
||||
When a market expires, the bot automatically:
|
||||
1. Calculates the next slot for the configured interval
|
||||
2. Fetches new token IDs from Gamma API
|
||||
3. Reconnects WebSocket with new asset IDs
|
||||
4. Timer resets for the new market window
|
||||
|
||||
---
|
||||
|
||||
## 8. Configuration Reference
|
||||
|
||||
All settings live in `config/config.json`. Here is every section:
|
||||
|
||||
### `safety` — Risk Controls
|
||||
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `dry_run` | `true` | If true, simulates trades without real money |
|
||||
| `max_order_size_usd` | `150` | Maximum single order size (contracts x price) |
|
||||
| `max_orders_per_minute` | `100` | Rate limit for orders |
|
||||
| `max_total_investment` | `1000` | Maximum cumulative investment per market slug |
|
||||
|
||||
### `trading` — Coin Enable/Disable
|
||||
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `btc.enabled` | `true` | Enable BTC trading |
|
||||
| `eth.enabled` | `true` | Enable ETH trading |
|
||||
| `sol.enabled` | `true` | Enable SOL trading |
|
||||
| `xrp.enabled` | `false` | Enable XRP trading (disabled by default — less liquid) |
|
||||
|
||||
### `strategy` — Entry Parameters
|
||||
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `name` | `late_entry_v3` | Strategy identifier |
|
||||
| `entry_window_sec` | `240` | Only enter in last N seconds of market |
|
||||
| `entry_frequency_sec` | `7` | Minimum seconds between entries in same market |
|
||||
| `min_confidence` | `0.30` | Minimum \|up_ask - down_ask\| to enter |
|
||||
| `max_spread` | `1.05` | Maximum up_ask + down_ask allowed |
|
||||
| `price_max` | `0.92` | Maximum price to pay for favorite side |
|
||||
| `max_investment_per_market` | `300` | Maximum USD invested per market |
|
||||
| `sizing.above_180_sec` | `8` | Contracts when > 180s remaining |
|
||||
| `sizing.above_120_sec` | `10` | Contracts when > 120s remaining |
|
||||
| `sizing.below_120_sec` | `12` | Contracts when <= 120s remaining |
|
||||
|
||||
### `exit.flip_stop` — Flip-Stop Settings
|
||||
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `enabled` | `true` | Enable flip-stop exit |
|
||||
| `price_threshold` | `0.48` | Sell when our side's ask drops to this |
|
||||
| `check_realtime` | `true` | Check on every price update |
|
||||
|
||||
### `exit.stop_loss` — Per-Coin Stop-Loss
|
||||
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `enabled` | `true` | Enable stop-loss |
|
||||
| `per_coin.btc.type` | `fixed` | `fixed` = dollar amount, `percent` = % of invested |
|
||||
| `per_coin.btc.value` | `-12.0` | Threshold (negative = loss amount) |
|
||||
| `per_coin.eth.value` | `-12.0` | ETH stop-loss threshold |
|
||||
| `per_coin.sol.value` | `-12.0` | SOL stop-loss threshold |
|
||||
| `per_coin.xrp.value` | `-11.0` | XRP stop-loss threshold |
|
||||
|
||||
### `execution.buy` — Buy Order Settings
|
||||
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `order_type` | `FAK` | Fill-And-Kill order type |
|
||||
| `max_fak_attempts` | `3` | Retry attempts per buy |
|
||||
| `retry_delay_sec` | `0.3` | Delay between retries |
|
||||
| `min_order_usd` | `1.0` | Minimum remaining order value to continue |
|
||||
| `target_fill_percent` | `98.0` | Stop retrying when this % is filled |
|
||||
|
||||
### `execution.sell` — Sell Order Settings
|
||||
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `strategy` | `FOK_CHUNKED` | Sell in FOK chunks |
|
||||
| `chunk_size` | `50` | Contracts per chunk |
|
||||
| `chunk_delay_sec` | `0.1` | Delay between chunks |
|
||||
| `max_chunk_retries` | `5` | Retries per chunk |
|
||||
| `price` | `0.01` | Sell price (market sell) |
|
||||
| `min_dust_threshold` | `0.1` | Ignore balances below this |
|
||||
| `sweep_max_attempts` | `3` | FOK sweep retry count |
|
||||
| `sweep_enable_fallback` | `true` | Enable FAK/GTC fallback |
|
||||
| `delayed_sweep_enabled` | `true` | Re-check balance after delay |
|
||||
| `delayed_sweep_delay_sec` | `1` | Seconds to wait before delayed sweep |
|
||||
|
||||
### `execution.redeem` — Redemption Settings
|
||||
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `startup_check_delay_sec` | `60` | Wait before first redeem check |
|
||||
| `first_check_delay_sec` | `480` | First regular check (8 min after start) |
|
||||
| `check_interval_sec` | `300` | Check every 5 minutes after that |
|
||||
| `sizeThreshold` | `0.1` | Minimum token balance to redeem |
|
||||
| `gas_limit` | `500000` | Gas limit for redeem transactions |
|
||||
| `gas_price_multiplier` | `1.5` | Gas price multiplier |
|
||||
|
||||
### `execution.rpc_config` — Polygon RPC
|
||||
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `endpoints` | `["https://polygon-rpc.com"]` | RPC endpoint(s) |
|
||||
| `retry_attempts` | `10` | Max retries for RPC calls |
|
||||
| `enable_parallel_requests` | `true` | Query multiple endpoints in parallel |
|
||||
|
||||
### `data_sources.polymarket` — Market window length
|
||||
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `gamma_api` | `https://gamma-api.polymarket.com` | Gamma REST base URL |
|
||||
| `ws_url` | `wss://ws-subscriptions-clob.polymarket.com/ws/market` | Orderbook WebSocket |
|
||||
| **`market_window`** | **`"15m"`** | **Choose here:** `"5m"` = 5-minute Up/Down (`{coin}-updown-5m-{slot}`), `"15m"` = 15-minute (`{coin}-updown-15m-{slot}`). |
|
||||
| `market_interval_sec` | (derived) | Set automatically from `market_window` (300 or 900). You can set this instead of `market_window` if you need raw seconds; if both are set, **`market_window` wins**. |
|
||||
|
||||
For **5-minute** markets, set `"market_window": "5m"` and tune `strategy.entry_window_sec` if needed (for example **90–120** seconds). The strategy scales time-based sizing tiers to shorter windows automatically (e.g. 60s/40s for 5m).
|
||||
|
||||
### `display` — Terminal Dashboard
|
||||
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `width` | `160` | Dashboard width in characters |
|
||||
| `update_interval` | `1` | Dashboard refresh interval (seconds) |
|
||||
|
||||
### `logging` — Log File Paths
|
||||
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `trades_file` | `logs/trades.jsonl` | Trade log (JSON Lines) |
|
||||
| `session_file` | `logs/session.json` | Session state file |
|
||||
|
||||
---
|
||||
|
||||
## 9. Environment Variables Reference
|
||||
|
||||
All environment variables are set in the `.env` file at the project root.
|
||||
|
||||
### Required for Live Trading
|
||||
|
||||
| Variable | Example | Description |
|
||||
|----------|---------|-------------|
|
||||
| `PRIVATE_KEY` | `0xabcdef...` | Polygon wallet private key (64 hex chars after 0x) |
|
||||
| `RPC_URL` | `https://polygon-rpc.com` | Polygon RPC endpoint |
|
||||
| `CHAIN_ID` | `137` | Polygon chain ID (always 137) |
|
||||
| `CLOB_HOST` | `https://clob.polymarket.com` | Polymarket CLOB API host |
|
||||
| `POLYMARKET_API_KEY` | `abc-123-...` | Your Polymarket API key |
|
||||
| `POLYMARKET_API_SECRET` | `base64string==` | Your Polymarket API secret |
|
||||
| `POLYMARKET_API_PASSPHRASE` | `passphrase` | Your Polymarket API passphrase |
|
||||
|
||||
### Optional
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `TELEGRAM_BOT_TOKEN` | Telegram bot token for notifications |
|
||||
| `TELEGRAM_CHAT_ID` | Telegram chat ID to receive messages |
|
||||
|
||||
### How to Get Polymarket API Credentials
|
||||
|
||||
1. Go to [Polymarket](https://polymarket.com) and log in
|
||||
2. Navigate to your account settings
|
||||
3. Generate API credentials (key, secret, passphrase)
|
||||
4. Your private key is the Polygon wallet key linked to your Polymarket account
|
||||
|
||||
---
|
||||
|
||||
## 10. Dashboard & Terminal UI
|
||||
|
||||
When running, the bot displays a live terminal dashboard (refreshes every second):
|
||||
|
||||
```
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
Runtime: 00:45:23 | BTC | ETH | SOL | XRP (Polymarket orderbooks)
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
|
||||
Strategy: late_v3
|
||||
Balance: $487.32 | Trades: 12 | W/L: 8/4 | Win Rate: 66.7%
|
||||
|
||||
BTC [15m-1711234500] Time: 142s UP: $0.72 DN: $0.30 Fav: UP Conf: 0.42
|
||||
Position: 10 UP @ $0.72 | PnL: +$1.20 | Max DD: -$0.80
|
||||
If UP wins: +$2.80 | If DN wins: -$7.20
|
||||
|
||||
ETH [15m-1711234500] Time: 142s UP: $0.65 DN: $0.38 Fav: UP Conf: 0.27
|
||||
(waiting for confidence >= 0.30)
|
||||
|
||||
SOL [15m-1711234500] Time: 142s UP: $0.80 DN: $0.22 Fav: UP Conf: 0.58
|
||||
Position: 12 UP @ $0.78 | PnL: +$0.24 | Max DD: -$0.60
|
||||
|
||||
XRP [disabled]
|
||||
|
||||
Recent trades:
|
||||
btc-updown-15m-1711233600 UP WIN +$3.40
|
||||
sol-updown-15m-1711233600 UP LOSS -$6.20
|
||||
|
||||
[M] Manual Redeem All | [Ctrl+C] Stop
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
```
|
||||
|
||||
**What each line shows:**
|
||||
- **Per coin:** Market ID suffix, time remaining, UP/DOWN asks, favorite side, confidence score
|
||||
- **Position:** Number of contracts, average entry price, current unrealized PnL, maximum drawdown
|
||||
- **Scenarios:** What happens if UP wins vs if DOWN wins (helps you understand your exposure)
|
||||
- **Recent trades:** Last closed trades with win/loss result and PnL
|
||||
|
||||
---
|
||||
|
||||
## 11. Web Dashboard (Browser)
|
||||
|
||||
Install dependencies (`flask` is listed in `requirements.txt`). From the `src` directory, start the bot with the web UI enabled:
|
||||
|
||||
```bash
|
||||
cd src
|
||||
python main.py --web
|
||||
```
|
||||
|
||||
Open **http://127.0.0.1:5050/** (or `--web-port` / `--web-host` as needed).
|
||||
|
||||
| Feature | Description |
|
||||
|--------|-------------|
|
||||
| **Live analytics** | Session uptime, mode (dry run / live), wallet balance, total PnL, ROI, per-coin orderbook (UP/DN asks, favorite, confidence), open position with unrealized PnL and scenario PnL |
|
||||
| **Recent trades** | Last closed trades across strategies |
|
||||
| **Settings** | Load and edit `config/config.json` in the browser; **save writes the file** — **restart the bot** to apply changes |
|
||||
| **Request stop** | Sends the same graceful stop as **Ctrl+C** (shutdown handler saves positions) |
|
||||
|
||||
**Security:** By default the server binds to `127.0.0.1` only. Use `--web-host 0.0.0.0` only on trusted networks (add a reverse proxy and authentication if exposing to the internet).
|
||||
|
||||
**API (for your own tools):** `GET /api/status` (JSON snapshot), `GET/POST /api/config`, `POST /api/bot/stop`, `GET /api/health`.
|
||||
|
||||
You can also run the Flask app alone for a read-only view when `logs/bot_state.json` is being updated: `cd src` then `python -m web_dashboard.server` (snapshot appears after the bot has run with `--web` at least once).
|
||||
|
||||
---
|
||||
|
||||
## 12. Telegram Integration
|
||||
|
||||
If configured, the bot sends notifications and accepts commands via Telegram.
|
||||
|
||||
### Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/chart` or `/pnl` | Generate and send a PnL chart image |
|
||||
| `/b` or `/balance` | Show wallet USDC and POL balance |
|
||||
| `/t` or `/positions` | Show all active positions |
|
||||
| `/r` or `/redeem` | Manually trigger redemption of resolved markets |
|
||||
| `/off` or `/stop` | Emergency shutdown (asks for confirmation) |
|
||||
| `/help` | List all available commands |
|
||||
|
||||
### Notifications Sent Automatically
|
||||
|
||||
- Trade entries (coin, side, contracts, price)
|
||||
- Trade exits (reason, PnL)
|
||||
- Stop-loss and flip-stop triggers
|
||||
- Market resolution results
|
||||
- Error alerts
|
||||
|
||||
---
|
||||
|
||||
## 13. Safety Features
|
||||
|
||||
### Dry Run Mode
|
||||
|
||||
When `safety.dry_run` is `true`:
|
||||
- All buy orders are **simulated** (no real transactions)
|
||||
- All sell orders are **simulated**
|
||||
- The bot behaves identically otherwise (prices, signals, dashboard)
|
||||
- Use this to verify the bot works before risking real money
|
||||
|
||||
### Order Size Limits
|
||||
|
||||
- **Per order:** `max_order_size_usd` (default $150) — rejects any single order above this
|
||||
- **Per market:** `max_total_investment` (default $1000) — tracks cumulative investment per market slug
|
||||
- **Per strategy:** `max_investment_per_market` (default $300) — checked by the strategy before signaling
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
- Maximum `max_orders_per_minute` (default 100) orders per minute
|
||||
- Entry frequency: one signal per 7 seconds per market (prevents order spam)
|
||||
|
||||
### Emergency Stop
|
||||
|
||||
- Press **Ctrl+C** to gracefully shut down (saves positions as emergency saves)
|
||||
- Use `/off` in Telegram for remote shutdown
|
||||
- `SafetyGuard.activate_emergency_stop()` blocks all future orders
|
||||
|
||||
### Price Validation
|
||||
|
||||
Before any exit decision, prices are validated:
|
||||
- Must be **fresh** (< 2 seconds old)
|
||||
- UP and DOWN timestamps must be **synchronized** (< 2 seconds apart)
|
||||
- Sum of asks must be **reasonable** (between $0.95 and $1.15)
|
||||
|
||||
This prevents the bot from making exit decisions on stale or corrupted data.
|
||||
|
||||
---
|
||||
|
||||
## 14. Project Structure
|
||||
|
||||
```
|
||||
up-down-spread-bot/
|
||||
├── src/
|
||||
│ ├── main.py # Entry point, main loop, callbacks, config loading
|
||||
│ ├── market_config.py # market_window "5m"/"15m" → market_interval_sec
|
||||
│ ├── strategy.py # Late Entry V3 strategy logic
|
||||
│ ├── trader.py # Per-coin position management and PnL tracking
|
||||
│ ├── multi_trader.py # Manages multiple Trader instances (one per coin)
|
||||
│ ├── data_feed.py # Gamma API + WebSocket orderbook feed
|
||||
│ ├── order_executor.py # CLOB client: FAK buys, FOK sells, sweeps, redeems
|
||||
│ ├── polymarket_api.py # Gamma API helper for market resolution
|
||||
│ ├── safety_guard.py # Dry run, order limits, rate limiting, emergency stop
|
||||
│ ├── position_tracker.py # Position model (for WebSocket user channel)
|
||||
│ ├── trade_logger.py # Logs trades to logs/trades.log
|
||||
│ ├── dashboard_multi_ab.py # Terminal UI rendering
|
||||
│ ├── telegram_notifier.py # Telegram bot notifications and commands
|
||||
│ ├── simple_redeem_collector.py # Background thread for automatic redemption
|
||||
│ ├── pnl_chart_generator.py # Matplotlib PnL chart generation
|
||||
│ ├── keyboard_listener.py # Non-blocking keyboard input (cross-platform)
|
||||
│ ├── web_dashboard_state.py # Thread-safe snapshot for browser dashboard
|
||||
│ └── web_dashboard/ # Flask app: API + static UI (python main.py --web)
|
||||
├── config/
|
||||
│ ├── config.json # Your trading configuration (create from example)
|
||||
│ └── config.example.json # Example configuration template
|
||||
├── logs/ # Log files (created automatically)
|
||||
│ ├── trades.jsonl # Trade history (JSON Lines)
|
||||
│ ├── safety.log # Safety guard events
|
||||
│ ├── bot_state.json # Written when using --web (optional monitoring file)
|
||||
│ └── session.json # Session state
|
||||
├── docs/ # Documentation
|
||||
│ └── README.md # This file
|
||||
├── requirements.txt # Python dependencies
|
||||
├── .env # Your environment variables (create from example)
|
||||
├── .env.example # Example environment template
|
||||
├── .gitignore # Git ignore rules
|
||||
└── README.md # Project overview
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 15. Troubleshooting
|
||||
|
||||
### `ModuleNotFoundError: No module named 'termios'`
|
||||
|
||||
This happens on Windows. The `keyboard_listener.py` uses Unix-only modules. Make sure you have the latest version which includes Windows support via `msvcrt`.
|
||||
|
||||
### `FileNotFoundError: config/config.json`
|
||||
|
||||
You need to create the config file from the example:
|
||||
|
||||
```bash
|
||||
cp config/config.example.json config/config.json # Linux/macOS
|
||||
copy config\config.example.json config\config.json # Windows
|
||||
```
|
||||
|
||||
### `UnicodeEncodeError: 'charmap' codec can't encode character`
|
||||
|
||||
This happens on Windows when writing emoji characters to log files. Make sure all `open()` calls in `safety_guard.py` use `encoding='utf-8'`.
|
||||
|
||||
### "Rate limit exceeded"
|
||||
|
||||
The public Polygon RPC has rate limits. Use a private RPC:
|
||||
|
||||
```env
|
||||
RPC_URL=https://polygon-mainnet.g.alchemy.com/v2/YOUR_API_KEY
|
||||
```
|
||||
|
||||
### "Invalid signature"
|
||||
|
||||
1. Verify your API credentials are correct in `.env`
|
||||
2. Make sure the private key matches your Polymarket account
|
||||
3. Regenerate API credentials on Polymarket if needed
|
||||
|
||||
### WebSocket Disconnects
|
||||
|
||||
The bot auto-reconnects between market windows. If connections drop frequently:
|
||||
1. Check your internet connection
|
||||
2. Try a VPN
|
||||
3. Change DNS to `1.1.1.1` or `8.8.8.8`
|
||||
|
||||
### Positions Not Redeeming
|
||||
|
||||
1. Oracle resolution takes 1-2 minutes after market close
|
||||
2. Use `/r` in Telegram to manually trigger
|
||||
3. Check `logs/` directory for error details
|
||||
4. The bot checks for redeemable positions every 5 minutes automatically
|
||||
|
||||
---
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This software is for **educational purposes only**. Trading on prediction markets involves **substantial risk of loss**. Past performance **does not** indicate future results. Use at your own risk and never trade with money you cannot afford to lose. **Extended strategies** (martingale / anti-martingale / Fibonacci sizing, full TA stacks, Bayesian edge, Avellaneda–Stoikov-style inventory, Kelly, Monte Carlo, and related) are offered **separately**—see the [repository README](../../README.md) and Telegram [@terauss](https://t.me/terauss).
|
||||
@@ -0,0 +1,26 @@
|
||||
# Trading & Blockchain
|
||||
py-clob-client>=0.20.0
|
||||
web3>=6.0.0
|
||||
eth-account>=0.9.0
|
||||
|
||||
# Network & WebSockets
|
||||
websocket-client>=1.6.0
|
||||
requests>=2.31.0
|
||||
|
||||
# Configuration
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
# Data Processing
|
||||
pandas>=2.0.0
|
||||
numpy>=1.24.0
|
||||
|
||||
# Visualization
|
||||
matplotlib>=3.7.0
|
||||
seaborn>=0.12.0
|
||||
|
||||
# Utilities
|
||||
python-dateutil>=2.8.0
|
||||
pytz>=2023.3
|
||||
|
||||
# Web dashboard (optional: python main.py --web)
|
||||
flask>=3.0.0
|
||||
@@ -0,0 +1,314 @@
|
||||
"""
|
||||
Meridian — terminal dashboard (late_v3 × BTC, ETH, SOL, XRP).
|
||||
"""
|
||||
import time
|
||||
from typing import Dict
|
||||
from multi_trader import MultiTrader
|
||||
|
||||
|
||||
class DashboardMultiAB:
|
||||
"""Multi-market dashboard - grouped by strategies"""
|
||||
|
||||
def __init__(self, width: int = 160, coins: list = None, config: dict = None):
|
||||
self.width = width
|
||||
self.start_time = time.time()
|
||||
self.coins = coins or ['btc', 'eth', 'sol', 'xrp']
|
||||
self.events_log = [] # Last N events for display
|
||||
self.max_events = 10
|
||||
self.config = config or {}
|
||||
|
||||
def add_event(self, message: str, event_type: str = 'info'):
|
||||
"""Add event to log (ONLY critical errors displayed in terminal)"""
|
||||
# FILTER: Show only errors in terminal
|
||||
if event_type not in ['error']:
|
||||
return # Ignore everything except errors
|
||||
|
||||
timestamp = time.strftime('%H:%M:%S')
|
||||
|
||||
# Shorten message for terminal display
|
||||
if len(message) > 70:
|
||||
message = message[:67] + "..."
|
||||
|
||||
emoji = '✗' # Only for errors
|
||||
|
||||
event = f"[{timestamp}] {emoji} {message}"
|
||||
self.events_log.append(event)
|
||||
|
||||
# Keep only last N events
|
||||
if len(self.events_log) > self.max_events:
|
||||
self.events_log = self.events_log[-self.max_events:]
|
||||
|
||||
def render(self, multi_trader: MultiTrader, strategies: Dict, data_feed, wallet_balance: float = None, pending_markets: Dict = None):
|
||||
"""Render dashboard"""
|
||||
# Clear screen
|
||||
print('\033[2J\033[H', end='')
|
||||
|
||||
# Build display
|
||||
lines = self._build_display(multi_trader, strategies, data_feed, wallet_balance, pending_markets)
|
||||
print(lines, end='', flush=True)
|
||||
|
||||
def _build_display(self, multi_trader: MultiTrader, strategies: Dict, data_feed, wallet_balance: float = None, pending_markets: Dict = None) -> str:
|
||||
"""Build display string"""
|
||||
output = []
|
||||
|
||||
# Get market states for all coins
|
||||
market_states = {}
|
||||
for coin in self.coins:
|
||||
market_states[coin] = data_feed.get_state(coin)
|
||||
|
||||
# Runtime
|
||||
runtime = time.time() - self.start_time
|
||||
runtime_str = self._format_time(runtime)
|
||||
|
||||
# Header - all coins use orderbook data
|
||||
header = f"⏱ {runtime_str} │ BTC │ ETH │ SOL │ XRP (Polymarket orderbooks)"
|
||||
|
||||
output.append("=" * self.width)
|
||||
output.append(header.center(self.width))
|
||||
output.append("=" * self.width)
|
||||
output.append("")
|
||||
|
||||
# Strategy base names
|
||||
strategy_bases = [
|
||||
('late_v3', 'LATE V3')
|
||||
]
|
||||
|
||||
# Display each strategy (grouped by base)
|
||||
for base_name, display_name in strategy_bases:
|
||||
output.append(f"┌─ {display_name.upper()} {'─' * (self.width - len(display_name) - 5)}┐")
|
||||
|
||||
# Calculate total for this strategy (all coins)
|
||||
traders = {}
|
||||
stats = {}
|
||||
for coin in self.coins:
|
||||
trader_name = f"{base_name}_{coin}"
|
||||
if trader_name in multi_trader.traders:
|
||||
traders[coin] = multi_trader.traders[trader_name]
|
||||
stats[coin] = traders[coin].get_performance_stats()
|
||||
|
||||
if not traders:
|
||||
output.append(f"│ ERROR: No traders found for strategy")
|
||||
output.append(f"└{'─' * (self.width - 2)}┘")
|
||||
output.append("")
|
||||
continue
|
||||
|
||||
# Strategy totals
|
||||
total_capital = sum(t.current_capital for t in traders.values())
|
||||
starting_capital = sum(t.starting_capital for t in traders.values())
|
||||
total_pnl = total_capital - starting_capital
|
||||
|
||||
# Calculate ROI correctly - use wallet_balance if available, otherwise use starting_capital
|
||||
# ROI = PnL / Initial Investment * 100
|
||||
if wallet_balance and wallet_balance > 0:
|
||||
# Use real wallet balance to calculate initial investment
|
||||
initial_balance = wallet_balance - total_pnl
|
||||
total_roi = (total_pnl / initial_balance * 100) if initial_balance > 0 else 0
|
||||
elif starting_capital > 0:
|
||||
# Fallback to starting_capital if available
|
||||
total_roi = (total_pnl / starting_capital * 100)
|
||||
else:
|
||||
# Last resort: calculate from current capital
|
||||
total_roi = (total_pnl / total_capital * 100) if total_capital > total_pnl and total_capital > 0 else 0
|
||||
total_trades = sum(s['total_trades'] for s in stats.values())
|
||||
total_wins = sum(s['wins'] for s in stats.values())
|
||||
total_losses = sum(s['losses'] for s in stats.values())
|
||||
total_wr = (total_wins / total_trades * 100) if total_trades > 0 else 0
|
||||
|
||||
# Color code total PnL
|
||||
pnl_color = '\033[92m' if total_pnl >= 0 else '\033[91m'
|
||||
pnl_reset = '\033[0m'
|
||||
pnl_sign = "+" if total_pnl >= 0 else ""
|
||||
|
||||
# Strategy summary line (unified balance)
|
||||
balance_display = f"${wallet_balance:,.2f}" if wallet_balance else f"${total_capital:,.0f}"
|
||||
output.append(f"│ Balance: {balance_display} │ PnL: {pnl_color}{pnl_sign}${total_pnl:,.0f}({pnl_sign}{total_roi:.1f}%){pnl_reset} │ "
|
||||
f"Trades: {total_trades} │ W/L: {total_wins}/{total_losses} │ WR: {total_wr:.1f}%")
|
||||
output.append(f"│")
|
||||
|
||||
# Display each coin market
|
||||
for coin in self.coins:
|
||||
if coin in traders:
|
||||
trader_name = f"{base_name}_{coin}"
|
||||
self._add_market_info(output, coin.upper(), market_states[coin], trader_name,
|
||||
traders[coin], strategies.get(trader_name), multi_trader)
|
||||
|
||||
output.append(f"└{'─' * (self.width - 2)}┘")
|
||||
output.append("")
|
||||
|
||||
# Recent activity (compact)
|
||||
output.append("📈 Recent Trades:")
|
||||
|
||||
all_closed = []
|
||||
for name, trader in multi_trader.traders.items():
|
||||
for trade in trader.closed_trades[-1:]:
|
||||
trade['strategy'] = name
|
||||
all_closed.append(trade)
|
||||
|
||||
all_closed.sort(key=lambda x: x.get('close_time', 0), reverse=True)
|
||||
|
||||
for trade in all_closed[:4]:
|
||||
strategy = trade['strategy']
|
||||
# Extract coin from strategy name (last part)
|
||||
coin = strategy.split('_')[-1].upper()
|
||||
# Extract base name (everything except coin)
|
||||
base = '_'.join(strategy.split('_')[:-1]).replace('late_v3', 'LV3')
|
||||
market = trade['market_slug'].split('-')[-1]
|
||||
pnl = trade['pnl']
|
||||
pnl_sign = "+" if pnl >= 0 else ""
|
||||
pnl_color = '\033[92m' if pnl >= 0 else '\033[91m'
|
||||
pnl_reset = '\033[0m'
|
||||
|
||||
activity = f" [{base:>3}/{coin:>3}] {market}: {pnl_color}{pnl_sign}${pnl:>5,.0f}{pnl_reset}"
|
||||
output.append(activity)
|
||||
|
||||
if not all_closed:
|
||||
output.append(" (None)")
|
||||
|
||||
output.append("")
|
||||
|
||||
# Pending markets
|
||||
if pending_markets:
|
||||
import time as time_module
|
||||
output.append("⏳ Pending:")
|
||||
|
||||
for market_slug_pending, info in pending_markets.items():
|
||||
elapsed = (time_module.time() - info['first_attempt']) / 60
|
||||
next_retry = (info['next_retry'] - time_module.time()) / 60
|
||||
|
||||
# Extract coin from market slug
|
||||
coin = market_slug_pending.split('-')[0].upper()
|
||||
market_short = market_slug_pending.split('-')[-1]
|
||||
|
||||
if next_retry > 0:
|
||||
status = f"~{next_retry:.0f}m (#{info['attempts']})"
|
||||
else:
|
||||
status = f"checking... (#{info['attempts'] + 1})"
|
||||
|
||||
output.append(f" • {coin}/{market_short}: {status}")
|
||||
|
||||
output.append("")
|
||||
|
||||
# Events log (ONLY critical errors, if any)
|
||||
if self.events_log:
|
||||
output.append("🚨 Critical Errors:")
|
||||
for event in self.events_log[-10:]: # Last 10
|
||||
output.append(f" {event}")
|
||||
output.append("")
|
||||
|
||||
# Add keyboard controls footer
|
||||
output.append("─" * self.width)
|
||||
output.append("🎹 Keyboard: [M] Manual Redeem All │ [Ctrl+C] Stop Trading".center(self.width))
|
||||
|
||||
return '\n'.join(output)
|
||||
|
||||
def _add_market_info(self, output, coin_label, market_state, trader_name, trader, strategy, multi_trader):
|
||||
"""Add market information block for a specific coin"""
|
||||
market_slug = market_state['market_slug']
|
||||
seconds_left = market_state['seconds_till_end']
|
||||
up_ask = market_state.get('up_ask') or 0.0
|
||||
down_ask = market_state.get('down_ask') or 0.0
|
||||
confidence = market_state.get('confidence', 0.0)
|
||||
|
||||
# Time left
|
||||
time_left_str = self._format_time(seconds_left) if seconds_left > 0 else "ENDED"
|
||||
market_short = market_slug.split('-')[-1] if market_slug else "N/A"
|
||||
|
||||
# MM Favorite (higher price = favorite)
|
||||
mm_favorite = 'UP' if up_ask > down_ask else 'DOWN'
|
||||
fav_arrow = '↑' if mm_favorite == 'UP' else '↓'
|
||||
|
||||
# Color code confidence
|
||||
conf_color = '\033[92m' if confidence >= 0.2 else '\033[93m'
|
||||
conf_reset = '\033[0m'
|
||||
|
||||
# Strategy stats
|
||||
stats = trader.get_performance_stats()
|
||||
pnl = trader.current_capital - trader.starting_capital
|
||||
pnl_sign = "+" if pnl >= 0 else ""
|
||||
pnl_color = '\033[92m' if pnl >= 0 else '\033[91m'
|
||||
pnl_reset = '\033[0m'
|
||||
|
||||
# WR Stop Loss stats
|
||||
wr_stopped = 0
|
||||
recoveries = 0
|
||||
if strategy:
|
||||
strategy_stats = strategy.get_stats()
|
||||
wr_stopped = strategy_stats['skip_breakdown'].get('wr_stop_loss', 0)
|
||||
recoveries = strategy_stats.get('wr_recoveries', 0)
|
||||
|
||||
# Trading status indicator
|
||||
coin_lower = coin_label.lower()
|
||||
trading_enabled = self.config.get('trading', {}).get(coin_lower, {}).get('enabled', True)
|
||||
trading_status = "📈" if trading_enabled else "👁️"
|
||||
|
||||
# Market header
|
||||
output.append(f"│ {trading_status} 【{coin_label}】 {market_short} │ ⏰ {time_left_str} │ "
|
||||
f"UP:{up_ask:.3f} DN:{down_ask:.3f} {fav_arrow}{mm_favorite} │ "
|
||||
f"Conf:{conf_color}{confidence:.3f}{conf_reset}")
|
||||
|
||||
# Stats line (no Cap - unified balance in header)
|
||||
output.append(f"│ PnL: {pnl_color}{pnl_sign}${pnl:,.0f}{pnl_reset} │ "
|
||||
f"Trades: {stats['total_trades']} │ W/L: {stats['wins']}/{stats['losses']} │ "
|
||||
f"WR: {stats['win_rate']:.1f}% │ St:{wr_stopped} Rc:{recoveries}")
|
||||
|
||||
# Current position
|
||||
pos = multi_trader.get_current_positions(trader_name, market_slug)
|
||||
|
||||
if pos and (pos['up_shares'] > 0 or pos['down_shares'] > 0):
|
||||
# Get detailed stats
|
||||
detailed_stats = trader.get_market_detailed_stats(market_slug, up_ask, down_ask)
|
||||
|
||||
if detailed_stats:
|
||||
up_shares = detailed_stats['up_shares']
|
||||
down_shares = detailed_stats['down_shares']
|
||||
up_invested = detailed_stats['up_invested']
|
||||
down_invested = detailed_stats['down_invested']
|
||||
total_invested = detailed_stats['total_invested']
|
||||
unrealized_pnl = detailed_stats['unrealized_pnl']
|
||||
unrealized_pct = detailed_stats['unrealized_pct']
|
||||
max_dd = detailed_stats['max_drawdown']
|
||||
max_dd_pct = detailed_stats['max_drawdown_pct']
|
||||
entries_count = detailed_stats['entries_count']
|
||||
|
||||
# Calculate PnL scenarios
|
||||
if_up_wins = (up_shares * 1.0) - total_invested
|
||||
if_down_wins = (down_shares * 1.0) - total_invested
|
||||
|
||||
# Determine our bet
|
||||
total_shares = up_shares + down_shares
|
||||
our_pct = (up_shares / total_shares * 100) if total_shares > 0 else 50
|
||||
our_favorite = 'UP' if up_shares > down_shares else 'DOWN'
|
||||
|
||||
# Status
|
||||
is_right = (our_favorite == mm_favorite)
|
||||
overall_status = '\033[92m✓\033[0m' if is_right else '\033[91m✗\033[0m'
|
||||
|
||||
# Color code unrealized PnL
|
||||
unreal_color = '\033[92m' if unrealized_pnl >= 0 else '\033[91m'
|
||||
unreal_reset = '\033[0m'
|
||||
|
||||
# Position details (compact 3-line format)
|
||||
output.append(f"│ Pos: UP:{int(up_shares)}×{up_ask:.3f}=${up_invested:.0f} │ "
|
||||
f"DN:{int(down_shares)}×{down_ask:.3f}=${down_invested:.0f} │ "
|
||||
f"Total:${total_invested:.0f} │ Entries:{entries_count}")
|
||||
output.append(f"│ Now: {unreal_color}{unrealized_pnl:+.0f}({unrealized_pct:+.0f}%){unreal_reset} │ "
|
||||
f"MaxDD:{max_dd:.0f}({max_dd_pct:.0f}%) │ "
|
||||
f"If↑:{if_up_wins:+.0f} If↓:{if_down_wins:+.0f}")
|
||||
output.append(f"│ Bet: {our_favorite}({our_pct:.0f}%) vs MM:{mm_favorite} {overall_status}")
|
||||
else:
|
||||
output.append(f"│ Position: None")
|
||||
|
||||
output.append(f"│")
|
||||
|
||||
def _format_time(self, seconds: float) -> str:
|
||||
"""Format seconds as HH:MM:SS or MM:SS"""
|
||||
seconds = int(seconds)
|
||||
if seconds >= 3600:
|
||||
hours = seconds // 3600
|
||||
minutes = (seconds % 3600) // 60
|
||||
secs = seconds % 60
|
||||
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
|
||||
else:
|
||||
minutes = seconds // 60
|
||||
secs = seconds % 60
|
||||
return f"{minutes:02d}:{secs:02d}"
|
||||
@@ -0,0 +1,604 @@
|
||||
"""
|
||||
Multi-Market data feed: Polymarket orderbook for 4 coins
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
import threading
|
||||
import websocket
|
||||
import subprocess
|
||||
import requests
|
||||
import os
|
||||
import hmac
|
||||
import hashlib
|
||||
import base64
|
||||
from typing import Optional, Dict
|
||||
import trader as trader_module
|
||||
from position_tracker import PositionTracker
|
||||
|
||||
|
||||
class DataFeed:
|
||||
"""Polymarket orderbooks for BTC, ETH, SOL, XRP (configurable 5m or 15m windows)."""
|
||||
|
||||
def __init__(self, config: Dict):
|
||||
self.config = config
|
||||
|
||||
# ✅ POSITION TRACKER - single source of truth for positions!
|
||||
self.position_tracker = PositionTracker()
|
||||
|
||||
# API credentials for authenticated WebSocket
|
||||
self.api_key = os.getenv('POLYMARKET_API_KEY')
|
||||
self.api_secret = os.getenv('POLYMARKET_API_SECRET')
|
||||
self.api_passphrase = os.getenv('POLYMARKET_API_PASSPHRASE')
|
||||
|
||||
pm = config.get("data_sources", {}).get("polymarket", {})
|
||||
self.market_interval_sec = int(pm.get("market_interval_sec", 900))
|
||||
if self.market_interval_sec <= 0:
|
||||
self.market_interval_sec = 900
|
||||
# Slug: {coin}-updown-5m-{slot} or {coin}-updown-15m-{slot}
|
||||
if self.market_interval_sec == 300:
|
||||
self.market_slug_suffix = "5m"
|
||||
elif self.market_interval_sec == 900:
|
||||
self.market_slug_suffix = "15m"
|
||||
else:
|
||||
self.market_slug_suffix = (
|
||||
f"{self.market_interval_sec // 60}m"
|
||||
if self.market_interval_sec % 60 == 0
|
||||
else "15m"
|
||||
)
|
||||
print(
|
||||
f"[DATA] Warning: market_interval_sec={self.market_interval_sec} "
|
||||
f"(standard Polymarket crypto up/down uses 300 or 900). Slug suffix={self.market_slug_suffix}"
|
||||
)
|
||||
|
||||
iv = self.market_interval_sec
|
||||
tnow = int(time.time())
|
||||
self.markets = {}
|
||||
for coin in ["btc", "eth", "sol", "xrp"]:
|
||||
self.markets[coin] = {
|
||||
"slug": "",
|
||||
"up_ask": 0.5,
|
||||
"down_ask": 0.5,
|
||||
"up_bid": 0.5,
|
||||
"down_bid": 0.5,
|
||||
"up_ask_timestamp": 0.0,
|
||||
"down_ask_timestamp": 0.0,
|
||||
"up_bid_timestamp": 0.0,
|
||||
"down_bid_timestamp": 0.0,
|
||||
"up_bids_full": [],
|
||||
"down_bids_full": [],
|
||||
"up_asks_full": [],
|
||||
"down_asks_full": [],
|
||||
"tokens": {},
|
||||
"seconds_till_end": iv,
|
||||
"market_end_time": tnow + iv,
|
||||
"market_start_price": 0.0,
|
||||
}
|
||||
|
||||
# Current prices (only BTC and ETH have price feeds)
|
||||
self.btc_price = 0.0
|
||||
self.eth_price = 0.0
|
||||
|
||||
# Thread safety - per-coin locks for full parallelism
|
||||
self.locks = {
|
||||
'btc': threading.Lock(),
|
||||
'eth': threading.Lock(),
|
||||
'sol': threading.Lock(),
|
||||
'xrp': threading.Lock()
|
||||
}
|
||||
self.stop_event = threading.Event()
|
||||
|
||||
# Threads
|
||||
self.threads = []
|
||||
|
||||
# Event-driven callbacks for price updates
|
||||
self.price_callbacks = []
|
||||
|
||||
def start(self):
|
||||
"""Start data streams for BTC, ETH, SOL, XRP + User Channel"""
|
||||
# Polymarket WebSocket for all 4 coins
|
||||
for coin in ['btc', 'eth', 'sol', 'xrp']:
|
||||
pm_thread = threading.Thread(target=self._polymarket_worker, args=(coin,), daemon=True)
|
||||
pm_thread.start()
|
||||
self.threads.append(pm_thread)
|
||||
print(f"[DATA] Started Polymarket feed for {coin.upper()}")
|
||||
|
||||
# ❌ USER CHANNEL DISABLED - WebSocket auth doesn't work
|
||||
# Using REST API takingAmount/makingAmount instead!
|
||||
print(f"[DATA] ℹ️ Position tracking via REST API responses")
|
||||
|
||||
# Start local timer update (fixes timer freeze)
|
||||
timer_thread = threading.Thread(target=self._timer_worker, daemon=True)
|
||||
timer_thread.start()
|
||||
self.threads.append(timer_thread)
|
||||
|
||||
print(
|
||||
f"[DATA] All feeds started: 4 Polymarket orderbooks "
|
||||
f"({self.market_slug_suffix} / {self.market_interval_sec}s windows)"
|
||||
)
|
||||
|
||||
def stop(self):
|
||||
"""Stop all data streams"""
|
||||
print("[DATA] Stopping feeds...")
|
||||
self.stop_event.set()
|
||||
|
||||
# Give threads time to cleanup
|
||||
for t in self.threads:
|
||||
if t.is_alive():
|
||||
t.join(timeout=1)
|
||||
|
||||
print("[DATA] Feeds stopped")
|
||||
|
||||
def get_state(self, coin: str = 'btc') -> Dict:
|
||||
"""Get current market state for specified coin (thread-safe)"""
|
||||
with self.locks[coin]:
|
||||
market = self.markets.get(coin)
|
||||
if not market:
|
||||
return None
|
||||
|
||||
# Price only for BTC and ETH (SOL/XRP don't have price feeds)
|
||||
if coin == 'btc':
|
||||
price = self.btc_price
|
||||
elif coin == 'eth':
|
||||
price = self.eth_price
|
||||
else:
|
||||
price = 0.0 # SOL and XRP don't need price
|
||||
|
||||
# Safe handling of None values
|
||||
up_ask = market.get('up_ask') or 0.0
|
||||
down_ask = market.get('down_ask') or 0.0
|
||||
confidence = abs(down_ask - up_ask) if (up_ask > 0 and down_ask > 0) else 0.0
|
||||
|
||||
return {
|
||||
'up_ask': up_ask,
|
||||
'down_ask': down_ask,
|
||||
'price': price,
|
||||
'market_start_price': market['market_start_price'],
|
||||
'seconds_till_end': market['seconds_till_end'],
|
||||
'market_slug': market['slug'],
|
||||
'confidence': confidence,
|
||||
'coin': coin,
|
||||
'market_interval_sec': self.market_interval_sec,
|
||||
'market_slug_suffix': self.market_slug_suffix,
|
||||
}
|
||||
|
||||
def register_price_callback(self, callback):
|
||||
"""Register callback function for price updates (event-driven)"""
|
||||
self.price_callbacks.append(callback)
|
||||
|
||||
def _current_slug(self, coin: str) -> str:
|
||||
"""Calculate current market slug (5m or 15m per config)."""
|
||||
iv = self.market_interval_sec
|
||||
current_slot = int(time.time()) // iv * iv
|
||||
return f"{coin}-updown-{self.market_slug_suffix}-{current_slot}"
|
||||
|
||||
def _fetch_tokens(self, coin: str) -> Optional[Dict]:
|
||||
"""Fetch current market tokens from Polymarket for specified coin"""
|
||||
try:
|
||||
gamma_api = self.config['data_sources']['polymarket']['gamma_api']
|
||||
slug = self._current_slug(coin)
|
||||
|
||||
# Use events API with specific slug
|
||||
url = f"{gamma_api}/events?slug={slug}"
|
||||
resp = requests.get(url, timeout=10)
|
||||
resp.raise_for_status()
|
||||
|
||||
events = resp.json()
|
||||
if not events:
|
||||
# Market not found - may not be open yet
|
||||
current_time = int(time.time())
|
||||
iv = self.market_interval_sec
|
||||
next_market = ((current_time // iv) + 1) * iv
|
||||
wait_time = next_market - current_time
|
||||
print(f"[PM-{coin.upper()}] Market {slug} not found (may not be open yet, next in {wait_time}s)")
|
||||
return None
|
||||
|
||||
# Get first market
|
||||
market = events[0]["markets"][0]
|
||||
clob_token_ids = market.get("clobTokenIds", [])
|
||||
outcomes = market.get("outcomes", [])
|
||||
condition_id = market.get("conditionId", "")
|
||||
neg_risk = market.get("negRisk", True)
|
||||
|
||||
# Parse if string format
|
||||
if isinstance(clob_token_ids, str):
|
||||
clob_token_ids = json.loads(clob_token_ids)
|
||||
if isinstance(outcomes, str):
|
||||
outcomes = json.loads(outcomes)
|
||||
|
||||
# Find Up and Down indices
|
||||
up_idx = outcomes.index("Up") if "Up" in outcomes else 0
|
||||
down_idx = outcomes.index("Down") if "Down" in outcomes else 1
|
||||
|
||||
return {
|
||||
'up': clob_token_ids[up_idx],
|
||||
'down': clob_token_ids[down_idx],
|
||||
'condition_id': condition_id,
|
||||
'neg_risk': neg_risk
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"[PM-{coin.upper()}] Error fetching tokens: {e}")
|
||||
return None
|
||||
|
||||
def _polymarket_worker(self, coin: str):
|
||||
"""Polymarket WebSocket worker for specified coin"""
|
||||
while not self.stop_event.is_set():
|
||||
# Fetch tokens
|
||||
tokens = self._fetch_tokens(coin)
|
||||
if not tokens:
|
||||
time.sleep(5)
|
||||
continue
|
||||
|
||||
with self.locks[coin]:
|
||||
self.markets[coin]['tokens'] = tokens
|
||||
|
||||
# Save token IDs to trader module for real trading
|
||||
market_slug = self._current_slug(coin)
|
||||
trader_module.set_token_ids(
|
||||
market_slug=market_slug,
|
||||
up_token_id=tokens['up'],
|
||||
down_token_id=tokens['down'],
|
||||
condition_id=tokens.get('condition_id', ''),
|
||||
neg_risk=tokens.get('neg_risk', True)
|
||||
)
|
||||
|
||||
# Calculate reconnect time
|
||||
current_time = int(time.time())
|
||||
iv = self.market_interval_sec
|
||||
market_end = ((current_time // iv) * iv) + iv
|
||||
reconnect_in = market_end - current_time + 2
|
||||
|
||||
# Get market slug
|
||||
market_slug = self._current_slug(coin)
|
||||
|
||||
with self.locks[coin]:
|
||||
self.markets[coin]['slug'] = market_slug
|
||||
self.markets[coin]['market_end_time'] = market_end
|
||||
self.markets[coin]['tokens'] = tokens
|
||||
|
||||
# ✅ Register market in PositionTracker for tracking via WebSocket
|
||||
self.position_tracker.register_market(
|
||||
market_slug=market_slug,
|
||||
up_token_id=tokens['up'],
|
||||
down_token_id=tokens['down']
|
||||
)
|
||||
|
||||
# Set market start price only for BTC/ETH (not needed for SOL/XRP)
|
||||
if self.markets[coin]['market_start_price'] == 0.0:
|
||||
if coin == 'btc':
|
||||
self.markets[coin]['market_start_price'] = self.btc_price
|
||||
elif coin == 'eth':
|
||||
self.markets[coin]['market_start_price'] = self.eth_price
|
||||
# SOL/XRP: leave at 0.0 (no price feed needed)
|
||||
|
||||
print(f"[PM-{coin.upper()}] Connected to {market_slug}, reconnect in {reconnect_in}s")
|
||||
|
||||
# Connect WebSocket
|
||||
try:
|
||||
ws_url = self.config['data_sources']['polymarket']['ws_url']
|
||||
ws_ref = [None] # Store ws reference for closing
|
||||
|
||||
ws = websocket.WebSocketApp(
|
||||
ws_url,
|
||||
on_message=lambda ws, msg: self._on_pm_message(msg, tokens, coin),
|
||||
on_error=lambda ws, err: None,
|
||||
on_close=lambda ws, code, reason: None
|
||||
)
|
||||
|
||||
ws_ref[0] = ws
|
||||
|
||||
def on_open(ws):
|
||||
sub_msg = {
|
||||
"auth": {},
|
||||
"type": "MARKET",
|
||||
"assets_ids": [tokens["up"], tokens["down"]]
|
||||
}
|
||||
ws.send(json.dumps(sub_msg))
|
||||
|
||||
ws.on_open = on_open
|
||||
|
||||
# Auto-reconnect timer
|
||||
timer = threading.Timer(reconnect_in, lambda: ws.close())
|
||||
timer.start()
|
||||
|
||||
# Stop checker thread
|
||||
def check_stop():
|
||||
while not self.stop_event.is_set():
|
||||
time.sleep(0.5)
|
||||
if ws_ref[0]:
|
||||
ws_ref[0].close()
|
||||
|
||||
stop_checker = threading.Thread(target=check_stop, daemon=True)
|
||||
stop_checker.start()
|
||||
|
||||
ws.run_forever(ping_interval=20, ping_timeout=10, skip_utf8_validation=True)
|
||||
timer.cancel()
|
||||
|
||||
# Stop immediately if stop_event is set
|
||||
if self.stop_event.is_set():
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
print(f"[PM-{coin.upper()}] Error: {e}")
|
||||
time.sleep(5)
|
||||
|
||||
def _on_pm_message(self, message: str, tokens: Dict, coin: str):
|
||||
"""Parse Polymarket orderbook message for specified coin"""
|
||||
try:
|
||||
data = json.loads(message)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return
|
||||
|
||||
# Only process "book" events (full orderbook snapshots)
|
||||
event_type = data.get("event_type", "unknown")
|
||||
if event_type != "book":
|
||||
return
|
||||
|
||||
# Parse orderbook
|
||||
asks_raw = data.get("asks", [])
|
||||
bids_raw = data.get("bids", [])
|
||||
|
||||
# Parse asks (price, size) tuples
|
||||
asks = []
|
||||
for ask in asks_raw or []:
|
||||
if isinstance(ask, dict):
|
||||
price = float(ask.get("price", 0))
|
||||
size = float(ask.get("size", 0))
|
||||
else:
|
||||
price = float(ask[0]) if len(ask) > 0 else 0
|
||||
size = float(ask[1]) if len(ask) > 1 else 0
|
||||
if price > 0 and size > 0:
|
||||
asks.append((price, size))
|
||||
|
||||
# Parse bids (price, size) tuples
|
||||
bids = []
|
||||
for bid in bids_raw or []:
|
||||
if isinstance(bid, dict):
|
||||
price = float(bid.get("price", 0))
|
||||
size = float(bid.get("size", 0))
|
||||
else:
|
||||
price = float(bid[0]) if len(bid) > 0 else 0
|
||||
size = float(bid[1]) if len(bid) > 1 else 0
|
||||
if price > 0 and size > 0:
|
||||
bids.append((price, size))
|
||||
|
||||
# Sort asks ascending (lowest first)
|
||||
asks.sort(key=lambda x: x[0])
|
||||
|
||||
# Sort bids descending (highest first)
|
||||
bids.sort(key=lambda x: x[0], reverse=True)
|
||||
|
||||
# Get best ask (lowest price) and best bid (highest price)
|
||||
best_ask = asks[0] if asks else None
|
||||
best_bid = bids[0] if bids else None
|
||||
|
||||
asset = data.get("asset_id", "")
|
||||
|
||||
# Update state and trigger callbacks (per-coin lock - fully parallel!)
|
||||
with self.locks[coin]:
|
||||
price_changed = False
|
||||
old_up_ask = self.markets[coin]['up_ask']
|
||||
old_down_ask = self.markets[coin]['down_ask']
|
||||
old_up_bid = self.markets[coin]['up_bid']
|
||||
old_down_bid = self.markets[coin]['down_bid']
|
||||
|
||||
if best_ask:
|
||||
price, size = best_ask
|
||||
|
||||
if asset == tokens.get("up"):
|
||||
self.markets[coin]['up_ask'] = price
|
||||
self.markets[coin]['up_ask_timestamp'] = time.time() # Track update time
|
||||
# Save full orderbook (1 ask level + 5 bid levels)
|
||||
self.markets[coin]['up_asks_full'] = asks[:1] # Top 1 ask
|
||||
self.markets[coin]['up_bids_full'] = bids[:5] # Top 5 bids
|
||||
if price != old_up_ask:
|
||||
price_changed = True
|
||||
elif asset == tokens.get("down"):
|
||||
self.markets[coin]['down_ask'] = price
|
||||
self.markets[coin]['down_ask_timestamp'] = time.time() # Track update time
|
||||
# Save full orderbook (1 ask level + 5 bid levels)
|
||||
self.markets[coin]['down_asks_full'] = asks[:1] # Top 1 ask
|
||||
self.markets[coin]['down_bids_full'] = bids[:5] # Top 5 bids
|
||||
if price != old_down_ask:
|
||||
price_changed = True
|
||||
|
||||
if best_bid:
|
||||
price, size = best_bid
|
||||
|
||||
if asset == tokens.get("up"):
|
||||
self.markets[coin]['up_bid'] = price
|
||||
self.markets[coin]['up_bid_timestamp'] = time.time() # Track update time
|
||||
# Update full orderbook if not set by ask
|
||||
if not self.markets[coin]['up_bids_full']:
|
||||
self.markets[coin]['up_bids_full'] = bids[:5]
|
||||
if price != old_up_bid:
|
||||
price_changed = True
|
||||
elif asset == tokens.get("down"):
|
||||
self.markets[coin]['down_bid'] = price
|
||||
self.markets[coin]['down_bid_timestamp'] = time.time() # Track update time
|
||||
# Update full orderbook if not set by ask
|
||||
if not self.markets[coin]['down_bids_full']:
|
||||
self.markets[coin]['down_bids_full'] = bids[:5]
|
||||
if price != old_down_bid:
|
||||
price_changed = True
|
||||
|
||||
# Trigger callbacks if price changed
|
||||
if price_changed:
|
||||
up_ask = self.markets[coin]['up_ask']
|
||||
down_ask = self.markets[coin]['down_ask']
|
||||
up_bid = self.markets[coin]['up_bid']
|
||||
down_bid = self.markets[coin]['down_bid']
|
||||
|
||||
# Skip if prices not ready yet
|
||||
if up_ask is None or down_ask is None:
|
||||
price_changed = False
|
||||
else:
|
||||
market_slug = self.markets[coin]['slug']
|
||||
seconds_till_end = self.markets[coin]['seconds_till_end']
|
||||
|
||||
# Get price only for BTC/ETH
|
||||
if coin == 'btc':
|
||||
market_price = self.btc_price
|
||||
elif coin == 'eth':
|
||||
market_price = self.eth_price
|
||||
else:
|
||||
market_price = 0.0 # SOL/XRP don't have price
|
||||
|
||||
market_start_price = self.markets[coin]['market_start_price']
|
||||
|
||||
# Build market_state for callback
|
||||
market_state = {
|
||||
'up_ask': up_ask,
|
||||
'down_ask': down_ask,
|
||||
'up_bid': up_bid,
|
||||
'down_bid': down_bid,
|
||||
'up_ask_timestamp': self.markets[coin]['up_ask_timestamp'],
|
||||
'down_ask_timestamp': self.markets[coin]['down_ask_timestamp'],
|
||||
'up_bid_timestamp': self.markets[coin]['up_bid_timestamp'],
|
||||
'down_bid_timestamp': self.markets[coin]['down_bid_timestamp'],
|
||||
'price': market_price,
|
||||
'market_start_price': market_start_price,
|
||||
'seconds_till_end': seconds_till_end,
|
||||
'market_slug': market_slug,
|
||||
'confidence': abs(down_ask - up_ask),
|
||||
'coin': coin
|
||||
}
|
||||
|
||||
# Call all registered callbacks (outside lock to avoid deadlock)
|
||||
callbacks_to_call = list(self.price_callbacks)
|
||||
|
||||
# Call callbacks outside the lock
|
||||
# 🔥 ASYNC: each coin is processed in parallel
|
||||
if price_changed and callbacks_to_call:
|
||||
for callback in callbacks_to_call:
|
||||
try:
|
||||
# Wrapper for safe call
|
||||
def safe_callback_wrapper():
|
||||
try:
|
||||
callback(coin, market_state)
|
||||
except Exception as e:
|
||||
# Log but don't crash
|
||||
print(f"[CALLBACK ERROR] {coin}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# 🛡️ Start in separate thread (doesn't block other coins)
|
||||
threading.Thread(
|
||||
target=safe_callback_wrapper,
|
||||
daemon=True,
|
||||
name=f"cb_{coin}_{int(time.time()*1000)}"
|
||||
).start()
|
||||
except Exception as e:
|
||||
print(f"[CALLBACK ERROR] Failed to start callback for {coin}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
pass # Ignore parsing errors
|
||||
|
||||
def _timer_worker(self):
|
||||
"""Update timer every second locally for all markets (per-coin locks)"""
|
||||
while not self.stop_event.is_set():
|
||||
current_time = int(time.time())
|
||||
# Update each coin's timer independently (fully parallel)
|
||||
for coin in ['btc', 'eth', 'sol', 'xrp']:
|
||||
with self.locks[coin]:
|
||||
market_end_time = self.markets[coin]['market_end_time']
|
||||
self.markets[coin]['seconds_till_end'] = max(0, market_end_time - current_time)
|
||||
time.sleep(1)
|
||||
|
||||
def _user_channel_worker(self):
|
||||
"""
|
||||
WebSocket User Channel - source of ALL position data!
|
||||
|
||||
Connects to authenticated channel and receives:
|
||||
- ORDER events (with size_matched - real amount!)
|
||||
- TRADE events (transaction confirmations)
|
||||
|
||||
THIS IS THE SINGLE SOURCE OF TRUTH!
|
||||
"""
|
||||
reconnect_delay = 5
|
||||
|
||||
while not self.stop_event.is_set():
|
||||
try:
|
||||
ws_url = "wss://ws-subscriptions-clob.polymarket.com/ws/user"
|
||||
|
||||
print("[USER-WS] 🔌 Connecting to User Channel...")
|
||||
|
||||
ws = websocket.WebSocketApp(
|
||||
ws_url,
|
||||
on_message=lambda ws, msg: self._on_user_message(msg),
|
||||
on_error=lambda ws, err: print(f"[USER-WS] ❌ Error: {err}") if err else None,
|
||||
on_close=lambda ws, code, reason: print(f"[USER-WS] 🔌 Disconnected (code={code})")
|
||||
)
|
||||
|
||||
def on_open(ws):
|
||||
"""Send authenticated subscription request"""
|
||||
try:
|
||||
# Create signature for authentication
|
||||
timestamp = str(int(time.time()))
|
||||
message = timestamp
|
||||
signature = hmac.new(
|
||||
self.api_secret.encode('utf-8'),
|
||||
message.encode('utf-8'),
|
||||
hashlib.sha256
|
||||
).digest()
|
||||
signature_b64 = base64.b64encode(signature).decode('utf-8')
|
||||
|
||||
sub_msg = {
|
||||
"auth": {
|
||||
"apikey": self.api_key,
|
||||
"secret": signature_b64,
|
||||
"passphrase": self.api_passphrase,
|
||||
"timestamp": timestamp
|
||||
},
|
||||
"type": "user"
|
||||
}
|
||||
ws.send(json.dumps(sub_msg))
|
||||
print("[USER-WS] ✅ Authenticated & subscribed to user channel")
|
||||
except Exception as e:
|
||||
print(f"[USER-WS] ⚠️ Auth failed: {e}")
|
||||
|
||||
ws.on_open = on_open
|
||||
|
||||
# Run forever (blocking call)
|
||||
ws.run_forever()
|
||||
|
||||
except Exception as e:
|
||||
print(f"[USER-WS] ⚠️ Exception: {e}")
|
||||
|
||||
# Reconnect delay
|
||||
if not self.stop_event.is_set():
|
||||
print(f"[USER-WS] ⏳ Reconnecting in {reconnect_delay}s...")
|
||||
time.sleep(reconnect_delay)
|
||||
|
||||
def _on_user_message(self, message: str):
|
||||
"""
|
||||
Process all USER events - SINGLE source of truth!
|
||||
|
||||
Event types:
|
||||
- order: ORDER events (PLACEMENT/UPDATE/CANCELLATION)
|
||||
- trade: TRADE events (MATCHED/MINED/CONFIRMED)
|
||||
|
||||
All events are passed to PositionTracker!
|
||||
"""
|
||||
try:
|
||||
data = json.loads(message)
|
||||
event_type = data.get("event_type")
|
||||
|
||||
if event_type == "order":
|
||||
# ✅ ORDER EVENT - update position via tracker
|
||||
self.position_tracker.on_order_event(data)
|
||||
|
||||
elif event_type == "trade":
|
||||
# ✅ TRADE EVENT - confirm trade
|
||||
self.position_tracker.on_trade_event(data)
|
||||
|
||||
else:
|
||||
# Other event types (e.g., heartbeat)
|
||||
pass
|
||||
|
||||
except json.JSONDecodeError:
|
||||
# Not JSON message (e.g., connection established)
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"[USER-WS] ⚠️ Parse error: {e}")
|
||||
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
Non-blocking keyboard listener for dashboard controls (cross-platform)
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
IS_WINDOWS = os.name == 'nt'
|
||||
|
||||
if IS_WINDOWS:
|
||||
import msvcrt
|
||||
else:
|
||||
import select
|
||||
import termios
|
||||
import tty
|
||||
|
||||
|
||||
class KeyboardListener:
|
||||
"""Non-blocking keyboard listener (Windows & Unix)"""
|
||||
|
||||
def __init__(self):
|
||||
self.running = False
|
||||
self.thread = None
|
||||
self.key_callbacks = {}
|
||||
self.last_key = None
|
||||
self.last_key_time = 0
|
||||
|
||||
def register_callback(self, key: str, callback, description: str = ""):
|
||||
"""Register a callback for a specific key
|
||||
|
||||
Args:
|
||||
key: Single character key (e.g., 'm', 'M', 'q')
|
||||
callback: Function to call when key is pressed
|
||||
description: Optional description for help display
|
||||
"""
|
||||
key = key.lower()
|
||||
self.key_callbacks[key] = {
|
||||
'callback': callback,
|
||||
'description': description
|
||||
}
|
||||
|
||||
def _get_key_windows(self):
|
||||
"""Get a single keypress (non-blocking on Windows)"""
|
||||
if msvcrt.kbhit():
|
||||
ch = msvcrt.getwch()
|
||||
return ch.lower()
|
||||
return None
|
||||
|
||||
def _get_key_unix(self):
|
||||
"""Get a single keypress (non-blocking on Unix)"""
|
||||
if select.select([sys.stdin], [], [], 0)[0]:
|
||||
return sys.stdin.read(1).lower()
|
||||
return None
|
||||
|
||||
def _listener_loop(self):
|
||||
"""Main listener loop (runs in thread)"""
|
||||
if IS_WINDOWS:
|
||||
self._listener_loop_windows()
|
||||
else:
|
||||
self._listener_loop_unix()
|
||||
|
||||
def _listener_loop_windows(self):
|
||||
while self.running:
|
||||
key = self._get_key_windows()
|
||||
self._handle_key(key)
|
||||
time.sleep(0.05)
|
||||
|
||||
def _listener_loop_unix(self):
|
||||
old_settings = termios.tcgetattr(sys.stdin)
|
||||
try:
|
||||
tty.setcbreak(sys.stdin.fileno())
|
||||
while self.running:
|
||||
key = self._get_key_unix()
|
||||
self._handle_key(key)
|
||||
time.sleep(0.05)
|
||||
finally:
|
||||
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
|
||||
|
||||
def _handle_key(self, key):
|
||||
if key and key in self.key_callbacks:
|
||||
now = time.time()
|
||||
if now - self.last_key_time > 0.5 or key != self.last_key:
|
||||
self.last_key = key
|
||||
self.last_key_time = now
|
||||
try:
|
||||
self.key_callbacks[key]['callback']()
|
||||
except Exception as e:
|
||||
print(f"\n[KEYBOARD] Error executing callback for '{key}': {e}")
|
||||
|
||||
def start(self):
|
||||
"""Start the keyboard listener in a background thread"""
|
||||
if self.running:
|
||||
return
|
||||
|
||||
self.running = True
|
||||
self.thread = threading.Thread(target=self._listener_loop, daemon=True)
|
||||
self.thread.start()
|
||||
print("[KEYBOARD] Listener started")
|
||||
|
||||
def stop(self):
|
||||
"""Stop the keyboard listener"""
|
||||
if not self.running:
|
||||
return
|
||||
|
||||
self.running = False
|
||||
if self.thread:
|
||||
self.thread.join(timeout=1.0)
|
||||
print("[KEYBOARD] Listener stopped")
|
||||
|
||||
def get_help_text(self):
|
||||
"""Get help text for all registered keys"""
|
||||
if not self.key_callbacks:
|
||||
return "No keyboard shortcuts registered"
|
||||
|
||||
lines = ["Keyboard shortcuts:"]
|
||||
for key, info in sorted(self.key_callbacks.items()):
|
||||
desc = info['description'] or 'No description'
|
||||
lines.append(f" [{key.upper()}] {desc}")
|
||||
|
||||
return "\n".join(lines)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
||||
"""
|
||||
Resolve Polymarket market window from config: user-friendly market_window + market_interval_sec.
|
||||
"""
|
||||
|
||||
|
||||
def apply_market_window_settings(cfg: dict) -> None:
|
||||
"""
|
||||
Mutates cfg in place: sets data_sources.polymarket.market_interval_sec.
|
||||
|
||||
Priority:
|
||||
1. market_window: "5m" or "15m" (also accepts 5min, 15min, 5, 15)
|
||||
2. existing market_interval_sec (e.g. 300 or 900)
|
||||
3. default 900 (15m)
|
||||
"""
|
||||
ds = cfg.get("data_sources")
|
||||
if not isinstance(ds, dict):
|
||||
return
|
||||
pm = ds.get("polymarket")
|
||||
if not isinstance(pm, dict):
|
||||
return
|
||||
|
||||
mw = str(pm.get("market_window", "")).strip().lower()
|
||||
if mw in ("5m", "5min", "5"):
|
||||
pm["market_interval_sec"] = 300
|
||||
return
|
||||
if mw in ("15m", "15min", "15"):
|
||||
pm["market_interval_sec"] = 900
|
||||
return
|
||||
|
||||
sec = pm.get("market_interval_sec")
|
||||
if sec is not None:
|
||||
try:
|
||||
pm["market_interval_sec"] = int(sec)
|
||||
except (TypeError, ValueError):
|
||||
pm["market_interval_sec"] = 900
|
||||
return
|
||||
|
||||
pm["market_interval_sec"] = 900
|
||||
@@ -0,0 +1,290 @@
|
||||
"""
|
||||
Multi-Trader Manager
|
||||
Manages 4 isolated Trader instances (2 strategies × 2 coins) with complete separation
|
||||
"""
|
||||
from typing import Dict, Optional
|
||||
from pathlib import Path
|
||||
from trader import Trader
|
||||
|
||||
|
||||
class MultiTrader:
|
||||
"""Manage multiple isolated trading strategies"""
|
||||
|
||||
def __init__(self, capital_per_strategy: float = 10000, strategy_names: list = None, config: dict = None):
|
||||
"""
|
||||
Initialize isolated traders
|
||||
|
||||
Args:
|
||||
capital_per_strategy: Starting capital for each strategy
|
||||
strategy_names: List of strategy names (if None, use default 6)
|
||||
config: Configuration dict (for stop-loss checks)
|
||||
"""
|
||||
self.capital_per_strategy = capital_per_strategy
|
||||
self.config = config
|
||||
|
||||
# Use provided strategy names or default 6
|
||||
if strategy_names is None:
|
||||
strategy_names = [
|
||||
'v1_current',
|
||||
'v11_extreme',
|
||||
'v9_sqrt',
|
||||
'v10_hedge_reduction',
|
||||
'v12_balanced',
|
||||
'v8_high_base'
|
||||
]
|
||||
|
||||
self.traders = {}
|
||||
# Get project root (parent of src directory)
|
||||
project_root = Path(__file__).parent.parent
|
||||
|
||||
for name in strategy_names:
|
||||
log_dir = project_root / "logs" / name
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.traders[name] = Trader(capital=capital_per_strategy, log_dir=str(log_dir), config=config)
|
||||
print(f"[MULTI-TRADER] Initialized {name} with ${capital_per_strategy:,.0f}")
|
||||
|
||||
print(f"[MULTI-TRADER] Total portfolio: ${len(self.traders) * capital_per_strategy:,.0f}")
|
||||
|
||||
def enter_position(self, strategy_name: str, market_slug: str, side: str,
|
||||
price: float, contracts: int,
|
||||
up_ask: float = None, down_ask: float = None,
|
||||
winner_ratio: float = 0.0, is_recovery: bool = False,
|
||||
entry_reason: str = 'normal',
|
||||
seconds_till_end: int = 0, time_from_start: int = 0) -> bool:
|
||||
"""
|
||||
Enter position for specific strategy (isolated)
|
||||
|
||||
Args:
|
||||
strategy_name: Which strategy's trader to use
|
||||
market_slug: Market identifier
|
||||
side: 'UP' or 'DOWN'
|
||||
price: Entry price
|
||||
contracts: Number of contracts
|
||||
up_ask: Current UP ask price (for detailed logging)
|
||||
down_ask: Current DOWN ask price (for detailed logging)
|
||||
winner_ratio: Current winner ratio (for detailed logging)
|
||||
is_recovery: Is this a recovery entry? (for detailed logging)
|
||||
entry_reason: Reason for entry (for detailed logging)
|
||||
seconds_till_end: Seconds until market end (for detailed logging)
|
||||
time_from_start: Seconds from market start (for detailed logging)
|
||||
|
||||
Returns:
|
||||
True if entered successfully
|
||||
"""
|
||||
if strategy_name not in self.traders:
|
||||
print(f"[ERROR] Unknown strategy: {strategy_name}")
|
||||
return False
|
||||
|
||||
try:
|
||||
trader = self.traders[strategy_name]
|
||||
return trader.enter_position_contracts(
|
||||
market_slug=market_slug,
|
||||
side=side,
|
||||
price=price,
|
||||
contracts=contracts,
|
||||
up_ask=up_ask,
|
||||
down_ask=down_ask,
|
||||
winner_ratio=winner_ratio,
|
||||
is_recovery=is_recovery,
|
||||
entry_reason=entry_reason,
|
||||
seconds_till_end=seconds_till_end,
|
||||
time_from_start=time_from_start
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[ERROR] {strategy_name} entry failed: {e}")
|
||||
return False
|
||||
|
||||
def close_market(self, strategy_name: str, market_slug: str,
|
||||
winner: str, btc_start: float, btc_final: float) -> Optional[Dict]:
|
||||
"""
|
||||
Close market for specific strategy (isolated)
|
||||
|
||||
Args:
|
||||
strategy_name: Which strategy's trader to use
|
||||
market_slug: Market identifier
|
||||
winner: 'UP' or 'DOWN'
|
||||
btc_start: Starting BTC price
|
||||
btc_final: Final BTC price
|
||||
|
||||
Returns:
|
||||
Trade result dict or None
|
||||
"""
|
||||
if strategy_name not in self.traders:
|
||||
print(f"[ERROR] Unknown strategy: {strategy_name}")
|
||||
return None
|
||||
|
||||
try:
|
||||
trader = self.traders[strategy_name]
|
||||
return trader.close_market(
|
||||
market_slug=market_slug,
|
||||
winner=winner,
|
||||
btc_start=btc_start,
|
||||
btc_final=btc_final
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[ERROR] {strategy_name} close failed: {e}")
|
||||
return None
|
||||
|
||||
def close_market_early_exit(self, strategy_name: str, market_slug: str,
|
||||
exit_price: float, exit_reason: str = 'early_exit',
|
||||
up_bid: float = None, down_bid: float = None) -> Optional[Dict]:
|
||||
"""
|
||||
Close market with early exit for specific strategy
|
||||
|
||||
Args:
|
||||
strategy_name: Which strategy's trader to use
|
||||
market_slug: Market identifier
|
||||
exit_price: Current favorite price
|
||||
exit_reason: Reason for exit ('stop_loss', 'flip_stop', 'early_exit')
|
||||
up_bid: Current UP bid price (for selling)
|
||||
down_bid: Current DOWN bid price (for selling)
|
||||
|
||||
Returns:
|
||||
Trade result dict or None
|
||||
"""
|
||||
if strategy_name not in self.traders:
|
||||
print(f"[ERROR] Unknown strategy: {strategy_name}")
|
||||
return None
|
||||
|
||||
try:
|
||||
trader = self.traders[strategy_name]
|
||||
return trader.close_market_early_exit(
|
||||
market_slug=market_slug,
|
||||
exit_price=exit_price,
|
||||
exit_reason=exit_reason,
|
||||
up_bid=up_bid,
|
||||
down_bid=down_bid
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[ERROR] {strategy_name} early exit failed: {e}")
|
||||
return None
|
||||
|
||||
def get_trader(self, strategy_name: str) -> Optional[Trader]:
|
||||
"""Get specific trader instance"""
|
||||
return self.traders.get(strategy_name)
|
||||
|
||||
def get_all_traders(self) -> Dict[str, Trader]:
|
||||
"""Get all trader instances"""
|
||||
return self.traders
|
||||
|
||||
def get_portfolio_stats(self) -> Dict:
|
||||
"""Get aggregate portfolio statistics"""
|
||||
total_capital = 0
|
||||
total_pnl = 0
|
||||
total_trades = 0
|
||||
total_wins = 0
|
||||
total_losses = 0
|
||||
|
||||
strategy_stats = {}
|
||||
|
||||
for name, trader in self.traders.items():
|
||||
stats = trader.get_performance_stats()
|
||||
|
||||
total_capital += trader.current_capital
|
||||
pnl = trader.current_capital - trader.starting_capital
|
||||
total_pnl += pnl
|
||||
total_trades += stats['total_trades']
|
||||
total_wins += stats['wins']
|
||||
total_losses += stats['losses']
|
||||
|
||||
strategy_stats[name] = {
|
||||
'capital': trader.current_capital,
|
||||
'pnl': pnl,
|
||||
'stats': stats
|
||||
}
|
||||
|
||||
total_starting = len(self.traders) * self.capital_per_strategy
|
||||
portfolio_roi = (total_pnl / total_starting * 100) if total_starting > 0 else 0
|
||||
|
||||
return {
|
||||
'total_capital': total_capital,
|
||||
'total_pnl': total_pnl,
|
||||
'total_trades': total_trades,
|
||||
'total_wins': total_wins,
|
||||
'total_losses': total_losses,
|
||||
'portfolio_roi': portfolio_roi,
|
||||
'strategy_stats': strategy_stats,
|
||||
'num_strategies': len(self.traders)
|
||||
}
|
||||
|
||||
def get_market_stats(self, strategy_name: str, market_slug: str, up_current: float = 0.5, down_current: float = 0.5) -> Optional[Dict]:
|
||||
"""
|
||||
Get market statistics for specific strategy
|
||||
|
||||
Args:
|
||||
strategy_name: Which strategy's trader to use
|
||||
market_slug: Market identifier
|
||||
up_current: Current UP ask price for unrealized PnL
|
||||
down_current: Current DOWN ask price for unrealized PnL
|
||||
|
||||
Returns:
|
||||
Market stats dict or None if no position
|
||||
"""
|
||||
if strategy_name not in self.traders:
|
||||
return None
|
||||
|
||||
trader = self.traders[strategy_name]
|
||||
return trader.get_market_stats(market_slug, up_current, down_current)
|
||||
|
||||
def get_current_positions(self, strategy_name: str, market_slug: str) -> Optional[Dict]:
|
||||
"""Get current positions for specific strategy and market"""
|
||||
if strategy_name not in self.traders:
|
||||
return None
|
||||
|
||||
trader = self.traders[strategy_name]
|
||||
if market_slug not in trader.positions:
|
||||
return None
|
||||
|
||||
pos = trader.positions[market_slug]
|
||||
return {
|
||||
'up_shares': pos['UP']['total_shares'],
|
||||
'down_shares': pos['DOWN']['total_shares'],
|
||||
'up_invested': pos['UP']['total_invested'],
|
||||
'down_invested': pos['DOWN']['total_invested'],
|
||||
'num_entries': len(pos['all_entries'])
|
||||
}
|
||||
|
||||
def get_session_stats(self, strategy_name: str, markets_skipped: int = 0) -> Dict:
|
||||
"""
|
||||
Get session statistics for a strategy/coin
|
||||
|
||||
Args:
|
||||
strategy_name: Strategy identifier (e.g. 'late_v3_btc')
|
||||
markets_skipped: Number of skipped markets (tracked externally)
|
||||
|
||||
Returns:
|
||||
Dict with session statistics
|
||||
"""
|
||||
if strategy_name not in self.traders:
|
||||
return {
|
||||
'markets_played': 0,
|
||||
'markets_skipped': 0,
|
||||
'wins': 0,
|
||||
'losses': 0,
|
||||
'win_rate': 0,
|
||||
'total_pnl': 0,
|
||||
'stop_losses': 0,
|
||||
'flip_stops': 0,
|
||||
}
|
||||
|
||||
trader = self.traders[strategy_name]
|
||||
stats = trader.get_performance_stats()
|
||||
|
||||
# Count exit types
|
||||
stop_losses = sum(1 for t in trader.closed_trades
|
||||
if t.get('exit_reason') == 'stop_loss')
|
||||
flip_stops = sum(1 for t in trader.closed_trades
|
||||
if t.get('exit_reason') == 'flip_stop')
|
||||
|
||||
return {
|
||||
'markets_played': stats['total_trades'],
|
||||
'markets_skipped': markets_skipped,
|
||||
'wins': stats['wins'],
|
||||
'losses': stats['losses'],
|
||||
'win_rate': stats['win_rate'],
|
||||
'total_pnl': trader.current_capital - trader.starting_capital,
|
||||
'stop_losses': stop_losses,
|
||||
'flip_stops': flip_stops,
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,313 @@
|
||||
"""
|
||||
PnL Chart Generator - Creates cumulative PnL charts for all 4 coins
|
||||
"""
|
||||
import matplotlib
|
||||
matplotlib.use('Agg') # Non-interactive backend
|
||||
import matplotlib.pyplot as plt
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
from datetime import datetime
|
||||
|
||||
def load_trades(log_dir: str, coins: List[str]) -> Dict[str, List[Dict]]:
|
||||
"""Load all trades from JSONL files for each coin"""
|
||||
all_trades = {}
|
||||
|
||||
# DEBUG: Write to file too
|
||||
debug_file = "/root/4coins_live/logs/chart_debug.log"
|
||||
with open(debug_file, 'a') as f:
|
||||
f.write(f"\n{'='*80}\n")
|
||||
f.write(f"[CHART DEBUG] {datetime.now()} load_trades called\n")
|
||||
f.write(f"[CHART DEBUG] log_dir = {log_dir}\n")
|
||||
f.write(f"[CHART DEBUG] coins = {coins}\n")
|
||||
|
||||
print(f"[CHART DEBUG] load_trades called")
|
||||
print(f"[CHART DEBUG] log_dir = {log_dir}")
|
||||
print(f"[CHART DEBUG] coins = {coins}")
|
||||
|
||||
for coin in coins:
|
||||
trades_file = Path(log_dir) / f"late_v3_{coin}" / "trades.jsonl"
|
||||
trades = []
|
||||
|
||||
with open(debug_file, 'a') as f:
|
||||
f.write(f"[CHART DEBUG] Looking for: {trades_file}\n")
|
||||
f.write(f"[CHART DEBUG] File exists: {trades_file.exists()}\n")
|
||||
|
||||
print(f"[CHART DEBUG] Looking for: {trades_file}")
|
||||
print(f"[CHART DEBUG] File exists: {trades_file.exists()}")
|
||||
|
||||
if trades_file.exists():
|
||||
with open(trades_file, 'r') as f:
|
||||
for line in f:
|
||||
try:
|
||||
trade = json.loads(line.strip())
|
||||
trades.append(trade)
|
||||
except Exception as e:
|
||||
with open(debug_file, 'a') as df:
|
||||
df.write(f"[CHART DEBUG] Failed to parse line: {e}\n")
|
||||
print(f"[CHART DEBUG] Failed to parse line: {e}")
|
||||
|
||||
with open(debug_file, 'a') as f:
|
||||
f.write(f"[CHART DEBUG] Loaded {len(trades)} trades from {coin}\n")
|
||||
print(f"[CHART DEBUG] Loaded {len(trades)} trades from {coin}")
|
||||
else:
|
||||
with open(debug_file, 'a') as f:
|
||||
f.write(f"[CHART DEBUG] File NOT FOUND: {trades_file}\n")
|
||||
print(f"[CHART DEBUG] File NOT FOUND: {trades_file}")
|
||||
|
||||
all_trades[coin] = trades
|
||||
|
||||
total = sum(len(t) for t in all_trades.values())
|
||||
with open(debug_file, 'a') as f:
|
||||
f.write(f"[CHART DEBUG] Total trades loaded: {total}\n")
|
||||
print(f"[CHART DEBUG] Total trades loaded: {total}")
|
||||
|
||||
return all_trades
|
||||
|
||||
def generate_pnl_chart(log_dir: str, coins: List[str], output_path: str) -> bool:
|
||||
"""
|
||||
Generate cumulative PnL chart for all coins + combined
|
||||
All lines use the same X-axis (unique market close timestamps)
|
||||
|
||||
Args:
|
||||
log_dir: Path to logs directory
|
||||
coins: List of coin names (e.g., ['btc', 'eth', 'sol', 'xrp'])
|
||||
output_path: Where to save the chart
|
||||
|
||||
Returns:
|
||||
True if chart created successfully
|
||||
"""
|
||||
try:
|
||||
# Load trades for all coins
|
||||
all_trades = load_trades(log_dir, coins)
|
||||
|
||||
# Check if we have any trades
|
||||
total_trades = sum(len(trades) for trades in all_trades.values())
|
||||
if total_trades == 0:
|
||||
print("[CHART] No trades found, skipping chart generation")
|
||||
return False
|
||||
|
||||
# 🔥 CRITICAL: Deduplication! Avoid double counting estimated + real PnL
|
||||
# Each trade is written twice:
|
||||
# 1. Estimated PnL (WITHOUT "updated" field)
|
||||
# 2. Real PnL (WITH "updated": true field)
|
||||
# Take only final entries with real PnL!
|
||||
trade_map = {} # {coin_market_slug: trade_data}
|
||||
|
||||
debug_file = "/root/4coins_live/logs/chart_debug.log"
|
||||
with open(debug_file, 'a') as f:
|
||||
f.write(f"[CHART DEBUG] Starting deduplication...\n")
|
||||
|
||||
for coin in coins:
|
||||
with open(debug_file, 'a') as f:
|
||||
f.write(f"[CHART DEBUG] Processing {len(all_trades[coin])} trades for {coin}\n")
|
||||
|
||||
for trade in all_trades[coin]:
|
||||
market_slug = trade.get('market_slug', '')
|
||||
key = f"{coin}_{market_slug}"
|
||||
has_updated = trade.get('updated', False)
|
||||
|
||||
# If entry has "updated": true - this is FINAL entry (real PnL)
|
||||
# Always replace previous estimated entry with it
|
||||
if has_updated:
|
||||
trade_map[key] = {
|
||||
'coin': coin,
|
||||
'close_time': trade.get('close_time', 0),
|
||||
'pnl': trade.get('pnl', 0)
|
||||
}
|
||||
# If NO "updated" and such entry doesn't exist - add it
|
||||
# (for old entries without dual logging or if real entry didn't arrive)
|
||||
elif key not in trade_map:
|
||||
trade_map[key] = {
|
||||
'coin': coin,
|
||||
'close_time': trade.get('close_time', 0),
|
||||
'pnl': trade.get('pnl', 0)
|
||||
}
|
||||
|
||||
# Convert to list of unique entries
|
||||
all_trades_timed = list(trade_map.values())
|
||||
|
||||
with open(debug_file, 'a') as f:
|
||||
f.write(f"[CHART DEBUG] After deduplication: {len(all_trades_timed)} trades\n")
|
||||
f.write(f"[CHART DEBUG] trade_map keys sample: {list(trade_map.keys())[:5]}\n")
|
||||
|
||||
# Sort by close_time
|
||||
all_trades_timed.sort(key=lambda x: x['close_time'])
|
||||
|
||||
debug_file = "/root/4coins_live/logs/chart_debug.log"
|
||||
with open(debug_file, 'a') as f:
|
||||
f.write(f"[CHART DEBUG] Sorted {len(all_trades_timed)} trades\n")
|
||||
|
||||
# Group trades by close_time (same timestamp = same point)
|
||||
time_groups = {}
|
||||
for trade in all_trades_timed:
|
||||
close_time = trade['close_time']
|
||||
if close_time not in time_groups:
|
||||
time_groups[close_time] = []
|
||||
time_groups[close_time].append(trade)
|
||||
|
||||
with open(debug_file, 'a') as f:
|
||||
f.write(f"[CHART DEBUG] Grouped into {len(time_groups)} time points\n")
|
||||
|
||||
# Create unified timeline
|
||||
unique_times = sorted(time_groups.keys())
|
||||
|
||||
with open(debug_file, 'a') as f:
|
||||
f.write(f"[CHART DEBUG] unique_times count: {len(unique_times)}\n")
|
||||
|
||||
# Calculate cumulative PnL for COMBINED (using grouped timestamps)
|
||||
combined_pnl = []
|
||||
current_combined = 0
|
||||
for time in unique_times:
|
||||
# Sum all PnL changes at this timestamp
|
||||
time_pnl = sum(t['pnl'] for t in time_groups[time])
|
||||
current_combined += time_pnl
|
||||
combined_pnl.append(current_combined)
|
||||
|
||||
# X-axis for combined (1 to N unique timestamps)
|
||||
combined_indices = list(range(1, len(combined_pnl) + 1))
|
||||
|
||||
# Calculate cumulative PnL for each coin on the SAME timeline
|
||||
# 🔥 FIX: Use DEDUPLICATED trades from trade_map, not original all_trades!
|
||||
coin_cumulative = {}
|
||||
coin_indices = {}
|
||||
|
||||
for coin in coins:
|
||||
# Get deduplicated trades for this coin from all_trades_timed
|
||||
coin_trades = [t for t in all_trades_timed if t['coin'] == coin]
|
||||
if not coin_trades:
|
||||
continue
|
||||
|
||||
# Sort by close_time
|
||||
coin_trades.sort(key=lambda x: x['close_time'])
|
||||
|
||||
# Map coin trades to unified timeline
|
||||
cumulative = []
|
||||
coin_times = []
|
||||
current_pnl = 0
|
||||
|
||||
for trade in coin_trades:
|
||||
current_pnl += trade['pnl']
|
||||
close_time = trade['close_time']
|
||||
|
||||
# Find position in unified timeline
|
||||
try:
|
||||
timeline_index = unique_times.index(close_time) + 1
|
||||
cumulative.append(current_pnl)
|
||||
coin_times.append(timeline_index)
|
||||
except ValueError:
|
||||
# Close_time not in unique_times (shouldn't happen but safety check)
|
||||
pass
|
||||
|
||||
coin_cumulative[coin] = cumulative
|
||||
coin_indices[coin] = coin_times
|
||||
|
||||
# Create figure
|
||||
fig, ax = plt.subplots(figsize=(14, 8))
|
||||
|
||||
# Colors for each coin
|
||||
colors = {
|
||||
'btc': '#F7931A', # Bitcoin orange
|
||||
'eth': '#627EEA', # Ethereum blue
|
||||
'sol': '#9945FF', # Solana purple
|
||||
'xrp': '#23292F', # XRP black
|
||||
}
|
||||
|
||||
# Plot combined line first (thicker, as background)
|
||||
if combined_pnl:
|
||||
combined_color = '#2ecc71' if combined_pnl[-1] >= 0 else '#e74c3c'
|
||||
ax.plot(combined_indices, combined_pnl,
|
||||
label=f'COMBINED (${combined_pnl[-1]:+.0f})',
|
||||
color=combined_color,
|
||||
linewidth=4,
|
||||
marker='s',
|
||||
markersize=6,
|
||||
alpha=0.9,
|
||||
zorder=10)
|
||||
|
||||
# Plot each coin line (using timeline indices)
|
||||
for coin in coins:
|
||||
if coin not in coin_cumulative:
|
||||
continue
|
||||
|
||||
cumulative = coin_cumulative[coin]
|
||||
indices = coin_indices[coin]
|
||||
|
||||
ax.plot(indices, cumulative,
|
||||
label=f'{coin.upper()} (${cumulative[-1]:+.0f})',
|
||||
color=colors.get(coin, '#888888'),
|
||||
linewidth=2,
|
||||
marker='o',
|
||||
markersize=4,
|
||||
alpha=0.7,
|
||||
zorder=5)
|
||||
|
||||
# Styling
|
||||
ax.axhline(y=0, color='gray', linestyle='--', linewidth=1, alpha=0.5)
|
||||
ax.grid(True, alpha=0.3, linestyle=':', linewidth=0.5)
|
||||
ax.set_xlabel('Market Close Events', fontsize=12, fontweight='bold')
|
||||
ax.set_ylabel('Cumulative PnL ($)', fontsize=12, fontweight='bold')
|
||||
|
||||
# Set X-axis limits to unified timeline
|
||||
ax.set_xlim(0.5, len(unique_times) + 0.5)
|
||||
|
||||
# Title with timestamp
|
||||
now = datetime.now().strftime('%Y-%m-%d %H:%M')
|
||||
ax.set_title(f'Meridian — portfolio performance\n{now}',
|
||||
fontsize=16, fontweight='bold', pad=20)
|
||||
|
||||
# Legend
|
||||
ax.legend(loc='best', fontsize=11, framealpha=0.95, shadow=True)
|
||||
|
||||
# Add stats text at bottom
|
||||
# 🔥 FIX: Use deduplicated trades for stats, not original all_trades!
|
||||
stats_lines = []
|
||||
stats_lines.append(f"Total Markets: {len(all_trades_timed)} • Events: {len(unique_times)}")
|
||||
|
||||
for coin in coins:
|
||||
# Get deduplicated trades for this coin
|
||||
coin_trades = [t for t in all_trades_timed if t['coin'] == coin]
|
||||
if coin_trades:
|
||||
wins = sum(1 for t in coin_trades if t.get('pnl', 0) > 0)
|
||||
wr = (wins / len(coin_trades) * 100) if coin_trades else 0
|
||||
final_pnl = coin_cumulative.get(coin, [0])[-1] if coin in coin_cumulative else 0
|
||||
# Use USD instead of $ to avoid matplotlib LaTeX parsing
|
||||
stats_lines.append(f"{coin.upper()}: {len(coin_trades)}m | {final_pnl:+.0f} USD | {wr:.0f}% WR")
|
||||
|
||||
stats_text = " • ".join(stats_lines)
|
||||
|
||||
ax.text(0.5, 0.02, stats_text,
|
||||
transform=ax.transAxes,
|
||||
ha='center',
|
||||
fontsize=9,
|
||||
bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.8))
|
||||
|
||||
# Tight layout and save
|
||||
plt.tight_layout()
|
||||
|
||||
debug_file = "/root/4coins_live/logs/chart_debug.log"
|
||||
with open(debug_file, 'a') as f:
|
||||
f.write(f"[CHART DEBUG] About to save chart to: {output_path}\n")
|
||||
|
||||
plt.savefig(output_path, dpi=150, bbox_inches='tight', facecolor='white')
|
||||
plt.close()
|
||||
|
||||
with open(debug_file, 'a') as f:
|
||||
f.write(f"[CHART DEBUG] Chart saved successfully!\n")
|
||||
|
||||
print(f"[CHART] ✓ Generated PnL chart: {output_path}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
debug_file = "/root/4coins_live/logs/chart_debug.log"
|
||||
with open(debug_file, 'a') as f:
|
||||
f.write(f"[CHART ERROR] Exception: {str(e)}\n")
|
||||
import traceback
|
||||
f.write(f"[CHART ERROR] Traceback:\n")
|
||||
f.write(traceback.format_exc())
|
||||
|
||||
print(f"[CHART] ✗ Error generating chart: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
Polymarket API integration for market outcome verification
|
||||
"""
|
||||
|
||||
import requests
|
||||
import json
|
||||
from typing import Optional, Dict
|
||||
|
||||
GAMMA_API = "https://gamma-api.polymarket.com"
|
||||
|
||||
def get_market_outcome(slug: str, timeout: int = 10) -> Dict:
|
||||
"""
|
||||
Get market outcome from Polymarket API
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": bool,
|
||||
"winner": "UP" | "DOWN" | None,
|
||||
"resolved": bool,
|
||||
"closed": bool,
|
||||
"error": str (if success=False)
|
||||
}
|
||||
"""
|
||||
try:
|
||||
url = f"{GAMMA_API}/events?slug={slug}"
|
||||
resp = requests.get(url, timeout=timeout)
|
||||
resp.raise_for_status()
|
||||
|
||||
events = resp.json()
|
||||
if not events or len(events) == 0:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Market not found in API: {slug}"
|
||||
}
|
||||
|
||||
event = events[0]
|
||||
markets = event.get("markets", [])
|
||||
|
||||
if not markets:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"No markets in event: {slug}"
|
||||
}
|
||||
|
||||
market = markets[0]
|
||||
|
||||
# Parse outcomes and prices
|
||||
outcomes = market.get("outcomes", [])
|
||||
prices = market.get("outcomePrices", [])
|
||||
|
||||
if isinstance(outcomes, str):
|
||||
outcomes = json.loads(outcomes)
|
||||
if isinstance(prices, str):
|
||||
prices = json.loads(prices)
|
||||
|
||||
# Get status
|
||||
closed = market.get("closed", False)
|
||||
resolved = market.get("resolved", False)
|
||||
|
||||
# Determine winner by prices (winner has price = $1.00)
|
||||
winner = None
|
||||
if prices and len(prices) >= 2:
|
||||
price_up = float(prices[0])
|
||||
price_down = float(prices[1])
|
||||
|
||||
if price_up > 0.99:
|
||||
winner = "UP"
|
||||
elif price_down > 0.99:
|
||||
winner = "DOWN"
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"winner": winner,
|
||||
"resolved": resolved,
|
||||
"closed": closed,
|
||||
"outcomes": outcomes,
|
||||
"prices": prices
|
||||
}
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"API timeout for {slug}"
|
||||
}
|
||||
except requests.exceptions.RequestException as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"API request failed: {str(e)}"
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Unexpected error: {str(e)}"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
"""
|
||||
Position Tracker - SINGLE source of truth for positions!
|
||||
|
||||
Updated ONLY from WebSocket User Channel events.
|
||||
No guesses or calculations - only real data from Polymarket API.
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Dict, Optional, List
|
||||
from dataclasses import dataclass
|
||||
from threading import Lock
|
||||
|
||||
|
||||
@dataclass
|
||||
class TradeInfo:
|
||||
"""Information about confirmed trade"""
|
||||
trade_id: str
|
||||
side: str # BUY/SELL
|
||||
contracts: float
|
||||
price: float
|
||||
usd_amount: float
|
||||
timestamp: float
|
||||
status: str # MATCHED/MINED/CONFIRMED
|
||||
|
||||
|
||||
class PositionTracker:
|
||||
"""
|
||||
SINGLE source of truth for positions!
|
||||
|
||||
Updated ONLY from WebSocket User Channel:
|
||||
- ORDER events (size_matched = real amount)
|
||||
- TRADE events (on-chain confirmation)
|
||||
|
||||
NO GUESSES! ONLY REAL DATA!
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.positions = {}
|
||||
# Structure:
|
||||
# {
|
||||
# 'market_slug': {
|
||||
# 'UP': {
|
||||
# 'contracts': 120.5, # REAL amount
|
||||
# 'invested': 85.32, # REAL investment amount
|
||||
# 'trades': [TradeInfo] # All trades
|
||||
# },
|
||||
# 'DOWN': {...}
|
||||
# }
|
||||
# }
|
||||
|
||||
self.pending_orders = {} # order_id -> order data
|
||||
self.confirmed_trades = {} # trade_id -> TradeInfo
|
||||
|
||||
self.asset_to_market = {} # asset_id -> (market_slug, side_name)
|
||||
self.lock = Lock()
|
||||
|
||||
print("[TRACKER] ✅ Position Tracker initialized - REAL DATA ONLY!")
|
||||
|
||||
def register_market(self, market_slug: str, up_token_id: str, down_token_id: str):
|
||||
"""
|
||||
Register market and its tokens
|
||||
|
||||
Required for mapping asset_id -> market_slug
|
||||
"""
|
||||
with self.lock:
|
||||
self.asset_to_market[up_token_id] = (market_slug, 'UP')
|
||||
self.asset_to_market[down_token_id] = (market_slug, 'DOWN')
|
||||
|
||||
if market_slug not in self.positions:
|
||||
self.positions[market_slug] = {
|
||||
'UP': {'contracts': 0.0, 'invested': 0.0, 'trades': []},
|
||||
'DOWN': {'contracts': 0.0, 'invested': 0.0, 'trades': []}
|
||||
}
|
||||
|
||||
print(f"[TRACKER] 📋 Registered market: {market_slug}")
|
||||
|
||||
def on_order_event(self, order_data: dict):
|
||||
"""
|
||||
Process ORDER event from WebSocket
|
||||
|
||||
Types:
|
||||
- PLACEMENT: order placed
|
||||
- UPDATE: order matched (partially or fully)
|
||||
- CANCELLATION: order cancelled
|
||||
"""
|
||||
try:
|
||||
order_type = order_data.get('type')
|
||||
order_id = order_data.get('id')
|
||||
|
||||
if order_type == 'PLACEMENT':
|
||||
# Save pending order
|
||||
with self.lock:
|
||||
self.pending_orders[order_id] = order_data
|
||||
print(f"[TRACKER] 📝 Order placed: {order_id[:16]}...")
|
||||
|
||||
elif order_type == 'UPDATE':
|
||||
# ✅ ORDER MATCHED! UPDATING POSITION WITH REAL DATA!
|
||||
size_matched = float(order_data.get('size_matched', 0))
|
||||
original_size = float(order_data.get('original_size', 0))
|
||||
asset_id = order_data.get('asset_id')
|
||||
side = order_data.get('side') # BUY/SELL
|
||||
price = float(order_data.get('price', 0))
|
||||
|
||||
# Find market by asset_id
|
||||
market_info = self.asset_to_market.get(asset_id)
|
||||
if not market_info:
|
||||
print(f"[TRACKER] ⚠ Unknown asset_id: {asset_id}")
|
||||
return
|
||||
|
||||
market_slug, side_name = market_info
|
||||
|
||||
with self.lock:
|
||||
# Ensure market is initialized
|
||||
if market_slug not in self.positions:
|
||||
self.positions[market_slug] = {
|
||||
'UP': {'contracts': 0.0, 'invested': 0.0, 'trades': []},
|
||||
'DOWN': {'contracts': 0.0, 'invested': 0.0, 'trades': []}
|
||||
}
|
||||
|
||||
pos = self.positions[market_slug][side_name]
|
||||
|
||||
if side == 'BUY':
|
||||
# ✅ BUY - add to position
|
||||
pos['contracts'] += size_matched
|
||||
pos['invested'] += (size_matched * price)
|
||||
|
||||
print(f"[TRACKER] ✅ BUY {side_name}: +{size_matched:.2f} @ ${price:.4f}")
|
||||
print(f" Position now: {pos['contracts']:.2f} contracts, ${pos['invested']:.2f} invested")
|
||||
|
||||
elif side == 'SELL':
|
||||
# ✅ SELL - remove from position
|
||||
pos['contracts'] -= size_matched
|
||||
# DON'T touch invested on sell (for PnL calculation)
|
||||
|
||||
received_usd = size_matched * price
|
||||
print(f"[TRACKER] ✅ SELL {side_name}: -{size_matched:.2f} @ ${price:.4f} = ${received_usd:.2f}")
|
||||
print(f" Position now: {pos['contracts']:.2f} contracts")
|
||||
|
||||
elif order_type == 'CANCELLATION':
|
||||
# Order cancelled
|
||||
with self.lock:
|
||||
if order_id in self.pending_orders:
|
||||
del self.pending_orders[order_id]
|
||||
print(f"[TRACKER] ❌ Order cancelled: {order_id[:16]}...")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[TRACKER] ⚠ Error processing order event: {e}")
|
||||
|
||||
def on_trade_event(self, trade_data: dict):
|
||||
"""
|
||||
Process TRADE event from WebSocket
|
||||
|
||||
Status progression:
|
||||
- MATCHED: trade matched
|
||||
- MINED: transaction in blockchain
|
||||
- CONFIRMED: transaction confirmed (FINAL!)
|
||||
- RETRYING/FAILED: errors
|
||||
"""
|
||||
try:
|
||||
trade_id = trade_data.get('id')
|
||||
status = trade_data.get('status')
|
||||
size = float(trade_data.get('size', 0))
|
||||
price = float(trade_data.get('price', 0))
|
||||
side = trade_data.get('side') # BUY/SELL
|
||||
asset_id = trade_data.get('asset_id')
|
||||
|
||||
if status == 'MATCHED':
|
||||
print(f"[TRACKER] 🔄 Trade matched: {trade_id[:16]}... ({side} {size:.2f})")
|
||||
|
||||
elif status == 'MINED':
|
||||
print(f"[TRACKER] ⛏️ Trade mined: {trade_id[:16]}...")
|
||||
|
||||
elif status == 'CONFIRMED':
|
||||
# ✅ TRADE CONFIRMED ON-CHAIN!
|
||||
market_info = self.asset_to_market.get(asset_id)
|
||||
if market_info:
|
||||
market_slug, side_name = market_info
|
||||
|
||||
trade_info = TradeInfo(
|
||||
trade_id=trade_id,
|
||||
side=side,
|
||||
contracts=size,
|
||||
price=price,
|
||||
usd_amount=size * price,
|
||||
timestamp=time.time(),
|
||||
status=status
|
||||
)
|
||||
|
||||
with self.lock:
|
||||
self.confirmed_trades[trade_id] = trade_info
|
||||
|
||||
# Add to position trades history
|
||||
if market_slug in self.positions:
|
||||
self.positions[market_slug][side_name]['trades'].append(trade_info)
|
||||
|
||||
print(f"[TRACKER] ✅ Trade CONFIRMED: {trade_id[:16]}...")
|
||||
print(f" {side} {size:.2f} @ ${price:.4f} = ${size * price:.2f}")
|
||||
|
||||
elif status in ['RETRYING', 'FAILED']:
|
||||
print(f"[TRACKER] ⚠️ Trade {status}: {trade_id[:16]}...")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[TRACKER] ⚠ Error processing trade event: {e}")
|
||||
|
||||
def get_position(self, market_slug: str, side: str) -> Dict:
|
||||
"""
|
||||
Get REAL position from WebSocket tracking
|
||||
|
||||
Returns:
|
||||
{
|
||||
'contracts': 120.5, # EXACT amount
|
||||
'invested': 85.32, # EXACT investment amount
|
||||
'avg_price': 0.71, # Average entry price
|
||||
'trades_count': 10 # Number of trades
|
||||
}
|
||||
"""
|
||||
with self.lock:
|
||||
if market_slug not in self.positions:
|
||||
return {
|
||||
'contracts': 0.0,
|
||||
'invested': 0.0,
|
||||
'avg_price': 0.0,
|
||||
'trades_count': 0
|
||||
}
|
||||
|
||||
pos = self.positions[market_slug].get(side, {'contracts': 0.0, 'invested': 0.0, 'trades': []})
|
||||
contracts = pos['contracts']
|
||||
invested = pos['invested']
|
||||
avg_price = invested / contracts if contracts > 0 else 0.0
|
||||
|
||||
return {
|
||||
'contracts': contracts,
|
||||
'invested': invested,
|
||||
'avg_price': avg_price,
|
||||
'trades_count': len(pos['trades'])
|
||||
}
|
||||
|
||||
def get_total_position(self, market_slug: str) -> Dict:
|
||||
"""
|
||||
Get total position by market (both sides)
|
||||
"""
|
||||
with self.lock:
|
||||
if market_slug not in self.positions:
|
||||
return {
|
||||
'up_contracts': 0.0,
|
||||
'down_contracts': 0.0,
|
||||
'up_invested': 0.0,
|
||||
'down_invested': 0.0,
|
||||
'total_invested': 0.0,
|
||||
'total_contracts': 0.0
|
||||
}
|
||||
|
||||
up = self.positions[market_slug]['UP']
|
||||
down = self.positions[market_slug]['DOWN']
|
||||
|
||||
return {
|
||||
'up_contracts': up['contracts'],
|
||||
'down_contracts': down['contracts'],
|
||||
'up_invested': up['invested'],
|
||||
'down_invested': down['invested'],
|
||||
'total_invested': up['invested'] + down['invested'],
|
||||
'total_contracts': up['contracts'] + down['contracts']
|
||||
}
|
||||
|
||||
def calculate_pnl(self, market_slug: str, up_price: float, down_price: float) -> Dict:
|
||||
"""
|
||||
Calculate REAL unrealized PnL based on REAL positions
|
||||
|
||||
Returns:
|
||||
{
|
||||
'unrealized_pnl': -5.32,
|
||||
'unrealized_pnl_pct': -5.87,
|
||||
'current_value': 85.18,
|
||||
'total_invested': 90.50
|
||||
}
|
||||
"""
|
||||
with self.lock:
|
||||
if market_slug not in self.positions:
|
||||
return {
|
||||
'unrealized_pnl': 0.0,
|
||||
'unrealized_pnl_pct': 0.0,
|
||||
'current_value': 0.0,
|
||||
'total_invested': 0.0
|
||||
}
|
||||
|
||||
up = self.positions[market_slug]['UP']
|
||||
down = self.positions[market_slug]['DOWN']
|
||||
|
||||
# Current position value
|
||||
up_value = up['contracts'] * up_price
|
||||
down_value = down['contracts'] * down_price
|
||||
current_value = up_value + down_value
|
||||
|
||||
# Total invested
|
||||
total_invested = up['invested'] + down['invested']
|
||||
|
||||
# PnL
|
||||
unrealized_pnl = current_value - total_invested
|
||||
unrealized_pnl_pct = (unrealized_pnl / total_invested * 100) if total_invested > 0 else 0.0
|
||||
|
||||
return {
|
||||
'unrealized_pnl': unrealized_pnl,
|
||||
'unrealized_pnl_pct': unrealized_pnl_pct,
|
||||
'current_value': current_value,
|
||||
'total_invested': total_invested
|
||||
}
|
||||
|
||||
def has_position(self, market_slug: str) -> bool:
|
||||
"""Check if there is an open position"""
|
||||
with self.lock:
|
||||
if market_slug not in self.positions:
|
||||
return False
|
||||
|
||||
up_contracts = self.positions[market_slug]['UP']['contracts']
|
||||
down_contracts = self.positions[market_slug]['DOWN']['contracts']
|
||||
|
||||
return up_contracts > 0.01 or down_contracts > 0.01
|
||||
|
||||
def clear_position(self, market_slug: str):
|
||||
"""Clear position (after market close)"""
|
||||
with self.lock:
|
||||
if market_slug in self.positions:
|
||||
print(f"[TRACKER] 🧹 Clearing position for {market_slug}")
|
||||
del self.positions[market_slug]
|
||||
|
||||
def get_all_positions(self) -> Dict:
|
||||
"""Get all open positions"""
|
||||
with self.lock:
|
||||
return {
|
||||
slug: self.get_total_position(slug)
|
||||
for slug in self.positions.keys()
|
||||
if self.has_position(slug)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
Safety Guard - Protection layer for real money trading
|
||||
"""
|
||||
import time
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, Tuple
|
||||
|
||||
|
||||
class SafetyGuard:
|
||||
"""Protection against accidental real money trading"""
|
||||
|
||||
def __init__(self, config: Dict):
|
||||
self.config = config
|
||||
|
||||
# Read from config (NO FALLBACKS - must be explicit!)
|
||||
safety_config = config.get("safety")
|
||||
if not safety_config:
|
||||
raise ValueError("❌ CRITICAL: 'safety' section missing in config.json!")
|
||||
|
||||
# Check required parameters
|
||||
if "dry_run" not in safety_config:
|
||||
raise ValueError("❌ CRITICAL: 'dry_run' not set in config.json!")
|
||||
if "max_order_size_usd" not in safety_config:
|
||||
raise ValueError("❌ CRITICAL: 'max_order_size_usd' not set in config.json!")
|
||||
if "max_total_investment" not in safety_config:
|
||||
raise ValueError("❌ CRITICAL: 'max_total_investment' not set in config.json!")
|
||||
|
||||
self.dry_run = safety_config["dry_run"]
|
||||
self.max_order_size_usd = safety_config["max_order_size_usd"]
|
||||
self.max_orders_per_minute = safety_config.get("max_orders_per_minute", 100) # OK fallback
|
||||
self.max_total_investment = safety_config["max_total_investment"]
|
||||
|
||||
# Tracking
|
||||
self.orders_history = []
|
||||
self.invested_per_market = {} # {market_slug: invested_usd} - PER MARKET!
|
||||
self.emergency_stop = False
|
||||
|
||||
# Logging
|
||||
self.safety_log = Path("logs/safety.log")
|
||||
self.safety_log.parent.mkdir(exist_ok=True)
|
||||
|
||||
self._log_init()
|
||||
|
||||
def _log_init(self):
|
||||
"""Log initialization"""
|
||||
mode = "🟢 DRY_RUN (SAFE)" if self.dry_run else "🔴 LIVE TRADING (REAL MONEY)"
|
||||
msg = f"\n{'='*80}\n[{time.strftime('%Y-%m-%d %H:%M:%S')}] SafetyGuard Initialized\n"
|
||||
msg += f"Mode: {mode}\n"
|
||||
msg += f"Max order size: ${self.max_order_size_usd}\n"
|
||||
msg += f"Max orders/min: {self.max_orders_per_minute}\n"
|
||||
msg += f"Max total investment: ${self.max_total_investment}\n"
|
||||
msg += f"{'='*80}\n"
|
||||
|
||||
with open(self.safety_log, 'a', encoding='utf-8') as f:
|
||||
f.write(msg)
|
||||
|
||||
print(msg)
|
||||
|
||||
def check_order_allowed(self, side: str, contracts: int, price: float,
|
||||
market_slug: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
Check if order is allowed
|
||||
|
||||
Returns:
|
||||
(allowed: bool, reason: str)
|
||||
"""
|
||||
# Emergency stop
|
||||
if self.emergency_stop:
|
||||
return False, "EMERGENCY_STOP_ACTIVE"
|
||||
|
||||
# DRY_RUN - block all real orders
|
||||
if self.dry_run:
|
||||
return False, "DRY_RUN_MODE"
|
||||
|
||||
# Order size
|
||||
order_size_usd = contracts * price
|
||||
if order_size_usd > self.max_order_size_usd:
|
||||
return False, f"ORDER_TOO_LARGE (${order_size_usd:.2f} > ${self.max_order_size_usd})"
|
||||
|
||||
# Rate limiting
|
||||
recent_orders = [o for o in self.orders_history
|
||||
if time.time() - o['timestamp'] < 60]
|
||||
if len(recent_orders) >= self.max_orders_per_minute:
|
||||
return False, f"RATE_LIMIT ({len(recent_orders)}/{self.max_orders_per_minute} per min)"
|
||||
|
||||
# Total investment PER THIS MARKET (resets on market change!)
|
||||
current_market_invested = self.invested_per_market.get(market_slug, 0.0)
|
||||
|
||||
if current_market_invested + order_size_usd > self.max_total_investment:
|
||||
return False, f"INVESTMENT_LIMIT for {market_slug} (${current_market_invested:.2f} + ${order_size_usd:.2f} > ${self.max_total_investment})"
|
||||
|
||||
return True, "OK"
|
||||
|
||||
def record_order(self, side: str, contracts: float, price: float,
|
||||
market_slug: str, order_id: str = None):
|
||||
"""Record executed order"""
|
||||
order_size_usd = contracts * price
|
||||
|
||||
order = {
|
||||
'timestamp': time.time(),
|
||||
'market_slug': market_slug,
|
||||
'side': side,
|
||||
'contracts': contracts,
|
||||
'price': price,
|
||||
'size_usd': order_size_usd,
|
||||
'order_id': order_id,
|
||||
'dry_run': self.dry_run
|
||||
}
|
||||
|
||||
self.orders_history.append(order)
|
||||
|
||||
# Accumulate for THIS MARKET (not globally!)
|
||||
if market_slug not in self.invested_per_market:
|
||||
self.invested_per_market[market_slug] = 0.0
|
||||
|
||||
self.invested_per_market[market_slug] += order_size_usd
|
||||
|
||||
# Write to log
|
||||
with open(self.safety_log, 'a', encoding='utf-8') as f:
|
||||
f.write(json.dumps(order) + '\n')
|
||||
|
||||
def reset_market(self, market_slug: str):
|
||||
"""
|
||||
Reset investment tracking for closed market
|
||||
|
||||
Called after redeem or market close.
|
||||
This allows trading new markets without limits from previous ones!
|
||||
"""
|
||||
if market_slug in self.invested_per_market:
|
||||
invested_amount = self.invested_per_market[market_slug]
|
||||
del self.invested_per_market[market_slug]
|
||||
print(f"[SAFETY] ♻️ Investment tracking reset for {market_slug} (was ${invested_amount:.2f})")
|
||||
|
||||
# Write to log
|
||||
with open(self.safety_log, 'a', encoding='utf-8') as f:
|
||||
f.write(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] RESET_MARKET: {market_slug} (${invested_amount:.2f})\n")
|
||||
|
||||
def get_market_investment(self, market_slug: str) -> float:
|
||||
"""Get current investment in market"""
|
||||
return self.invested_per_market.get(market_slug, 0.0)
|
||||
|
||||
def get_total_investment_all_markets(self) -> float:
|
||||
"""Get total investment across all active markets (for info)"""
|
||||
return sum(self.invested_per_market.values())
|
||||
|
||||
def activate_emergency_stop(self, reason: str):
|
||||
"""Activate emergency stop"""
|
||||
self.emergency_stop = True
|
||||
msg = f"\n🚨 EMERGENCY STOP ACTIVATED: {reason}\n"
|
||||
print(msg)
|
||||
|
||||
with open(self.safety_log, 'a', encoding='utf-8') as f:
|
||||
f.write(msg)
|
||||
@@ -0,0 +1,431 @@
|
||||
"""
|
||||
Simple Redeem Collector
|
||||
Periodically collects all unredeemed positions via Polymarket API
|
||||
Simple replacement for complex system pending_markets
|
||||
"""
|
||||
import time
|
||||
import threading
|
||||
import requests
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
class SimpleRedeemCollector:
|
||||
"""
|
||||
Simple collector for unredeemed positions on timer
|
||||
|
||||
Uses Polymarket API for automatic detection of all
|
||||
redeemable positions and triggers redeem for each.
|
||||
|
||||
✅ Does NOT block main processes (trading)
|
||||
✅ Runs in separate daemon thread
|
||||
✅ Finds ALL positions (even after restart)
|
||||
"""
|
||||
|
||||
def __init__(self, wallet_address: str, config: dict, order_executor, trader_module,
|
||||
multi_trader=None, notifier=None):
|
||||
"""
|
||||
Args:
|
||||
wallet_address: Wallet address (0x...)
|
||||
config: Configuration with parameters
|
||||
order_executor: OrderExecutor instance for redeem
|
||||
trader_module: Trader module for getting token IDs
|
||||
multi_trader: MultiTrader instance for creating trade records (optional)
|
||||
notifier: TelegramNotifier for notifications (optional)
|
||||
"""
|
||||
self.wallet = wallet_address
|
||||
self.config = config
|
||||
self.executor = order_executor
|
||||
self.trader = trader_module
|
||||
self.multi_trader = multi_trader
|
||||
self.notifier = notifier
|
||||
|
||||
# Load parameters from config
|
||||
redeem_cfg = config.get('execution', {}).get('redeem', {})
|
||||
self.check_interval = redeem_cfg.get('check_interval_sec', 300) # 5 min
|
||||
self.startup_delay = redeem_cfg.get('startup_check_delay_sec', 60) # 1 min
|
||||
self.first_delay = redeem_cfg.get('first_check_delay_sec', 480) # 8 min
|
||||
self.pause_between = redeem_cfg.get('pause_between_redeems_sec', 2)
|
||||
self.size_threshold = redeem_cfg.get('sizeThreshold', 0.1)
|
||||
|
||||
# Rate limit protection
|
||||
self.api_max_retries = redeem_cfg.get('api_max_retries', 3)
|
||||
self.api_retry_delay = redeem_cfg.get('api_retry_delay_sec', 60)
|
||||
self.api_timeout = redeem_cfg.get('api_timeout_sec', 30)
|
||||
|
||||
# State
|
||||
self.is_running = False
|
||||
self.last_check = 0
|
||||
self.stats = {
|
||||
'total_checks': 0,
|
||||
'total_redeemed': 0,
|
||||
'startup_check_done': False
|
||||
}
|
||||
|
||||
print(f"[REDEEM COLLECTOR] Initialized:")
|
||||
print(f" Wallet: {wallet_address[:10]}...{wallet_address[-8:]}")
|
||||
print(f" Startup check: {self.startup_delay}s")
|
||||
print(f" Regular checks: every {self.check_interval//60} minutes")
|
||||
|
||||
def start(self):
|
||||
"""Start in background thread (daemon - doesn't block shutdown)"""
|
||||
if self.is_running:
|
||||
print("[REDEEM COLLECTOR] Already running!")
|
||||
return
|
||||
|
||||
self.is_running = True
|
||||
self.thread = threading.Thread(
|
||||
target=self._loop,
|
||||
daemon=True,
|
||||
name="SimpleRedeemCollector"
|
||||
)
|
||||
self.thread.start()
|
||||
print(f"[REDEEM COLLECTOR] ✅ Started (daemon thread)")
|
||||
|
||||
def stop(self):
|
||||
"""Stop background thread"""
|
||||
self.is_running = False
|
||||
if hasattr(self, 'thread') and self.thread:
|
||||
self.thread.join(timeout=5)
|
||||
print(f"[REDEEM COLLECTOR] Stopped")
|
||||
|
||||
def _loop(self):
|
||||
"""Background loop - runs in separate thread"""
|
||||
print(f"\n[REDEEM COLLECTOR] Background loop started")
|
||||
|
||||
# 🔥 STARTUP CHECK: right after start (after startup_delay)
|
||||
# Goal: collect everything accumulated before script start
|
||||
print(f"[REDEEM COLLECTOR] ⏰ Startup check in {self.startup_delay}s...")
|
||||
print(f"[REDEEM COLLECTOR] Will collect all unredeemed positions from before restart")
|
||||
time.sleep(self.startup_delay)
|
||||
|
||||
print(f"\n[REDEEM COLLECTOR] 🚀 STARTUP CHECK")
|
||||
try:
|
||||
self._check_and_redeem_all(check_type="STARTUP")
|
||||
self.stats['startup_check_done'] = True
|
||||
except Exception as e:
|
||||
print(f"[REDEEM COLLECTOR] ⚠️ Startup check error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# 🔥 FIRST REGULAR CHECK: after first_delay from startup
|
||||
# (for fresh markets that just closed)
|
||||
remaining_delay = max(0, self.first_delay - self.startup_delay)
|
||||
if remaining_delay > 0:
|
||||
print(f"\n[REDEEM COLLECTOR] ⏰ First regular check in {remaining_delay//60} minutes...")
|
||||
time.sleep(remaining_delay)
|
||||
|
||||
# 🔥 REGULAR CHECKS: every check_interval
|
||||
while self.is_running:
|
||||
try:
|
||||
self._check_and_redeem_all(check_type="PERIODIC")
|
||||
except Exception as e:
|
||||
print(f"[REDEEM COLLECTOR] ⚠️ Periodic check error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# Wait until next check
|
||||
if self.is_running:
|
||||
print(f"[REDEEM COLLECTOR] ⏰ Next check in {self.check_interval//60} minutes...")
|
||||
time.sleep(self.check_interval)
|
||||
|
||||
def _check_and_redeem_all(self, check_type: str = "PERIODIC"):
|
||||
"""
|
||||
Check API and redeem ALL
|
||||
|
||||
Args:
|
||||
check_type: "STARTUP" (at startup) or "PERIODIC" (regular)
|
||||
"""
|
||||
print(f"\n{'='*80}")
|
||||
if check_type == "STARTUP":
|
||||
print(f"[REDEEM COLLECTOR] 🚀 STARTUP CHECK")
|
||||
print(f"[REDEEM COLLECTOR] Collecting unredeemed from before restart...")
|
||||
else:
|
||||
print(f"[REDEEM COLLECTOR] 🔍 PERIODIC CHECK #{self.stats['total_checks'] + 1}")
|
||||
print(f"{'='*80}")
|
||||
|
||||
self.stats['total_checks'] += 1
|
||||
self.last_check = time.time()
|
||||
|
||||
# STEP 1: Query API
|
||||
positions = self._fetch_redeemable_positions()
|
||||
|
||||
if positions is None:
|
||||
print(f"[REDEEM COLLECTOR] ⚠️ API request failed, skipping this cycle")
|
||||
return
|
||||
|
||||
print(f"[REDEEM COLLECTOR] Found {len(positions)} redeemable position(s)")
|
||||
|
||||
if not positions:
|
||||
print(f"[REDEEM COLLECTOR] ✓ Nothing to redeem")
|
||||
if check_type == "STARTUP":
|
||||
print(f"[REDEEM COLLECTOR] ✓ All positions were already claimed before restart")
|
||||
return
|
||||
|
||||
# Show summary
|
||||
total_size = sum(p.get('size', 0) for p in positions)
|
||||
total_value = sum(p.get('currentValue', 0) for p in positions)
|
||||
print(f"[REDEEM COLLECTOR] Summary:")
|
||||
print(f" Total contracts: {total_size:.2f}")
|
||||
print(f" Estimated value: ${total_value:.2f}")
|
||||
|
||||
if check_type == "STARTUP":
|
||||
print(f"[REDEEM COLLECTOR] 💰 These positions accumulated before script restart")
|
||||
|
||||
# STEP 2: Redeem each position (sequentially)
|
||||
print(f"\n[REDEEM COLLECTOR] Starting redeem process...")
|
||||
success_count = 0
|
||||
failed_count = 0
|
||||
|
||||
for i, pos in enumerate(positions, 1):
|
||||
result = self._redeem_one(i, len(positions), pos)
|
||||
if result:
|
||||
success_count += 1
|
||||
else:
|
||||
failed_count += 1
|
||||
|
||||
# Pause between redeems (from config)
|
||||
if i < len(positions):
|
||||
time.sleep(self.pause_between)
|
||||
|
||||
print(f"\n[REDEEM COLLECTOR] ✅ Check completed")
|
||||
print(f" Successful: {success_count}/{len(positions)}")
|
||||
print(f" Failed: {failed_count}/{len(positions)}")
|
||||
print(f" Total redeemed (session): {self.stats['total_redeemed']}")
|
||||
print(f"{'='*80}\n")
|
||||
|
||||
def _fetch_redeemable_positions(self) -> Optional[List[Dict]]:
|
||||
"""
|
||||
Query Polymarket API to get redeemable positions
|
||||
With rate limit handling and retry logic
|
||||
"""
|
||||
url = "https://data-api.polymarket.com/positions"
|
||||
params = {
|
||||
'user': self.wallet,
|
||||
'redeemable': 'true',
|
||||
'sizeThreshold': self.size_threshold,
|
||||
'limit': 500
|
||||
}
|
||||
|
||||
print(f"[REDEEM COLLECTOR] Requesting Polymarket API...")
|
||||
print(f" URL: {url}")
|
||||
print(f" Filter: redeemable=true, sizeThreshold={self.size_threshold}")
|
||||
|
||||
for attempt in range(1, self.api_max_retries + 1):
|
||||
try:
|
||||
response = requests.get(url, params=params, timeout=self.api_timeout)
|
||||
|
||||
# ✅ SUCCESS
|
||||
if response.status_code == 200:
|
||||
positions = response.json()
|
||||
print(f"[REDEEM COLLECTOR] ✓ API response: {len(positions)} position(s)")
|
||||
return positions
|
||||
|
||||
# ⚠️ RATE LIMIT
|
||||
elif response.status_code == 429:
|
||||
retry_after = int(response.headers.get('Retry-After', self.api_retry_delay))
|
||||
print(f"[REDEEM COLLECTOR] ⚠️ Rate limit hit (429)")
|
||||
print(f"[REDEEM COLLECTOR] Retry-After: {retry_after}s")
|
||||
|
||||
if attempt < self.api_max_retries:
|
||||
print(f"[REDEEM COLLECTOR] Waiting {retry_after}s before retry...")
|
||||
time.sleep(retry_after)
|
||||
continue
|
||||
else:
|
||||
print(f"[REDEEM COLLECTOR] ❌ Rate limit persists after {self.api_max_retries} attempts")
|
||||
return None
|
||||
|
||||
# ❌ OTHER ERROR
|
||||
else:
|
||||
print(f"[REDEEM COLLECTOR] ❌ API error: {response.status_code}")
|
||||
print(f" Response: {response.text[:200]}")
|
||||
|
||||
if attempt < self.api_max_retries:
|
||||
wait_time = 5 * attempt # Exponential backoff
|
||||
print(f"[REDEEM COLLECTOR] Retry {attempt}/{self.api_max_retries} in {wait_time}s...")
|
||||
time.sleep(wait_time)
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
print(f"[REDEEM COLLECTOR] ⚠️ Request timeout (attempt {attempt})")
|
||||
if attempt < self.api_max_retries:
|
||||
time.sleep(5)
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
print(f"[REDEEM COLLECTOR] ❌ Request exception (attempt {attempt}): {e}")
|
||||
if attempt < self.api_max_retries:
|
||||
time.sleep(5)
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
def _redeem_one(self, index: int, total: int, position: Dict) -> bool:
|
||||
"""
|
||||
Redeem one position
|
||||
|
||||
Returns:
|
||||
True if successful, False if failed
|
||||
"""
|
||||
slug = position.get('slug')
|
||||
condition_id = position.get('conditionId')
|
||||
size = position.get('size', 0)
|
||||
neg_risk = position.get('negativeRisk', True)
|
||||
current_value = position.get('currentValue', 0)
|
||||
outcome = position.get('outcome', '')
|
||||
|
||||
print(f"\n[REDEEM COLLECTOR] [{index}/{total}] Processing: {slug}")
|
||||
print(f" Condition ID: {condition_id[:20]}...")
|
||||
print(f" Size: {size:.2f} contracts")
|
||||
print(f" Value: ${current_value:.2f}")
|
||||
print(f" Outcome: {outcome}")
|
||||
|
||||
try:
|
||||
# Get token IDs from cache
|
||||
token_ids = self.trader.get_token_ids(slug)
|
||||
|
||||
if not token_ids:
|
||||
print(f"[REDEEM COLLECTOR] No token IDs in cache, fetching metadata...")
|
||||
# Try to fetch metadata
|
||||
metadata = self.trader.get_market_metadata(slug)
|
||||
token_ids = self.trader.get_token_ids(slug)
|
||||
|
||||
if not token_ids or not token_ids.get('UP') or not token_ids.get('DOWN'):
|
||||
print(f"[REDEEM COLLECTOR] ⚠️ No token IDs for {slug}, skipping")
|
||||
print(f"[REDEEM COLLECTOR] This position cannot be redeemed without token IDs")
|
||||
return False
|
||||
|
||||
print(f"[REDEEM COLLECTOR] UP token: {token_ids['UP'][:10]}...")
|
||||
print(f"[REDEEM COLLECTOR] DOWN token: {token_ids['DOWN'][:10]}...")
|
||||
print(f"[REDEEM COLLECTOR] Calling redeem_position()...")
|
||||
|
||||
# Call redeem via order_executor
|
||||
success, amount = self.executor.redeem_position(
|
||||
market_slug=slug,
|
||||
condition_id=condition_id,
|
||||
up_token_id=token_ids['UP'],
|
||||
down_token_id=token_ids['DOWN'],
|
||||
neg_risk=neg_risk
|
||||
)
|
||||
|
||||
if success:
|
||||
print(f"[REDEEM COLLECTOR] ✅ Redeemed ${amount:.2f} USDC!")
|
||||
self.stats['total_redeemed'] += 1
|
||||
|
||||
# 🔥 FIX: Create trade record for dashboard (for all 4 coins)
|
||||
if self.multi_trader:
|
||||
try:
|
||||
from polymarket_api import get_market_outcome
|
||||
|
||||
# Get real market outcome from Polymarket API
|
||||
print(f"[REDEEM COLLECTOR] Fetching market outcome from API...")
|
||||
api_result = get_market_outcome(slug)
|
||||
|
||||
if api_result.get("success") and api_result.get("winner"):
|
||||
winner = api_result["winner"]
|
||||
print(f"[REDEEM COLLECTOR] Winner: {winner}")
|
||||
|
||||
# Determine coin from market_slug
|
||||
coin = None
|
||||
for c in ['btc', 'eth', 'sol', 'xrp']:
|
||||
if f'{c}-updown-' in slug:
|
||||
coin = c
|
||||
break
|
||||
|
||||
if coin:
|
||||
strategy_name = f"late_v3_{coin}"
|
||||
print(f"[REDEEM COLLECTOR] Creating trade record for {strategy_name}...")
|
||||
|
||||
# Create trade record via multi_trader
|
||||
result = self.multi_trader.close_market(
|
||||
strategy_name=strategy_name,
|
||||
market_slug=slug,
|
||||
winner=winner,
|
||||
btc_start=0.0, # Unknown for redeems
|
||||
btc_final=0.0
|
||||
)
|
||||
|
||||
if result:
|
||||
print(f"[REDEEM COLLECTOR] ✅ Trade record created!")
|
||||
print(f"[REDEEM COLLECTOR] PnL: ${result['pnl']:+.2f}")
|
||||
print(f"[REDEEM COLLECTOR] ROI: {result['roi_pct']:+.1f}%")
|
||||
|
||||
# Send Telegram notification
|
||||
if self.notifier:
|
||||
try:
|
||||
session_stats = self.multi_trader.get_session_stats(strategy_name, 0)
|
||||
|
||||
# Create correct format portfolio_stats for Telegram
|
||||
portfolio_stats = {}
|
||||
for c in ['btc', 'eth', 'sol', 'xrp']:
|
||||
trader_name = f"late_v3_{c}"
|
||||
trader = self.multi_trader.traders.get(trader_name)
|
||||
if trader:
|
||||
perf = trader.get_performance_stats()
|
||||
portfolio_stats[f'{c}_pnl'] = trader.current_capital - trader.starting_capital
|
||||
portfolio_stats[f'{c}_wr'] = perf['win_rate']
|
||||
portfolio_stats[f'{c}_markets_played'] = perf['total_trades']
|
||||
else:
|
||||
portfolio_stats[f'{c}_pnl'] = 0
|
||||
portfolio_stats[f'{c}_wr'] = 0
|
||||
portfolio_stats[f'{c}_markets_played'] = 0
|
||||
|
||||
portfolio_stats['total_pnl'] = sum(portfolio_stats.get(f'{c}_pnl', 0) for c in ['btc', 'eth', 'sol', 'xrp'])
|
||||
portfolio_stats['uptime'] = 0 # For redeem uptime doesn't matter
|
||||
|
||||
self.notifier.send_market_closed(
|
||||
coin=coin,
|
||||
trade=result,
|
||||
session_stats=session_stats,
|
||||
portfolio_stats=portfolio_stats
|
||||
)
|
||||
print(f"[REDEEM COLLECTOR] ✅ Telegram notification sent")
|
||||
except Exception as notify_err:
|
||||
print(f"[REDEEM COLLECTOR] ⚠️ Notification failed: {notify_err}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
else:
|
||||
print(f"[REDEEM COLLECTOR] ⚠️ Trade record creation returned None")
|
||||
print(f"[REDEEM COLLECTOR] (Position might have been empty)")
|
||||
else:
|
||||
print(f"[REDEEM COLLECTOR] ⚠️ Could not determine coin from slug: {slug}")
|
||||
else:
|
||||
print(f"[REDEEM COLLECTOR] ⚠️ Market outcome not available")
|
||||
print(f"[REDEEM COLLECTOR] API result: {api_result}")
|
||||
|
||||
except Exception as trade_err:
|
||||
print(f"[REDEEM COLLECTOR] ⚠️ Failed to create trade record: {trade_err}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# Reset market tracking in safety guard
|
||||
try:
|
||||
if hasattr(self.trader, 'order_executor') and self.trader.order_executor:
|
||||
self.trader.order_executor.safety.reset_market(slug)
|
||||
print(f"[REDEEM COLLECTOR] Market tracking reset")
|
||||
except Exception as reset_err:
|
||||
print(f"[REDEEM COLLECTOR] ⚠️ Failed to reset tracking: {reset_err}")
|
||||
|
||||
return True
|
||||
else:
|
||||
print(f"[REDEEM COLLECTOR] ⚠️ Redeem failed")
|
||||
print(f"[REDEEM COLLECTOR] Reason: Oracle not resolved or no tokens")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"[REDEEM COLLECTOR] ❌ Error processing {slug}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
def get_stats(self) -> Dict:
|
||||
"""Get collector statistics"""
|
||||
return {
|
||||
'total_checks': self.stats['total_checks'],
|
||||
'total_redeemed': self.stats['total_redeemed'],
|
||||
'startup_check_done': self.stats['startup_check_done'],
|
||||
'last_check_time': self.last_check,
|
||||
'is_running': self.is_running,
|
||||
'check_interval_min': self.check_interval // 60
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
Meridian — late-window entry strategy (Late Entry V3 / late_v3).
|
||||
Time-based sizing; supports 5m and 15m Polymarket windows (see data_sources.polymarket.market_interval_sec).
|
||||
"""
|
||||
import time
|
||||
from typing import Optional, Dict
|
||||
|
||||
|
||||
class LateEntryStrategy:
|
||||
"""Late-window entry: trade the favorite side in the final minutes of the window."""
|
||||
|
||||
def __init__(self, config: Dict):
|
||||
# Read ALL params from config (NO HARDCODED VALUES!)
|
||||
strategy_cfg = config.get('strategy', {})
|
||||
pm = config.get("data_sources", {}).get("polymarket", {})
|
||||
self.market_interval_sec = int(pm.get("market_interval_sec", 900))
|
||||
if self.market_interval_sec <= 0:
|
||||
self.market_interval_sec = 900
|
||||
|
||||
# Default entry window: ~last 4 min of 15m, ~last 2 min of 5m (override in config)
|
||||
default_entry = 240 if self.market_interval_sec >= 900 else min(120, self.market_interval_sec - 10)
|
||||
raw_ew = int(strategy_cfg.get("entry_window_sec", default_entry))
|
||||
# If config still has 15m-style values (e.g. 240) on a 5m market, use default_entry
|
||||
if self.market_interval_sec < 900 and raw_ew > self.market_interval_sec * 0.5:
|
||||
raw_ew = default_entry
|
||||
self.entry_window = min(raw_ew, max(10, self.market_interval_sec - 5))
|
||||
self.entry_freq = strategy_cfg.get('entry_frequency_sec', 7)
|
||||
self.min_confidence = strategy_cfg.get('min_confidence', 0.30)
|
||||
self.max_spread = strategy_cfg.get('max_spread', 1.05)
|
||||
self.price_max = strategy_cfg.get('price_max', 0.93)
|
||||
|
||||
# Sizing (contracts) - time-based FROM CONFIG!
|
||||
sizing_cfg = strategy_cfg.get('sizing', {})
|
||||
self.size_above_180 = sizing_cfg.get('above_180_sec', 8)
|
||||
self.size_above_120 = sizing_cfg.get('above_120_sec', 10)
|
||||
self.size_below_120 = sizing_cfg.get('below_120_sec', 12)
|
||||
# Scale 180s/120s thresholds for shorter windows (e.g. 5m → 60s/40s)
|
||||
scale = self.market_interval_sec / 900.0
|
||||
self.sizing_t1 = max(15, int(180 * scale))
|
||||
self.sizing_t2 = max(10, int(120 * scale))
|
||||
|
||||
# Max investment per market
|
||||
self.max_investment = strategy_cfg.get('max_investment_per_market', 300)
|
||||
|
||||
# Flip-stop price (price reversal protection)
|
||||
exit_cfg = config.get('exit', {})
|
||||
flip_cfg = exit_cfg.get('flip_stop', {})
|
||||
self.flip_stop_price = flip_cfg.get('price_threshold', 0.48)
|
||||
|
||||
# Track last entry per market
|
||||
self.last_entry = {}
|
||||
self.last_favorite = {}
|
||||
|
||||
def should_enter(self, state: Dict, position: Optional[Dict] = None) -> Optional[Dict]:
|
||||
"""
|
||||
Check if should enter (Late Entry V3 logic)
|
||||
|
||||
Args:
|
||||
state: Market state with keys:
|
||||
- market_slug: str
|
||||
- seconds_till_end: int
|
||||
- up_ask: float
|
||||
- down_ask: float
|
||||
position: Optional position stats
|
||||
|
||||
Returns:
|
||||
Signal dict or None
|
||||
"""
|
||||
market = state['market_slug']
|
||||
time_left = state['seconds_till_end']
|
||||
up_ask = state['up_ask']
|
||||
down_ask = state['down_ask']
|
||||
|
||||
# TIME: only inside configured late window
|
||||
if time_left > self.entry_window or time_left <= 0:
|
||||
return None
|
||||
|
||||
# FREQUENCY
|
||||
now = time.time()
|
||||
if market in self.last_entry and now - self.last_entry[market] < self.entry_freq:
|
||||
return None
|
||||
|
||||
# SPREAD
|
||||
spread = up_ask + down_ask
|
||||
if spread > self.max_spread or spread <= 0:
|
||||
return None
|
||||
|
||||
# CONFIDENCE
|
||||
confidence = abs(up_ask - down_ask)
|
||||
if confidence < self.min_confidence:
|
||||
return None
|
||||
|
||||
# FAVORITE
|
||||
favorite = 'UP' if up_ask > down_ask else 'DOWN'
|
||||
fav_price = up_ask if favorite == 'UP' else down_ask
|
||||
|
||||
# PRICE MAX
|
||||
if fav_price > self.price_max:
|
||||
return None
|
||||
|
||||
# INVESTMENT LIMIT
|
||||
if position:
|
||||
total_cost = position.get('total_cost', 0)
|
||||
if total_cost >= self.max_investment:
|
||||
return None
|
||||
|
||||
# RISK CHECKS - stop-loss removed, only flip-stop via main.py
|
||||
# Flip-stop logic in main.py (check: our_price <= strategy.flip_stop_price)
|
||||
|
||||
# ENTRY (sizing thresholds scale with market length: 15m → 180/120s, 5m → 60/40s)
|
||||
size = (
|
||||
self.size_above_180
|
||||
if time_left > self.sizing_t1
|
||||
else (self.size_above_120 if time_left > self.sizing_t2 else self.size_below_120)
|
||||
)
|
||||
|
||||
self.last_entry[market] = now
|
||||
self.last_favorite[market] = favorite
|
||||
|
||||
return {
|
||||
'favored': {
|
||||
'side': favorite,
|
||||
'price': fav_price,
|
||||
'contracts': size,
|
||||
},
|
||||
'hedge': {
|
||||
'side': 'DOWN' if favorite == 'UP' else 'UP',
|
||||
'price': down_ask if favorite == 'UP' else up_ask,
|
||||
'contracts': 0,
|
||||
},
|
||||
'confidence': confidence,
|
||||
'is_recovery': False,
|
||||
'entry_reason': f'late_entry_{time_left}s',
|
||||
'winner_ratio': 0.0
|
||||
}
|
||||
|
||||
def get_stats(self) -> Dict:
|
||||
"""Get strategy statistics (for dashboard compatibility)"""
|
||||
return {
|
||||
'generated': 0,
|
||||
'skipped': 0,
|
||||
'total': 0,
|
||||
'skip_breakdown': {},
|
||||
'gen_pct': 0,
|
||||
'skip_pct': 0,
|
||||
'wr_recoveries': 0
|
||||
}
|
||||
|
||||
def reset_market(self, market_slug: str):
|
||||
"""Reset tracking for a market"""
|
||||
if market_slug in self.last_entry:
|
||||
del self.last_entry[market_slug]
|
||||
if market_slug in self.last_favorite:
|
||||
del self.last_favorite[market_slug]
|
||||
@@ -0,0 +1,730 @@
|
||||
"""
|
||||
Telegram Notification System for Trading Bot
|
||||
Sends detailed market updates after each trade - NO SPAM!
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import requests
|
||||
from datetime import timedelta
|
||||
from threading import Thread, Lock
|
||||
from queue import Queue, Empty
|
||||
from typing import Dict
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv("/root/4coins_live/.env")
|
||||
|
||||
|
||||
class TelegramNotifier:
|
||||
"""
|
||||
Non-blocking Telegram notification sender with rate limiting
|
||||
|
||||
Features:
|
||||
- Background thread for sending
|
||||
- Rate limiting (2 msg/sec max to avoid spam)
|
||||
- Graceful error handling (never crashes main process)
|
||||
- Queue-based with drop counter
|
||||
- ONLY market close/skip notifications (no startup spam)
|
||||
"""
|
||||
|
||||
def __init__(self, bot_token: str = None, chat_id: str = None, rate_limit: float = 2.0, event_callback=None):
|
||||
"""
|
||||
Initialize Telegram notifier
|
||||
|
||||
Args:
|
||||
bot_token: Telegram bot token (from @BotFather)
|
||||
chat_id: Telegram chat ID (your user ID)
|
||||
rate_limit: Max messages per second (default: 2)
|
||||
event_callback: Callback function(message, event_type) for logging events
|
||||
"""
|
||||
# Get from env if not provided
|
||||
self.bot_token = bot_token or os.getenv("TELEGRAM_BOT_TOKEN", "")
|
||||
self.chat_id = chat_id or os.getenv("TELEGRAM_CHAT_ID", "")
|
||||
self.event_callback = event_callback
|
||||
|
||||
# Configuration
|
||||
self.rate_limit = rate_limit
|
||||
self.min_interval = 1.0 / rate_limit
|
||||
self.last_send_time = 0.0
|
||||
|
||||
# Queue for messages
|
||||
self.queue = Queue(maxsize=30) # Small queue - only market notifications
|
||||
self.running = True
|
||||
self.enabled = bool(self.bot_token and self.chat_id)
|
||||
|
||||
# Statistics
|
||||
self.dropped_count = 0
|
||||
self.sent_count = 0
|
||||
self.error_count = 0
|
||||
self.last_error_time = 0.0
|
||||
|
||||
# Session tracking
|
||||
self.session_start_time = time.time()
|
||||
|
||||
# Start worker thread if enabled
|
||||
if self.enabled:
|
||||
self.thread = Thread(target=self._worker, daemon=True, name="TelegramNotifier")
|
||||
self.thread.start()
|
||||
if self.event_callback:
|
||||
self.event_callback("Notifier started", 'telegram')
|
||||
else:
|
||||
if self.event_callback:
|
||||
self.event_callback("Telegram disabled (no credentials)", 'info')
|
||||
|
||||
def _worker(self):
|
||||
"""Background worker that sends messages from queue"""
|
||||
while self.running:
|
||||
try:
|
||||
# Get message with timeout
|
||||
msg = self.queue.get(timeout=1.0)
|
||||
if msg is None:
|
||||
continue
|
||||
|
||||
# Rate limiting
|
||||
now = time.time()
|
||||
elapsed = now - self.last_send_time
|
||||
if elapsed < self.min_interval:
|
||||
time.sleep(self.min_interval - elapsed)
|
||||
|
||||
# Send message
|
||||
if self._send(msg):
|
||||
self.sent_count += 1
|
||||
else:
|
||||
self.error_count += 1
|
||||
|
||||
self.last_send_time = time.time()
|
||||
|
||||
except Empty:
|
||||
continue
|
||||
except Exception:
|
||||
# Silent error handling
|
||||
self.error_count += 1
|
||||
pass
|
||||
|
||||
def _send(self, message: str) -> bool:
|
||||
"""
|
||||
Send message to Telegram (with timeout)
|
||||
|
||||
Returns:
|
||||
True if sent successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
url = f"https://api.telegram.org/bot{self.bot_token}/sendMessage"
|
||||
response = requests.post(url, json={
|
||||
"chat_id": self.chat_id,
|
||||
"text": message,
|
||||
"parse_mode": "HTML",
|
||||
"disable_web_page_preview": True
|
||||
}, timeout=5.0)
|
||||
|
||||
return response.status_code == 200
|
||||
|
||||
except Exception as e:
|
||||
# Only log error once per minute to avoid spam
|
||||
now = time.time()
|
||||
if now - self.last_error_time > 60:
|
||||
if self.event_callback:
|
||||
self.event_callback(f"Send error: {str(e)[:40]}", 'error')
|
||||
self.last_error_time = now
|
||||
return False
|
||||
|
||||
def notify(self, message: str):
|
||||
"""
|
||||
Queue a notification (non-blocking)
|
||||
|
||||
Args:
|
||||
message: Message text (HTML formatting supported)
|
||||
"""
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
try:
|
||||
self.queue.put_nowait(message)
|
||||
except:
|
||||
self.dropped_count += 1
|
||||
|
||||
def send_market_closed(self, coin: str, trade: Dict, session_stats: Dict, portfolio_stats: Dict = None):
|
||||
"""
|
||||
Send compact notification when a market closes with trade
|
||||
|
||||
Args:
|
||||
coin: Coin name ('btc', 'eth', 'sol', 'xrp')
|
||||
trade: Trade result dict from trader
|
||||
session_stats: Session statistics for this coin
|
||||
portfolio_stats: Optional portfolio stats for all coins
|
||||
"""
|
||||
# Extract trade data
|
||||
market_slug = trade.get('market_slug', 'unknown')
|
||||
pnl = trade.get('pnl', 0)
|
||||
roi_pct = trade.get('roi_pct', 0)
|
||||
winner = trade.get('winner', '?')
|
||||
|
||||
# Determine result emoji
|
||||
if pnl > 0:
|
||||
result_emoji = "🟢"
|
||||
result_text = "WIN"
|
||||
else:
|
||||
result_emoji = "🔴"
|
||||
result_text = "LOSS"
|
||||
|
||||
# Format PnL
|
||||
pnl_str = f"${pnl:+.2f}"
|
||||
roi_str = f"{roi_pct:+.1f}%"
|
||||
|
||||
# Market ID (short)
|
||||
market_id = market_slug.split('-')[-1][:10] if '-' in market_slug else market_slug[-10:]
|
||||
|
||||
# Build compact message
|
||||
message = f"""<b>{coin.upper()}</b> {result_emoji} {result_text}
|
||||
━━━━━━━━━━━━━━━
|
||||
Market: ...{market_id}
|
||||
PnL: {pnl_str} ({roi_str})
|
||||
Winner: {winner}"""
|
||||
|
||||
# Session summary (compact)
|
||||
total_pnl = session_stats.get('total_pnl', 0)
|
||||
win_rate = session_stats.get('win_rate', 0)
|
||||
|
||||
message += f"\nTotal: ${total_pnl:+.2f} | WR: {win_rate:.0f}%"
|
||||
|
||||
# Portfolio stats (all coins)
|
||||
if portfolio_stats:
|
||||
message += "\n\n━━━━━━━━━━━━━━━\n<b>🏦 PORTFOLIO</b>"
|
||||
|
||||
coins = ['btc', 'eth', 'sol', 'xrp']
|
||||
for c in coins:
|
||||
c_pnl = portfolio_stats.get(f'{c}_pnl', 0)
|
||||
c_wr = portfolio_stats.get(f'{c}_wr', 0)
|
||||
c_markets = portfolio_stats.get(f'{c}_markets_played', 0)
|
||||
|
||||
# Emoji for PnL
|
||||
pnl_emoji = "🟢" if c_pnl > 0 else "🔴" if c_pnl < 0 else "⚪"
|
||||
|
||||
message += f"\n{c.upper()}: {pnl_emoji} ${c_pnl:+.2f} ({c_wr:.0f}% WR, {c_markets}m)"
|
||||
|
||||
# Total
|
||||
total_portfolio_pnl = portfolio_stats.get('total_pnl', 0)
|
||||
total_emoji = "🟢" if total_portfolio_pnl > 0 else "🔴" if total_portfolio_pnl < 0 else "⚪"
|
||||
uptime = portfolio_stats.get('uptime', 0)
|
||||
uptime_str = self._format_uptime(uptime)
|
||||
|
||||
message += f"\n<b>Total: {total_emoji} ${total_portfolio_pnl:+.2f}</b> | {uptime_str}"
|
||||
|
||||
# Send notification
|
||||
self.notify(message)
|
||||
|
||||
def send_market_skipped(self, coin: str, market_slug: str, skip_reason: str, session_stats: Dict, portfolio_stats: Dict = None):
|
||||
"""
|
||||
Send minimal notification when a market is skipped (no trades)
|
||||
|
||||
Args:
|
||||
coin: Coin name ('btc', 'eth', 'sol', 'xrp')
|
||||
market_slug: Market identifier (UNUSED)
|
||||
skip_reason: Reason for skipping (UNUSED)
|
||||
session_stats: Session statistics (UNUSED)
|
||||
portfolio_stats: Portfolio stats (UNUSED)
|
||||
"""
|
||||
# Ultra-minimal message: just coin + skipped
|
||||
message = f"<b>{coin.upper()}</b> ⏭️ SKIPPED"
|
||||
|
||||
# Send notification
|
||||
self.notify(message)
|
||||
|
||||
def send_photo(self, photo_path: str, caption: str = ""):
|
||||
"""
|
||||
Send photo to Telegram
|
||||
|
||||
Args:
|
||||
photo_path: Path to image file
|
||||
caption: Optional caption (HTML supported)
|
||||
|
||||
Returns:
|
||||
True if sent successfully, False otherwise
|
||||
"""
|
||||
if not self.enabled:
|
||||
return False
|
||||
|
||||
try:
|
||||
url = f"https://api.telegram.org/bot{self.bot_token}/sendPhoto"
|
||||
|
||||
with open(photo_path, 'rb') as photo:
|
||||
files = {'photo': photo}
|
||||
data = {
|
||||
'chat_id': self.chat_id,
|
||||
'caption': caption,
|
||||
'parse_mode': 'HTML'
|
||||
}
|
||||
|
||||
response = requests.post(url, data=data, files=files, timeout=30)
|
||||
|
||||
if response.status_code == 200:
|
||||
self.sent_count += 1
|
||||
return True
|
||||
else:
|
||||
self.error_count += 1
|
||||
if self.event_callback:
|
||||
self.event_callback(f"Photo send failed: {response.status_code}", 'error')
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.error_count += 1
|
||||
if self.event_callback:
|
||||
self.event_callback(f"Photo error: {str(e)[:40]}", 'error')
|
||||
return False
|
||||
|
||||
def _format_uptime(self, seconds: float) -> str:
|
||||
"""Format uptime in human-readable format"""
|
||||
delta = timedelta(seconds=int(seconds))
|
||||
hours = delta.seconds // 3600
|
||||
minutes = (delta.seconds % 3600) // 60
|
||||
|
||||
if delta.days > 0:
|
||||
return f"{delta.days}d {hours}h {minutes}m"
|
||||
elif hours > 0:
|
||||
return f"{hours}h {minutes}m"
|
||||
else:
|
||||
return f"{minutes}m"
|
||||
|
||||
def get_stats(self) -> Dict:
|
||||
"""Get notifier statistics"""
|
||||
return {
|
||||
'enabled': self.enabled,
|
||||
'sent_count': self.sent_count,
|
||||
'dropped_count': self.dropped_count,
|
||||
'error_count': self.error_count,
|
||||
'queue_size': self.queue.qsize()
|
||||
}
|
||||
|
||||
def stop(self):
|
||||
"""Stop the notifier"""
|
||||
self.running = False
|
||||
if self.enabled and self.event_callback:
|
||||
self.event_callback(f"Stopped (sent:{self.sent_count} drop:{self.dropped_count} err:{self.error_count})", 'telegram')
|
||||
|
||||
def start_command_listener(self, on_chart_command, on_balance_command=None,
|
||||
on_positions_command=None, on_redeem_command=None, on_redeem_callbacks=None,
|
||||
on_shutdown_command=None, on_shutdown_callbacks=None):
|
||||
"""
|
||||
Start background thread to listen for Telegram commands
|
||||
THREAD-SAFE: Runs in separate daemon thread with full error handling
|
||||
|
||||
Args:
|
||||
on_chart_command: Callback function to call when /chart or /pnl command received
|
||||
on_balance_command: Callback function to call when /balance command received
|
||||
on_positions_command: Callback function to call when /t or /positions command received
|
||||
on_redeem_command: Callback function to call when /r or /redeem command received
|
||||
on_redeem_callbacks: Dict with callback functions for redeem buttons
|
||||
{'redeem_all': func, 'redeem_position': func, 'redeem_cancel': func}
|
||||
on_shutdown_command: Callback function to call when /off or /stop command received
|
||||
on_shutdown_callbacks: Dict with callback functions for shutdown buttons
|
||||
{'shutdown_confirm': func, 'shutdown_cancel': func}
|
||||
"""
|
||||
if not self.enabled:
|
||||
if self.event_callback:
|
||||
self.event_callback("Command listener disabled", 'info')
|
||||
return None
|
||||
|
||||
def listener_thread():
|
||||
last_update_id = 0
|
||||
consecutive_errors = 0
|
||||
max_consecutive_errors = 10
|
||||
|
||||
if self.event_callback:
|
||||
self.event_callback("Command listener started", 'telegram')
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
# Long polling for updates (30s timeout)
|
||||
url = f"https://api.telegram.org/bot{self.bot_token}/getUpdates"
|
||||
params = {
|
||||
'offset': last_update_id + 1,
|
||||
'timeout': 30, # Long polling - wait up to 30s for updates
|
||||
'allowed_updates': ['message', 'callback_query'] # Messages and button clicks
|
||||
}
|
||||
|
||||
response = requests.get(url, params=params, timeout=35)
|
||||
|
||||
# Reset error counter on successful connection
|
||||
consecutive_errors = 0
|
||||
|
||||
if response.status_code != 200:
|
||||
if self.event_callback:
|
||||
self.event_callback(f"API status {response.status_code}", 'error')
|
||||
time.sleep(5)
|
||||
continue
|
||||
|
||||
data = response.json()
|
||||
|
||||
if not data.get('ok'):
|
||||
if self.event_callback:
|
||||
self.event_callback(f"API error: {data.get('description', 'unknown')[:30]}", 'error')
|
||||
time.sleep(5)
|
||||
continue
|
||||
|
||||
updates = data.get('result', [])
|
||||
|
||||
# Process all updates
|
||||
for update in updates:
|
||||
try:
|
||||
last_update_id = update['update_id']
|
||||
|
||||
# Handle callback queries (button clicks)
|
||||
if 'callback_query' in update and on_redeem_callbacks:
|
||||
callback_query = update['callback_query']
|
||||
callback_data = callback_query.get('data', '')
|
||||
callback_id = callback_query['id']
|
||||
message_id = callback_query['message']['message_id']
|
||||
from_chat_id = str(callback_query['from']['id'])
|
||||
|
||||
# SECURITY: Only respond to callbacks from our chat_id
|
||||
if from_chat_id != self.chat_id:
|
||||
continue
|
||||
|
||||
print(f"[TELEGRAM] Callback received: {callback_data}")
|
||||
|
||||
try:
|
||||
# Redeem callbacks
|
||||
if callback_data == "redeem_all":
|
||||
on_redeem_callbacks['redeem_all'](callback_id, message_id)
|
||||
|
||||
elif callback_data.startswith("redeem_pos_"):
|
||||
index = int(callback_data.split("_")[-1])
|
||||
on_redeem_callbacks['redeem_position'](callback_id, message_id, index)
|
||||
|
||||
elif callback_data == "redeem_cancel":
|
||||
on_redeem_callbacks['redeem_cancel'](callback_id, message_id)
|
||||
|
||||
# Shutdown callbacks
|
||||
elif on_shutdown_callbacks:
|
||||
if callback_data.startswith("shutdown_confirm_"):
|
||||
pid = callback_data.split("_")[-1]
|
||||
on_shutdown_callbacks['shutdown_confirm'](callback_id, message_id, pid)
|
||||
|
||||
elif callback_data == "shutdown_cancel":
|
||||
on_shutdown_callbacks['shutdown_cancel'](callback_id, message_id)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)[:200]
|
||||
print(f"[TELEGRAM] Callback error: {error_msg}")
|
||||
self.answer_callback_query(callback_id, f"Error: {error_msg[:50]}", show_alert=True)
|
||||
|
||||
continue
|
||||
|
||||
# Handle regular messages
|
||||
if 'message' not in update:
|
||||
continue
|
||||
|
||||
message = update['message']
|
||||
|
||||
if 'text' not in message:
|
||||
continue
|
||||
|
||||
text = message['text'].strip().lower()
|
||||
from_chat_id = str(message['chat']['id'])
|
||||
from_user = message.get('from', {}).get('username', 'unknown')
|
||||
|
||||
# SECURITY: Only respond to messages from our chat_id
|
||||
if from_chat_id != self.chat_id:
|
||||
if self.event_callback:
|
||||
self.event_callback(f"Unauthorized msg from {from_user}", 'error')
|
||||
continue
|
||||
|
||||
# Handle commands
|
||||
if text in ['/chart', '/pnl', '/график']:
|
||||
if self.event_callback:
|
||||
self.event_callback(f"Received {text}", 'telegram')
|
||||
try:
|
||||
# Call the callback (should be thread-safe!)
|
||||
on_chart_command()
|
||||
except Exception as e:
|
||||
error_msg = str(e)[:200]
|
||||
if self.event_callback:
|
||||
self.event_callback(f"Chart cmd error: {error_msg[:40]}", 'error')
|
||||
self.send_message(f"❌ Error generating chart:\n<code>{error_msg}</code>")
|
||||
|
||||
elif text in ['/balance', '/b']:
|
||||
if self.event_callback:
|
||||
self.event_callback(f"Received {text}", 'telegram')
|
||||
try:
|
||||
if on_balance_command:
|
||||
on_balance_command()
|
||||
else:
|
||||
self.send_message("❌ Balance command not available")
|
||||
except Exception as e:
|
||||
error_msg = str(e)[:200]
|
||||
if self.event_callback:
|
||||
self.event_callback(f"Balance cmd error: {error_msg[:40]}", 'error')
|
||||
self.send_message(f"❌ Error getting balance:\n<code>{error_msg}</code>")
|
||||
|
||||
elif text in ['/t', '/positions']:
|
||||
if self.event_callback:
|
||||
self.event_callback(f"Received {text}", 'telegram')
|
||||
try:
|
||||
if on_positions_command:
|
||||
on_positions_command()
|
||||
else:
|
||||
self.send_message("❌ Positions command not available")
|
||||
except Exception as e:
|
||||
error_msg = str(e)[:200]
|
||||
if self.event_callback:
|
||||
self.event_callback(f"Positions cmd error: {error_msg[:40]}", 'error')
|
||||
self.send_message(f"❌ Error getting positions:\n<code>{error_msg}</code>")
|
||||
|
||||
elif text in ['/r', '/redeem']:
|
||||
if self.event_callback:
|
||||
self.event_callback(f"Received {text}", 'telegram')
|
||||
try:
|
||||
if on_redeem_command:
|
||||
on_redeem_command()
|
||||
else:
|
||||
self.send_message("❌ Redeem command not available")
|
||||
except Exception as e:
|
||||
error_msg = str(e)[:200]
|
||||
if self.event_callback:
|
||||
self.event_callback(f"Redeem cmd error: {error_msg[:40]}", 'error')
|
||||
self.send_message(f"❌ Error getting redeemable positions:\n<code>{error_msg}</code>")
|
||||
|
||||
elif text in ['/off', '/shutdown', '/stop']:
|
||||
if self.event_callback:
|
||||
self.event_callback(f"Received {text}", 'telegram')
|
||||
try:
|
||||
if on_shutdown_command:
|
||||
on_shutdown_command()
|
||||
else:
|
||||
self.send_message("❌ Shutdown command not available")
|
||||
except Exception as e:
|
||||
error_msg = str(e)[:200]
|
||||
if self.event_callback:
|
||||
self.event_callback(f"Shutdown cmd error: {error_msg[:40]}", 'error')
|
||||
self.send_message(f"❌ Error executing shutdown:\n<code>{error_msg}</code>")
|
||||
|
||||
elif text in ['/help', '/start']:
|
||||
help_text = """<b>📊 Trading Bot Commands:</b>
|
||||
|
||||
/chart or /pnl - Generate current PnL chart
|
||||
/b or /balance - Show wallet balance (USDC + POL)
|
||||
/t or /positions - Show active positions
|
||||
/r or /redeem - Redeem completed markets (interactive)
|
||||
/off or /stop - Emergency shutdown (with confirmation)
|
||||
/help - Show this help message
|
||||
|
||||
<b>💡 Tip:</b> Charts are sent automatically every 10 markets.
|
||||
|
||||
<b>🔒 Security:</b> Commands only work from authorized chat ID."""
|
||||
self.send_message(help_text)
|
||||
|
||||
elif text.startswith('/'):
|
||||
# Unknown command
|
||||
self.send_message(f"❌ Unknown command: {text}\nSend /help for available commands")
|
||||
|
||||
except Exception as e:
|
||||
# Error processing individual update - log and continue
|
||||
if self.event_callback:
|
||||
self.event_callback(f"Update error: {str(e)[:40]}", 'error')
|
||||
continue
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
# Timeout is NORMAL for long polling - just continue
|
||||
continue
|
||||
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
consecutive_errors += 1
|
||||
if self.event_callback and consecutive_errors % 5 == 1: # Log every 5th error
|
||||
self.event_callback(f"Connection error ({consecutive_errors})", 'error')
|
||||
|
||||
if consecutive_errors >= max_consecutive_errors:
|
||||
if self.event_callback:
|
||||
self.event_callback("Too many errors, stopping listener", 'error')
|
||||
break
|
||||
|
||||
time.sleep(min(10 * consecutive_errors, 60)) # Exponential backoff
|
||||
|
||||
except Exception as e:
|
||||
consecutive_errors += 1
|
||||
if self.event_callback and consecutive_errors % 5 == 1: # Log every 5th error
|
||||
self.event_callback(f"Listener error ({consecutive_errors})", 'error')
|
||||
|
||||
if consecutive_errors >= max_consecutive_errors:
|
||||
if self.event_callback:
|
||||
self.event_callback("Too many errors, stopping listener", 'error')
|
||||
break
|
||||
|
||||
time.sleep(10)
|
||||
|
||||
if self.event_callback:
|
||||
self.event_callback("Command listener stopped", 'telegram')
|
||||
|
||||
# Start listener in background daemon thread
|
||||
# Daemon=True means thread will be killed when main program exits
|
||||
thread = Thread(target=listener_thread, daemon=True, name="TelegramCommandListener")
|
||||
thread.start()
|
||||
|
||||
if self.event_callback:
|
||||
self.event_callback("Command listener thread started", 'telegram')
|
||||
return thread
|
||||
|
||||
def send_message_with_buttons(self, text: str, buttons: list) -> int:
|
||||
"""
|
||||
Send message with Inline Keyboard buttons
|
||||
|
||||
Args:
|
||||
text: Message text (supports HTML)
|
||||
buttons: List of buttons [[{text, callback_data}, ...], ...]
|
||||
|
||||
Returns:
|
||||
message_id if successful, None on error
|
||||
"""
|
||||
if not self.enabled:
|
||||
return None
|
||||
|
||||
try:
|
||||
url = f"https://api.telegram.org/bot{self.bot_token}/sendMessage"
|
||||
payload = {
|
||||
"chat_id": self.chat_id,
|
||||
"text": text,
|
||||
"parse_mode": "HTML",
|
||||
"reply_markup": {
|
||||
"inline_keyboard": buttons
|
||||
}
|
||||
}
|
||||
|
||||
response = requests.post(url, json=payload, timeout=10)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
message_id = data['result']['message_id']
|
||||
print(f"[TELEGRAM] ✅ Message with buttons sent (ID: {message_id})")
|
||||
return message_id
|
||||
else:
|
||||
print(f"[TELEGRAM] ⚠️ Failed to send message with buttons: {response.status_code}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"[TELEGRAM] ⚠️ Error sending message with buttons: {e}")
|
||||
return None
|
||||
|
||||
def edit_message_text(self, message_id: int, text: str, buttons: list = None) -> bool:
|
||||
"""
|
||||
Edit text of existing message
|
||||
|
||||
Args:
|
||||
message_id: Message ID to edit
|
||||
text: New text (supports HTML)
|
||||
buttons: New buttons (optional)
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
if not self.enabled:
|
||||
return False
|
||||
|
||||
try:
|
||||
url = f"https://api.telegram.org/bot{self.bot_token}/editMessageText"
|
||||
payload = {
|
||||
"chat_id": self.chat_id,
|
||||
"message_id": message_id,
|
||||
"text": text,
|
||||
"parse_mode": "HTML"
|
||||
}
|
||||
|
||||
if buttons:
|
||||
payload["reply_markup"] = {"inline_keyboard": buttons}
|
||||
|
||||
response = requests.post(url, json=payload, timeout=10)
|
||||
|
||||
if response.status_code == 200:
|
||||
print(f"[TELEGRAM] ✅ Message edited (ID: {message_id})")
|
||||
return True
|
||||
else:
|
||||
print(f"[TELEGRAM] ⚠️ Failed to edit message: {response.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"[TELEGRAM] ⚠️ Error editing message: {e}")
|
||||
return False
|
||||
|
||||
def answer_callback_query(self, callback_query_id: str, text: str = "", show_alert: bool = False) -> bool:
|
||||
"""
|
||||
Answer callback query (show popup notification)
|
||||
|
||||
Args:
|
||||
callback_query_id: ID callback query
|
||||
text: Notification text
|
||||
show_alert: Show as alert (True) or toast (False)
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
if not self.enabled:
|
||||
return False
|
||||
|
||||
try:
|
||||
url = f"https://api.telegram.org/bot{self.bot_token}/answerCallbackQuery"
|
||||
payload = {
|
||||
"callback_query_id": callback_query_id,
|
||||
"text": text,
|
||||
"show_alert": show_alert
|
||||
}
|
||||
|
||||
response = requests.post(url, json=payload, timeout=10)
|
||||
return response.status_code == 200
|
||||
|
||||
except Exception as e:
|
||||
print(f"[TELEGRAM] ⚠️ Error answering callback: {e}")
|
||||
return False
|
||||
|
||||
def send_message(self, message: str):
|
||||
"""
|
||||
Send plain text message to Telegram (for command responses)
|
||||
Sends directly (not queued) since this is for immediate command responses
|
||||
|
||||
Args:
|
||||
message: Text message to send
|
||||
"""
|
||||
if not self.enabled:
|
||||
return False
|
||||
|
||||
# Send directly for immediate response (not queued)
|
||||
try:
|
||||
url = f"https://api.telegram.org/bot{self.bot_token}/sendMessage"
|
||||
data = {
|
||||
'chat_id': self.chat_id,
|
||||
'text': message,
|
||||
'parse_mode': 'HTML',
|
||||
'disable_web_page_preview': True
|
||||
}
|
||||
|
||||
response = requests.post(url, json=data, timeout=10)
|
||||
|
||||
if response.status_code == 200:
|
||||
self.sent_count += 1
|
||||
return True
|
||||
else:
|
||||
self.error_count += 1
|
||||
if self.event_callback:
|
||||
self.event_callback(f"Send msg failed: {response.status_code}", 'error')
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.error_count += 1
|
||||
if self.event_callback:
|
||||
self.event_callback(f"Send msg error: {str(e)[:40]}", 'error')
|
||||
return False
|
||||
|
||||
|
||||
# Global notifier instance (singleton)
|
||||
_notifier = None
|
||||
_notifier_lock = Lock()
|
||||
|
||||
|
||||
def get_notifier() -> TelegramNotifier:
|
||||
"""Get or create the global Telegram notifier (singleton)"""
|
||||
global _notifier
|
||||
if _notifier is None:
|
||||
with _notifier_lock:
|
||||
if _notifier is None: # Double-check
|
||||
_notifier = TelegramNotifier()
|
||||
return _notifier
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
Trade Logger - Detailed logging of all buy/sell operations
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict
|
||||
|
||||
# Setup trades logger
|
||||
# Determine logs path relative to project
|
||||
project_root = Path(__file__).parent.parent
|
||||
log_dir = project_root / "logs"
|
||||
log_dir.mkdir(exist_ok=True)
|
||||
|
||||
trades_logger = logging.getLogger('trades')
|
||||
trades_logger.setLevel(logging.INFO)
|
||||
trades_handler = logging.FileHandler(log_dir / 'trades.log')
|
||||
trades_handler.setFormatter(logging.Formatter(
|
||||
'%(asctime)s [%(levelname)s] %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
))
|
||||
trades_logger.addHandler(trades_handler)
|
||||
|
||||
def log_buy_attempt(market_slug: str, side: str, contracts: float, price: float, attempt: int, max_attempts: int):
|
||||
"""Log a buy order attempt"""
|
||||
trades_logger.info(
|
||||
f"BUY_ATTEMPT | Market: {market_slug} | Side: {side} | "
|
||||
f"Contracts: {contracts:.2f} | Price: ${price:.4f} | "
|
||||
f"Expected USD: ${contracts * price:.2f} | Attempt: {attempt}/{max_attempts}"
|
||||
)
|
||||
|
||||
def log_buy_result(market_slug: str, side: str,
|
||||
requested_contracts: float, filled_contracts: float,
|
||||
requested_usd: float, filled_usd: float,
|
||||
success: bool, error: Optional[str] = None,
|
||||
fak_attempts: int = 1, elapsed_ms: int = 0):
|
||||
"""Log a buy order result"""
|
||||
fill_pct = (filled_contracts / requested_contracts * 100) if requested_contracts > 0 else 0
|
||||
|
||||
if success:
|
||||
trades_logger.info(
|
||||
f"BUY_SUCCESS | Market: {market_slug} | Side: {side} | "
|
||||
f"Requested: {requested_contracts:.2f} contracts (${requested_usd:.2f}) | "
|
||||
f"Filled: {filled_contracts:.2f} contracts (${filled_usd:.2f}) | "
|
||||
f"Fill: {fill_pct:.1f}% | FAK Attempts: {fak_attempts} | "
|
||||
f"Time: {elapsed_ms}ms"
|
||||
)
|
||||
else:
|
||||
trades_logger.error(
|
||||
f"BUY_FAILED | Market: {market_slug} | Side: {side} | "
|
||||
f"Requested: {requested_contracts:.2f} contracts (${requested_usd:.2f}) | "
|
||||
f"Filled: {filled_contracts:.2f} contracts (${filled_usd:.2f}) | "
|
||||
f"Fill: {fill_pct:.1f}% | Error: {error} | FAK Attempts: {fak_attempts}"
|
||||
)
|
||||
|
||||
def log_sell_attempt(market_slug: str, side: str, contracts: float, price: float, attempt: int, max_attempts: int):
|
||||
"""Log a sell order attempt"""
|
||||
trades_logger.info(
|
||||
f"SELL_ATTEMPT | Market: {market_slug} | Side: {side} | "
|
||||
f"Contracts: {contracts:.2f} | Price: ${price:.4f} | "
|
||||
f"Expected USD: ${contracts * price:.2f} | Attempt: {attempt}/{max_attempts}"
|
||||
)
|
||||
|
||||
def log_sell_result(market_slug: str, side: str,
|
||||
requested_contracts: float, sold_contracts: float,
|
||||
requested_usd: float, received_usd: float,
|
||||
success: bool, error: Optional[str] = None,
|
||||
fak_attempts: int = 1, elapsed_ms: int = 0):
|
||||
"""Log a sell order result"""
|
||||
fill_pct = (sold_contracts / requested_contracts * 100) if requested_contracts > 0 else 0
|
||||
|
||||
if success:
|
||||
trades_logger.info(
|
||||
f"SELL_SUCCESS | Market: {market_slug} | Side: {side} | "
|
||||
f"Requested: {requested_contracts:.2f} contracts (expected ${requested_usd:.2f}) | "
|
||||
f"Sold: {sold_contracts:.2f} contracts (${received_usd:.2f}) | "
|
||||
f"Fill: {fill_pct:.1f}% | FAK Attempts: {fak_attempts} | "
|
||||
f"Time: {elapsed_ms}ms"
|
||||
)
|
||||
else:
|
||||
trades_logger.error(
|
||||
f"SELL_FAILED | Market: {market_slug} | Side: {side} | "
|
||||
f"Requested: {requested_contracts:.2f} contracts | "
|
||||
f"Sold: {sold_contracts:.2f} contracts | "
|
||||
f"Fill: {fill_pct:.1f}% | Error: {error} | FAK Attempts: {fak_attempts}"
|
||||
)
|
||||
|
||||
def log_position_summary(market_slug: str, position: Dict):
|
||||
"""Log position summary after trade"""
|
||||
up_shares = position.get('UP', {}).get('total_shares', 0)
|
||||
down_shares = position.get('DOWN', {}).get('total_shares', 0)
|
||||
up_invested = position.get('UP', {}).get('total_invested', 0)
|
||||
down_invested = position.get('DOWN', {}).get('total_invested', 0)
|
||||
total_invested = up_invested + down_invested
|
||||
|
||||
trades_logger.info(
|
||||
f"POSITION | Market: {market_slug} | "
|
||||
f"UP: {up_shares:.2f} shares (${up_invested:.2f}) | "
|
||||
f"DOWN: {down_shares:.2f} shares (${down_invested:.2f}) | "
|
||||
f"Total: ${total_invested:.2f}"
|
||||
)
|
||||
|
||||
def log_exit_trigger(market_slug: str, exit_reason: str, coin: str = None,
|
||||
trigger_price: float = None, threshold_price: float = None,
|
||||
unrealized_pnl: float = None, threshold_pnl: float = None,
|
||||
time_remaining: int = None):
|
||||
"""
|
||||
🔥 NEW: Log exit triggers (stop-loss, flip-stop, emergency)
|
||||
Works for all 4 coins (BTC, ETH, SOL, XRP)
|
||||
Works for both sell types (stop-loss + flip-stop)
|
||||
|
||||
Args:
|
||||
market_slug: Market identifier
|
||||
exit_reason: 'stop_loss', 'flip_stop', 'emergency_exit'
|
||||
coin: Coin name (btc, eth, sol, xrp)
|
||||
trigger_price: Current price that triggered exit
|
||||
threshold_price: Threshold price (for flip-stop)
|
||||
unrealized_pnl: Current unrealized PnL (for stop-loss)
|
||||
threshold_pnl: Threshold PnL (for stop-loss)
|
||||
time_remaining: Seconds until market end
|
||||
"""
|
||||
msg_parts = [f"EXIT_TRIGGER | Market: {market_slug}"]
|
||||
|
||||
if coin:
|
||||
msg_parts.append(f"Coin: {coin.upper()}")
|
||||
|
||||
msg_parts.append(f"Reason: {exit_reason.upper()}")
|
||||
|
||||
if exit_reason == 'stop_loss':
|
||||
if unrealized_pnl is not None:
|
||||
msg_parts.append(f"PnL: ${unrealized_pnl:.2f}")
|
||||
if threshold_pnl is not None:
|
||||
msg_parts.append(f"Threshold: ${threshold_pnl:.2f}")
|
||||
|
||||
elif exit_reason == 'flip_stop':
|
||||
if trigger_price is not None:
|
||||
msg_parts.append(f"Price: ${trigger_price:.2f}")
|
||||
if threshold_price is not None:
|
||||
msg_parts.append(f"Flip-Stop: ${threshold_price:.2f}")
|
||||
|
||||
elif exit_reason == 'emergency_exit':
|
||||
if time_remaining is not None:
|
||||
msg_parts.append(f"Time Remaining: {time_remaining}s")
|
||||
|
||||
trades_logger.warning(" | ".join(msg_parts))
|
||||
|
||||
def log_market_closing_blocked(market_slug: str, blocked_at: str):
|
||||
"""
|
||||
🔥 NEW: Log race condition protection - blocked buy orders
|
||||
Works for all 4 coins (BTC, ETH, SOL, XRP)
|
||||
|
||||
Args:
|
||||
market_slug: Market identifier
|
||||
blocked_at: Where the block occurred (e.g. 'BUY_ORDER_INIT', 'BUY_ORDER_FAK_ATTEMPT_1')
|
||||
"""
|
||||
trades_logger.warning(
|
||||
f"RACE_CONDITION_BLOCK | Market: {market_slug} | "
|
||||
f"Blocked at: {blocked_at} | "
|
||||
f"Reason: Market closing, preventing new buy orders"
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
"""Web dashboard package (Flask UI + API)."""
|
||||
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
Flask web dashboard: API + static UI.
|
||||
Run inside the bot process (--web) or standalone (reads logs/bot_state.json).
|
||||
"""
|
||||
import json
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, jsonify, request
|
||||
|
||||
from market_config import apply_market_window_settings
|
||||
|
||||
# Project root: repository root (parent of /config, /src)
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
CONFIG_PATH = PROJECT_ROOT / "config" / "config.json"
|
||||
STATIC_DIR = Path(__file__).resolve().parent / "static"
|
||||
TEMPLATE_DIR = Path(__file__).resolve().parent / "templates"
|
||||
|
||||
|
||||
def create_app(project_root: Path | None = None) -> Flask:
|
||||
root = project_root or PROJECT_ROOT
|
||||
|
||||
app = Flask(
|
||||
__name__,
|
||||
static_folder=str(STATIC_DIR),
|
||||
template_folder=str(TEMPLATE_DIR),
|
||||
)
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
from flask import render_template
|
||||
|
||||
return render_template("index.html")
|
||||
|
||||
@app.route("/api/health")
|
||||
def health():
|
||||
import web_dashboard_state as wds
|
||||
|
||||
snap = wds.get_snapshot()
|
||||
ts = snap.get("updated_at", 0)
|
||||
age = time.time() - ts if ts else 9999
|
||||
file_snap = wds.read_state_file(root)
|
||||
file_ts = file_snap.get("updated_at", 0) if file_snap else 0
|
||||
file_age = time.time() - file_ts if file_ts else 9999
|
||||
bot_live = age < 15.0 or file_age < 15.0
|
||||
return jsonify(
|
||||
{
|
||||
"ok": True,
|
||||
"bot_live": bot_live,
|
||||
"snapshot_age_sec": round(min(age, file_age), 2),
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/status")
|
||||
def api_status():
|
||||
import web_dashboard_state as wds
|
||||
|
||||
snap = wds.get_snapshot()
|
||||
if snap.get("status") == "initializing" or not snap.get("coins"):
|
||||
file_snap = wds.read_state_file(root)
|
||||
if file_snap:
|
||||
return jsonify(file_snap)
|
||||
return jsonify(snap)
|
||||
|
||||
@app.route("/api/config", methods=["GET"])
|
||||
def get_config():
|
||||
if not CONFIG_PATH.exists():
|
||||
return jsonify({"error": "config.json not found"}), 404
|
||||
try:
|
||||
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
apply_market_window_settings(data)
|
||||
return jsonify(data)
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route("/api/config", methods=["POST"])
|
||||
def post_config():
|
||||
if not request.is_json:
|
||||
return jsonify({"error": "Expected JSON body"}), 400
|
||||
body = request.get_json()
|
||||
if not isinstance(body, dict):
|
||||
return jsonify({"error": "Invalid JSON"}), 400
|
||||
apply_market_window_settings(body)
|
||||
if not CONFIG_PATH.parent.is_dir():
|
||||
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
backup = CONFIG_PATH.with_suffix(".json.bak")
|
||||
try:
|
||||
if CONFIG_PATH.exists():
|
||||
shutil.copy2(CONFIG_PATH, backup)
|
||||
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump(body, f, indent=2)
|
||||
return jsonify({"ok": True, "message": "Saved. Restart the bot to apply."})
|
||||
except OSError as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route("/api/bot/stop", methods=["POST"])
|
||||
def bot_stop():
|
||||
import web_dashboard_state as wds
|
||||
|
||||
wds.request_stop()
|
||||
return jsonify({"ok": True, "message": "Stop requested — bot will shut down gracefully."})
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def run_server_thread(
|
||||
host: str, port: int, project_root: Path | None = None
|
||||
) -> None:
|
||||
"""Start Flask in a daemon thread (used by main.py --web)."""
|
||||
app = create_app(project_root or PROJECT_ROOT)
|
||||
|
||||
def run():
|
||||
# Werkzeug production warning suppressed for local dashboard
|
||||
import logging
|
||||
|
||||
log = logging.getLogger("werkzeug")
|
||||
log.setLevel(logging.ERROR)
|
||||
app.run(host=host, port=port, threaded=True, use_reloader=False)
|
||||
|
||||
t = threading.Thread(target=run, name="WebDashboard", daemon=True)
|
||||
t.start()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Standalone: UI only (status from bot_state.json when bot runs with --web)
|
||||
import logging
|
||||
|
||||
logging.getLogger("werkzeug").setLevel(logging.ERROR)
|
||||
app = create_app()
|
||||
print(f"[WEB] Open http://127.0.0.1:5050 (dashboard)")
|
||||
app.run(host="127.0.0.1", port=5050, threaded=True)
|
||||
@@ -0,0 +1,147 @@
|
||||
"""
|
||||
Build JSON-serializable dashboard snapshot from live trading objects.
|
||||
"""
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
def build_snapshot(
|
||||
*,
|
||||
coins: List[str],
|
||||
strategy_base: str,
|
||||
multi_trader,
|
||||
data_feed,
|
||||
wallet_balance: Optional[float],
|
||||
config: Dict[str, Any],
|
||||
session_start_time: float,
|
||||
dry_run: bool,
|
||||
markets_skipped: Dict[str, int],
|
||||
) -> Dict[str, Any]:
|
||||
now = time.time()
|
||||
uptime = now - session_start_time
|
||||
|
||||
portfolio = multi_trader.get_portfolio_stats()
|
||||
|
||||
coin_blocks: Dict[str, Any] = {}
|
||||
for coin in coins:
|
||||
trader_name = f"{strategy_base}_{coin}"
|
||||
st = data_feed.get_state(coin)
|
||||
trader = multi_trader.traders.get(trader_name)
|
||||
|
||||
ms: Dict[str, Any] = {
|
||||
"market_slug": st.get("market_slug") or "",
|
||||
"seconds_till_end": int(st.get("seconds_till_end") or 0),
|
||||
"up_ask": float(st.get("up_ask") or 0),
|
||||
"down_ask": float(st.get("down_ask") or 0),
|
||||
"confidence": float(st.get("confidence") or 0),
|
||||
"price": float(st.get("price") or 0),
|
||||
}
|
||||
ua, da = ms["up_ask"], ms["down_ask"]
|
||||
ms["favorite"] = "UP" if ua > da else "DOWN"
|
||||
|
||||
trading_cfg = config.get("trading", {}).get(coin, {})
|
||||
ms["trading_enabled"] = bool(trading_cfg.get("enabled", True))
|
||||
ms["trading_reason"] = trading_cfg.get("reason") or ""
|
||||
|
||||
pos_detail = None
|
||||
if trader:
|
||||
perf = trader.get_performance_stats()
|
||||
pnl_coin = trader.current_capital - trader.starting_capital
|
||||
slug = ms["market_slug"]
|
||||
ms["stats"] = {
|
||||
"pnl": round(pnl_coin, 2),
|
||||
"total_trades": perf.get("total_trades", 0),
|
||||
"wins": perf.get("wins", 0),
|
||||
"losses": perf.get("losses", 0),
|
||||
"win_rate": round(perf.get("win_rate", 0), 2),
|
||||
}
|
||||
if slug:
|
||||
pos = multi_trader.get_current_positions(trader_name, slug)
|
||||
if pos and (pos.get("up_shares", 0) > 0 or pos.get("down_shares", 0) > 0):
|
||||
detailed = trader.get_market_detailed_stats(slug, ua, da)
|
||||
if detailed:
|
||||
pos_detail = {
|
||||
"up_shares": detailed.get("up_shares", 0),
|
||||
"down_shares": detailed.get("down_shares", 0),
|
||||
"up_invested": round(detailed.get("up_invested", 0), 2),
|
||||
"down_invested": round(detailed.get("down_invested", 0), 2),
|
||||
"total_invested": round(detailed.get("total_invested", 0), 2),
|
||||
"unrealized_pnl": round(detailed.get("unrealized_pnl", 0), 2),
|
||||
"unrealized_pct": round(detailed.get("unrealized_pct", 0), 2),
|
||||
"max_drawdown": round(detailed.get("max_drawdown", 0), 2),
|
||||
"entries_count": detailed.get("entries_count", 0),
|
||||
"our_side": "UP"
|
||||
if detailed.get("up_shares", 0) > detailed.get("down_shares", 0)
|
||||
else "DOWN",
|
||||
}
|
||||
pos_detail["if_up_wins"] = round(
|
||||
(pos_detail["up_shares"] * 1.0) - pos_detail["total_invested"], 2
|
||||
)
|
||||
pos_detail["if_down_wins"] = round(
|
||||
(pos_detail["down_shares"] * 1.0) - pos_detail["total_invested"], 2
|
||||
)
|
||||
else:
|
||||
ms["stats"] = None
|
||||
|
||||
ms["position"] = pos_detail
|
||||
coin_blocks[coin] = ms
|
||||
|
||||
recent: List[Dict[str, Any]] = []
|
||||
for name, tr in multi_trader.traders.items():
|
||||
closed = getattr(tr, "closed_trades", []) or []
|
||||
for trade in closed[-1:]:
|
||||
t = dict(trade)
|
||||
t["strategy"] = name
|
||||
recent.append(t)
|
||||
recent.sort(key=lambda x: x.get("close_time", 0), reverse=True)
|
||||
recent_trimmed = []
|
||||
for t in recent[:12]:
|
||||
recent_trimmed.append(
|
||||
{
|
||||
"strategy": t.get("strategy"),
|
||||
"market_slug": t.get("market_slug"),
|
||||
"pnl": round(float(t.get("pnl", 0)), 2),
|
||||
"winner": t.get("winner"),
|
||||
"close_time": t.get("close_time"),
|
||||
}
|
||||
)
|
||||
|
||||
strat_cfg = config.get("strategy", {})
|
||||
safety_cfg = config.get("safety", {})
|
||||
exit_cfg = config.get("exit", {})
|
||||
pm = config.get("data_sources", {}).get("polymarket", {})
|
||||
market_interval_sec = int(pm.get("market_interval_sec", 900))
|
||||
|
||||
return {
|
||||
"status": "running",
|
||||
"uptime_sec": round(uptime, 1),
|
||||
"session_start": session_start_time,
|
||||
"wallet_balance": round(wallet_balance, 2) if wallet_balance is not None else None,
|
||||
"dry_run": dry_run,
|
||||
"markets_skipped": dict(markets_skipped),
|
||||
"portfolio": {
|
||||
"total_capital": round(portfolio.get("total_capital", 0), 2),
|
||||
"total_pnl": round(portfolio.get("total_pnl", 0), 2),
|
||||
"portfolio_roi": round(portfolio.get("portfolio_roi", 0), 2),
|
||||
"total_trades": portfolio.get("total_trades", 0),
|
||||
},
|
||||
"market_interval_sec": market_interval_sec,
|
||||
"market_label": "5m" if market_interval_sec == 300 else ("15m" if market_interval_sec == 900 else f"{market_interval_sec}s"),
|
||||
"strategy_summary": {
|
||||
"entry_window_sec": strat_cfg.get("entry_window_sec"),
|
||||
"entry_frequency_sec": strat_cfg.get("entry_frequency_sec"),
|
||||
"min_confidence": strat_cfg.get("min_confidence"),
|
||||
"price_max": strat_cfg.get("price_max"),
|
||||
"max_spread": strat_cfg.get("max_spread"),
|
||||
"max_investment_per_market": strat_cfg.get("max_investment_per_market"),
|
||||
"sizing": strat_cfg.get("sizing", {}),
|
||||
},
|
||||
"safety_summary": {
|
||||
"max_order_size_usd": safety_cfg.get("max_order_size_usd"),
|
||||
"max_orders_per_minute": safety_cfg.get("max_orders_per_minute"),
|
||||
"max_total_investment": safety_cfg.get("max_total_investment"),
|
||||
},
|
||||
"flip_stop": exit_cfg.get("flip_stop", {}),
|
||||
"coins": coin_blocks,
|
||||
"recent_trades": recent_trimmed,
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
:root {
|
||||
--bg: #0f1419;
|
||||
--surface: #1a2332;
|
||||
--border: #2d3a4d;
|
||||
--text: #e7ecf3;
|
||||
--muted: #8b9cb3;
|
||||
--accent: #3d8bfd;
|
||||
--green: #3ecf8e;
|
||||
--red: #f56565;
|
||||
--warn: #ecc94b;
|
||||
--radius: 10px;
|
||||
font-family: "Segoe UI", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.layout {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1.25rem 1rem 3rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 1.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0 0 1rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-ok {
|
||||
background: rgba(62, 207, 142, 0.2);
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.badge-warn {
|
||||
background: rgba(236, 201, 75, 0.2);
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.badge-off {
|
||||
background: rgba(139, 156, 179, 0.2);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.45rem 0.9rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: rgba(245, 101, 101, 0.25);
|
||||
color: var(--red);
|
||||
border: 1px solid rgba(245, 101, 101, 0.4);
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem 1.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-weight: 400;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.grid-4 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.stat-card .label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.stat-card .value {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-card .value.pos {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.stat-card .value.neg {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.stat-card .value.ok {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.stat-card .value.warn {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.coins-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.coin-card {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.coin-card h3 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.coin-card .row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.88rem;
|
||||
margin: 0.35rem 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.coin-card .row strong {
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.coin-card .pos-block {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
text-align: left;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
color: var(--muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pnl-pos {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.pnl-neg {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
#config-editor {
|
||||
width: 100%;
|
||||
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
|
||||
font-size: 0.82rem;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.settings-actions {
|
||||
margin-top: 0.75rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
|
||||
.message.ok {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.message.err {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 2rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.footer code {
|
||||
background: var(--surface);
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.disabled-tag {
|
||||
font-size: 0.75rem;
|
||||
color: var(--warn);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
(function () {
|
||||
const summaryEl = document.getElementById("summary-stats");
|
||||
const coinsEl = document.getElementById("coins-container");
|
||||
const tbody = document.querySelector("#recent-trades tbody");
|
||||
const badge = document.getElementById("conn-badge");
|
||||
const configEditor = document.getElementById("config-editor");
|
||||
const configMsg = document.getElementById("config-message");
|
||||
const headerSubtitle = document.getElementById("header-subtitle");
|
||||
|
||||
function labelFromIntervalSec(sec) {
|
||||
if (sec == null || Number.isNaN(sec)) return null;
|
||||
if (sec === 300) return "5m";
|
||||
if (sec === 900) return "15m";
|
||||
return `${sec}s`;
|
||||
}
|
||||
|
||||
function updateHeaderSubtitle(data) {
|
||||
if (!headerSubtitle) return;
|
||||
let ml = data.market_label;
|
||||
if (!ml && data.market_interval_sec != null) {
|
||||
ml = labelFromIntervalSec(data.market_interval_sec);
|
||||
}
|
||||
const part = ml ? `${ml} ` : "";
|
||||
headerSubtitle.textContent = `Polymarket ${part}desk · live status · settings · analytics`;
|
||||
}
|
||||
|
||||
function updateHeaderSubtitleFromConfig(cfg) {
|
||||
if (!headerSubtitle || !cfg || typeof cfg !== "object") return;
|
||||
const pm = cfg.data_sources && cfg.data_sources.polymarket;
|
||||
if (!pm) return;
|
||||
let ml = pm.market_window;
|
||||
if (!ml && pm.market_interval_sec != null) {
|
||||
ml = labelFromIntervalSec(pm.market_interval_sec);
|
||||
}
|
||||
if (ml) {
|
||||
headerSubtitle.textContent = `Polymarket ${ml} desk · live status · settings · analytics`;
|
||||
}
|
||||
}
|
||||
|
||||
function fmtTime(sec) {
|
||||
sec = Math.floor(sec || 0);
|
||||
const h = Math.floor(sec / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
const s = sec % 60;
|
||||
if (h > 0) return `${h}h ${m}m ${s}s`;
|
||||
return `${m}m ${s}s`;
|
||||
}
|
||||
|
||||
function fmtUsd(n) {
|
||||
if (n == null || Number.isNaN(n)) return "—";
|
||||
const sign = n >= 0 ? "+" : "";
|
||||
return sign + "$" + Number(n).toFixed(2);
|
||||
}
|
||||
|
||||
function renderSummary(data) {
|
||||
const p = data.portfolio || {};
|
||||
const dry = data.dry_run;
|
||||
summaryEl.innerHTML = [
|
||||
card("Uptime", fmtTime(data.uptime_sec)),
|
||||
card("Market", data.market_label || "—"),
|
||||
card("Mode", dry ? "DRY RUN" : "LIVE", dry ? "warn" : "ok"),
|
||||
card("Wallet", data.wallet_balance != null ? "$" + data.wallet_balance.toFixed(2) : "—"),
|
||||
card("Total PnL", fmtUsd(p.total_pnl), (p.total_pnl || 0) >= 0 ? "pos" : "neg"),
|
||||
card("Trades", String(p.total_trades ?? "0")),
|
||||
card("ROI %", (p.portfolio_roi != null ? p.portfolio_roi.toFixed(2) + "%" : "—")),
|
||||
].join("");
|
||||
}
|
||||
|
||||
function card(label, value, valClass) {
|
||||
const vc = valClass ? ` ${valClass}` : "";
|
||||
return `<div class="stat-card"><div class="label">${label}</div><div class="value${vc}">${escapeHtml(
|
||||
String(value)
|
||||
)}</div></div>`;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function renderCoins(data) {
|
||||
const coins = data.coins || {};
|
||||
const names = ["btc", "eth", "sol", "xrp"];
|
||||
coinsEl.innerHTML = names
|
||||
.map((c) => {
|
||||
const x = coins[c];
|
||||
if (!x) return "";
|
||||
const en = x.trading_enabled !== false;
|
||||
const fav = x.favorite || "—";
|
||||
const conf = x.confidence != null ? x.confidence.toFixed(3) : "—";
|
||||
const slugShort = (x.market_slug || "").split("-").pop() || "—";
|
||||
const st = x.stats || {};
|
||||
let posHtml = '<div class="row"><span>Position</span><strong>None</strong></div>';
|
||||
if (x.position) {
|
||||
const p = x.position;
|
||||
posHtml = `
|
||||
<div class="pos-block">
|
||||
<div class="row"><span>Unrealized</span><strong class="${p.unrealized_pnl >= 0 ? "pnl-pos" : "pnl-neg"}">${fmtUsd(
|
||||
p.unrealized_pnl
|
||||
)}</strong></div>
|
||||
<div class="row"><span>Invested</span><strong>$${p.total_invested}</strong></div>
|
||||
<div class="row"><span>Side / entries</span><strong>${p.our_side} · ${p.entries_count}</strong></div>
|
||||
<div class="row"><span>If UP wins</span><strong>${fmtUsd(p.if_up_wins)}</strong></div>
|
||||
<div class="row"><span>If DOWN wins</span><strong>${fmtUsd(p.if_down_wins)}</strong></div>
|
||||
</div>`;
|
||||
}
|
||||
return `
|
||||
<div class="coin-card">
|
||||
<h3>${c.toUpperCase()}
|
||||
${en ? "" : '<span class="disabled-tag">disabled</span>'}
|
||||
</h3>
|
||||
<div class="row"><span>Market</span><strong>${escapeHtml(slugShort)}</strong></div>
|
||||
<div class="row"><span>Time left</span><strong>${fmtTime(x.seconds_till_end)}</strong></div>
|
||||
<div class="row"><span>UP / DN ask</span><strong>${x.up_ask?.toFixed(3) ?? "—"} / ${x.down_ask?.toFixed(
|
||||
3
|
||||
) ?? "—"}</strong></div>
|
||||
<div class="row"><span>Favorite · Conf</span><strong>${fav} · ${conf}</strong></div>
|
||||
<div class="row"><span>PnL (coin)</span><strong class="${(st.pnl || 0) >= 0 ? "pnl-pos" : "pnl-neg"}">${fmtUsd(
|
||||
st.pnl
|
||||
)}</strong></div>
|
||||
<div class="row"><span>W/L · WR</span><strong>${st.wins ?? 0}/${st.losses ?? 0} · ${st.win_rate ?? 0}%</strong></div>
|
||||
${posHtml}
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderRecent(data) {
|
||||
const rows = data.recent_trades || [];
|
||||
tbody.innerHTML = rows
|
||||
.map((t) => {
|
||||
const pnl = t.pnl;
|
||||
const cls = pnl >= 0 ? "pnl-pos" : "pnl-neg";
|
||||
const m = (t.market_slug || "").split("-").pop() || t.market_slug;
|
||||
return `<tr>
|
||||
<td>${escapeHtml(t.strategy || "")}</td>
|
||||
<td>${escapeHtml(m || "")}</td>
|
||||
<td class="${cls}">${fmtUsd(pnl)}</td>
|
||||
<td>${escapeHtml(String(t.winner ?? ""))}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
if (!rows.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="4">No closed trades this session yet</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStatus() {
|
||||
const r = await fetch("/api/status", { cache: "no-store" });
|
||||
if (!r.ok) throw new Error("status " + r.status);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
try {
|
||||
const data = await fetchStatus();
|
||||
updateHeaderSubtitle(data);
|
||||
renderSummary(data);
|
||||
renderCoins(data);
|
||||
renderRecent(data);
|
||||
const health = await fetch("/api/health", { cache: "no-store" }).then((x) => x.json());
|
||||
if (health.bot_live) {
|
||||
badge.textContent = "live";
|
||||
badge.className = "badge badge-ok";
|
||||
} else {
|
||||
badge.textContent = "no live bot";
|
||||
badge.className = "badge badge-off";
|
||||
}
|
||||
} catch (e) {
|
||||
badge.textContent = "disconnected";
|
||||
badge.className = "badge badge-warn";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
configMsg.textContent = "";
|
||||
configMsg.className = "message";
|
||||
try {
|
||||
const r = await fetch("/api/config");
|
||||
const j = await r.json();
|
||||
if (j.error) throw new Error(j.error);
|
||||
configEditor.value = JSON.stringify(j, null, 2);
|
||||
updateHeaderSubtitleFromConfig(j);
|
||||
configMsg.textContent = "Loaded.";
|
||||
configMsg.className = "message ok";
|
||||
} catch (e) {
|
||||
configMsg.textContent = String(e.message || e);
|
||||
configMsg.className = "message err";
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("btn-refresh").addEventListener("click", tick);
|
||||
document.getElementById("btn-load-config").addEventListener("click", loadConfig);
|
||||
document.getElementById("btn-save-config").addEventListener("click", async () => {
|
||||
configMsg.textContent = "";
|
||||
try {
|
||||
const parsed = JSON.parse(configEditor.value);
|
||||
const r = await fetch("/api/config", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(parsed),
|
||||
});
|
||||
const j = await r.json();
|
||||
if (!r.ok) throw new Error(j.error || "save failed");
|
||||
configMsg.textContent = j.message || "Saved.";
|
||||
configMsg.className = "message ok";
|
||||
} catch (e) {
|
||||
configMsg.textContent = String(e.message || e);
|
||||
configMsg.className = "message err";
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("btn-stop").addEventListener("click", async () => {
|
||||
if (!confirm("Request graceful stop? The bot will exit (same as Ctrl+C).")) return;
|
||||
try {
|
||||
const r = await fetch("/api/bot/stop", { method: "POST" });
|
||||
const j = await r.json();
|
||||
alert(j.message || "OK");
|
||||
} catch (e) {
|
||||
alert(e);
|
||||
}
|
||||
});
|
||||
|
||||
loadConfig();
|
||||
tick();
|
||||
setInterval(tick, 1200);
|
||||
})();
|
||||
@@ -0,0 +1,66 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Meridian — Control & Analytics</title>
|
||||
<link rel="stylesheet" href="/static/app.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<header class="header">
|
||||
<h1>Meridian</h1>
|
||||
<p class="subtitle" id="header-subtitle">Polymarket desk · live status · settings · analytics</p>
|
||||
<div class="header-actions">
|
||||
<span id="conn-badge" class="badge badge-warn">connecting…</span>
|
||||
<button type="button" id="btn-refresh" class="btn btn-secondary">Refresh now</button>
|
||||
<button type="button" id="btn-stop" class="btn btn-danger">Request stop</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="panel summary" id="panel-summary">
|
||||
<h2>Session</h2>
|
||||
<div class="grid-4" id="summary-stats"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Markets (live)</h2>
|
||||
<div class="coins-grid" id="coins-container"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Recent closed trades</h2>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table" id="recent-trades">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Strategy</th>
|
||||
<th>Market</th>
|
||||
<th>PnL</th>
|
||||
<th>Winner</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Settings <span class="hint">(edit JSON — restart bot to apply)</span></h2>
|
||||
<div class="settings-row">
|
||||
<textarea id="config-editor" spellcheck="false" rows="18"></textarea>
|
||||
<div class="settings-actions">
|
||||
<button type="button" id="btn-load-config" class="btn btn-secondary">Reload from disk</button>
|
||||
<button type="button" id="btn-save-config" class="btn btn-primary">Save config.json</button>
|
||||
</div>
|
||||
<p id="config-message" class="message"></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="footer">
|
||||
<p>API: <code>/api/status</code>, <code>/api/config</code>, <code>POST /api/bot/stop</code> · Start bot with <code>python main.py --web</code></p>
|
||||
</footer>
|
||||
</div>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
Thread-safe snapshot + stop request for the web dashboard (same process as the bot).
|
||||
"""
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
_lock = threading.RLock()
|
||||
_snapshot: Dict[str, Any] = {"status": "initializing"}
|
||||
_stop_requested = False
|
||||
_session_start: float = 0.0
|
||||
|
||||
|
||||
def set_session_start(ts: float) -> None:
|
||||
global _session_start
|
||||
with _lock:
|
||||
_session_start = ts
|
||||
|
||||
|
||||
def set_snapshot(data: Dict[str, Any]) -> None:
|
||||
"""Called from main trading loop (every ~0.1s)."""
|
||||
global _snapshot
|
||||
with _lock:
|
||||
data = dict(data)
|
||||
data["updated_at"] = time.time()
|
||||
_snapshot = data
|
||||
|
||||
|
||||
def get_snapshot() -> Dict[str, Any]:
|
||||
with _lock:
|
||||
return dict(_snapshot)
|
||||
|
||||
|
||||
def request_stop() -> None:
|
||||
global _stop_requested
|
||||
with _lock:
|
||||
_stop_requested = True
|
||||
|
||||
|
||||
def consume_stop_request() -> bool:
|
||||
"""Main loop: if True, set stop_flag and clear request."""
|
||||
global _stop_requested
|
||||
with _lock:
|
||||
if _stop_requested:
|
||||
_stop_requested = False
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def write_state_file(project_root: Path, data: Dict[str, Any]) -> None:
|
||||
"""Optional: write logs/bot_state.json for read-only monitoring without shared memory."""
|
||||
path = project_root / "logs" / "bot_state.json"
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(".json.tmp")
|
||||
payload = dict(data)
|
||||
payload["updated_at"] = time.time()
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, indent=2)
|
||||
tmp.replace(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def read_state_file(project_root: Path) -> Optional[Dict[str, Any]]:
|
||||
path = project_root / "logs" / "bot_state.json"
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
Reference in New Issue
Block a user