PDB_VWAP代理设置完成

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