commit 4cc94bf3169942c6fce15c6c360e64b8dd14e5bf Author: gavindiaz Date: Sun Jul 26 22:56:35 2026 +0800 PDB_VWAP代理设置完成 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a722a15 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/5min-15min-PTB-bot/.gitignore b/5min-15min-PTB-bot/.gitignore new file mode 100644 index 0000000..4611873 --- /dev/null +++ b/5min-15min-PTB-bot/.gitignore @@ -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 \ No newline at end of file diff --git a/5min-15min-PTB-bot/README.md b/5min-15min-PTB-bot/README.md new file mode 100644 index 0000000..72e3c6c --- /dev/null +++ b/5min-15min-PTB-bot/README.md @@ -0,0 +1,137 @@ +# Polymarket BTC Auto-Trader — PTB, Diff & Probability (5m / 15m) + +A Python bot for Polymarket **BTC Up/Down** markets in **5-minute** or **15-minute** windows. It merges **Binance** and **Polymarket CLOB** WebSockets, compares **live BTC** to Polymarket’s **price-to-beat (PTB)** for the active event, and can **auto-buy** when **time**, **dollar gap**, and **implied probability** rules align. After a fill, it supports **probability-based take-profit and stop-loss**, optional **auto-redeem** (Builder relayer flow), and a **browser dashboard**. + +| Resource | Link | +|----------|------| +| **Suite overview** | [Repository README](../README.md) | +| **GitHub** | [PolyBullLabs/polymakret-5min-15min-1hour-arbitrage-bot](https://github.com/PolyBullLabs/polymakret-5min-15min-1hour-arbitrage-bot.git) | +| **Telegram** | [@terauss](https://t.me/terauss) | + +--- + +## Why this strategy can work (and what breaks it) + +**Economic story:** Each market defines a **reference path** for BTC (PTB) over the window. **Chainlink** (or the bot’s configured spot reference) and **outcome token prices** should **co-move** as the window ages. If **spot** is **far on one side** of PTB **late** in the window while the **UP or DOWN token** is still **cheap enough**, a rule-based buy can capture **misalignment** between **physical gap** and **implied probability**. + +**What actually makes money:** Positive expectancy only appears if, **after fees and slippage**, wins and sizes combine so that **average PnL per trade > 0**. That requires either **calibrated triggers** (your `CONDITION_*` bands) or **good exit discipline** (TP/SL). **Simulation** helps estimate behavior; **live** liquidity differs. + +**Failure modes:** **Feed lag** (Binance vs Chainlink vs Polymarket), **stale books**, **PTB definition** nuances (`variant=fifteen` alignment with the site), **partial fills**, and **regime change** (trending vs chop). **Always** start with `SIMULATION_MODE=true`. + +--- + +## Risk management + +| Layer | What it does | +|--------|----------------| +| **`SIMULATION_MODE`** | Runs rules with **instant** simulated fills at book prices—**no** CLOB orders, **no** auto-redeem. Cumulative simulated PnL in `state.json`. | +| **`AUTO_TRADE`** | Master switch for **live** order placement (still respect simulation). | +| **`TRADE_AMOUNT`** | Caps **USDC per buy**—start minimal. | +| **TP / SL** | After entry, **take-profit** and **stop-loss** are tracked in **probability** (0–1) space on the position token—see `config.env` comments. | +| **`MARKET_DATA_MAX_LAG_SEC`** | Skips or guards actions when data is **too stale**. | +| **Builder keys** | Optional keys for **auto-redeem**—treat like secrets. | + +**Operational:** Use a **dedicated wallet**, **private RPC**, and **proxy** if your network blocks or throttles Polymarket. + +--- + +## When to use this bot + +| Use this bot when… | Consider another suite bot when… | +|--------------------|----------------------------------| +| You care about **PTB vs BTC** and **explicit trigger rows** (`CONDITION_1` … `CONDITION_4`) | You want **multi-asset** late consensus → **Meridian** (`up-down-spread-bot`) | +| You want a **web dashboard** at `http://localhost:5080` (default) | You want a **Rich terminal** + **VWAP/momentum** → `btc-binary-VWAP-Momentum-bot` | +| You will **paper** first with `SIMULATION_MODE=true` | You need **only** redeem / manual tools—trim features accordingly | + +--- + +## Features + +- **Live prices:** Binance and Polymarket CLOB for BTC and outcome tokens. +- **Auto trading:** Up to **four** configurable trigger rule groups; **any** matching rule can fire a buy. +- **TP / SL:** Probability-based take-profit and stop-loss after a fill. +- **Auto redeem:** Optional Polymarket **Builder** relayer flow for winning positions. +- **Dashboard:** Browser UI for balance, positions, history, logs, and **5m / 15m** toggle. +- **Structured logging:** `TRADING_ANALYSIS_LOG` (default `trading_analysis.jsonl`) — JSON Lines with `schema_version: 2` for research and replay. + +--- + +## Requirements + +- **Python** 3.8+ recommended. +- **Dependencies:** `pip install -r requirements.txt` (includes `py-clob-client`; redeem path uses `web3` / builder libraries as applicable). + +--- + +## Configuration (`config.env`) + +### Wallet & network + +- `PRIVATE_KEY` — signing key (**never** commit). +- `FUNDER_ADDRESS` — proxy / funder when using signature type 1. +- `POLYGON_RPC_URL` — Polygon RPC (**private** endpoint recommended). +- `SIGNATURE_TYPE` — e.g. `1` = Gnosis Safe, `2` = EOA (see your setup). + +### Proxy (optional) + +- `HTTP_PROXY` / `HTTPS_PROXY` — e.g. `http://host:port` or `http://user:pass@host:port`. + +### Trading + +- `BTC_MARKET_MINUTES` — `5` or `15` (which Polymarket BTC window). PTB uses Polymarket’s crypto-price API with event start/end from Gamma (`variant=fifteen` for both intervals so PTB matches the site). +- `AUTO_TRADE` — `true` / `false` (live orders; ignored when simulation handles placement). +- `SIMULATION_MODE` — `true` / `false`. Paper mode: **no** CLOB orders / auto-redeem; PnL tracked in `state.json`. +- `TRADING_ANALYSIS_LOG` — optional path; default `trading_analysis.jsonl` next to `polymarket_auto_trade.py`. Relative paths resolve from that script’s directory. Each line is JSON with stable keys: `slug`, `shares_type` (UP/DOWN), `share_price`, `share_amount`, `ptb`, `btc_price`, `difference` (BTC−PTB USD), `status`, `take_profit` / `stop_loss`, `time`, `pnl_trade_usd`, `pnl_total_usd`, `simulation`, etc. +- `TRADE_AMOUNT` — USDC per buy. + +### Triggers + +- `CONDITION_1_*` … `CONDITION_4_*` — time window, min/max diff vs PTB, probability bands for UP/DOWN (see inline comments in `config.env`). + +### Risk & loop + +- `STOP_LOSS_PROB_PCT`, `TAKE_PROFIT_RR`, `TAKE_PROFIT_CAP`, `MARKET_DATA_MAX_LAG_SEC`, `LOOP_INTERVAL_SEC`, `BUY_RETRY_STEP`, etc. +- `CHECK_INTERVAL` — auxiliary check interval where used. +- Builder API keys for auto-redeem: `POLY_BUILDER_API_KEY`, `POLY_BUILDER_SECRET`, `POLY_BUILDER_PASSPHRASE`. + +--- + +## Run + +```bash +python polymarket_auto_trade.py +``` + +Use the dashboard **Market 5m / 15m** toggle or `BTC_MARKET_MINUTES` in `config.env` to switch horizons. + +--- + +## Web dashboard + +Default: **http://localhost:5080** (or `http://:5080` if bound externally). + +Includes balances, live prices, manual trading panel, history, round summary, and log stream. + +--- + +## Project layout (essential) + +| Path | Role | +|------|------| +| `polymarket_auto_trade.py` | Main loop, feeds, rules, orders, dashboard server | +| `config.env` | Secrets and trading switches (**gitignore** in your fork) | +| `static/dashboard.html` | Dashboard UI | +| `state.json` | Persisted runtime / simulation PnL state | +| `trading_analysis.jsonl` | Append-only analysis log (optional path) | + +--- + +## Extended strategies (contact) + +This folder ships the **PTB / diff / probability** design. **Separate professional offerings** from the same author include advanced **risk and sizing** (martingale, anti-martingale, Fibonacci), **TA** (RSI, MACD, Bollinger Bands), and **quant** tooling (Bayesian belief updates, edge vs market, spread modeling, Avellaneda–Stoikov-style inventory skew, Kelly / fractional Kelly, Monte Carlo). These are **not** all included here—reach out on **[Telegram @terauss](https://t.me/terauss)**. + +--- + +## Disclaimer + +**Educational and research use only.** You are solely responsible for trading outcomes. **No warranty.** Prediction markets can **zero** your position. Never share **private keys** or **API secrets**. See the [repository README](../README.md) for the full three-bot map and risk overview. diff --git a/5min-15min-PTB-bot/polymarket_auto_trade.py b/5min-15min-PTB-bot/polymarket_auto_trade.py new file mode 100644 index 0000000..ce4b873 --- /dev/null +++ b/5min-15min-PTB-bot/polymarket_auto_trade.py @@ -0,0 +1,3174 @@ +#!/usr/bin/env python3 +""" +Polymarket BTC 5m / 15m up-down auto-trading (WebSocket) +Monitors markets, checks rules, places orders, manages stops. +Uses WebSockets for lower-latency prices. +""" +import os +import sys +import time +import json +import threading +import requests +from datetime import datetime, timezone +from urllib.parse import urlencode +from dotenv import load_dotenv +from flask import Flask, Response, jsonify, request, send_from_directory, stream_with_context + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +STATIC_DIR = os.path.join(BASE_DIR, "static") + +# Load environment +load_dotenv(os.path.join(BASE_DIR, "config.env")) + +try: + from py_clob_client.client import ClobClient + from py_clob_client.clob_types import OrderArgs + from py_clob_client.order_builder.constants import BUY, SELL + HAS_CLOB = True +except: + HAS_CLOB = False + print("Please install: pip install py-clob-client") + sys.exit(1) + +try: + import websocket + HAS_WS = True +except: + HAS_WS = False + print("Please install: pip install websocket-client") + sys.exit(1) + +try: + from web3 import Web3 + HAS_WEB3 = True +except: + HAS_WEB3 = False + +# ============== Settings ============== +GAMMA_API = "https://gamma-api.polymarket.com" +CRYPTO_PRICE_API = "https://polymarket.com/api/crypto/crypto-price" +# PTB must match Polymarket UI: use "fifteen" for both 5m and 15m BTC up/down when passing +# the event's eventStartTime + endDate from Gamma. variant "five" returns a different anchor. +CRYPTO_PRICE_PTB_VARIANT = "fifteen" +BINANCE_WSS = "wss://stream.binance.com:9443/ws/btcusdt@trade" +POLYMARKET_WSS = "wss://ws-subscriptions-clob.polymarket.com/ws/market" +CLOB_API = "https://clob.polymarket.com" +RTDS_WS = "wss://ws-live-data.polymarket.com" # Chainlink price WebSocket +DATA_API = "https://data-api.polymarket.com" +CTF_CONTRACT = "0x4d97dcd97ec945f40cf65f87097ace5ea0476045" +USDC_E_CONTRACT = "0x2791bca1f2de4661ed88a30c99a7a9449aa84174" + +# Proxy (optional) +HTTP_PROXY = os.getenv("HTTP_PROXY", "") # e.g. http://127.0.0.1:7890 +HTTPS_PROXY = os.getenv("HTTPS_PROXY", "") + +# Build proxy dict +PROXIES = {} +if HTTP_PROXY: + PROXIES["http"] = HTTP_PROXY + # log(f"Using HTTP proxy: {HTTP_PROXY}", "INFO") # log function not yet defined here +if HTTPS_PROXY: + PROXIES["https"] = HTTPS_PROXY + # log(f"Using HTTPS proxy: {HTTPS_PROXY}", "INFO") # log function not yet defined here + +# Trading +AUTO_TRADE = os.getenv("AUTO_TRADE", "false").lower() == "true" +TRADE_AMOUNT = float(os.getenv("TRADE_AMOUNT", "5")) +# Paper trading: run strategy with instant fills; no CLOB orders. Implies no on-chain redeem in main(). +SIMULATION_MODE = os.getenv("SIMULATION_MODE", "false").lower() == "true" +# One JSON object per line: fills, PnL, BTC/PTB context for offline analysis +# Relative paths are resolved against BASE_DIR (script folder), not the shell cwd. +_tal_raw = (os.getenv("TRADING_ANALYSIS_LOG", "") or "").strip() +if not _tal_raw: + TRADING_ANALYSIS_LOG = os.path.join(BASE_DIR, "trading_analysis.jsonl") +elif os.path.isabs(_tal_raw): + TRADING_ANALYSIS_LOG = os.path.normpath(_tal_raw) +else: + TRADING_ANALYSIS_LOG = os.path.normpath(os.path.join(BASE_DIR, _tal_raw)) +TRADING_ANALYSIS_LOG = os.path.abspath(TRADING_ANALYSIS_LOG) + +# Trigger rules (examples in comments) +# C1: within 120s left, diff ≥ 30, UP prob ~80–92% +C1_TIME = int(os.getenv("CONDITION_1_TIME", "120")) +C1_DIFF = float(os.getenv("CONDITION_1_DIFF", "30")) +C1_MIN_PROB = float(os.getenv("CONDITION_1_MIN_PROB", "0.80")) +C1_MAX_PROB = float(os.getenv("CONDITION_1_MAX_PROB", "0.92")) + +# C2: within 120s, diff ≥ 30, DOWN prob ~80–92% +C2_TIME = int(os.getenv("CONDITION_2_TIME", "120")) +C2_DIFF = float(os.getenv("CONDITION_2_DIFF", "30")) +C2_MIN_PROB = float(os.getenv("CONDITION_2_MIN_PROB", "0.80")) +C2_MAX_PROB = float(os.getenv("CONDITION_2_MAX_PROB", "0.92")) + +# C3: within 60s, diff ≥ 50, UP prob ~80–92% +C3_TIME = int(os.getenv("CONDITION_3_TIME", "60")) +C3_DIFF = float(os.getenv("CONDITION_3_DIFF", "50")) +C3_MIN_PROB = float(os.getenv("CONDITION_3_MIN_PROB", "0.80")) +C3_MAX_PROB = float(os.getenv("CONDITION_3_MAX_PROB", "0.92")) + +# C4: within 60s, diff ≥ 50, DOWN prob ~80–92% +C4_TIME = int(os.getenv("CONDITION_4_TIME", "60")) +C4_DIFF = float(os.getenv("CONDITION_4_DIFF", "50")) +C4_MIN_PROB = float(os.getenv("CONDITION_4_MIN_PROB", "0.80")) +C4_MAX_PROB = float(os.getenv("CONDITION_4_MAX_PROB", "0.92")) + +ORDER_TIMEOUT_SEC = int(os.getenv("ORDER_TIMEOUT_SEC", "8")) # cancel if unfilled after N seconds +SLIPPAGE_THRESHOLD = float(os.getenv("SLIPPAGE_THRESHOLD", "0.05")) # 5% slippage cap +MAX_RETRY_PER_MARKET = int(os.getenv("MAX_RETRY_PER_MARKET", "2")) # max retries per market +BUY_RETRY_STEP = max(0.001, float(os.getenv("BUY_RETRY_STEP", "0.01"))) +STOP_LOSS_PROB_PCT = float(os.getenv("STOP_LOSS_PROB_PCT", "0.15")) +TAKE_PROFIT_RR = max(0.2, float(os.getenv("TAKE_PROFIT_RR", "1.0"))) +TAKE_PROFIT_CAP = min(0.995, max(0.55, float(os.getenv("TAKE_PROFIT_CAP", "0.99")))) +TAKE_PROFIT_RETRY_STEP = max(0.001, float(os.getenv("TAKE_PROFIT_RETRY_STEP", "0.005"))) +TAKE_PROFIT_RETRY_MAX = max(1, int(os.getenv("TAKE_PROFIT_RETRY_MAX", "3"))) +MARKET_DATA_MAX_LAG_SEC = max(0.2, float(os.getenv("MARKET_DATA_MAX_LAG_SEC", "1.2"))) +LOOP_INTERVAL_SEC = max(0.1, float(os.getenv("LOOP_INTERVAL_SEC", "0.25"))) + +# Risk / ops +CHECK_INTERVAL = int(os.getenv("CHECK_INTERVAL", "2")) + +AUTO_REDEEM = os.getenv("AUTO_REDEEM", "true").lower() == "true" +POLYGON_RPC_URL = os.getenv("POLYGON_RPC_URL", "") +REDEEM_SCAN_INTERVAL = max(3, int(os.getenv("REDEEM_SCAN_INTERVAL", "15"))) +REDEEM_RETRY_INTERVAL = max(10, int(os.getenv("REDEEM_RETRY_INTERVAL", "120"))) +REDEEM_MAX_PER_SCAN = max(1, int(os.getenv("REDEEM_MAX_PER_SCAN", "2"))) +REDEEM_PENDING_LOG_INTERVAL = max(10, int(os.getenv("REDEEM_PENDING_LOG_INTERVAL", "30"))) +POLY_BUILDER_API_KEY = os.getenv("POLY_BUILDER_API_KEY", "") +POLY_BUILDER_SECRET = os.getenv("POLY_BUILDER_SECRET", "") +POLY_BUILDER_PASSPHRASE = os.getenv("POLY_BUILDER_PASSPHRASE", "") +RELAYER_URL = os.getenv("RELAYER_URL", "https://relayer-v2.polymarket.com") +RELAYER_TX_TYPE = os.getenv("RELAYER_TX_TYPE", "SAFE").upper() +DASHBOARD_ACCOUNT_SYNC_SEC = max(10, int(os.getenv("DASHBOARD_ACCOUNT_SYNC_SEC", "20"))) +MARKET_FOUND_LOG_INTERVAL = max(10, int(os.getenv("MARKET_FOUND_LOG_INTERVAL", "30"))) +MARKET_META_REFRESH_SEC = max(2, int(os.getenv("MARKET_META_REFRESH_SEC", "5"))) + +WEB_ENABLED = os.getenv("WEB_ENABLED", "true").lower() == "true" +WEB_HOST = os.getenv("WEB_HOST", "0.0.0.0") +WEB_PORT = int(os.getenv("WEB_PORT", "5080")) + + +def _normalize_btc_market_minutes(m): + """Polymarket supports 5m and 15m BTC up/down events.""" + try: + n = int(float(str(m).strip())) + except (TypeError, ValueError): + return 15 + return 5 if n == 5 else 15 + + +_btc_market_minutes = _normalize_btc_market_minutes(os.getenv("BTC_MARKET_MINUTES", "15")) +_market_interval_sec = _btc_market_minutes * 60 + +# Persisted state file +STATE_FILE = os.path.join(BASE_DIR, "state.json") + +# Global price snapshot +price_data = { + "btc": None, # Chainlink BTC (trading reference) + "binance": None, # Binance BTC (secondary) + "ptb": None, # Price to Beat + "up_price": None, # UP token mid + "down_price": None, # DOWN token mid + "up_bid": None, + "up_ask": None, + "down_bid": None, + "down_ask": None, + "btc_update_ts": 0.0, + "up_update_ts": 0.0, + "down_update_ts": 0.0, + "last_update": None, +} + +dashboard_lock = threading.Lock() +dashboard_cond = threading.Condition(dashboard_lock) +dashboard_version = 0 +dashboard_state = { + "updated_at": None, + "market": {}, + "wallet_balance": None, + "prices": {}, + "position": {}, + "pending_order": {}, + "last_order": {}, + "trade_history": [], + "wallet_positions": [], + "wallet_history": [], + "live_trades": [], + "live_positions_count": 0, + "live_realized_pnl": 0.0, + "live_unrealized_pnl": 0.0, + "live_total_pnl": 0.0, + "auto_redeem": {}, + "activity": [], + "btc_market_minutes": _btc_market_minutes, + "cumulative_realized_pnl": 0.0, + "simulation_mode": SIMULATION_MODE, +} + +app = Flask(__name__, static_folder=STATIC_DIR) + +_market_found_log_state = {"slug": "", "kind": "", "last_ts": 0.0} +_price_refresh_lock = threading.Lock() +_price_refresh_running = False +_market_cache_lock = threading.Lock() +_market_cache = None +_market_refresh_running = False +_account_sync_lock = threading.Lock() +_account_sync_running = False +_trading_analysis_log_lock = threading.Lock() + + +def _log_market_found_throttled(kind, slug, remaining): + same_market = (_market_found_log_state.get("slug") == slug and _market_found_log_state.get("kind") == kind) + if same_market: + return + _market_found_log_state["slug"] = slug + _market_found_log_state["kind"] = kind + _market_found_log_state["last_ts"] = time.time() + log(f"Found {kind} market: {slug[:40]}... ({remaining//60}m {remaining%60}s left)", "OK") + + +def _trigger_price_refresh(): + global _price_refresh_running + with _price_refresh_lock: + if _price_refresh_running: + return + _price_refresh_running = True + + def worker(): + global _price_refresh_running + try: + chainlink_price = get_chainlink_btc_price() + if chainlink_price: + price_data["btc"] = chainlink_price + ts = time.time() + price_data["btc_update_ts"] = ts + price_data["last_update"] = ts + + binance_price = get_binance_btc_price() + if binance_price: + price_data["binance"] = binance_price + finally: + with _price_refresh_lock: + _price_refresh_running = False + + threading.Thread(target=worker, daemon=True).start() + + +def _trigger_market_refresh(): + global _market_refresh_running, _market_cache + with _market_cache_lock: + if _market_refresh_running: + return + _market_refresh_running = True + + def worker(): + global _market_refresh_running, _market_cache + try: + market = get_active_market() + with _market_cache_lock: + _market_cache = dict(market) if isinstance(market, dict) else None + finally: + with _market_cache_lock: + _market_refresh_running = False + + threading.Thread(target=worker, daemon=True).start() + + +def _get_market_cache(): + with _market_cache_lock: + return dict(_market_cache) if isinstance(_market_cache, dict) else None + + +def _clear_market_cache(): + global _market_cache + with _market_cache_lock: + _market_cache = None + + +def _trigger_account_sync(user): + global _account_sync_running + u = str(user or "").strip().lower() + if not u: + return + with _account_sync_lock: + if _account_sync_running: + return + _account_sync_running = True + + def worker(): + global _account_sync_running + try: + _sync_dashboard_account_snapshot(u) + except Exception: + pass + finally: + with _account_sync_lock: + _account_sync_running = False + + threading.Thread(target=worker, daemon=True).start() + + +def _dashboard_set(**kwargs): + global dashboard_version + with dashboard_cond: + for k, v in kwargs.items(): + dashboard_state[k] = v + dashboard_state["updated_at"] = datetime.now().isoformat() + dashboard_version += 1 + dashboard_cond.notify_all() + + +@app.route("/") +def dashboard_index(): + return send_from_directory(STATIC_DIR, "dashboard.html") + + +@app.route("/api/status") +def dashboard_status(): + with dashboard_lock: + return jsonify(dict(dashboard_state)) + + +@app.route("/api/logs") +def dashboard_logs(): + with dashboard_lock: + return jsonify({"items": list(dashboard_state.get("activity") or [])[-300:]}) + + +@app.route("/api/stream") +def dashboard_stream(): + def _event(name, payload): + return f"event: {name}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n" + + def generate(): + last_seen = -1 + last_log_sig = "" + while True: + with dashboard_cond: + if dashboard_version == last_seen: + dashboard_cond.wait(timeout=15) + version_now = dashboard_version + state_now = dict(dashboard_state) + + if version_now != last_seen: + logs = list(state_now.get("activity") or [])[-300:] + state_now.pop("activity", None) + yield _event("status", {"data": state_now}) + + if logs: + tail = logs[-1] + sig = f"{len(logs)}|{tail.get('time','')}|{tail.get('message','')}" + else: + sig = "0" + if sig != last_log_sig: + yield _event("logs", {"items": logs}) + last_log_sig = sig + + last_seen = version_now + else: + yield ": ping\n\n" + + return Response( + stream_with_context(generate()), + mimetype="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + +@app.route("/api/history") +def dashboard_history(): + with dashboard_lock: + live_items = list(dashboard_state.get("live_trades") or []) + if live_items: + return jsonify({"items": live_items[-300:]}) + local_items = list(dashboard_state.get("trade_history") or []) + wallet_items = list(dashboard_state.get("wallet_history") or []) + return jsonify({"items": (local_items + wallet_items)[-300:]}) + + +@app.route("/api/btc-market-minutes", methods=["POST"]) +def api_btc_market_minutes(): + """Switch between 5m and 15m BTC markets (clears cached market metadata).""" + if not WEB_ENABLED: + return jsonify({"ok": False, "error": "web disabled"}), 404 + try: + body = request.get_json(silent=True) or {} + m = body.get("minutes", body.get("interval", 15)) + m = int(m) + except (TypeError, ValueError): + return jsonify({"ok": False, "error": "invalid body"}), 400 + if m not in (5, 15): + return jsonify({"ok": False, "error": "minutes must be 5 or 15"}), 400 + set_btc_market_minutes(m) + return jsonify({"ok": True, "minutes": get_btc_market_minutes()}) + + +def start_web_server(): + if not WEB_ENABLED: + return + + def run(): + app.run(host=WEB_HOST, port=WEB_PORT, threaded=True, use_reloader=False) + + t = threading.Thread(target=run, daemon=True) + t.start() + +# ============== Utilities ============== +def log(msg, level="INFO", force=False): + """Console + dashboard log.""" + if force or level in ["OK", "ERR", "WARN", "TRADE"]: + icons = {"INFO": "ℹ️", "OK": "✅", "ERR": "❌", "WARN": "⚠️", "TRADE": "💰"} + icon = icons.get(level, "ℹ️") + ts = datetime.now().strftime("%H:%M:%S") + log_msg = f"[{ts}] {icon} {msg}" + print(log_msg) + + global dashboard_version + with dashboard_cond: + arr = dashboard_state.get("activity") or [] + arr.append({ + "time": ts, + "level": level, + "message": str(msg), + }) + if len(arr) > 400: + arr = arr[-400:] + dashboard_state["activity"] = arr + dashboard_state["updated_at"] = datetime.now().isoformat() + dashboard_version += 1 + dashboard_cond.notify_all() + + # Persist TRADE and ERR to trade.log + if level in ["TRADE", "ERR"]: + try: + with open("trade.log", "a", encoding="utf-8") as f: + f.write(log_msg + "\n") + except: + pass + + +def get_btc_market_minutes(): + return _btc_market_minutes + + +def set_btc_market_minutes(m): + global _btc_market_minutes, _market_interval_sec + _btc_market_minutes = _normalize_btc_market_minutes(m) + _market_interval_sec = _btc_market_minutes * 60 + price_data["ptb"] = None + _clear_market_cache() + _trigger_market_refresh() + _dashboard_set(btc_market_minutes=_btc_market_minutes) + log( + f"BTC market interval set to {_btc_market_minutes}m " + f"(slug btc-updown-{_btc_market_minutes}m-*, PTB via {CRYPTO_PRICE_PTB_VARIANT!r} + event window)", + "OK", + force=True, + ) + + +def get_binance_btc_price(): + """Fetch BTC/USDT from Binance REST.""" + try: + r = requests.get("https://api.binance.com/api/v3/ticker/price", + params={"symbol": "BTCUSDT"}, + proxies=PROXIES if PROXIES else None, + timeout=5) + if r.status_code == 200: + return float(r.json().get("price")) + except: + pass + return None + +def get_chainlink_btc_price(): + """Chainlink BTC via Polymarket RTDS WebSocket (fallback).""" + result = {"price": None} + + def on_message(ws, message): + try: + data = json.loads(message) + if data.get("topic") == "crypto_prices" and data.get("payload"): + payload = data["payload"] + if "data" in payload and payload.get("symbol") == "btc/usd": + prices = payload["data"] + if prices: + result["price"] = prices[-1]["value"] + elif "value" in payload: + result["price"] = payload["value"] + ws.close() + except: + pass + + def on_open(ws): + sub_msg = { + "action": "subscribe", + "subscriptions": [{ + "topic": "crypto_prices_chainlink", + "type": "*", + "filters": "{\"symbol\":\"btc/usd\"}" + }] + } + ws.send(json.dumps(sub_msg)) + + def on_error(ws, error): + pass + + try: + ws = websocket.WebSocketApp(RTDS_WS, + on_open=on_open, + on_message=on_message, + on_error=on_error) + + def close_after(): + time.sleep(3) + try: + ws.close() + except: + pass + threading.Thread(target=close_after, daemon=True).start() + + ws.run_forever() + return result["price"] + except: + return None + +def get_crypto_price_api(start_time, end_time): + """ + PTB from Polymarket crypto-price API. + Returns: {"openPrice": PTB, "closePrice": current or None, "completed": bool} + """ + try: + # Accept str or datetime for window bounds + if isinstance(start_time, str): + start_str = start_time.replace("Z", "+00:00") + if "+" in start_str: + start_str = start_str.split("+")[0] + "Z" + else: + start_str = start_time + else: + start_str = start_time.strftime("%Y-%m-%dT%H:%M:%SZ") + + if isinstance(end_time, str): + end_str = end_time.replace("Z", "+00:00") + if "+" in end_str: + end_str = end_str.split("+")[0] + "Z" + else: + end_str = end_time + else: + end_str = end_time.strftime("%Y-%m-%dT%H:%M:%SZ") + + params = { + "symbol": "BTC", + "eventStartTime": start_str, + "variant": CRYPTO_PRICE_PTB_VARIANT, + "endDate": end_str + } + + # Browser-like headers + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Accept": "application/json", + "Referer": "https://polymarket.com/" + } + + log(f"PTB request: {CRYPTO_PRICE_API}?{urlencode(params)}", "INFO") + r = requests.get(CRYPTO_PRICE_API, params=params, headers=headers, + proxies=PROXIES if PROXIES else None, timeout=10) + + log(f"PTB HTTP status: {r.status_code}", "INFO") + + if r.status_code == 200: + data = r.json() + log(f"PTB payload: {data}", "INFO") + return data + else: + log(f"PTB request failed: HTTP {r.status_code} - {r.text[:200]}", "ERR") + except Exception as e: + log(f"crypto-price error: {type(e).__name__}: {str(e)}", "ERR") + return {} + +def get_current_slug(): + """Current window slug for configured interval (5m or 15m).""" + ts = int(time.time()) + step = _market_interval_sec + window_start = (ts // step) * step + return f"btc-updown-{_btc_market_minutes}m-{window_start}" + +def get_next_slug(): + """Next window slug for configured interval.""" + ts = int(time.time()) + step = _market_interval_sec + window_start = ((ts // step) + 1) * step + return f"btc-updown-{_btc_market_minutes}m-{window_start}" + +def get_active_market(): + """Active BTC up/down market for the configured interval.""" + try: + # Try current window first + current_slug = get_current_slug() + market = fetch_market_by_slug(current_slug) + if market and market["remaining"] > 0: + _log_market_found_throttled("current", current_slug, market["remaining"]) + return market + + # Then next window + next_slug = get_next_slug() + market = fetch_market_by_slug(next_slug) + if market and market["remaining"] > 0: + _log_market_found_throttled("next", next_slug, market["remaining"]) + return market + + log("No active market in current or next window", "WARN") + + except Exception as e: + log(f"Market fetch failed: {e}", "ERR") + import traceback + traceback.print_exc() + return None + +def fetch_market_by_slug(slug): + """Gamma API market row for slug.""" + try: + r = requests.get(f"{GAMMA_API}/events", params={"slug": slug}, + proxies=PROXIES if PROXIES else None, timeout=10) + data = r.json() + + if not data: + return None + + event = data[0] + + # Skip closed events + if event.get("closed", False): + return None + + end_str = event.get("endDate", "") + start_str = event.get("startTime", "") + if not end_str or not start_str: + return None + + # Seconds until end + now = datetime.now(timezone.utc).timestamp() + end_ts = datetime.fromisoformat(end_str.replace("Z", "+00:00")).timestamp() + remaining_time = int(end_ts - now) + + if remaining_time <= 0: + return None + + # Parse first market + markets = event.get("markets", []) + if not markets: + return None + + m = markets[0] + outcomes = json.loads(m.get("outcomes", "[]")) if isinstance(m.get("outcomes"), str) else m.get("outcomes", []) + prices = json.loads(m.get("outcomePrices", "[]")) if isinstance(m.get("outcomePrices"), str) else m.get("outcomePrices", []) + tokens = json.loads(m.get("clobTokenIds", "[]")) if isinstance(m.get("clobTokenIds"), str) else m.get("clobTokenIds", []) + + # Assume outcomes[0]=UP, [1]=DOWN + up_price = float(prices[0]) if len(prices) > 0 else None + down_price = float(prices[1]) if len(prices) > 1 else None + up_token = tokens[0] if len(tokens) > 0 else None + down_token = tokens[1] if len(tokens) > 1 else None + + return { + "slug": slug, + "start": start_str, + "end": end_str, + "remaining": remaining_time, + "up_price": up_price, + "down_price": down_price, + "up_token": up_token, + "down_token": down_token + } + except Exception as e: + # Missing market is normal + return None + +def get_ptb(start_time, end_time): + """Fetch Price to Beat (open) for window.""" + try: + params = { + "symbol": "BTC", + "eventStartTime": start_time, + "variant": CRYPTO_PRICE_PTB_VARIANT, + "endDate": end_time + } + r = requests.get(CRYPTO_PRICE_API, params=params, timeout=10) + if r.status_code == 200: + data = r.json() + return float(data.get("openPrice")) if data.get("openPrice") else None + except: + pass + return None + + +def _normalize_state(state): + if not isinstance(state, dict): + state = {} + if not isinstance(state.get("position"), dict): + state["position"] = {} + if not isinstance(state.get("pending_order"), dict): + state["pending_order"] = {} + if not isinstance(state.get("last_order"), dict): + state["last_order"] = {} + if not isinstance(state.get("take_profit_order"), dict): + state["take_profit_order"] = {} + if not isinstance(state.get("trade_history"), list): + state["trade_history"] = [] + if state.get("cumulative_realized_pnl") is None or not isinstance( + state.get("cumulative_realized_pnl"), (int, float) + ): + try: + state["cumulative_realized_pnl"] = float(state.get("cumulative_realized_pnl") or 0.0) + except (TypeError, ValueError): + state["cumulative_realized_pnl"] = 0.0 + return state + + +def _dashboard_pending_order_from_state(state): + state = _normalize_state(state) + pending = dict(state.get("pending_order") or {}) + if pending: + return pending + tp = dict(state.get("take_profit_order") or {}) + if tp: + tp.setdefault("action", "SELL") + tp.setdefault("reason", "take_profit") + return tp + + +def _append_trade_history(state, item): + state = _normalize_state(state) + hist = list(state.get("trade_history") or []) + hist.append(item) + if len(hist) > 300: + hist = hist[-300:] + state["trade_history"] = hist + _dashboard_set(trade_history=list(hist)) + return state + + +def _planned_take_profit_stop_loss(entry_prob): + """ + Same TP/SL probability levels as the main loop (for logging planned targets after a buy). + Returns (take_profit_prob, stop_loss_prob) or (None, None). + """ + if entry_prob is None or entry_prob <= 0: + return None, None + try: + ep = float(entry_prob) + except (TypeError, ValueError): + return None, None + stop_prob = max(0.0, ep * (1.0 - STOP_LOSS_PROB_PCT)) + risk_abs = max(0.0, ep - stop_prob) + tp_trigger = min(TAKE_PROFIT_CAP, ep + risk_abs * TAKE_PROFIT_RR) + if tp_trigger <= ep: + return None, stop_prob + balanced_risk = (tp_trigger - ep) / TAKE_PROFIT_RR + balanced_stop = max(0.0, ep - balanced_risk) + if balanced_stop > stop_prob: + stop_prob = balanced_stop + return tp_trigger, stop_prob + + +def _emit_trading_analysis(event, **fields): + """Append one JSON line with a stable schema for analysis (see schema_version).""" + ts = fields.get("time") or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ") + + btc = fields.get("btc_price") + if btc is None: + btc = fields.get("chainlink_btc") + ptb = fields.get("ptb") + + diff = fields.get("difference") + if diff is None: + diff = fields.get("diff_rule", fields.get("diff")) + if diff is None and btc is not None and ptb is not None: + try: + diff = float(btc) - float(ptb) + except (TypeError, ValueError): + diff = None + + st = fields.get("status") + if not st: + act = str(fields.get("action") or "").upper() + if act == "BUY": + st = "buy" + elif act == "SELL": + st = "sell" + + shares_type = fields.get("shares_type") or fields.get("side") + + share_price = fields.get("share_price") + if share_price is None: + share_price = fields.get("price") + if share_price is None: + share_price = fields.get("exit_share_price") + + share_amount = fields.get("share_amount") + if share_amount is None: + share_amount = fields.get("shares") + + pnl_trade = fields.get("pnl_trade_usd") + if pnl_trade is None: + pnl_trade = fields.get("realized_pnl_usd") + + pnl_total = fields.get("pnl_total_usd") + if pnl_total is None: + pnl_total = fields.get("cumulative_realized_pnl_usd") + + tp = fields.get("take_profit") + sl = fields.get("stop_loss") + if tp is None and sl is None: + entry_plan = fields.get("entry_share_price") + if entry_plan is None: + entry_plan = share_price + _no_auto_plan = ( + "SELL_CLOSE", + "SELL_SUBMIT", + "SELL_FAILED", + "SELL_ALERT", + "BUY_CANCEL_TIMEOUT", + ) + if entry_plan is not None and event not in _no_auto_plan: + tp, sl = _planned_take_profit_stop_loss(entry_plan) + + def _nf(x): + if x is None: + return None + try: + return float(x) + except (TypeError, ValueError): + return None + + row = { + "schema_version": 2, + "event": event, + "slug": fields.get("slug"), + "shares_type": shares_type, + "share_price": _nf(share_price), + "share_amount": _nf(share_amount), + "ptb": _nf(ptb), + "btc_price": _nf(btc), + "difference": _nf(diff), + "difference_note": "Chainlink BTC minus PTB (USD); same as diff in bot logic.", + "status": st, + "take_profit": _nf(tp), + "stop_loss": _nf(sl), + "time": ts, + "pnl_trade_usd": _nf(pnl_trade), + "pnl_total_usd": _nf(pnl_total), + "simulation": SIMULATION_MODE, + "btc_market_minutes": _btc_market_minutes, + } + + passthrough = ( + "reason", + "order_id", + "order_size_usdc", + "remaining_sec", + "entry_share_price", + "exit_share_price", + "notional_exit_usd", + "action", + "chainlink_btc", + "btc_minus_ptb", + "diff_rule", + ) + for k in passthrough: + if k in fields and fields[k] is not None: + row[k] = fields[k] + + try: + log_dir = os.path.dirname(TRADING_ANALYSIS_LOG) + if log_dir: + os.makedirs(log_dir, exist_ok=True) + with _trading_analysis_log_lock: + with open(TRADING_ANALYSIS_LOG, "a", encoding="utf-8") as f: + f.write(json.dumps(row, default=str, ensure_ascii=False) + "\n") + except Exception as e: + try: + log(f"Trading analysis log write failed ({TRADING_ANALYSIS_LOG}): {e}", "ERR", force=True) + except Exception: + print(f"Trading analysis log write failed ({TRADING_ANALYSIS_LOG}): {e}", file=sys.stderr) + + +def _init_trading_analysis_session(): + """Create log file and write SESSION_START so path is visible even before any trade.""" + row = { + "schema_version": 2, + "event": "SESSION_START", + "log_path": TRADING_ANALYSIS_LOG, + "slug": None, + "shares_type": None, + "share_price": None, + "share_amount": None, + "ptb": None, + "btc_price": None, + "difference": None, + "difference_note": "Chainlink BTC minus PTB (USD).", + "status": None, + "take_profit": None, + "stop_loss": None, + "time": None, + "pnl_trade_usd": None, + "pnl_total_usd": None, + "simulation": SIMULATION_MODE, + "auto_trade": AUTO_TRADE, + "btc_market_minutes": _btc_market_minutes, + "trade_amount_usdc": TRADE_AMOUNT, + "note": "Trade rows use the same keys as SESSION_START; pnl_total_usd is cumulative realized.", + } + row["logged_at"] = row["time"] = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ") + try: + log_dir = os.path.dirname(TRADING_ANALYSIS_LOG) + if log_dir: + os.makedirs(log_dir, exist_ok=True) + with _trading_analysis_log_lock: + with open(TRADING_ANALYSIS_LOG, "a", encoding="utf-8") as f: + f.write(json.dumps(row, default=str, ensure_ascii=False) + "\n") + except Exception as e: + print(f"FATAL: cannot write trading log at {TRADING_ANALYSIS_LOG}: {e}", file=sys.stderr) + try: + log(f"Cannot init trading analysis log: {e}", "ERR", force=True) + except Exception: + pass + + +def _shares_from_usdc_buy(usdc, share_price): + if share_price and share_price > 0 and usdc and usdc > 0: + return float(usdc) / float(share_price) + return 0.0 + + +def _btc_ptb_snapshot(btc, ptb): + if btc is None or ptb is None: + return None + try: + return float(btc) - float(ptb) + except (TypeError, ValueError): + return None + + +def _to_float(value, default=0.0): + try: + return float(value) + except Exception: + return float(default) + + +def _maybe_float(value): + try: + return float(value) + except Exception: + return None + + +def _to_bool(value): + if isinstance(value, bool): + return value + if value is None: + return False + s = str(value).strip().lower() + return s in ("1", "true", "yes", "y", "on") + + +def _data_api_get(path, params=None): + try: + r = requests.get( + f"{DATA_API}{path}", + params=params or {}, + proxies=PROXIES if PROXIES else None, + timeout=12, + ) + if r.status_code == 200: + return r.json() + except Exception: + return None + return None + + +def _text_scalar(v): + if isinstance(v, (str, int, float, bool)): + return str(v).strip() + return "" + + +def _normalize_outcome_label(v): + s = str(v or "").upper() + if "UP" in s or s == "YES": + return "UP" + if "DOWN" in s or s == "NO": + return "DOWN" + return s or "-" + + +def _trade_pick_field(tr, *keys): + if not isinstance(tr, dict): + return "" + sources = [tr] + market = tr.get("market") + if isinstance(market, dict): + sources.append(market) + event = tr.get("event") + if isinstance(event, dict): + sources.append(event) + for src in sources: + for k in keys: + if k not in src: + continue + s = _text_scalar(src.get(k)) + if s: + return s + return "" + + +def _trade_event_kind(tr): + typ = str((tr or {}).get("type") or "").upper().strip() + side = str((tr or {}).get("side") or "").upper().strip() + if typ == "REDEEM": + return "REDEEM" + if typ in ["DEPOSIT", "WITHDRAW", "WITHDRAWAL", "TRANSFER"]: + return "IGNORE" + if side in ["BUY", "SELL"]: + return side + return "IGNORE" + + +def _trade_ts_ms(tr): + v = (tr or {}).get("matchtime") or (tr or {}).get("match_time") or (tr or {}).get("timestamp") or (tr or {}).get("created_at") or (tr or {}).get("time") + if isinstance(v, (int, float)): + n = float(v) + return int(n if n > 1e12 else n * 1000) + s = str(v or "").strip() + if not s: + return 0 + if s.isdigit(): + n = int(s) + return n if n > 1e12 else n * 1000 + try: + dt = datetime.fromisoformat(s.replace("Z", "+00:00")) + return int(dt.timestamp() * 1000) + except Exception: + return 0 + + +def _trade_usdc_size(tr): + usdc = _maybe_float((tr or {}).get("usdcSize") or (tr or {}).get("usdc_size")) + if usdc is not None: + return abs(usdc) + price = _maybe_float((tr or {}).get("price")) + size = _maybe_float((tr or {}).get("size_matched") or (tr or {}).get("size") or (tr or {}).get("original_size")) + if price is not None and size is not None: + return abs(price * size) + return 0.0 + + +def _trade_market_key(tr): + cond = _trade_pick_field(tr, "conditionId", "condition_id", "market", "market_id") + slug = _trade_pick_field(tr, "eventSlug", "slug") + if cond: + return cond + if slug: + return slug + asset = _trade_pick_field(tr, "asset_id", "asset", "token_id") + return asset or "market" + + +def _resolve_trade_reason(tr): + title = _trade_pick_field(tr, "title", "eventTitle", "name", "question") + if title: + return title + slug = _trade_pick_field(tr, "eventSlug", "slug") + if slug: + return slug + return "market" + + +def _fetch_trade_activity(user, limit=500): + if not user: + return [] + lim = min(max(int(limit), 50), 1000) + param_sets = [ + {"user": user, "limit": lim, "offset": 0}, + {"user": user}, + {"address": user, "limit": lim, "offset": 0}, + {"wallet": user, "limit": lim, "offset": 0}, + ] + + rows = [] + seen = set() + for params in param_sets: + data = _data_api_get("/activity", params) + if not isinstance(data, list): + continue + for item in data: + if not isinstance(item, dict): + continue + kind = _trade_event_kind(item) + if kind == "IGNORE": + continue + tid = _text_scalar(item.get("id") or item.get("tradeID") or item.get("transaction_hash") or item.get("transactionHash")) + if not tid: + tid = f"act-{kind}-{_trade_ts_ms(item)}-{_trade_usdc_size(item):.6f}-{_trade_market_key(item)}" + if tid in seen: + continue + seen.add(tid) + norm = dict(item) + if norm.get("type") is not None: + norm["type"] = str(norm.get("type")).upper() + if norm.get("side") is not None: + norm["side"] = str(norm.get("side")).upper() + norm["id"] = tid + rows.append(norm) + if rows: + break + + rows.sort(key=_trade_ts_ms) + return rows + + +def _build_market_aggregated_trades(raw_trades): + groups = {} + for tr in sorted((raw_trades or []), key=_trade_ts_ms): + if not isinstance(tr, dict): + continue + kind = _trade_event_kind(tr) + if kind == "IGNORE": + continue + + price = _maybe_float(tr.get("price")) + size = _maybe_float(tr.get("size_matched") or tr.get("size") or tr.get("original_size")) + usdc_size = _trade_usdc_size(tr) + if kind in ["BUY", "SELL"] and (price is None or size is None or size <= 0): + continue + if kind == "REDEEM" and usdc_size <= 0: + continue + + key = _trade_market_key(tr) + ts = tr.get("matchtime") or tr.get("match_time") or tr.get("timestamp") or tr.get("created_at") or tr.get("time") + ts_ms = _trade_ts_ms(tr) + g = groups.get(key) + if g is None: + g = { + "id": f"agg-{key}", + "direction": _normalize_outcome_label(tr.get("outcome") or tr.get("direction")), + "outcomes": set(), + "reason": _resolve_trade_reason(tr), + "buy_count": 0, + "sell_count": 0, + "redeem_count": 0, + "buy_size": 0.0, + "sell_size": 0.0, + "buy_notional": 0.0, + "sell_notional": 0.0, + "redeem_notional": 0.0, + "first_ts": ts, + "last_ts": ts, + "first_ts_ms": ts_ms, + "last_ts_ms": ts_ms, + } + groups[key] = g + + if ts_ms and ts_ms < g["first_ts_ms"]: + g["first_ts_ms"] = ts_ms + g["first_ts"] = ts + if ts_ms and ts_ms >= g["last_ts_ms"]: + g["last_ts_ms"] = ts_ms + g["last_ts"] = ts + + outcome = _normalize_outcome_label(tr.get("outcome") or tr.get("direction")) + if outcome and outcome != "-": + g["outcomes"].add(outcome) + + if kind == "BUY": + g["buy_count"] += 1 + g["buy_size"] += float(size) + g["buy_notional"] += float(usdc_size) + elif kind == "SELL": + g["sell_count"] += 1 + g["sell_size"] += float(size) + g["sell_notional"] += float(usdc_size) + elif kind == "REDEEM": + g["redeem_count"] += 1 + g["redeem_notional"] += float(usdc_size) + + rows = [] + for g in groups.values(): + if (g["buy_count"] + g["sell_count"] + g["redeem_count"]) <= 0: + continue + buy_avg = (g["buy_notional"] / g["buy_size"]) if g["buy_size"] > 1e-9 else None + sell_avg = (g["sell_notional"] / g["sell_size"]) if g["sell_size"] > 1e-9 else None + matched_size = min(g["buy_size"], g["sell_size"]) + pnl = g["sell_notional"] + g["redeem_notional"] - g["buy_notional"] + + if len(g["outcomes"]) == 1: + g["direction"] = list(g["outcomes"])[0] + elif len(g["outcomes"]) > 1: + g["direction"] = "MIX" + + result = "CLOSED" if (g["sell_count"] > 0 or g["redeem_count"] > 0) else "OPEN" + rows.append({ + "id": g["id"], + "pair_id": g["id"], + "direction": g["direction"], + "reason": g["reason"], + "buy_count": g["buy_count"], + "sell_count": g["sell_count"], + "redeem_count": g["redeem_count"], + "buy_usdc": g["buy_notional"], + "sell_usdc": g["sell_notional"], + "redeem_usdc": g["redeem_notional"], + "size": matched_size if matched_size > 1e-9 else max(g["buy_size"], g["sell_size"]), + "entry_price_quote": buy_avg, + "exit_price_quote": sell_avg, + "order_time": g["first_ts"], + "settle_time": g["last_ts"], + "profit": pnl, + "result": result, + "status": "AGG", + }) + + rows.sort(key=lambda x: _trade_ts_ms({"timestamp": x.get("settle_time")}) if isinstance(x, dict) else 0) + return rows + + +def _compute_wallet_realized_pnl(rows): + realized = 0.0 + for row in rows or []: + if not isinstance(row, dict): + continue + rp = _maybe_float(row.get("realizedPnl") if row.get("realizedPnl") is not None else row.get("realized_pnl")) + if rp is not None: + realized += rp + return float(realized) + + +def _compute_wallet_unrealized_pnl(rows): + unrealized = 0.0 + for row in rows or []: + if not isinstance(row, dict): + continue + mark = _maybe_float(row.get("curPrice") if row.get("curPrice") is not None else row.get("cur_price")) + avg = _maybe_float(row.get("avgPrice") if row.get("avgPrice") is not None else row.get("avg_price")) + size = _maybe_float(row.get("size")) + if mark is None or avg is None or size is None: + continue + unrealized += (mark - avg) * size + return float(unrealized) + + +def _fetch_wallet_usdc_balance(user): + if not HAS_WEB3: + return None + rpc_url = (POLYGON_RPC_URL or "").strip() + if not rpc_url or not user: + return None + try: + w3 = Web3(Web3.HTTPProvider(rpc_url, request_kwargs={"timeout": 8})) + if not w3.is_connected(): + return None + usdc_addr = Web3.to_checksum_address(USDC_E_CONTRACT) + user_addr = Web3.to_checksum_address(user) + contract = w3.eth.contract( + address=usdc_addr, + abi=[ + { + "name": "balanceOf", + "type": "function", + "stateMutability": "view", + "inputs": [{"name": "account", "type": "address"}], + "outputs": [{"name": "", "type": "uint256"}], + }, + { + "name": "decimals", + "type": "function", + "stateMutability": "view", + "inputs": [], + "outputs": [{"name": "", "type": "uint8"}], + }, + ], + ) + raw = contract.functions.balanceOf(user_addr).call() + decimals = contract.functions.decimals().call() + return float(raw) / (10 ** int(decimals)) + except Exception: + return None + + +def _sync_dashboard_account_snapshot(user): + u = str(user or "").strip().lower() + if not u: + return False + wallet_positions = _fetch_wallet_positions(u) + wallet_closed = _fetch_wallet_closed_positions(u) + wallet_history = _build_wallet_history_items(wallet_closed) + raw_activity = _fetch_trade_activity(u, limit=500) + agg_trades = _build_market_aggregated_trades(raw_activity) + realized_pnl = _compute_wallet_realized_pnl(wallet_closed) + unrealized_pnl = _compute_wallet_unrealized_pnl(wallet_positions) + wallet_balance = _fetch_wallet_usdc_balance(u) + _dashboard_set( + wallet_balance=wallet_balance, + wallet_positions=list(wallet_positions)[:120], + wallet_history=list(wallet_history)[:200], + live_trades=list(agg_trades)[-300:], + live_positions_count=len(wallet_positions), + live_realized_pnl=float(realized_pnl), + live_unrealized_pnl=float(unrealized_pnl), + live_total_pnl=float(realized_pnl + unrealized_pnl), + ) + return True + + +def _fetch_wallet_positions(user): + if not user: + return [] + try: + r = requests.get( + f"{DATA_API}/positions", + params={"user": user, "sizeThreshold": 0}, + proxies=PROXIES if PROXIES else None, + timeout=12, + ) + if r.status_code == 200: + rows = r.json() + if isinstance(rows, list): + out = [] + for row in rows: + if not isinstance(row, dict): + continue + size = _to_float(row.get("size"), 0) + if size <= 0: + continue + if _to_bool(row.get("redeemable")) or _to_bool(row.get("mergeable")): + continue + out.append(row) + return out + except Exception: + pass + return [] + + +def _fetch_wallet_closed_positions(user): + if not user: + return [] + try: + r = requests.get( + f"{DATA_API}/closed-positions", + params={ + "user": user, + "limit": 200, + "offset": 0, + "sortBy": "TIMESTAMP", + "sortDirection": "DESC", + }, + proxies=PROXIES if PROXIES else None, + timeout=12, + ) + if r.status_code == 200: + rows = r.json() + if isinstance(rows, list): + return rows + except Exception: + pass + return [] + + +def _build_wallet_history_items(rows): + items = [] + for row in rows or []: + if not isinstance(row, dict): + continue + side = row.get("outcome") or row.get("side") or row.get("positionSide") or "-" + item = { + "time": row.get("endDate") or row.get("timestamp") or row.get("updatedAt") or "-", + "slug": row.get("slug") or row.get("marketSlug") or row.get("question") or "-", + "action": "CLOSE", + "side": side, + "price": row.get("avgPrice") if row.get("avgPrice") is not None else row.get("avg_price"), + "amount": row.get("size"), + "order_id": row.get("transactionHash") or row.get("id") or "", + "status": "closed", + "reason": "wallet_sync", + "pnl": row.get("realizedPnl") if row.get("realizedPnl") is not None else row.get("realized_pnl"), + } + items.append(item) + return items[:200] + +def load_state(): + """Load persisted bot state.""" + if not os.path.exists(STATE_FILE): + return _normalize_state({}) + try: + with open(STATE_FILE, "r", encoding="utf-8") as f: + return _normalize_state(json.load(f)) + except: + return _normalize_state({}) + +def save_state(state): + """Persist bot state + latest prices.""" + try: + state = _normalize_state(state) + # Snapshot prices into state file + state["ptb"] = price_data.get("ptb") + state["chainlink"] = price_data.get("btc") + state["binance"] = price_data.get("binance") + state["up_price"] = price_data.get("up_price") + state["down_price"] = price_data.get("down_price") + state["last_update"] = datetime.now().isoformat() + + with open(STATE_FILE, "w", encoding="utf-8") as f: + json.dump(state, f, indent=2) + except Exception as e: + log(f"save_state failed: {e}", "ERR") + +# ============== WebSocket feeds ============== +class BTCPriceListener: + """Binance BTC trades WebSocket.""" + def __init__(self): + self.ws = None + self.running = False + + def on_message(self, ws, message): + try: + data = json.loads(message) + if "p" in data: # trade price field + price_data["btc"] = float(data["p"]) + ts = time.time() + price_data["btc_update_ts"] = ts + price_data["last_update"] = ts + except: + pass + + def on_error(self, ws, error): + pass + + def on_close(self, ws, *args): + if self.running: + log("BTC feed disconnected, reconnecting in 5s...", "WARN") + time.sleep(5) + self.start() + + def on_open(self, ws): + log("BTC WebSocket connected", "OK") + + def start(self): + self.running = True + self.ws = websocket.WebSocketApp( + BINANCE_WSS, + on_open=self.on_open, + on_message=self.on_message, + on_error=self.on_error, + on_close=self.on_close + ) + threading.Thread(target=self.ws.run_forever, daemon=True).start() + + def stop(self): + self.running = False + if self.ws: + self.ws.close() + +class MarketPriceListener: + """CLOB market book / price_change for UP/DOWN.""" + def __init__(self, up_token, down_token): + self.up_token = up_token + self.down_token = down_token + self.ws = None + self.running = False + + def on_message(self, ws, message): + try: + data = json.loads(message) + + # Message may be list or dict + items = data if isinstance(data, list) else [data] + + for item in items: + if not isinstance(item, dict): + continue + + event_type = item.get("event_type") + asset_id = item.get("asset_id") + + # Order book snapshot + if event_type == "book": + bids = item.get("bids") or [] + asks = item.get("asks") or [] + + if bids and asks: + best_bid = max([float(b["price"]) for b in bids], default=0) + best_ask = min([float(a["price"]) for a in asks], default=0) + mid_price = (best_bid + best_ask) / 2 + ts = time.time() + + if asset_id == self.up_token: + price_data["up_bid"] = best_bid + price_data["up_ask"] = best_ask + price_data["up_price"] = mid_price + price_data["up_update_ts"] = ts + price_data["last_update"] = ts + elif asset_id == self.down_token: + price_data["down_bid"] = best_bid + price_data["down_ask"] = best_ask + price_data["down_price"] = mid_price + price_data["down_update_ts"] = ts + price_data["last_update"] = ts + + # Incremental price_change + elif event_type == "price_change": + price_changes = item.get("price_changes", []) + if price_changes: + pc = price_changes[0] + best_bid = float(pc.get("best_bid", 0)) + best_ask = float(pc.get("best_ask", 0)) + + if best_bid > 0 and best_ask > 0: + mid_price = (best_bid + best_ask) / 2 + ts = time.time() + + if asset_id == self.up_token: + price_data["up_bid"] = best_bid + price_data["up_ask"] = best_ask + price_data["up_price"] = mid_price + price_data["up_update_ts"] = ts + price_data["last_update"] = ts + elif asset_id == self.down_token: + price_data["down_bid"] = best_bid + price_data["down_ask"] = best_ask + price_data["down_price"] = mid_price + price_data["down_update_ts"] = ts + price_data["last_update"] = ts + except: + pass + + def on_error(self, ws, error): + pass + + def on_close(self, ws, *args): + if self.running: + log("Market feed disconnected, reconnecting in 5s...", "WARN") + time.sleep(5) + self.start() + + def on_open(self, ws): + # Subscribe both outcome tokens + ws.send(json.dumps({ + "assets_ids": [self.up_token, self.down_token], + "type": "market" + })) + log("Market WebSocket connected", "OK") + + def start(self): + self.running = True + self.ws = websocket.WebSocketApp( + POLYMARKET_WSS, + on_open=self.on_open, + on_message=self.on_message, + on_error=self.on_error, + on_close=self.on_close + ) + threading.Thread(target=self.ws.run_forever, daemon=True).start() + + def stop(self): + self.running = False + if self.ws: + self.ws.close() + +# ============== CLOB client ============== +class Trader: + def __init__(self): + self.client = None + self.connected = False + self.address = None + + def connect(self): + """Connect py-clob client.""" + pk = os.getenv("PRIVATE_KEY") + if not pk: + log("PRIVATE_KEY not set", "ERR") + return False + + try: + if not pk.startswith("0x"): + pk = "0x" + pk + + log("Connecting CLOB client...") + temp = ClobClient(host="https://clob.polymarket.com", chain_id=137, key=pk) + self.address = temp.get_address() + log(f"Wallet: {self.address}") + + creds = temp.create_or_derive_api_creds() + funder = os.getenv("FUNDER_ADDRESS") or self.address + sig_type = int(os.getenv("SIGNATURE_TYPE", "2")) + + self.client = ClobClient( + host="https://clob.polymarket.com", + chain_id=137, + key=pk, + creds=creds, + signature_type=sig_type, + funder=funder + ) + self.connected = True + log("CLOB client connected", "OK") + return True + except Exception as e: + log(f"Connect failed: {e}", "ERR") + return False + + def place_order(self, token_id, side, price, size): + """Place limit order.""" + if not self.connected: + log("CLOB client not connected", "ERR") + return None + + try: + log(f"Order: {side} ${size} @ {price:.3f}", "TRADE") + + order_args = OrderArgs( + token_id=token_id, + price=price, + size=size, + side=BUY if side == "BUY" else SELL + ) + + signed_order = self.client.create_order(order_args) + resp = self.client.post_order(signed_order) + + if resp and resp.get("orderID"): + order_id = resp.get("orderID") + log(f"Order placed, id: {order_id}", "OK") + return order_id + else: + log("Order rejected", "ERR") + return None + except Exception as e: + log(f"Order error: {e}", "ERR") + return None + + def get_order_status(self, order_id): + """Poll order status.""" + if not self.connected or not order_id: + return None + + try: + order = self.client.get_order(order_id) + if order: + status = order.get("status", "").upper() + original_size = float(order.get("original_size", 0) or 0) + size_matched = float(order.get("size_matched", 0) or 0) + + return { + "status": status, + "original_size": original_size, + "size_matched": size_matched, + "filled": size_matched >= original_size if original_size > 0 else False + } + except Exception as e: + log(f"get_order failed: {e}", "WARN") + return None + + def cancel_order(self, order_id): + """Cancel open order.""" + if not self.connected or not order_id: + return False + + try: + log(f"Cancel order: {order_id}", "WARN") + resp = self.client.cancel(order_id) + if resp: + log("Order canceled", "OK") + return True + else: + log("Cancel failed", "ERR") + return False + except Exception as e: + log(f"Cancel error: {e}", "ERR") + return False + +class AutoRedeemer: + def __init__(self, private_key, funder_address): + self.enabled = bool(AUTO_REDEEM) + self.private_key = (private_key or "").strip() + if self.private_key and not self.private_key.startswith("0x"): + self.private_key = "0x" + self.private_key + self.funder_address = (funder_address or "").strip() + self.scan_addresses = [] + self.last_try_by_condition = {} + self.last_pending_signature = "" + self.last_pending_log_ts = 0.0 + self.running = False + self.thread = None + self.relayer_client = None + self.relayer_error = "" + self.last_pending_count = 0 + self.last_claimable_count = 0 + self.last_result = {} + self.last_error = "" + + if not self.enabled: + _dashboard_set(auto_redeem={"enabled": False, "pending_count": 0, "claimable_count": 0, "last_result": {}, "last_error": ""}) + return + if not HAS_WEB3: + log("Auto-redeem disabled: web3 not installed", "WARN", force=True) + self.enabled = False + _dashboard_set(auto_redeem={"enabled": False, "pending_count": 0, "claimable_count": 0, "last_result": {}, "last_error": "web3 missing"}) + return + if not self.private_key: + log("Auto-redeem disabled: PRIVATE_KEY missing", "WARN", force=True) + self.enabled = False + _dashboard_set(auto_redeem={"enabled": False, "pending_count": 0, "claimable_count": 0, "last_result": {}, "last_error": "PRIVATE_KEY missing"}) + return + if not self.funder_address: + log("Auto-redeem disabled: FUNDER_ADDRESS missing (proxy wallet)", "WARN", force=True) + self.enabled = False + _dashboard_set(auto_redeem={"enabled": False, "pending_count": 0, "claimable_count": 0, "last_result": {}, "last_error": "FUNDER_ADDRESS missing"}) + return + if not (POLY_BUILDER_API_KEY and POLY_BUILDER_SECRET and POLY_BUILDER_PASSPHRASE): + log("Auto-redeem disabled: POLY_BUILDER_API_KEY/SECRET/PASSPHRASE missing", "WARN", force=True) + self.enabled = False + _dashboard_set(auto_redeem={"enabled": False, "pending_count": 0, "claimable_count": 0, "last_result": {}, "last_error": "Builder API creds missing"}) + return + + self.scan_addresses = [self.funder_address] + + client, err = self._create_relayer_client() + if client is None: + log(f"Auto-redeem disabled: relayer init failed {err}", "ERR", force=True) + self.enabled = False + _dashboard_set(auto_redeem={"enabled": False, "pending_count": 0, "claimable_count": 0, "last_result": {}, "last_error": str(err)}) + return + self.relayer_client = client + + def _normalize_condition_id(self, value): + s = str(value or "").strip().lower() + if not s: + return "" + if s.startswith("0x"): + s = s[2:] + if len(s) != 64: + return "" + try: + int(s, 16) + except Exception: + return "" + return "0x" + s + + def _fetch_positions(self, user): + try: + r = requests.get( + f"{DATA_API}/positions", + params={"user": user, "sizeThreshold": 0}, + proxies=PROXIES if PROXIES else None, + timeout=12, + ) + if r.status_code == 200: + data = r.json() + if isinstance(data, list): + return data + except Exception: + pass + return [] + + def _create_relayer_client(self): + try: + import inspect + import py_builder_relayer_client.client as rel_mod + from py_builder_relayer_client.client import RelayClient + try: + from py_builder_signing_sdk import BuilderConfig, BuilderApiKeyCreds + except Exception: + from py_builder_signing_sdk.config import BuilderConfig, BuilderApiKeyCreds + + cfg = BuilderConfig( + local_builder_creds=BuilderApiKeyCreds( + key=POLY_BUILDER_API_KEY, + secret=POLY_BUILDER_SECRET, + passphrase=POLY_BUILDER_PASSPHRASE, + ) + ) + + args = [RELAYER_URL, 137, self.private_key, cfg] + init_params = inspect.signature(RelayClient.__init__).parameters + if len(init_params) >= 6: + tx_enum = getattr(rel_mod, "RelayerTxType", None) or getattr(rel_mod, "TransactionType", None) + tx_value = None + if tx_enum is not None: + if RELAYER_TX_TYPE == "PROXY" and hasattr(tx_enum, "PROXY"): + tx_value = getattr(tx_enum, "PROXY") + elif hasattr(tx_enum, "SAFE"): + tx_value = getattr(tx_enum, "SAFE") + elif hasattr(tx_enum, "SAFE_CREATE"): + tx_value = getattr(tx_enum, "SAFE_CREATE") + if tx_value is not None: + args.append(tx_value) + + return RelayClient(*args), "" + except Exception as e: + return None, str(e) + + def _collect_redeemable(self): + pending = [] + seen = set() + claimable = [] + + for owner in self.scan_addresses: + rows = self._fetch_positions(owner) + owner_l = owner.lower() + for row in rows: + if not isinstance(row, dict): + continue + size = row.get("size") + try: + size_f = float(size or 0) + except Exception: + size_f = 0.0 + if size_f <= 0: + continue + + redeemable = bool(row.get("redeemable") or row.get("mergeable")) + if not redeemable: + continue + + cid = self._normalize_condition_id( + row.get("conditionId") or row.get("condition_id") + ) + if not cid: + continue + + key = owner_l + "|" + cid + if key in seen: + continue + seen.add(key) + pending.append({"owner": owner, "condition_id": cid}) + + if owner_l == self.funder_address.lower() and cid not in claimable: + claimable.append(cid) + + return pending, claimable + + def _redeem_condition(self, condition_id): + try: + from py_builder_relayer_client.models import SafeTransaction, OperationType + + ctf_addr = Web3.to_checksum_address(CTF_CONTRACT) + usdc_addr = Web3.to_checksum_address(USDC_E_CONTRACT) + contract = Web3().eth.contract( + address=ctf_addr, + abi=[{ + "name": "redeemPositions", + "type": "function", + "stateMutability": "nonpayable", + "inputs": [ + {"name": "collateralToken", "type": "address"}, + {"name": "parentCollectionId", "type": "bytes32"}, + {"name": "conditionId", "type": "bytes32"}, + {"name": "indexSets", "type": "uint256[]"}, + ], + "outputs": [], + }], + ) + cond_bytes = bytes.fromhex(condition_id[2:]) + data = contract.encode_abi( + abi_element_identifier="redeemPositions", + args=[usdc_addr, b"\x00" * 32, cond_bytes, [1, 2]], + ) + op_call = getattr(OperationType, "Call", None) + if op_call is None: + op_call = list(OperationType)[0] + tx = SafeTransaction(to=str(ctf_addr), operation=op_call, data=str(data), value="0") + + def execute_once(): + resp = self.relayer_client.execute([tx], f"Redeem {condition_id}") + result = resp.wait() + txh = str(getattr(resp, "transaction_hash", "") or "") + state = "" + if isinstance(result, dict): + txh = str(result.get("transaction_hash") or result.get("transactionHash") or txh) + state = str(result.get("state") or "") + else: + txh = str(getattr(result, "transaction_hash", "") or getattr(result, "transactionHash", "") or txh) + state = str(getattr(result, "state", "") or "") + if result is None: + return False, txh, "relayer_not_confirmed" + if state and state not in ["STATE_CONFIRMED", "STATE_MINED", "STATE_EXECUTED"]: + return False, txh, f"state={state}" + return True, txh, "" + + try: + return execute_once() + except Exception as e: + msg = str(e) + low = msg.lower() + if "expected safe" in low and "not deployed" in low: + dep = self.relayer_client.deploy() + dep.wait() + return execute_once() + return False, "", msg + except Exception as e: + return False, "", str(e) + + def scan_once(self): + if not self.enabled: + return + + pending, claimable = self._collect_redeemable() + now = time.time() + self.last_pending_count = len(pending) + self.last_claimable_count = len(claimable) + _dashboard_set(auto_redeem={ + "enabled": self.enabled, + "pending_count": self.last_pending_count, + "claimable_count": self.last_claimable_count, + "last_result": dict(self.last_result or {}), + "last_error": self.last_error, + "scan_interval": REDEEM_SCAN_INTERVAL, + }) + + if pending: + signature = "|".join([f"{x['owner']}:{x['condition_id']}" for x in pending]) + if signature != self.last_pending_signature or (now - self.last_pending_log_ts) >= REDEEM_PENDING_LOG_INTERVAL: + self.last_pending_signature = signature + self.last_pending_log_ts = now + owners = sorted(list({x["owner"] for x in pending})) + owner_text = ", ".join(owners[:3]) + if len(owners) > 3: + owner_text += f" +{len(owners) - 3} more" + log(f"Redeemable pending: {len(pending)}, relayer-claimable: {len(claimable)}, owners: {owner_text}", "WARN", force=True) + + if not claimable: + return + + processed = 0 + for cid in claimable: + t0 = self.last_try_by_condition.get(cid, 0) + if now - t0 < REDEEM_RETRY_INTERVAL: + continue + self.last_try_by_condition[cid] = now + + ok, tx_hash, err = self._redeem_condition(cid) + if ok: + log(f"Relayer redeem ok: {cid} | tx {tx_hash}", "TRADE", force=True) + self.last_error = "" + self.last_result = { + "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": True, + "condition_id": cid, + "tx": tx_hash, + "message": "ok", + } + else: + log(f"Relayer redeem failed: {cid} | {err}", "ERR", force=True) + self.last_error = str(err) + self.last_result = { + "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": False, + "condition_id": cid, + "tx": tx_hash, + "message": str(err), + } + + _dashboard_set(auto_redeem={ + "enabled": self.enabled, + "pending_count": self.last_pending_count, + "claimable_count": self.last_claimable_count, + "last_result": dict(self.last_result or {}), + "last_error": self.last_error, + "scan_interval": REDEEM_SCAN_INTERVAL, + }) + _sync_dashboard_account_snapshot(self.funder_address) + + processed += 1 + if processed >= REDEEM_MAX_PER_SCAN: + break + + def _loop(self): + while self.running: + try: + self.scan_once() + except Exception as e: + log(f"Auto-redeem scan error: {e}", "ERR", force=True) + for _ in range(REDEEM_SCAN_INTERVAL): + if not self.running: + break + time.sleep(1) + + def start(self): + if not self.enabled: + return + if self.running: + return + self.running = True + self.thread = threading.Thread(target=self._loop, daemon=True) + self.thread.start() + log(f"Auto-redeem on: scan every {REDEEM_SCAN_INTERVAL}s", "OK", force=True) + _dashboard_set(auto_redeem={ + "enabled": self.enabled, + "pending_count": self.last_pending_count, + "claimable_count": self.last_claimable_count, + "last_result": dict(self.last_result or {}), + "last_error": self.last_error, + "scan_interval": REDEEM_SCAN_INTERVAL, + }) + + def stop(self): + self.running = False + +# ============== Main loop ============== +def main(): + start_web_server() + if WEB_ENABLED: + log(f"Dashboard: http://{WEB_HOST}:{WEB_PORT}", "OK", force=True) + + print("\n" + "="*60) + print(f" Polymarket BTC {_btc_market_minutes}m auto-trader") + print("="*60) + print(f" Simulation mode: {'on (paper, no CLOB)' if SIMULATION_MODE else 'off'}") + print(f" Auto trade: {'on' if AUTO_TRADE else 'off'}") + print(f" BTC market window: {_btc_market_minutes}m (config BTC_MARKET_MINUTES or dashboard)") + print(f" Trading analysis log: {TRADING_ANALYSIS_LOG}") + print(f" Auto redeem: {'on' if AUTO_REDEEM and not SIMULATION_MODE else 'off'}") + _init_trading_analysis_session() + log(f"Trading analysis log ready (append-only): {TRADING_ANALYSIS_LOG}", "OK", force=True) + print(f" Order size: ${TRADE_AMOUNT}") + print(f" Rule 1: time≤{C1_TIME}s and diff≥${C1_DIFF} (UP prob {C1_MIN_PROB*100:.0f}-{C1_MAX_PROB*100:.0f}%)") + print(f" Rule 2: time≤{C2_TIME}s and diff≥${C2_DIFF} (DOWN prob {C2_MIN_PROB*100:.0f}-{C2_MAX_PROB*100:.0f}%)") + print(f" Rule 3: time≤{C3_TIME}s and diff≥${C3_DIFF} (UP prob {C3_MIN_PROB*100:.0f}-{C3_MAX_PROB*100:.0f}%)") + print(f" Rule 4: time≤{C4_TIME}s and diff≥${C4_DIFF} (DOWN prob {C4_MIN_PROB*100:.0f}-{C4_MAX_PROB*100:.0f}%)") + print(f" TP/SL: prob-based (SL {STOP_LOSS_PROB_PCT*100:.0f}%, RR≈{TAKE_PROFIT_RR:.2f}, TP cap {TAKE_PROFIT_CAP*100:.1f}%)") + print(f" Cancel after: {ORDER_TIMEOUT_SEC}s unfilled") + print(f" Slippage cap: {SLIPPAGE_THRESHOLD*100:.0f}%") + print(f" Max retries / market: {MAX_RETRY_PER_MARKET}") + print(f" Chase step: +{BUY_RETRY_STEP*100:.1f}% per retry") + print(f" TP retries: up to {TAKE_PROFIT_RETRY_MAX}, step +{TAKE_PROFIT_RETRY_STEP*100:.1f}%") + print(f" Stop: entry prob down {STOP_LOSS_PROB_PCT*100:.0f}%") + print(f" Stale data skip: >{MARKET_DATA_MAX_LAG_SEC:.1f}s") + print(f" Loop interval: {LOOP_INTERVAL_SEC:.2f}s") + print("="*60 + "\n") + + trader = Trader() + redeemer = AutoRedeemer(os.getenv("PRIVATE_KEY"), os.getenv("FUNDER_ADDRESS")) + if SIMULATION_MODE: + log("SIMULATION_MODE: paper trading — instant fills, no CLOB orders, no redeem", "OK", force=True) + elif AUTO_TRADE: + if not trader.connect(): + log("Cannot connect CLOB client, exiting", "ERR", force=True) + return + if not SIMULATION_MODE: + redeemer.start() + else: + _dashboard_set(auto_redeem={"enabled": False, "pending_count": 0, "claimable_count": 0, "last_result": {}, "last_error": "simulation mode"}) + + init_state = load_state() + _dashboard_set( + position=dict(init_state.get("position") or {}), + pending_order=_dashboard_pending_order_from_state(init_state), + last_order=dict(init_state.get("last_order") or {}), + trade_history=list(init_state.get("trade_history") or []), + wallet_balance=None, + wallet_positions=[], + wallet_history=[], + live_trades=[], + live_positions_count=0, + live_realized_pnl=0.0, + live_unrealized_pnl=0.0, + live_total_pnl=0.0, + cumulative_realized_pnl=float(init_state.get("cumulative_realized_pnl") or 0.0), + simulation_mode=SIMULATION_MODE, + ) + + log("Starting price feeds...", "INFO", force=True) + + last_slug = None + market_listener = None + first_display = True + last_chainlink_update = 0 + last_account_sync = 0.0 + last_market_fetch = 0.0 + last_stale_log_ts = 0.0 + dashboard_user = (os.getenv("FUNDER_ADDRESS", "") or "").strip().lower() + if not dashboard_user: + dashboard_user = (os.getenv("PRIVATE_KEY_ADDRESS", "") or "").strip().lower() + if not SIMULATION_MODE and AUTO_TRADE and trader.address: + dashboard_user = ((os.getenv("FUNDER_ADDRESS", "") or trader.address) or "").strip().lower() + _trigger_market_refresh() + _trigger_account_sync(dashboard_user) + + try: + while True: + now = time.time() + + # Refresh reference prices async so the loop stays responsive + if now - last_chainlink_update > 5: + _trigger_price_refresh() + last_chainlink_update = now + + # Market metadata refresh async + if now - last_market_fetch >= MARKET_META_REFRESH_SEC: + _trigger_market_refresh() + last_market_fetch = now + + market = None + market_data_cache = _get_market_cache() + if market_data_cache: + try: + end_ts = datetime.fromisoformat(str(market_data_cache.get("end", "")).replace("Z", "+00:00")).timestamp() + remaining_live = int(end_ts - now) + except Exception: + remaining_live = 0 + if remaining_live <= 0: + _clear_market_cache() + last_market_fetch = 0.0 + else: + market = dict(market_data_cache) + market["remaining"] = remaining_live + + if now - last_account_sync >= DASHBOARD_ACCOUNT_SYNC_SEC: + _trigger_account_sync(dashboard_user) + last_account_sync = now + + if not market: + state_snapshot = load_state() + _dashboard_set( + market={"slug": "", "remaining": 0, "status": "waiting"}, + prices={ + "ptb": price_data.get("ptb"), + "chainlink_btc": price_data.get("btc"), + "binance_btc": price_data.get("binance"), + "up_price": price_data.get("up_price"), + "down_price": price_data.get("down_price"), + "diff": None, + "diff_abs": None, + }, + position=dict(state_snapshot.get("position") or {}), + pending_order=_dashboard_pending_order_from_state(state_snapshot), + last_order=dict(state_snapshot.get("last_order") or {}), + trade_history=list(state_snapshot.get("trade_history") or []), + btc_market_minutes=_btc_market_minutes, + cumulative_realized_pnl=float(state_snapshot.get("cumulative_realized_pnl") or 0.0), + simulation_mode=SIMULATION_MODE, + ) + if first_display: + print("\nWaiting for active market...") + if price_data["btc"]: + print(f"BTC (Chainlink): ${price_data['btc']:,.2f}") + time.sleep(LOOP_INTERVAL_SEC) + continue + + slug = market["slug"] + remaining = market["remaining"] + + # Market rollover + if last_slug and slug != last_slug: + if market_listener: + market_listener.stop() + + state = load_state() + state.pop("position", None) + state.pop("last_order", None) + state.pop("take_profit_order", None) + save_state(state) + + market_listener = MarketPriceListener(market["up_token"], market["down_token"]) + market_listener.start() + + price_data["ptb"] = None + + first_display = True + + time.sleep(2) + + elif not last_slug: + market_listener = MarketPriceListener(market["up_token"], market["down_token"]) + market_listener.start() + time.sleep(2) + + last_slug = slug + + # PTB from crypto-price API + if not price_data["ptb"]: + crypto_data = get_crypto_price_api(market["start"], market["end"]) + if crypto_data.get("openPrice"): + price_data["ptb"] = crypto_data["openPrice"] + elif crypto_data.get("closePrice"): + price_data["ptb"] = crypto_data["closePrice"] + log(f"Using prior window closePrice as PTB: {price_data['ptb']}", "INFO") + + # Live book mid from WebSocket + btc = _to_float(price_data.get("btc"), 0.0) + ptb = _to_float(price_data.get("ptb"), 0.0) + up_price = _to_float(price_data.get("up_price") if price_data.get("up_price") is not None else market.get("up_price"), 0.0) + down_price = _to_float(price_data.get("down_price") if price_data.get("down_price") is not None else market.get("down_price"), 0.0) + up_bid = _maybe_float(price_data.get("up_bid")) + up_ask = _maybe_float(price_data.get("up_ask")) + down_bid = _maybe_float(price_data.get("down_bid")) + down_ask = _maybe_float(price_data.get("down_ask")) + + # Use ask side for buys (executable) + up_entry_price = up_ask if (up_ask is not None and up_ask > 0) else up_price + down_entry_price = down_ask if (down_ask is not None and down_ask > 0) else down_price + + # Diff vs PTB + diff = btc - ptb if (btc > 0 and ptb > 0) else 0 + diff_abs = abs(diff) + _dashboard_set( + market={ + "slug": slug, + "remaining": remaining, + "remaining_text": f"{remaining//60}m {remaining%60}s", + "start": market.get("start"), + "end": market.get("end"), + "status": "active", + }, + prices={ + "ptb": ptb if ptb > 0 else None, + "chainlink_btc": btc if btc > 0 else None, + "binance_btc": (price_data.get("binance") or None), + "up_price": up_price, + "down_price": down_price, + "up_bid": up_bid, + "up_ask": up_ask, + "down_bid": down_bid, + "down_ask": down_ask, + "diff": diff if (btc > 0 and ptb > 0) else None, + "diff_abs": diff_abs if (btc > 0 and ptb > 0) else None, + "updated_ts": time.time(), + }, + btc_market_minutes=_btc_market_minutes, + ) + + state_snapshot = load_state() + _dashboard_set( + position=dict(state_snapshot.get("position") or {}), + pending_order=_dashboard_pending_order_from_state(state_snapshot), + last_order=dict(state_snapshot.get("last_order") or {}), + trade_history=list(state_snapshot.get("trade_history") or []), + cumulative_realized_pnl=float(state_snapshot.get("cumulative_realized_pnl") or 0.0), + simulation_mode=SIMULATION_MODE, + ) + + if first_display: + print("\n" + "="*90) + print(f"Market: {slug}") + print(f"Time left: {remaining//60}m {remaining%60}s") + print() + print("┌────────────────────────┬────────────────────────┬────────────────────────┐") + print("│ PTB │ Chainlink (ref) │ Binance (ref) │") + ptb_display = f"${ptb:,.2f}" if ptb > 0 else "fetching..." + btc_display = f"${btc:,.2f}" if btc > 0 else "fetching..." + binance = price_data.get("binance") or 0 + binance_display = f"${binance:,.2f}" if binance > 0 else "fetching..." + print(f"│ {ptb_display:22s} │ {btc_display:22s} │ {binance_display:22s} │") + print("├────────────────────────┴────────────────────────┴────────────────────────┤") + print("│ Market mid │") + print(f"│ UP: {up_price*100:.2f}% DOWN: {down_price*100:.2f}% │") + print("├──────────────────────────────────────────────────────────────────────────┤") + print("│ Live diff (Chainlink - PTB) │") + if btc > 0 and ptb > 0: + diff_display = f"{diff:+.0f} USD" + else: + diff_display = "waiting for prices..." + print(f"│ {diff_display:72s} │") + print("└──────────────────────────────────────────────────────────────────────────┘") + print() + print("="*90) + print("Live log:") + print("="*90) + first_display = False + + ptb_str = f"${ptb:,.0f}" if ptb > 0 else "..." + btc_str = f"${btc:,.0f}" if btc > 0 else "..." + binance = price_data.get("binance") or 0 + binance_str = f"${binance:,.0f}" if binance > 0 else "N/A" + diff_str = f"{diff:+.0f}" if (btc > 0 and ptb > 0) else "N/A" + status = f"[{datetime.now().strftime('%H:%M:%S')}] left {remaining//60:02d}m{remaining%60:02d}s | CL:{btc_str} | BN:{binance_str} | PTB:{ptb_str} | diff:{diff_str} | UP:{up_price*100:.1f}% DOWN:{down_price*100:.1f}%" + print(f"\r{status}" + " "*10, end="", flush=True) + + # Evaluate trigger rules + triggered = False + condition = None + side = None + desired_side = None + price = None + token = None + + if remaining <= C1_TIME and diff >= C1_DIFF: + prob = up_entry_price + if C1_MIN_PROB <= prob <= C1_MAX_PROB: + triggered = True + desired_side = "UP" + condition = f"R1: time≤{C1_TIME}s & diff≥${C1_DIFF} (UP {prob*100:.0f}%)" + else: + log(f"R1 skip: UP prob {prob*100:.1f}% not in {C1_MIN_PROB*100:.0f}–{C1_MAX_PROB*100:.0f}%", "INFO") + + elif remaining <= C2_TIME and diff <= -C2_DIFF: + prob = down_entry_price + if C2_MIN_PROB <= prob <= C2_MAX_PROB: + triggered = True + desired_side = "DOWN" + condition = f"R2: time≤{C2_TIME}s & diff≤-${C2_DIFF} (DOWN {prob*100:.0f}%)" + else: + log(f"R2 skip: DOWN prob {prob*100:.1f}% not in {C2_MIN_PROB*100:.0f}–{C2_MAX_PROB*100:.0f}%", "INFO") + + elif remaining <= C3_TIME and diff >= C3_DIFF: + prob = up_entry_price + if C3_MIN_PROB <= prob <= C3_MAX_PROB: + triggered = True + desired_side = "UP" + condition = f"R3: time≤{C3_TIME}s & diff≥${C3_DIFF} (UP {prob*100:.0f}%)" + else: + log(f"R3 skip: UP prob {prob*100:.1f}% not in {C3_MIN_PROB*100:.0f}–{C3_MAX_PROB*100:.0f}%", "INFO") + + elif remaining <= C4_TIME and diff <= -C4_DIFF: + prob = down_entry_price + if C4_MIN_PROB <= prob <= C4_MAX_PROB: + triggered = True + desired_side = "DOWN" + condition = f"R4: time≤{C4_TIME}s & diff≤-${C4_DIFF} (DOWN {prob*100:.0f}%)" + else: + log(f"R4 skip: DOWN prob {prob*100:.1f}% not in {C4_MIN_PROB*100:.0f}–{C4_MAX_PROB*100:.0f}%", "INFO") + + if triggered: + side = desired_side or ("UP" if diff > 0 else "DOWN") + price = up_entry_price if side == "UP" else down_entry_price + token = market["up_token"] if side == "UP" else market["down_token"] + + # Skip stale book / BTC to avoid chasing on lag + side_ts = _to_float(price_data.get("up_update_ts" if side == "UP" else "down_update_ts"), 0.0) + btc_ts = _to_float(price_data.get("btc_update_ts"), 0.0) + side_age = now - side_ts if side_ts > 0 else 999.0 + btc_age = now - btc_ts if btc_ts > 0 else 999.0 + if price <= 0: + triggered = False + condition = None + elif side_age > MARKET_DATA_MAX_LAG_SEC or btc_age > MARKET_DATA_MAX_LAG_SEC: + if now - last_stale_log_ts >= 2: + log( + f"Stale data skip: {side} book age {side_age:.2f}s, BTC age {btc_age:.2f}s (max {MARKET_DATA_MAX_LAG_SEC:.1f}s)", + "WARN", + ) + last_stale_log_ts = now + triggered = False + condition = None + + # Order / position state + state = load_state() + last_order = state.get("last_order", {}) + order_key = f"{slug}|{side}" + + # Track working orders + pending_order = state.get("pending_order") + _dashboard_set( + position=dict(state.get("position") or {}), + pending_order=_dashboard_pending_order_from_state(state), + last_order=dict(last_order or {}), + ) + if pending_order and (not SIMULATION_MODE) and AUTO_TRADE and trader.connected: + order_id = pending_order.get("order_id") + order_time = pending_order.get("time") + + if order_time: + elapsed = (datetime.now() - datetime.fromisoformat(order_time)).total_seconds() + if elapsed > ORDER_TIMEOUT_SEC: + order_status = trader.get_order_status(order_id) + if order_status and not order_status.get("filled"): + log(f"Order timeout, cancel & retry (id {order_id})", "TRADE") + trader.cancel_order(order_id) + state.pop("pending_order", None) + save_state(state) + _emit_trading_analysis( + "BUY_CANCEL_TIMEOUT", + slug=slug, + order_id=order_id, + status="buy", + shares_type=pending_order.get("side"), + share_price=float(pending_order.get("price") or 0) or None, + btc_price=btc if btc > 0 else None, + chainlink_btc=btc if btc > 0 else None, + ptb=ptb if ptb > 0 else None, + btc_minus_ptb=_btc_ptb_snapshot(btc if btc > 0 else None, ptb if ptb > 0 else None), + remaining_sec=remaining, + pnl_trade_usd=None, + pnl_total_usd=_to_float(state.get("cumulative_realized_pnl"), 0.0), + ) + _dashboard_set( + position=dict(state.get("position") or {}), + pending_order=_dashboard_pending_order_from_state(state), + last_order=dict(state.get("last_order") or {}), + ) + elif order_status and order_status.get("filled"): + filled_side = pending_order.get("side") or side + filled_price = float(pending_order.get("price") or price or 0) + filled_slug = pending_order.get("slug") or slug + log(f"Filled {filled_side} @ {filled_price*100:.2f}% ({filled_slug})", "TRADE") + state.pop("pending_order", None) + filled_size = float(order_status.get("size_matched") or order_status.get("original_size") or TRADE_AMOUNT) + state["position"] = { + "slug": filled_slug, + "side": filled_side, + "entry_price": filled_price, + "entry_diff": diff_abs, + "size": filled_size, + } + cum = _to_float(state.get("cumulative_realized_pnl"), 0.0) + state = _append_trade_history(state, { + "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "slug": filled_slug, + "action": "BUY", + "side": filled_side, + "price": filled_price, + "amount": TRADE_AMOUNT, + "shares": filled_size, + "order_size_usdc": TRADE_AMOUNT, + "order_id": order_id, + "status": "filled", + "reason": "pending_filled", + "diff": diff, + "btc": btc if btc > 0 else None, + "ptb": ptb if ptb > 0 else None, + "btc_minus_ptb": _btc_ptb_snapshot(btc if btc > 0 else None, ptb if ptb > 0 else None), + "remaining_sec": remaining, + "cumulative_realized_pnl_usd": cum, + }) + _emit_trading_analysis( + "BUY_FILL", + action="BUY", + slug=filled_slug, + status="buy", + shares_type=filled_side, + share_price=filled_price, + share_amount=filled_size, + order_size_usdc=TRADE_AMOUNT, + order_id=order_id, + reason="pending_filled", + btc_price=btc if btc > 0 else None, + chainlink_btc=btc if btc > 0 else None, + ptb=ptb if ptb > 0 else None, + btc_minus_ptb=_btc_ptb_snapshot(btc if btc > 0 else None, ptb if ptb > 0 else None), + diff_rule=diff, + remaining_sec=remaining, + pnl_trade_usd=0.0, + pnl_total_usd=cum, + ) + save_state(state) + _dashboard_set( + position=dict(state.get("position") or {}), + pending_order=_dashboard_pending_order_from_state(state), + last_order=dict(state.get("last_order") or {}), + trade_history=list(state.get("trade_history") or []), + cumulative_realized_pnl=cum, + ) + _sync_dashboard_account_snapshot(dashboard_user) + + # New entry if flat and no working order + has_position = bool(state.get("position")) + retry_count = int(last_order.get("retry_count", 0) or 0) + same_key_retry = (last_order.get("key") == order_key) + can_place = (not pending_order) and (not has_position) and ((not same_key_retry) or (retry_count < MAX_RETRY_PER_MARKET)) + if can_place: + if same_key_retry and retry_count > 0: + last_price = _to_float(last_order.get("last_price"), price) + retry_cap_price = min(0.995, last_price + BUY_RETRY_STEP) + if price > retry_cap_price: + log( + f"Chase cap: {price*100:.2f}% > last {last_price*100:.2f}%+{BUY_RETRY_STEP*100:.2f}%, use {retry_cap_price*100:.2f}%", + "INFO", + ) + price = min(price, retry_cap_price) + + current_price = up_entry_price if side == "UP" else down_entry_price + if price > 0: + slippage = abs(current_price - price) / price + if slippage > SLIPPAGE_THRESHOLD: + log(f"Slippage too high: {slippage*100:.1f}% > {SLIPPAGE_THRESHOLD*100:.0f}%, skip", "WARN") + triggered = False + condition = None + + if triggered: + if same_key_retry and retry_count >= MAX_RETRY_PER_MARKET: + log(f"Max retries ({MAX_RETRY_PER_MARKET}) for {order_key}", "WARN") + triggered = False + condition = None + + if triggered: + log(f"Trigger: {condition} -> {side} @ {price*100:.1f}%", "TRADE") + + if SIMULATION_MODE: + sim_shares = _shares_from_usdc_buy(TRADE_AMOUNT, price) + if sim_shares <= 0: + log(f"[SIM] Buy skipped: invalid price {price}", "WARN") + else: + sim_oid = f"SIM-{int(time.time() * 1000)}" + state.pop("pending_order", None) + current_retry = retry_count if same_key_retry else 0 + state["last_order"] = { + "key": order_key, + "time": datetime.now().isoformat(), + "retry_count": current_retry + 1, + "last_price": price, + } + state["position"] = { + "slug": slug, + "side": side, + "entry_price": price, + "entry_diff": diff_abs, + "size": sim_shares, + } + cum = _to_float(state.get("cumulative_realized_pnl"), 0.0) + state = _append_trade_history(state, { + "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "slug": slug, + "action": "BUY", + "side": side, + "price": price, + "amount": TRADE_AMOUNT, + "shares": sim_shares, + "order_size_usdc": TRADE_AMOUNT, + "order_id": sim_oid, + "status": "filled", + "reason": condition, + "diff": diff, + "btc": btc if btc > 0 else None, + "ptb": ptb if ptb > 0 else None, + "btc_minus_ptb": _btc_ptb_snapshot(btc if btc > 0 else None, ptb if ptb > 0 else None), + "remaining_sec": remaining, + "cumulative_realized_pnl_usd": cum, + }) + _emit_trading_analysis( + "BUY_FILL", + action="BUY", + slug=slug, + status="buy", + shares_type=side, + share_price=price, + share_amount=sim_shares, + order_size_usdc=TRADE_AMOUNT, + order_id=sim_oid, + reason=condition, + btc_price=btc if btc > 0 else None, + chainlink_btc=btc if btc > 0 else None, + ptb=ptb if ptb > 0 else None, + btc_minus_ptb=_btc_ptb_snapshot(btc if btc > 0 else None, ptb if ptb > 0 else None), + diff_rule=diff, + remaining_sec=remaining, + pnl_trade_usd=0.0, + pnl_total_usd=cum, + ) + save_state(state) + _dashboard_set( + position=dict(state.get("position") or {}), + pending_order=_dashboard_pending_order_from_state(state), + last_order=dict(state.get("last_order") or {}), + trade_history=list(state.get("trade_history") or []), + cumulative_realized_pnl=cum, + ) + log( + f"[SIM] BUY {side} @ {price*100:.2f}% | USDC {TRADE_AMOUNT} | shares≈{sim_shares:.4f} | id {sim_oid}", + "TRADE", + ) + elif AUTO_TRADE and trader.connected: + order_id = trader.place_order(token, "BUY", price, TRADE_AMOUNT) + + if order_id: + state["pending_order"] = { + "order_id": order_id, + "time": datetime.now().isoformat(), + "slug": slug, + "side": side, + "price": price + } + current_retry = retry_count if same_key_retry else 0 + state["last_order"] = { + "key": order_key, + "time": datetime.now().isoformat(), + "retry_count": current_retry + 1, + "last_price": price, + } + cum = _to_float(state.get("cumulative_realized_pnl"), 0.0) + state = _append_trade_history(state, { + "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "slug": slug, + "action": "BUY", + "side": side, + "price": price, + "amount": TRADE_AMOUNT, + "order_size_usdc": TRADE_AMOUNT, + "order_id": order_id, + "status": "submitted", + "reason": condition, + "diff": diff, + "btc": btc if btc > 0 else None, + "ptb": ptb if ptb > 0 else None, + "btc_minus_ptb": _btc_ptb_snapshot(btc if btc > 0 else None, ptb if ptb > 0 else None), + "remaining_sec": remaining, + "cumulative_realized_pnl_usd": cum, + }) + _emit_trading_analysis( + "BUY_SUBMIT", + action="BUY", + slug=slug, + status="buy", + shares_type=side, + share_price=price, + order_size_usdc=TRADE_AMOUNT, + order_id=order_id, + reason=condition, + btc_price=btc if btc > 0 else None, + chainlink_btc=btc if btc > 0 else None, + ptb=ptb if ptb > 0 else None, + btc_minus_ptb=_btc_ptb_snapshot(btc if btc > 0 else None, ptb if ptb > 0 else None), + diff_rule=diff, + remaining_sec=remaining, + pnl_trade_usd=0.0, + pnl_total_usd=cum, + ) + save_state(state) + _dashboard_set( + pending_order=_dashboard_pending_order_from_state(state), + last_order=dict(state.get("last_order") or {}), + trade_history=list(state.get("trade_history") or []), + cumulative_realized_pnl=cum, + ) + _sync_dashboard_account_snapshot(dashboard_user) + log(f"Order submitted, watching id {order_id}", "TRADE") + else: + log(f"Order failed: {side} @ {price*100:.1f}%", "ERR") + current_retry = retry_count if same_key_retry else 0 + state["last_order"] = { + "key": order_key, + "time": datetime.now().isoformat(), + "retry_count": current_retry + 1, + "last_price": price, + } + cum = _to_float(state.get("cumulative_realized_pnl"), 0.0) + state = _append_trade_history(state, { + "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "slug": slug, + "action": "BUY", + "side": side, + "price": price, + "amount": TRADE_AMOUNT, + "order_id": "", + "status": "failed", + "reason": condition, + "diff": diff, + "btc": btc if btc > 0 else None, + "ptb": ptb if ptb > 0 else None, + "btc_minus_ptb": _btc_ptb_snapshot(btc if btc > 0 else None, ptb if ptb > 0 else None), + "remaining_sec": remaining, + "cumulative_realized_pnl_usd": cum, + }) + _emit_trading_analysis( + "BUY_FAILED", + action="BUY", + slug=slug, + status="buy", + shares_type=side, + share_price=price, + order_size_usdc=TRADE_AMOUNT, + reason=condition, + btc_price=btc if btc > 0 else None, + chainlink_btc=btc if btc > 0 else None, + ptb=ptb if ptb > 0 else None, + btc_minus_ptb=_btc_ptb_snapshot(btc if btc > 0 else None, ptb if ptb > 0 else None), + diff_rule=diff, + remaining_sec=remaining, + pnl_trade_usd=None, + pnl_total_usd=cum, + ) + save_state(state) + _dashboard_set( + last_order=dict(state.get("last_order") or {}), + trade_history=list(state.get("trade_history") or []), + cumulative_realized_pnl=cum, + ) + _sync_dashboard_account_snapshot(dashboard_user) + elif not SIMULATION_MODE: + log(f"Alert: consider BUY {side} @ {price*100:.1f}%", "TRADE") + current_retry = retry_count if same_key_retry else 0 + state["last_order"] = { + "key": order_key, + "time": datetime.now().isoformat(), + "retry_count": current_retry + 1, + "last_price": price, + } + _emit_trading_analysis( + "BUY_ALERT", + action="BUY", + slug=slug, + status="buy", + shares_type=side, + share_price=price, + order_size_usdc=TRADE_AMOUNT, + reason=condition, + btc_price=btc if btc > 0 else None, + chainlink_btc=btc if btc > 0 else None, + ptb=ptb if ptb > 0 else None, + btc_minus_ptb=_btc_ptb_snapshot(btc if btc > 0 else None, ptb if ptb > 0 else None), + diff_rule=diff, + remaining_sec=remaining, + pnl_trade_usd=None, + pnl_total_usd=_to_float(state.get("cumulative_realized_pnl"), 0.0), + ) + save_state(state) + _dashboard_set(last_order=dict(state.get("last_order") or {})) + + # Position TP / SL + state = load_state() + pos = state.get("position") + tp_order = state.get("take_profit_order") or {} + if pos and pos.get("slug") == slug: + pos_side = pos.get("side") + current_prob = up_price if pos_side == "UP" else down_price + position_size = max(0.001, _to_float(pos.get("size"), TRADE_AMOUNT)) + entry_prob = _maybe_float(pos.get("entry_price")) + stop_loss_triggered = False + stop_prob = None + tp_trigger_prob = None + tp_sell_price = None + if entry_prob is not None and entry_prob > 0: + stop_prob = max(0.0, entry_prob * (1.0 - STOP_LOSS_PROB_PCT)) + risk_abs = max(0.0, entry_prob - stop_prob) + tp_trigger_prob = min(TAKE_PROFIT_CAP, entry_prob + risk_abs * TAKE_PROFIT_RR) + if tp_trigger_prob <= entry_prob: + tp_trigger_prob = None + else: + # If TP capped, tighten stop to preserve RR + balanced_risk = (tp_trigger_prob - entry_prob) / TAKE_PROFIT_RR + balanced_stop_prob = max(0.0, entry_prob - balanced_risk) + if balanced_stop_prob > stop_prob: + stop_prob = balanced_stop_prob + tp_sell_price = tp_trigger_prob + stop_loss_triggered = (current_prob > 0) and (current_prob <= stop_prob) + + if SIMULATION_MODE and pos and pos.get("slug") == slug and entry_prob is not None and entry_prob > 0: + sim_exit = False + if stop_loss_triggered: + sell_x = (up_bid if pos_side == "UP" else down_bid) or (up_price if pos_side == "UP" else down_price) + ep = float(entry_prob) + xp = float(sell_x) + realized = position_size * (xp - ep) + cum = _to_float(state.get("cumulative_realized_pnl"), 0.0) + realized + state["cumulative_realized_pnl"] = cum + state = _append_trade_history(state, { + "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "slug": slug, + "action": "SELL", + "side": pos_side, + "price": xp, + "entry_price": ep, + "shares": position_size, + "amount": position_size * xp, + "order_id": f"SIM-SL-{int(time.time() * 1000)}", + "status": "filled", + "reason": "stop_loss", + "diff": diff, + "btc": btc if btc > 0 else None, + "ptb": ptb if ptb > 0 else None, + "btc_minus_ptb": _btc_ptb_snapshot(btc if btc > 0 else None, ptb if ptb > 0 else None), + "remaining_sec": remaining, + "realized_pnl_usd": realized, + "cumulative_realized_pnl_usd": cum, + }) + _emit_trading_analysis( + "SELL_CLOSE", + reason="stop_loss", + slug=slug, + action="SELL", + status="sell", + shares_type=pos_side, + share_price=xp, + share_amount=position_size, + entry_share_price=ep, + exit_share_price=xp, + notional_exit_usd=position_size * xp, + take_profit=tp_trigger_prob, + stop_loss=stop_prob, + btc_price=btc if btc > 0 else None, + chainlink_btc=btc if btc > 0 else None, + ptb=ptb if ptb > 0 else None, + btc_minus_ptb=_btc_ptb_snapshot(btc if btc > 0 else None, ptb if ptb > 0 else None), + diff_rule=diff, + remaining_sec=remaining, + order_id=f"SIM-SL-{int(time.time() * 1000)}", + pnl_trade_usd=realized, + pnl_total_usd=cum, + ) + state.pop("position", None) + state.pop("take_profit_order", None) + save_state(state) + _dashboard_set( + position={}, + pending_order=_dashboard_pending_order_from_state(state), + trade_history=list(state.get("trade_history") or []), + cumulative_realized_pnl=cum, + ) + log( + f"[SIM] Stop-loss {pos_side} @ {xp*100:.2f}% | PnL ${realized:+.4f} | cumulative ${cum:+.4f}", + "TRADE", + ) + sim_exit = True + elif ( + tp_trigger_prob is not None + and tp_sell_price + and current_prob > 0 + and current_prob >= tp_trigger_prob + ): + ep = float(entry_prob) + xp = float(tp_sell_price) + realized = position_size * (xp - ep) + cum = _to_float(state.get("cumulative_realized_pnl"), 0.0) + realized + state["cumulative_realized_pnl"] = cum + state = _append_trade_history(state, { + "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "slug": slug, + "action": "SELL", + "side": pos_side, + "price": xp, + "entry_price": ep, + "shares": position_size, + "amount": position_size * xp, + "order_id": f"SIM-TP-{int(time.time() * 1000)}", + "status": "filled", + "reason": "take_profit", + "diff": diff, + "btc": btc if btc > 0 else None, + "ptb": ptb if ptb > 0 else None, + "btc_minus_ptb": _btc_ptb_snapshot(btc if btc > 0 else None, ptb if ptb > 0 else None), + "remaining_sec": remaining, + "realized_pnl_usd": realized, + "cumulative_realized_pnl_usd": cum, + }) + _emit_trading_analysis( + "SELL_CLOSE", + reason="take_profit", + slug=slug, + action="SELL", + status="sell", + shares_type=pos_side, + share_price=xp, + share_amount=position_size, + entry_share_price=ep, + exit_share_price=xp, + notional_exit_usd=position_size * xp, + take_profit=tp_trigger_prob, + stop_loss=stop_prob, + btc_price=btc if btc > 0 else None, + chainlink_btc=btc if btc > 0 else None, + ptb=ptb if ptb > 0 else None, + btc_minus_ptb=_btc_ptb_snapshot(btc if btc > 0 else None, ptb if ptb > 0 else None), + diff_rule=diff, + remaining_sec=remaining, + order_id=f"SIM-TP-{int(time.time() * 1000)}", + pnl_trade_usd=realized, + pnl_total_usd=cum, + ) + state.pop("position", None) + state.pop("take_profit_order", None) + save_state(state) + _dashboard_set( + position={}, + pending_order=_dashboard_pending_order_from_state(state), + trade_history=list(state.get("trade_history") or []), + cumulative_realized_pnl=cum, + ) + log( + f"[SIM] Take-profit {pos_side} @ {xp*100:.2f}% | PnL ${realized:+.4f} | cumulative ${cum:+.4f}", + "TRADE", + ) + sim_exit = True + if sim_exit: + state = load_state() + pos = state.get("position") + tp_order = state.get("take_profit_order") or {} + + # Working TP order lifecycle (live only) + if ( + (not SIMULATION_MODE) + and tp_order + and tp_order.get("slug") == slug + and tp_order.get("side") == pos_side + and AUTO_TRADE + and trader.connected + ): + tp_order_id = tp_order.get("order_id") + tp_status = trader.get_order_status(tp_order_id) + if tp_status and tp_status.get("filled"): + tp_price = float(tp_order.get("price") or tp_sell_price or 0.0) + tp_amount = max(0.001, _to_float(tp_order.get("amount"), position_size)) + ep = _maybe_float(pos.get("entry_price")) or 0.0 + realized = tp_amount * (tp_price - ep) if ep else 0.0 + cum = _to_float(state.get("cumulative_realized_pnl"), 0.0) + realized + state["cumulative_realized_pnl"] = cum + state = _append_trade_history(state, { + "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "slug": slug, + "action": "SELL", + "side": pos_side, + "price": tp_price, + "entry_price": ep, + "shares": tp_amount, + "amount": tp_amount * tp_price, + "order_id": tp_order_id or "", + "status": "filled", + "reason": "take_profit", + "diff": diff, + "btc": btc if btc > 0 else None, + "ptb": ptb if ptb > 0 else None, + "btc_minus_ptb": _btc_ptb_snapshot(btc if btc > 0 else None, ptb if ptb > 0 else None), + "remaining_sec": remaining, + "realized_pnl_usd": realized, + "cumulative_realized_pnl_usd": cum, + }) + _emit_trading_analysis( + "SELL_CLOSE", + reason="take_profit", + slug=slug, + action="SELL", + status="sell", + shares_type=pos_side, + share_price=tp_price, + share_amount=tp_amount, + entry_share_price=ep, + exit_share_price=tp_price, + notional_exit_usd=tp_amount * tp_price, + take_profit=tp_trigger_prob, + stop_loss=stop_prob, + btc_price=btc if btc > 0 else None, + chainlink_btc=btc if btc > 0 else None, + ptb=ptb if ptb > 0 else None, + btc_minus_ptb=_btc_ptb_snapshot(btc if btc > 0 else None, ptb if ptb > 0 else None), + diff_rule=diff, + remaining_sec=remaining, + order_id=tp_order_id or "", + pnl_trade_usd=realized, + pnl_total_usd=cum, + ) + state.pop("take_profit_order", None) + state.pop("position", None) + save_state(state) + _dashboard_set( + position={}, + pending_order=_dashboard_pending_order_from_state(state), + trade_history=list(state.get("trade_history") or []), + cumulative_realized_pnl=cum, + ) + _sync_dashboard_account_snapshot(dashboard_user) + log(f"TP order filled: {pos_side} @ {tp_price*100:.2f}%", "TRADE") + pos = None + tp_order = {} + elif tp_status and tp_status.get("status") in ["CANCELED", "CANCELLED", "REJECTED", "EXPIRED"]: + log("TP order dead; will re-arm if price moves", "WARN") + state.pop("take_profit_order", None) + save_state(state) + _dashboard_set(pending_order=_dashboard_pending_order_from_state(state)) + tp_order = {} + + if ( + (not SIMULATION_MODE) + and pos + and (not tp_order) + and tp_trigger_prob is not None + and current_prob > 0 + and current_prob >= tp_trigger_prob + ): + log( + f"TP arm: entry {entry_prob*100:.1f}%, now {current_prob*100:.1f}%, target {tp_trigger_prob*100:.1f}% (RR≈{TAKE_PROFIT_RR:.2f})", + "TRADE", + ) + if AUTO_TRADE and trader.connected: + sell_token = market["up_token"] if pos_side == "UP" else market["down_token"] + tp_order_id = None + tp_submit_price = tp_sell_price + attempt_price = tp_sell_price + for attempt_idx in range(TAKE_PROFIT_RETRY_MAX): + tp_order_id = trader.place_order(sell_token, "SELL", attempt_price, position_size) + if tp_order_id: + tp_submit_price = attempt_price + break + if attempt_idx + 1 >= TAKE_PROFIT_RETRY_MAX: + break + next_price = min(TAKE_PROFIT_CAP, attempt_price + TAKE_PROFIT_RETRY_STEP) + if next_price <= attempt_price + 1e-9: + break + log( + f"TP retry {attempt_idx+2}/{TAKE_PROFIT_RETRY_MAX}: bump to {next_price*100:.1f}%", + "WARN", + ) + attempt_price = next_price + if tp_order_id: + state["take_profit_order"] = { + "order_id": tp_order_id, + "time": datetime.now().isoformat(), + "slug": slug, + "side": pos_side, + "price": tp_submit_price, + "amount": position_size, + "action": "SELL", + "reason": "take_profit", + } + cum = _to_float(state.get("cumulative_realized_pnl"), 0.0) + state = _append_trade_history(state, { + "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "slug": slug, + "action": "SELL", + "side": pos_side, + "price": tp_submit_price, + "amount": position_size, + "order_id": tp_order_id, + "status": "submitted", + "reason": "take_profit", + "diff": diff, + "btc": btc if btc > 0 else None, + "ptb": ptb if ptb > 0 else None, + "btc_minus_ptb": _btc_ptb_snapshot(btc if btc > 0 else None, ptb if ptb > 0 else None), + "remaining_sec": remaining, + "cumulative_realized_pnl_usd": cum, + }) + _emit_trading_analysis( + "SELL_SUBMIT", + reason="take_profit", + slug=slug, + action="SELL", + status="sell", + shares_type=pos_side, + share_price=tp_submit_price, + share_amount=position_size, + take_profit=tp_trigger_prob, + stop_loss=stop_prob, + order_id=tp_order_id, + btc_price=btc if btc > 0 else None, + chainlink_btc=btc if btc > 0 else None, + ptb=ptb if ptb > 0 else None, + btc_minus_ptb=_btc_ptb_snapshot(btc if btc > 0 else None, ptb if ptb > 0 else None), + diff_rule=diff, + remaining_sec=remaining, + pnl_trade_usd=None, + pnl_total_usd=cum, + ) + save_state(state) + _dashboard_set( + pending_order=_dashboard_pending_order_from_state(state), + trade_history=list(state.get("trade_history") or []), + cumulative_realized_pnl=cum, + ) + _sync_dashboard_account_snapshot(dashboard_user) + tp_order = dict(state.get("take_profit_order") or {}) + log(f"TP order live id {tp_order_id}", "TRADE") + else: + log(f"TP order failed: {pos_side} @ {attempt_price*100:.1f}%", "ERR") + elif not SIMULATION_MODE: + log(f"Alert: consider SELL {pos_side} @ {tp_sell_price*100:.1f}% (size {position_size:.4f})", "TRADE") + _emit_trading_analysis( + "SELL_ALERT", + reason="take_profit", + slug=slug, + action="SELL", + status="sell", + shares_type=pos_side, + share_price=tp_sell_price, + share_amount=position_size, + take_profit=tp_trigger_prob, + stop_loss=stop_prob, + btc_price=btc if btc > 0 else None, + chainlink_btc=btc if btc > 0 else None, + ptb=ptb if ptb > 0 else None, + btc_minus_ptb=_btc_ptb_snapshot(btc if btc > 0 else None, ptb if ptb > 0 else None), + remaining_sec=remaining, + pnl_trade_usd=None, + pnl_total_usd=_to_float(state.get("cumulative_realized_pnl"), 0.0), + ) + + if (not SIMULATION_MODE) and pos and stop_loss_triggered: + log( + f"Stop hit: {pos_side} prob {current_prob*100:.1f}% <= stop {stop_prob*100:.1f}% (entry {entry_prob*100:.1f}%)", + "TRADE", + ) + + if AUTO_TRADE and trader.connected: + if tp_order and tp_order.get("order_id"): + trader.cancel_order(tp_order.get("order_id")) + state.pop("take_profit_order", None) + + sell_price = (up_bid if pos_side == "UP" else down_bid) or (up_price if pos_side == "UP" else down_price) + sell_token = market["up_token"] if pos_side == "UP" else market["down_token"] + sell_order_id = trader.place_order(sell_token, "SELL", sell_price, position_size) + ep = _maybe_float(pos.get("entry_price")) or 0.0 + xp = float(sell_price) + realized = position_size * (xp - ep) if sell_order_id and ep else 0.0 + cum = _to_float(state.get("cumulative_realized_pnl"), 0.0) + (realized if sell_order_id else 0.0) + if sell_order_id: + state["cumulative_realized_pnl"] = cum + state = _append_trade_history(state, { + "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "slug": slug, + "action": "SELL", + "side": pos_side, + "price": sell_price, + "entry_price": ep, + "shares": position_size, + "amount": position_size * xp, + "order_id": sell_order_id or "", + "status": "submitted" if sell_order_id else "failed", + "reason": "stop_loss", + "diff": diff, + "btc": btc if btc > 0 else None, + "ptb": ptb if ptb > 0 else None, + "btc_minus_ptb": _btc_ptb_snapshot(btc if btc > 0 else None, ptb if ptb > 0 else None), + "remaining_sec": remaining, + "realized_pnl_usd": realized if sell_order_id else None, + "cumulative_realized_pnl_usd": cum if sell_order_id else _to_float(state.get("cumulative_realized_pnl"), 0.0), + }) + _emit_trading_analysis( + "SELL_SUBMIT" if sell_order_id else "SELL_FAILED", + reason="stop_loss", + slug=slug, + action="SELL", + status="sell", + shares_type=pos_side, + share_price=xp, + share_amount=position_size, + entry_share_price=ep, + exit_share_price=xp, + take_profit=tp_trigger_prob, + stop_loss=stop_prob, + order_id=sell_order_id or "", + btc_price=btc if btc > 0 else None, + chainlink_btc=btc if btc > 0 else None, + ptb=ptb if ptb > 0 else None, + btc_minus_ptb=_btc_ptb_snapshot(btc if btc > 0 else None, ptb if ptb > 0 else None), + diff_rule=diff, + remaining_sec=remaining, + pnl_trade_usd=realized if sell_order_id else None, + pnl_total_usd=cum if sell_order_id else _to_float(state.get("cumulative_realized_pnl"), 0.0), + ) + state.pop("position", None) + state.pop("take_profit_order", None) + save_state(state) + _dashboard_set( + position={}, + pending_order=_dashboard_pending_order_from_state(state), + trade_history=list(state.get("trade_history") or []), + cumulative_realized_pnl=cum if sell_order_id else _to_float(state.get("cumulative_realized_pnl"), 0.0), + ) + _sync_dashboard_account_snapshot(dashboard_user) + log(f"Stop-loss sell done: {pos_side} @ {sell_price*100:.2f}%", "TRADE") + + time.sleep(LOOP_INTERVAL_SEC) + + except KeyboardInterrupt: + print("\n\nStopped.") + if market_listener: + market_listener.stop() + redeemer.stop() + +if __name__ == "__main__": + main() diff --git a/5min-15min-PTB-bot/requirements.txt b/5min-15min-PTB-bot/requirements.txt new file mode 100644 index 0000000..7b9d676 --- /dev/null +++ b/5min-15min-PTB-bot/requirements.txt @@ -0,0 +1,8 @@ +py-clob-client +requests +python-dotenv +websocket-client +web3 +py-builder-relayer-client +py-builder-signing-sdk +flask diff --git a/5min-15min-PTB-bot/state.json b/5min-15min-PTB-bot/state.json new file mode 100644 index 0000000..9f1da3d --- /dev/null +++ b/5min-15min-PTB-bot/state.json @@ -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": {} +} \ No newline at end of file diff --git a/5min-15min-PTB-bot/static/dashboard.html b/5min-15min-PTB-bot/static/dashboard.html new file mode 100644 index 0000000..e79731c --- /dev/null +++ b/5min-15min-PTB-bot/static/dashboard.html @@ -0,0 +1,1133 @@ + + + + + + BTC Auto-Trading Dashboard + + + + + + +
+
+
+
Polymarket BTC Auto-Trading Dashboard
+
Market: -
+
+
+
+ Market + + +
+
Balance: -
+
Updated: -
+
+
+ +
+
+
+

Market

+
+
Time remaining
-
+
Market status
-
+
Price update (ts)
-
+
+
+ +
+

Prices & diff

+
+
PTB
-
+
Chainlink BTC
+
Binance BTC
-
+
UP price
-
+
DOWN price
-
+
Diff (BTC-PTB)
-
+
+
+ +
+

Manual trade

+
+
+ + +
+
+ + +
+ + + + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + + + +
+
Note: Confirm market and price before submit; fills are per exchange.
+
+
+ +
+

Orders & position

+
+
Position
-
+
Open order
-
+
Last order
-
+
+
+ +
+

Trade history

+
+
Realized P&L
$0.000
+
Unrealized P&L
$0.000
+
Total P&L
$0.000
+
Win rate
-
+
+
Records: 0
+
+
+ +
+

Auto redeem (relayer)

+
+
Status
-
+
Pending redeemable
-
+
Claimable (this wallet)
-
+
+
Last result: -
+
Last error: -
+
+ +
+ +
+

Live log

+
+
Rounds: 0
+
+
+
+
+ + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e99c0ea --- /dev/null +++ b/README.md @@ -0,0 +1,403 @@ +# Polymarket 5min, 15min, 1hour Trading Bot — Polymarket Arbitrage Trading Bot & Automation Toolkit + +**Languages / 语言:** English (this page, top to bottom) plus a **[Simplified Chinese section (简体中文)](#zh-cn)** at the end. / 页面主体为英文,文末提供**简体中文**完整说明与表格。 + +--- + +**Polymarket trading bot** tooling for short-horizon crypto **Up / Down** markets: this repo ships **three** production-style Python **Polymarket bot** implementations you can run, study, and extend. Whether you search for a **polymarket arbitrage trading bot**, a **prediction market bot**, or a **Polymarket API bot** for automated execution, you get readable strategies, WebSocket market data, and CLOB-style order flow patterns built for speed and reliability in research and live-like testing. + +This suite is **educational and experimental**. Use **simulation / dry-run** first, size small, and own your risk. + +**(中文摘要)** 本仓库提供三个可运行的 Python **Polymarket 短线(5m/15m)Up/Down** 机器人实现,含 WebSocket、下单与风控范式;仅供**学习与研究**,请优先**模拟 / 小资金**并自担风险。完整安装、机器人对照表与策略目录见文末 [简体中文](#zh-cn)。 + + **Contact (Telegram)** — [@terauss](https://t.me/terauss) · **联系(Telegram)** — [@terauss](https://t.me/terauss) + +This repository is hosted under **[PolyBullLabs](https://github.com/PolyBullLabs)** ([polymarket-5min-15min-1hour-arbitrage-trading-bot](https://github.com/PolyBullLabs/polymarket-5min-15min-1hour-arbitrage-trading-bot)). The org also publishes **Rust** Polymarket bots on **5m**, **15m**, and **1h** horizons with **different strategies** than this Python suite—see the table below. + + + +**Proof** +![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) + +5min-3-6-1 +5min-3-6 + + + + +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 + +Polymarket trading bot VWAP momentum dashboard + +Polymarket bot terminal UI + +### Meridian / late consensus (`late_v3`) + +https://github.com/user-attachments/assets/c811e320-3a0a-4cbe-9cec-8b7a42c0cf6d + +Polymarket arbitrage trading bot Meridian spread view + +Prediction market bot multi-asset dashboard + +### PTB bot (5m / 15m) + +https://github.com/user-attachments/assets/bfdf7590-5458-4883-88cf-b8343a316f6f + +Polymarket API bot PTB web dashboard + +Automated trading bot PTB controls + +--- + +## Table of contents + +- [Related Rust Polymarket bots (Poly-Tutor)](#related-rust-polymarket-bots-poly-tutor) +- [Features](#features) +- [How it works](#how-it-works) +- [Bots in this repository](#bots-in-this-repository) +- [Installation](#installation) +- [Usage](#usage) +- [Configuration](#configuration) +- [Screenshots & demo](#screenshots--demo) +- [Risk management snapshot](#risk-management-snapshot) +- [Which Polymarket bot should I run?](#which-polymarket-bot-should-i-run) +- [Roadmap](#roadmap) +- [FAQ](#faq) +- [Additional strategy catalog](#additional-strategy-catalog) +- [Extended strategies (separate offerings)](#extended-strategies-separate-offerings) +- [Disclaimer](#disclaimer) +- [License](#license) +- [Contact](#contact) +- [Simplified Chinese (简体中文)](#zh-cn) + +--- + +## Features + +- **Multi-strategy Polymarket trading bot collection** — VWAP/momentum, late-window consensus with structured exits, and PTB-driven triggers. +- **Polymarket API bot patterns** — REST + WebSocket usage, order execution helpers, and configs suitable for paper and small live tests. +- **Risk controls** — dry run / simulation modes, investment caps, stop-loss and flip-stop (where implemented), entry windows, and spread or confidence gates. +- **Dashboards & UX** — Rich terminal UI (VWAP bot), web dashboards (PTB and Meridian paths), logging for operational review. +- **Educational depth** — Mechanics explained (why entries can work and how they can fail); aimed at serious traders and builders, not hype. + +--- + +## How it works + +1. **Connect** — Configure wallet/API credentials and Polymarket-compatible endpoints per the bot README (never commit secrets). +2. **Select a market window** — Short-horizon **5m / 15m** crypto Up/Down markets; parameters differ by **polymarket bot** (BTC-only vs multi-asset). +3. **Ingest & signal** — Live quotes and/or oracle-aligned inputs feed rules (VWAP deviation, late-book skew, PTB vs spot distance, etc.). +4. **Execute with guardrails** — Orders respect caps, max prices, simulation flags, and stop logic—treat this as an **automated trading bot** with explicit failure modes, not magic alpha. + +None of this is investment advice; it is **mechanics** and software behavior. + +--- + +## Bots in this repository + +All runnable bots live as **top-level folders** next to this README. Each has its own **README**, `requirements.txt`, and configuration (`.env` / `config.json` / `config.env`). + +| Directory | Focus | Markets | Core idea | +|-----------|--------|---------|-----------| +| [`btc-binary-VWAP-Momentum-bot/`](btc-binary-VWAP-Momentum-bot/) | VWAP, deviation, momentum, z-score | BTC **5m** or **15m** | Enter the **favorite** when price has **pulled above VWAP** with **positive momentum** in a **late, narrow window**—filtering for “consensus + short-term continuation.” | +| [`up-down-spread-bot/`](up-down-spread-bot/) (**Meridian**) | Late Entry V3 (`late_v3`) | BTC, ETH, SOL, XRP — **5m** or **15m** | In the **last minutes**, buy the side the book **already favors**, if **spread** and **confidence** (ask skew) pass checks; **stop-loss** and **flip-stop** cut bad paths before expiry. | +| [`5min-15min-PTB-bot/`](5min-15min-PTB-bot/) | PTB diff + probability triggers | BTC **5m** or **15m** | Compare **live BTC** to Polymarket’s **price-to-beat (PTB)**; fire when **time**, **dollar diff**, and **implied probability** align; manage risk with **take-profit / stop-loss** on token prices. | + +--- + +## Installation + +```bash +git clone https://github.com/PolyBullLabs/polymarket-5min-15min-1hour-arbitrage-trading-bot.git +cd polymarket-5min-15min-1hour-arbitrage-trading-bot +``` + +Then enter the bot you want and create a virtual environment: + +```bash +cd btc-binary-VWAP-Momentum-bot # or: up-down-spread-bot / 5min-15min-PTB-bot +python -m venv .venv +# Windows: .venv\Scripts\activate +# macOS/Linux: source .venv/bin/activate +pip install -r requirements.txt +``` + +Follow the **README inside that bot** for exact entrypoints, dependencies, and any extra setup (dashboards, env files). + +--- + +## Usage + +1. Pick a bot folder at the repo root and open its README (e.g. [`btc-binary-VWAP-Momentum-bot/README.md`](btc-binary-VWAP-Momentum-bot/README.md)). +2. Copy example config / `.env` as documented; set **`SIMULATION_MODE`** or **dry run** when offered. +3. Run the documented command (often `python main.py` or the bot-specific script—see each README). +4. Monitor logs and dashboards; scale size only after you trust behavior end-to-end. + +**Operational hygiene:** dedicated wallet, **never commit keys**, private **RPC** where relevant, monitor **logs**, start with **minimum size**. + +--- + +## Configuration + +Configuration is **per bot**: + +- **Environment variables** — API keys, RPC URLs, Telegram hooks, simulation flags (see each bot’s `.env.example` or README). +- **JSON / config files** — Market choice, windows, price bands, bet sizing, stop-loss and flip-stop parameters. +- **Runtime flags** — Some bots expose CLI flags (e.g. web dashboard); check the bot README. + +You can run more than one **polymarket trading bot** only if you understand **collateral**, **nonce / rate limits**, and **position overlap**—typically use **separate wallets** or **non-overlapping** markets. + + + +## Risk management snapshot + +| Bot | Primary levers | +|-----|----------------| +| **VWAP / momentum** | Price band (`min_price` / `max_price`), **narrow entry window**, **bet size**, optional **hedge** (opposite-side GTD), **FAK** execution with retries, **max entry price** cap. | +| **Meridian** | **Dry run**, **max order / total investment**, **entry window**, **confidence** and **spread** gates, **stop-loss**, **flip-stop**, **entry frequency**, **FAK / FOK** execution behavior. | +| **PTB bot** | **Simulation mode**, **per-trade USDC**, **TP/SL** on probability, **trigger** windows, **market lag** limits, **loop** cadence. | + +--- + +## Which Polymarket bot should I run? + +| Situation | Sensible starting point | +|-----------|-------------------------| +| You want **one asset (BTC)** and **indicator-style** rules with a **terminal dashboard** | `btc-binary-VWAP-Momentum-bot` | +| You want **several coins** from **one wallet** and **late-window consensus** with **structured exits** | `up-down-spread-bot` (Meridian) | +| You care about **PTB vs Chainlink BTC** and **rule-based** triggers with a **web dashboard** | `5min-15min-PTB-bot` | + +--- +--- + +## Related Rust Polymarket bots (Poly-Tutor) + +This repository is the **Python** toolkit (VWAP, Meridian, PTB). The same org maintains separate repos optimized for **pair-cost / hedging**, **dump-and-hedge**, and **hourly pre-limit + merge** flows. + +| Timeframe | Repository | Language | Strategy (summary) | +|-----------|------------|----------|-------------------| +| **5 min** | [**5min-btc-polymarket-trading-bot**](https://github.com/Poly-Tutor/5min-btc-polymarket-trading-bot) | Rust | **BTC 5m** Up/Down: lock when **combined cost per pair** stays under your cap (e.g. Up + Down < $1), plus **hedging**, expansion when the opposite side rises, **ride-the-winner**, and PnL rebalance. | +| **15 min** | [**Polymarket-15min-arbitrage-bot**](https://github.com/Poly-Tutor/Polymarket-15min-arbitrage-bot) | Rust | **15m** Up/Down (BTC, ETH, SOL, XRP): **dump-and-hedge**—detect a sharp drop, leg in, then **hedge** when pair cost meets targets; optional production CLOB mode. | +| **1 hour** | [**1hour-crypto-polymarket-trading-bot**](https://github.com/Poly-Tutor/1hour-crypto-polymarket-trading-bot) | Rust | **Hourly** Up/Down (BTC, ETH, SOL, XRP, Eastern Time): **limit buys** on both sides before the hour, optional **merge** for a small locked edge, **risk exit** if only one side fills. | + +Clone URLs: + +- `git clone https://github.com/Poly-Tutor/5min-btc-polymarket-trading-bot.git` +- `git clone https://github.com/Poly-Tutor/Polymarket-15min-arbitrage-bot.git` +- `git clone https://github.com/Poly-Tutor/1hour-crypto-polymarket-trading-bot.git` + + +--- + +## Roadmap + +- Broader **paper-trading** defaults and clearer “first run” checklists per bot. +- Additional **observability** (structured logs, optional metrics hooks) for production-minded users of this **crypto trading bot** toolkit. +- Documentation cross-links and version pins where it helps reproducibility. +- Community-driven examples (strategies as plugins) where it fits the architecture. + +--- + +## FAQ + +### What is a Polymarket trading bot? + +A **Polymarket trading bot** is software that connects to Polymarket’s APIs, reads market data, and places or manages orders according to rules—so you can automate entries, exits, and risk limits instead of trading only by hand. + +### Is this a polymarket arbitrage trading bot? + +This repository is framed as **polymarket arbitrage trading bot** *tooling*: strategies that seek **edge** in short-horizon Up/Down markets using execution and timing rules. It is **not** a guarantee of arbitrage in the strict sense on every tick—**slippage, fees, and adverse selection** apply. + +### How is this different from a generic crypto trading bot? + +A **crypto trading bot** on CEXes trades spot or perps; this suite is built for **prediction market** mechanics on Polymarket (binary outcomes, CLOB, resolution). The overlap is **automation and risk controls**; the market model is different. + +### Do I need a Polymarket API bot setup? + +Yes, for serious automation you should plan for API keys, signing, and rate-limit-aware clients. These bots follow **Polymarket API bot** patterns (REST/WebSocket, order helpers)—see each bot’s README for specifics. + +### Can I use this as a fully automated trading bot out of the box? + +You can run the code and automation paths, but you must **configure** markets, sizes, and risk. Start with **simulation / dry-run**, validate fills and logs, then scale. **No automated trading bot** removes market or operational risk. + +### Is this prediction market bot software safe? + +Software has bugs; markets have tail risk. Use small size, isolated wallets, and monitoring. Past backtests or demos **do not** predict live results. + +### Who is this for? + +**Developers** who want to learn or extend a **polymarket bot**, and **traders** who accept that **no bot guarantees profit** and who will test responsibly. + +### Where do I get help or custom strategies? + +For **custom deployment**, **additional strategies**, or **professional risk frameworks**, contact **[Telegram @terauss](https://t.me/terauss)**. + +--- + +## Additional strategy catalog + +The strategies below are **examples of directions** PolyBull Labs builds or discusses as **separate offerings**, custom deployments, or research tracks. They are **not guaranteed** to exist as public drop-ins in this repository—availability, implementation language, and terms are handled individually on **[Telegram @terauss](https://t.me/terauss)**. + +下表为**策略方向示例**(可定制开发或单独交付),**不一定**以本仓库公开子目录形式提供;是否落地、技术栈与商务条款请在 **[Telegram @terauss](https://t.me/terauss)** 沟通。 + +| Strategy (EN) | 策略(中文) | Idea (EN) | 思路(中文) | +|----------------|-------------|-----------|-------------| +| **1c buy** | **1 美分买入** | Seek **ultra-cheap** bids (near **$0.01**) when microstructure or book updates create dislocations; **high variance**, needs **hard notional caps** and kill switches. | 在盘口/流动性短暂失衡时捕捉**极低价**(约 **1 美分**)一侧;**方差极大**,必须配合**严格资金上限**与熔断。 | +| **99c sniper** | **99 美分狙击** | Target **near-resolution** asks around **$0.99** when you believe the outcome is effectively settled but liquidity still prints; **tail risk** if the market flips. | 在临近结算、认为结果已高度确定时参与**约 99 美分**一侧;若结果反转则**尾部风险**显著。 | +| **Low-side dual reversion** | **弱势双边均值回归** | Work **both** underdog sides when prices are **compressed**, betting on **mean reversion** or late **volatility expansion** with paired risk controls. | 在两侧价格**受压**时,对**弱势/低价**双边做**均值回归**或博弈尾盘**波动放大**,需配对风控。 | +| **Pre-order market** | **预挂单 / 盘前布局** | Place **limits ahead** of the active window (or refresh ladders early) to **shape queue position** before competing flow arrives. | 在窗口正式激烈博弈**之前**挂出限价或刷新阶梯,以**排队与价位**占据主动。 | +| **Cross-market bot** | **跨市场机器人** | Link **two or more** related markets (same asset, different horizons, or correlated events) for **hedge**, **spread**, or **arbitrage-style** books. | 将**多个相关市场**(同资产不同周期、或相关事件)联动,做**对冲、价差或类套利**组合。 | +| **Martingale & anti-martingale @ ~45c** | **约 45¢ 马丁 / 反马丁** | Around **mid prices (~45¢)**, either **add on adverse moves** (martingale-style, very dangerous) or **pyramid into strength / cut into weakness** (anti-martingale); must be **regime-gated**. | 在**中段价位(约 45 美分)**附近,按规则做**亏损加仓(马丁,极高风险)**或**顺势加码 / 逆势减仓(反马丁)**;必须有**行情过滤**与**爆仓防护**。 | +| **Fibonacci strategy bot** | **斐波那契策略机器人** | Size entries or grids using **Fibonacci retracement/extension** levels relative to a swing anchor; combines **TA structure** with prediction-market **binary payoffs**. | 以波段锚点计算**斐波那契回撤/扩展**位,用于**分批建仓或网格**;将**技术结构**与二元**盈亏不对称**结合。 | +| **Binary momentum (MACD, RSI, VWAP)** | **二元动量(MACD / RSI / VWAP)** | **Momentum** stack on binary Up/Down: **MACD** for trend impulse, **RSI** for stretch / mean-revert filter, **VWAP** for intraday fair value—similar spirit to the shipped VWAP bot but **multi-indicator**. | 在二元 Up/Down 上叠加**动量**:**MACD** 看趋势动能,**RSI** 看过热/回调过滤,**VWAP** 看日内公允;理念接近本仓库 VWAP 机器人但为**多指标融合**。 | +| **Dump-hedge** | **急跌对冲** | Detect a **sharp dump**, leg in, then **hedge** the other side when **combined pair cost** clears your edge threshold (see also the **Rust 15m** repo in [Related Rust Polymarket bots](#related-rust-polymarket-bots-poly-tutor)). | 识别**急跌**后先进一侧,再在**组合成本**达标时**对冲**另一侧锁定结构(亦可对照上文 **Rust 15m** 仓库的 **dump-and-hedge** 思路)。 | + +--- + +## Extended strategies (separate offerings) + +Beyond these three open folders, **PolyBull Labs** works on advanced quant-style ideas (sizing sequences, TA combinations, execution-aware models, inventory/skew concepts, Monte Carlo for drawdown, etc.). These are **not** all shipped as drop-in modules here. The **[strategy catalog](#additional-strategy-catalog)** above summarizes **frequently requested** tracks in **English and Chinese**. For **access, customization, or collaboration**, use **[Telegram: @terauss](https://t.me/terauss)**. + +--- + +## Disclaimer + +Trading prediction markets can result in **total loss** of capital deployed. Everything here is provided **for education and experimentation**. Authors and contributors are **not** responsible for trading losses, bugs, exchange or protocol rule changes, or regulatory issues in your jurisdiction. + +**Not financial advice.** **No warranty.** Use **simulation / dry-run** until you trust the full stack. + +--- + +## License + +Individual bots may ship their own **LICENSE**; where none is specified, treat usage as **at your own risk**. + +--- + +## Contact + +- **Telegram:** [@terauss](https://t.me/terauss) + +--- + + + +## 简体中文(Simplified Chinese) + +### 项目简介 + +本仓库是 **PolyBullLabs** 旗下的 **Polymarket 5 分钟 / 15 分钟 / 1 小时**相关 **Python 交易机器人**套件(公开部分为 **三个** 可运行子项目),面向加密 **Up/Down** 短期预测市场:含 REST/WebSocket、下单与风控范式,适合学习、二次开发与**小资金模拟**。**不构成投资建议**;请务必先 **dry-run / 模拟**,并自行承担全部风险。 + +- **仓库地址:** [github.com/PolyBullLabs/polymarket-5min-15min-1hour-arbitrage-trading-bot](https://github.com/PolyBullLabs/polymarket-5min-15min-1hour-arbitrage-trading-bot) +- **联系:** [Telegram @terauss](https://t.me/terauss) + +### 本仓库内的机器人(公开目录) + +| 目录 | 侧重点 | 市场 | 核心思路 | +|------|--------|------|----------| +| [`btc-binary-VWAP-Momentum-bot/`](btc-binary-VWAP-Momentum-bot/) | VWAP、偏离、动量、z-score | BTC **5m** 或 **15m** | 在**较晚的窄窗口**内,当价格**站上 VWAP** 且**动量为正**时倾向**强势侧**;强调“共识 + 短线延续”过滤。 | +| [`up-down-spread-bot/`](up-down-spread-bot/)(**Meridian**) | Late Entry V3(`late_v3`) | BTC、ETH、SOL、XRP — **5m** 或 **15m** | **尾盘**若点差与**置信度(卖盘偏斜等)**过关,则跟随订单簿已偏向的一侧;**止损 / 翻转止损**在到期前截断劣路径。 | +| [`5min-15min-PTB-bot/`](5min-15min-PTB-bot/) | PTB 差值 + 概率触发 | BTC **5m** 或 **15m** | 对比**链上/现货 BTC** 与 Polymarket **PTB(price-to-beat)**;在**时间、价差、隐含概率**共振时触发;可用 **TP/SL** 管理仓位。 | + +### 附加策略目录(与上表一致 · EN/中文) + +与英文章节 **[Additional strategy catalog](#additional-strategy-catalog)** 相同,便于中文读者直接阅读: + +| Strategy (EN) | 策略(中文) | Idea (EN) | 思路(中文) | +|----------------|-------------|-----------|-------------| +| **1c buy** | **1 美分买入** | Seek **ultra-cheap** bids (near **$0.01**) when microstructure or book updates create dislocations; **high variance**, needs **hard notional caps** and kill switches. | 在盘口/流动性短暂失衡时捕捉**极低价**(约 **1 美分**)一侧;**方差极大**,必须配合**严格资金上限**与熔断。 | +| **99c sniper** | **99 美分狙击** | Target **near-resolution** asks around **$0.99** when you believe the outcome is effectively settled but liquidity still prints; **tail risk** if the market flips. | 在临近结算、认为结果已高度确定时参与**约 99 美分**一侧;若结果反转则**尾部风险**显著。 | +| **Low-side dual reversion** | **弱势双边均值回归** | Work **both** underdog sides when prices are **compressed**, betting on **mean reversion** or late **volatility expansion** with paired risk controls. | 在两侧价格**受压**时,对**弱势/低价**双边做**均值回归**或博弈尾盘**波动放大**,需配对风控。 | +| **Pre-order market** | **预挂单 / 盘前布局** | Place **limits ahead** of the active window (or refresh ladders early) to **shape queue position** before competing flow arrives. | 在窗口正式激烈博弈**之前**挂出限价或刷新阶梯,以**排队与价位**占据主动。 | +| **Cross-market bot** | **跨市场机器人** | Link **two or more** related markets (same asset, different horizons, or correlated events) for **hedge**, **spread**, or **arbitrage-style** books. | 将**多个相关市场**(同资产不同周期、或相关事件)联动,做**对冲、价差或类套利**组合。 | +| **Martingale & anti-martingale @ ~45c** | **约 45¢ 马丁 / 反马丁** | Around **mid prices (~45¢)**, either **add on adverse moves** (martingale-style, very dangerous) or **pyramid into strength / cut into weakness** (anti-martingale); must be **regime-gated**. | 在**中段价位(约 45 美分)**附近,按规则做**亏损加仓(马丁,极高风险)**或**顺势加码 / 逆势减仓(反马丁)**;必须有**行情过滤**与**爆仓防护**。 | +| **Fibonacci strategy bot** | **斐波那契策略机器人** | Size entries or grids using **Fibonacci retracement/extension** levels relative to a swing anchor; combines **TA structure** with prediction-market **binary payoffs**. | 以波段锚点计算**斐波那契回撤/扩展**位,用于**分批建仓或网格**;将**技术结构**与二元**盈亏不对称**结合。 | +| **Binary momentum (MACD, RSI, VWAP)** | **二元动量(MACD / RSI / VWAP)** | **Momentum** stack on binary Up/Down: **MACD** for trend impulse, **RSI** for stretch / mean-revert filter, **VWAP** for intraday fair value—similar spirit to the shipped VWAP bot but **multi-indicator**. | 在二元 Up/Down 上叠加**动量**:**MACD** 看趋势动能,**RSI** 看过热/回调过滤,**VWAP** 看日内公允;理念接近本仓库 VWAP 机器人但为**多指标融合**。 | +| **Dump-hedge** | **急跌对冲** | Detect a **sharp dump**, leg in, then **hedge** the other side when **combined pair cost** clears your edge threshold (see also the **Rust 15m** repo in [Related Rust Polymarket bots](#related-rust-polymarket-bots-poly-tutor)). | 识别**急跌**后先进一侧,再在**组合成本**达标时**对冲**另一侧锁定结构(亦可对照英文区 **Rust 15m** 仓库的 **dump-and-hedge** 描述)。 | + +### 克隆与安装(摘要) + +```bash +git clone https://github.com/PolyBullLabs/polymarket-5min-15min-1hour-arbitrage-trading-bot.git +cd polymarket-5min-15min-1hour-arbitrage-trading-bot +cd btc-binary-VWAP-Momentum-bot # 或: up-down-spread-bot / 5min-15min-PTB-bot +python -m venv .venv +# Windows: .venv\Scripts\activate +# macOS/Linux: source .venv/bin/activate +pip install -r requirements.txt +``` + +各子目录另有 **README** 与示例环境变量,请**勿将密钥提交到 Git**。 + +### 相关 Rust 仓库(Poly-Tutor) + +英文主文档见 **[Related Rust Polymarket bots (Poly-Tutor)](#related-rust-polymarket-bots-poly-tutor)**:含 **5m / 15m / 1h** 的 **pair-cost、dump-hedge、限价+merge** 等 Rust 实现链接与 `git clone` 命令。 + +### 免责声明(中文) + +预测市场交易可能导致**本金全部亏损**。本仓库内容仅供**教育与实验**;作者与贡献者**不对**因使用代码产生的交易亏损、程序错误、交易所或协议规则变更、以及您所在司法辖区的合规问题承担责任。**非投资建议。无担保。** 在充分理解行为与风险前,请持续使用**模拟 / 小资金**。 + +### 联系(中文) + +- **Telegram:** [@terauss](https://t.me/terauss) + +--- + +**If this Polymarket trading bot toolkit is useful, please star the repo** to improve discoverability for other builders and traders—and open issues if you want to contribute improvements to docs or code. + +**若本仓库对您有帮助,欢迎点 Star**,方便其他开发者与交易者发现本项目;也欢迎通过 Issue 贡献文档与代码改进。 diff --git a/btc-binary-VWAP-Momentum-bot/.env.example b/btc-binary-VWAP-Momentum-bot/.env.example new file mode 100644 index 0000000..ddaf335 --- /dev/null +++ b/btc-binary-VWAP-Momentum-bot/.env.example @@ -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= diff --git a/btc-binary-VWAP-Momentum-bot/.gitignore b/btc-binary-VWAP-Momentum-bot/.gitignore new file mode 100644 index 0000000..28996e0 --- /dev/null +++ b/btc-binary-VWAP-Momentum-bot/.gitignore @@ -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 \ No newline at end of file diff --git a/btc-binary-VWAP-Momentum-bot/CONFIG.md b/btc-binary-VWAP-Momentum-bot/CONFIG.md new file mode 100644 index 0000000..f8fa074 --- /dev/null +++ b/btc-binary-VWAP-Momentum-bot/CONFIG.md @@ -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-` (300s window). Example strategy: `min_elapsed_sec` ~150–210, `no_entry_before_end_sec` ~90–120. +- **15** — slug pattern `btc-updown-15m-` (900s window). Example strategy: `min_elapsed_sec` ~480–530, `no_entry_before_end_sec` ~300–335. + +The bot aligns Chainlink anchor resets, market discovery, and elapsed-time logic to this interval. **Always** set `min_elapsed_sec` and `no_entry_before_end_sec` so they fit inside the market length (e.g. for 5m, `min_elapsed_sec` must be < 300). + +--- + +## simulation -- Paper trading (no real money) + +``` +"enabled": false +``` + +**When `true`:** the bot still connects to market WebSockets, RTDS Chainlink, runs the dashboard, and evaluates the same entry rules. **No** orders are sent to Polymarket, **no** User WebSocket for fills, **no** auto-redeemer loop. Entries are logged as instant hypothetical fills at **best ask + `entry.price_offset`**, subject to `max_entry_price` and the same contract sizing as live. Optional hedge is simulated as a placed GTD with id `SIM-HEDGE` (no on-chain or CLOB effect). + +``` +"separate_trading_log": true, +"trading_log_path": "logs/trading_log_sim.json" +``` + +If `separate_trading_log` is **true**, simulated P&L and trades are written only to `trading_log_path`, so your live `logs/trading_log.json` stays untouched. Set to **false** to append simulation results to the same file as live (not recommended if you also run live). + +**Trading history for analysis (simulation only):** + +- `history_csv_path` — Append-only CSV with **OPEN** rows (each simulated entry) and **CLOSE** rows (each resolved position). CLOSE rows include **trade_pnl_usd**, **cumulative_pnl_usd** after that trade, **win_rate_pct**, and **total_closed_trades**. Open in Excel / pandas. +- `history_jsonl_path` — One JSON object per line (`type`: `open` or `close`) for streaming tools. Set to `""` to disable. +- `history_summary_path` — Rewritten after every close: rolling **summary** (total PnL, wins/losses, win rate, best/worst trade) plus the full **trades** array (same data as `trading_log_path`, convenient for a single analysis file). + +The main `trading_log_path` JSON also includes a **`summary`** block (totals and win rate) on each save, for both live and sim logs. + +**Credentials:** With `simulation.enabled` true, `PRIVATE_KEY` and Polymarket API keys in `.env` are **not** required. You can still set Telegram tokens for notifications. + +--- + +## strategy -- When to enter a trade + +These parameters control **which signals the bot acts on**. Think of them as filters: a trade is only placed when ALL conditions pass simultaneously. + +``` +"min_price": 0.75 +``` +**Minimum token price to enter.** The bot only buys tokens priced at or above this value. Lower prices mean higher potential profit but lower probability of winning. At $0.75, you need a 75% win rate to break even. Range: 0.50 - 0.95. Start with 0.75. + +``` +"max_price": 0.88 +``` +**Maximum token price to enter.** The bot rejects tokens priced above this. Higher prices mean higher probability but tiny profit margin. At $0.88, you profit only $0.12 per contract on a win but lose $0.88 on a loss (need 88% win rate). Range: 0.80 - 0.95. Start with 0.88. + +``` +"min_elapsed_sec": 530 +``` +**Minimum seconds elapsed since market opened before allowing entry.** Each market lasts 900 seconds (15 min). This prevents entering too early when the market direction is unclear. At 530, the bot waits ~8.8 minutes. Range: 300 - 800. Higher = safer but fewer opportunities. + +``` +"min_deviation_pct": 3 +``` +**Minimum VWAP deviation (%) to trigger a signal.** VWAP = volume-weighted average price. Deviation measures how far the current price has moved from this average. A deviation of 3% means the token price is 3% above its recent average, indicating strong directional movement. Range: 0 - 15. Set to 0 to disable this filter. Higher = stricter, fewer trades. + +``` +"max_deviation_pct": 100 +``` +**Maximum VWAP deviation (%) allowed.** Rejects signals where deviation is abnormally high (potential spike/manipulation). Set to 100 to effectively disable the upper bound. To cap, try 15-25. Range: must be greater than min_deviation_pct. + +``` +"no_entry_before_end_sec": 335 +``` +**Stop entering trades if fewer than this many seconds remain.** At 335, the bot stops entering after ~9 min 25 sec (with 5 min 35 sec left). This protects against entering too late when there is not enough time for the position to be meaningful. Range: 60 - 500. + +> **Entry window example:** With min_elapsed_sec=530 and no_entry_before_end_sec=335, the bot can only enter between 530s and 565s elapsed -- a 35-second window each market. + +``` +"momentum_window_sec": 60 +``` +**Lookback window for momentum calculation (seconds).** Momentum compares current price to the price N seconds ago. At 60, it asks: "Is the price higher than 1 minute ago?" Shorter windows = more reactive, noisier. Longer = smoother, slower to react. Range: 15 - 300. + +``` +"vwap_window_sec": 30 +``` +**Lookback window for VWAP calculation (seconds).** Only trades from the last N seconds are used to compute VWAP. Shorter = more responsive to recent trades. Longer = smoother average. Range: 10 - 120. + +``` +"win_rate_csv": "data/win_rate.csv" +``` +**Path to the historical win rate table.** A CSV file containing win probabilities by price range and time bin. The bot uses this to display win rate on the dashboard. Generally no need to change this unless you build your own win rate data. + +--- + +## entry -- How to execute orders + +These parameters control **order mechanics**: how much to bet, how aggressively to fill, and what to do on failure. + +``` +"bet_amount_usd": 5 +``` +**How much USD to risk per trade.** The bot divides this by the entry price to get the number of contracts. Example: $5 at price $0.80 = 6 contracts. Start small ($1-5) while learning. Scale up only after consistent results. Range: 1 - any amount you are comfortable losing. + +``` +"price_offset": 0.02 +``` +**Price offset added to best bid for FAK orders.** FAK (Fill-And-Kill) orders must cross the spread to fill immediately. An offset of 0.02 means: if best bid is $0.80, the order is placed at $0.82. Higher offset = more aggressive fill but worse entry price. Range: 0.01 - 0.05. + +``` +"order_type": "FAK" +``` +**Order type for entry.** FAK = Fill-And-Kill. The order fills immediately at the specified price or gets cancelled. This is the recommended type for fast-moving 15-minute markets. Alternative: "GTC" (Good-Till-Cancel) which stays on the book. + +``` +"max_retries": 3 +``` +**How many times to retry a failed order.** If the first attempt fails (rejected, no fill), the bot retries up to this many times. Range: 1 - 10. More retries = better chance of filling but uses more time. + +``` +"retry_delay_ms": 300 +``` +**Milliseconds to wait between retry attempts.** Range: 100 - 2000. Shorter = faster retries. Don't set too low or you may hit rate limits. + +``` +"fill_timeout_ms": 1000 +``` +**How long to wait for fill confirmation (ms).** After placing a FAK order, the bot waits this long for a WebSocket fill message. If no fill arrives in time, it enters recovery mode. Range: 500 - 5000. + +``` +"min_contracts": 5 +``` +**Minimum number of contracts per order.** Polymarket requires at least 5 contracts. If bet_amount_usd / price results in fewer than 5 contracts, the order is skipped. Generally no need to change. + +``` +"min_order_usd": 1 +``` +**Minimum order value in USD.** Orders below this value are skipped. Generally no need to change. + +``` +"max_entry_price": 0.88 +``` +**Hard price ceiling for entry.** Even if the signal says BUY, the order is rejected if the execution price exceeds this. Acts as a safety net. Should be equal to or less than strategy.max_price. + +``` +"ws_recovery_timeout_sec": 10 +``` +**Timeout for WebSocket fill recovery (seconds).** When an order times out, the bot checks the User WebSocket for fills. This is how long it waits during recovery. Range: 5 - 30. + +--- + +## hedge -- Automatic hedging (advanced) + +Hedging places a cheap order on the **opposite** token after entry. If your main trade loses, the hedge may fill and partially offset the loss. + +``` +"enabled": false +``` +**Enable or disable automatic hedging.** Set to true to activate. When enabled, after each entry the bot places a GTD order on the opposite token. Recommended to leave false until you understand the mechanics. + +``` +"hedge_price": 0.02 +``` +**Price to place the hedge order at.** The hedge buys the opposite token at this price. At $0.02, you pay $0.02 per contract. If your main trade loses, the opposite token resolves to $1.00, netting $0.98 per contract. The hedge only fills if the market strongly moves in your favor (opposite token drops to $0.02). Range: 0.01 - 0.10. + +``` +"order_type": "GTD" +``` +**Hedge order type.** GTD = Good-Till-Date. A limit order that sits on the book until it fills or expires. Expires in 1 hour (market resolves in 15 minutes). No need to change. + +``` +"max_retries": 3 +``` +**Retry count for hedge order placement.** If hedge placement fails, retry up to this many times. + +``` +"retry_delay_ms": 1000 +``` +**Delay between hedge retry attempts (ms).** Hedge placement is less time-critical than entry, so a longer delay is fine. + +--- + +## redeem -- Automatic on-chain redemption + +After a market resolves, winning positions must be redeemed on the blockchain to collect your payout. + +``` +"enabled": true +``` +**Enable automatic redemption.** When true, the bot periodically checks for resolved positions and redeems them. If false, you must redeem manually on polymarket.com. Recommended: true. + +``` +"interval_seconds": 180 +``` +**How often to check for redeemable positions (seconds).** Every 180 seconds (3 minutes), the bot scans for positions that can be redeemed. Range: 60 - 600. Lower = more frequent checks but more API calls. + +``` +"auto_confirm": true +``` +**Automatically confirm redemptions.** When true, redemptions happen without manual approval. When false, redemptions are logged but not executed. + +--- + +## telegram -- Notifications + +Optional Telegram integration for trade alerts and equity charts. Requires TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID in .env. + +``` +"enabled": false +``` +**Enable Telegram notifications.** Set to true after configuring bot token and chat ID in .env. You will receive messages on trade entry, market end results, and periodic equity charts. + +``` +"chart_every_n_trades": 5 +``` +**Send an equity chart every N trades.** After every 5 trades (by default), the bot generates a P&L chart and sends it to your Telegram. Range: 1 - 50. + +--- + +## logging -- Log settings + +``` +"level": "INFO" +``` +**Logging verbosity.** Options: "DEBUG" (very verbose), "INFO" (normal), "WARNING" (errors only). Use DEBUG for troubleshooting, INFO for normal operation. + +``` +"file_rotation_hours": 3 +``` +**Rotate log files every N hours.** Prevents log files from growing indefinitely. Range: 1 - 24. + +--- + +## Quick Presets + +### Conservative (low risk, fewer trades) +```json +{ + "strategy": { + "min_price": 0.80, + "max_price": 0.85, + "min_elapsed_sec": 600, + "min_deviation_pct": 5, + "no_entry_before_end_sec": 300 + }, + "entry": { "bet_amount_usd": 2 }, + "hedge": { "enabled": true } +} +``` + +### Moderate (balanced) +```json +{ + "strategy": { + "min_price": 0.75, + "max_price": 0.88, + "min_elapsed_sec": 530, + "min_deviation_pct": 3, + "no_entry_before_end_sec": 335 + }, + "entry": { "bet_amount_usd": 10 }, + "hedge": { "enabled": true } +} +``` + +### Aggressive (more trades, higher risk) +```json +{ + "strategy": { + "min_price": 0.70, + "max_price": 0.90, + "min_elapsed_sec": 480, + "min_deviation_pct": 0, + "no_entry_before_end_sec": 120 + }, + "entry": { "bet_amount_usd": 50 }, + "hedge": { "enabled": false } +} +``` diff --git a/btc-binary-VWAP-Momentum-bot/PROJECT_LOGIC.md b/btc-binary-VWAP-Momentum-bot/PROJECT_LOGIC.md new file mode 100644 index 0000000..7a9e7f3 --- /dev/null +++ b/btc-binary-VWAP-Momentum-bot/PROJECT_LOGIC.md @@ -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 +} +``` diff --git a/btc-binary-VWAP-Momentum-bot/README.md b/btc-binary-VWAP-Momentum-bot/README.md new file mode 100644 index 0000000..600f280 --- /dev/null +++ b/btc-binary-VWAP-Momentum-bot/README.md @@ -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 diff --git a/btc-binary-VWAP-Momentum-bot/_debug_btc_markets.json b/btc-binary-VWAP-Momentum-bot/_debug_btc_markets.json new file mode 100644 index 0000000..40ad7e1 --- /dev/null +++ b/btc-binary-VWAP-Momentum-bot/_debug_btc_markets.json @@ -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": [] + } +] \ No newline at end of file diff --git a/btc-binary-VWAP-Momentum-bot/_readlog.py b/btc-binary-VWAP-Momentum-bot/_readlog.py new file mode 100644 index 0000000..aa61983 --- /dev/null +++ b/btc-binary-VWAP-Momentum-bot/_readlog.py @@ -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) \ No newline at end of file diff --git a/btc-binary-VWAP-Momentum-bot/chart_pnl.py b/btc-binary-VWAP-Momentum-bot/chart_pnl.py new file mode 100644 index 0000000..e18679c --- /dev/null +++ b/btc-binary-VWAP-Momentum-bot/chart_pnl.py @@ -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}") diff --git a/btc-binary-VWAP-Momentum-bot/check_ws_proxy.py b/btc-binary-VWAP-Momentum-bot/check_ws_proxy.py new file mode 100644 index 0000000..d8ddbdc --- /dev/null +++ b/btc-binary-VWAP-Momentum-bot/check_ws_proxy.py @@ -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())) diff --git a/btc-binary-VWAP-Momentum-bot/config.example.json b/btc-binary-VWAP-Momentum-bot/config.example.json new file mode 100644 index 0000000..fa09135 --- /dev/null +++ b/btc-binary-VWAP-Momentum-bot/config.example.json @@ -0,0 +1,75 @@ +{ + "_docs": "Full parameter guide: see CONFIG.md", + + "market": { + "_comment": "5 or 15 — slug btc-updown-5m- or btc-updown-15m-", + "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 + } +} \ No newline at end of file diff --git a/btc-binary-VWAP-Momentum-bot/debug_list_markets.py b/btc-binary-VWAP-Momentum-bot/debug_list_markets.py new file mode 100644 index 0000000..9d377a9 --- /dev/null +++ b/btc-binary-VWAP-Momentum-bot/debug_list_markets.py @@ -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()) \ No newline at end of file diff --git a/btc-binary-VWAP-Momentum-bot/docs/README.md b/btc-binary-VWAP-Momentum-bot/docs/README.md new file mode 100644 index 0000000..23799f1 --- /dev/null +++ b/btc-binary-VWAP-Momentum-bot/docs/README.md @@ -0,0 +1,554 @@ +# BTC 15-Minute Polymarket Bot — Full Beginner Guide + +**Suite:** [PolyBullLabs — polymakret-5min-15min-1hour-arbitrage-bot](https://github.com/PolyBullLabs/polymakret-5min-15min-1hour-arbitrage-bot) · **Telegram:** [@terauss](https://t.me/terauss) · **Parent overview:** [`../README.md`](../README.md) + +This document walks you from **zero to running**, explains the **trading strategy** with **numbers**, and lists **every important parameter** with **examples**. +Shorter references: [`CONFIG.md`](../CONFIG.md) (parameter list), [`PROJECT_LOGIC.md`](../PROJECT_LOGIC.md) (implementation detail). + +--- + +## Table of contents + +1. [What you are trading](#1-what-you-are-trading) +2. [Trading strategy (logic + formulas)](#2-trading-strategy-logic--formulas) +3. [Prerequisites checklist](#3-prerequisites-checklist) +4. [Environment setup — Windows](#4-environment-setup--windows) +5. [Environment setup — Linux / macOS](#5-environment-setup--linux--macos) +6. [Get the project and install dependencies](#6-get-the-project-and-install-dependencies) +7. [Configure `.env` (secrets)](#7-configure-env-secrets) +8. [Configure `config.json` (strategy and execution)](#8-configure-configjson-strategy-and-execution) +9. [Run the bot](#9-run-the-bot) +10. [Optional: Telegram](#10-optional-telegram) +11. [Optional: P&L chart](#11-optional-pnl-chart) +12. [Logs and files](#12-logs-and-files) +13. [Troubleshooting](#13-troubleshooting) +14. [Risk summary](#14-risk-summary) + +--- + +## 1. What you are trading + +### 1.1 The market + +Polymarket lists **15-minute** BTC markets (slug pattern like `btc-updown-15m-`). Each market has two outcome tokens: + +| Token | Pays if | +|--------|---------| +| **UP** | BTC finishes the window **above** the reference (market rules on Polymarket define the exact oracle) | +| **DOWN** | BTC finishes **below** | + +In practice the bot reads **live token prices** from Polymarket (not a manual prediction). It buys the **favorite** — whichever side has the **higher** last traded price. + +### 1.2 Payout math (simplified) + +If you buy **N** contracts at price **P** (in dollars per contract, 0–1): + +- **Cost** ≈ **N × P** +- If your side **wins**, each contract is worth **$1** → payout **N × $1** +- **Profit before fees** ≈ **N × (1 − P)** on a win; on a loss you lose the cost. + +**Example (numbers only):** + +- Buy **6** UP @ **$0.82** → cost **6 × 0.82 = $4.92** +- If UP wins → value **6 × $1 = $6.00** → gross profit **$6.00 − $4.92 = $1.08** + +The bot does **not** guarantee profit; it automates entries when **its rules** are satisfied. + +--- + +## 2. Trading strategy (logic + formulas) + +### 2.1 Favorite + +The bot compares **UP** and **DOWN** `last_price` (from the market WebSocket). The **favorite** is the side with the **higher** price. All deviation and momentum calculations below use the **favorite’s** trade history and price. + +### 2.2 VWAP (volume-weighted average price) + +Over the last **`vwap_window_sec`** seconds (e.g. **30**), take all trades on that token, then: + +\[ +\text{VWAP} = \frac{\sum (\text{price} \times \text{size})}{\sum \text{size}} +\] + +**Example** + +| Time | Price | Size | +|------|-------|------| +| T1 | 0.78 | 10 | +| T2 | 0.79 | 5 | + +\[ +\text{VWAP} = \frac{0.78 \times 10 + 0.79 \times 5}{10 + 5} = \frac{11.75}{15} \approx 0.7833 +\] + +### 2.3 Deviation (%) + +Compare **last** traded price to VWAP: + +\[ +\text{Deviation (\%)} = \frac{\text{last\_price} - \text{VWAP}}{\text{VWAP}} \times 100 +\] + +**Example** + +- Last price **0.82**, VWAP **0.78** +- Deviation = \((0.82 - 0.78) / 0.78 × 100 ≈ 5.13\%\) + +The bot requires deviation **strictly greater than** `min_deviation_pct` and **strictly less than** `max_deviation_pct` (see [§8.1](#81-strategy-block)). + +### 2.4 Momentum (%) + +Momentum uses a lookback of **`momentum_window_sec`** (e.g. **60**). The code takes trades whose timestamps fall in a **small band** around “now − 60s”, averages their prices, then compares **current** last price to that average: + +\[ +\text{Momentum (\%)} = \frac{\text{last\_price} - \text{avg\_price\_ago}}{\text{avg\_price\_ago}} \times 100 +\] + +If there are no trades in that window, momentum is **missing** (`None`) and the signal **cannot** fire. + +**Important:** In code, momentum must be **> 5%** (not configurable in `config.json` today). So `momentum_window_sec` changes *how* momentum is measured, not the **5%** threshold. + +**Example** + +- Average price ~60s ago: **0.77** +- Current last price: **0.82** +- Momentum = \((0.82 - 0.77) / 0.77 × 100 ≈ 6.5\%\) → **passes** the > 5% rule + +### 2.5 Time window for entries (15 minutes = 900 seconds) + +Each market lasts **900 seconds** from start to end. + +- `min_elapsed_sec` — do **not** enter until at least this many seconds **after** the market started. + Elapsed = **900 − time_left** (seconds). + +- `no_entry_before_end_sec` — do **not** enter if **time_left** ≤ this value (too close to expiry). + +**Worked example** (matches `CONFIG.md`): + +- `min_elapsed_sec = 530` → need elapsed **≥ 530** +- `no_entry_before_end_sec = 335` → need time_left **> 335** → elapsed **< 565** + +So entries are only possible when **530 ≤ elapsed < 565** → about **35 seconds** per market (if all other filters pass). + +| Variable | Value | +|----------|--------| +| `min_elapsed_sec` | 530 | +| `no_entry_before_end_sec` | 335 | +| Allowed elapsed | 530 … 564 | +| Allowed time_left | 336 … 370 | + +If you widen the window (e.g. lower `min_elapsed_sec` or raise `no_entry_before_end_sec`), you get **more** opportunities and usually **more** risk. + +### 2.6 Win rate table (`data/win_rate.csv`) + +Rows are **price bands** (e.g. `0.75-0.79`), columns are **minutes** (`min_0` … `min_14`). The dashboard uses this to **display** a historical win rate for the current favorite price and time bin. It does **not** by itself block a trade in the main signal logic (the hard filters are price, time, deviation, momentum). + +### 2.7 Entry checklist (all must pass) + +| # | Rule | Typical config | +|---|------|----------------| +| 1 | Favorite price in `[min_price, max_price]` | e.g. 0.75–0.88 | +| 2 | `elapsed_sec ≥ min_elapsed_sec` | e.g. ≥ 530 | +| 3 | `min_deviation_pct < deviation < max_deviation_pct` | e.g. 3% < dev < 100% | +| 4 | Momentum **not** `None` and **> 5%** | fixed in code | +| 5 | `time_left > no_entry_before_end_sec` | e.g. > 335 | + +### 2.8 After a buy + +1. **FAK** order: buy up to your size; unfilled part is cancelled. +2. Optional **hedge** (if enabled): **GTD** limit on the **opposite** token at `hedge_price` (often **0.02**). +3. Near market end, the bot **closes** the internal position for P&L tracking using last prices. +4. **Auto-redeem** (if enabled) periodically redeems winning positions on Polygon. + +--- + +## 3. Prerequisites checklist + +| Item | Why | +|------|-----| +| **Python 3.11+** (3.12 is fine) | Runs the bot | +| **pip / venv** | Install packages in isolation | +| **Polymarket account + USDC on Polygon** | Trading collateral | +| **Small amount of POL (MATIC)** | Gas for on-chain redemptions (if you use auto-redeem) | +| **CLOB API credentials** | key, secret, passphrase from Polymarket | +| **Wallet private key** (`0x…`) | Signs orders and redeem txs; **never share** | + +--- + +## 4. Environment setup — Windows + +### 4.1 Install Python + +1. Download the installer from [https://www.python.org/downloads/](https://www.python.org/downloads/) (Windows 64-bit). +2. Run it. **Enable “Add Python to PATH”** (important). +3. Close and reopen **PowerShell** or **Command Prompt**. + +### 4.2 Verify + +```powershell +python --version +pip --version +``` + +You should see Python 3.11+ and pip. If `python` is not found, try `py` (Windows launcher): + +```powershell +py --version +``` + +### 4.3 Git Bash / `sudo` / `apt` + +This project is **not** installed with `sudo apt` on Windows. Use **Python for Windows** or **WSL** (Ubuntu) if you want Linux-style commands. + +### 4.4 Execution policy (PowerShell venv) + +If activation fails with “running scripts is disabled”: + +```powershell +Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser +``` + +Then try `.\venv\Scripts\Activate.ps1` again. + +--- + +## 5. Environment setup — Linux / macOS + +### 5.1 Linux (Debian/Ubuntu example) + +```bash +sudo apt update +sudo apt install -y python3 python3-pip python3-venv git +python3 --version +``` + +### 5.2 macOS + +Install Python 3 from [python.org](https://www.python.org/downloads/) or `brew install python`. Then: + +```bash +python3 --version +``` + +--- + +## 6. Get the project and install dependencies + +### 6.1 Go to the project folder + +If you already have the folder (`btc-binary-VWAP-Momentum-bot`), **cd** into it: + +```bash +cd "path/to/polymakret-5min-15min-1hour-arbitrage-bot/btc-binary-VWAP-Momentum-bot" +``` + +If you clone from git: + +```bash +git clone https://github.com/PolyBullLabs/polymakret-5min-15min-1hour-arbitrage-bot.git +cd polymakret-5min-15min-1hour-arbitrage-bot/btc-binary-VWAP-Momentum-bot +``` + +### 6.2 Create and activate a virtual environment + +**Windows (PowerShell)** + +```powershell +python -m venv venv +.\venv\Scripts\Activate.ps1 +``` + +**Linux / macOS** + +```bash +python3 -m venv venv +source venv/bin/activate +``` + +Your prompt should show `(venv)`. + +### 6.3 Install Python packages + +```bash +pip install --upgrade pip +pip install -r requirements.txt +``` + +Wait until it finishes without errors. + +### 6.4 Quick sanity check + +```bash +python -c "import rich, aiohttp, websockets; print('OK')" +``` + +If you see `OK`, dependencies are installed. + +--- + +## 7. Configure `.env` (secrets) + +### 7.1 Create `.env` from the example + +**Windows** + +```powershell +copy .env.example .env +``` + +**Linux / macOS** + +```bash +cp .env.example .env +``` + +### 7.2 Fill each variable + +| Variable | Required | Example / notes | +|----------|----------|-----------------| +| `PRIVATE_KEY` | **Yes** | `0x` + 64 hex chars. **Never** commit or share. | +| `SIGNATURE_TYPE` | **Yes** | `0` = EOA (normal wallet). `1` or `2` = proxy / magic — see Polymarket docs. | +| `FUNDER_ADDRESS` | If proxy | Your Polymarket proxy wallet address when `SIGNATURE_TYPE` is 1 or 2. | +| `POLY_API_KEY` | **Yes** | From CLOB API. | +| `POLY_API_SECRET` | **Yes** | From CLOB API. | +| `POLY_API_PASSPHRASE` | **Yes** | From CLOB API. | +| `RPC_URL` | Optional | Default `https://polygon-rpc.com`, Alchemy/Infura recommended for production. | +| `CHAIN_ID` | Optional | `137` for Polygon mainnet. | +| `CLOB_HOST` | Optional | Usually `https://clob.polymarket.com`. | +| `TELEGRAM_BOT_TOKEN` | Optional | From @BotFather. | +| `TELEGRAM_CHAT_ID` | Optional | Your numeric chat id (e.g. from @userinfobot). | + +### 7.3 Where to get API keys + +- Log in to Polymarket, open the **CLOB API** / developer settings, and create **API credentials** (key, secret, passphrase). +- Official URL referenced in the repo: [https://clob.polymarket.com](https://clob.polymarket.com) + +### 7.4 Example `.env` shape (fake values) + +```env +PRIVATE_KEY=0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +SIGNATURE_TYPE=0 +FUNDER_ADDRESS= + +POLY_API_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +POLY_API_SECRET=your_secret_here +POLY_API_PASSPHRASE=your_passphrase_here + +RPC_URL=https://polygon-rpc.com +CHAIN_ID=137 +CLOB_HOST=https://clob.polymarket.com + +TELEGRAM_BOT_TOKEN= +TELEGRAM_CHAT_ID= +``` + +Save the file. **Confirm `.env` is gitignored** (do not commit). + +--- + +## 8. Configure `config.json` (strategy and execution) + +Edit **`config.json`** in the project root. Below: **what each block does**, **recommended ranges**, and **numeric examples**. + +### 8.1 `strategy` block + +| Parameter | Meaning | Example | +|-----------|---------|---------| +| `min_price` | Minimum favorite token price to allow entry | `0.75` — ignore favorites below $0.75 | +| `max_price` | Maximum favorite token price | `0.88` — do not buy above $0.88 | +| `min_elapsed_sec` | Seconds after market open before entry | `530` — wait ~8.8 min | +| `min_deviation_pct` | Deviation must be **>** this | `3` — need more than 3% above VWAP | +| `max_deviation_pct` | Deviation must be **<** this | `100` — effectively no upper cap | +| `no_entry_before_end_sec` | Stop entering if `time_left ≤` this | `335` — no new entries in last ~5.6 min | +| `momentum_window_sec` | Seconds of history for momentum | `60` — compare to ~1 minute ago | +| `vwap_window_sec` | Seconds of trades for VWAP | `30` — short-term average | +| `win_rate_csv` | Path to CSV for dashboard win rate | `"data/win_rate.csv"` | + +**Deviation example** + +- VWAP (30s) = **0.78**, last = **0.80** → deviation ≈ **2.56%** → fails if `min_deviation_pct` is **3** +- Last = **0.81** → deviation ≈ **3.85%** → passes if `min_deviation_pct` is **3** + +### 8.2 `entry` block + +| Parameter | Meaning | Example | +|-----------|---------|---------| +| `bet_amount_usd` | Target spend per entry (subject to sizing rules) | `5` → roughly $5 notional | +| `price_offset` | Added to price when placing FAK (more aggressive fill) | `0.02` → pay up to +$0.02 vs reference | +| `order_type` | Entry type | `"FAK"` (fill and kill) | +| `max_retries` | Retries if order does not complete as expected | `3` | +| `retry_delay_ms` | Pause between retries | `300` | +| `fill_timeout_ms` | Used in executor / fill logic | `1000` | +| `min_contracts` | Polymarket minimum is often 5 | `5` | +| `min_order_usd` | Minimum order size in USD | `1` | +| `max_entry_price` | Hard cap on execution price | `0.88` — should align with `strategy.max_price` | +| `ws_recovery_timeout_sec` | After HTTP timeout, how long to watch User WS for fills | `10` | + +**Sizing example** + +- `bet_amount_usd = 5`, best ask ≈ **0.80** → rough contracts = floor(5 / 0.80) = **6** (subject to mins and API). + +### 8.3 `hedge` block + +| Parameter | Meaning | Example | +|-----------|---------|---------| +| `enabled` | `true` / `false` | `false` for beginners | +| `hedge_price` | Limit price for opposite token | `0.02` | +| `order_type` | Usually `"GTD"` | passive limit | +| `max_retries` | Placement retries | `3` | +| `retry_delay_ms` | Delay between retries | `1000` | + +**Hedge intuition (not financial advice)** +After a long on UP, a **cheap** limit order on DOWN can act as a partial hedge if the market moves so that DOWN trades near your limit. **Costs and risks** are real; start with `enabled: false` until you understand fills. + +### 8.4 `redeem` block + +| Parameter | Meaning | Example | +|-----------|---------|---------| +| `enabled` | Run periodic on-chain redemption | `true` | +| `interval_seconds` | Seconds between scans | `180` | +| `auto_confirm` | Confirm in code path | `true` | + +**Note:** On **Windows**, some Unix-only locking in redeem may fail; **Linux** or **WSL** is safer for production. + +### 8.5 `telegram` block + +| Parameter | Meaning | +|-----------|---------| +| `enabled` | `true` to send Telegram messages | +| `chart_every_n_trades` | Intended for periodic equity charts (see `TelegramNotifier.send_equity_chart`); **may not be wired** in `main.py` in all versions — check the code if you rely on auto-charts | + +Tokens and chat id still come from **`.env`**. + +### 8.6 `logging` block in `config.json` + +The repo may include a `logging` section for documentation. **Current `main.py` sets logging in code** (e.g. `logs/bot.log`, `INFO` level). Do not assume `config.json` logging keys change behavior unless you wire them in code. + +### 8.7 Preset ideas (copy-paste starting points) + +**Conservative (fewer trades, tighter band)** + +```json +"strategy": { + "min_price": 0.80, + "max_price": 0.85, + "min_elapsed_sec": 600, + "min_deviation_pct": 5, + "max_deviation_pct": 100, + "no_entry_before_end_sec": 300, + "momentum_window_sec": 60, + "vwap_window_sec": 30, + "win_rate_csv": "data/win_rate.csv" +}, +"entry": { "bet_amount_usd": 2 }, +"hedge": { "enabled": false } +``` + +**Aggressive (more trades — higher risk)** + +```json +"strategy": { + "min_price": 0.70, + "max_price": 0.90, + "min_elapsed_sec": 400, + "min_deviation_pct": 0, + "max_deviation_pct": 100, + "no_entry_before_end_sec": 120, + "momentum_window_sec": 60, + "vwap_window_sec": 30, + "win_rate_csv": "data/win_rate.csv" +}, +"entry": { "bet_amount_usd": 5 }, +"hedge": { "enabled": false } +``` + +--- + +## 9. Run the bot + +1. Activate **venv** (see [§6.2](#62-create-and-activate-a-virtual-environment)). +2. Ensure `.env` and `config.json` are saved. +3. From the **project root** (folder containing `main.py`): + +```bash +python main.py +``` + +### 9.1 What you should see + +- Startup messages (config summary, CLOB init). +- A **live Rich dashboard**: timer, UP/DOWN token panels, indicators, **Strategy** line, P&L. +- When a **BUY UP** / **BUY DOWN** signal is valid, the bot fires an entry (real money if your keys are live). + +### 9.2 Stop the bot + +Press **Ctrl+C** in the terminal. On Windows, Unix signal handlers may be limited; **Ctrl+C** still stops the process. + +### 9.3 First-time recommendation + +- Set **`bet_amount_usd`** small. +- Set **`hedge.enabled`** to **`false`** until you understand behavior. +- Watch **`logs/`** while the market runs. + +--- + +## 10. Optional: Telegram + +1. **@BotFather** → `/newbot` → copy **token** → `TELEGRAM_BOT_TOKEN` in `.env`. +2. **@userinfobot** → `/start` → copy **Id** → `TELEGRAM_CHAT_ID`. +3. Open your bot in Telegram and tap **Start** (required). +4. In `config.json`, set `"telegram": { "enabled": true, ... }`. + +--- + +## 11. Optional: P&L chart + +After you have trades in **`logs/trading_log.json`**: + +```bash +python chart_pnl.py +``` + +Output image: **`logs/pnl_chart.png`** (see `chart_pnl.py`). + +--- + +## 12. Logs and files + +| File / folder | Content | +|----------------|---------| +| `logs/bot.log` | General bot log | +| `logs/orders.log` | Order execution detail | +| `logs/hedges.log` | Hedge-related logs | +| `logs/signals.log` | Signal snapshots | +| `logs/trading_log.json` | Persisted trades + stats | +| `logs/pnl_chart.png` | Generated by `chart_pnl.py` | + +--- + +## 13. Troubleshooting + +| Problem | What to try | +|--------|-------------| +| `python` not found (Windows) | Reinstall Python with **Add to PATH**, or use `py -m venv venv` | +| `NotImplementedError` on `add_signal_handler` | Already fixed on Windows in `main.py` — use latest code | +| Config errors on startup | Read the printed message; usually missing `PRIVATE_KEY` or API fields | +| `python` works but imports fail | Activate **venv** and run `pip install -r requirements.txt` again | +| No trades for a long time | Strategy window is narrow (see [§2.5](#25-time-window-for-entries-15-minutes--900-seconds)); or market never satisfies all filters | +| Redeem errors on Windows | Prefer **WSL** or **Linux** for auto-redeem; or disable `redeem.enabled` and redeem manually on Polymarket | +| Telegram not sending | Bot token + chat id + user pressed **Start** on bot; `enabled: true` | + +--- + +## 14. Risk summary + +- **Real money** — you can lose your stake. +- **No strategy edge is guaranteed** — this bot automates rules. +- **Fees, slippage, and failed orders** happen. +- **Protect your private key** — treat `.env` like a password. + +For **multi-asset late-entry** trading, see **Meridian** (`up-down-spread-bot`) in the same repository. For **PTB / oracle-diff** rules and a web dashboard, see `5min-15min-PTB-bot`. Extended **quant** offerings (Kelly, Monte Carlo, advanced TA, sizing systems) are described in the [repository root README](https://github.com/PolyBullLabs/polymakret-5min-15min-1hour-arbitrage-bot) — contact [@terauss](https://t.me/terauss). + +--- + +*For a single-page parameter list, see [`CONFIG.md`](../CONFIG.md). For internals, see [`PROJECT_LOGIC.md`](../PROJECT_LOGIC.md).* diff --git a/btc-binary-VWAP-Momentum-bot/main.py b/btc-binary-VWAP-Momentum-bot/main.py new file mode 100644 index 0000000..f84e87a --- /dev/null +++ b/btc-binary-VWAP-Momentum-bot/main.py @@ -0,0 +1,2490 @@ +#!/usr/bin/env python3 +""" +BTC 5/15-min Live Trading Bot with Real-time Dashboard + +Single file that combines: +- Visual terminal dashboard (rich) +- Real order execution +- Hedge management +- Auto-redemption +- Telegram notifications + +Usage: + python main.py +""" + +import asyncio +import json +import time +import csv +import math +import statistics +import logging +import signal +import sys +from datetime import datetime, timezone +from collections import deque +from dataclasses import dataclass, field +from typing import Optional, Dict, List, Any +from pathlib import Path + +import aiohttp +import websockets +from rich.console import Console +from rich.live import Live +from rich.panel import Panel +from rich.layout import Layout +import os +from dotenv import load_dotenv # noqa: E402 + +# Setup logging +Path("logs").mkdir(exist_ok=True) + +# Pre-load .env EARLY so proxy detection can read HTTP_PROXY from it +_PROJECT_ROOT = Path(__file__).parent +load_dotenv(_PROJECT_ROOT / ".env") + +# Shared proxy helpers (HTTP CONNECT tunnel for websockets 13.x) +from src.proxy_util import ( # noqa: E402 + apply_proxy_env, + get_proxy_url, + ws_connect, + ws_connect_kwargs as _ws_connect_kwargs, +) + +_PROXY_URL = apply_proxy_env() +if _PROXY_URL: + print(f"[proxy] Detected proxy: {_PROXY_URL}") + print(f"[proxy] WebSocket via HTTP CONNECT tunnel; aiohttp via trust_env/proxy=") + print(f"[proxy] websockets={getattr(websockets, '__version__', '?')}") +else: + print("[proxy] No HTTP_PROXY / HTTPS_PROXY env var detected. " + "If you are in a restricted region, set them in .env.") + + +def _aiohttp_session(**kwargs) -> aiohttp.ClientSession: + """Create an aiohttp ClientSession that respects env proxy settings.""" + timeout = kwargs.pop("timeout", None) or aiohttp.ClientTimeout(total=30) + return aiohttp.ClientSession( + timeout=timeout, + trust_env=True, + **kwargs + ) + + +# Main logger +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s [%(name)s] %(levelname)s: %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + handlers=[logging.FileHandler('logs/bot.log')] +) +logger = logging.getLogger("btc_live") + +# Detailed order execution logger +order_logger = logging.getLogger("btc_live.orders") +order_handler = logging.FileHandler('logs/orders.log') +order_handler.setFormatter(logging.Formatter( + '%(asctime)s %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' +)) +order_logger.addHandler(order_handler) +order_logger.setLevel(logging.DEBUG) + +# Detailed hedge logger +hedge_logger = logging.getLogger("btc_live.hedges") +hedge_handler = logging.FileHandler('logs/hedges.log') +hedge_handler.setFormatter(logging.Formatter( + '%(asctime)s %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' +)) +hedge_logger.addHandler(hedge_handler) +hedge_logger.setLevel(logging.DEBUG) + +# Signals logger +signal_logger = logging.getLogger("btc_live.signals") +signal_handler = logging.FileHandler('logs/signals.log') +signal_handler.setFormatter(logging.Formatter( + '%(asctime)s %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' +)) +signal_logger.addHandler(signal_handler) +signal_logger.setLevel(logging.DEBUG) + +# Project imports +from src.config_loader import load_config, validate_config +from src.web_dashboard import WebSnapshotHolder, start_web_dashboard +from src.order_executor import OrderExecutor, ExecutionConfig +from src.hedge_manager import HedgeManager, HedgeConfig as HedgeManagerConfig, HedgeResult +from src.auto_redeemer import AsyncAutoRedeemer +from src.telegram_notifier import TelegramNotifier +from src.user_websocket import UserWebSocket +from src.simulation_history import SimulationHistoryLogger + +# Constants +GAMMA_API = "https://gamma-api.polymarket.com" +WSS_URL = "wss://ws-subscriptions-clob.polymarket.com/ws/market" +RTDS_URL = "wss://ws-live-data.polymarket.com" + +console = Console() + +# ============================================================================= +# DATA CLASSES +# ============================================================================= + +@dataclass +class Trade: + """Single trade record""" + timestamp: float + price: float + size: float + side: str + + +@dataclass +class TokenData: + """Data for a single token (Up or Down)""" + token_id: str + name: str + + best_bid: float = 0.0 + best_bid_size: float = 0.0 + best_ask: float = 0.0 + best_ask_size: float = 0.0 + + trades: deque = field(default_factory=lambda: deque(maxlen=5000)) + + last_price: float = 0.0 + last_trade_time: float = 0.0 + + trade_count: int = 0 + volume_total: float = 0.0 + volume_buy: float = 0.0 + volume_sell: float = 0.0 + + def reset(self): + self.best_bid = 0.0 + self.best_bid_size = 0.0 + self.best_ask = 0.0 + self.best_ask_size = 0.0 + self.trades.clear() + self.last_price = 0.0 + self.last_trade_time = 0.0 + self.trade_count = 0 + self.volume_total = 0.0 + self.volume_buy = 0.0 + self.volume_sell = 0.0 + + +@dataclass +class MarketState: + """Current market state""" + market_id: str = "" + condition_id: str = "" + slug: str = "" + end_time: float = 0.0 + + up_token: Optional[TokenData] = None + down_token: Optional[TokenData] = None + + connected: bool = False + last_update: float = 0.0 + + # Chainlink BTC/USD price tracking + btc_anchor_price: float = 0.0 # Price at market start + btc_current_price: float = 0.0 # Latest Chainlink price + btc_last_update: float = 0.0 # Timestamp of last price update + btc_connected: bool = False # RTDS connection status + + +@dataclass +class Position: + """Current open position""" + token_name: str + token_id: str + opposite_token_id: str + entry_price: float + contracts: int + entry_time: float + market_slug: str + hedged: bool = False + hedge_contracts: int = 0 + hedge_price: float = 0.0 + min_price_seen: float = 0.0 # Lowest price after entry (for drawdown tracking) + + +@dataclass +class TradeRecord: + """Completed trade record""" + market_slug: str + token_name: str + entry_price: float + exit_price: float + contracts: int + pnl: float + won: bool + timestamp: float + max_drawdown_abs: float = 0.0 # Max absolute price drop from entry + max_drawdown_pct: float = 0.0 # Max percentage drawdown from entry + + +# ============================================================================= +# UTILITIES +# ============================================================================= + +class IndicatorCalculator: + @staticmethod + def get_trades_in_window(trades: deque, window_seconds: float) -> List[Trade]: + now = time.time() + cutoff = now - window_seconds + return [t for t in trades if t.timestamp >= cutoff] + + @staticmethod + def calc_vwap(trades: List[Trade]) -> float: + if not trades: + return 0.0 + total_value = sum(t.price * t.size for t in trades) + total_volume = sum(t.size for t in trades) + return total_value / total_volume if total_volume > 0 else 0.0 + + @staticmethod + def calc_deviation(current_price: float, vwap: float) -> float: + if vwap == 0: + return 0.0 + return ((current_price - vwap) / vwap) * 100 + + @staticmethod + def calc_momentum(trades: deque, current_price: float, window: float = 120, avg_band: float = 1.5) -> Optional[float]: + """ + Price change vs average price ~window seconds ago. + + Takes all trades in [now-window-avg_band, now-window+avg_band] (3s band), + computes arithmetic mean, returns % change from that to current_price. + + Returns None if no trades found in the band (not enough history). + """ + now = time.time() + band_start = now - window - avg_band + band_end = now - window + avg_band + + band_prices = [t.price for t in trades if band_start <= t.timestamp <= band_end] + + if not band_prices: + return None + + avg_price_ago = sum(band_prices) / len(band_prices) + if avg_price_ago == 0: + return None + + return ((current_price - avg_price_ago) / avg_price_ago) * 100 + + @staticmethod + def calc_zscore(trades: deque, current_price: float, window: float = 5) -> float: + now = time.time() + recent = [t for t in trades if t.timestamp >= now - window] + if len(recent) < 2: + return 0.0 + prices = [t.price for t in recent] + mean_price = statistics.mean(prices) + std_price = statistics.stdev(prices) if len(prices) > 1 else 0.001 + return (current_price - mean_price) / std_price if std_price > 0 else 0.0 + + +class WinRateTable: + def __init__(self, csv_path: str): + self.data = {} + self.price_ranges = [] + self._load(csv_path) + + def _load(self, csv_path): + try: + with open(csv_path, 'r') as f: + reader = csv.reader(f) + next(reader) # Skip header + for row in reader: + if not row or not row[0]: + continue + price_range = row[0] + self.price_ranges.append(price_range) + self.data[price_range] = {} + for i, val in enumerate(row[1:], start=0): + if val: + try: + self.data[price_range][i] = float(val) + except ValueError: + pass + except Exception as e: + logger.warning(f"Could not load win_rate.csv: {e}") + + def get_winrate(self, price: float, minute: int, interval_minutes: int = 15) -> Optional[float]: + price_range = None + for pr in self.price_ranges: + try: + low, high = pr.split('-') + if float(low) <= price <= float(high): + price_range = pr + break + except: + continue + if not price_range and price > 0.99 and self.price_ranges: + price_range = self.price_ranges[-1] + if not price_range: + return None + cap = max(0, interval_minutes - 1) + minute = max(0, min(cap, minute)) + return self.data.get(price_range, {}).get(minute) + + +# ============================================================================= +# TRADING STATS +# ============================================================================= + +class TradingStats: + def __init__(self, log_file: str = "logs/trading_log.json"): + self.log_file = Path(log_file) + self.position: Optional[Position] = None + self.trades: List[TradeRecord] = [] + self.markets_seen: int = 0 + self.current_market_slug: str = "" + self.position_closed_this_market: bool = False + self.entry_blocked: bool = False # Блокировка повторных попыток после таймаута + self._load() + + def _load(self): + try: + if self.log_file.exists(): + with open(self.log_file, 'r') as f: + data = json.load(f) + self.trades = [TradeRecord(**t) for t in data.get('trades', [])] + self.markets_seen = data.get('markets_seen', 0) + except Exception: + pass + + def summary_dict(self) -> Dict[str, Any]: + """Aggregates for dashboards and simulation summary files.""" + tc = len(self.trades) + wins = sum(1 for t in self.trades if t.won) + losses = tc - wins + total = sum(t.pnl for t in self.trades) + pnls = [t.pnl for t in self.trades] + wr = (wins / tc * 100.0) if tc else 0.0 + return { + "total_pnl_usd": round(total, 6), + "trade_count": tc, + "wins": wins, + "losses": losses, + "win_rate_pct": round(wr, 4), + "avg_trade_pnl_usd": round(total / tc, 6) if tc else 0.0, + "best_trade_pnl_usd": round(max(pnls), 6) if pnls else None, + "worst_trade_pnl_usd": round(min(pnls), 6) if pnls else None, + "last_close_unix": max((t.timestamp for t in self.trades), default=None), + } + + def _save(self): + try: + self.log_file.parent.mkdir(parents=True, exist_ok=True) + data = { + 'trades': [t.__dict__ for t in self.trades], + 'markets_seen': self.markets_seen, + 'summary': self.summary_dict(), + } + with open(self.log_file, 'w') as f: + json.dump(data, f, indent=2) + except Exception: + pass + + def new_market(self, slug: str): + if slug != self.current_market_slug: + self.current_market_slug = slug + self.markets_seen += 1 + self.position = None + self.position_closed_this_market = False + self.entry_blocked = False # Сброс блокировки для нового рынка + self._save() + + def can_enter(self) -> bool: + return self.position is None and not self.position_closed_this_market and not self.entry_blocked + + def block_entry(self, reason: str = ""): + """Блокирует повторные попытки входа на текущем рынке.""" + self.entry_blocked = True + if reason: + logger.warning(f"Entry blocked: {reason}") + + def record_entry(self, token_name: str, token_id: str, opposite_token_id: str, + price: float, contracts: int, market_slug: str): + self.position = Position( + token_name=token_name, + token_id=token_id, + opposite_token_id=opposite_token_id, + entry_price=price, + contracts=contracts, + entry_time=time.time(), + market_slug=market_slug, + min_price_seen=price # Start tracking from entry price + ) + + def record_hedge(self, contracts: int, price: float): + if self.position: + self.position.hedged = True + self.position.hedge_contracts = contracts + self.position.hedge_price = price + + def update_drawdown(self, current_price: float): + """Track minimum price seen since entry for drawdown calculation.""" + if self.position and current_price > 0: + if current_price < self.position.min_price_seen: + self.position.min_price_seen = current_price + + def close_position(self, final_price: float) -> Optional[TradeRecord]: + if not self.position: + return None + + won = final_price >= 0.70 # Win threshold + entry_cost = self.position.contracts * self.position.entry_price + + if won: + pnl = self.position.contracts - entry_cost + else: + pnl = -entry_cost + + # Calculate max drawdown from entry + dd_abs = max(0, self.position.entry_price - self.position.min_price_seen) + dd_pct = (dd_abs / self.position.entry_price * 100) if self.position.entry_price > 0 else 0 + + record = TradeRecord( + market_slug=self.position.market_slug, + token_name=self.position.token_name, + entry_price=self.position.entry_price, + exit_price=final_price, + contracts=self.position.contracts, + pnl=pnl, + won=won, + timestamp=time.time(), + max_drawdown_abs=dd_abs, + max_drawdown_pct=dd_pct, + ) + + self.trades.append(record) + self.position = None + self.position_closed_this_market = True + self._save() + return record + + @property + def total_pnl(self) -> float: + return sum(t.pnl for t in self.trades) + + @property + def win_count(self) -> int: + return sum(1 for t in self.trades if t.won) + + @property + def trade_count(self) -> int: + return len(self.trades) + + @property + def win_rate(self) -> float: + if not self.trades: + return 0.0 + return (self.win_count / self.trade_count) * 100 + + +# ============================================================================= +# WEBSOCKET CLIENT +# ============================================================================= + +class WebSocketClient: + def __init__(self, state: MarketState): + self.state = state + self.running = False + self._tokens_validated = False + self._ws: Optional[websockets.WebSocketClientProtocol] = None + + def _validate_tokens(self): + """Log token prices after first WebSocket data received. + + NOTE: Token swap logic was REMOVED because it was buggy. + The API token assignment should be trusted. + """ + if self._tokens_validated: + return + + up = self.state.up_token + down = self.state.down_token + + if not up or not down: + return + + up_price = up.best_bid or up.best_ask or up.last_price + down_price = down.best_bid or down.best_ask or down.last_price + + # Only log once we have valid prices + if up_price > 0.05 and down_price > 0.05: + price_sum = up_price + down_price + logger.info(f"Tokens validated: UP={up_price:.2f}, DOWN={down_price:.2f}, sum={price_sum:.2f}") + self._tokens_validated = True + + async def connect(self): + self.running = True + + while self.running: + try: + async with ws_connect(WSS_URL, **_ws_connect_kwargs()) as ws: + self._ws = ws + self.state.connected = True + + token_ids = [] + if self.state.up_token: + token_ids.append(self.state.up_token.token_id) + if self.state.down_token: + token_ids.append(self.state.down_token.token_id) + + # Log exact token_ids being subscribed + logger.info(f"WebSocket subscribing to tokens:") + logger.info(f" UP: {self.state.up_token.token_id[:40]}..." if self.state.up_token else " UP: None") + logger.info(f" DOWN: {self.state.down_token.token_id[:40]}..." if self.state.down_token else " DOWN: None") + + await ws.send(json.dumps({"assets_ids": token_ids, "type": "market"})) + + async for message in ws: + if not self.running: + break + await self._handle_message(message) + + self._ws = None + + except websockets.ConnectionClosed: + self._ws = None + self.state.connected = False + if self.running: + await asyncio.sleep(1) + except Exception: + self._ws = None + self.state.connected = False + if self.running: + await asyncio.sleep(2) + + async def disconnect(self): + """Gracefully close WebSocket connection with code 1000 (normal closure).""" + self.running = False + if self._ws: + try: + await self._ws.close(code=1000, reason="Normal shutdown") + logger.info("WebSocket closed gracefully (code 1000)") + except Exception as e: + logger.warning(f"Error during WebSocket close: {e}") + finally: + self._ws = None + self.state.connected = False + + async def _handle_message(self, message: str): + try: + data = json.loads(message) + + if isinstance(data, list): + for item in data: + if isinstance(item, dict): + await self._process_item(item) + elif isinstance(data, dict): + await self._process_item(data) + + self.state.last_update = time.time() + + # Validate tokens after receiving price data + if not self._tokens_validated: + self._validate_tokens() + except Exception: + pass + + async def _process_item(self, data: dict): + event_type = data.get("event_type", "") + + if event_type == "last_trade_price": + asset_id = data.get("asset_id") + token = self._get_token(asset_id) + + if not token and asset_id: + # Asset ID doesn't match our tokens - might indicate subscription issue + logger.warning(f"Received price for unknown asset: {asset_id[:30]}...") + logger.warning(f" Our UP token: {self.state.up_token.token_id[:30] if self.state.up_token else 'None'}...") + logger.warning(f" Our DOWN token: {self.state.down_token.token_id[:30] if self.state.down_token else 'None'}...") + + if token: + price = float(data.get("price", 0)) + size = float(data.get("size", 0)) + side = data.get("side", "BUY") + + if price > 0 and size > 0: + token.last_price = price + token.last_trade_time = time.time() + token.trades.append(Trade(time.time(), price, size, side)) + token.trade_count += 1 + token.volume_total += size + if side == "BUY": + token.volume_buy += size + else: + token.volume_sell += size + + elif event_type == "price_change": + for change in data.get("price_changes", []): + token = self._get_token(change.get("asset_id")) + if token: + if change.get("best_bid"): + token.best_bid = float(change["best_bid"]) + if change.get("best_ask"): + token.best_ask = float(change["best_ask"]) + + elif event_type == "book": + token = self._get_token(data.get("asset_id")) + if token: + bids = data.get("bids", []) + if bids: + bids.sort(key=lambda x: float(x["price"]), reverse=True) + token.best_bid = float(bids[0]["price"]) + token.best_bid_size = float(bids[0]["size"]) + asks = data.get("asks", []) + if asks: + asks.sort(key=lambda x: float(x["price"])) + token.best_ask = float(asks[0]["price"]) + token.best_ask_size = float(asks[0]["size"]) + + def _get_token(self, asset_id: str) -> Optional[TokenData]: + if self.state.up_token and asset_id == self.state.up_token.token_id: + return self.state.up_token + elif self.state.down_token and asset_id == self.state.down_token.token_id: + return self.state.down_token + return None + + def stop(self): + """Stop WebSocket (sync version - just sets flag).""" + self.running = False + + async def stop_graceful(self): + """Stop WebSocket gracefully with proper close.""" + await self.disconnect() + + +# ============================================================================= +# CHAINLINK BTC PRICE CLIENT +# ============================================================================= + +class ChainlinkPriceClient: + """ + Always-on BTC/USD price stream from Polymarket RTDS (Chainlink source). + + Connects to wss://ws-live-data.polymarket.com and subscribes to + crypto_prices_chainlink for btc/usd. + + Autonomously tracks market boundaries (epoch-aligned to interval length) + and snapshots the anchor price at the exact boundary crossing, independent + of the bot's market finding flow. This ensures the anchor is captured + within ~1 second of the real boundary, not 5-15s later. + """ + + def __init__(self, state: 'MarketState', market_duration_sec: int): + self.state = state + self._market_duration = int(market_duration_sec) + if self._market_duration <= 0: + self._market_duration = 900 + self.running = False + self._ws = None + self._ping_task: Optional[asyncio.Task] = None + # Track which window the current anchor belongs to + self._current_window: int = 0 + # Buffer: last price before boundary (for most accurate anchor) + self._last_price_before_boundary: float = 0.0 + self._last_price_ts: float = 0.0 + + def _get_window(self, ts: float) -> int: + """Window start timestamp (epoch) for the configured interval.""" + d = self._market_duration + return int(ts) // d * d + + DATA_TIMEOUT = 30 # seconds without any message → force reconnect + + async def connect(self): + """Connect to RTDS and subscribe to Chainlink BTC/USD prices. Always on.""" + self.running = True + self._last_msg_time = time.time() + + while self.running: + try: + async with ws_connect(RTDS_URL, **_ws_connect_kwargs()) as ws: + self._ws = ws + self.state.btc_connected = True + self._last_msg_time = time.time() + logger.info("RTDS Chainlink connected") + + # Subscribe to chainlink prices (all symbols, filter in code) + subscribe_msg = json.dumps({ + "action": "subscribe", + "subscriptions": [{ + "topic": "crypto_prices_chainlink", + "type": "*", + "filters": "" + }] + }) + await ws.send(subscribe_msg) + + # Start ping task and watchdog + self._ping_task = asyncio.create_task(self._ping_loop(ws)) + watchdog_task = asyncio.create_task(self._watchdog(ws)) + + try: + async for message in ws: + if not self.running: + break + self._last_msg_time = time.time() + self._handle_message(message) + finally: + watchdog_task.cancel() + try: + await watchdog_task + except asyncio.CancelledError: + pass + + self._ws = None + + except websockets.ConnectionClosed: + self._ws = None + self.state.btc_connected = False + if self.running: + logger.warning("RTDS Chainlink disconnected, reconnecting in 2s...") + await asyncio.sleep(2) + except Exception as e: + self._ws = None + self.state.btc_connected = False + if self.running: + logger.warning( + f"RTDS Chainlink error: {type(e).__name__}: {e or repr(e)}, " + f"reconnecting in 5s..." + ) + await asyncio.sleep(5) + finally: + if self._ping_task and not self._ping_task.done(): + self._ping_task.cancel() + try: + await self._ping_task + except: + pass + self._ping_task = None + + async def _watchdog(self, ws): + """Force-close WebSocket if no messages received for DATA_TIMEOUT seconds.""" + try: + while self.running: + await asyncio.sleep(5) + silence = time.time() - self._last_msg_time + if silence > self.DATA_TIMEOUT: + logger.warning( + f"RTDS Chainlink watchdog: no data for {silence:.0f}s, forcing reconnect" + ) + self.state.btc_connected = False + await ws.close() + break + except asyncio.CancelledError: + pass + + def _handle_message(self, message: str): + """Parse incoming Chainlink price message and auto-detect market boundaries.""" + try: + if not isinstance(message, str) or not message.strip(): + return + + data = json.loads(message) + topic = data.get("topic", "") + + if topic != "crypto_prices_chainlink": + return + + payload = data.get("payload", {}) + symbol = payload.get("symbol", "") + + if symbol != "btc/usd": + return + + price = float(payload.get("value", 0)) + if price <= 0: + return + + # Use Chainlink's own timestamp (ms) for precise boundary detection + chainlink_ts_ms = payload.get("timestamp", 0) + if chainlink_ts_ms: + price_ts = chainlink_ts_ms / 1000.0 + else: + price_ts = time.time() + + now = time.time() + + # Update current price (always) + self.state.btc_current_price = price + self.state.btc_last_update = now + + # === CALIBRATION LOG: every tick within [-15s..+5s] of any boundary === + price_window = self._get_window(price_ts) + next_boundary = price_window + self._market_duration + secs_to_next = next_boundary - price_ts + secs_from_prev = price_ts - price_window + + # Log if within 15s before next boundary OR 5s after current boundary start + if secs_to_next <= 15.0 or secs_from_prev <= 5.0: + cl_time = datetime.fromtimestamp(price_ts, tz=timezone.utc).strftime('%H:%M:%S.%f')[:-3] + local_time = datetime.fromtimestamp(now, tz=timezone.utc).strftime('%H:%M:%S.%f')[:-3] + if secs_from_prev <= 5.0: + offset_str = f"+{secs_from_prev:.3f}s after {datetime.fromtimestamp(price_window, tz=timezone.utc).strftime('%H:%M:%S')}" + else: + offset_str = f"-{secs_to_next:.3f}s before {datetime.fromtimestamp(next_boundary, tz=timezone.utc).strftime('%H:%M:%S')}" + logger.info( + f"BTC_TICK {cl_time} (local {local_time}) ${price:,.2f} [{offset_str}]" + ) + + # Detect window boundary crossing + + if self._current_window == 0: + # First price ever — initialize + self._current_window = price_window + self.state.btc_anchor_price = price + logger.info( + f"BTC Chainlink init: ${price:,.2f} " + f"(window {self._current_window}, " + f"ts={datetime.fromtimestamp(price_ts, tz=timezone.utc).strftime('%H:%M:%S.%f')[:-3]})" + ) + elif price_window != self._current_window: + # === NEW WINDOW === use FIRST tick of new window as anchor + # Calibrated: reference program uses the first tick AT or AFTER boundary + old_anchor = self.state.btc_anchor_price + old_window = self._current_window + + self.state.btc_anchor_price = price # First tick of new window + self._current_window = price_window + + boundary_time = datetime.fromtimestamp(price_window, tz=timezone.utc).strftime('%H:%M:%S') + price_time = datetime.fromtimestamp(price_ts, tz=timezone.utc).strftime('%H:%M:%S.%f')[:-3] + delay_ms = (price_ts - price_window) * 1000 + + logger.info( + f"BTC anchor reset: ${self.state.btc_anchor_price:,.2f} " + f"(boundary {boundary_time}, first tick at {price_time}, " + f"delay {delay_ms:.0f}ms, prev anchor ${old_anchor:,.2f})" + ) + + # Always buffer the latest price for next boundary crossing + self._last_price_before_boundary = price + self._last_price_ts = price_ts + + except (json.JSONDecodeError, ValueError, KeyError): + pass + + async def _ping_loop(self, ws): + """Send ping every 5 seconds to keep connection alive.""" + try: + while self.running: + await asyncio.sleep(5) + try: + await ws.ping() + except Exception: + break + except asyncio.CancelledError: + pass + + async def disconnect(self): + """Gracefully close RTDS WebSocket connection.""" + self.running = False + + if self._ping_task and not self._ping_task.done(): + self._ping_task.cancel() + try: + await self._ping_task + except: + pass + self._ping_task = None + + if self._ws: + try: + # Unsubscribe before closing + unsub_msg = json.dumps({ + "action": "unsubscribe", + "subscriptions": [{ + "topic": "crypto_prices_chainlink", + "type": "*", + "filters": "" + }] + }) + await self._ws.send(unsub_msg) + await self._ws.close(code=1000, reason="Normal shutdown") + logger.info("RTDS Chainlink closed gracefully") + except Exception as e: + logger.warning(f"RTDS close error: {e}") + finally: + self._ws = None + + self.state.btc_connected = False + + +# ============================================================================= +# DASHBOARD +# ============================================================================= + +class Dashboard: + def __init__(self, state: MarketState, stats: TradingStats, config: Any): + self.state = state + self.stats = stats + self.config = config + self.calc = IndicatorCalculator() + + win_rate_path = Path(__file__).parent / config.strategy.win_rate_csv + self.winrate_table = WinRateTable(str(win_rate_path)) + + self.last_signal = "" + self.entry_flash = False + self.hedge_flash = False + + def _fmt_price(self, price: float) -> str: + if price >= 0.6: + return f"[green]{price:.3f}[/green]" + elif price <= 0.4: + return f"[red]{price:.3f}[/red]" + return f"[yellow]{price:.3f}[/yellow]" + + def _fmt_dev(self, dev: float) -> str: + if dev > 5: + return f"[bold green]+{dev:.1f}%[/bold green]" + elif dev > 0: + return f"[green]+{dev:.1f}%[/green]" + elif dev < -5: + return f"[bold red]{dev:.1f}%[/bold red]" + elif dev < 0: + return f"[red]{dev:.1f}%[/red]" + return f"{dev:+.1f}%" + + def _fmt_zscore(self, z: float) -> str: + if z > 2: + return f"[bold magenta]+{z:.2f}[/bold magenta] ⚡" + elif z > 1: + return f"[magenta]+{z:.2f}[/magenta]" + elif z < -2: + return f"[bold cyan]{z:.2f}[/bold cyan] ⚡" + elif z < -1: + return f"[cyan]{z:.2f}[/cyan]" + return f"{z:+.2f}" + + def create_header(self) -> Panel: + now = time.time() + time_left = max(0, self.state.end_time - now) + minutes = int(time_left // 60) + seconds = int(time_left % 60) + + if time_left < 60: + timer = f"[bold red]⏱️ {seconds}s[/bold red]" + elif time_left < 180: + timer = f"[yellow]⏱️ {minutes}:{seconds:02d}[/yellow]" + else: + timer = f"[green]⏱️ {minutes}:{seconds:02d}[/green]" + + status = "[green]● LIVE[/green]" if self.state.connected else "[red]○ DISCONNECTED[/red]" + if getattr(self.config, "simulation", None) and self.config.simulation.enabled: + mode = "[bold yellow]SIMULATION (no real orders)[/bold yellow]" + else: + mode = "[bold cyan]REAL TRADING[/bold cyan]" + + header = f"{timer} | {self.state.slug} | {status} | {mode}" + im = self.config.market.interval_minutes + return Panel(header, title=f"[bold]BTC {im}-Min Live Bot[/bold]") + + def create_token_panel(self, token: TokenData, label: str) -> Panel: + if not token: + return Panel("No data", title=label) + + lines = [] + if token.best_ask > 0: + lines.append(f"[red]ASK {token.best_ask:.3f}[/red] | {token.best_ask_size:.0f}") + else: + lines.append(f"[red]ASK ---[/red]") + + lines.append("─" * 20) + lines.append(f"[bold white]LAST {token.last_price:.3f}[/bold white]") + + if token.best_ask > 0 and token.best_bid > 0: + spread = token.best_ask - token.best_bid + lines.append(f"[dim]Spread: {spread:.3f}[/dim]") + + lines.append("─" * 20) + + if token.best_bid > 0: + lines.append(f"[green]BID {token.best_bid:.3f}[/green] | {token.best_bid_size:.0f}") + else: + lines.append(f"[green]BID ---[/green]") + + return Panel( + "\n".join(lines), + title=f"[bold]{label}[/bold] - {self._fmt_price(token.last_price)}", + border_style="green" if "Up" in label else "red" + ) + + def _fmt_momentum(self, m: Optional[float]) -> str: + if m is None: + return "[dim]N/A[/dim]" + if m > 0: + return f"[green]+{m:.2f}%[/green]" + elif m < 0: + return f"[red]{m:.2f}%[/red]" + return f"[cyan]0.00%[/cyan]" + + def create_indicators_panel(self, token: TokenData, label: str) -> Panel: + if not token or not token.trades: + return Panel("Waiting for data...", title=f"{label} Indicators") + + mom_window = self.config.strategy.momentum_window_sec + + vwap_window = self.config.strategy.vwap_window_sec + vwap = self.calc.calc_vwap(self.calc.get_trades_in_window(token.trades, vwap_window)) + deviation = self.calc.calc_deviation(token.last_price, vwap) + zscore = self.calc.calc_zscore(token.trades, token.last_price, window=5) + momentum = self.calc.calc_momentum(token.trades, token.last_price, window=mom_window) + + def fmt_vol(v): + if v >= 1_000_000: + return f"{v/1_000_000:.1f}M" + elif v >= 1_000: + return f"{v/1_000:.1f}K" + return f"{v:.0f}" + + lines = [ + f"VWAP {vwap_window}s: {vwap:.4f}", + f"Deviation: {self._fmt_dev(deviation)}", + f"Z-Score 5s: {self._fmt_zscore(zscore)}", + f"Mom {mom_window}s: {self._fmt_momentum(momentum)}", + "", + f"Trades: {token.trade_count}", + f"Volume: {fmt_vol(token.volume_total)}", + f" Buy: [green]{fmt_vol(token.volume_buy)}[/green]", + f" Sell: [red]{fmt_vol(token.volume_sell)}[/red]", + ] + + return Panel("\n".join(lines), title=f"{label} Indicators", border_style="blue") + + def create_strategy_panel(self) -> Panel: + if not self.state.up_token or not self.state.down_token: + return Panel("Waiting for data...", title="Strategy Signal") + + up = self.state.up_token + down = self.state.down_token + + vwap_window = self.config.strategy.vwap_window_sec + up_vwap = self.calc.calc_vwap(self.calc.get_trades_in_window(up.trades, vwap_window)) + down_vwap = self.calc.calc_vwap(self.calc.get_trades_in_window(down.trades, vwap_window)) + + up_dev = self.calc.calc_deviation(up.last_price, up_vwap) + down_dev = self.calc.calc_deviation(down.last_price, down_vwap) + + mom_window = self.config.strategy.momentum_window_sec + up_mom = self.calc.calc_momentum(up.trades, up.last_price, window=mom_window) + down_mom = self.calc.calc_momentum(down.trades, down.last_price, window=mom_window) + + time_left = max(0, self.state.end_time - time.time()) + time_minutes = time_left / 60 + span = self.config.market.interval_minutes + time_bin = int((span - 1) - time_minutes) + time_bin = max(0, min(time_bin, span - 1)) + + if up.last_price > down.last_price: + fav_name = "UP" + fav_price = up.last_price + fav_dev = up_dev + fav_mom = up_mom + else: + fav_name = "DOWN" + fav_price = down.last_price + fav_dev = down_dev + fav_mom = down_mom + + base_wr = self.winrate_table.get_winrate(fav_price, time_bin, span) + wr_str = f"{base_wr:.1f}%" if base_wr else "N/A" + + min_price = self.config.strategy.min_price + max_price = self.config.strategy.max_price + min_elapsed = self.config.strategy.min_elapsed_sec + min_dev = self.config.strategy.min_deviation_pct + max_dev = self.config.strategy.max_deviation_pct + + no_entry_cutoff = self.config.strategy.no_entry_before_end_sec + + elapsed_sec = self.config.market.duration_sec - time_left + + price_ok = min_price <= fav_price <= max_price + time_ok = elapsed_sec >= min_elapsed + dev_ok = fav_dev > min_dev and fav_dev < max_dev + mom_ok = fav_mom is not None and fav_mom > 5 + time_cutoff_ok = time_left > no_entry_cutoff + + signal = "⏳ WAIT" + signal_color = "yellow" + + if not time_cutoff_ok: + signal = f"🚫 NO ENTRY (< {no_entry_cutoff}s left)" + signal_color = "red" + self.last_signal = "" + elif price_ok and time_ok and dev_ok and mom_ok: + signal = f"✅ BUY {fav_name}" + signal_color = "bold green" + self.last_signal = f"BUY_{fav_name}" + elif fav_price >= 0.70 and time_ok: + if not mom_ok: + signal = "🟡 ALMOST (need Mom>0%)" + elif fav_dev >= max_dev: + signal = f"🟡 ALMOST (Dev≥{max_dev}%)" + else: + signal = "🟡 ALMOST (need dev)" + self.last_signal = "" + else: + self.last_signal = "" + if not time_ok: + signal = f"⏳ WAIT (elapsed<{min_elapsed}s)" + elif not price_ok: + signal = f"⏳ WAIT (P not in range)" + elif not dev_ok: + if fav_dev >= max_dev: + signal = f"⏳ WAIT (Dev≥{max_dev}%)" + else: + signal = f"⏳ WAIT (Dev<{min_dev}%)" + elif not mom_ok: + signal = f"⏳ WAIT (Mom≤0%)" + + lines = [ + f"Favorite: [{signal_color}]{fav_name} ({fav_price:.3f})[/{signal_color}] — WR: [cyan]{wr_str}[/cyan]", + f"Signal: [{signal_color}][bold]{signal}[/bold][/{signal_color}]", + "", + f"Price: {self._fmt_price(fav_price)} (range: {min_price}-{max_price})", + f"Deviation: {self._fmt_dev(fav_dev)} (need {min_dev}%–{max_dev}%)", + f"Momentum: {self._fmt_momentum(fav_mom)}", + f"Elapsed: {int(elapsed_sec)}s (need ≥{min_elapsed}s) [bin {time_bin}]", + "", + f"Up: {self._fmt_price(up.last_price)} | Dev: {self._fmt_dev(up_dev)} | Mom: {self._fmt_momentum(up_mom)}", + f"Down: {self._fmt_price(down.last_price)} | Dev: {self._fmt_dev(down_dev)} | Mom: {self._fmt_momentum(down_mom)}", + ] + + title = f"[bold]Strategy: P {min_price}-{max_price}, T≥{min_elapsed}s, Dev {min_dev}%-{max_dev}%[/bold]" + border = "green" if signal_color == "bold green" else "magenta" + return Panel("\n".join(lines), title=title, border_style=border) + + def create_trading_panel(self) -> Panel: + s = self.stats + bet = self.config.entry.bet_amount_usd + + wr_str = f"{s.win_rate:.1f}%" if s.trade_count > 0 else "N/A" + stats_line = f"📊 Markets: {s.markets_seen} | Trades: {s.trade_count} | WR: {wr_str}" + + pnl_color = "green" if s.total_pnl >= 0 else "red" + pnl_line = f"💰 PnL: [{pnl_color}]${s.total_pnl:+.2f}[/{pnl_color}]" + + if s.position: + pos = s.position + if pos.token_name == "UP" and self.state.up_token: + current_price = self.state.up_token.best_bid or self.state.up_token.last_price + elif pos.token_name == "DOWN" and self.state.down_token: + current_price = self.state.down_token.best_bid or self.state.down_token.last_price + else: + current_price = pos.entry_price + + unrealized = (pos.contracts * current_price) - (pos.contracts * pos.entry_price) + ur_color = "green" if unrealized >= 0 else "red" + + hedge_str = " [cyan]🛡️ HEDGED[/cyan]" if pos.hedged else "" + flash = "🔔 " if self.entry_flash else "" + self.entry_flash = False + + pos_line = f"{flash}🟢 LONG {pos.token_name} @ {pos.entry_price:.3f} ({pos.contracts} contracts){hedge_str}" + ur_line = f" Unrealized: [{ur_color}]${unrealized:+.2f}[/{ur_color}] (price: {current_price:.3f})" + + # Live drawdown + dd_price = max(0, pos.entry_price - pos.min_price_seen) + dd_pct = (dd_price / pos.entry_price * 100) if pos.entry_price > 0 else 0 + dd_usd = dd_price * pos.contracts + if dd_price > 0: + ur_line += f"\n Max DD: [red]-${dd_usd:.2f} (-{dd_pct:.1f}%)[/red] (low: {pos.min_price_seen:.3f})" + else: + pos_line = "⏳ No position (waiting for signal)" + ur_line = "" + + last_trades_lines = [] + for trade in s.trades[-3:][::-1]: + icon = "✅" if trade.won else "❌" + last_trades_lines.append(f" {icon} {trade.token_name} @ {trade.entry_price:.2f} → ${trade.pnl:+.2f}") + + lines = [stats_line, pnl_line, "", pos_line] + if ur_line: + lines.append(ur_line) + if last_trades_lines: + lines.append("") + lines.append("Last trades:") + lines.extend(last_trades_lines) + + border = "bold yellow" if self.entry_flash or self.hedge_flash else "cyan" + self.hedge_flash = False + return Panel("\n".join(lines), title=f"[bold]💰 REAL Trading (${bet:.0f}/trade)[/bold]", border_style=border) + + def create_btc_price_panel(self) -> Panel: + """Panel showing Chainlink BTC/USD price and deviation from market start.""" + s = self.state + + if s.btc_current_price <= 0: + status = "[green]● LIVE[/green]" if s.btc_connected else "[red]○ OFF[/red]" + return Panel( + f"Chainlink {status}\nWaiting for price...", + title="[bold]₿ BTC/USD (Chainlink)[/bold]", + border_style="dim" + ) + + # Connection status + status = "[green]●[/green]" if s.btc_connected else "[red]○[/red]" + + # Freshness indicator + age = time.time() - s.btc_last_update if s.btc_last_update > 0 else 999 + if age < 5: + fresh = "[green]LIVE[/green]" + elif age < 30: + fresh = f"[yellow]{int(age)}s ago[/yellow]" + else: + fresh = f"[red]{int(age)}s ago[/red]" + + lines = [ + f"Price: [bold white]${s.btc_current_price:,.2f}[/bold white] {status} {fresh}", + ] + + if s.btc_anchor_price > 0: + dev_abs = s.btc_current_price - s.btc_anchor_price + dev_pct = (dev_abs / s.btc_anchor_price) * 100 if s.btc_anchor_price else 0 + + # Color based on direction + if dev_abs > 0: + dev_abs_str = f"[green]+${dev_abs:,.2f}[/green]" + dev_pct_str = f"[green]+{dev_pct:.3f}%[/green]" + elif dev_abs < 0: + dev_abs_str = f"[red]-${abs(dev_abs):,.2f}[/red]" + dev_pct_str = f"[red]{dev_pct:.3f}%[/red]" + else: + dev_abs_str = "$0.00" + dev_pct_str = "0.000%" + + lines.append(f"Anchor: [dim]${s.btc_anchor_price:,.2f}[/dim]") + lines.append(f"Deviation: {dev_abs_str} ({dev_pct_str})") + else: + lines.append("[dim]Anchor: waiting for market start...[/dim]") + + return Panel( + "\n".join(lines), + title="[bold]₿ BTC/USD (Chainlink)[/bold]", + border_style="yellow" + ) + + def render(self) -> Layout: + layout = Layout() + + layout.split_column( + Layout(self.create_header(), name="header", size=3), + Layout(name="body"), + Layout(name="footer", size=16), + Layout(self.create_btc_price_panel(), name="btc_price", size=6) + ) + + layout["body"].split_row( + Layout(name="left"), + Layout(name="right") + ) + + layout["left"].split_column( + Layout(self.create_token_panel(self.state.up_token, "⬆️ UP"), name="up_book"), + Layout(self.create_indicators_panel(self.state.up_token, "UP"), name="up_ind") + ) + + layout["right"].split_column( + Layout(self.create_token_panel(self.state.down_token, "⬇️ DOWN"), name="down_book"), + Layout(self.create_indicators_panel(self.state.down_token, "DOWN"), name="down_ind") + ) + + layout["footer"].split_row( + Layout(name="strategy"), + Layout(name="trading") + ) + layout["strategy"].update(self.create_strategy_panel()) + layout["trading"].update(self.create_trading_panel()) + + return layout + + def build_web_snapshot(self) -> dict: + """Plain dict for the HTTP dashboard (same numbers as terminal panels; no Rich markup).""" + now = time.time() + time_left = max(0.0, self.state.end_time - now) + sim = bool(getattr(self.config, "simulation", None) and self.config.simulation.enabled) + header = { + "slug": self.state.slug or "—", + "time_left_sec": time_left, + "elapsed_sec": max(0.0, self.config.market.duration_sec - time_left), + "ws_connected": bool(self.state.connected), + "simulation": sim, + "interval_minutes": self.config.market.interval_minutes, + } + + def token_block(token: Optional[TokenData]) -> Optional[dict]: + if not token: + return None + book = { + "best_bid": token.best_bid, + "best_bid_size": token.best_bid_size, + "best_ask": token.best_ask, + "best_ask_size": token.best_ask_size, + "last_price": token.last_price, + "trade_count": token.trade_count, + "volume_total": token.volume_total, + "volume_buy": token.volume_buy, + "volume_sell": token.volume_sell, + } + ind = None + if token.trades: + vw = self.config.strategy.vwap_window_sec + mw = self.config.strategy.momentum_window_sec + vwap = self.calc.calc_vwap(self.calc.get_trades_in_window(token.trades, vw)) + ind = { + "vwap_window_sec": vw, + "vwap": vwap, + "deviation_pct": self.calc.calc_deviation(token.last_price, vwap), + "zscore": self.calc.calc_zscore(token.trades, token.last_price, window=5), + "momentum_window_sec": mw, + "momentum_pct": self.calc.calc_momentum(token.trades, token.last_price, window=mw), + } + return {"book": book, "indicators": ind} + + strategy: dict = { + "signal_text": "Waiting for data...", + "favorite": None, + "win_rate_str": None, + "checks": {}, + "up_line": "", + "down_line": "", + } + + if self.state.up_token and self.state.down_token: + up = self.state.up_token + down = self.state.down_token + vwap_window = self.config.strategy.vwap_window_sec + up_vwap = self.calc.calc_vwap(self.calc.get_trades_in_window(up.trades, vwap_window)) + down_vwap = self.calc.calc_vwap(self.calc.get_trades_in_window(down.trades, vwap_window)) + up_dev = self.calc.calc_deviation(up.last_price, up_vwap) + down_dev = self.calc.calc_deviation(down.last_price, down_vwap) + mom_window = self.config.strategy.momentum_window_sec + up_mom = self.calc.calc_momentum(up.trades, up.last_price, window=mom_window) + down_mom = self.calc.calc_momentum(down.trades, down.last_price, window=mom_window) + + time_minutes = time_left / 60.0 + span = self.config.market.interval_minutes + time_bin = int((span - 1) - time_minutes) + time_bin = max(0, min(time_bin, span - 1)) + + if up.last_price > down.last_price: + fav_name = "UP" + fav_price = up.last_price + fav_dev = up_dev + fav_mom = up_mom + else: + fav_name = "DOWN" + fav_price = down.last_price + fav_dev = down_dev + fav_mom = down_mom + + base_wr = self.winrate_table.get_winrate(fav_price, time_bin, span) + wr_str = f"{base_wr:.1f}%" if base_wr else None + + min_price = self.config.strategy.min_price + max_price = self.config.strategy.max_price + min_elapsed = self.config.strategy.min_elapsed_sec + min_dev = self.config.strategy.min_deviation_pct + max_dev = self.config.strategy.max_deviation_pct + no_entry_cutoff = self.config.strategy.no_entry_before_end_sec + elapsed_sec = self.config.market.duration_sec - time_left + + price_ok = min_price <= fav_price <= max_price + time_ok = elapsed_sec >= min_elapsed + dev_ok = fav_dev > min_dev and fav_dev < max_dev + mom_ok = fav_mom is not None and fav_mom > 5 + time_cutoff_ok = time_left > no_entry_cutoff + + if not time_cutoff_ok: + signal = f"🚫 NO ENTRY (< {no_entry_cutoff}s left)" + elif price_ok and time_ok and dev_ok and mom_ok: + signal = f"✅ BUY {fav_name}" + elif fav_price >= 0.70 and time_ok: + if not mom_ok: + signal = "🟡 ALMOST (need Mom>0%)" + elif fav_dev >= max_dev: + signal = f"🟡 ALMOST (Dev≥{max_dev}%)" + else: + signal = "🟡 ALMOST (need dev)" + elif not time_ok: + signal = f"⏳ WAIT (elapsed<{min_elapsed}s)" + elif not price_ok: + signal = "⏳ WAIT (P not in range)" + elif not dev_ok: + signal = ( + f"⏳ WAIT (Dev≥{max_dev}%)" + if fav_dev >= max_dev + else f"⏳ WAIT (Dev<{min_dev}%)" + ) + elif not mom_ok: + signal = "⏳ WAIT (Mom≤0%)" + else: + signal = "⏳ WAIT" + + strategy = { + "signal_text": signal, + "favorite": f"{fav_name} ({fav_price:.3f})", + "win_rate_str": wr_str, + "time_bin": time_bin, + "checks": { + "price": price_ok, + "time": time_ok, + "dev": dev_ok, + "mom": mom_ok, + "time_cutoff": time_cutoff_ok, + }, + "up_line": f"{up.last_price:.3f} | Dev {up_dev:+.1f}% | Mom {up_mom if up_mom is not None else 0:.2f}%", + "down_line": f"{down.last_price:.3f} | Dev {down_dev:+.1f}% | Mom {down_mom if down_mom is not None else 0:.2f}%", + } + + s = self.state + btc_age = time.time() - s.btc_last_update if s.btc_last_update > 0 else None + btc_block: dict = { + "btc_current_price": s.btc_current_price, + "btc_anchor_price": s.btc_anchor_price, + "btc_connected": s.btc_connected, + "fresh_sec": btc_age, + "deviation_line": "", + } + if s.btc_current_price > 0 and s.btc_anchor_price > 0: + dev_abs = s.btc_current_price - s.btc_anchor_price + dev_pct = (dev_abs / s.btc_anchor_price) * 100 if s.btc_anchor_price else 0.0 + btc_block["deviation_line"] = f"${dev_abs:+,.2f} ({dev_pct:+.3f}%)" + + st = self.stats + bet = self.config.entry.bet_amount_usd + wr_str = f"{st.win_rate:.1f}%" if st.trade_count > 0 else None + trading: dict = { + "bet_usd": bet, + "markets_seen": st.markets_seen, + "trade_count": st.trade_count, + "win_rate_str": wr_str, + "total_pnl": st.total_pnl, + "position": None, + "recent_trades": [], + } + if st.position: + pos = st.position + if pos.token_name == "UP" and self.state.up_token: + current_price = self.state.up_token.best_bid or self.state.up_token.last_price + elif pos.token_name == "DOWN" and self.state.down_token: + current_price = self.state.down_token.best_bid or self.state.down_token.last_price + else: + current_price = pos.entry_price + unrealized = (pos.contracts * current_price) - (pos.contracts * pos.entry_price) + dd_price = max(0.0, pos.entry_price - pos.min_price_seen) + dd_pct = (dd_price / pos.entry_price * 100) if pos.entry_price > 0 else 0.0 + dd_usd = dd_price * pos.contracts + trading["position"] = { + "token_name": pos.token_name, + "entry_price": pos.entry_price, + "contracts": pos.contracts, + "hedged": pos.hedged, + "current_price": current_price, + "unrealized_pnl": unrealized, + "max_dd_usd": dd_usd, + "max_dd_pct": dd_pct, + "min_price_seen": pos.min_price_seen, + } + for trade in st.trades[-5:][::-1]: + icon = "✅" if trade.won else "❌" + trading["recent_trades"].append({ + "line": f"{icon} {trade.token_name} @ {trade.entry_price:.2f} → ${trade.pnl:+.2f}", + }) + + return { + "ts": now, + "header": header, + "strategy": strategy, + "up": token_block(self.state.up_token), + "down": token_block(self.state.down_token), + "btc": btc_block, + "trading": trading, + "last_signal": self.last_signal, + } + + +# ============================================================================= +# MAIN BOT +# ============================================================================= + +class LiveTradingBot: + def __init__(self): + self.config = None + self.state = MarketState() + self.stats = TradingStats() + self.dashboard: Dashboard = None + + # Trading components + self.executor: OrderExecutor = None + self.hedge_mgr: HedgeManager = None + self.redeemer: Optional[AsyncAutoRedeemer] = None + self.telegram: TelegramNotifier = None + self.user_ws = None + self._user_ws_task: Optional[asyncio.Task] = None + + # WebSocket + self.ws_client: WebSocketClient = None + + # Chainlink BTC price + self.chainlink_client: ChainlinkPriceClient = None + self._chainlink_task: Optional[asyncio.Task] = None + + # Control + self.running = False + self.tasks = [] + self._sim_history: Optional[SimulationHistoryLogger] = None + self._web_snapshot_holder: Optional[WebSnapshotHolder] = None + + async def initialize(self) -> bool: + # Load config + self.config = load_config() + errors = validate_config(self.config) + if errors: + for err in errors: + console.print(f"[red]Config error: {err}[/red]") + return False + + im = self.config.market.interval_minutes + console.print(f"[bold cyan]🚀 BTC {im}-Min Live Trading Bot[/bold cyan]") + if self.config.simulation.enabled: + console.print("[bold yellow] SIMULATION MODE — no CLOB orders, no redeemer[/bold yellow]\n") + else: + console.print("[bold cyan] Real Trading + Dashboard[/bold cyan]\n") + + console.print(f"[green]✓ Market: BTC up/down {im}m (slug btc-updown-{im}m-*)[/green]") + console.print(f"[green]✓ Config: P {self.config.strategy.min_price}-{self.config.strategy.max_price}, " + f"T≥{self.config.strategy.min_elapsed_sec}s, " + f"Dev {self.config.strategy.min_deviation_pct}%-{self.config.strategy.max_deviation_pct}%[/green]") + console.print(f"[green]✓ Bet: ${self.config.entry.bet_amount_usd}, " + f"Hedge: {'ON' if self.config.hedge.enabled else 'OFF'}[/green]") + if self.config.simulation.enabled: + if self.config.simulation.separate_trading_log: + self.stats = TradingStats(self.config.simulation.trading_log_path) + console.print( + f"[yellow]✓ Simulation stats: {self.config.simulation.trading_log_path}[/yellow]" + ) + else: + console.print("[yellow]✓ Simulation stats: same file as live (trading_log.json)[/yellow]") + + # Initialize trading components + console.print("[yellow]Initializing trading components...[/yellow]") + + # Telegram + self.telegram = TelegramNotifier( + bot_token=self.config.telegram.bot_token, + chat_id=self.config.telegram.chat_id, + enabled=self.config.telegram.enabled + ) + + sim = self.config.simulation.enabled + + if sim: + self.user_ws = None + self._user_ws_task = None + # Dummy credentials — CLOB is never initialized in simulation + pk = self.config.polymarket.private_key or "0x0000000000000000000000000000000000000000000000000000000000000001" + ak = self.config.polymarket.api_key or "sim" + sec = self.config.polymarket.api_secret or "sim" + ph = self.config.polymarket.api_passphrase or "sim" + self.executor = OrderExecutor( + private_key=pk, + api_key=ak, + api_secret=sec, + api_passphrase=ph, + clob_host=self.config.polymarket.clob_host, + chain_id=self.config.polymarket.chain_id, + signature_type=self.config.polymarket.signature_type, + funder_address=self.config.polymarket.funder_address or None, + user_ws=None, + simulation_mode=True, + ) + console.print("[green]✓ Order executor: simulation (no CLOB)[/green]") + else: + # User WebSocket for order tracking (CRITICAL for fill confirmation!) + self.user_ws = UserWebSocket( + api_key=self.config.polymarket.api_key, + api_secret=self.config.polymarket.api_secret, + api_passphrase=self.config.polymarket.api_passphrase + ) + self._user_ws_task = None + + self.executor = OrderExecutor( + private_key=self.config.polymarket.private_key, + api_key=self.config.polymarket.api_key, + api_secret=self.config.polymarket.api_secret, + api_passphrase=self.config.polymarket.api_passphrase, + clob_host=self.config.polymarket.clob_host, + chain_id=self.config.polymarket.chain_id, + signature_type=self.config.polymarket.signature_type, + funder_address=self.config.polymarket.funder_address or None, + user_ws=self.user_ws, + simulation_mode=False, + ) + + if not await self.executor.initialize(): + console.print("[red]Failed to initialize order executor[/red]") + return False + + console.print("[yellow]Starting User WebSocket for order tracking...[/yellow]") + self._user_ws_task = asyncio.create_task(self.user_ws.connect()) + await asyncio.sleep(1) + if self.user_ws.connected: + console.print("[green]User WebSocket connected - order tracking active[/green]") + logger.info("User WebSocket connected for order fill tracking") + else: + console.print("[yellow]User WebSocket connecting... (will retry)[/yellow]") + logger.warning("User WebSocket not yet connected") + + # Hedge manager + hedge_config = HedgeManagerConfig( + enabled=self.config.hedge.enabled, + hedge_price=self.config.hedge.hedge_price, + order_type=self.config.hedge.order_type, + max_retries=self.config.hedge.max_retries, + retry_delay_ms=self.config.hedge.retry_delay_ms, + simulation_mode=sim, + ) + self.hedge_mgr = HedgeManager(self.executor, hedge_config) + + # Auto redeemer (live only) + if sim: + self.redeemer = None + console.print("[yellow]✓ Auto-redeemer: disabled in simulation[/yellow]") + else: + self.redeemer = AsyncAutoRedeemer( + private_key=self.config.polymarket.private_key, + rpc_url=self.config.polymarket.rpc_url, + funder_address=self.config.polymarket.funder_address or None, + signature_type=self.config.polymarket.signature_type, + interval_seconds=self.config.redeem.interval_seconds, + telegram_notifier=self.telegram + ) + + if sim: + jl = (self.config.simulation.history_jsonl_path or "").strip() + self._sim_history = SimulationHistoryLogger( + csv_path=self.config.simulation.history_csv_path, + jsonl_path=jl if jl else None, + summary_path=self.config.simulation.history_summary_path, + ) + if self.stats.trades: + self._sim_history.write_summary( + [t.__dict__ for t in self.stats.trades], + self.stats.summary_dict(), + ) + csv_p = self.config.simulation.history_csv_path or "(disabled)" + sum_p = self.config.simulation.history_summary_path or "(disabled)" + jl_p = jl or "(disabled)" + console.print( + f"[green]✓ Simulation analytics: CSV={csv_p} | JSONL={jl_p} | summary={sum_p}[/green]" + ) + else: + self._sim_history = None + + # Chainlink BTC price client + self.chainlink_client = ChainlinkPriceClient( + self.state, self.config.market.duration_sec + ) + self._chainlink_task = asyncio.create_task(self.chainlink_client.connect()) + console.print("[green]✓ Chainlink BTC/USD price feed starting...[/green]") + + # Dashboard + self.dashboard = Dashboard(self.state, self.stats, self.config) + + wd = self.config.web_dashboard + if wd.enabled: + self._web_snapshot_holder = WebSnapshotHolder() + ok = start_web_dashboard(wd.host, wd.port, self._web_snapshot_holder) + # 0.0.0.0 is not a valid host in a browser URL; use loopback for display. + if wd.host in ("0.0.0.0", ""): + open_url = f"http://127.0.0.1:{wd.port}/" + elif wd.host in ("::", "[::]"): + open_url = f"http://[::1]:{wd.port}/" + else: + open_url = f"http://{wd.host}:{wd.port}/" + if ok: + console.print(f"[green]✓ Web dashboard:[/green] [bold]{open_url}[/bold]") + console.print( + "[dim] Use http:// not https://. On Windows, if the page fails in your browser, " + "open this exact URL (avoid typing only “localhost”, which may use IPv6).[/dim]" + ) + else: + console.print( + f"[yellow]⚠ Web dashboard did not start on port {wd.port} " + f"(in use by another app, or bind failed). Check logs.[/yellow]" + ) + + console.print("[green]✓ All components initialized[/green]\n") + return True + + async def find_market(self) -> bool: + d = self.config.market.duration_sec + sfx = self.config.market.slug_infix + console.print(f"[yellow]Searching for active BTC {self.config.market.interval_minutes}-min market...[/yellow]") + + async with _aiohttp_session() as session: + now = int(time.time()) + current_window = (now // d) * d + + for offset in [0, d, -d, 2 * d]: + target_ts = current_window + offset + expected_slug = f"btc-updown-{sfx}-{target_ts}" + + try: + async with session.get( + f"{GAMMA_API}/markets?slug={expected_slug}", + proxy=_PROXY_URL or None, + ) as resp: + if resp.status == 200: + markets = await resp.json() + if markets: + market = markets[0] + returned_slug = market.get("slug", "") + + # CRITICAL: Verify API returned the market we asked for + if returned_slug != expected_slug: + logger.warning(f"API slug mismatch! Asked for {expected_slug}, got {returned_slug}") + continue + + if not market.get("closed", True): + return await self._setup_market(market) + except Exception as e: + logger.debug(f"Error finding market {expected_slug}: {e}") + continue + + return False + + async def _setup_market(self, market: dict) -> bool: + console.print(f"[green]Found: {market.get('slug')}[/green]") + + outcomes = market.get("outcomes", []) + tokens = market.get("clobTokenIds", []) + + if isinstance(outcomes, str): + outcomes = json.loads(outcomes) + if isinstance(tokens, str): + tokens = json.loads(tokens) + + up_token_id = None + down_token_id = None + + # Use exact index lookup like reference implementation + try: + up_index = outcomes.index("Up") if "Up" in outcomes else None + down_index = outcomes.index("Down") if "Down" in outcomes else None + + if up_index is not None and up_index < len(tokens): + up_token_id = tokens[up_index] + if down_index is not None and down_index < len(tokens): + down_token_id = tokens[down_index] + except (ValueError, IndexError): + pass + + # Fallback to contains-based matching + if not up_token_id or not down_token_id: + for i, outcome in enumerate(outcomes): + if i < len(tokens): + outcome_lower = str(outcome).lower() + if not up_token_id and "up" in outcome_lower: + up_token_id = tokens[i] + elif not down_token_id and "down" in outcome_lower: + down_token_id = tokens[i] + + # Last resort fallback + if not up_token_id and len(tokens) >= 1: + up_token_id = tokens[0] + if not down_token_id and len(tokens) >= 2: + down_token_id = tokens[1] + + if not up_token_id or not down_token_id: + return False + + end_str = market.get("end_date_iso") or market.get("endDate", "") + try: + end_time = datetime.fromisoformat(end_str.replace("Z", "+00:00")) + end_timestamp = end_time.timestamp() + except: + end_timestamp = time.time() + self.config.market.duration_sec + + slug = market.get("slug", "") + + self.state.market_id = market.get("id", "") + self.state.condition_id = market.get("conditionId", "") + self.state.slug = slug + self.state.end_time = end_timestamp + self.state.up_token = TokenData(token_id=up_token_id, name="Up") + self.state.down_token = TokenData(token_id=down_token_id, name="Down") + self.state.connected = False + + # Log token assignments for debugging + logger.info(f"Market tokens assigned:") + logger.info(f" Slug: {slug}") + logger.info(f" End Time: {end_str} (timestamp: {end_timestamp})") + logger.info(f" UP token: {up_token_id[:40]}...") + logger.info(f" DOWN token: {down_token_id[:40]}...") + + self.stats.new_market(self.state.slug) + self.hedge_mgr.clear() # Reset hedge state for new market + if self.user_ws: + self.user_ws.clear_token_fills() # Reset WS fill buffer for new market + + # BTC anchor is now auto-managed by ChainlinkPriceClient + # It detects interval boundaries from Chainlink timestamps independently + + return True + + def _simulation_log_entry( + self, + token_name: str, + avg_price: float, + contracts: int, + total_cost: float, + ) -> None: + if not self._sim_history or not self.config.simulation.enabled: + return + pos = self.stats.position + hedged = bool(pos and pos.hedged) + self._sim_history.log_open( + market_slug=self.state.slug, + token_name=token_name, + contracts=contracts, + avg_price=avg_price, + total_cost=total_cost, + cumulative_realized_pnl=self.stats.total_pnl, + hedged=hedged, + trade_number=len(self.stats.trades) + 1, + ) + signal_logger.info( + f" [SIM] History OPEN logged | realized PnL before exit: ${self.stats.total_pnl:+.4f}" + ) + + def _simulation_log_close(self, record: TradeRecord, hedged_was: bool) -> None: + if not self._sim_history or not self.config.simulation.enabled: + return + n = len(self.stats.trades) + self._sim_history.log_close( + record, + cumulative_pnl=self.stats.total_pnl, + total_closed=n, + win_rate_pct=self.stats.win_rate, + hedged=hedged_was, + ) + self._sim_history.write_summary( + [t.__dict__ for t in self.stats.trades], + self.stats.summary_dict(), + ) + s = self.stats.summary_dict() + signal_logger.info( + f" [SIM] History CLOSE logged | trade PnL ${record.pnl:+.4f} | " + f"cumulative ${s['total_pnl_usd']:+.4f} | WR {s['win_rate_pct']:.2f}% ({n} closed)" + ) + + async def execute_entry(self, side: str): + """Execute entry order (live CLOB or simulation).""" + if not self.stats.can_enter(): + signal_logger.info(f"SIGNAL IGNORED: {side} - cannot enter (already in position)") + return + + # Defensive time cutoff check (race condition guard) + time_left = max(0, self.state.end_time - time.time()) + no_entry_cutoff = self.config.strategy.no_entry_before_end_sec + if time_left < no_entry_cutoff: + signal_logger.info( + f"SIGNAL BLOCKED: {side} - too close to market end " + f"({time_left:.0f}s left < {no_entry_cutoff}s cutoff)" + ) + logger.warning(f"Entry blocked: {time_left:.0f}s left < {no_entry_cutoff}s cutoff") + return + + if side == "BUY_UP": + token = self.state.up_token + token_name = "UP" + opposite_token = self.state.down_token + else: + token = self.state.down_token + token_name = "DOWN" + opposite_token = self.state.up_token + + if not token or not opposite_token: + signal_logger.warning(f"SIGNAL IGNORED: {side} - token data missing") + return + + # Log full signal snapshot + signal_logger.info("=" * 60) + signal_logger.info( + "TRADE SIGNAL TRIGGERED (SIMULATION)" if self.config.simulation.enabled else "TRADE SIGNAL TRIGGERED" + ) + signal_logger.info(f" Time: {datetime.now().isoformat()}") + signal_logger.info(f" Market: {self.state.slug}") + signal_logger.info(f" Signal: {side}") + signal_logger.info(f" Token: {token_name}") + + time_left = max(0, self.state.end_time - time.time()) + dur = self.config.market.duration_sec + span = self.config.market.interval_minutes + elapsed_sec = dur - time_left + time_bin = int((span - 1) - time_left / 60) + time_bin = max(0, min(time_bin, span - 1)) + signal_logger.info(f" Elapsed: {elapsed_sec:.0f}s | Remaining: {time_left:.0f}s | Bin: {time_bin}") + + # Calculate all indicators for both tokens + calc = self.dashboard.calc + vwap_window = self.config.strategy.vwap_window_sec + mom_window = self.config.strategy.momentum_window_sec + + for label, tk in [("UP", self.state.up_token), ("DOWN", self.state.down_token)]: + if not tk: + signal_logger.info(f" {label}: no data") + continue + + vwap = calc.calc_vwap(calc.get_trades_in_window(tk.trades, vwap_window)) + dev = calc.calc_deviation(tk.last_price, vwap) + zscore = calc.calc_zscore(tk.trades, tk.last_price, window=5) + mom = calc.calc_momentum(tk.trades, tk.last_price, window=mom_window) + mom_str = f"{mom:+.2f}%" if mom is not None else "N/A" + + signal_logger.info(f" --- {label} ---") + signal_logger.info(f" Price: LAST={tk.last_price:.4f} BID={tk.best_bid:.4f} ASK={tk.best_ask:.4f}") + signal_logger.info(f" VWAP {vwap_window}s: {vwap:.4f} | Deviation: {dev:+.2f}%") + signal_logger.info(f" Z-Score 5s: {zscore:+.2f} | Momentum {mom_window}s: {mom_str}") + signal_logger.info(f" Trades: {tk.trade_count} | Volume: {tk.volume_total:.0f}") + signal_logger.info(f" Buy Vol: {tk.volume_buy:.0f} | Sell Vol: {tk.volume_sell:.0f}") + + # Win rate + up = self.state.up_token + down = self.state.down_token + if up and down: + fav_price = up.last_price if up.last_price > down.last_price else down.last_price + wr = self.dashboard.winrate_table.get_winrate( + fav_price, time_bin, self.config.market.interval_minutes + ) + signal_logger.info(f" Win Rate: {wr:.1f}%" if wr else " Win Rate: N/A") + + # Strategy conditions snapshot + signal_logger.info(f" Config: min_price={self.config.strategy.min_price}, " + f"max_price={self.config.strategy.max_price}, " + f"min_elapsed={self.config.strategy.min_elapsed_sec}s, " + f"dev_range={self.config.strategy.min_deviation_pct}%-{self.config.strategy.max_deviation_pct}%, " + f"no_entry_cutoff={self.config.strategy.no_entry_before_end_sec}s") + + # Chainlink BTC/USD + s = self.state + if s.btc_current_price > 0 and s.btc_anchor_price > 0: + btc_dev_abs = s.btc_current_price - s.btc_anchor_price + btc_dev_pct = (btc_dev_abs / s.btc_anchor_price) * 100 + signal_logger.info(f" BTC Chainlink: ${s.btc_current_price:,.2f} (anchor: ${s.btc_anchor_price:,.2f})") + signal_logger.info(f" BTC Deviation: ${btc_dev_abs:+,.2f} ({btc_dev_pct:+.4f}%)") + else: + signal_logger.info(f" BTC Chainlink: N/A") + + signal_logger.info("=" * 60) + + logger.info(f"Executing entry: {token_name}") + + exec_config = ExecutionConfig( + bet_amount_usd=self.config.entry.bet_amount_usd, + price_offset=self.config.entry.price_offset, + max_retries=self.config.entry.max_retries, + retry_delay_ms=self.config.entry.retry_delay_ms, + fill_timeout_ms=self.config.entry.fill_timeout_ms, + min_contracts=self.config.entry.min_contracts, + min_order_usd=self.config.entry.min_order_usd, + max_entry_price=self.config.entry.max_entry_price + ) + + result = await self.executor.execute_entry( + token_id=token.token_id, + config=exec_config, + websocket_price=token.best_ask # Для ПОКУПКИ нужен ASK! Мы платим продавцам. + ) + + if result.success: + self.stats.record_entry( + token_name=token_name, + token_id=token.token_id, + opposite_token_id=opposite_token.token_id, + price=result.avg_price, + contracts=result.contracts_filled, + market_slug=self.state.slug + ) + self._simulation_log_entry( + token_name, result.avg_price, result.contracts_filled, result.total_cost + ) + + self.dashboard.entry_flash = True + + # Log successful entry + signal_logger.info( + "ENTRY EXECUTED SUCCESSFULLY (SIMULATED)" + if self.config.simulation.enabled + else "ENTRY EXECUTED SUCCESSFULLY" + ) + signal_logger.info(f" Token: {token_name}") + signal_logger.info(f" Contracts: {result.contracts_filled}") + signal_logger.info(f" Avg Price: {result.avg_price:.4f}") + signal_logger.info(f" Total Cost: ${result.total_cost:.2f}") + signal_logger.info(f" Attempts: {result.attempts}") + signal_logger.info("-" * 40) + + await self.telegram.notify_entry( + side=token_name, + price=result.avg_price, + contracts=result.contracts_filled, + cost=result.total_cost, + retries=result.attempts, + interval_minutes=self.config.market.interval_minutes, + simulation=self.config.simulation.enabled, + ) + + logger.info(f"Entry complete: {result.contracts_filled} @ {result.avg_price:.3f}") + + # === PLACE GTD HEDGE ORDER === + if self.config.hedge.enabled: + self.hedge_mgr.set_position( + opposite_token_id=opposite_token.token_id, + contracts=result.contracts_filled + ) + + hedge_result = await self.hedge_mgr.place_gtd_hedge() + + if hedge_result.success: + self.dashboard.hedge_flash = True + hedge_cost = hedge_result.contracts * hedge_result.price + + hsim = "🎮 [SIMULATION]\n" if self.config.simulation.enabled else "" + await self.telegram.send_message( + f"{hsim}" + f"🛡️ Hedge Order Placed (GTD)\n" + f"📦 {hedge_result.contracts} contracts @ ${hedge_result.price}\n" + f"💰 Cost: ${hedge_cost:.2f}\n" + f"🔖 Order ID: {hedge_result.order_id[:20]}...\n" + f"📋 Status: LIVE (passive)\n" + f"🔄 Attempts: {hedge_result.attempts}" + ) + + # Register WebSocket handler for hedge fills + self._register_hedge_ws_handler() + + logger.info(f"GTD hedge placed: {hedge_result.contracts} @ ${hedge_result.price}") + else: + await self.telegram.send_message( + f"⚠️ Hedge Failed\n" + f"❌ {hedge_result.error}\n" + f"🔄 Attempts: {hedge_result.attempts}" + ) + logger.error(f"Hedge failed: {hedge_result.error}") + else: + signal_logger.error(f"ENTRY FAILED: {result.error}") + signal_logger.info(f" Attempts: {result.attempts}") + signal_logger.info("-" * 40) + logger.error(f"Entry failed: {result.error}") + + # ============================================================ + # КРИТИЧНО: Если был таймаут - НЕ делаем retry (двойная покупка!) + # Вместо этого проверяем через WebSocket - может ордер исполнился + # ============================================================ + if result.was_timeout: + signal_logger.error("🛑 TIMEOUT: Checking WebSocket for fills...") + logger.warning("Timeout detected — starting WS recovery") + + recovered = False + + if self.user_ws and self.user_ws.connected: + recovery_timeout = self.config.entry.ws_recovery_timeout_sec + + signal_logger.info(f" Checking WS for fills on {token.token_id[:30]}...") + signal_logger.info(f" Recovery timeout: {recovery_timeout}s") + + fill_data = await self.user_ws.wait_for_fills_on_token( + token_id=token.token_id, + timeout=recovery_timeout + ) + + if fill_data and fill_data["contracts"] > 0: + # ============================== + # RECOVERY: Order DID execute! + # ============================== + recovered = True + rec_contracts = fill_data["contracts"] + rec_price = fill_data["avg_price"] + rec_cost = fill_data["total_cost"] + + signal_logger.info("=" * 60) + signal_logger.info("✅ TIMEOUT RECOVERY: Position found via WebSocket!") + signal_logger.info(f" Contracts: {rec_contracts}") + signal_logger.info(f" Avg Price: {rec_price:.4f}") + signal_logger.info(f" Total Cost: ${rec_cost:.2f}") + signal_logger.info(f" Fills: {len(fill_data['fills'])}") + signal_logger.info("=" * 60) + + logger.info(f"Timeout recovery: {rec_contracts} @ {rec_price:.4f}") + + # Record position as if entry succeeded + self.stats.record_entry( + token_name=token_name, + token_id=token.token_id, + opposite_token_id=opposite_token.token_id, + price=rec_price, + contracts=rec_contracts, + market_slug=self.state.slug + ) + self._simulation_log_entry( + token_name, rec_price, rec_contracts, rec_cost + ) + + self.dashboard.entry_flash = True + + await self.telegram.send_message( + f"🔄 Timeout Recovery!\n" + f"Order filled despite HTTP timeout.\n" + f"📊 {token_name} {rec_contracts} @ ${rec_price:.4f}\n" + f"💰 Cost: ${rec_cost:.2f}\n" + f"Market: {self.state.slug}" + ) + + await self.telegram.notify_entry( + side=token_name, + price=rec_price, + contracts=rec_contracts, + cost=rec_cost, + retries=result.attempts, + interval_minutes=self.config.market.interval_minutes, + simulation=self.config.simulation.enabled, + ) + + # Place hedge (normal flow) + if self.config.hedge.enabled: + self.hedge_mgr.set_position( + opposite_token_id=opposite_token.token_id, + contracts=rec_contracts + ) + + hedge_result = await self.hedge_mgr.place_gtd_hedge() + + if hedge_result.success: + self.dashboard.hedge_flash = True + hedge_cost = hedge_result.contracts * hedge_result.price + hsim2 = "🎮 [SIMULATION]\n" if self.config.simulation.enabled else "" + await self.telegram.send_message( + f"{hsim2}" + f"🛡️ Hedge Order Placed (GTD)\n" + f"📦 {hedge_result.contracts} contracts @ ${hedge_result.price}\n" + f"💰 Cost: ${hedge_cost:.2f}\n" + f"🔖 Order ID: {hedge_result.order_id[:20]}...\n" + f"📋 Status: LIVE (passive)\n" + f"🔄 Attempts: {hedge_result.attempts}" + ) + + self._register_hedge_ws_handler() + logger.info(f"GTD hedge placed after recovery: {hedge_result.contracts} @ ${hedge_result.price}") + else: + await self.telegram.send_message( + f"⚠️ Hedge Failed (after recovery)\n" + f"❌ {hedge_result.error}" + ) + else: + signal_logger.info(" WS recovery: no fills found") + else: + signal_logger.warning(" WS not connected — cannot recover") + + if not recovered: + # No fill found — block entry (original behavior) + self.stats.block_entry("Network timeout - no fill detected via WS. Blocking re-entry.") + signal_logger.error("🛑 ENTRY BLOCKED: Timeout + no WS fill detected") + await self.telegram.send_message( + f"⚠️ TIMEOUT — No Fill Detected\n" + f"Order status unknown after timeout.\n" + f"WebSocket recovery found nothing.\n" + f"Re-entry blocked.\n" + f"Market: {self.state.slug}" + ) + + def _register_hedge_ws_handler(self): + """Register WebSocket handler to track hedge order fills.""" + if not self.user_ws: + logger.warning("User WebSocket not available for hedge tracking") + return + + hedge_order_id = self.hedge_mgr.hedge_order_id + if not hedge_order_id: + return + + original_on_trade = self.user_ws._on_trade + + async def _hedge_trade_handler(data: dict): + """Handle trade events and check for hedge fills.""" + # Call original handler first + if original_on_trade: + await original_on_trade(data) + + # Check if this trade is for our hedge order + # GTD orders are maker orders, so check maker_order_id + trade_order_id = data.get("maker_order_id", "") or data.get("taker_order_id", "") + status = data.get("status", "") + + if trade_order_id == hedge_order_id and status == "MATCHED": + size = int(float(data.get("size", 0))) + price = float(data.get("price", 0)) + + self.hedge_mgr.on_hedge_fill(size, price) + + pos = self._position if hasattr(self, '_position') else None + filled = self.hedge_mgr._position.hedge_contracts_filled if self.hedge_mgr._position else 0 + total = self.hedge_mgr._position.contracts if self.hedge_mgr._position else 0 + + if self.hedge_mgr.is_hedged: + # Fully filled + self.stats.record_hedge(filled, price) + self.dashboard.hedge_flash = True + + await self.telegram.send_message( + f"✅ Hedge FULLY Filled!\n" + f"📦 {filled} contracts @ ${price}\n" + f"🛡️ Position fully protected" + ) + logger.info(f"Hedge fully filled: {filled} contracts") + else: + # Partial fill + await self.telegram.send_message( + f"🛡️ Hedge Partial Fill\n" + f"📦 +{size} contracts @ ${price}\n" + f"📊 Progress: {filled}/{total}" + ) + logger.info(f"Hedge partial fill: +{size}, total {filled}/{total}") + + self.user_ws._on_trade = _hedge_trade_handler + logger.info(f"Registered hedge fill handler for order {hedge_order_id[:20]}...") + + async def check_market_end(self): + """Close position at market end.""" + pos = self.stats.position + if not pos: + return + + time_left = self.state.end_time - time.time() + if time_left <= 10: # 10 seconds before end + hedged_was = pos.hedged + if pos.token_name == "UP" and self.state.up_token: + final_price = self.state.up_token.last_price + elif pos.token_name == "DOWN" and self.state.down_token: + final_price = self.state.down_token.last_price + else: + final_price = 0.5 + + # Log market end details + signal_logger.info("=" * 60) + signal_logger.info("MARKET END - POSITION CLOSING") + signal_logger.info(f" Time: {datetime.now().isoformat()}") + signal_logger.info(f" Market: {self.state.slug}") + signal_logger.info(f" Position: {pos.token_name}") + signal_logger.info(f" Entry Price: {pos.entry_price:.4f}") + signal_logger.info(f" Final Price: {final_price:.4f}") + signal_logger.info(f" Contracts: {pos.contracts}") + signal_logger.info(f" Hedged: {pos.hedged}") + + record = self.stats.close_position(final_price) + if record: + self._simulation_log_close(record, hedged_was) + status = "✅ WIN" if record.won else "❌ LOSS" + + signal_logger.info(f" Result: {'WIN' if record.won else 'LOSS'}") + signal_logger.info(f" P&L: ${record.pnl:+.2f}") + signal_logger.info(f" Max Drawdown: -{record.max_drawdown_abs:.4f} (-{record.max_drawdown_pct:.2f}%)") + dd_usd = record.max_drawdown_abs * record.contracts + signal_logger.info(f" Max DD ($): -${dd_usd:.2f} (min price: {record.entry_price - record.max_drawdown_abs:.4f})") + signal_logger.info(f" Total Trades: {len(self.stats.trades)}") + signal_logger.info(f" Session Stats: W={sum(1 for r in self.stats.trades if r.won)} / L={sum(1 for r in self.stats.trades if not r.won)}") + signal_logger.info(f" Total P&L: ${sum(r.pnl for r in self.stats.trades):+.2f}") + signal_logger.info("=" * 60) + + logger.info(f"Position closed: {status}, PnL: ${record.pnl:+.2f}") + + async def run_session(self): + """Run single market session with dashboard.""" + # Start WebSocket + self.ws_client = WebSocketClient(self.state) + ws_task = asyncio.create_task(self.ws_client.connect()) + + await asyncio.sleep(1) + + # Track running order task (для non-blocking execution) + order_task: Optional[asyncio.Task] = None + + try: + with Live(self.dashboard.render(), refresh_per_second=4, console=console) as live: + while self.running: + # Update dashboard (никогда не блокируется) + live.update(self.dashboard.render()) + if self._web_snapshot_holder: + self._web_snapshot_holder.set(self.dashboard.build_web_snapshot()) + + # Check for entry signal - запускаем в отдельном task + if self.stats.can_enter() and self.dashboard.last_signal: + if order_task is None or order_task.done(): + signal = self.dashboard.last_signal + self.dashboard.last_signal = "" + order_task = asyncio.create_task(self._safe_execute_entry(signal)) + + # Check if order completed + if order_task and order_task.done(): + try: + order_task.result() # Получаем исключения если были + except Exception as e: + logger.error(f"Order task error: {e}") + order_task = None + + # Track drawdown while in position + if self.stats.position: + pos = self.stats.position + if pos.token_name == "UP" and self.state.up_token: + self.stats.update_drawdown(self.state.up_token.last_price) + elif pos.token_name == "DOWN" and self.state.down_token: + self.stats.update_drawdown(self.state.down_token.last_price) + + # Check market end (быстрая операция - не выносим в task) + await self.check_market_end() + + # Market ended? + if time.time() > self.state.end_time: + console.print("\n[yellow]Market ended![/yellow]") + break + + await asyncio.sleep(0.25) + finally: + # Cancel any running order tasks + for task in [order_task]: + if task and not task.done(): + task.cancel() + try: + await task + except: + pass + + # Graceful WebSocket shutdown + await self.ws_client.stop_graceful() + try: + ws_task.cancel() + await ws_task + except: + pass + + # Stop User WebSocket for order tracking + if self.user_ws: + await self.user_ws.disconnect() + if self._user_ws_task: + try: + self._user_ws_task.cancel() + await self._user_ws_task + except: + pass + + async def _safe_execute_entry(self, signal: str): + """Execute entry in separate task with error handling.""" + try: + await self.execute_entry(signal) + except Exception as e: + logger.error(f"Entry execution error: {e}") + signal_logger.error(f"ENTRY ERROR: {e}") + + # GTD hedge is placed immediately after entry (no polling needed) + # Fills are tracked via WebSocket _register_hedge_ws_handler() + + async def run(self): + """Main run loop.""" + if not await self.initialize(): + return + + self.running = True + + redeemer_task = None + if self.redeemer is not None: + redeemer_task = asyncio.create_task(self.redeemer.run_loop()) + + sim_note = "" + if self.config.simulation.enabled: + sim_note = "🎮 SIMULATION MODE — no real orders\n" + await self.telegram.send_message( + f"{sim_note}" + f"🤖 Bot Started\n" + f"Strategy: ${self.config.entry.bet_amount_usd} per trade\n" + f"Hedge: {'enabled' if self.config.hedge.enabled else 'disabled'}" + ) + + try: + while self.running: + # Find market + if not await self.find_market(): + console.print("[red]No market found. Waiting 30s...[/red]") + await asyncio.sleep(30) + continue + + console.print("\n[bold green]Starting session...[/bold green]\n") + await self.run_session() + + console.print("[yellow]Waiting 5s for next market...[/yellow]") + await asyncio.sleep(5) + + except KeyboardInterrupt: + console.print("\n[yellow]Stopping...[/yellow]") + finally: + self.running = False + if self.redeemer is not None: + self.redeemer.stop() + if redeemer_task is not None: + try: + redeemer_task.cancel() + await redeemer_task + except Exception: + pass + + # Gracefully close Chainlink RTDS WebSocket + if self.chainlink_client: + await self.chainlink_client.disconnect() + if self._chainlink_task: + try: + self._chainlink_task.cancel() + await self._chainlink_task + except: + pass + + await self.telegram.send_message("🛑 Bot stopped") + await self.telegram.close() + + console.print("[green]Bot stopped.[/green]") + + +async def main(): + bot = LiveTradingBot() + + loop = asyncio.get_event_loop() + + def shutdown(): + bot.running = False + + if sys.platform != "win32": + for sig in (signal.SIGTERM, signal.SIGINT): + loop.add_signal_handler(sig, shutdown) + + await bot.run() + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + pass \ No newline at end of file diff --git a/btc-binary-VWAP-Momentum-bot/requirements.txt b/btc-binary-VWAP-Momentum-bot/requirements.txt new file mode 100644 index 0000000..3d83cea --- /dev/null +++ b/btc-binary-VWAP-Momentum-bot/requirements.txt @@ -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 \ No newline at end of file diff --git a/btc-binary-VWAP-Momentum-bot/src/__init__.py b/btc-binary-VWAP-Momentum-bot/src/__init__.py new file mode 100644 index 0000000..5346769 --- /dev/null +++ b/btc-binary-VWAP-Momentum-bot/src/__init__.py @@ -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" diff --git a/btc-binary-VWAP-Momentum-bot/src/auto_redeemer.py b/btc-binary-VWAP-Momentum-bot/src/auto_redeemer.py new file mode 100644 index 0000000..5e7f5e0 --- /dev/null +++ b/btc-binary-VWAP-Momentum-bot/src/auto_redeemer.py @@ -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") + ) \ No newline at end of file diff --git a/btc-binary-VWAP-Momentum-bot/src/config_loader.py b/btc-binary-VWAP-Momentum-bot/src/config_loader.py new file mode 100644 index 0000000..3f2cd2c --- /dev/null +++ b/btc-binary-VWAP-Momentum-bot/src/config_loader.py @@ -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-).""" + 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 \ No newline at end of file diff --git a/btc-binary-VWAP-Momentum-bot/src/hedge_manager.py b/btc-binary-VWAP-Momentum-bot/src/hedge_manager.py new file mode 100644 index 0000000..0dc2820 --- /dev/null +++ b/btc-binary-VWAP-Momentum-bot/src/hedge_manager.py @@ -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, + } diff --git a/btc-binary-VWAP-Momentum-bot/src/market_finder.py b/btc-binary-VWAP-Momentum-bot/src/market_finder.py new file mode 100644 index 0000000..8f7ccb4 --- /dev/null +++ b/btc-binary-VWAP-Momentum-bot/src/market_finder.py @@ -0,0 +1,483 @@ +#!/usr/bin/env python3 +""" +Market Finder + +Searches for active BTC 5- or 15-minute up/down markets on Polymarket. +Features: +- Async HTTP with retry logic +- Caching to reduce API calls +- Automatic market lifecycle detection +- Robust error handling +""" + +import asyncio +import json +import logging +import os +import re +from dataclasses import dataclass, field +from datetime import datetime, timezone, timedelta +from typing import Optional, List, Dict, Any, Tuple + +import aiohttp + +from src.proxy_util import apply_proxy_env, aiohttp_proxy + +apply_proxy_env() +_PROXY_URL = aiohttp_proxy() or "" + +logger = logging.getLogger("btc_live.market_finder") + +GAMMA_API = "https://gamma-api.polymarket.com" +CLOB_API = "https://clob.polymarket.com" + +def _btc_slug_pattern(interval_minutes: int) -> re.Pattern: + # Support multiple slug formats: + # - btc-updown-5m-1752345600 (original) + # - btc-up-or-down-5m-1752345600 (newer format) + # - btc-5m-up-down-1752345600 (older) + # - btc-updown-0726-5m-... (with date) + return re.compile(rf"btc-.*(up|down).*{int(interval_minutes)}m.*") + + +@dataclass +class Market: + """Represents a BTC up/down interval market (5m or 15m slug).""" + + id: str + slug: str + question: str + condition_id: str + + # Token IDs + up_token_id: str + down_token_id: str + + # Timing + start_time: datetime + end_time: datetime + + # State + active: bool = True + closed: bool = False + accepting_orders: bool = True + + # Prices (updated from WebSocket) + up_price: float = 0.5 + down_price: float = 0.5 + best_bid: float = 0.0 + best_ask: float = 0.0 + + # Metadata + volume: float = 0.0 + liquidity: float = 0.0 + + def time_remaining_seconds(self) -> float: + """Get seconds until market ends.""" + now = datetime.now(timezone.utc) + delta = self.end_time - now + return max(0, delta.total_seconds()) + + def time_elapsed_seconds(self) -> float: + """Get seconds since market started.""" + now = datetime.now(timezone.utc) + delta = now - self.start_time + return max(0, delta.total_seconds()) + + def minutes_remaining(self) -> float: + """Get minutes until market ends.""" + return self.time_remaining_seconds() / 60 + + def minutes_elapsed(self) -> float: + """Get minutes since market started.""" + return self.time_elapsed_seconds() / 60 + + def is_tradeable(self) -> bool: + """Check if market is currently tradeable.""" + return ( + self.active and + not self.closed and + self.accepting_orders and + self.time_remaining_seconds() > 0 + ) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary.""" + return { + "id": self.id, + "slug": self.slug, + "question": self.question, + "condition_id": self.condition_id, + "up_token_id": self.up_token_id, + "down_token_id": self.down_token_id, + "start_time": self.start_time.isoformat(), + "end_time": self.end_time.isoformat(), + "active": self.active, + "closed": self.closed, + "up_price": self.up_price, + "down_price": self.down_price, + } + + +class MarketFinder: + """ + Finds and tracks BTC up/down markets for a chosen interval (5 or 15 minutes). + + Features: + - Async HTTP requests with exponential backoff + - Market caching + - Automatic refresh + - Error recovery + """ + + def __init__( + self, + refresh_interval: float = 30.0, + max_retries: int = 3, + retry_delay: float = 2.0, + interval_minutes: int = 15, + ): + self.refresh_interval = refresh_interval + self.max_retries = max_retries + self.retry_delay = retry_delay + self.interval_minutes = int(interval_minutes) if int(interval_minutes) in (5, 15) else 15 + self._slug_pattern = _btc_slug_pattern(self.interval_minutes) + + self._session: Optional[aiohttp.ClientSession] = None + self._current_market: Optional[Market] = None + self._market_history: List[str] = [] # List of processed market slugs + self._last_refresh: Optional[datetime] = None + self._running = False + + # Callbacks + self._on_new_market_callbacks: List[callable] = [] + self._on_market_end_callbacks: List[callable] = [] + + async def _get_session(self) -> aiohttp.ClientSession: + """Get or create HTTP session (proxy-aware via env).""" + if self._session is None or self._session.closed: + timeout = aiohttp.ClientTimeout(total=30, connect=10) + self._session = aiohttp.ClientSession(timeout=timeout, trust_env=True) + return self._session + + async def _request_with_retry( + self, + url: str, + params: Optional[Dict] = None, + method: str = "GET" + ) -> Optional[Dict]: + """ + Make HTTP request with retry logic. + + Args: + url: Request URL + params: Query parameters + method: HTTP method + + Returns: + JSON response or None on failure + """ + session = await self._get_session() + last_error = None + + for attempt in range(self.max_retries): + try: + async with session.request( + method, url, params=params, proxy=_PROXY_URL or None + ) as resp: + if resp.status == 200: + return await resp.json() + elif resp.status == 429: + # Rate limited - wait longer + wait_time = self.retry_delay * (2 ** attempt) * 2 + logger.warning(f"Rate limited, waiting {wait_time:.1f}s") + await asyncio.sleep(wait_time) + continue + elif resp.status >= 500: + # Server error - retry + logger.warning(f"Server error {resp.status}, retrying...") + await asyncio.sleep(self.retry_delay * (2 ** attempt)) + continue + else: + logger.error(f"HTTP {resp.status}: {await resp.text()}") + return None + + except asyncio.TimeoutError: + logger.warning(f"Request timeout (attempt {attempt + 1}/{self.max_retries})") + last_error = "timeout" + except aiohttp.ClientError as e: + logger.warning(f"Client error: {e} (attempt {attempt + 1}/{self.max_retries})") + last_error = str(e) + except Exception as e: + logger.error(f"Unexpected error: {e}") + last_error = str(e) + + if attempt < self.max_retries - 1: + await asyncio.sleep(self.retry_delay * (2 ** attempt)) + + logger.error(f"Request failed after {self.max_retries} attempts: {last_error}") + return None + + def _parse_market(self, data: Dict) -> Optional[Market]: + """ + Parse market data from Gamma API response. + + Args: + data: Raw market data from API + + Returns: + Market object or None if parsing fails + """ + try: + slug = data.get("slug", "") + + # Check if it's a BTC up/down market for our interval + match = self._slug_pattern.match(slug) + if not match: + return None + + # Parse token IDs + clob_token_ids = data.get("clobTokenIds", "[]") + if isinstance(clob_token_ids, str): + clob_token_ids = json.loads(clob_token_ids) + + if len(clob_token_ids) < 2: + logger.warning(f"Market {slug} has insufficient token IDs") + return None + + # Parse outcomes to match tokens + outcomes = data.get("outcomes", "[]") + if isinstance(outcomes, str): + outcomes = json.loads(outcomes) + + # Determine Up/Down token indices + up_idx, down_idx = 0, 1 + for i, outcome in enumerate(outcomes): + if outcome.lower() == "up": + up_idx = i + elif outcome.lower() == "down": + down_idx = i + + # Parse times + end_date_str = data.get("endDate", "") + start_time_str = data.get("eventStartTime") or data.get("startDate", "") + + if not end_date_str: + logger.warning(f"Market {slug} has no end date") + return None + + # Parse ISO dates + end_time = datetime.fromisoformat(end_date_str.replace("Z", "+00:00")) + + if start_time_str: + start_time = datetime.fromisoformat(start_time_str.replace("Z", "+00:00")) + else: + start_time = end_time - timedelta(minutes=self.interval_minutes) + + # Parse prices + outcome_prices = data.get("outcomePrices", "[]") + if isinstance(outcome_prices, str): + outcome_prices = json.loads(outcome_prices) + + up_price = float(outcome_prices[up_idx]) if len(outcome_prices) > up_idx else 0.5 + down_price = float(outcome_prices[down_idx]) if len(outcome_prices) > down_idx else 0.5 + + return Market( + id=data.get("id", ""), + slug=slug, + question=data.get("question", ""), + condition_id=data.get("conditionId", ""), + up_token_id=clob_token_ids[up_idx], + down_token_id=clob_token_ids[down_idx], + start_time=start_time, + end_time=end_time, + active=data.get("active", True), + closed=data.get("closed", False), + accepting_orders=data.get("acceptingOrders", True), + up_price=up_price, + down_price=down_price, + best_bid=float(data.get("bestBid", 0) or 0), + best_ask=float(data.get("bestAsk", 0) or 0), + volume=float(data.get("volume", 0) or 0), + liquidity=float(data.get("liquidity", 0) or 0), + ) + + except Exception as e: + logger.error(f"Error parsing market: {e}") + return None + + async def find_active_market(self) -> Optional[Market]: + """ + Find the currently active BTC up/down market for this finder’s interval. + + Returns: + Active Market or None if not found + """ + slug_part = f"btc-updown-{self.interval_minutes}m" + logger.debug("Searching for active %s market...", slug_part) + + # Search Gamma API + url = f"{GAMMA_API}/markets" + params = { + "slug_contains": slug_part, + "active": "true", + "closed": "false", + "limit": 10, + "order": "endDate", + "ascending": "true" + } + + data = await self._request_with_retry(url, params) + + if not data: + logger.warning("No response from Gamma API") + return None + + # Handle both list and single object responses + markets_list = data if isinstance(data, list) else [data] + + now = datetime.now(timezone.utc) + best_market: Optional[Market] = None + + for market_data in markets_list: + market = self._parse_market(market_data) + + if market is None: + continue + + # Skip already processed markets + if market.slug in self._market_history: + continue + + # Check if market is currently tradeable + if not market.is_tradeable(): + continue + + # Check if market has started + if market.start_time > now: + continue + + # Prefer market with most time remaining + if best_market is None or market.time_remaining_seconds() > best_market.time_remaining_seconds(): + best_market = market + + if best_market: + logger.info( + f"Found active market: {best_market.slug} " + f"({best_market.minutes_remaining():.1f} min remaining)" + ) + + return best_market + + async def refresh(self) -> Optional[Market]: + """ + Refresh market status. + + Checks if current market is still active, or finds a new one. + + Returns: + Current active market or None + """ + self._last_refresh = datetime.now(timezone.utc) + + # Check if current market has ended + if self._current_market: + if self._current_market.time_remaining_seconds() <= 0: + logger.info(f"Market {self._current_market.slug} has ended") + + # Mark as processed + self._market_history.append(self._current_market.slug) + + # Trigger callbacks + for callback in self._on_market_end_callbacks: + try: + if asyncio.iscoroutinefunction(callback): + await callback(self._current_market) + else: + callback(self._current_market) + except Exception as e: + logger.error(f"Market end callback error: {e}") + + self._current_market = None + + # Find new market if needed + if self._current_market is None: + new_market = await self.find_active_market() + + if new_market: + self._current_market = new_market + + # Trigger callbacks + for callback in self._on_new_market_callbacks: + try: + if asyncio.iscoroutinefunction(callback): + await callback(new_market) + else: + callback(new_market) + except Exception as e: + logger.error(f"New market callback error: {e}") + + return self._current_market + + def on_new_market(self, callback: callable): + """Register callback for new market discovery.""" + self._on_new_market_callbacks.append(callback) + + def on_market_end(self, callback: callable): + """Register callback for market end.""" + self._on_market_end_callbacks.append(callback) + + @property + def current_market(self) -> Optional[Market]: + """Get current market.""" + return self._current_market + + async def run_loop(self): + """ + Main loop - continuously searches for markets. + """ + self._running = True + logger.info("Market finder started") + + while self._running: + try: + await self.refresh() + + # Adjust sleep based on market state + if self._current_market: + remaining = self._current_market.time_remaining_seconds() + + if remaining < 60: + # Market ending soon - check frequently + await asyncio.sleep(5) + elif remaining < 300: + # Less than 5 min - moderate frequency + await asyncio.sleep(15) + else: + await asyncio.sleep(self.refresh_interval) + else: + # No active market - search more frequently + await asyncio.sleep(10) + + except asyncio.CancelledError: + logger.info("Market finder cancelled") + break + except Exception as e: + logger.error(f"Market finder error: {e}") + await asyncio.sleep(self.retry_delay) + + # Cleanup + if self._session and not self._session.closed: + await self._session.close() + + logger.info("Market finder stopped") + + def stop(self): + """Stop the market finder.""" + self._running = False + + async def close(self): + """Close resources.""" + self.stop() + if self._session and not self._session.closed: + await self._session.close() \ No newline at end of file diff --git a/btc-binary-VWAP-Momentum-bot/src/order_executor.py b/btc-binary-VWAP-Momentum-bot/src/order_executor.py new file mode 100644 index 0000000..68ac297 --- /dev/null +++ b/btc-binary-VWAP-Momentum-bot/src/order_executor.py @@ -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 + } diff --git a/btc-binary-VWAP-Momentum-bot/src/position_tracker.py b/btc-binary-VWAP-Momentum-bot/src/position_tracker.py new file mode 100644 index 0000000..05fe3ae --- /dev/null +++ b/btc-binary-VWAP-Momentum-bot/src/position_tracker.py @@ -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 + } diff --git a/btc-binary-VWAP-Momentum-bot/src/proxy_util.py b/btc-binary-VWAP-Momentum-bot/src/proxy_util.py new file mode 100644 index 0000000..0076880 --- /dev/null +++ b/btc-binary-VWAP-Momentum-bot/src/proxy_util.py @@ -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 diff --git a/btc-binary-VWAP-Momentum-bot/src/simulation_history.py b/btc-binary-VWAP-Momentum-bot/src/simulation_history.py new file mode 100644 index 0000000..b42fc51 --- /dev/null +++ b/btc-binary-VWAP-Momentum-bot/src/simulation_history.py @@ -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) diff --git a/btc-binary-VWAP-Momentum-bot/src/telegram_notifier.py b/btc-binary-VWAP-Momentum-bot/src/telegram_notifier.py new file mode 100644 index 0000000..7549320 --- /dev/null +++ b/btc-binary-VWAP-Momentum-bot/src/telegram_notifier.py @@ -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 = "🎮 [SIMULATION]\n" if simulation else "" + text = ( + f"{mode}" + f"🟢 ENTRY\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"🛡 HEDGE\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"🏁 MARKET RESOLVED\n" + f"{emoji} Winner: {winner}\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 + } \ No newline at end of file diff --git a/btc-binary-VWAP-Momentum-bot/src/user_websocket.py b/btc-binary-VWAP-Momentum-bot/src/user_websocket.py new file mode 100644 index 0000000..005a63a --- /dev/null +++ b/btc-binary-VWAP-Momentum-bot/src/user_websocket.py @@ -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 \ No newline at end of file diff --git a/btc-binary-VWAP-Momentum-bot/src/web_dashboard.py b/btc-binary-VWAP-Momentum-bot/src/web_dashboard.py new file mode 100644 index 0000000..443eac0 --- /dev/null +++ b/btc-binary-VWAP-Momentum-bot/src/web_dashboard.py @@ -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 = """ + + + + + BTC Live Bot + + + +

BTC up/down — live

+
Loading…
+
+

Session

+

Strategy

+

UP

+

DOWN

+

BTC / USD (Chainlink)

+

Trading

+
+
Refreshes every second ·
+ + + +""" + + +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 diff --git a/btc-binary-VWAP-Momentum-bot/src/websocket_client.py b/btc-binary-VWAP-Momentum-bot/src/websocket_client.py new file mode 100644 index 0000000..de5f98e --- /dev/null +++ b/btc-binary-VWAP-Momentum-bot/src/websocket_client.py @@ -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 \ No newline at end of file diff --git a/up-down-spread-bot/.env.example b/up-down-spread-bot/.env.example new file mode 100644 index 0000000..0c3c22b --- /dev/null +++ b/up-down-spread-bot/.env.example @@ -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= diff --git a/up-down-spread-bot/.gitignore b/up-down-spread-bot/.gitignore new file mode 100644 index 0000000..a722a15 --- /dev/null +++ b/up-down-spread-bot/.gitignore @@ -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 diff --git a/up-down-spread-bot/README.md b/up-down-spread-bot/README.md new file mode 100644 index 0000000..274d6de --- /dev/null +++ b/up-down-spread-bot/README.md @@ -0,0 +1,283 @@ +# Meridian — Polymarket Multi-Asset Crypto Desk + +Automated execution for Polymarket **5- or 15-minute** crypto Up/Down markets. Runs **four parallel traders** (BTC, ETH, SOL, XRP) from **one Polygon wallet**, using **Late Entry V3** (`late_v3`): entries only in the **final minutes**, when **spread** and **confidence** (ask skew) show a **clear favorite**, with **stop-loss**, **flip-stop**, and **safety guard** limits. + +| Resource | Link | +|----------|------| +| **Suite overview** | [Repository README](../README.md) | +| **Full guide** | [docs/README.md](docs/README.md) | +| **GitHub** | [PolyBullLabs/polymakret-5min-15min-1hour-arbitrage-bot](https://github.com/PolyBullLabs/polymakret-5min-15min-1hour-arbitrage-bot.git) | +| **Telegram** | [@terauss](https://t.me/terauss) | + +--- + +## Why this strategy can work (and what breaks it) + +**Idea:** Very late in the window, token prices often **embed** a view on how the underlying will fix vs the strike. Meridian **follows the book’s favorite** (higher ask) but **filters** noise: **tight sum of asks** (liquidity sanity), **minimum skew** (`min_confidence`), and **max price** cap so you do not pay **too much** for the last bit of certainty. + +**Profit source (when it exists):** If the **favorite wins** more often than its **entry price** implies (e.g. buy at $0.72 and wins >72% in that regime), expectancy can be positive. **Shorter exposure** vs early entry can reduce **path risk** but usually **increases** average entry price. + +**Risk controls:** **Dry run**, **per-order and per-market caps**, **stop-loss** (fixed $ or % of stake), **flip-stop** when your side loses leadership, **entry cooldown**, and validated prices before exits (fresh book, ask sum in range). See [docs/README.md](docs/README.md) for exact formulas. + +**When to use:** You want **several coins**, **one wallet**, **terminal + optional web dashboard**, and **explicit exit rules**. **When not to:** You need **BTC-only VWAP/momentum** filters—use `btc-binary-VWAP-Momentum-bot`—or **PTB vs spot diff** rules—use `5min-15min-PTB-bot`. + +--- + +## Features + +- **Multi-Market Trading** — Trade 4 cryptocurrencies in parallel (BTC, ETH, SOL, XRP) +- **Late Entry Strategy** — Enter positions in the last 4 minutes before market close +- **Real-time WebSocket Data** — Live orderbook updates from Polymarket +- **Automatic Redeem** — Background collection of winnings after market resolution +- **Telegram Integration** — Commands for monitoring, charts, balance, and emergency shutdown +- **Safety Guard** — Protection layer with order limits and emergency stop +- **Position Tracking** — Real-time position monitoring via REST API +- **Stop-Loss & Flip-Stop** — Configurable exit strategies per coin +- **PnL Charts** — Visual performance tracking with matplotlib + +## Architecture + +``` +┌──────────────────────────────────────────────────────────────┐ +│ MAIN TRADING LOOP │ +├──────────────────────────────────────────────────────────────┤ +│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ +│ │ BTC │ │ ETH │ │ SOL │ │ XRP │ │ +│ │ Trader │ │ Trader │ │ Trader │ │ Trader │ │ +│ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │ +│ └──────────┬─┴───────────┬┘──────────┘ │ +│ ┌───┴───┐ ┌────┴────┐ │ +│ │ Order │ │ Data │ │ +│ │Executor│ │ Feed │ │ +│ └───────┘ └─────────┘ │ +└──────────────────────────────────────────────────────────────┘ +``` + +## Requirements + +- Python 3.10 or higher +- Polygon wallet with USDC (bridged) +- Small amount of POL/MATIC for gas fees +- Polymarket API credentials +- VPN (if needed for geo-restrictions) + +## Installation + +### 1. Clone the Repository + +```bash +git clone https://github.com/PolyBullLabs/polymakret-5min-15min-1hour-arbitrage-bot.git +cd polymakret-5min-15min-1hour-arbitrage-bot/up-down-spread-bot +``` + +### 2. Create Virtual Environment + +**IMPORTANT: You must use a virtual environment (venv)!** + +```bash +# Create venv +python3 -m venv venv + +# Activate venv +# Linux/macOS: +source venv/bin/activate + +# Windows: +.\venv\Scripts\activate +``` + +### 3. Install Dependencies + +```bash +pip install -r requirements.txt +``` + +### 4. Configuration + +```bash +# Copy configuration files +cp .env.example .env +cp config/config.example.json config/config.json + +# Edit .env with your credentials +nano .env + +# Edit config.json for trading parameters +nano config/config.json +``` + +## Configuration + +### Environment Variables (.env) + +```env +# Wallet (REQUIRED) +PRIVATE_KEY=0x...your_private_key... + +# Polygon Network +RPC_URL=https://polygon-rpc.com +CHAIN_ID=137 + +# Polymarket API (REQUIRED) +CLOB_HOST=https://clob.polymarket.com +POLYMARKET_API_KEY=your_api_key +POLYMARKET_API_SECRET=your_api_secret +POLYMARKET_API_PASSPHRASE=your_api_passphrase + +# Telegram Notifications (optional) +TELEGRAM_BOT_TOKEN=your_bot_token +TELEGRAM_CHAT_ID=your_chat_id +``` + +### Trading Configuration (config/config.json) + +Key parameters: + +| Section | Parameter | Description | +|---------|-----------|-------------| +| `safety.dry_run` | `true/false` | Enable dry run mode (no real trades) | +| `safety.max_order_size_usd` | `150` | Maximum single order size in USD | +| `safety.max_total_investment` | `1000` | Maximum investment per market | +| `trading.btc/eth/sol/xrp.enabled` | `true/false` | Enable/disable specific coins | +| `data_sources.polymarket.market_window` | `"15m"` or `"5m"` | **Which Polymarket horizon:** 15-minute or 5-minute Up/Down markets | +| `strategy.entry_window_sec` | `240` | Entry window (last 4 minutes) | +| `strategy.min_confidence` | `0.30` | Minimum price difference to enter | +| `strategy.price_max` | `0.92` | Maximum entry price | +| `exit.stop_loss.per_coin.*.value` | `-12` | Stop-loss threshold in USD | + +## Usage + +### Start Trading + +```bash +# Activate virtual environment +source venv/bin/activate + +# Run the trading bot +cd src +python3 main.py +``` + +### Keyboard Controls + +| Key | Action | +|-----|--------| +| `Q` | Quit gracefully | +| `E` | Emergency stop (blocks all trading) | + +### Telegram Commands + +| Command | Description | +|---------|-------------| +| `/chart` or `/pnl` | Generate current PnL chart | +| `/b` or `/balance` | Show wallet balance (USDC + POL) | +| `/t` or `/positions` | Show active positions | +| `/r` or `/redeem` | Redeem completed markets (interactive) | +| `/off` or `/stop` | Emergency shutdown (with confirmation) | +| `/help` | Show all available commands | + +## Project Structure + +``` +up-down-spread-bot/ +├── src/ +│ ├── main.py # Main entry point +│ ├── strategy.py # Late Entry V3 strategy +│ ├── data_feed.py # WebSocket data feeds +│ ├── multi_trader.py # Multi-market trader manager +│ ├── trader.py # Individual trader logic +│ ├── order_executor.py # Order execution engine +│ ├── position_tracker.py # Real-time position tracking +│ ├── safety_guard.py # Safety limits and emergency stop +│ ├── simple_redeem_collector.py # Automatic redeem collection +│ ├── telegram_notifier.py # Telegram bot integration +│ ├── dashboard_multi_ab.py # Terminal dashboard +│ ├── polymarket_api.py # Polymarket API wrapper +│ ├── pnl_chart_generator.py # PnL chart generation +│ ├── trade_logger.py # Trade logging +│ └── keyboard_listener.py # Keyboard input handler +├── config/ +│ └── config.json # Trading configuration +├── logs/ # Log files +├── requirements.txt # Python dependencies +├── .env # Environment variables +└── README.md # This file +``` + +## Strategy: late-window entry (Late Entry V3) + +Meridian uses the Late Entry V3 / `late_v3` entry rules: + +1. **Entry Window**: Only enter positions in the last 4 minutes (240 seconds) before market close +2. **Favorite Detection**: Buy the side with higher ask price (market consensus) +3. **Confidence Filter**: Only enter when price difference exceeds 30% +4. **Time-based Sizing**: + - Above 180s remaining: 8 contracts + - Above 120s remaining: 10 contracts + - Below 120s remaining: 12 contracts +5. **Exit Strategies**: + - Natural close (market resolution) + - Stop-loss (configurable per coin) + - Flip-stop (when our position becomes underdog) + +## Safety Features + +- **Dry Run Mode**: Test without real trades +- **Order Size Limits**: Maximum per-order and per-market limits +- **Rate Limiting**: Maximum orders per minute +- **Emergency Stop**: Keyboard shortcut to halt all trading +- **Investment Tracking**: Per-market investment limits +- **Position Persistence**: Save positions on shutdown + +## Logs + +Logs are stored in the `logs/` directory: + +- `trades.jsonl` — All executed trades (JSON Lines format) +- `orders.jsonl` — Order execution details +- `safety.log` — Safety guard events +- `session.json` — Current session state +- `error.log` — Error messages + +## Troubleshooting + +### "Rate limit exceeded" + +Use a private RPC endpoint: +```env +RPC_URL=https://polygon-mainnet.g.alchemy.com/v2/YOUR_API_KEY +``` + +### "Invalid signature" + +1. Check that API credentials are correct +2. Verify the private key matches the Polymarket account +3. Regenerate API credentials on Polymarket + +### WebSocket connection drops + +The bot automatically reconnects. If persistent: +1. Check internet connection +2. Use a VPN +3. Change DNS to 1.1.1.1 or 8.8.8.8 + +### Positions not redeeming + +1. Wait for oracle resolution (1-2 minutes after market close) +2. Use `/r` command in Telegram to manually trigger +3. Check `logs/` for error messages + +## Important Notes + +1. **USDC Type**: Polymarket uses USDC (Bridged), not USDC.e (Native) +2. **Gas Fees**: Keep POL/MATIC balance for transactions +3. **API Limits**: Public RPCs have rate limits — use private RPC for stability +4. **Risks**: Cryptocurrency trading involves significant risks + +## License + +MIT License + +## Disclaimer + +This software is for **educational and research purposes only**. Trading on prediction markets involves **substantial risk**; you may lose **all** funds you deploy. **Past results do not guarantee future performance.** Authors are **not** liable for losses. For **licensing**, **custom strategies**, or **advanced quant tooling** (Kelly, Monte Carlo, martingale / anti-martingale frameworks, RSI/MACD/Bollinger stacks, Bayesian edge models, etc.), contact [@terauss](https://t.me/terauss). See the [repository README](../README.md). diff --git a/up-down-spread-bot/config/config.example.json b/up-down-spread-bot/config/config.example.json new file mode 100644 index 0000000..7d3fe8e --- /dev/null +++ b/up-down-spread-bot/config/config.example.json @@ -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 + } +} diff --git a/up-down-spread-bot/docs/README.md b/up-down-spread-bot/docs/README.md new file mode 100644 index 0000000..c8e4a31 --- /dev/null +++ b/up-down-spread-bot/docs/README.md @@ -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-" # or "btc-updown-5m-" +market_end = slot + interval +``` + +### WebSocket Reconnection + +When a market expires, the bot automatically: +1. Calculates the next slot for the configured interval +2. Fetches new token IDs from Gamma API +3. Reconnects WebSocket with new asset IDs +4. Timer resets for the new market window + +--- + +## 8. Configuration Reference + +All settings live in `config/config.json`. Here is every section: + +### `safety` — Risk Controls + +| Key | Default | Description | +|-----|---------|-------------| +| `dry_run` | `true` | If true, simulates trades without real money | +| `max_order_size_usd` | `150` | Maximum single order size (contracts x price) | +| `max_orders_per_minute` | `100` | Rate limit for orders | +| `max_total_investment` | `1000` | Maximum cumulative investment per market slug | + +### `trading` — Coin Enable/Disable + +| Key | Default | Description | +|-----|---------|-------------| +| `btc.enabled` | `true` | Enable BTC trading | +| `eth.enabled` | `true` | Enable ETH trading | +| `sol.enabled` | `true` | Enable SOL trading | +| `xrp.enabled` | `false` | Enable XRP trading (disabled by default — less liquid) | + +### `strategy` — Entry Parameters + +| Key | Default | Description | +|-----|---------|-------------| +| `name` | `late_entry_v3` | Strategy identifier | +| `entry_window_sec` | `240` | Only enter in last N seconds of market | +| `entry_frequency_sec` | `7` | Minimum seconds between entries in same market | +| `min_confidence` | `0.30` | Minimum \|up_ask - down_ask\| to enter | +| `max_spread` | `1.05` | Maximum up_ask + down_ask allowed | +| `price_max` | `0.92` | Maximum price to pay for favorite side | +| `max_investment_per_market` | `300` | Maximum USD invested per market | +| `sizing.above_180_sec` | `8` | Contracts when > 180s remaining | +| `sizing.above_120_sec` | `10` | Contracts when > 120s remaining | +| `sizing.below_120_sec` | `12` | Contracts when <= 120s remaining | + +### `exit.flip_stop` — Flip-Stop Settings + +| Key | Default | Description | +|-----|---------|-------------| +| `enabled` | `true` | Enable flip-stop exit | +| `price_threshold` | `0.48` | Sell when our side's ask drops to this | +| `check_realtime` | `true` | Check on every price update | + +### `exit.stop_loss` — Per-Coin Stop-Loss + +| Key | Default | Description | +|-----|---------|-------------| +| `enabled` | `true` | Enable stop-loss | +| `per_coin.btc.type` | `fixed` | `fixed` = dollar amount, `percent` = % of invested | +| `per_coin.btc.value` | `-12.0` | Threshold (negative = loss amount) | +| `per_coin.eth.value` | `-12.0` | ETH stop-loss threshold | +| `per_coin.sol.value` | `-12.0` | SOL stop-loss threshold | +| `per_coin.xrp.value` | `-11.0` | XRP stop-loss threshold | + +### `execution.buy` — Buy Order Settings + +| Key | Default | Description | +|-----|---------|-------------| +| `order_type` | `FAK` | Fill-And-Kill order type | +| `max_fak_attempts` | `3` | Retry attempts per buy | +| `retry_delay_sec` | `0.3` | Delay between retries | +| `min_order_usd` | `1.0` | Minimum remaining order value to continue | +| `target_fill_percent` | `98.0` | Stop retrying when this % is filled | + +### `execution.sell` — Sell Order Settings + +| Key | Default | Description | +|-----|---------|-------------| +| `strategy` | `FOK_CHUNKED` | Sell in FOK chunks | +| `chunk_size` | `50` | Contracts per chunk | +| `chunk_delay_sec` | `0.1` | Delay between chunks | +| `max_chunk_retries` | `5` | Retries per chunk | +| `price` | `0.01` | Sell price (market sell) | +| `min_dust_threshold` | `0.1` | Ignore balances below this | +| `sweep_max_attempts` | `3` | FOK sweep retry count | +| `sweep_enable_fallback` | `true` | Enable FAK/GTC fallback | +| `delayed_sweep_enabled` | `true` | Re-check balance after delay | +| `delayed_sweep_delay_sec` | `1` | Seconds to wait before delayed sweep | + +### `execution.redeem` — Redemption Settings + +| Key | Default | Description | +|-----|---------|-------------| +| `startup_check_delay_sec` | `60` | Wait before first redeem check | +| `first_check_delay_sec` | `480` | First regular check (8 min after start) | +| `check_interval_sec` | `300` | Check every 5 minutes after that | +| `sizeThreshold` | `0.1` | Minimum token balance to redeem | +| `gas_limit` | `500000` | Gas limit for redeem transactions | +| `gas_price_multiplier` | `1.5` | Gas price multiplier | + +### `execution.rpc_config` — Polygon RPC + +| Key | Default | Description | +|-----|---------|-------------| +| `endpoints` | `["https://polygon-rpc.com"]` | RPC endpoint(s) | +| `retry_attempts` | `10` | Max retries for RPC calls | +| `enable_parallel_requests` | `true` | Query multiple endpoints in parallel | + +### `data_sources.polymarket` — Market window length + +| Key | Default | Description | +|-----|---------|-------------| +| `gamma_api` | `https://gamma-api.polymarket.com` | Gamma REST base URL | +| `ws_url` | `wss://ws-subscriptions-clob.polymarket.com/ws/market` | Orderbook WebSocket | +| **`market_window`** | **`"15m"`** | **Choose here:** `"5m"` = 5-minute Up/Down (`{coin}-updown-5m-{slot}`), `"15m"` = 15-minute (`{coin}-updown-15m-{slot}`). | +| `market_interval_sec` | (derived) | Set automatically from `market_window` (300 or 900). You can set this instead of `market_window` if you need raw seconds; if both are set, **`market_window` wins**. | + +For **5-minute** markets, set `"market_window": "5m"` and tune `strategy.entry_window_sec` if needed (for example **90–120** seconds). The strategy scales time-based sizing tiers to shorter windows automatically (e.g. 60s/40s for 5m). + +### `display` — Terminal Dashboard + +| Key | Default | Description | +|-----|---------|-------------| +| `width` | `160` | Dashboard width in characters | +| `update_interval` | `1` | Dashboard refresh interval (seconds) | + +### `logging` — Log File Paths + +| Key | Default | Description | +|-----|---------|-------------| +| `trades_file` | `logs/trades.jsonl` | Trade log (JSON Lines) | +| `session_file` | `logs/session.json` | Session state file | + +--- + +## 9. Environment Variables Reference + +All environment variables are set in the `.env` file at the project root. + +### Required for Live Trading + +| Variable | Example | Description | +|----------|---------|-------------| +| `PRIVATE_KEY` | `0xabcdef...` | Polygon wallet private key (64 hex chars after 0x) | +| `RPC_URL` | `https://polygon-rpc.com` | Polygon RPC endpoint | +| `CHAIN_ID` | `137` | Polygon chain ID (always 137) | +| `CLOB_HOST` | `https://clob.polymarket.com` | Polymarket CLOB API host | +| `POLYMARKET_API_KEY` | `abc-123-...` | Your Polymarket API key | +| `POLYMARKET_API_SECRET` | `base64string==` | Your Polymarket API secret | +| `POLYMARKET_API_PASSPHRASE` | `passphrase` | Your Polymarket API passphrase | + +### Optional + +| Variable | Description | +|----------|-------------| +| `TELEGRAM_BOT_TOKEN` | Telegram bot token for notifications | +| `TELEGRAM_CHAT_ID` | Telegram chat ID to receive messages | + +### How to Get Polymarket API Credentials + +1. Go to [Polymarket](https://polymarket.com) and log in +2. Navigate to your account settings +3. Generate API credentials (key, secret, passphrase) +4. Your private key is the Polygon wallet key linked to your Polymarket account + +--- + +## 10. Dashboard & Terminal UI + +When running, the bot displays a live terminal dashboard (refreshes every second): + +``` +═══════════════════════════════════════════════════════════════════ + Runtime: 00:45:23 | BTC | ETH | SOL | XRP (Polymarket orderbooks) +═══════════════════════════════════════════════════════════════════ + + Strategy: late_v3 + Balance: $487.32 | Trades: 12 | W/L: 8/4 | Win Rate: 66.7% + + BTC [15m-1711234500] Time: 142s UP: $0.72 DN: $0.30 Fav: UP Conf: 0.42 + Position: 10 UP @ $0.72 | PnL: +$1.20 | Max DD: -$0.80 + If UP wins: +$2.80 | If DN wins: -$7.20 + + ETH [15m-1711234500] Time: 142s UP: $0.65 DN: $0.38 Fav: UP Conf: 0.27 + (waiting for confidence >= 0.30) + + SOL [15m-1711234500] Time: 142s UP: $0.80 DN: $0.22 Fav: UP Conf: 0.58 + Position: 12 UP @ $0.78 | PnL: +$0.24 | Max DD: -$0.60 + + XRP [disabled] + + Recent trades: + btc-updown-15m-1711233600 UP WIN +$3.40 + sol-updown-15m-1711233600 UP LOSS -$6.20 + + [M] Manual Redeem All | [Ctrl+C] Stop +═══════════════════════════════════════════════════════════════════ +``` + +**What each line shows:** +- **Per coin:** Market ID suffix, time remaining, UP/DOWN asks, favorite side, confidence score +- **Position:** Number of contracts, average entry price, current unrealized PnL, maximum drawdown +- **Scenarios:** What happens if UP wins vs if DOWN wins (helps you understand your exposure) +- **Recent trades:** Last closed trades with win/loss result and PnL + +--- + +## 11. Web Dashboard (Browser) + +Install dependencies (`flask` is listed in `requirements.txt`). From the `src` directory, start the bot with the web UI enabled: + +```bash +cd src +python main.py --web +``` + +Open **http://127.0.0.1:5050/** (or `--web-port` / `--web-host` as needed). + +| Feature | Description | +|--------|-------------| +| **Live analytics** | Session uptime, mode (dry run / live), wallet balance, total PnL, ROI, per-coin orderbook (UP/DN asks, favorite, confidence), open position with unrealized PnL and scenario PnL | +| **Recent trades** | Last closed trades across strategies | +| **Settings** | Load and edit `config/config.json` in the browser; **save writes the file** — **restart the bot** to apply changes | +| **Request stop** | Sends the same graceful stop as **Ctrl+C** (shutdown handler saves positions) | + +**Security:** By default the server binds to `127.0.0.1` only. Use `--web-host 0.0.0.0` only on trusted networks (add a reverse proxy and authentication if exposing to the internet). + +**API (for your own tools):** `GET /api/status` (JSON snapshot), `GET/POST /api/config`, `POST /api/bot/stop`, `GET /api/health`. + +You can also run the Flask app alone for a read-only view when `logs/bot_state.json` is being updated: `cd src` then `python -m web_dashboard.server` (snapshot appears after the bot has run with `--web` at least once). + +--- + +## 12. Telegram Integration + +If configured, the bot sends notifications and accepts commands via Telegram. + +### Commands + +| Command | Description | +|---------|-------------| +| `/chart` or `/pnl` | Generate and send a PnL chart image | +| `/b` or `/balance` | Show wallet USDC and POL balance | +| `/t` or `/positions` | Show all active positions | +| `/r` or `/redeem` | Manually trigger redemption of resolved markets | +| `/off` or `/stop` | Emergency shutdown (asks for confirmation) | +| `/help` | List all available commands | + +### Notifications Sent Automatically + +- Trade entries (coin, side, contracts, price) +- Trade exits (reason, PnL) +- Stop-loss and flip-stop triggers +- Market resolution results +- Error alerts + +--- + +## 13. Safety Features + +### Dry Run Mode + +When `safety.dry_run` is `true`: +- All buy orders are **simulated** (no real transactions) +- All sell orders are **simulated** +- The bot behaves identically otherwise (prices, signals, dashboard) +- Use this to verify the bot works before risking real money + +### Order Size Limits + +- **Per order:** `max_order_size_usd` (default $150) — rejects any single order above this +- **Per market:** `max_total_investment` (default $1000) — tracks cumulative investment per market slug +- **Per strategy:** `max_investment_per_market` (default $300) — checked by the strategy before signaling + +### Rate Limiting + +- Maximum `max_orders_per_minute` (default 100) orders per minute +- Entry frequency: one signal per 7 seconds per market (prevents order spam) + +### Emergency Stop + +- Press **Ctrl+C** to gracefully shut down (saves positions as emergency saves) +- Use `/off` in Telegram for remote shutdown +- `SafetyGuard.activate_emergency_stop()` blocks all future orders + +### Price Validation + +Before any exit decision, prices are validated: +- Must be **fresh** (< 2 seconds old) +- UP and DOWN timestamps must be **synchronized** (< 2 seconds apart) +- Sum of asks must be **reasonable** (between $0.95 and $1.15) + +This prevents the bot from making exit decisions on stale or corrupted data. + +--- + +## 14. Project Structure + +``` +up-down-spread-bot/ +├── src/ +│ ├── main.py # Entry point, main loop, callbacks, config loading +│ ├── market_config.py # market_window "5m"/"15m" → market_interval_sec +│ ├── strategy.py # Late Entry V3 strategy logic +│ ├── trader.py # Per-coin position management and PnL tracking +│ ├── multi_trader.py # Manages multiple Trader instances (one per coin) +│ ├── data_feed.py # Gamma API + WebSocket orderbook feed +│ ├── order_executor.py # CLOB client: FAK buys, FOK sells, sweeps, redeems +│ ├── polymarket_api.py # Gamma API helper for market resolution +│ ├── safety_guard.py # Dry run, order limits, rate limiting, emergency stop +│ ├── position_tracker.py # Position model (for WebSocket user channel) +│ ├── trade_logger.py # Logs trades to logs/trades.log +│ ├── dashboard_multi_ab.py # Terminal UI rendering +│ ├── telegram_notifier.py # Telegram bot notifications and commands +│ ├── simple_redeem_collector.py # Background thread for automatic redemption +│ ├── pnl_chart_generator.py # Matplotlib PnL chart generation +│ ├── keyboard_listener.py # Non-blocking keyboard input (cross-platform) +│ ├── web_dashboard_state.py # Thread-safe snapshot for browser dashboard +│ └── web_dashboard/ # Flask app: API + static UI (python main.py --web) +├── config/ +│ ├── config.json # Your trading configuration (create from example) +│ └── config.example.json # Example configuration template +├── logs/ # Log files (created automatically) +│ ├── trades.jsonl # Trade history (JSON Lines) +│ ├── safety.log # Safety guard events +│ ├── bot_state.json # Written when using --web (optional monitoring file) +│ └── session.json # Session state +├── docs/ # Documentation +│ └── README.md # This file +├── requirements.txt # Python dependencies +├── .env # Your environment variables (create from example) +├── .env.example # Example environment template +├── .gitignore # Git ignore rules +└── README.md # Project overview +``` + +--- + +## 15. Troubleshooting + +### `ModuleNotFoundError: No module named 'termios'` + +This happens on Windows. The `keyboard_listener.py` uses Unix-only modules. Make sure you have the latest version which includes Windows support via `msvcrt`. + +### `FileNotFoundError: config/config.json` + +You need to create the config file from the example: + +```bash +cp config/config.example.json config/config.json # Linux/macOS +copy config\config.example.json config\config.json # Windows +``` + +### `UnicodeEncodeError: 'charmap' codec can't encode character` + +This happens on Windows when writing emoji characters to log files. Make sure all `open()` calls in `safety_guard.py` use `encoding='utf-8'`. + +### "Rate limit exceeded" + +The public Polygon RPC has rate limits. Use a private RPC: + +```env +RPC_URL=https://polygon-mainnet.g.alchemy.com/v2/YOUR_API_KEY +``` + +### "Invalid signature" + +1. Verify your API credentials are correct in `.env` +2. Make sure the private key matches your Polymarket account +3. Regenerate API credentials on Polymarket if needed + +### WebSocket Disconnects + +The bot auto-reconnects between market windows. If connections drop frequently: +1. Check your internet connection +2. Try a VPN +3. Change DNS to `1.1.1.1` or `8.8.8.8` + +### Positions Not Redeeming + +1. Oracle resolution takes 1-2 minutes after market close +2. Use `/r` in Telegram to manually trigger +3. Check `logs/` directory for error details +4. The bot checks for redeemable positions every 5 minutes automatically + +--- + +## Disclaimer + +This software is for **educational purposes only**. Trading on prediction markets involves **substantial risk of loss**. Past performance **does not** indicate future results. Use at your own risk and never trade with money you cannot afford to lose. **Extended strategies** (martingale / anti-martingale / Fibonacci sizing, full TA stacks, Bayesian edge, Avellaneda–Stoikov-style inventory, Kelly, Monte Carlo, and related) are offered **separately**—see the [repository README](../../README.md) and Telegram [@terauss](https://t.me/terauss). diff --git a/up-down-spread-bot/requirements.txt b/up-down-spread-bot/requirements.txt new file mode 100644 index 0000000..002e166 --- /dev/null +++ b/up-down-spread-bot/requirements.txt @@ -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 diff --git a/up-down-spread-bot/src/dashboard_multi_ab.py b/up-down-spread-bot/src/dashboard_multi_ab.py new file mode 100644 index 0000000..4df6a31 --- /dev/null +++ b/up-down-spread-bot/src/dashboard_multi_ab.py @@ -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}" diff --git a/up-down-spread-bot/src/data_feed.py b/up-down-spread-bot/src/data_feed.py new file mode 100644 index 0000000..62aa0cb --- /dev/null +++ b/up-down-spread-bot/src/data_feed.py @@ -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}") diff --git a/up-down-spread-bot/src/keyboard_listener.py b/up-down-spread-bot/src/keyboard_listener.py new file mode 100644 index 0000000..bf2cf97 --- /dev/null +++ b/up-down-spread-bot/src/keyboard_listener.py @@ -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) diff --git a/up-down-spread-bot/src/main.py b/up-down-spread-bot/src/main.py new file mode 100644 index 0000000..e1585ef --- /dev/null +++ b/up-down-spread-bot/src/main.py @@ -0,0 +1,2301 @@ +#!/usr/bin/env python3 +""" +Meridian — Polymarket 15-minute multi-asset trading system. + +Four parallel traders (BTC, ETH, SOL, XRP), one wallet. +Strategy: late-window entry (Late Entry V3 / late_v3). +""" +import argparse +import json +import time +import signal +import sys +import subprocess +import os +import threading +import requests +from pathlib import Path +from typing import Dict +from concurrent.futures import ThreadPoolExecutor + +from data_feed import DataFeed +from strategy import LateEntryStrategy +from multi_trader import MultiTrader +from dashboard_multi_ab import DashboardMultiAB +from polymarket_api import get_market_outcome +from telegram_notifier import get_notifier +from safety_guard import SafetyGuard +from order_executor import OrderExecutor +from keyboard_listener import KeyboardListener +from market_config import apply_market_window_settings +import trader as trader_module + + +# Global configuration constants +STRATEGY_BASES = ['late_v3'] +COINS = ['btc', 'eth', 'sol', 'xrp'] + +# Global stop flag +stop_flag = False +data_feed = None +multi_trader_instance = None # Will hold MultiTrader for graceful shutdown +keyboard_listener = None # Will hold KeyboardListener for cleanup + +# Global redeem positions cache for Telegram /r command +redeem_positions_cache = [] +redeem_cache_lock = threading.Lock() + + +def signal_handler(sig, frame): + """Handle Ctrl+C gracefully""" + global stop_flag, data_feed, multi_trader_instance, keyboard_listener + print("\n[SYSTEM] Shutdown signal received, stopping...") + stop_flag = True + + # Stop keyboard listener first + if keyboard_listener: + print("[KEYBOARD] Stopping listener...") + keyboard_listener.stop() + + # Stop data feed + if data_feed: + print("[DATA] Stopping feeds...") + data_feed.stop() + print("[DATA] Feeds stopped") + + # Save all active positions before exit + if multi_trader_instance: + print("[SHUTDOWN] Saving active positions...") + saved_count = 0 + for strategy_name, trader in multi_trader_instance.traders.items(): + if trader.positions: + print(f"[{strategy_name}] Has {len(trader.positions)} active position(s)") + for market_slug, pos in list(trader.positions.items()): + try: + # Force-save position as emergency exit + # We don't know the final price, so save current state + trade = { + 'market_slug': market_slug, + 'strategy': strategy_name, + 'up_contracts': pos['UP']['contracts'], + 'down_contracts': pos['DOWN']['contracts'], + 'up_invested': pos['UP']['invested'], + 'down_invested': pos['DOWN']['invested'], + 'total_invested': pos['UP']['invested'] + pos['DOWN']['invested'], + 'pnl': 0.0, # Unknown - will calculate on next run + 'winner': 'UNKNOWN', + 'closed_at': int(time.time()), + 'btc_start': pos.get('btc_start', 0), + 'btc_final': 0, # Unknown + 'entries_count': pos.get('entries_count', 0), + 'status': 'EMERGENCY_SAVE' # Mark as emergency + } + trader._log_trade(trade) + saved_count += 1 + print(f" ✓ Saved {market_slug}") + except Exception as e: + print(f" ✗ Failed to save {market_slug}: {e}") + print(f"[SHUTDOWN] Saved {saved_count} position(s)") + + print("[SYSTEM] Shutdown complete") + sys.exit(0) + + +signal.signal(signal.SIGINT, signal_handler) + + +def load_config(config_path: str = None) -> dict: + """Load configuration and resolve market_window → market_interval_sec.""" + if config_path is None: + # Default to ../config/config.json relative to this file + config_path = Path(__file__).parent.parent / "config" / "config.json" + with open(config_path, "r", encoding="utf-8") as f: + cfg = json.load(f) + apply_market_window_settings(cfg) + return cfg + + +def _parse_cli_args(): + """CLI for optional web dashboard.""" + p = argparse.ArgumentParser(description="Meridian — Polymarket 15m crypto desk") + p.add_argument( + "--web", + action="store_true", + help="Serve web dashboard (Flask) in background for control + live analytics", + ) + p.add_argument("--web-port", type=int, default=5050, help="Dashboard port (default 5050)") + p.add_argument( + "--web-host", + type=str, + default="127.0.0.1", + help="Bind address (default 127.0.0.1; use 0.0.0.0 for LAN)", + ) + return p.parse_args() + + +def validate_system(): + """Validate all components before starting""" + print("[VALIDATION] Testing sizing formulas...") + # Validation passed + + print("[VALIDATION] All systems ready") + return True + + +def _get_portfolio_stats(multi_trader, markets_skipped, session_start_time): + """Helper to calculate portfolio statistics for Telegram notifications""" + stats = {} + + for coin in COINS: + strategy_name = f"{STRATEGY_BASES[0]}_{coin}" + trader = multi_trader.traders.get(strategy_name) + + if not trader: + stats[f'{coin}_pnl'] = 0 + stats[f'{coin}_wr'] = 0 + stats[f'{coin}_markets_played'] = 0 + stats[f'{coin}_markets_skipped'] = 0 + continue + + perf = trader.get_performance_stats() + + stats[f'{coin}_pnl'] = trader.current_capital - trader.starting_capital + stats[f'{coin}_wr'] = perf['win_rate'] + stats[f'{coin}_markets_played'] = perf['total_trades'] + stats[f'{coin}_markets_skipped'] = markets_skipped.get(coin, 0) + + stats['total_pnl'] = sum(stats.get(f'{coin}_pnl', 0) for coin in COINS) + stats['uptime'] = time.time() - session_start_time + + return stats + + +# ═══════════════════════════════════════════════════════════ +# GLOBAL STATE (for callbacks) +# ═══════════════════════════════════════════════════════════ +wallet_balance = 0.0 # Will be set in main() after wallet check + + +def validate_prices(up_ask: float, down_ask: float, up_timestamp: float, down_timestamp: float, + coin: str = '', threshold_sec: float = 2.0) -> tuple: + """ + Validate that prices are synchronized and fresh + + Returns: (is_valid: bool, reason: str) + """ + now = time.time() + + # Check 1: Freshness (prices updated recently) + up_age = now - up_timestamp if up_timestamp > 0 else 999 + down_age = now - down_timestamp if down_timestamp > 0 else 999 + + if up_age > threshold_sec: + return False, f"UP_STALE_{up_age:.1f}s" + if down_age > threshold_sec: + return False, f"DOWN_STALE_{down_age:.1f}s" + + # Check 2: Timestamp sync (both updated in same time window) + if abs(up_timestamp - down_timestamp) > threshold_sec: + return False, f"DESYNC_{abs(up_timestamp - down_timestamp):.1f}s" + + # Check 3: Sum validation (UP + DOWN ≈ 1.0) + # Allow wider range (0.95-1.15) to account for spread and rapid price changes + price_sum = up_ask + down_ask + if price_sum < 0.95 or price_sum > 1.15: + return False, f"INVALID_SUM_{price_sum:.3f}" + + return True, "OK" + + +def run_manual_redeem(): + """Callback for manual redeem (M key)""" + print("\n" + "="*80) + print(" MANUAL REDEEM TRIGGERED ".center(80, "=")) + print("="*80 + "\n") + + try: + # Import the redeemall module directly + import sys + sys.path.insert(0, "/root/clip") + + # Load environment from 4coins_live + from dotenv import load_dotenv + from pathlib import Path + env_path = Path("/root/4coins_live/.env") + load_dotenv(env_path, override=True) + + # Import and run redeemall with auto-confirm + import redeemall + print("[REDEEM] Starting automatic redemption...") + print("[REDEEM] Using wallet from: /root/4coins_live/.env") + print() + + redeemall.main(auto_confirm=True) + + print("\n[REDEEM] Completed!") + + except Exception as e: + print(f"\n[REDEEM] Error: {e}") + import traceback + traceback.print_exc() + + print("\n" + "="*80) + print(" Returning to trading... ".center(80)) + print("="*80 + "\n") + + # Give user 2 seconds to see the result + time.sleep(2) + + +def main(args=None): + """Main trading loop""" + global stop_flag, data_feed, wallet_balance, keyboard_listener + if args is None: + args = _parse_cli_args() + + # Track session start time for uptime + session_start_time = time.time() + + config = load_config() + + print("=" * 115) + _pm = config.get("data_sources", {}).get("polymarket", {}) + _iv = int(_pm.get("market_interval_sec", 900)) + _ml = "5m" if _iv == 300 else ("15m" if _iv == 900 else f"{_iv}s") + print(f" MERIDIAN — Polymarket crypto desk ({_ml} markets)".center(115)) + print(" BTC · ETH · SOL · XRP | Late-window entry | Hybrid stop-loss & flip-stop".center(115)) + print(" Unified wallet | Real-time books | FAK execution".center(115)) + print("=" * 115) + print() + + # Validate system + if not validate_system(): + print("[ERROR] System validation failed!") + return + + # Track skipped markets for each coin + markets_skipped = {coin: 0 for coin in COINS} + + # Track completed markets for chart generation + total_completed_markets = 0 + last_chart_at = 0 # Markets count when last chart was sent + CHART_INTERVAL = config.get('notifications', {}).get('chart_every_n_markets', 10) + print(f"[CONFIG] Loaded configuration (Meridian · late-window entry + hybrid stop-loss)") + _pm_cfg = config.get("data_sources", {}).get("polymarket", {}) + _iv_cfg = int(_pm_cfg.get("market_interval_sec", 900)) + _mw_cfg = str(_pm_cfg.get("market_window", "") or ("15m" if _iv_cfg == 900 else "5m" if _iv_cfg == 300 else "")) + print( + f" Market window: \"{_mw_cfg or _iv_cfg}\" → {_iv_cfg}s " + f"(edit data_sources.polymarket.market_window: \"5m\" or \"15m\")" + ) + print(f" Entry window (config file): {config['strategy'].get('entry_window_sec', 'default')} seconds (strategy may cap to market length)") + print(f" Entry Frequency: Every {config['strategy']['entry_frequency_sec']} seconds") + print(f" Price Max: ${config['strategy']['price_max']}") + print(f" Exit #1: Hybrid Stop-Loss (per coin):") + + # Dynamically derive from config + for coin in ['btc', 'eth', 'sol', 'xrp']: + sl_cfg = config.get('exit', {}).get('stop_loss', {}).get('per_coin', {}).get(coin, {}) + if sl_cfg.get('enabled'): + sl_type = sl_cfg.get('type', 'fixed') + sl_value = sl_cfg.get('value', 0) + if sl_type == 'fixed': + print(f" {coin.upper()}: Fixed ${sl_value}") + else: + print(f" {coin.upper()}: Percent {sl_value}%") + else: + print(f" {coin.upper()}: Disabled") + + print(f" Exit #2: Flip-Stop (price reversal protection)") + _sz = config.get("strategy", {}).get("sizing", {}) + print( + f" Sizing: {_sz.get('above_180_sec', 8)}/{_sz.get('above_120_sec', 10)}/{_sz.get('below_120_sec', 12)} " + f"contracts (tiers vs time-left; thresholds scale with market window)" + ) + print() + + # ═══════════════════════════════════════════════════════════ + # SAFETY & REAL TRADING SETUP + # ═══════════════════════════════════════════════════════════ + + # Create SafetyGuard (pass ENTIRE config, SafetyGuard will take safety section itself) + safety_guard = SafetyGuard(config) + + # Create OrderExecutor (pass config for retry parameters!) + order_executor = OrderExecutor(safety_guard, config) + + # Setup balance change callback to update global wallet_balance + def on_balance_change(amount: float, operation: str, is_absolute: bool = False): + """ + Callback for balance changes from OrderExecutor + + Args: + amount: Amount changed (positive = received, negative = spent) or absolute balance + operation: Operation type ('BUY', 'SELL', 'REDEEM', 'REDEEM_REFRESH') + is_absolute: If True, amount is the new absolute balance (not a delta) + """ + global wallet_balance + try: + if is_absolute: + # Absolute value from blockchain + old_balance = wallet_balance + wallet_balance = amount + change = amount - old_balance + change_sign = "+" if change >= 0 else "" + print(f"[BALANCE] 🔄 Updated from blockchain: ${wallet_balance:,.2f} ({change_sign}${change:.2f})") + else: + # Delta change + wallet_balance += amount + sign = "+" if amount >= 0 else "" + print(f"[BALANCE] 💰 {operation}: {sign}${amount:.2f} → ${wallet_balance:,.2f}") + except Exception as e: + print(f"[BALANCE] ⚠️ Callback error: {e}") + import traceback + traceback.print_exc() + + order_executor.set_balance_callback(on_balance_change) + + # Setup market closing check callback (race condition protection) + def is_market_closing(market_slug: str, coin: str) -> bool: + """ + Check: is market closing for SPECIFIC coin (stop-loss/flip-stop triggered) + + 🔥 CRITICAL: Blocks buys if market_start_prices[coin] == -2 + Prevents race condition when buy goes through AFTER trigger + + Args: + market_slug: Market identifier + coin: Coin name ('btc', 'eth', 'sol', 'xrp') + + Returns: + True - market is closing for THIS coin, block buys + False - market is open for this coin, buys are allowed + """ + # Check ONLY for specified coin (per-coin blocking!) + if coin in market_start_prices: + status = market_start_prices[coin].get(market_slug, None) + if status == -2: + return True # Market is closing for THIS coin! + return False # Market is open for this coin + + order_executor.set_market_closing_check(is_market_closing) + + # Check wallet balance (if not DRY_RUN) + if not safety_guard.dry_run: + print("\n[WALLET] Checking wallet balance...") + wallet_balance = order_executor.get_wallet_usdc_balance() + + if not wallet_balance or wallet_balance <= 0: + print("\n" + "="*80) + print("❌ ERROR: Cannot read wallet balance or balance is 0!") + print(" Check your PRIVATE_KEY in .env and ensure wallet has USDC") + print("="*80) + sys.exit(1) + + print("\n" + "="*80) + print(f"💰 Wallet balance: ${wallet_balance:.2f}") + print(f" Address: {order_executor.wallet_address}") + print("🔴 LIVE TRADING MODE - REAL MONEY") + print("="*80 + "\n") + else: + # DRY_RUN - use simulated balance + wallet_balance = 10000.0 # Simulated balance + print("\n" + "="*80) + print(f"🟢 DRY_RUN MODE: Simulated balance ${wallet_balance:.2f}") + print(" No real orders will be placed") + print("="*80 + "\n") + + # Inject executor into trader module + trader_module.set_order_executor(order_executor) + print("[SYSTEM] ✓ OrderExecutor injected into trader module") + + # 📂 Load metadata from disk (CRITICAL for redeem after restart!) + trader_module.load_market_metadata_from_disk() + print() + + # ═══════════════════════════════════════════════════════════ + + # Initialize data feed (shared across all strategies) + print("[SYSTEM] Initializing multi-market data feed...") + data_feed = DataFeed(config) + data_feed.start() + time.sleep(5) # Let data stabilize + + # Initialize 2 strategies (1 base × 2 coins) using global constants + print(f"[SYSTEM] Initializing 2 parallel strategies...") + strategies = {} + strategy_names = [] + + for base_name in STRATEGY_BASES: + for coin in COINS: + strategy_name = f"{base_name}_{coin}" + strategy_names.append(strategy_name) + strategies[strategy_name] = LateEntryStrategy(config) + print(f" ✓ {strategy_name:30s} (late-window entry | time-based sizing)") + + _sample_st = strategies.get(f"{STRATEGY_BASES[0]}_{COINS[0]}") + if _sample_st: + print(f" Effective entry window: last {_sample_st.entry_window}s | sizing tiers: >{_sample_st.sizing_t1}s / >{_sample_st.sizing_t2}s") + + # Initialize multi-trader (unified wallet - no capital distribution) + global multi_trader_instance + print("\n[SYSTEM] Initializing multi-trader...") + # Note: capital_per_strategy=0 because all strategies share one wallet balance + # Individual trader capital is only used for per-coin PnL statistics, not limits + multi_trader = MultiTrader(capital_per_strategy=0, strategy_names=strategy_names) + multi_trader_instance = multi_trader # Store for graceful shutdown + print() + + # Initialize dashboard (pass config for trading status display) + dashboard = DashboardMultiAB(width=160, coins=COINS, config=config) + + import web_dashboard_state as web_dashboard_state_mod + web_dashboard_state_mod.set_session_start(session_start_time) + if getattr(args, "web", False): + from web_dashboard.server import run_server_thread + proj_root = Path(__file__).resolve().parent.parent + run_server_thread(host=args.web_host, port=args.web_port, project_root=proj_root) + print(f"[WEB] Dashboard: http://{args.web_host}:{args.web_port}/") + print() + + # Initialize Telegram notifier with event callback + dashboard.add_event("Initializing Telegram notifier...", 'system') + from telegram_notifier import TelegramNotifier + notifier = TelegramNotifier(event_callback=lambda msg, t: dashboard.add_event(msg, t)) + + # Track market start prices for EACH coin separately + # {coin: {market_slug: price or status}} + # Values: positive float (valid price), -1 (skipped - started mid-market) + market_start_prices = {coin: {} for coin in COINS} + + # Track pending markets for EACH coin separately + # {coin: {market_slug: {...}}} + pending_markets = {coin: {} for coin in COINS} + + # Track if we witnessed a market switch for EACH coin + witnessed_market_switch = {coin: False for coin in COINS} + + # Thread-safe lock for shared state access + market_lock = threading.Lock() + + # 🛡️ ASYNC SYSTEM #2: ThreadPoolExecutor for parallel exit checks + sys2_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="sys2") + + # 🔄 ASYNC REDEEM: ThreadPoolExecutor for sequential redeems + # max_workers=1 so redeems go one by one (not in parallel) + redeem_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="redeem") + + # ═══════════════════════════════════════════════════════════════ + # TELEGRAM COMMAND HANDLER - Thread-safe chart generation on demand + # ═══════════════════════════════════════════════════════════════ + def handle_chart_command(): + """ + Generate and send PnL chart on demand when user sends /chart or /pnl + THREAD-SAFE: Uses market_lock to safely read multi_trader data + FAULT-TOLERANT: Full error handling, never crashes main loop + """ + try: + print("\n[TELEGRAM CMD] 📊 Generating PnL chart on demand...") + + # Generate chart path (unique name to avoid conflicts) + import uuid + chart_path = f"/root/4coins_live/logs/pnl_chart_on_demand_{uuid.uuid4().hex[:8]}.png" + + print(f"[TELEGRAM CMD] 📊 Chart request received") + print(f"[TELEGRAM CMD] Chart path: {chart_path}") + print(f"[TELEGRAM CMD] COINS list: {COINS}") + print(f"[TELEGRAM CMD] Log dir: /root/4coins_live/logs") + + # Import chart generator + from pnl_chart_generator import generate_pnl_chart + + # Generate chart (reads JSONL files - safe concurrent read) + # NOTE: We don't check total_completed_markets because it resets after restart + # Instead, generate_pnl_chart will check actual files and return False if no data + print(f"[TELEGRAM CMD] Calling generate_pnl_chart()...") + result = generate_pnl_chart('/root/4coins_live/logs', COINS, chart_path) + print(f"[TELEGRAM CMD] generate_pnl_chart() returned: {result}") + + if not result: + print("[TELEGRAM CMD] ⚠️ No trade data found in files") + notifier.send_message("⚠️ No completed markets yet. Chart will be available after first market closes.") + return + + # THREAD-SAFE: Lock access to shared data for stats reading + with market_lock: + + # Get current portfolio stats (safe read under lock) + try: + portfolio_stats = _get_portfolio_stats(multi_trader, markets_skipped, session_start_time) + except Exception as e: + print(f"[TELEGRAM CMD] ⚠️ Stats error: {e}") + portfolio_stats = {'total_pnl': 0, 'uptime': '?'} + + # Count actual completed markets from files (not from memory variable) + # This works correctly after bot restart + actual_markets_count = 0 + for coin in COINS: + trades_file = Path(f"/root/4coins_live/logs/late_v3_{coin}/trades.jsonl") + if trades_file.exists(): + try: + with open(trades_file, 'r') as f: + actual_markets_count += sum(1 for _ in f) + except: + pass + + # Create caption + total_pnl = portfolio_stats.get('total_pnl', 0) + uptime = portfolio_stats.get('uptime', '?') + + # Format PnL by coin + coin_stats = [] + for coin in COINS: + coin_pnl = portfolio_stats.get(f'{coin}_pnl', 0) + emoji = "🟢" if coin_pnl >= 0 else "🔴" + coin_stats.append(f"{coin.upper()}: {emoji} ${coin_pnl:+.0f}") + + caption = f"""📊 Current PnL Chart + +💰 Total: ${total_pnl:+.2f} +📈 Markets: {actual_markets_count} +⏱ Session: {uptime} + +By Coin: +{' | '.join(coin_stats)}""" + + # Send photo (outside lock - network I/O can be slow) + if notifier.send_photo(chart_path, caption): + print(f"[TELEGRAM CMD] ✓ Chart sent successfully") + else: + print(f"[TELEGRAM CMD] ✗ Failed to send chart to Telegram") + notifier.send_message("❌ Chart generated but failed to send. Please try again.") + + # Cleanup temp file + try: + import os + os.remove(chart_path) + except: + pass + + except Exception as e: + error_msg = str(e)[:200] + print(f"[TELEGRAM CMD] ✗ Fatal error: {error_msg}") + try: + notifier.send_message(f"❌ Error generating chart:\n{error_msg}") + except: + pass # Don't crash if notification fails + + def get_pol_price_usd() -> float: + """ + Get current POL price in USD via CoinGecko API + + Returns: + POL price in USD or 0.45 (fallback) if API unavailable + """ + try: + url = "https://api.coingecko.com/api/v3/simple/price" + params = { + 'ids': 'polygon-ecosystem-token', + 'vs_currencies': 'usd' + } + response = requests.get(url, params=params, timeout=5) + + if response.status_code == 200: + data = response.json() + price = data.get('polygon-ecosystem-token', {}).get('usd') + if price: + print(f"[PRICE API] POL price: ${price:.4f}") + return float(price) + + # Fallback if API didn't return price + print(f"[PRICE API] ⚠️ Failed to get POL price, using fallback: $0.45") + return 0.45 + + except Exception as e: + print(f"[PRICE API] ⚠️ Error getting POL price: {e}, using fallback: $0.45") + return 0.45 + + def get_active_positions(): + """ + Get active positions via Polymarket Data API + THREAD-SAFE: Only readonly API requests, doesn't use shared state + + Returns: + List of positions or None on error + """ + try: + # Get wallet address from order_executor + wallet = order_executor.wallet_address + if not wallet: + print("[POSITIONS API] ⚠️ No wallet address") + return None + + url = "https://data-api.polymarket.com/positions" + params = { + 'user': wallet, + 'sizeThreshold': 0.1, # Minimum 0.1 contracts + 'limit': 50, + 'sortBy': 'CURRENT', + 'sortDirection': 'DESC' + } + + print(f"[POSITIONS API] Fetching positions for {wallet[:6]}...{wallet[-4:]}") + response = requests.get(url, params=params, timeout=10) + + if response.status_code == 200: + positions = response.json() + print(f"[POSITIONS API] ✅ Got {len(positions)} positions") + return positions + else: + print(f"[POSITIONS API] ⚠️ Failed: HTTP {response.status_code}") + return None + + except Exception as e: + print(f"[POSITIONS API] ⚠️ Error: {e}") + return None + + def handle_balance_command(): + """ + Show wallet balance when user sends /balance + THREAD-SAFE: Safe concurrent access + """ + try: + print("\n[TELEGRAM CMD] 💰 Getting wallet balance...") + + # Get balances + usdc_balance = order_executor.get_wallet_usdc_balance() + pol_balance = order_executor.get_pol_balance() + + if usdc_balance is None: + notifier.send_message("❌ Failed to get USDC balance") + return + + # Get current POL price via CoinGecko API + pol_price_usd = get_pol_price_usd() + pol_value_usd = (pol_balance or 0) * pol_price_usd + + total_usd = usdc_balance + pol_value_usd + + # Format message + message = f"""💰 WALLET BALANCE +━━━━━━━━━━━━━━━ + +USDC: ${usdc_balance:,.2f} +POL: {pol_balance or 0:.4f} (~${pol_value_usd:.2f}) + +━━━━━━━━━━━━━━━ +TOTAL: ${total_usd:,.2f} + +Wallet: {order_executor.wallet_address[:6]}...{order_executor.wallet_address[-4:]}""" + + notifier.send_message(message) + print(f"[TELEGRAM CMD] ✅ Balance sent: ${total_usd:.2f}") + + except Exception as e: + error_msg = str(e)[:200] + print(f"[TELEGRAM CMD] ✗ Balance error: {error_msg}") + try: + notifier.send_message(f"❌ Error getting balance:\n{error_msg}") + except: + pass # Don't crash if notification fails + + def handle_positions_command(): + """ + Show active positions when user sends /t or /positions + THREAD-SAFE: Only readonly API calls, no shared state access + """ + try: + print("\n[TELEGRAM CMD] 📊 Getting active positions...") + + # Get positions via API (thread-safe - only API request) + positions = get_active_positions() + + if positions is None: + notifier.send_message("❌ Failed to get positions from API") + return + + if not positions: + notifier.send_message("📊 No active positions\n\nAll markets closed or redeemed! 🎉") + return + + # Calculate total metrics + total_value = sum(p.get('currentValue', 0) for p in positions) + total_pnl = sum(p.get('cashPnl', 0) for p in positions) + redeemable_value = sum(p.get('currentValue', 0) for p in positions if p.get('redeemable')) + redeemable_count = sum(1 for p in positions if p.get('redeemable')) + + # Format message + message = f"📊 ACTIVE POSITIONS ({len(positions)})\n" + message += "━━━━━━━━━━━━━━━\n\n" + + # Show up to 10 positions + for i, p in enumerate(positions[:10]): + title = p.get('title', 'Unknown') + # Truncate long names + if len(title) > 45: + title = title[:42] + "..." + + outcome = p.get('outcome', '?') + size = p.get('size', 0) + avg_price = p.get('avgPrice', 0) + cur_price = p.get('curPrice', 0) + initial = p.get('initialValue', 0) + current = p.get('currentValue', 0) + pnl = p.get('cashPnl', 0) + pnl_pct = p.get('percentPnl', 0) + redeemable = p.get('redeemable', False) + + # Emoji by status + if redeemable: + emoji = "💰" + status = " [REDEEM!]" + elif pnl >= 0: + emoji = "🟢" + status = "" + else: + emoji = "🔴" + status = "" + + message += f"{outcome}: {title}\n" + message += f"├ Size: {size:.1f} contracts\n" + message += f"├ Entry: ${avg_price:.3f} → Now: ${cur_price:.3f}\n" + message += f"├ Value: ${initial:.2f} → ${current:.2f}\n" + message += f"└ PnL: ${pnl:+.2f} ({pnl_pct:+.1f}%) {emoji}{status}\n\n" + + # If more than 10 positions + if len(positions) > 10: + hidden_value = sum(p.get('currentValue', 0) for p in positions[10:]) + hidden_pnl = sum(p.get('cashPnl', 0) for p in positions[10:]) + message += f"...and {len(positions) - 10} more positions" + message += f" (${hidden_value:.2f}, PnL: ${hidden_pnl:+.2f})\n\n" + + # Final statistics + message += "━━━━━━━━━━━━━━━\n" + message += f"Total Value: ${total_value:.2f}\n" + message += f"Total PnL: ${total_pnl:+.2f}" + + if total_value > 0: + total_pnl_pct = (total_pnl / (total_value - total_pnl)) * 100 + message += f" ({total_pnl_pct:+.1f}%)" + + if redeemable_count > 0: + message += f"\n💰 Redeemable: ${redeemable_value:.2f} ({redeemable_count} markets)" + + notifier.send_message(message) + print(f"[TELEGRAM CMD] ✅ Positions sent: {len(positions)} items, ${total_value:.2f}") + + except Exception as e: + error_msg = str(e)[:200] + print(f"[TELEGRAM CMD] ✗ Positions error: {error_msg}") + import traceback + traceback.print_exc() + try: + notifier.send_message(f"❌ Error getting positions:\n{error_msg}") + except: + pass # Don't crash if notification fails + + def handle_redeem_command(): + """ + Show redeemable positions with interactive buttons + THREAD-SAFE: Uses API calls and redeem_collector methods + """ + global redeem_positions_cache + + try: + print("\n[TELEGRAM CMD] 💰 Getting redeemable positions...") + + # Use existing method from SimpleRedeemCollector + positions = redeem_collector._fetch_redeemable_positions() + + if positions is None: + notifier.send_message("❌ Failed to fetch redeemable positions from API") + return + + if not positions: + notifier.send_message("✅ No positions to redeem!\n\nAll markets are already redeemed or still open.") + return + + # Save to cache for callback handlers (thread-safe) + with redeem_cache_lock: + redeem_positions_cache = positions + + # Calculate total value + total_value = sum(p.get('currentValue', 0) for p in positions) + + # Format message + message = f"💰 REDEEMABLE POSITIONS ({len(positions)})\n" + message += "━━━━━━━━━━━━━━━\n\n" + + for i, p in enumerate(positions[:10]): # Max 10 positions in list + title = p.get('title', 'Unknown') + if len(title) > 40: + title = title[:37] + "..." + + outcome = p.get('outcome', '?') + size = p.get('size', 0) + value = p.get('currentValue', 0) + + message += f"#{i+1} [{outcome}] {title}\n" + message += f" └ {size:.1f} contracts = ${value:.2f}\n\n" + + if len(positions) > 10: + hidden_value = sum(p.get('currentValue', 0) for p in positions[10:]) + message += f"...and {len(positions) - 10} more (${hidden_value:.2f})\n\n" + + message += "━━━━━━━━━━━━━━━\n" + message += f"Total Value: ${total_value:.2f}\n\n" + message += "Choose action:" + + # Create buttons + buttons = [ + [ + {"text": "💰 Redeem All", "callback_data": "redeem_all"}, + {"text": "❌ Cancel", "callback_data": "redeem_cancel"} + ] + ] + + # Add button for each position (up to 10 items) + for i in range(min(len(positions), 10)): + buttons.append([ + {"text": f"💰 Redeem #{i+1}", "callback_data": f"redeem_pos_{i}"} + ]) + + # Send message with buttons + notifier.send_message_with_buttons(message, buttons) + print(f"[TELEGRAM CMD] ✅ Redeem menu sent: {len(positions)} positions, ${total_value:.2f}") + + except Exception as e: + error_msg = str(e)[:200] + print(f"[TELEGRAM CMD] ✗ Redeem error: {error_msg}") + import traceback + traceback.print_exc() + try: + notifier.send_message(f"❌ Error getting redeemable positions:\n{error_msg}") + except: + pass + + def handle_redeem_all_callback(callback_id: str, message_id: int): + """Handle 'Redeem All' button click""" + global redeem_positions_cache + + try: + # Get positions from cache (thread-safe) + with redeem_cache_lock: + positions = redeem_positions_cache.copy() + + if not positions: + notifier.answer_callback_query(callback_id, "❌ No positions in cache", show_alert=True) + return + + notifier.answer_callback_query(callback_id, "🚀 Starting redeem process...") + + total = len(positions) + + # Update message + notifier.edit_message_text( + message_id, + f"🚀 REDEEMING {total} POSITIONS...\n\nPlease wait, this may take a few minutes..." + ) + + # Redeem process with pauses + success_count = 0 + fail_count = 0 + total_redeemed = 0.0 + + for i, pos in enumerate(positions): + # Use existing method from SimpleRedeemCollector + result = redeem_collector._redeem_one(i + 1, total, pos) + + if result: + success_count += 1 + total_redeemed += pos.get('currentValue', 0) + else: + fail_count += 1 + + # Pause between redeems (as in automatic collector) + if i < total - 1: + pause = redeem_collector.pause_between + print(f"[REDEEM] Pause {pause}s before next redeem...") + time.sleep(pause) + + # Final report + message = f"✅ REDEEM COMPLETED!\n" + message += "━━━━━━━━━━━━━━━\n\n" + message += f"Total positions: {total}\n" + message += f"Redeemed: {success_count} ✅\n" + message += f"Failed: {fail_count} ❌\n" + message += f"Total value: ${total_redeemed:.2f}\n" + + notifier.edit_message_text(message_id, message) + print(f"[TELEGRAM CMD] ✅ Redeem all completed: {success_count}/{total}") + + except Exception as e: + error_msg = str(e)[:200] + print(f"[TELEGRAM CMD] ✗ Redeem all error: {error_msg}") + import traceback + traceback.print_exc() + try: + notifier.edit_message_text(message_id, f"❌ Redeem failed:\n{error_msg}") + except: + pass + + def handle_redeem_position_callback(callback_id: str, message_id: int, index: int): + """Handle 'Redeem #N' button click""" + global redeem_positions_cache + + try: + # Get positions from cache (thread-safe) + with redeem_cache_lock: + positions = redeem_positions_cache.copy() + + if index >= len(positions): + notifier.answer_callback_query(callback_id, "❌ Position not found", show_alert=True) + return + + pos = positions[index] + title = pos.get('title', 'Unknown')[:40] + + notifier.answer_callback_query(callback_id, f"🚀 Redeeming position #{index+1}...") + + # Update message + notifier.edit_message_text( + message_id, + f"🚀 REDEEMING POSITION #{index+1}...\n\n{title}\n\nPlease wait..." + ) + + # Redeem one position + result = redeem_collector._redeem_one(1, 1, pos) + + if result: + value = pos.get('currentValue', 0) + message = f"✅ REDEEM SUCCESS!\n\n" + message += f"Position #{index+1} redeemed\n" + message += f"Value: ${value:.2f}" + else: + message = f"❌ REDEEM FAILED!\n\n" + message += f"Position #{index+1} failed to redeem\n" + message += f"Check logs for details." + + notifier.edit_message_text(message_id, message) + print(f"[TELEGRAM CMD] ✅ Redeem position #{index+1}: {'success' if result else 'failed'}") + + except Exception as e: + error_msg = str(e)[:200] + print(f"[TELEGRAM CMD] ✗ Redeem position error: {error_msg}") + import traceback + traceback.print_exc() + try: + notifier.edit_message_text(message_id, f"❌ Redeem failed:\n{error_msg}") + except: + pass + + def handle_redeem_cancel_callback(callback_id: str, message_id: int): + """Handle 'Cancel' button click""" + try: + notifier.answer_callback_query(callback_id, "Cancelled") + notifier.edit_message_text(message_id, "❌ Redeem cancelled") + print(f"[TELEGRAM CMD] ℹ️ Redeem cancelled by user") + except Exception as e: + print(f"[TELEGRAM CMD] ✗ Cancel error: {e}") + + def handle_shutdown_command(): + """ + Emergency shutdown: find and stop main.py process + THREAD-SAFE: Uses OS signals, doesn't access shared state + + ⚠️ CRITICAL: This will stop the trading bot! + """ + try: + print("\n[TELEGRAM CMD] 🛑 EMERGENCY SHUTDOWN requested!") + + # Find process main.py + try: + result = subprocess.run( + ['pgrep', '-f', 'python3.*src/main.py'], + capture_output=True, + text=True, + timeout=5 + ) + + if result.returncode == 0: + pid = result.stdout.strip() + + if not pid: + notifier.send_message("❌ Process not found!\n\nThe bot is not running.") + return + + # Send confirmation with buttons + message = f"⚠️ EMERGENCY SHUTDOWN\n\n" + message += f"Process found: PID {pid}\n" + message += f"Command: python3 src/main.py\n\n" + message += f"This will gracefully stop the bot and save all positions.\n\n" + message += f"Are you sure?" + + buttons = [ + [ + {"text": "🛑 STOP BOT", "callback_data": f"shutdown_confirm_{pid}"}, + {"text": "❌ Cancel", "callback_data": "shutdown_cancel"} + ] + ] + + notifier.send_message_with_buttons(message, buttons) + print(f"[TELEGRAM CMD] ℹ️ Shutdown confirmation sent for PID {pid}") + + else: + notifier.send_message("❌ Process not found!\n\nThe bot is not running.") + + except subprocess.TimeoutExpired: + notifier.send_message("❌ Timeout!\n\nFailed to find process.") + except Exception as e: + error_msg = str(e)[:200] + notifier.send_message(f"❌ Error finding process:\n{error_msg}") + + except Exception as e: + error_msg = str(e)[:200] + print(f"[TELEGRAM CMD] ✗ Shutdown error: {error_msg}") + import traceback + traceback.print_exc() + try: + notifier.send_message(f"❌ Shutdown failed:\n{error_msg}") + except: + pass + + def handle_shutdown_confirm_callback(callback_id: str, message_id: int, pid: str): + """Handle 'STOP BOT' confirmation button click""" + try: + notifier.answer_callback_query(callback_id, "🛑 Stopping bot...", show_alert=True) + + # Update message + notifier.edit_message_text( + message_id, + f"🛑 STOPPING BOT...\n\nPID: {pid}\n\nSending SIGINT signal..." + ) + + # Send SIGINT (like Ctrl+C) + try: + os.kill(int(pid), signal.SIGINT) + + # Wait a bit + time.sleep(2) + + # Check that process is stopped + result = subprocess.run( + ['ps', '-p', pid], + capture_output=True, + text=True, + timeout=5 + ) + + if result.returncode == 0: + # Process still running (graceful shutdown in progress) + message = f"✅ SHUTDOWN SIGNAL SENT!\n\n" + message += f"PID: {pid}\n\n" + message += f"Bot is shutting down gracefully...\n" + message += f"Check logs for details." + else: + # Process stopped + message = f"✅ BOT STOPPED!\n\n" + message += f"PID: {pid}\n\n" + message += f"All positions saved." + + notifier.edit_message_text(message_id, message) + print(f"[TELEGRAM CMD] ✅ Shutdown signal sent to PID {pid}") + + except ProcessLookupError: + # Process no longer exists + notifier.edit_message_text( + message_id, + f"ℹ️ BOT ALREADY STOPPED\n\nPID {pid} no longer exists." + ) + except PermissionError: + notifier.edit_message_text( + message_id, + f"❌ PERMISSION DENIED\n\nCannot stop PID {pid}.\nRun bot as same user." + ) + + except Exception as e: + error_msg = str(e)[:200] + print(f"[TELEGRAM CMD] ✗ Shutdown confirm error: {error_msg}") + import traceback + traceback.print_exc() + try: + notifier.edit_message_text(message_id, f"❌ Shutdown failed:\n{error_msg}") + except: + pass + + def handle_shutdown_cancel_callback(callback_id: str, message_id: int): + """Handle 'Cancel' button click""" + try: + notifier.answer_callback_query(callback_id, "Cancelled") + notifier.edit_message_text(message_id, "✅ Shutdown cancelled\n\nBot continues running.") + print(f"[TELEGRAM CMD] ℹ️ Shutdown cancelled by user") + except Exception as e: + print(f"[TELEGRAM CMD] ✗ Cancel error: {e}") + + # Create dict with redeem callback handlers + redeem_callbacks = { + 'redeem_all': handle_redeem_all_callback, + 'redeem_position': handle_redeem_position_callback, + 'redeem_cancel': handle_redeem_cancel_callback + } + + # Create dict with shutdown callback handlers + shutdown_callbacks = { + 'shutdown_confirm': handle_shutdown_confirm_callback, + 'shutdown_cancel': handle_shutdown_cancel_callback + } + + # Start Telegram command listener (daemon thread, won't block shutdown) + dashboard.add_event("Starting command listener...", 'telegram') + try: + notifier.start_command_listener( + on_chart_command=handle_chart_command, + on_balance_command=handle_balance_command, + on_positions_command=handle_positions_command, + on_redeem_command=handle_redeem_command, + on_redeem_callbacks=redeem_callbacks, + on_shutdown_command=handle_shutdown_command, + on_shutdown_callbacks=shutdown_callbacks + ) + dashboard.add_event("Command listener active (/chart, /b, /t, /r, /off)", 'success') + except Exception as e: + dashboard.add_event(f"Listener failed: {str(e)[:40]}", 'error') + dashboard.add_event("Bot continues without commands", 'info') + + # ═══════════════════════════════════════════════════════════════ + # 🔥 SIMPLE REDEEM COLLECTOR - Periodic API-based redeem system + # Replaces complex pending_markets logic + # ═══════════════════════════════════════════════════════════════ + from simple_redeem_collector import SimpleRedeemCollector + + # Get wallet address from order_executor + wallet_address = order_executor.wallet_address + + if wallet_address: + print(f"\n[SYSTEM] Initializing Simple Redeem Collector...") + print(f"[SYSTEM] Wallet: {wallet_address[:10]}...{wallet_address[-8:]}") + + redeem_collector = SimpleRedeemCollector( + wallet_address=wallet_address, + config=config, + order_executor=order_executor, + trader_module=trader_module, + multi_trader=multi_trader, # 🔥 FIX: For creating trade records + notifier=notifier # 🔥 FIX: For Telegram notifications + ) + + # Start in background thread (daemon - doesn't block shutdown) + redeem_collector.start() + print(f"[SYSTEM] ✅ Simple Redeem Collector started") + dashboard.add_event("Redeem collector active", 'success') + else: + print(f"\n[SYSTEM] ⚠️ WARNING: No wallet address, redeem collector disabled") + print(f"[SYSTEM] Check that POLYMARKET_PRIVATE_KEY is set in .env") + redeem_collector = None + dashboard.add_event("Redeem collector disabled (no wallet)", 'warning') + + # ═══════════════════════════════════════════════════════════════ + # LEGACY: Old async redeem processor (will be removed) + # ═══════════════════════════════════════════════════════════════ + def process_redeem_async(coin, prev_market, pending_info, config, markets_skipped, + session_start_time): + """Process redeem asynchronously without blocking main loop""" + # 🔍 CRITICAL: Log function start (confirms that submit() worked!) + print(f"\n[REDEEM ASYNC] 🚀 Started for {coin.upper()} market {prev_market}") + + try: + redeem_cfg = config.get("execution", {}).get("redeem", {}) + max_attempts = redeem_cfg.get("max_attempts", 3) + retry_delay = redeem_cfg.get("retry_delay_sec", 300) + now = time.time() + + elapsed = (now - pending_info['first_attempt']) / 60 + print(f"[{coin.upper()} REDEEM] Attempt {pending_info['attempts']}/{max_attempts} for {prev_market} (after {elapsed:.1f} min)") + + # Try to redeem + metadata = trader_module.get_market_metadata(prev_market) + redeem_success = False + + # 🔍 DETAILED metadata DIAGNOSTICS + print(f"[REDEEM] Checking metadata for {prev_market}...") + print(f"[REDEEM] - Metadata exists: {metadata is not None}") + if metadata: + print(f"[REDEEM] - Has condition_id: {'condition_id' in metadata}") + if 'condition_id' in metadata: + print(f"[REDEEM] - Condition ID: {metadata['condition_id'][:20]}...") + + if metadata and metadata.get('condition_id'): + token_ids = trader_module.get_token_ids(prev_market) + print(f"[REDEEM] - Token IDs exist: {token_ids is not None}") + if token_ids: + print(f"[REDEEM] - Has UP token: {'UP' in token_ids}") + print(f"[REDEEM] - Has DOWN token: {'DOWN' in token_ids}") + + if token_ids and token_ids.get('UP') and token_ids.get('DOWN'): + print(f"[REDEEM] ✅ All metadata OK, calling redeem_position()...") + success, amount = order_executor.redeem_position( + market_slug=prev_market, + condition_id=metadata['condition_id'], + up_token_id=token_ids['UP'], + down_token_id=token_ids['DOWN'], + neg_risk=metadata.get('neg_risk', True) + ) + + if success: + redeem_success = True + print(f"[REDEEM] ✅ Redeemed ${amount:.2f} USDC!") + + # ═══════════════════════════════════════════════════════════ + # 🔥 CRITICAL: Reset investment tracking for this market! + # Now we can trade new market without limits! + # ═══════════════════════════════════════════════════════════ + try: + # Get safety_guard from order_executor + if hasattr(trader_module, 'order_executor') and trader_module.order_executor: + trader_module.order_executor.safety.reset_market(prev_market) + except Exception as reset_err: + print(f"[REDEEM] ⚠ Failed to reset market tracking: {reset_err}") + else: + print(f"[REDEEM] ⚠ Failed (oracle not resolved or no tokens)") + else: + print(f"[REDEEM] ❌ CRITICAL: No token IDs cached for {prev_market}") + print(f"[REDEEM] This market cannot be redeemed without token IDs!") + print(f"[REDEEM] Possible causes:") + print(f"[REDEEM] 1. Market was opened before restart") + print(f"[REDEEM] 2. EMERGENCY_SAVE position (no metadata saved)") + print(f"[REDEEM] 3. Metadata file corrupted or missing") + else: + print(f"[REDEEM] ❌ CRITICAL: No metadata cached for {prev_market}") + print(f"[REDEEM] Missing condition_id - redeem IMPOSSIBLE!") + print(f"[REDEEM] Metadata: {metadata}") + print(f"[REDEEM] Possible causes:") + print(f"[REDEEM] 1. Market was opened before restart") + print(f"[REDEEM] 2. Metadata not saved to disk (check logs/market_metadata.json)") + print(f"[REDEEM] 3. Bug in set_token_ids() or save_market_metadata_to_disk()") + + # If redeem successful, close positions + if redeem_success: + api_result = get_market_outcome(prev_market) + + if api_result.get("winner"): + winner = api_result["winner"] + price_start = pending_info['price_start'] + price_final = pending_info['price_final'] + + # Close for all strategies + for base_name in STRATEGY_BASES: + strategy_name = f"{base_name}_{coin}" + try: + result = multi_trader.close_market( + strategy_name=strategy_name, + market_slug=prev_market, + winner=winner, + btc_start=price_start, + btc_final=price_final + ) + if result: + # Send Telegram notification + session_stats = multi_trader.get_session_stats(strategy_name, markets_skipped[coin]) + portfolio_stats = _get_portfolio_stats(multi_trader, markets_skipped, session_start_time) + notifier.send_market_closed(coin, result, session_stats, portfolio_stats) + + # Chart generation (if needed) + nonlocal total_completed_markets, last_chart_at + total_completed_markets += 1 + + if total_completed_markets - last_chart_at >= CHART_INTERVAL: + print(f"[CHART] {total_completed_markets} markets completed, generating PnL chart...") + chart_path = f"/root/4coins_live/logs/pnl_chart_{total_completed_markets}.png" + from pnl_chart_generator import generate_pnl_chart + if generate_pnl_chart('/root/4coins_live/logs', COINS, chart_path): + caption = f"📊 PnL Chart - {total_completed_markets} Markets Completed" + if notifier.send_photo(chart_path, caption): + print(f"[CHART] ✓ Sent to Telegram successfully") + last_chart_at = total_completed_markets + else: + print(f"[CHART] ✗ Failed to send to Telegram") + else: + print(f"[CHART] ✗ Failed to generate chart") + + pnl_sign = "+" if result['pnl'] >= 0 else "" + print(f"[{strategy_name:30s}] Closed {prev_market}: {pnl_sign}${result['pnl']:,.2f}") + elif redeem_amount > 0: + # ═══════════════════════════════════════════════════════════ + # 🔥 FIX: If close_market() returned None (position empty after restart) + # but redeem was successful, create minimal trade record from orders + # This ensures ALL natural closes appear in dashboard! + # ═══════════════════════════════════════════════════════════ + print(f"[{strategy_name}] Position was empty but redeem successful (${redeem_amount:.2f})") + print(f"[{strategy_name}] Creating trade record from orders for dashboard...") + + try: + # Get trader + trader = multi_trader.traders.get(strategy_name) + if trader: + # Reconstruct minimal trade from orders.jsonl + import json + total_cost = 0 + total_contracts = 0 + + try: + with open(f'logs/orders.jsonl', 'r') as f: + for line in f: + try: + order = json.loads(line) + if (order.get('market_slug') == prev_market and + order.get('order_type') == 'BUY' and + order.get('success')): + total_cost += order.get('total_spent_usd', 0) + total_contracts += order.get('contracts', 0) + except: + continue + except Exception as e: + print(f"[{strategy_name}] Warning: Could not read orders: {e}") + + if total_cost > 0: + # Create minimal trade record + pnl = redeem_amount - total_cost + roi_pct = (pnl / total_cost * 100) if total_cost > 0 else 0 + + minimal_trade = { + 'market_slug': prev_market, + 'winner': winner, + 'exit_type': 'natural_close', + 'exit_reason': 'natural_close', + 'pnl': pnl, + 'roi_pct': roi_pct, + 'total_cost': total_cost, + 'payout': redeem_amount, + 'winner_ratio': 100.0, # Unknown + 'total_entries': 0, # Unknown + 'up_entries': 0, + 'down_entries': 0, + 'up_invested': total_cost, + 'down_invested': 0.0, + 'up_shares': total_contracts, + 'down_shares': 0.0, + 'duration': 0, + 'close_time': time.time(), + 'close_timestamp': time.strftime('%Y-%m-%d %H:%M:%S'), + 'reconstructed': True # Flag to indicate this was reconstructed + } + + # Add to closed_trades for dashboard visibility + trader.closed_trades.append(minimal_trade) + + # Log to file + try: + trader._log_trade(minimal_trade) + except Exception as e: + print(f"[{strategy_name}] Warning: Could not log trade: {e}") + + pnl_sign = "+" if pnl >= 0 else "" + print(f"[{strategy_name:30s}] Reconstructed {prev_market}: {pnl_sign}${pnl:,.2f} (from redeem)") + else: + print(f"[{strategy_name}] No buy orders found in logs, skipping reconstruction") + except Exception as e: + print(f"[{strategy_name}] Warning: Could not reconstruct trade: {e}") + except Exception as e: + print(f"[ERROR] {strategy_name} close failed: {e}") + + # Remove from pending - success! + del pending_markets[coin][prev_market] + print(f"[SUCCESS] Market {prev_market} completed and redeemed!") + print() + return True + + # Redeem failed + if pending_info['attempts'] < max_attempts: + pending_info['next_retry'] = now + retry_delay + print(f"[PENDING] Will retry in {retry_delay // 60} minutes") + return False + else: + # Failed after max attempts + print(f"[ERROR] ❌ Market {prev_market} failed after {max_attempts} attempts!") + + # Get position info + strategy_name = f"{STRATEGY_BASES[0]}_{coin}" + trader = multi_trader.get_trader(strategy_name) + position_info = "" + if trader and prev_market in trader.positions: + pos = trader.positions[prev_market] + for side in ['UP', 'DOWN']: + if pos[side]['total_shares'] > 0: + position_info += f" {side}:{pos[side]['total_shares']:.0f}@${pos[side]['total_invested']:.2f}" + + # Log failure + failed_log = Path("logs/failed_redeems.log") + failed_log.parent.mkdir(exist_ok=True) + with open(failed_log, "a") as f: + timestamp = time.strftime('%Y-%m-%d %H:%M:%S') + f.write(f"{timestamp} | {prev_market} | {position_info}\n") + + print(f"[ERROR] Logged to logs/failed_redeems.log") + + # Send alert + alert_msg = f"⚠️ FAILED REDEEM\n\nMarket: {prev_market}\nPosition: {position_info}\nAttempts: {max_attempts}\n\nCheck logs/failed_redeems.log" + order_executor._send_telegram_alert(alert_msg) + + # Remove from pending + del pending_markets[coin][prev_market] + print() + return False + + except Exception as e: + print(f"\n[REDEEM ERROR] ❌ EXCEPTION in process_redeem_async!") + print(f"[REDEEM ERROR] Coin: {coin}, Market: {prev_market}") + print(f"[REDEEM ERROR] Exception: {e}") + print(f"[REDEEM ERROR] Full traceback:") + import traceback + traceback.print_exc() + print(f"[REDEEM ERROR] This redeem task will be abandoned!") + return False + + # ═══════════════════════════════════════════════════════════════ + # EVENT-DRIVEN CALLBACK - Called INSTANTLY on price changes + # ═══════════════════════════════════════════════════════════════ + def on_price_update(coin: str, market_state: Dict): + """ + Called IMMEDIATELY when price changes from Polymarket WebSocket + Handles both EXIT checks and ENTRY signals in real-time + Thread-safe with comprehensive error handling + """ + try: + # ═══════════════════════════════════════════════════════ + # VALIDATION: Check inputs + # ═══════════════════════════════════════════════════════ + if not market_state or not coin: + return + + market_slug = market_state.get('market_slug') + if not market_slug: + return + + # Get prices with safe defaults + up_ask = market_state.get('up_ask', 0.5) + down_ask = market_state.get('down_ask', 0.5) + up_bid = market_state.get('up_bid', up_ask * 0.95) # BID for selling (fallback: 95% of ASK) + down_bid = market_state.get('down_bid', down_ask * 0.95) # BID for selling (fallback: 95% of ASK) + + # Validate prices + if up_ask <= 0 or down_ask <= 0 or up_ask > 1 or down_ask > 1: + return + + # ═══════════════════════════════════════════════════════ + # THREAD-SAFE: Check market status + # ═══════════════════════════════════════════════════════ + with market_lock: + if coin not in market_start_prices: + return + if market_slug not in market_start_prices[coin]: + return + + status = market_start_prices[coin].get(market_slug, -999) + if status in [-1, -2, -999]: + return # Market inactive, closed, or unknown + + # ═══════════════════════════════════════════════════════ + # PROCESS: All strategies for this coin + # ═══════════════════════════════════════════════════════ + for base_name in STRATEGY_BASES: + strategy_name = f"{base_name}_{coin}" + + # Validate strategy exists + if strategy_name not in strategies: + continue + + try: + # Get current position stats (thread-safe via multi_trader locks) + position_stats = multi_trader.get_market_stats(strategy_name, market_slug, up_ask, down_ask) + + # ═══════════════════════════════════════════════════════ + # PART 1: EXIT CHECKS (if we have a position) + # ═══════════════════════════════════════════════════════ + if position_stats and position_stats.get('total_invested', 0) > 0: + # ───────────────────────────────────────────────── + # CRITICAL: Validate price freshness and synchronization + # Prevents false stop-loss triggers from stale/desync prices + # ───────────────────────────────────────────────── + up_ask_ts = market_state.get('up_ask_timestamp', 0) + down_ask_ts = market_state.get('down_ask_timestamp', 0) + + is_valid, reason = validate_prices(up_ask, down_ask, up_ask_ts, down_ask_ts, coin) + + if not is_valid: + # Prices invalid - skip ALL exit checks + print(f"[PRICE] ⚠️ {coin.upper()} prices invalid: {reason}, skipping exit checks") + continue + + # Determine our side (by contract count) + up_shares = position_stats.get('up_shares', 0) + down_shares = position_stats.get('down_shares', 0) + + our_side = None + our_price = None + + if up_shares > down_shares and up_shares > 0: + our_side = 'UP' + our_price = up_ask + elif down_shares > 0: + our_side = 'DOWN' + our_price = down_ask + + if not our_side or not our_price: + continue # No clear position + + # Get unrealized PnL for stop-loss check + unrealized_pnl = position_stats.get('unrealized_pnl', 0) + total_invested = position_stats.get('total_invested', 0) + + # ───────────────────────────────────────────────── + # EXIT CHECK #1: HYBRID STOP-LOSS (per coin config) + # BTC: None | ETH: -$10 | SOL: -15% | XRP: -$10 + # Backtest: +126% profit improvement (hybrid approach) + # ───────────────────────────────────────────────── + # Get stop-loss config for this coin + sl_config = config.get('exit', {}).get('stop_loss', {}).get('per_coin', {}).get(coin, {}) + sl_enabled = sl_config.get('enabled', False) + sl_type = sl_config.get('type', 'none') + sl_value = sl_config.get('value', None) + + # Calculate threshold based on type + stop_loss_triggered = False + stop_loss_threshold = 0 + + if sl_enabled and sl_value is not None: + if sl_type == 'fixed': + # Fixed dollar amount + stop_loss_threshold = sl_value + stop_loss_triggered = unrealized_pnl <= stop_loss_threshold + elif sl_type == 'percent': + # Percentage of invested capital + stop_loss_threshold = total_invested * (sl_value / 100.0) + stop_loss_triggered = unrealized_pnl <= stop_loss_threshold + + if stop_loss_triggered: + # Double-check position still exists (race condition protection) + trader = multi_trader.get_trader(strategy_name) + if not trader or market_slug not in trader.positions: + continue # Position already closed + + # Thread-safe check: market not already closed + with market_lock: + current_status = market_start_prices[coin].get(market_slug, -999) + if current_status == -2: + continue # Already closed by another callback + + # 🔥 FIX 1: LOG EXIT TRIGGER (for all 4 coins) + from trade_logger import log_exit_trigger + log_exit_trigger( + market_slug=market_slug, + exit_reason='stop_loss', + coin=coin, + unrealized_pnl=unrealized_pnl, + threshold_pnl=stop_loss_threshold + ) + + # 🔥 FIX 2: Mark market as closed BEFORE exit to prevent race condition (thread-safe) + with market_lock: + market_start_prices[coin][market_slug] = -2 + + # 🔥 FIX 2.1: ATOMIC BLOCK (per-coin protection) + order_executor.block_market(market_slug, coin) + + # Close position with stop-loss (pass current BID prices for selling) + result = multi_trader.close_market_early_exit( + strategy_name=strategy_name, + market_slug=market_slug, + exit_price=our_price, + exit_reason='stop_loss', + up_bid=up_bid, # ✅ REAL BID for selling UP tokens + down_bid=down_bid # ✅ REAL BID for selling DOWN tokens + ) + + if result: + + # Send notifications + if isinstance(result, dict): + try: + session_stats = multi_trader.get_session_stats(strategy_name, markets_skipped[coin]) + portfolio_stats = _get_portfolio_stats(multi_trader, markets_skipped, session_start_time) + notifier.send_market_closed(coin, result, session_stats, portfolio_stats) + + # Increment completed markets counter + total_completed_markets += 1 + + # Generate and send PnL chart every CHART_INTERVAL markets + if total_completed_markets - last_chart_at >= CHART_INTERVAL: + print(f"[CHART] {total_completed_markets} markets completed, generating PnL chart...") + + chart_path = f"/root/4coins_live/logs/pnl_chart_{total_completed_markets}.png" + + # Import chart generator + from pnl_chart_generator import generate_pnl_chart + + if generate_pnl_chart('/root/4coins_live/logs', COINS, chart_path): + # Send to Telegram + caption = f"📊 PnL Chart - {total_completed_markets} Markets Completed" + if notifier.send_photo(chart_path, caption): + print(f"[CHART] ✓ Sent to Telegram successfully") + last_chart_at = total_completed_markets + else: + print(f"[CHART] ✗ Failed to send to Telegram") + else: + print(f"[CHART] ✗ Failed to generate chart") + except Exception as e: + print(f"[ERROR] Notification failed: {e}") + + # Print confirmation + print(f"\n{'='*80}") + if sl_type == 'fixed': + print(f"[{coin.upper()}] 🛑 STOP-LOSS (Fixed ${sl_value:.2f})") + elif sl_type == 'percent': + print(f"[{coin.upper()}] 🛑 STOP-LOSS (Percent {sl_value:.0f}% = ${stop_loss_threshold:.2f})") + else: + print(f"[{coin.upper()}] 🛑 STOP-LOSS") + print(f"[{strategy_name}] {market_slug}") + print(f"[EXIT] Our side: {our_side}") + print(f"[EXIT] Invested: ${total_invested:.2f}") + print(f"[EXIT] Unrealized PnL: ${unrealized_pnl:.2f} (threshold: ${stop_loss_threshold:.2f})") + if isinstance(result, dict): + print(f"[EXIT] Final PnL: ${result['pnl']:+.2f}") + print(f"[EXIT] Market is NO LONGER trading!") + print(f"{'='*80}\n") + return # Exit callback after closing + + # ───────────────────────────────────────────────── + # EXIT CHECK #2: FLIP-STOP (dynamic from strategy) + # Triggers when our side price drops too low + # ───────────────────────────────────────────────── + strategy = strategies.get(strategy_name) + if strategy and our_price <= strategy.flip_stop_price: + # Double-check position still exists (race condition protection) + trader = multi_trader.get_trader(strategy_name) + if not trader or market_slug not in trader.positions: + continue # Position already closed + + # Thread-safe check: market not already closed + with market_lock: + current_status = market_start_prices[coin].get(market_slug, -999) + if current_status == -2: + continue # Already closed by another callback + + # 🔥 FIX 1: LOG EXIT TRIGGER (for all 4 coins) + from trade_logger import log_exit_trigger + log_exit_trigger( + market_slug=market_slug, + exit_reason='flip_stop', + coin=coin, + trigger_price=our_price, + threshold_price=strategy.flip_stop_price + ) + + # 🔥 FIX 2: Mark market as closed BEFORE exit to prevent race condition (thread-safe) + with market_lock: + market_start_prices[coin][market_slug] = -2 + + # 🔥 FIX 2.1: ATOMIC BLOCK (per-coin protection) + order_executor.block_market(market_slug, coin) + + # Close position (flip-stop with current BID prices for selling) + result = multi_trader.close_market_early_exit( + strategy_name=strategy_name, + market_slug=market_slug, + exit_price=our_price, + exit_reason='flip_stop', + up_bid=up_bid, # ✅ REAL BID for selling UP tokens + down_bid=down_bid # ✅ REAL BID for selling DOWN tokens + ) + + if result: + + # Send notifications + if isinstance(result, dict): + try: + session_stats = multi_trader.get_session_stats(strategy_name, markets_skipped[coin]) + portfolio_stats = _get_portfolio_stats(multi_trader, markets_skipped, session_start_time) + notifier.send_market_closed(coin, result, session_stats, portfolio_stats) + + # Increment completed markets counter + total_completed_markets += 1 + + # Generate and send PnL chart every CHART_INTERVAL markets + if total_completed_markets - last_chart_at >= CHART_INTERVAL: + print(f"[CHART] {total_completed_markets} markets completed, generating PnL chart...") + + chart_path = f"/root/4coins_live/logs/pnl_chart_{total_completed_markets}.png" + + # Import chart generator + from pnl_chart_generator import generate_pnl_chart + + if generate_pnl_chart('/root/4coins_live/logs', COINS, chart_path): + # Send to Telegram + caption = f"📊 PnL Chart - {total_completed_markets} Markets Completed" + if notifier.send_photo(chart_path, caption): + print(f"[CHART] ✓ Sent to Telegram successfully") + last_chart_at = total_completed_markets + else: + print(f"[CHART] ✗ Failed to send to Telegram") + else: + print(f"[CHART] ✗ Failed to generate chart") + except Exception as e: + print(f"[ERROR] Notification failed: {e}") + + # Print confirmation + print(f"\n{'='*80}") + print(f"[{coin.upper()}] 🛑 FLIP-STOP @ ${our_price:.2f}") + print(f"[{strategy_name}] {market_slug}") + print(f"[EXIT] Our side: {our_side}") + print(f"[EXIT] Price dropped to: ${our_price:.2f} (≤${strategy.flip_stop_price:.2f})") + if isinstance(result, dict): + print(f"[EXIT] PnL: ${result['pnl']:+.2f}") + print(f"[EXIT] Market is NO LONGER trading!") + print(f"{'='*80}\n") + return # Exit callback after closing + + # ═══════════════════════════════════════════════════════ + # PART 2: ENTRY SIGNAL CHECK (real-time) + # ═══════════════════════════════════════════════════════ + strategy = strategies.get(strategy_name) + if not strategy: + print(f"[ERROR] Strategy {strategy_name} not found in strategies dict!") + continue + + # Generate signal with current market state + signal = strategy.should_enter(market_state, position_stats) + + if signal: + # Extract side/contracts - handle LateEntryStrategy format + side = None + contracts = None + + if 'favored' in signal: + # LateEntryStrategy format + favored = signal.get('favored', {}) + side = favored.get('side') + contracts = favored.get('contracts') + else: + # Fallback format + side = signal.get('side') + contracts = signal.get('contracts') + + # Validate extracted values + if not side or contracts is None or contracts <= 0: + continue + + # ═══════════════════════════════════════════════════════ + # CRITICAL: Prevent race condition re-entry + # Double-check market status before entry + # Another thread may have closed market during signal processing + # ═══════════════════════════════════════════════════════ + with market_lock: + current_status = market_start_prices[coin].get(market_slug, -999) + if current_status in [-1, -2]: + # Market closed/skipped during signal processing + print(f"[RACE] {coin.upper()} market {market_slug} status={current_status}, skipping entry") + continue + + # Check if trading is enabled for this coin + trading_enabled = config.get('trading', {}).get(coin, {}).get('enabled', True) + if not trading_enabled: + # Skip entry - trading disabled for this coin + dashboard.add_event(f"Trading disabled for {coin.upper()}, skipping entry", 'system') + continue + + # Calculate price + price = up_ask if side == 'UP' else down_ask + + # Execute trade (using correct method name) + success = multi_trader.enter_position( + strategy_name=strategy_name, + market_slug=market_slug, + side=side, + price=price, + contracts=contracts, + up_ask=up_ask, + down_ask=down_ask, + seconds_till_end=market_state.get('seconds_till_end', 0) + ) + + if success and contracts > 0: + # Update position stats after entry + updated_stats = multi_trader.get_market_stats(strategy_name, market_slug, up_ask, down_ask) + if updated_stats: + total_entries = updated_stats.get('total_entries', 0) + total_invested = updated_stats.get('total_invested', 0) + unrealized_pnl = updated_stats.get('unrealized_pnl', 0) + + # Print entry confirmation + print(f"[{strategy_name:30s}] {market_slug} | {side:5s} {contracts:3.0f} @ ${price:.2f} | " + f"Total: {total_entries:3d} entries ${total_invested:7.2f} | PnL: ${unrealized_pnl:+7.2f}") + + except KeyError as e: + print(f"[ERROR] Callback KeyError for {strategy_name}: {e}") + except AttributeError as e: + print(f"[ERROR] Callback AttributeError for {strategy_name}: {e}") + except Exception as e: + print(f"[ERROR] Callback unexpected error for {strategy_name}: {e}") + import traceback + traceback.print_exc() + + except Exception as e: + # Top-level error handler - should never reach here + print(f"[CRITICAL] Callback top-level error: {e}") + import traceback + traceback.print_exc() + + # Register callback with data feed + data_feed.register_price_callback(on_price_update) + print("[SYSTEM] ✓ Event-driven trading callbacks registered (INSTANT entry & exit)") + print() + + print("[SYSTEM] Starting trading loop...") + print(" Press Ctrl+C to stop") + print(" NOTE: First market for each coin will be skipped (started mid-market)") + print(" Will start trading after first market switch on each coin") + print() + + # Initialize keyboard listener for manual commands + keyboard_listener = KeyboardListener() + keyboard_listener.register_callback('m', run_manual_redeem, "Manual redeem all positions") + keyboard_listener.start() + print("[KEYBOARD] 🎹 Listener active - Press [M] to manually redeem all positions") + print() + + loop_counter = 0 + + # Main loop + while not stop_flag: + try: + if web_dashboard_state_mod.consume_stop_request(): + stop_flag = True + break + loop_counter += 1 + + # Process EACH coin independently + for coin in COINS: + market_state = data_feed.get_state(coin) + market_slug = market_state['market_slug'] + price = market_state['price'] + + if not market_slug: + continue + + # STEP 1: Check for market switch FIRST + for prev_market in list(market_start_prices[coin].keys()): + if prev_market != market_slug and prev_market != "": + # Market switch detected! + if not witnessed_market_switch[coin]: + witnessed_market_switch[coin] = True + print(f"\n{'='*80}") + print(f"[{coin.upper()}] ✓✓✓ FIRST MARKET SWITCH DETECTED ✓✓✓") + print(f"[{coin.upper()}] From: {prev_market}") + print(f"[{coin.upper()}] To: {market_slug}") + print(f"[{coin.upper()}] Will now start trading from this market onwards!") + print(f"{'='*80}\n") + else: + print(f"\n[{coin.upper()}] Market switch: {prev_market} → {market_slug}") + + price_start = market_start_prices[coin].get(prev_market, 0) + + # Check if we had a position in this market + strategy_name = f"{STRATEGY_BASES[0]}_{coin}" # Use constant instead of hardcoded + trader = multi_trader.get_trader(strategy_name) + had_position = trader and prev_market in trader.positions + + if price_start > 0 or (price_start == 0 and had_position): + # 🔥 DISABLED: Old pending_markets logic (replaced by SimpleRedeemCollector) + # SimpleRedeemCollector will find and redeem this position automatically via API + print(f"\n[{coin.upper()}] Market ended: {prev_market}") + print(f"[REDEEM] Will be collected by SimpleRedeemCollector API scanner") + # if prev_market not in pending_markets[coin]: + # redeem_cfg = config.get("execution", {}).get("redeem", {}) + # first_delay = redeem_cfg.get("first_attempt_delay_sec", 300) + # print(f"[PENDING] Added to pending queue, first redeem attempt in {first_delay // 60} minutes...") + # pending_markets[coin][prev_market] = { + # 'price_start': price_start if price_start > 0 else 0.0, + # 'price_final': price if price > 0 else 0.0, + # 'first_attempt': time.time(), + # 'attempts': 0, + # 'next_retry': time.time() + first_delay + # } + elif price_start == -1: + # Market was skipped (started mid-market) + markets_skipped[coin] += 1 + session_stats = multi_trader.get_session_stats(strategy_name, markets_skipped[coin]) + portfolio_stats = _get_portfolio_stats(multi_trader, markets_skipped, session_start_time) + notifier.send_market_skipped(coin, prev_market, "Started mid-market", session_stats, portfolio_stats) + print(f"\n[{coin.upper()}] ⏭️ Skipped market {prev_market} ended (was started mid-market)") + elif price_start == 0 and not had_position: + # Market was active but we didn't enter - skipped! + markets_skipped[coin] += 1 + session_stats = multi_trader.get_session_stats(strategy_name, markets_skipped[coin]) + portfolio_stats = _get_portfolio_stats(multi_trader, markets_skipped, session_start_time) + notifier.send_market_skipped(coin, prev_market, "No entry signals", session_stats, portfolio_stats) + print(f"\n[{coin.upper()}] ⏭️ Skipped market {prev_market} ended (no entry signals)") + elif price_start == -2: + # 🔥 Market was closed early (stop-loss/flip-stop) + # 🔥 DISABLED: Old pending_markets logic (replaced by SimpleRedeemCollector) + # SimpleRedeemCollector will find and redeem this position automatically via API + print(f"\n[{coin.upper()}] Market {prev_market} ended (was closed early)") + print(f"[REDEEM] Will be collected by SimpleRedeemCollector API scanner") + # if prev_market not in pending_markets[coin]: + # redeem_cfg = config.get("execution", {}).get("redeem", {}) + # first_delay = redeem_cfg.get("first_attempt_delay_sec", 300) + # print(f"[PENDING] Added to pending queue (early exit), first redeem attempt in {first_delay // 60} minutes...") + # pending_markets[coin][prev_market] = { + # 'price_start': -2, # Mark as early exit + # 'price_final': price if price > 0 else 0.0, + # 'first_attempt': time.time(), + # 'attempts': 0, + # 'next_retry': time.time() + first_delay + # } + + # Remove from tracking + del market_start_prices[coin][prev_market] + + # STEP 2: Track market start price + if market_slug not in market_start_prices[coin]: + # First time seeing this market + if not witnessed_market_switch[coin]: + # This is the FIRST market we see at startup - skip it + market_start_prices[coin][market_slug] = -1 # -1 = skip + print(f"\n[{coin.upper()}] First market detected at startup: {market_slug}") + print(f"[SKIP] Not trading this market (script started mid-market)") + print(f"[SKIP] Will start trading after this market ends\n") + # DON'T continue - let it check if in entry window below! + else: + # We've witnessed a market switch, so this is a NEW valid market + market_start_prices[coin][market_slug] = price if price > 0 else 0.0 + print(f"\n[{coin.upper()}] ✓ New market witnessed from start: {market_slug}") + print(f"[TRADE] Start price: ${price:,.2f}" if price > 0 else "[TRADE] Start price: pending...") + print(f"[TRADE] Will trade this market ✓\n") + + elif market_start_prices[coin][market_slug] == 0: + # Update pending market with valid price + if price > 0: + market_start_prices[coin][market_slug] = price + print(f"\n[{coin.upper()}] ✓ Start price updated: {market_slug} | Price: ${price:,.2f}\n") + + elif market_start_prices[coin][market_slug] == -1: + # This market is marked as skip - don't trade it + pass + + # 🔥 DISABLED: Old pending_markets processing (replaced by SimpleRedeemCollector) + # SimpleRedeemCollector handles all redeems via periodic API scanning + # now = time.time() + # + # for prev_market in list(pending_markets[coin].keys()): + # pending_info = pending_markets[coin][prev_market] + # + # # Check if it's time to retry + # if now < pending_info['next_retry']: + # continue + # + # # Increment attempts + # pending_info['attempts'] += 1 + # + # # Submit to async executor (non-blocking!) + # try: + # print(f"[REDEEM SUBMIT] 📤 Submitting {coin.upper()} market {prev_market} to async executor...") + # future = redeem_executor.submit( + # process_redeem_async, + # coin, prev_market, pending_info, config, + # markets_skipped, session_start_time + # ) + # print(f"[REDEEM SUBMIT] ✅ Task submitted successfully (Future: {future})") + # # Update next retry immediately (don't wait for result) + # redeem_cfg = config.get("execution", {}).get("redeem", {}) + # retry_delay = redeem_cfg.get("retry_delay_sec", 300) + # pending_info['next_retry'] = now + retry_delay + # except Exception as e: + # print(f"[REDEEM] Failed to submit {coin}/{prev_market}: {e}") + + # ═══════════════════════════════════════════════════════ + # BALANCE CHECK: 60 seconds before BTC market end + # (BTC market ends every 15 minutes - good timing for balance refresh) + # ═══════════════════════════════════════════════════════ + if coin == 'btc': + seconds_till_end = market_state.get('seconds_till_end', 0) + + # Check balance 60 seconds before market end + if 55 <= seconds_till_end <= 65: + # Track which markets we've checked to avoid duplicates + if not hasattr(main, '_balance_checked_markets'): + main._balance_checked_markets = set() + + current_market = market_state.get('market_slug') + if current_market and current_market not in main._balance_checked_markets: + main._balance_checked_markets.add(current_market) + + # Cleanup old entries (keep only last 10) + if len(main._balance_checked_markets) > 10: + main._balance_checked_markets = set(list(main._balance_checked_markets)[-10:]) + + # Async balance check (non-blocking) + def check_balance_async(): + global wallet_balance + try: + if not safety_guard.dry_run: + new_balance = order_executor.get_wallet_usdc_balance() + if new_balance and new_balance > 0: + old_balance = wallet_balance + wallet_balance = new_balance + change = new_balance - old_balance + change_sign = "+" if change >= 0 else "" + print(f"[BALANCE] 🔄 Updated: ${wallet_balance:,.2f} ({change_sign}${change:.2f})") + except Exception as e: + print(f"[BALANCE] ⚠️ Check failed: {e}") + + threading.Thread(target=check_balance_async, daemon=True, name="balance_check").start() + + # Check if this market is active + current_market_status = market_start_prices[coin].get(market_slug, -999) + + # If market was skipped (-1) but now in entry window — allow trading + _strategy_name = f"{STRATEGY_BASES[0]}_{coin}" + _ew = strategies[_strategy_name].entry_window + if current_market_status == -1 and market_state['seconds_till_end'] <= _ew: + market_start_prices[coin][market_slug] = 0 # Activate market + print(f"\n[{coin.upper()}] ✅ Market {market_slug} NOW ACTIVE (entry window)") + elif current_market_status in [-1, -2, -999]: + # Market is inactive (-1), closed by early exit (-2), or not tracked (-999) + continue + + # ======================================== + # MARKET DISCOVERY & MONITORING + # Entry/Exit signals now handled by callback! + # ======================================== + # Dashboard update loop (no signal processing here) + pass # Market monitoring handled by callback + + + # Update dashboard in REAL-TIME + # 🔥 CHANGED: pending_markets replaced by SimpleRedeemCollector + # Dashboard now shows empty pending (collector handles redeems automatically) + all_pending = {} # Empty - collector handles redeems in background + dashboard.render(multi_trader, strategies, data_feed, wallet_balance, all_pending) + + try: + from web_dashboard.snapshot_builder import build_snapshot + _proj = Path(__file__).resolve().parent.parent + _snap = build_snapshot( + coins=COINS, + strategy_base=STRATEGY_BASES[0], + multi_trader=multi_trader, + data_feed=data_feed, + wallet_balance=wallet_balance, + config=config, + session_start_time=session_start_time, + dry_run=safety_guard.dry_run, + markets_skipped=markets_skipped, + ) + web_dashboard_state_mod.set_snapshot(_snap) + if getattr(args, "web", False): + web_dashboard_state_mod.write_state_file(_proj, _snap) + except Exception: + pass + + # ═══════════════════════════════════════════════════════════ + # 🔥 SYSTEM #2: ASYNC INSTANT STOP-LOSS CHECK (every 0.1 sec) + # Checks all 4 coins IN PARALLEL + # ═══════════════════════════════════════════════════════════ + for coin in COINS: + def check_coin_sys2(coin_name): + """Async stop-loss/flip-stop check for one coin""" + try: + strategy_name = f"late_entry_v3_{coin_name}" + if strategy_name not in strategies: + return + + market_state = data_feed.get_state(coin_name) + market_slug = market_state.get('market_slug') + if not market_slug: + return + + # Check market status + with market_lock: + status = market_start_prices.get(coin_name, {}).get(market_slug, -999) + if status in [-1, -2, -999]: + return + + # Get prices + up_ask = market_state.get('up_ask', 0.5) + down_ask = market_state.get('down_ask', 0.5) + up_bid = market_state.get('up_bid', 0.5) + down_bid = market_state.get('down_bid', 0.5) + + # Validate price freshness before exit checks + up_ask_ts = market_state.get('up_ask_timestamp', 0) + down_ask_ts = market_state.get('down_ask_timestamp', 0) + + is_valid, reason = validate_prices(up_ask, down_ask, up_ask_ts, down_ask_ts, coin_name) + if not is_valid: + # Skip stop-loss/flip-stop if prices invalid + return + + # Get detailed stats + detailed_stats = multi_trader.traders[strategy_name].get_market_detailed_stats( + market_slug=market_slug, + up_ask=up_ask, + down_ask=down_ask + ) + + if not detailed_stats: + return + + # Check stop-loss + if detailed_stats.get('stop_loss_triggered', False): + with market_lock: + if market_start_prices[coin_name].get(market_slug, -999) == -2: + return + + up_shares = detailed_stats['up_shares'] + down_shares = detailed_stats['down_shares'] + our_side = 'UP' if up_shares > down_shares else 'DOWN' + our_price = up_ask if our_side == 'UP' else down_ask + + # 🔥 FIX 1: LOG EXIT TRIGGER (for all 4 coins) + from trade_logger import log_exit_trigger + log_exit_trigger( + market_slug=market_slug, + exit_reason='stop_loss', + coin=coin_name, + unrealized_pnl=detailed_stats.get('unrealized_pnl', 0), + threshold_pnl=detailed_stats.get('stop_loss_threshold', 0) + ) + + # 🔥 FIX 2: Mark market as closed BEFORE exit to prevent race condition + with market_lock: + market_start_prices[coin_name][market_slug] = -2 + + # 🔥 FIX 2.1: ATOMIC BLOCK (per-coin protection) + order_executor.block_market(market_slug, coin_name) + + result = multi_trader.close_market_early_exit( + strategy_name=strategy_name, + market_slug=market_slug, + exit_price=our_price, + exit_reason='stop_loss', + up_bid=up_bid, + down_bid=down_bid + ) + + if result: + print(f"[SYS#2] 🚨 {coin_name.upper()} STOP-LOSS: PnL=${detailed_stats['unrealized_pnl']:.2f}") + + # Check flip-stop + if detailed_stats.get('flip_stop_triggered', False): + with market_lock: + if market_start_prices[coin_name].get(market_slug, -999) == -2: + return + + up_shares = detailed_stats['up_shares'] + down_shares = detailed_stats['down_shares'] + our_side = 'UP' if up_shares > down_shares else 'DOWN' + our_price = up_ask if our_side == 'UP' else down_ask + + # 🔥 FIX 1: LOG EXIT TRIGGER (for all 4 coins) + from trade_logger import log_exit_trigger + log_exit_trigger( + market_slug=market_slug, + exit_reason='flip_stop', + coin=coin_name, + trigger_price=our_price, + threshold_price=detailed_stats.get('flip_stop_price', 0) + ) + + # 🔥 FIX 2: Mark market as closed BEFORE exit to prevent race condition + with market_lock: + market_start_prices[coin_name][market_slug] = -2 + + # 🔥 FIX 2.1: ATOMIC BLOCK (per-coin protection) + order_executor.block_market(market_slug, coin_name) + + result = multi_trader.close_market_early_exit( + strategy_name=strategy_name, + market_slug=market_slug, + exit_price=our_price, + exit_reason='flip_stop', + up_bid=up_bid, + down_bid=down_bid + ) + + if result: + print(f"[SYS#2] 🚨 {coin_name.upper()} FLIP-STOP") + + except Exception as e: + pass # Silent - don't spam logs + + # 🔥 Run via executor (in parallel for all coins) + try: + sys2_executor.submit(check_coin_sys2, coin) + except: + pass + + # Sleep - can be slower now (entry/exit in callback) + time.sleep(0.1) + + except Exception as e: + print(f"[ERROR] Main loop error: {e}") + import traceback + traceback.print_exc() + time.sleep(1) + + # Cleanup + print("\n[SYSTEM] Stopping keyboard listener...") + if keyboard_listener: + keyboard_listener.stop() + + print("[SYSTEM] Stopping data feed...") + data_feed.stop() + + # Final summary + print("\n" + "=" * 115) + print(" MERIDIAN — SESSION RESULTS".center(115)) + print("=" * 115) + + portfolio_stats = multi_trader.get_portfolio_stats() + + # Display by strategy (grouped by base, showing BTC and ETH) + for base_name in STRATEGY_BASES: + print(f"\n=== {base_name.upper()} (BTC + ETH) ===") + + total_capital_strategy = 0 + total_pnl_strategy = 0 + total_trades_strategy = 0 + + for coin in COINS: + strategy_name = f"{base_name}_{coin}" + trader = multi_trader.traders.get(strategy_name) + if not trader: + print(f"[WARNING] Trader {strategy_name} not found!") + continue + stats = trader.get_performance_stats() + pnl = trader.current_capital - trader.starting_capital + pnl_sign = "+" if pnl >= 0 else "" + + total_capital_strategy += trader.current_capital + total_pnl_strategy += pnl + total_trades_strategy += stats['total_trades'] + + print(f" {coin.upper():3s}: ${trader.current_capital:>8,.0f} | PnL: {pnl_sign}${pnl:>7,.0f} | " + f"Trades: {stats['total_trades']:3d} | WR: {stats['win_rate']:.1f}%") + + # Strategy total + pnl_sign = "+" if total_pnl_strategy >= 0 else "" + print(f" {'TOTAL':3s}: ${total_capital_strategy:>8,.0f} | PnL: {pnl_sign}${total_pnl_strategy:>7,.0f} | " + f"Trades: {total_trades_strategy:3d}") + + # Portfolio total + print("\n" + "=" * 115) + total_pnl = portfolio_stats['total_pnl'] + pnl_sign = "+" if total_pnl >= 0 else "" + print(f"{'TOTAL PORTFOLIO':30s}: ${portfolio_stats['total_capital']:>10,.0f} | " + f"PnL: {pnl_sign}${total_pnl:>8,.0f} ({pnl_sign}{portfolio_stats['portfolio_roi']:.2f}%)") + print("=" * 115) + print() + + +if __name__ == '__main__': + main(_parse_cli_args()) diff --git a/up-down-spread-bot/src/market_config.py b/up-down-spread-bot/src/market_config.py new file mode 100644 index 0000000..feecf33 --- /dev/null +++ b/up-down-spread-bot/src/market_config.py @@ -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 diff --git a/up-down-spread-bot/src/multi_trader.py b/up-down-spread-bot/src/multi_trader.py new file mode 100644 index 0000000..cfca983 --- /dev/null +++ b/up-down-spread-bot/src/multi_trader.py @@ -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, + } + + diff --git a/up-down-spread-bot/src/order_executor.py b/up-down-spread-bot/src/order_executor.py new file mode 100644 index 0000000..5e5a001 --- /dev/null +++ b/up-down-spread-bot/src/order_executor.py @@ -0,0 +1,2346 @@ +""" +Order Executor - Real trading engine with retry logic +Based on /root/clip/trade.py methods +""" +import os +import time +import json +import math +import requests +from pathlib import Path +from typing import Dict, Optional +from dataclasses import dataclass +import concurrent.futures + +from web3 import Web3 +from eth_account import Account +from py_clob_client.client import ClobClient +from py_clob_client.clob_types import OrderArgs, OrderType +from py_clob_client.order_builder.constants import BUY, SELL + +from safety_guard import SafetyGuard +import logging +from trade_logger import log_buy_attempt, log_buy_result, log_sell_attempt, log_sell_result +import threading + +# 🔥 GLOBAL: Blocked markets per-coin (race condition protection) +# Markets in this dict CANNOT receive new buy orders (stop-loss/flip-stop active) +# Structure: {'btc': set(), 'eth': set(), 'sol': set(), 'xrp': set()} +_blocked_markets_lock = threading.Lock() +_blocked_markets = { + 'btc': set(), + 'eth': set(), + 'sol': set(), + 'xrp': set() +} + + +@dataclass +class OrderResult: + """Order execution result (with FAK/FOK retry support)""" + success: bool + order_id: Optional[str] = None + filled_size: float = 0.0 # Total contracts filled (can be less than target!) + filled_price: float = 0.0 # Average price per contract + total_spent_usd: float = 0.0 # Total USD spent/received (sum across all attempts) + attempts: int = 1 # Number of attempts made + error: Optional[str] = None + dry_run: bool = False + elapsed_ms: int = 0 + remaining_balance: float = 0.0 # 🔥 FIX 4: Final balance on blockchain after sell + + +class OrderExecutor: + """Execute real orders on Polymarket (methods from /root/clip)""" + + @staticmethod + def block_market(market_slug: str, coin: str): + """ + 🔥 CRITICAL: Block market from receiving new buy orders (per-coin) + Call this IMMEDIATELY when stop-loss/flip-stop triggers + + Args: + market_slug: Market identifier + coin: Coin name ('btc', 'eth', 'sol', 'xrp') + """ + with _blocked_markets_lock: + if coin in _blocked_markets: + _blocked_markets[coin].add(market_slug) + print(f"[EXECUTOR] 🔒 MARKET BLOCKED: {coin.upper()} - {market_slug}") + else: + print(f"[EXECUTOR] ⚠️ Unknown coin: {coin}") + + @staticmethod + def unblock_market(market_slug: str, coin: str): + """ + Unblock market (call after successful redeem) + + Args: + market_slug: Market identifier + coin: Coin name ('btc', 'eth', 'sol', 'xrp') + """ + with _blocked_markets_lock: + if coin in _blocked_markets and market_slug in _blocked_markets[coin]: + _blocked_markets[coin].remove(market_slug) + print(f"[EXECUTOR] 🔓 MARKET UNBLOCKED: {coin.upper()} - {market_slug}") + + @staticmethod + def is_market_blocked(market_slug: str, coin: str) -> bool: + """ + Check if market is blocked for specific coin (atomic check) + + Args: + market_slug: Market identifier + coin: Coin name ('btc', 'eth', 'sol', 'xrp') + + Returns: + True if blocked for this coin, False otherwise + """ + with _blocked_markets_lock: + return coin in _blocked_markets and market_slug in _blocked_markets[coin] + + def __init__(self, safety_guard: SafetyGuard, config: Dict, data_feed=None): + self.safety = safety_guard + self.config = config + self.data_feed = data_feed # ✅ For access to position_tracker + + # Initialize CLOB client + self.client = None + self.wallet_address = None + + if not self.safety.dry_run: + try: + from dotenv import load_dotenv + # Load .env from project root (not from current directory) + project_root = Path(__file__).parent.parent + env_path = project_root / ".env" + load_dotenv(env_path) + + # Read PRIVATE_KEY AFTER loading .env + self.private_key = os.getenv("PRIVATE_KEY", "") + if not self.private_key: + raise ValueError("PRIVATE_KEY not found in .env") + + # Read signature type and funder address + signature_type = int(os.getenv("SIGNATURE_TYPE", "0")) + funder_address = os.getenv("FUNDER_ADDRESS", "") + + # Get wallet address based on SIGNATURE_TYPE + # Type 0: Use address from PRIVATE_KEY (standard EOA wallet) + # Type 1/2: Use FUNDER_ADDRESS (Polymarket proxy/smart contract wallet) + if signature_type == 0: + self.wallet_address = Account.from_key(self.private_key).address + wallet_type = "EOA" + else: + if not funder_address: + raise ValueError(f"SIGNATURE_TYPE={signature_type} requires FUNDER_ADDRESS in .env") + self.wallet_address = funder_address + wallet_type = f"Proxy (type {signature_type})" + + host = os.getenv("CLOB_HOST", "https://clob.polymarket.com") + chain_id = int(os.getenv("CHAIN_ID", "137")) + + # Initialize ClobClient with signature type and funder if needed + if signature_type == 0: + self.client = ClobClient( + host=host, + chain_id=chain_id, + key=self.private_key, + signature_type=0 + ) + else: + self.client = ClobClient( + host=host, + chain_id=chain_id, + key=self.private_key, + signature_type=signature_type, + funder=funder_address + ) + # 🚨 CRITICAL: Generate and set API credentials + print(f"[EXECUTOR] Generating API credentials...") + creds = self.client.create_or_derive_api_creds() + self.client.set_api_creds(creds) + print(f"[EXECUTOR] ✓ API credentials set") + + print(f"[EXECUTOR] ✓ CLOB client initialized") + print(f"[EXECUTOR] Wallet: {self.wallet_address[:6]}...{self.wallet_address[-4:]}") + print(f"[EXECUTOR] Type: {wallet_type}") + except Exception as e: + print(f"[EXECUTOR] ❌ Failed to init CLOB client: {e}") + self.safety.activate_emergency_stop("CLOB_INIT_FAILED") + else: + self.private_key = "" # DRY_RUN - no private key needed + print("[EXECUTOR] ✓ DRY_RUN mode (no real orders)") + + # 🔥 RPC Configuration (Multiple endpoints with parallel requests) + self.rpc_config = config.get('execution', {}).get('rpc_config', {}) + + # RPC endpoints (fallback to env var if not in config) + self.rpc_endpoints = self.rpc_config.get('endpoints', [ + os.getenv("RPC_URL", "https://polygon-rpc.com") + ]) + + # RPC parameters + self.rpc_single_timeout = self.rpc_config.get('single_request_timeout_sec', 3) + self.rpc_parallel_timeout = self.rpc_config.get('parallel_timeout_sec', 5) + self.rpc_retry_attempts = self.rpc_config.get('retry_attempts', 2) + self.rpc_retry_delay = self.rpc_config.get('retry_delay_sec', 0.3) + self.rpc_parallel_enabled = self.rpc_config.get('enable_parallel_requests', True) + + # Log RPC configuration + print(f"[EXECUTOR] {'='*60}") + print(f"[EXECUTOR] 🌐 RPC CONFIGURATION:") + print(f"[EXECUTOR] Endpoints: {len(self.rpc_endpoints)}") + for i, rpc in enumerate(self.rpc_endpoints, 1): + rpc_short = rpc.split('/')[2][:30] if '://' in rpc else rpc[:30] + print(f"[EXECUTOR] #{i}: {rpc_short}...") + print(f"[EXECUTOR] Single timeout: {self.rpc_single_timeout}s") + print(f"[EXECUTOR] Parallel timeout: {self.rpc_parallel_timeout}s") + print(f"[EXECUTOR] Retry attempts: {self.rpc_retry_attempts}") + print(f"[EXECUTOR] Retry delay: {self.rpc_retry_delay}s") + print(f"[EXECUTOR] Parallel mode: {'ENABLED ⚡' if self.rpc_parallel_enabled else 'DISABLED'}") + print(f"[EXECUTOR] {'='*60}\n") + + # CTF contract for token balances + self.CTF_ADDRESS = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045" + self.CTF_ABI = [ + {"inputs": [{"name": "_owner", "type": "address"}, {"name": "_id", "type": "uint256"}], + "name": "balanceOf", "outputs": [{"name": "", "type": "uint256"}], + "stateMutability": "view", "type": "function"} + ] + + # USDC contracts + self.USDC_BRIDGED = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174" + self.USDC_NATIVE = "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359" + self.ERC20_ABI = [ + {'constant': True, 'inputs': [{'name': '_owner', 'type': 'address'}], + 'name': 'balanceOf', 'outputs': [{'name': 'balance', 'type': 'uint256'}], 'type': 'function'}, + {'constant': True, 'inputs': [], 'name': 'decimals', + 'outputs': [{'name': '', 'type': 'uint8'}], 'type': 'function'} + ] + + # Order logging + self.orders_log = Path("logs/orders.jsonl") + self.orders_log.parent.mkdir(exist_ok=True) + + # Callback for tracking balance changes + self.balance_change_callback = None + + # Callback for checking market close (race condition protection) + self.market_closing_check_callback = None + + def set_balance_callback(self, callback): + """ + Set callback for balance changes + callback(amount, operation, is_absolute=False) + - amount: float - change amount or absolute value + - operation: str - operation type ('BUY', 'SELL', 'REDEEM', 'REDEEM_REFRESH') + - is_absolute: bool - if True, amount = full balance, else delta + """ + self.balance_change_callback = callback + print("[EXECUTOR] ✓ Balance change callback registered") + + def set_market_closing_check(self, callback): + """ + Set callback for checking market close (race condition protection) + callback(market_slug: str) -> bool + - Returns True if market is closing and buys should be blocked + - Returns False if market is open and buys are allowed + + 🔥 CRITICAL: Prevents buys AFTER stop-loss/flip-stop trigger + """ + self.market_closing_check_callback = callback + print("[EXECUTOR] ✓ Market closing check callback registered") + + def _log_redeem(self, market_slug: str, success: bool, amount: float, tx_hash: str = "", reason: str = ""): + """Log redeem operation to separate file""" + try: + import os + from datetime import datetime + + log_file = "logs/redeem.log" + os.makedirs(os.path.dirname(log_file), exist_ok=True) + + with open(log_file, 'a') as f: + timestamp = datetime.now().isoformat() + status = "SUCCESS" if success else "FAILED" + f.write(f"{timestamp} | {market_slug} | {status} | ${amount:.2f} | {tx_hash} | {reason}\n") + except Exception as e: + print(f"[ERROR] Failed to log redeem: {e}") + + def get_wallet_usdc_balance(self) -> Optional[float]: + """ + Get wallet USDC balance (bridged + native) + Copy of method from /root/clip/trade.py + """ + try: + if not self.wallet_address and self.private_key: + self.wallet_address = Account.from_key(self.private_key).address + + if not self.wallet_address: + print("[EXECUTOR] ❌ No wallet address") + return None + + # Use first RPC endpoint for wallet balance queries + rpc_url = self.rpc_endpoints[0] if self.rpc_endpoints else "https://polygon-rpc.com" + w3 = Web3(Web3.HTTPProvider(rpc_url, request_kwargs={'timeout': self.rpc_single_timeout})) + + if not w3.is_connected(): + print("[EXECUTOR] ⚠ Cannot connect to RPC") + return None + + total = 0.0 + + # USDC.e (bridged) - main Polymarket token + usdc_e = w3.eth.contract( + address=Web3.to_checksum_address(self.USDC_BRIDGED), + abi=self.ERC20_ABI + ) + balance_e = usdc_e.functions.balanceOf(self.wallet_address).call() + decimals_e = usdc_e.functions.decimals().call() + total += balance_e / (10 ** decimals_e) + + # Native USDC + usdc_n = w3.eth.contract( + address=Web3.to_checksum_address(self.USDC_NATIVE), + abi=self.ERC20_ABI + ) + balance_n = usdc_n.functions.balanceOf(self.wallet_address).call() + decimals_n = usdc_n.functions.decimals().call() + total += balance_n / (10 ** decimals_n) + + print(f"[EXECUTOR] Wallet balance: ${total:.2f}") + return total + + except Exception as e: + print(f"[EXECUTOR] ❌ Balance query error: {e}") + return None + + def get_pol_balance(self) -> Optional[float]: + """ + Get POL balance (native Polygon token) + + Returns: + Balance in POL or None on error + """ + try: + if not self.wallet_address and self.private_key: + self.wallet_address = Account.from_key(self.private_key).address + + if not self.wallet_address: + print("[EXECUTOR] ❌ No wallet address") + return None + + # Use first RPC endpoint for wallet balance queries + rpc_url = self.rpc_endpoints[0] if self.rpc_endpoints else "https://polygon-rpc.com" + w3 = Web3(Web3.HTTPProvider(rpc_url, request_kwargs={'timeout': self.rpc_single_timeout})) + + if not w3.is_connected(): + print("[EXECUTOR] ⚠ Cannot connect to RPC") + return None + + # Get native balance (in Wei) + balance_wei = w3.eth.get_balance(self.wallet_address) + # Convert to POL (1 POL = 10^18 Wei) + balance_pol = balance_wei / 1e18 + + print(f"[EXECUTOR] POL balance: {balance_pol:.4f}") + return balance_pol + + except Exception as e: + print(f"[EXECUTOR] ❌ POL balance query error: {e}") + return None + + def get_blockchain_token_balance(self, token_id: str) -> Optional[float]: + """ + ✅ GET REAL TOKEN BALANCE FROM BLOCKCHAIN! + + Uses PARALLEL REQUESTS to multiple RPC endpoints for: + - Maximum speed (take first successful response ~20-70ms) + - Maximum reliability (if one RPC fails - use another) + - Minimum timeout (instead of 60 seconds -> 5-10 seconds) + + Args: + token_id: Token ID (e.g. "52114319501245915516055106046884209969926127482827954674443846427813813222426") + + Returns: + Real balance in contracts (float) or None if all RPCs unavailable + """ + if self.safety.dry_run: + return 0.0 + + try: + if not self.wallet_address and self.private_key: + self.wallet_address = Account.from_key(self.private_key).address + + if not self.wallet_address: + print("[EXECUTOR] ❌ No wallet address for token balance query") + return None + + # 🔥 FUNCTION: Request to one RPC endpoint + def query_single_rpc(rpc_url: str, attempt: int = 1) -> Optional[float]: + """Query balance from a single RPC endpoint""" + try: + w3 = Web3(Web3.HTTPProvider( + rpc_url, + request_kwargs={'timeout': self.rpc_single_timeout} + )) + + if not w3.is_connected(): + return None + + ctf = w3.eth.contract( + address=Web3.to_checksum_address(self.CTF_ADDRESS), + abi=self.CTF_ABI + ) + + balance_raw = ctf.functions.balanceOf( + self.wallet_address, + int(token_id) + ).call() + balance = balance_raw / 1e6 # Convert from raw to USDC decimals (6 decimals) + + rpc_short = rpc_url.split('/')[2][:20] if '://' in rpc_url else rpc_url[:20] + print(f"[EXECUTOR] ✅ RPC [{rpc_short}...] balance: {balance:.4f} contracts") + return balance + + except Exception as e: + rpc_short = rpc_url.split('/')[2][:20] if '://' in rpc_url else rpc_url[:20] + print(f"[EXECUTOR] ⚠️ RPC [{rpc_short}...] failed: {type(e).__name__}") + return None + + # 🔥 RETRY LOOP with parallel or sequential requests + for attempt in range(1, self.rpc_retry_attempts + 1): + print(f"[EXECUTOR] 🔄 Balance query attempt {attempt}/{self.rpc_retry_attempts}...") + + if self.rpc_parallel_enabled and len(self.rpc_endpoints) > 1: + # 🚀 PARALLEL REQUESTS + print(f"[EXECUTOR] 🚀 Querying {len(self.rpc_endpoints)} RPCs in parallel...") + + executor = concurrent.futures.ThreadPoolExecutor(max_workers=len(self.rpc_endpoints)) + + try: + # Start all requests SIMULTANEOUSLY + futures = { + executor.submit(query_single_rpc, rpc, attempt): rpc + for rpc in self.rpc_endpoints + } + + # Wait for FIRST successful result + for future in concurrent.futures.as_completed( + futures, + timeout=self.rpc_parallel_timeout + ): + try: + balance = future.result() + if balance is not None: + # 🔥 CRITICAL: Immediately cancel remaining futures! + executor.shutdown(wait=False, cancel_futures=True) + print(f"[EXECUTOR] ✅ Got balance: {balance:.4f} contracts (token: {token_id[:16]}...)") + return balance # ← EARLY EXIT! + except Exception: + continue + + except concurrent.futures.TimeoutError: + print(f"[EXECUTOR] ⏱️ All RPCs timeout after {self.rpc_parallel_timeout}s") + finally: + # Guaranteed cleanup + executor.shutdown(wait=False, cancel_futures=True) + + else: + # 🔄 SEQUENTIAL REQUESTS (fallback or if parallel disabled) + print(f"[EXECUTOR] 🔄 Querying RPCs sequentially...") + for rpc in self.rpc_endpoints: + balance = query_single_rpc(rpc, attempt) + if balance is not None: + print(f"[EXECUTOR] ✅ Got balance: {balance:.4f} contracts (token: {token_id[:16]}...)") + return balance # ✅ Success! + + # Didn't get balance - wait before next attempt + if attempt < self.rpc_retry_attempts: + print(f"[EXECUTOR] ⏸️ Waiting {self.rpc_retry_delay}s before retry...") + time.sleep(self.rpc_retry_delay) + + # All attempts failed + print(f"[EXECUTOR] ❌ All {self.rpc_retry_attempts} attempts failed for all {len(self.rpc_endpoints)} RPC endpoints!") + return None + + except Exception as e: + print(f"[EXECUTOR] ❌ CRITICAL ERROR in get_blockchain_token_balance: {e}") + import traceback + traceback.print_exc() + return None + + def _get_fresh_bid_price(self, market_slug: str, side: str) -> Optional[float]: + """ + ✅ GET FRESH BID PRICE FROM WEBSOCKET DATA FEED! + + Used to update sell price on each FAK attempt. + Prices are updated real-time from Polymarket WebSocket (no delays or API requests). + + Args: + market_slug: Market slug (e.g. 'btc-updown-15m-1768134600') + side: 'UP' or 'DOWN' + + Returns: + Fresh BID price (float) or None if unavailable + """ + if not self.data_feed: + return None + + try: + # Determine coin from market_slug + # Format: 'btc-updown-15m-1768134600' -> 'btc' + coin = market_slug.split('-')[0].lower() + + if coin not in ['btc', 'eth', 'sol', 'xrp']: + print(f"[EXECUTOR] ⚠️ Unknown coin in market_slug: {market_slug}") + return None + + # Get state from WebSocket + market_state = self.data_feed.get_state(coin) + + if not market_state: + return None + + # Get BID for required side + if side == 'UP': + bid = market_state.get('up_bid') + elif side == 'DOWN': + bid = market_state.get('down_bid') + else: + print(f"[EXECUTOR] ⚠️ Invalid side: {side}") + return None + + # Validate BID price + if bid and 0.01 < bid < 0.99: + return bid + else: + return None + + except Exception as e: + print(f"[EXECUTOR] ⚠️ Failed to get fresh BID: {e}") + return None + + def place_buy_order(self, market_slug: str, token_id: str, side: str, + contracts: int, ask_price: float, coin: str = None) -> OrderResult: + """ + Place BUY order with FAK partial fill tracking + + 🚨 CRITICAL: FAK orders can fill partially! + - Track actual fill through takingAmount/makingAmount + - Complete to target with max_fak_attempts attempts + - Round to 2 decimals, minimum $1.00 + + Args: + market_slug: Market slug + token_id: Token ID to buy + side: 'UP' or 'DOWN' + contracts: Target number of contracts (may NOT be reached!) + ask_price: Current ask price + coin: Coin name ('btc', 'eth', 'sol', 'xrp') for per-coin blocking + + Returns: + OrderResult (filled_size can be < contracts!) + """ + # Determine coin from market_slug if not provided + if not coin: + for c in ['btc', 'eth', 'sol', 'xrp']: + if f'{c}-updown-' in market_slug: + coin = c + break + # Read parameters from config! + exec_config = self.config.get('execution', {}).get('buy', {}) + MAX_FAK_ATTEMPTS = exec_config.get('max_fak_attempts', 3) + RETRY_DELAY = exec_config.get('retry_delay_sec', 0.3) + MIN_ORDER_USD = exec_config.get('min_order_usd', 1.00) + TARGET_FILL_PERCENT = exec_config.get('target_fill_percent', 95.0) / 100.0 + + # Safety check + allowed, reason = self.safety.check_order_allowed( + side=side, + contracts=contracts, + price=ask_price, + market_slug=market_slug + ) + + if not allowed: + # DRY_RUN - simulate success + if reason == "DRY_RUN_MODE": + result = OrderResult( + success=True, + order_id=f"DRY_RUN_{int(time.time())}", + filled_size=contracts, + filled_price=ask_price, + total_spent_usd=round(contracts * ask_price, 2), + attempts=1, + dry_run=True + ) + self._log_order(market_slug, side, contracts, ask_price, result, "BUY", fak_attempt=1) + return result + else: + # Other blocking reason + result = OrderResult( + success=False, + error=reason + ) + print(f"[EXECUTOR] ❌ Order blocked: {reason}") + return result + + # 🔥 REAL TRADING WITH FAK PARTIAL FILL TRACKING + target_contracts = contracts + + # Market order pricing: add slippage for guaranteed fill + SLIPPAGE_BUY = 0.05 # 5% above ASK (safety margin, exchange will buy cheaper if possible) + aggressive_price = ask_price * (1 + SLIPPAGE_BUY) + normalized_price = math.ceil(aggressive_price * 100) / 100 # Round UP to 2 decimals + + total_filled_contracts = 0.0 + total_spent_usd = 0.0 + start_time_total = time.time() + + # 🔥 RACE CONDITION PROTECTION #1: ATOMIC CHECK (highest priority!) + # Check blocked_markets for THIS COIN FIRST before any operations + if coin and OrderExecutor.is_market_blocked(market_slug, coin): + print(f"[EXECUTOR] 🛑 BLOCKED (ATOMIC): {coin.upper()} market {market_slug} is blocked!") + + from trade_logger import trades_logger + trades_logger.warning( + f"BUY_BLOCKED_ATOMIC | Market: {market_slug} | Coin: {coin.upper()} | Side: {side} | " + f"Contracts: {contracts} | Reason: Per-coin block active (exit triggered)" + ) + + return OrderResult( + success=False, + error="MARKET_BLOCKED_FOR_COIN", + remaining_balance=0.0 + ) + + print(f"[EXECUTOR] 🎯 BUY TARGET: {target_contracts} {side} @ ${normalized_price:.2f} (ASK ${ask_price:.2f} +{SLIPPAGE_BUY*100:.0f}% slippage, max {MAX_FAK_ATTEMPTS} FAK)") + + # 🔥 RACE CONDITION PROTECTION #2: Callback check (secondary) + if coin and hasattr(self, 'market_closing_check_callback') and self.market_closing_check_callback: + if self.market_closing_check_callback(market_slug, coin): + print(f"[EXECUTOR] 🛑 BLOCKED: {coin.upper()} market {market_slug} is closing (stop-loss/flip-stop triggered)") + + # 📝 LOG: Race condition block + from trade_logger import trades_logger + trades_logger.warning( + f"MARKET_CLOSING_BLOCKED | Market: {market_slug} | Coin: {coin.upper()} | Side: {side} | " + f"Contracts: {contracts} | Reason: Per-coin stop-loss or flip-stop active" + ) + + return OrderResult( + success=False, + error="MARKET_CLOSING_RACE_CONDITION_BLOCKED", + remaining_balance=0.0 + ) + + for fak_attempt in range(1, MAX_FAK_ATTEMPTS + 1): + try: + # 🔥 RACE CONDITION PROTECTION #1: ATOMIC CHECK in FAK loop + if coin and OrderExecutor.is_market_blocked(market_slug, coin): + print(f"[EXECUTOR] 🛑 BLOCKED (ATOMIC in FAK {fak_attempt}): {coin.upper()} market {market_slug}") + break # Exit FAK loop immediately + + # 🔥 RACE CONDITION PROTECTION #2: Callback check + if coin and hasattr(self, 'market_closing_check_callback') and self.market_closing_check_callback: + if self.market_closing_check_callback(market_slug, coin): + print(f"[EXECUTOR] 🛑 BLOCKED (attempt {fak_attempt}): {coin.upper()} market {market_slug} is closing") + + # 📝 LOG: Race condition block during buy loop + from trade_logger import trades_logger + trades_logger.warning( + f"BUY_BLOCKED_DURING_FAK | Market: {market_slug} | Coin: {coin.upper()} | Side: {side} | " + f"FAK Attempt: {fak_attempt}/{MAX_FAK_ATTEMPTS} | Reason: Per-coin market closing" + ) + + break # Exit loop, return what we've accumulated + + # How much more to buy? + remaining_contracts = target_contracts - total_filled_contracts + + # Check: already bought enough? + if remaining_contracts <= 0.01 or total_filled_contracts >= target_contracts * TARGET_FILL_PERCENT: + fill_pct = (total_filled_contracts / target_contracts) * 100 + print(f"[EXECUTOR] ✅ BUY TARGET REACHED: {total_filled_contracts:.2f}/{target_contracts} ({fill_pct:.1f}%)") + break + + # 🚨 CRITICAL: Convert contracts to DOLLARS! + remaining_usd = remaining_contracts * normalized_price + order_size_usd = round(remaining_usd, 2) # Round to 2 decimals! + + # 🚨 Minimum $1.00 + if order_size_usd < MIN_ORDER_USD: + print(f"[EXECUTOR] ⚠ Remaining ${order_size_usd:.2f} < ${MIN_ORDER_USD:.2f} minimum, stopping") + break + + log_buy_attempt(market_slug, side, round(remaining_contracts, 2), normalized_price, fak_attempt, MAX_FAK_ATTEMPTS) + print(f"[EXECUTOR] [FAK {fak_attempt}/{MAX_FAK_ATTEMPTS}] Ordering {round(remaining_contracts, 2)} contracts of {side} @ ${normalized_price:.2f} (=${order_size_usd:.2f})") + + start_time = time.time() + + # Create FAK order (size in DOLLARS!) + order_args = OrderArgs( + price=normalized_price, + size=round(remaining_contracts, 2), # 🚨 IN CONTRACTS! + side=BUY, + token_id=token_id, + ) + + signed_order = self.client.create_order(order_args) + api_result = self.client.post_order(signed_order, OrderType.FAK) + + elapsed_ms = int((time.time() - start_time) * 1000) + + if api_result.get("success"): + # 🚨 READ ACTUAL FILL! + taking_amount = float(api_result.get("takingAmount", 0)) # Contracts received + making_amount = float(api_result.get("makingAmount", 0)) # Dollars spent + order_id = api_result.get("orderID", "N/A") + + # Update counters + total_filled_contracts += taking_amount + total_spent_usd += making_amount + log_buy_result(market_slug, side, target_contracts, total_filled_contracts, target_contracts * normalized_price, total_spent_usd, True, fak_attempts=fak_attempt, elapsed_ms=elapsed_ms) + + fill_pct = (total_filled_contracts / target_contracts) * 100 + print(f"[EXECUTOR] → Filled {taking_amount:.2f} contracts for ${making_amount:.2f} ({elapsed_ms}ms)") + print(f"[EXECUTOR] → Progress: {total_filled_contracts:.2f}/{target_contracts} ({fill_pct:.1f}%)") + + # Write to SafetyGuard + self.safety.record_order( + side=side, + contracts=taking_amount, + price=normalized_price, + market_slug=market_slug, + order_id=order_id + ) + + # Log each FAK attempt separately + partial_result = OrderResult( + success=True, + order_id=order_id, + filled_size=taking_amount, + filled_price=normalized_price, + total_spent_usd=making_amount, + attempts=fak_attempt, + elapsed_ms=elapsed_ms + ) + self._log_order(market_slug, side, taking_amount, normalized_price, partial_result, "BUY", fak_attempt=fak_attempt) + + else: + error_msg = api_result.get("errorMsg", "Unknown") + print(f"[EXECUTOR] ⚠ [FAK {fak_attempt}] FAILED: {error_msg}") + print(f"[EXECUTOR] 🔍 Full API response: {json.dumps(api_result, indent=2)}") + print(f"[EXECUTOR] 📋 Sent OrderArgs: price=${sell_price:.2f}, size={remaining_contracts:.2f} contracts, side=SELL, token={token_id}") + + # Pause before next FAK attempt + if fak_attempt < MAX_FAK_ATTEMPTS: + time.sleep(RETRY_DELAY) + + except Exception as e: + print(f"[EXECUTOR] ❌ [FAK {fak_attempt}] Exception: {e}") + # Log failed attempt + if fak_attempt < MAX_FAK_ATTEMPTS: + time.sleep(RETRY_DELAY) + + # After all FAK attempts - STOP! + elapsed_total_ms = int((time.time() - start_time_total) * 1000) + + if total_filled_contracts > 0: + fill_pct = (total_filled_contracts / target_contracts) * 100 + avg_price = total_spent_usd / total_filled_contracts if total_filled_contracts > 0 else normalized_price + + result = OrderResult( + success=True, + filled_size=total_filled_contracts, + filled_price=avg_price, + total_spent_usd=total_spent_usd, + attempts=fak_attempt, + elapsed_ms=elapsed_total_ms + ) + + if fill_pct >= TARGET_FILL_PERCENT * 100: + print(f"[EXECUTOR] ✅ BUY SUCCESS: {total_filled_contracts:.2f}/{target_contracts} contracts, ${total_spent_usd:.2f}") + else: + print(f"[EXECUTOR] ⚠ PARTIAL BUY: {total_filled_contracts:.2f}/{target_contracts} ({fill_pct:.1f}%), ${total_spent_usd:.2f}") + + # Notify balance change (spent money) + if self.balance_change_callback and not result.dry_run: + try: + self.balance_change_callback(-total_spent_usd, "BUY") + except Exception as e: + print(f"[EXECUTOR] ⚠ Balance callback error: {e}") + + return result + else: + log_buy_result(market_slug, side, target_contracts, total_filled_contracts, target_contracts * normalized_price, total_spent_usd, False, error="NO_FILL_AFTER_FAK", fak_attempts=MAX_FAK_ATTEMPTS) + print(f"[EXECUTOR] ❌ BUY FAILED: No fills after {MAX_FAK_ATTEMPTS} FAK attempts") + return OrderResult( + success=False, + error=f"NO_FILL_AFTER_{MAX_FAK_ATTEMPTS}_FAK", + attempts=fak_attempt + ) + + def sell_position(self, market_slug: str, token_id: str, side: str, + contracts: float, bid_price: float = None) -> OrderResult: + """ + Sell position with FOK CHUNKED strategy + + 🔥 FOK CHUNKED = Split into chunks + Fill-Or-Kill for each + + ✅ STRATEGY: + 1. Request balance from blockchain (once at start) + 2. Split into chunks by CHUNK_SIZE (default: 40 contracts) + 3. Sequentially send each chunk as FOK @ $0.01 + 4. Instant retry on failed (up to MAX_CHUNK_RETRIES attempts) + 5. CHUNK_DELAY pause between successful chunks + 6. Final sweep: check balance and sell remainder + + ✅ ADVANTAGES: + - Simplicity: FOK = all or nothing (no partial fills) + - Reliability: small chunks always pass + - Speed: 164 contracts = ~4.5 seconds + - Predictability: you know what you're sending + + ✅ WORKS FOR ALL 4 COINS (BTC, ETH, SOL, XRP) + ✅ WORKS FOR BOTH SELL TYPES (stop-loss + flip-stop) + + Args: + market_slug: Market slug (any coin) + token_id: Token ID to sell + side: 'UP' or 'DOWN' + contracts: Number of contracts (for reference, re-requested) + bid_price: Current BID price (not used, always $0.01) + + Returns: + OrderResult (success=True if sold ≥99% or dust remains) + """ + # ═══════════════════════════════════════════════════════════ + # 🔥 READ ALL PARAMETERS FROM CONFIG + # ═══════════════════════════════════════════════════════════ + exec_config = self.config.get('execution', {}).get('sell', {}) + + STRATEGY = exec_config.get('strategy', 'FOK_CHUNKED') + CHUNK_SIZE = exec_config.get('chunk_size', 40) + CHUNK_DELAY = exec_config.get('chunk_delay_sec', 0.5) + MAX_CHUNK_RETRIES = exec_config.get('max_chunk_retries', 3) + PRICE = exec_config.get('price', 0.01) + MIN_DUST_THRESHOLD = exec_config.get('min_dust_threshold', 0.1) + SWEEP_MAX_ATTEMPTS = exec_config.get('sweep_max_attempts', 3) + SWEEP_RETRY_DELAY = exec_config.get('sweep_retry_delay_sec', 1.0) + + # Log parameters + print(f"\n[EXECUTOR] {'='*60}") + print(f"[EXECUTOR] 🔥 FOK CHUNKED SELL STARTED") + print(f"[EXECUTOR] {'='*60}") + print(f"[EXECUTOR] Market: {market_slug}") + print(f"[EXECUTOR] Side: {side}") + print(f"[EXECUTOR] Tracked Position: {contracts:.2f} contracts") + print(f"[EXECUTOR] ") + print(f"[EXECUTOR] ⚙️ CONFIG:") + print(f"[EXECUTOR] Strategy: {STRATEGY}") + print(f"[EXECUTOR] Chunk Size: {CHUNK_SIZE} contracts") + print(f"[EXECUTOR] Chunk Delay: {CHUNK_DELAY}s") + print(f"[EXECUTOR] Max Chunk Retries: {MAX_CHUNK_RETRIES}") + print(f"[EXECUTOR] Price: ${PRICE:.2f} (aggressive market order)") + print(f"[EXECUTOR] Dust Threshold: {MIN_DUST_THRESHOLD}") + print(f"[EXECUTOR] {'='*60}\n") + + # ═══════════════════════════════════════════════════════════ + # STEP 1: GET INITIAL BALANCE FROM BLOCKCHAIN + # ═══════════════════════════════════════════════════════════ + print(f"[EXECUTOR] [STEP 1] 📊 Fetching balance from blockchain...") + + initial_balance = self.get_blockchain_token_balance(token_id) + + if initial_balance is None: + error_msg = "RPC_UNAVAILABLE_CANNOT_GET_BALANCE" + print(f"[EXECUTOR] ❌ CRITICAL: Cannot get balance from blockchain!") + self._send_telegram_alert( + f"🚨 SELL FAILED: Cannot get balance!\n" + f"\nMarket: {market_slug}" + f"\nSide: {side}" + f"\nTracked: {contracts:.2f}" + f"\nReason: RPC unavailable" + ) + return OrderResult(success=False, error=error_msg) + + print(f"[EXECUTOR] ✓ Blockchain balance: {initial_balance:.4f} contracts") + + # Check: if balance is already near 0 + if initial_balance < MIN_DUST_THRESHOLD: + print(f"[EXECUTOR] ✓ Balance below dust threshold ({MIN_DUST_THRESHOLD}), nothing to sell") + return OrderResult( + success=True, + filled_size=0.0, + total_spent_usd=0.0, + error="BALANCE_ALREADY_ZERO", + remaining_balance=0.0 + ) + + # ═══════════════════════════════════════════════════════════ + # STEP 2: SPLIT INTO CHUNKS + # ═══════════════════════════════════════════════════════════ + print(f"\n[EXECUTOR] [STEP 2] 🔪 Splitting into chunks...") + + chunks = [] + remaining = initial_balance + chunk_num = 0 + + while remaining > MIN_DUST_THRESHOLD: + chunk_num += 1 + chunk = min(CHUNK_SIZE, remaining) + chunks.append(chunk) + remaining -= chunk + print(f"[EXECUTOR] Chunk #{chunk_num}: {chunk:.2f} contracts") + + print(f"[EXECUTOR] ✓ Total chunks: {len(chunks)}") + print(f"[EXECUTOR] ✓ Total to sell: {sum(chunks):.2f} contracts") + print(f"[EXECUTOR] ✓ Estimated time: {len(chunks) * CHUNK_DELAY:.1f}s") + + # ═══════════════════════════════════════════════════════════ + # STEP 3: SEND EACH CHUNK WITH INSTANT RETRY + # ═══════════════════════════════════════════════════════════ + print(f"\n[EXECUTOR] [STEP 3] 🚀 Sending FOK orders...") + + total_sold = 0.0 + total_received_usd = 0.0 + successful_chunks = 0 + failed_chunks = [] + + start_time = time.time() + + for i, chunk in enumerate(chunks, start=1): + chunk_sold = False + chunk_start = time.time() + + # ════════════════════════════════════════════════════════ + # RETRY LOOP: Instant retry on failed (NO pause!) + # ════════════════════════════════════════════════════════ + for attempt in range(1, MAX_CHUNK_RETRIES + 1): + print(f"\n[EXECUTOR] [FOK {i}/{len(chunks)}] Attempt {attempt}/{MAX_CHUNK_RETRIES}") + print(f"[EXECUTOR] Selling {chunk:.2f} contracts @ ${PRICE:.2f}...") + + # 📝 LOG: Attempt to sell chunk + log_sell_attempt( + market_slug=market_slug, + side=f"{side}_CHUNK_{i}/{len(chunks)}", + contracts=chunk, + price=PRICE, + attempt=attempt, + max_attempts=MAX_CHUNK_RETRIES + ) + + attempt_start = time.time() + + # DRY RUN check + if self.safety.dry_run: + print(f"[EXECUTOR] [FOK {i}] ✓ DRY_RUN: Simulated success") + total_sold += chunk + total_received_usd += chunk * PRICE + successful_chunks += 1 + chunk_sold = True + break + + # Send FOK order + try: + order_args = OrderArgs( + price=PRICE, + size=chunk, + side=SELL, + token_id=token_id, + ) + + signed_order = self.client.create_order(order_args) + api_result = self.client.post_order(signed_order, OrderType.FOK) # 🔥 FOK! + + attempt_elapsed = int((time.time() - attempt_start) * 1000) + + if api_result and api_result.get("success"): + # Check errorMsg (success=true doesn't mean order was filled!) + error_msg = api_result.get("errorMsg", "") + taking_amount = float(api_result.get("takingAmount", 0)) # USD received + making_amount = float(api_result.get("makingAmount", 0)) # Contracts sold + order_id = api_result.get("orderID", "N/A") + + # 🔥 CRITICAL CHECK: FOK_ORDER_NOT_FILLED or amounts = 0 + if error_msg and ("FOK_ORDER_NOT_FILLED" in error_msg or "not filled" in error_msg.lower()): + # FOK couldn't be fully filled - this is FAILURE! + print(f"[EXECUTOR] [FOK {i}] ❌ NOT FILLED (attempt {attempt}): {error_msg}") + if attempt == MAX_CHUNK_RETRIES: + log_sell_result( + market_slug=market_slug, + side=side, + requested_contracts=chunk, + sold_contracts=0.0, + requested_usd=chunk * PRICE, + received_usd=0.0, + success=False, + error=error_msg, + fak_attempts=attempt, + elapsed_ms=attempt_elapsed + ) + # Continue retry loop + + elif taking_amount == 0 or making_amount == 0: + # Amounts = 0 means nothing was sold! + print(f"[EXECUTOR] [FOK {i}] ❌ ZERO FILL (attempt {attempt}): taking={taking_amount}, making={making_amount}") + if attempt == MAX_CHUNK_RETRIES: + log_sell_result( + market_slug=market_slug, + side=side, + requested_contracts=chunk, + sold_contracts=0.0, + requested_usd=chunk * PRICE, + received_usd=0.0, + success=False, + error=f"ZERO_FILL: taking={taking_amount}, making={making_amount}", + fak_attempts=attempt, + elapsed_ms=attempt_elapsed + ) + # Continue retry loop + + else: + # ✅ REAL SUCCESS - there's a fill! + filled = making_amount + received = taking_amount + + total_sold += filled + total_received_usd += received + successful_chunks += 1 + chunk_sold = True + + print(f"[EXECUTOR] [FOK {i}] ✅ SUCCESS (attempt {attempt})!") + print(f"[EXECUTOR] Sold: {filled:.2f} contracts") + print(f"[EXECUTOR] Received: ${received:.2f}") + print(f"[EXECUTOR] Time: {attempt_elapsed}ms") + if error_msg: + print(f"[EXECUTOR] Warning: {error_msg}") + + # Log success + try: + log_sell_result( + market_slug=market_slug, + side=side, + requested_contracts=chunk, + sold_contracts=filled, + requested_usd=chunk * PRICE, + received_usd=received, + success=True, + fak_attempts=attempt, + elapsed_ms=attempt_elapsed + ) + except Exception as log_err: + print(f"[EXECUTOR] ⚠️ Logging error: {log_err}") + + # Notify balance change + if self.balance_change_callback: + try: + self.balance_change_callback(received, "SELL") + except Exception as cb_err: + print(f"[EXECUTOR] ⚠️ Balance callback error: {cb_err}") + + break # ← Exit retry loop, go to next chunk + + else: + # ❌ FAILED → instant retry (NO pause!) + error = api_result.get("errorMsg", "UNKNOWN") if api_result else "NO_API_RESPONSE" + print(f"[EXECUTOR] [FOK {i}] ❌ FAILED (attempt {attempt}): {error}") + + if attempt == MAX_CHUNK_RETRIES: + # Last attempt - log it + try: + log_sell_result( + market_slug=market_slug, + side=side, + requested_contracts=chunk, + sold_contracts=0.0, + requested_usd=chunk * PRICE, + received_usd=0.0, + success=False, + error=error, + fak_attempts=attempt, + elapsed_ms=attempt_elapsed + ) + except Exception as log_err: + print(f"[EXECUTOR] ⚠️ Logging error: {log_err}") + # NO time.sleep() - immediately next attempt! + + except Exception as e: + print(f"[EXECUTOR] [FOK {i}] ❌ EXCEPTION (attempt {attempt}): {e}") + if attempt == MAX_CHUNK_RETRIES: + chunk_sold = False + + # If not sold after all attempts + if not chunk_sold: + chunk_elapsed = int((time.time() - chunk_start) * 1000) + print(f"[EXECUTOR] [FOK {i}] ⚠️ FAILED after {MAX_CHUNK_RETRIES} attempts ({chunk_elapsed}ms)") + failed_chunks.append({'chunk': i, 'size': chunk, 'attempts': MAX_CHUNK_RETRIES}) + + # ════════════════════════════════════════════════════════ + # PAUSE BEFORE NEXT CHUNK + # (only if this is not the last chunk) + # ════════════════════════════════════════════════════════ + if i < len(chunks): + print(f"[EXECUTOR] [FOK {i}] Waiting {CHUNK_DELAY}s before next chunk...") + time.sleep(CHUNK_DELAY) + + total_elapsed = time.time() - start_time + + print(f"\n[EXECUTOR] Chunks completed in {total_elapsed:.1f}s") + print(f"[EXECUTOR] Successful: {successful_chunks}/{len(chunks)}") + print(f"[EXECUTOR] Failed: {len(failed_chunks)}") + + # ═══════════════════════════════════════════════════════════ + # STEP 4: FINAL BALANCE CHECK + # ═══════════════════════════════════════════════════════════ + print(f"\n[EXECUTOR] [STEP 4] 🔍 Final balance check...") + + final_balance = self.get_blockchain_token_balance(token_id) + + if final_balance is None: + print(f"[EXECUTOR] ⚠️ WARNING: Cannot verify final balance (RPC error)") + final_balance = initial_balance - total_sold # Estimate + + print(f"[EXECUTOR] ✓ Final balance: {final_balance:.4f} contracts") + + # ═══════════════════════════════════════════════════════════ + # STEP 4.5: FINAL SWEEP (if balance remains) + # ═══════════════════════════════════════════════════════════ + if final_balance > MIN_DUST_THRESHOLD: + print(f"\n[EXECUTOR] [STEP 4.5] 🧹 FINAL SWEEP REQUIRED") + print(f"[EXECUTOR] ⚠️ Remaining balance: {final_balance:.2f} contracts") + print(f"[EXECUTOR] Attempting to sell remainder...") + + sweep_success = False + + for sweep_attempt in range(1, SWEEP_MAX_ATTEMPTS + 1): + sweep_start = time.time() + + print(f"\n[EXECUTOR] [SWEEP {sweep_attempt}/{SWEEP_MAX_ATTEMPTS}] Selling {final_balance:.2f} @ ${PRICE:.2f}...") + + # 📝 LOG: Sweep attempt + log_sell_attempt( + market_slug=market_slug, + side=f"{side}_SWEEP", + contracts=final_balance, + price=PRICE, + attempt=sweep_attempt, + max_attempts=SWEEP_MAX_ATTEMPTS + ) + + # DRY RUN check + if self.safety.dry_run: + print(f"[EXECUTOR] [SWEEP {sweep_attempt}] ✓ DRY_RUN: Simulated success") + total_sold += final_balance + total_received_usd += final_balance * PRICE + sweep_success = True + final_balance = 0.0 + break + + # Send FOK for sweep + try: + order_args = OrderArgs( + price=PRICE, + size=final_balance, + side=SELL, + token_id=token_id, + ) + + signed_order = self.client.create_order(order_args) + api_result = self.client.post_order(signed_order, OrderType.FOK) + + sweep_elapsed = int((time.time() - sweep_start) * 1000) + + # 🔥 DEBUG: Log full API response + print(f"[EXECUTOR] [SWEEP {sweep_attempt}] API Response:") + print(f"[EXECUTOR] Raw: {api_result}") + + if api_result and api_result.get("success"): + # Check errorMsg and amounts + error_msg = api_result.get("errorMsg", "") + taking_amount = float(api_result.get("takingAmount", 0)) + making_amount = float(api_result.get("makingAmount", 0)) + sweep_balance_before = final_balance # Save for logging + + # 🔥 CRITICAL CHECK: FOK_ORDER_NOT_FILLED or amounts = 0 + if error_msg and ("FOK_ORDER_NOT_FILLED" in error_msg or "not filled" in error_msg.lower()): + # FOK couldn't be filled + print(f"[EXECUTOR] [SWEEP {sweep_attempt}] ❌ NOT FILLED: {error_msg}") + try: + log_sell_result( + market_slug=market_slug, + side=side, + requested_contracts=sweep_balance_before, + sold_contracts=0.0, + requested_usd=sweep_balance_before * PRICE, + received_usd=0.0, + success=False, + error=error_msg, + fak_attempts=sweep_attempt, + elapsed_ms=sweep_elapsed + ) + except Exception as log_err: + print(f"[EXECUTOR] ⚠️ Logging error: {log_err}") + # Continue retry loop + + elif taking_amount == 0 or making_amount == 0: + # Amounts = 0 means nothing was sold + print(f"[EXECUTOR] [SWEEP {sweep_attempt}] ❌ ZERO FILL: taking={taking_amount}, making={making_amount}") + try: + log_sell_result( + market_slug=market_slug, + side=side, + requested_contracts=sweep_balance_before, + sold_contracts=0.0, + requested_usd=sweep_balance_before * PRICE, + received_usd=0.0, + success=False, + error=f"ZERO_FILL: taking={taking_amount}, making={making_amount}", + fak_attempts=sweep_attempt, + elapsed_ms=sweep_elapsed + ) + except Exception as log_err: + print(f"[EXECUTOR] ⚠️ Logging error: {log_err}") + # Continue retry loop + + else: + # ✅ REAL SUCCESS + filled = making_amount + received = taking_amount + + total_sold += filled + total_received_usd += received + sweep_success = True + + print(f"[EXECUTOR] [SWEEP {sweep_attempt}] ✅ SUCCESS!") + print(f"[EXECUTOR] Sold: {filled:.2f} contracts") + print(f"[EXECUTOR] Received: ${received:.2f}") + print(f"[EXECUTOR] Time: {sweep_elapsed}ms") + if error_msg: + print(f"[EXECUTOR] Warning: {error_msg}") + + # Log success + try: + log_sell_result( + market_slug=market_slug, + side=side, + requested_contracts=sweep_balance_before, + sold_contracts=filled, + requested_usd=sweep_balance_before * PRICE, + received_usd=received, + success=True, + fak_attempts=sweep_attempt, + elapsed_ms=sweep_elapsed + ) + except Exception as log_err: + print(f"[EXECUTOR] ⚠️ Logging error: {log_err}") + + # Re-check balance + final_balance = self.get_blockchain_token_balance(token_id) + if final_balance is None: + final_balance = 0.0 # Assume success + + if final_balance < MIN_DUST_THRESHOLD: + print(f"[EXECUTOR] ✅ All sold! (remaining dust: {final_balance:.4f})") + break + else: + print(f"[EXECUTOR] ⚠️ Still remaining: {final_balance:.2f}, will retry...") + + else: + error = api_result.get("errorMsg", "UNKNOWN") if api_result else "NO_API_RESPONSE" + print(f"[EXECUTOR] [SWEEP {sweep_attempt}] ❌ FAILED: {error}") + + # Log failure + try: + log_sell_result( + market_slug=market_slug, + side=side, + requested_contracts=final_balance, + sold_contracts=0.0, + requested_usd=final_balance * PRICE, + received_usd=0.0, + success=False, + error=error, + fak_attempts=sweep_attempt, + elapsed_ms=sweep_elapsed + ) + except Exception as log_err: + print(f"[EXECUTOR] ⚠️ Logging error: {log_err}") + + except Exception as e: + print(f"[EXECUTOR] [SWEEP {sweep_attempt}] ❌ EXCEPTION: {e}") + + # Retry delay (except last attempt) + if sweep_attempt < SWEEP_MAX_ATTEMPTS and not sweep_success: + print(f"[EXECUTOR] Waiting {SWEEP_RETRY_DELAY}s before retry...") + time.sleep(SWEEP_RETRY_DELAY) + + # Re-check balance before next attempt + final_balance = self.get_blockchain_token_balance(token_id) + if final_balance is None or final_balance < MIN_DUST_THRESHOLD: + print(f"[EXECUTOR] Balance cleared or unavailable, stopping sweep") + break + + # Final check after sweep + final_balance = self.get_blockchain_token_balance(token_id) + if final_balance is None: + final_balance = 0.0 # Assume cleared + + print(f"\n[EXECUTOR] Sweep completed:") + print(f"[EXECUTOR] Success: {sweep_success}") + print(f"[EXECUTOR] Final balance: {final_balance:.4f}") + + # ═══════════════════════════════════════════════════════════ + # 🔥 FIX 3: SWEEP FALLBACK (FOK → FAK → Market) + # If FOK didn't pass, try FAK and Market order + # ═══════════════════════════════════════════════════════════ + SWEEP_ENABLE_FALLBACK = exec_config.get('sweep_enable_fallback', False) + SWEEP_FAK_ATTEMPTS = exec_config.get('sweep_fak_attempts', 2) + SWEEP_MARKET_PRICE = exec_config.get('sweep_market_price', 0.01) + + if SWEEP_ENABLE_FALLBACK and not sweep_success and final_balance > MIN_DUST_THRESHOLD: + print(f"\n[EXECUTOR] [STEP 4.6] 🔄 SWEEP FALLBACK ACTIVATED") + print(f"[EXECUTOR] FOK failed, trying FAK → Market order") + + # ───────────────────────────────────────────────────── + # FALLBACK #1: FAK (Fill-And-Kill) + # ───────────────────────────────────────────────────── + print(f"\n[EXECUTOR] [FALLBACK FAK] Attempting FAK orders...") + + for fak_attempt in range(1, SWEEP_FAK_ATTEMPTS + 1): + if final_balance < MIN_DUST_THRESHOLD: + break + + fak_start = time.time() + print(f"\n[EXECUTOR] [FAK {fak_attempt}/{SWEEP_FAK_ATTEMPTS}] Selling {final_balance:.2f} @ ${PRICE:.2f}...") + + # 📝 LOG: FAK attempt + log_sell_attempt( + market_slug=market_slug, + side=f"{side}_SWEEP_FAK", + contracts=final_balance, + price=PRICE, + attempt=fak_attempt, + max_attempts=SWEEP_FAK_ATTEMPTS + ) + + # DRY RUN check + if self.safety.dry_run: + print(f"[EXECUTOR] [FAK {fak_attempt}] ✓ DRY_RUN: Simulated success") + total_sold += final_balance + total_received_usd += final_balance * PRICE + final_balance = 0.0 + break + + # Send FAK order + try: + order_args = OrderArgs( + price=PRICE, + size=final_balance, + side=SELL, + token_id=token_id, + ) + + signed_order = self.client.create_order(order_args) + api_result = self.client.post_order(signed_order, OrderType.FAK) # 🔥 FAK! + + fak_elapsed = int((time.time() - fak_start) * 1000) + + # 🔥 DEBUG: Log full API response + print(f"[EXECUTOR] [FAK {fak_attempt}] API Response:") + print(f"[EXECUTOR] Raw: {api_result}") + + if api_result and api_result.get("success"): + taking_amount = float(api_result.get("takingAmount", 0)) + making_amount = float(api_result.get("makingAmount", 0)) + + if taking_amount > 0 and making_amount > 0: + # ✅ Partial or full sale + filled = making_amount + received = taking_amount + + total_sold += filled + total_received_usd += received + + print(f"[EXECUTOR] [FAK {fak_attempt}] ✅ SUCCESS!") + print(f"[EXECUTOR] Sold: {filled:.2f} contracts") + print(f"[EXECUTOR] Received: ${received:.2f}") + print(f"[EXECUTOR] Time: {fak_elapsed}ms") + + # Log success + try: + log_sell_result( + market_slug=market_slug, + side=side, + requested_contracts=final_balance, + sold_contracts=filled, + requested_usd=final_balance * PRICE, + received_usd=received, + success=True, + fak_attempts=fak_attempt, + elapsed_ms=fak_elapsed + ) + except Exception as log_err: + print(f"[EXECUTOR] ⚠️ Logging error: {log_err}") + + # Re-check balance + final_balance = self.get_blockchain_token_balance(token_id) + if final_balance is None or final_balance < MIN_DUST_THRESHOLD: + final_balance = 0.0 + break + else: + # ❌ Not sold + print(f"[EXECUTOR] [FAK {fak_attempt}] ❌ NO FILL") + else: + error = api_result.get("errorMsg", "UNKNOWN") if api_result else "NO_API_RESPONSE" + print(f"[EXECUTOR] [FAK {fak_attempt}] ❌ FAILED: {error}") + + except Exception as e: + print(f"[EXECUTOR] [FAK {fak_attempt}] ❌ EXCEPTION: {e}") + + # Delay before next attempt + if fak_attempt < SWEEP_FAK_ATTEMPTS and final_balance > MIN_DUST_THRESHOLD: + time.sleep(SWEEP_RETRY_DELAY) + + # ───────────────────────────────────────────────────── + # FALLBACK #2: MARKET ORDER (GTC - Good Till Cancelled) + # Guaranteed sale at any price + # ───────────────────────────────────────────────────── + if final_balance > MIN_DUST_THRESHOLD: + print(f"\n[EXECUTOR] [FALLBACK MARKET] FAK failed, trying Market order...") + print(f"[EXECUTOR] ⚠️ WARNING: Market order may have high slippage!") + + market_start = time.time() + print(f"\n[EXECUTOR] [MARKET] Selling {final_balance:.2f} @ ${SWEEP_MARKET_PRICE:.2f}...") + + # 📝 LOG: Market order attempt + log_sell_attempt( + market_slug=market_slug, + side=f"{side}_SWEEP_MARKET", + contracts=final_balance, + price=SWEEP_MARKET_PRICE, + attempt=1, + max_attempts=1 + ) + + # DRY RUN check + if self.safety.dry_run: + print(f"[EXECUTOR] [MARKET] ✓ DRY_RUN: Simulated success") + total_sold += final_balance + total_received_usd += final_balance * SWEEP_MARKET_PRICE + final_balance = 0.0 + else: + # Send Market order (GTC) + try: + order_args = OrderArgs( + price=SWEEP_MARKET_PRICE, + size=final_balance, + side=SELL, + token_id=token_id, + ) + + signed_order = self.client.create_order(order_args) + api_result = self.client.post_order(signed_order, OrderType.GTC) # 🔥 GTC = Market! + + market_elapsed = int((time.time() - market_start) * 1000) + + # 🔥 DEBUG: Log full API response + print(f"[EXECUTOR] [MARKET] API Response:") + print(f"[EXECUTOR] Raw: {api_result}") + + if api_result and api_result.get("success"): + taking_amount = float(api_result.get("takingAmount", 0)) + making_amount = float(api_result.get("makingAmount", 0)) + + if taking_amount > 0 and making_amount > 0: + # ✅ SUCCESS + filled = making_amount + received = taking_amount + + total_sold += filled + total_received_usd += received + + print(f"[EXECUTOR] [MARKET] ✅ SUCCESS!") + print(f"[EXECUTOR] Sold: {filled:.2f} contracts") + print(f"[EXECUTOR] Received: ${received:.2f}") + print(f"[EXECUTOR] Actual price: ${received/filled:.4f}") + print(f"[EXECUTOR] Time: {market_elapsed}ms") + + # Log success + try: + log_sell_result( + market_slug=market_slug, + side=side, + requested_contracts=final_balance, + sold_contracts=filled, + requested_usd=final_balance * SWEEP_MARKET_PRICE, + received_usd=received, + success=True, + fak_attempts=1, + elapsed_ms=market_elapsed + ) + except Exception as log_err: + print(f"[EXECUTOR] ⚠️ Logging error: {log_err}") + + # Final balance check + final_balance = self.get_blockchain_token_balance(token_id) + if final_balance is None: + final_balance = 0.0 + else: + print(f"[EXECUTOR] [MARKET] ❌ NO FILL") + else: + error = api_result.get("errorMsg", "UNKNOWN") if api_result else "NO_API_RESPONSE" + print(f"[EXECUTOR] [MARKET] ❌ FAILED: {error}") + + except Exception as e: + print(f"[EXECUTOR] [MARKET] ❌ EXCEPTION: {e}") + + print(f"\n[EXECUTOR] Fallback completed:") + print(f"[EXECUTOR] Final balance: {final_balance:.4f}") + + # ═══════════════════════════════════════════════════════════ + # 🔥 DELAYED FINAL SWEEP (catch in-flight buys from race conditions) + # NOTE: Reporting moved AFTER delayed sweep for correct data! + # ═══════════════════════════════════════════════════════════ + DELAYED_SWEEP_ENABLED = exec_config.get('delayed_sweep_enabled', True) + DELAYED_SWEEP_DELAY = exec_config.get('delayed_sweep_delay_sec', 5) + DELAYED_SWEEP_MIN_BALANCE = exec_config.get('delayed_sweep_min_balance', 0.1) + DELAYED_SWEEP_FOK_ATTEMPTS = exec_config.get('delayed_sweep_fok_attempts', 3) + DELAYED_SWEEP_FAK_ATTEMPTS = exec_config.get('delayed_sweep_fak_attempts', 2) + DELAYED_SWEEP_RETRY_DELAY = exec_config.get('delayed_sweep_retry_delay_sec', 1.0) + + if DELAYED_SWEEP_ENABLED: + print(f"\n[EXECUTOR] {'='*60}") + print(f"[EXECUTOR] [DELAYED SWEEP] STAGE 1: WAIT FOR BLOCKCHAIN") + print(f"[EXECUTOR] {'='*60}") + print(f"[EXECUTOR] [DELAYED SWEEP] Current balance (before wait): {final_balance:.4f}") + print(f"[EXECUTOR] [DELAYED SWEEP] ⏰ Waiting {DELAYED_SWEEP_DELAY}s for in-flight purchases...") + print(f"[EXECUTOR] [DELAYED SWEEP] (Catching race conditions with blockchain)") + time.sleep(DELAYED_SWEEP_DELAY) + + # Re-fetch balance from blockchain + print(f"\n[EXECUTOR] [DELAYED SWEEP] STAGE 2: RE-FETCH BALANCE") + print(f"[EXECUTOR] [DELAYED SWEEP] 🔄 Fetching REAL balance from blockchain...") + delayed_balance = self.get_blockchain_token_balance(token_id) + print(f"[EXECUTOR] [DELAYED SWEEP] Balance after re-fetch: {delayed_balance if delayed_balance is not None else 'ERROR'}...") + + if delayed_balance is None: + print(f"[EXECUTOR] [DELAYED SWEEP] ⚠️ Cannot fetch balance, skipping delayed sweep") + elif delayed_balance > DELAYED_SWEEP_MIN_BALANCE: + print(f"[EXECUTOR] [DELAYED SWEEP] 🔥 FOUND IN-FLIGHT PURCHASES!") + print(f"[EXECUTOR] [DELAYED SWEEP] Balance: {delayed_balance:.2f} contracts") + print(f"[EXECUTOR] [DELAYED SWEEP] (These appeared AFTER initial sale started)") + print(f"\n[EXECUTOR] [DELAYED SWEEP] 🧹 Starting cascade sale (FOK → FAK → Market)...") + + delayed_sold = 0.0 + delayed_received = 0.0 + delayed_success = False + + # ───────────────────────────────────────────────────── + # DELAYED SWEEP #1: FOK attempts + # ───────────────────────────────────────────────────── + print(f"\n[EXECUTOR] [DELAYED FOK] Attempting FOK orders...") + + for fok_attempt in range(1, DELAYED_SWEEP_FOK_ATTEMPTS + 1): + if delayed_balance < DELAYED_SWEEP_MIN_BALANCE: + break + + fok_start = time.time() + print(f"\n[EXECUTOR] [DELAYED FOK {fok_attempt}/{DELAYED_SWEEP_FOK_ATTEMPTS}] Selling {delayed_balance:.2f} @ ${PRICE:.2f}...") + + log_sell_attempt( + market_slug=market_slug, + side=f"{side}_DELAYED_FOK", + contracts=delayed_balance, + price=PRICE, + attempt=fok_attempt, + max_attempts=DELAYED_SWEEP_FOK_ATTEMPTS + ) + + if self.safety.dry_run: + print(f"[EXECUTOR] [DELAYED FOK {fok_attempt}] ✓ DRY_RUN success") + delayed_sold += delayed_balance + delayed_received += delayed_balance * PRICE + delayed_balance = 0.0 + delayed_success = True + break + + try: + order_args = OrderArgs( + price=PRICE, + size=delayed_balance, + side=SELL, + token_id=token_id, + ) + + signed_order = self.client.create_order(order_args) + api_result = self.client.post_order(signed_order, OrderType.FOK) + + fok_elapsed = int((time.time() - fok_start) * 1000) + + if api_result and api_result.get("success"): + error_msg = api_result.get("errorMsg", "") + taking_amount = float(api_result.get("takingAmount", 0)) + making_amount = float(api_result.get("makingAmount", 0)) + + if error_msg and ("FOK_ORDER_NOT_FILLED" in error_msg or "not filled" in error_msg.lower()): + print(f"[EXECUTOR] [DELAYED FOK {fok_attempt}] ❌ NOT FILLED") + elif taking_amount == 0 or making_amount == 0: + print(f"[EXECUTOR] [DELAYED FOK {fok_attempt}] ❌ ZERO FILL") + else: + # ✅ SUCCESS! + filled = making_amount + received = taking_amount + + delayed_sold += filled + delayed_received += received + delayed_success = True + + print(f"[EXECUTOR] [DELAYED FOK {fok_attempt}] ✅ SUCCESS!") + print(f"[EXECUTOR] Sold: {filled:.2f} contracts") + print(f"[EXECUTOR] Received: ${received:.2f}") + + log_sell_result( + market_slug=market_slug, + side=side, + requested_contracts=delayed_balance, + sold_contracts=filled, + requested_usd=delayed_balance * PRICE, + received_usd=received, + success=True, + fak_attempts=fok_attempt, + elapsed_ms=fok_elapsed + ) + + # Re-check balance + delayed_balance = self.get_blockchain_token_balance(token_id) + if delayed_balance is None or delayed_balance < DELAYED_SWEEP_MIN_BALANCE: + delayed_balance = 0.0 + break + + except Exception as e: + print(f"[EXECUTOR] [DELAYED FOK {fok_attempt}] ❌ EXCEPTION: {e}") + + if fok_attempt < DELAYED_SWEEP_FOK_ATTEMPTS and delayed_balance > DELAYED_SWEEP_MIN_BALANCE: + time.sleep(DELAYED_SWEEP_RETRY_DELAY) + + # ───────────────────────────────────────────────────── + # DELAYED SWEEP #2: FAK attempts (if FOK failed) + # ───────────────────────────────────────────────────── + if not delayed_success and delayed_balance > DELAYED_SWEEP_MIN_BALANCE: + print(f"\n[EXECUTOR] [DELAYED FAK] FOK failed, trying FAK orders...") + + for fak_attempt in range(1, DELAYED_SWEEP_FAK_ATTEMPTS + 1): + if delayed_balance < DELAYED_SWEEP_MIN_BALANCE: + break + + fak_start = time.time() + print(f"\n[EXECUTOR] [DELAYED FAK {fak_attempt}/{DELAYED_SWEEP_FAK_ATTEMPTS}] Selling {delayed_balance:.2f} @ ${PRICE:.2f}...") + + log_sell_attempt( + market_slug=market_slug, + side=f"{side}_DELAYED_FAK", + contracts=delayed_balance, + price=PRICE, + attempt=fak_attempt, + max_attempts=DELAYED_SWEEP_FAK_ATTEMPTS + ) + + if self.safety.dry_run: + print(f"[EXECUTOR] [DELAYED FAK {fak_attempt}] ✓ DRY_RUN success") + delayed_sold += delayed_balance + delayed_received += delayed_balance * PRICE + delayed_balance = 0.0 + delayed_success = True + break + + try: + order_args = OrderArgs( + price=PRICE, + size=delayed_balance, + side=SELL, + token_id=token_id, + ) + + signed_order = self.client.create_order(order_args) + api_result = self.client.post_order(signed_order, OrderType.FAK) + + fak_elapsed = int((time.time() - fak_start) * 1000) + + if api_result and api_result.get("success"): + taking_amount = float(api_result.get("takingAmount", 0)) + making_amount = float(api_result.get("makingAmount", 0)) + + if taking_amount > 0 and making_amount > 0: + # ✅ Partial or full fill + filled = making_amount + received = taking_amount + + delayed_sold += filled + delayed_received += received + delayed_success = True + + print(f"[EXECUTOR] [DELAYED FAK {fak_attempt}] ✅ SUCCESS!") + print(f"[EXECUTOR] Sold: {filled:.2f} contracts") + print(f"[EXECUTOR] Received: ${received:.2f}") + + log_sell_result( + market_slug=market_slug, + side=side, + requested_contracts=delayed_balance, + sold_contracts=filled, + requested_usd=delayed_balance * PRICE, + received_usd=received, + success=True, + fak_attempts=fak_attempt, + elapsed_ms=fak_elapsed + ) + + # Re-check balance + delayed_balance = self.get_blockchain_token_balance(token_id) + if delayed_balance is None or delayed_balance < DELAYED_SWEEP_MIN_BALANCE: + delayed_balance = 0.0 + break + else: + print(f"[EXECUTOR] [DELAYED FAK {fak_attempt}] ❌ NO FILL") + + except Exception as e: + print(f"[EXECUTOR] [DELAYED FAK {fak_attempt}] ❌ EXCEPTION: {e}") + + if fak_attempt < DELAYED_SWEEP_FAK_ATTEMPTS and delayed_balance > DELAYED_SWEEP_MIN_BALANCE: + time.sleep(DELAYED_SWEEP_RETRY_DELAY) + + # ───────────────────────────────────────────────────── + # DELAYED SWEEP #3: Market order (if FAK failed) + # ───────────────────────────────────────────────────── + if not delayed_success and delayed_balance > DELAYED_SWEEP_MIN_BALANCE: + print(f"\n[EXECUTOR] [DELAYED MARKET] FAK failed, trying Market order...") + print(f"[EXECUTOR] [DELAYED MARKET] ⚠️ WARNING: May have slippage") + + market_start = time.time() + print(f"\n[EXECUTOR] [DELAYED MARKET] Selling {delayed_balance:.2f} @ ${PRICE:.2f}...") + + log_sell_attempt( + market_slug=market_slug, + side=f"{side}_DELAYED_MARKET", + contracts=delayed_balance, + price=PRICE, + attempt=1, + max_attempts=1 + ) + + if self.safety.dry_run: + print(f"[EXECUTOR] [DELAYED MARKET] ✓ DRY_RUN success") + delayed_sold += delayed_balance + delayed_received += delayed_balance * PRICE + delayed_balance = 0.0 + delayed_success = True + else: + try: + order_args = OrderArgs( + price=PRICE, + size=delayed_balance, + side=SELL, + token_id=token_id, + ) + + signed_order = self.client.create_order(order_args) + api_result = self.client.post_order(signed_order, OrderType.GTC) + + market_elapsed = int((time.time() - market_start) * 1000) + + if api_result and api_result.get("success"): + taking_amount = float(api_result.get("takingAmount", 0)) + making_amount = float(api_result.get("makingAmount", 0)) + + if taking_amount > 0 and making_amount > 0: + filled = making_amount + received = taking_amount + + delayed_sold += filled + delayed_received += received + delayed_success = True + + print(f"[EXECUTOR] [DELAYED MARKET] ✅ SUCCESS!") + print(f"[EXECUTOR] Sold: {filled:.2f} contracts") + print(f"[EXECUTOR] Received: ${received:.2f}") + + log_sell_result( + market_slug=market_slug, + side=side, + requested_contracts=delayed_balance, + sold_contracts=filled, + requested_usd=delayed_balance * PRICE, + received_usd=received, + success=True, + fak_attempts=1, + elapsed_ms=market_elapsed + ) + + # Final balance check + delayed_balance = self.get_blockchain_token_balance(token_id) + if delayed_balance is None: + delayed_balance = 0.0 + else: + print(f"[EXECUTOR] [DELAYED MARKET] ❌ NO FILL") + + except Exception as e: + print(f"[EXECUTOR] [DELAYED MARKET] ❌ EXCEPTION: {e}") + + # Update totals with delayed sweep results + total_sold += delayed_sold + total_received_usd += delayed_received + final_balance = delayed_balance + + print(f"\n[EXECUTOR] {'='*60}") + print(f"[EXECUTOR] [DELAYED SWEEP] STAGE 3: RESULTS") + print(f"[EXECUTOR] {'='*60}") + print(f"[EXECUTOR] [DELAYED SWEEP] Additional Sold: {delayed_sold:.2f} contracts") + print(f"[EXECUTOR] [DELAYED SWEEP] Additional Received: ${delayed_received:.2f}") + print(f"[EXECUTOR] [DELAYED SWEEP] Final Balance: {final_balance:.4f}") + print(f"[EXECUTOR] [DELAYED SWEEP] Success: {delayed_success}") + print(f"[EXECUTOR] {'='*60}") + + if delayed_sold > 0: + print(f"\n[EXECUTOR] ✅ Delayed sweep caught in-flight purchases!") + print(f"[EXECUTOR] This proves the race condition fix is working!") + else: + print(f"\n[EXECUTOR] {'='*60}") + print(f"[EXECUTOR] [DELAYED SWEEP] STAGE 3: RESULTS") + print(f"[EXECUTOR] {'='*60}") + print(f"[EXECUTOR] [DELAYED SWEEP] ✓ No additional balance found") + print(f"[EXECUTOR] [DELAYED SWEEP] Balance: {delayed_balance:.4f} (below threshold {DELAYED_SWEEP_MIN_BALANCE})") + print(f"[EXECUTOR] [DELAYED SWEEP] No in-flight purchases detected") + print(f"[EXECUTOR] {'='*60}") + final_balance = delayed_balance + + # ═══════════════════════════════════════════════════════════ + # STEP 5: FINAL REPORT (AFTER DELAYED SWEEP!) + # ═══════════════════════════════════════════════════════════ + total_elapsed = time.time() - start_time + + # 📝 LOG: Summary of FOK CHUNKED sell (with FINAL balance after delayed sweep) + from trade_logger import trades_logger + trades_logger.info( + f"FOK_CHUNKED_COMPLETE | Market: {market_slug} | Side: {side} | " + f"Initial: {initial_balance:.2f} | Sold: {total_sold:.2f} ({total_sold/initial_balance*100:.1f}%) | " + f"Remaining: {final_balance:.2f} | Chunks: {successful_chunks}/{len(chunks)} | " + f"Failed: {len(failed_chunks)} | Received: ${total_received_usd:.2f} | " + f"Time: {total_elapsed:.1f}s" + ) + + print(f"\n[EXECUTOR] {'='*60}") + print(f"[EXECUTOR] 📊 FOK CHUNKED SELL COMPLETED (FINAL REPORT)") + print(f"[EXECUTOR] {'='*60}") + print(f"[EXECUTOR] Initial Balance: {initial_balance:.2f}") + print(f"[EXECUTOR] Total Sold: {total_sold:.2f} ({total_sold/initial_balance*100:.1f}%)") + print(f"[EXECUTOR] Final Balance: {final_balance:.2f}") + print(f"[EXECUTOR] ") + print(f"[EXECUTOR] Successful Chunks: {successful_chunks}/{len(chunks)}") + print(f"[EXECUTOR] Failed Chunks: {len(failed_chunks)}") + print(f"[EXECUTOR] ") + print(f"[EXECUTOR] Total Received: ${total_received_usd:.2f}") + if total_sold > 0: + print(f"[EXECUTOR] Avg Price: ${total_received_usd/total_sold:.4f}") + print(f"[EXECUTOR] Total Time: {total_elapsed:.1f}s") + print(f"[EXECUTOR] {'='*60}\n") + + # Check: did significant balance remain? (FINAL check!) + if final_balance > MIN_DUST_THRESHOLD: + warning_msg = ( + f"⚠️ WARNING: Significant balance remains!\n" + f"\n🔥 AFTER DELAYED SWEEP (5s delay + retries)" + f"\nMarket: {market_slug}" + f"\nSide: {side}" + f"\nInitial: {initial_balance:.2f}" + f"\nSold: {total_sold:.2f} ({total_sold/initial_balance*100:.1f}%)" + f"\nRemaining: {final_balance:.2f} ({final_balance/initial_balance*100:.1f}%)" + f"\nReceived: ${total_received_usd:.2f}" + f"\n" + f"\nFailed chunks: {len(failed_chunks)}" + ) + + if failed_chunks: + warning_msg += "\n\nFailed details:" + for fc in failed_chunks[:3]: # Show first 3 + warning_msg += f"\n • Chunk {fc['chunk']}: {fc['size']:.2f} (attempts: {fc.get('attempts', '?')})" + + print(f"[EXECUTOR] ⚠️ Sending Telegram alert for FINAL remaining balance...") + self._send_telegram_alert(warning_msg) + + # Success = False if >10% remains + success = (final_balance / initial_balance) < 0.1 + else: + print(f"[EXECUTOR] ✅ SUCCESS: All sold (remaining = dust)") + success = True + + avg_price = total_received_usd / total_sold if total_sold > 0 else 0.0 + + # 🔥 FIX 4: Final logging of remaining balance for redeem + if final_balance > MIN_DUST_THRESHOLD: + print(f"\n[EXECUTOR] ⚠️ WARNING: Remaining balance detected!") + print(f"[EXECUTOR] Token: {token_id}") + print(f"[EXECUTOR] Balance: {final_balance:.4f} contracts") + print(f"[EXECUTOR] Market: {market_slug}") + print(f"[EXECUTOR] This market should be added to pending_markets for redeem!") + + return OrderResult( + success=success, + filled_size=total_sold, + filled_price=avg_price, + total_spent_usd=total_received_usd, + attempts=len(chunks), + error=f"REMAINING_{final_balance:.2f}" if final_balance > MIN_DUST_THRESHOLD else None, + elapsed_ms=int(total_elapsed * 1000), + remaining_balance=final_balance # 🔥 FIX 4: Return final balance + ) + + def _send_telegram_alert(self, message: str): + """ + Send critical notification to Telegram + """ + print(f"[EXECUTOR] [TELEGRAM] {message[:100]}...") # Debug + try: + token = os.getenv("TELEGRAM_BOT_TOKEN") + chat_id = os.getenv("TELEGRAM_CHAT_ID") + + if not token or not chat_id: + return + + url = f"https://api.telegram.org/bot{token}/sendMessage" + payload = { + "chat_id": chat_id, + "text": message + } + requests.post(url, json=payload, timeout=5) + except Exception as e: + print(f"[EXECUTOR] ⚠️ Telegram alert failed: {e}") + + def _log_order(self, market_slug: str, side: str, contracts: float, + price: float, result: OrderResult, order_type: str, fak_attempt: int = 1): + """Write order to log (each FAK attempt separately)""" + log_entry = { + 'timestamp': time.time(), + 'datetime': time.strftime('%Y-%m-%d %H:%M:%S'), + 'market_slug': market_slug, + 'side': order_type, + 'order_type': order_type, + 'fak_attempt': fak_attempt, + 'contracts': contracts, + 'price': price, + 'size_usd': contracts * price if contracts and price else 0, + 'total_spent_usd': result.total_spent_usd, + 'success': result.success, + 'order_id': result.order_id, + 'error': result.error, + 'dry_run': result.dry_run, + 'elapsed_ms': result.elapsed_ms, + 'attempts_total': result.attempts + } + + orders_log_path = Path(self.config.get('logging', {}).get('orders_file', 'logs/orders.jsonl')) + os.makedirs(orders_log_path.parent, exist_ok=True) + + with open(orders_log_path, 'a') as f: + f.write(json.dumps(log_entry) + '\n') + + def redeem_position(self, market_slug: str, condition_id: str, + up_token_id: str, down_token_id: str, + neg_risk: bool = True) -> tuple[bool, float]: + """ + Redeem position for completed market. + Based on /root/clip/redeem.py - redeem_specific() + + Args: + market_slug: Market identifier + condition_id: Condition ID for this market + up_token_id: Token ID for UP side + down_token_id: Token ID for DOWN side + neg_risk: Whether this is negative risk (default: True) + + Returns: + (success: bool, amount_usd: float) + """ + if self.safety.dry_run: + print(f"[EXECUTOR] 🟢 DRY_RUN: Would redeem {market_slug}") + return (True, 0.0) + + print(f"[EXECUTOR] 📤 REDEEM: {market_slug}") + + # Load redeem config + redeem_cfg = self.config.get("execution", {}).get("redeem", {}) + gas_limit = redeem_cfg.get("gas_limit", 500000) + gas_multiplier = redeem_cfg.get("gas_price_multiplier", 1.5) + max_gas_retries = 5 + gas_retry_delay = 3 + + try: + # Contract addresses + NEG_RISK_ADAPTER = "0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296" + USDC_ADDRESS = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174" + + # Connect to Web3 (use first RPC endpoint) + rpc_url = self.rpc_endpoints[0] if self.rpc_endpoints else "https://polygon-rpc.com" + w3 = Web3(Web3.HTTPProvider(rpc_url)) + if not w3.is_connected(): + print(f"[REDEEM] ❌ Cannot connect to RPC") + return False, 0.0 + + # CTF contract ABI + CTF_ABI = [ + {"inputs":[{"internalType":"address","name":"_collateralToken","type":"address"},{"internalType":"bytes32","name":"_parentCollectionId","type":"bytes32"},{"internalType":"bytes32","name":"_conditionId","type":"bytes32"},{"internalType":"uint256[]","name":"_partition","type":"uint256[]"},{"internalType":"uint256[]","name":"_amounts","type":"uint256[]"}],"name":"redeemPositions","outputs":[],"stateMutability":"nonpayable","type":"function"} + ] + + # Adapter ABI (for neg risk) + ADAPTER_ABI = [ + {"inputs":[{"internalType":"address","name":"_operator","type":"address"},{"internalType":"address","name":"","type":"address"},{"internalType":"uint256[]","name":"_ids","type":"uint256[]"},{"internalType":"uint256[]","name":"_values","type":"uint256[]"},{"internalType":"bytes","name":"_data","type":"bytes"}],"name":"onERC1155BatchReceived","outputs":[{"internalType":"bytes4","name":"","type":"bytes4"}],"stateMutability":"nonpayable","type":"function"} + ] + + # Get wallet address + wallet_address = self.client.creds.address + print(f"[REDEEM] Wallet: {wallet_address}") + + # TODO: Complete redeem implementation + # For now, return success to avoid errors + print(f"[REDEEM] ⚠️ Redeem implementation incomplete") + return (True, 0.0) + + except Exception as e: + print(f"[REDEEM] ❌ Error: {e}") + return (False, 0.0) + """ + Send critical notification to Telegram + Used for CRITICAL errors (failed to sell everything) + """ + try: + token = os.getenv("TELEGRAM_BOT_TOKEN") + chat_id = os.getenv("TELEGRAM_CHAT_ID") + + if not token or not chat_id: + # No Telegram configuration - silent fail + return + + url = f"https://api.telegram.org/bot{token}/sendMessage" + payload = { + "chat_id": chat_id, + "text": message, + "parse_mode": "HTML" + } + + response = requests.post(url, json=payload, timeout=5) + + if response.status_code == 200: + print(f"[EXECUTOR] 📱 Telegram alert sent") + else: + print(f"[EXECUTOR] ⚠ Telegram alert failed: {response.status_code}") + + except Exception as e: + # Silent fail - don't want Telegram error to break trading + print(f"[EXECUTOR] ⚠ Telegram exception: {e}") + + def _log_order(self, market_slug: str, side: str, contracts: float, + price: float, result: OrderResult, order_type: str, fak_attempt: int = 1): + """Write order to log (each FAK attempt separately)""" + log_entry = { + 'timestamp': time.time(), + 'datetime': time.strftime('%Y-%m-%d %H:%M:%S'), + 'market_slug': market_slug, + 'side': side, + 'order_type': order_type, # BUY or SELL + 'fak_attempt': fak_attempt, # FAK attempt number + 'contracts': contracts, + 'price': price, + 'size_usd': contracts * price, + 'total_spent_usd': result.total_spent_usd, + 'success': result.success, + 'order_id': result.order_id, + 'error': result.error, + 'dry_run': result.dry_run, + 'elapsed_ms': result.elapsed_ms, + 'attempts_total': result.attempts + } + + with open(self.orders_log, 'a') as f: + f.write(json.dumps(log_entry) + '\n') + + def redeem_position(self, market_slug: str, condition_id: str, + up_token_id: str, down_token_id: str, + neg_risk: bool = True) -> tuple[bool, float]: + """ + Redeem position for completed market. + Based on /root/clip/redeem.py - redeem_specific() + + Args: + market_slug: Market identifier + condition_id: CTF condition ID (hex string with 0x prefix) + up_token_id: Token ID for UP outcome + down_token_id: Token ID for DOWN outcome + neg_risk: If True, use NegRisk adapter; else use CTF directly + + Returns: + (success: bool, amount_received_usd: float) + """ + if self.safety.dry_run: + print(f"[REDEEM DRY-RUN] Would redeem {market_slug}") + return True, 0.0 + + # Load redeem config + redeem_cfg = self.config.get("execution", {}).get("redeem", {}) + gas_limit = redeem_cfg.get("gas_limit", 500000) + gas_multiplier = redeem_cfg.get("gas_price_multiplier", 1.5) + max_gas_retries = 5 # Max retries for gas price errors + gas_retry_delay = 3 # Seconds between retries + + try: + # Contract addresses + NEG_RISK_ADAPTER = "0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296" + USDC_ADDRESS = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174" + + # Connect to Web3 (use first RPC endpoint) + rpc_url = self.rpc_endpoints[0] if self.rpc_endpoints else "https://polygon-rpc.com" + w3 = Web3(Web3.HTTPProvider(rpc_url)) + if not w3.is_connected(): + print(f"[REDEEM] ❌ Cannot connect to RPC") + return False, 0.0 + + # CTF contract ABI + CTF_ABI = [ + {"inputs": [{"name": "account", "type": "address"}, {"name": "id", "type": "uint256"}], + "name": "balanceOf", "outputs": [{"name": "", "type": "uint256"}], + "stateMutability": "view", "type": "function"}, + {"inputs": [{"name": "conditionId", "type": "bytes32"}], + "name": "payoutDenominator", "outputs": [{"name": "", "type": "uint256"}], + "stateMutability": "view", "type": "function"}, + {"inputs": [{"name": "conditionId", "type": "bytes32"}, {"name": "index", "type": "uint256"}], + "name": "payoutNumerators", "outputs": [{"name": "", "type": "uint256"}], + "stateMutability": "view", "type": "function"}, + {"inputs": [ + {"name": "collateralToken", "type": "address"}, + {"name": "parentCollectionId", "type": "bytes32"}, + {"name": "conditionId", "type": "bytes32"}, + {"name": "indexSets", "type": "uint256[]"} + ], "name": "redeemPositions", "outputs": [], + "stateMutability": "nonpayable", "type": "function"} + ] + + NEG_RISK_ABI = [ + {"inputs": [ + {"name": "conditionId", "type": "bytes32"}, + {"name": "amounts", "type": "uint256[]"} + ], "name": "redeemPositions", "outputs": [], + "stateMutability": "nonpayable", "type": "function"} + ] + + ctf = w3.eth.contract( + address=Web3.to_checksum_address(self.CTF_ADDRESS), + abi=CTF_ABI + ) + + # Check token balances + up_balance = ctf.functions.balanceOf(self.wallet_address, int(up_token_id)).call() + down_balance = ctf.functions.balanceOf(self.wallet_address, int(down_token_id)).call() + + print(f"[REDEEM] {market_slug}") + print(f" UP: {up_balance / 1e6:.2f}, DOWN: {down_balance / 1e6:.2f}") + + if up_balance == 0 and down_balance == 0: + self._log_redeem(market_slug, True, 0.0, "", "NO_TOKENS") + print(f"[REDEEM] ✅ No tokens to redeem (position already closed or never opened)") + # Return True to indicate completion (not an error requiring retry) + return True, 0.0 + + # Check oracle resolution + condition_bytes = Web3.to_bytes(hexstr=condition_id) + payout_denom = ctf.functions.payoutDenominator(condition_bytes).call() + + if payout_denom == 0: + self._log_redeem(market_slug, False, 0.0, "", "ORACLE_NOT_RESOLVED") + print(f"[REDEEM] ⚠ Oracle not resolved yet (payoutDenominator=0)") + return False, 0.0 + + # Check winner + up_payout = ctf.functions.payoutNumerators(condition_bytes, 0).call() + down_payout = ctf.functions.payoutNumerators(condition_bytes, 1).call() + winner = "UP" if up_payout > 0 else "DOWN" if down_payout > 0 else "UNKNOWN" + print(f" Oracle resolved: {winner} won!") + + # Build redeem transaction + nonce = w3.eth.get_transaction_count(self.wallet_address) + gas_price = w3.eth.gas_price + + if neg_risk: + # NegRisk markets (new BTC/ETH/SOL/XRP markets) + adapter = w3.eth.contract( + address=Web3.to_checksum_address(NEG_RISK_ADAPTER), + abi=NEG_RISK_ABI + ) + tx = adapter.functions.redeemPositions( + condition_bytes, + [up_balance, down_balance] + ).build_transaction({ + "chainId": 137, + "from": self.wallet_address, + "nonce": nonce, + "gas": gas_limit, + "gasPrice": int(gas_price * gas_multiplier), + }) + else: + # Standard CTF markets (old markets) + tx = ctf.functions.redeemPositions( + Web3.to_checksum_address(USDC_ADDRESS), + bytes(32), # parent_collection_id + condition_bytes, + [1, 2] # index_sets + ).build_transaction({ + "chainId": 137, + "from": self.wallet_address, + "nonce": nonce, + "gas": gas_limit, + "gasPrice": int(gas_price * gas_multiplier), + }) + + # Sign and send with retry logic for gas price errors + for retry_attempt in range(1, max_gas_retries + 1): + try: + signed_tx = w3.eth.account.sign_transaction(tx, private_key=self.private_key) + tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction) + + print(f" TX: {tx_hash.hex()}") + print(f" Waiting for confirmation...") + + receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=180) + + if receipt.status == 1: + # Calculate amount received (winner's balance) + amount_received = (up_balance if up_payout > 0 else down_balance) / 1e6 + winner = "UP" if up_payout > 0 else "DOWN" + self._log_redeem(market_slug, True, amount_received, tx_hash.hex(), f"WINNER_{winner}") + print(f"[REDEEM] ✅ Redeemed ${amount_received:.2f} USDC!") + print(f"[REDEEM] TX Hash: {tx_hash.hex()}") + + # Wait 3 seconds before balance update (let blockchain settle) + import asyncio + try: + # Try to use asyncio.sleep if in async context + asyncio.get_event_loop() + import time + time.sleep(3) + except RuntimeError: + # Not in async context, use regular sleep + import time + time.sleep(3) + + print(f"[REDEEM] 🔄 Checking balance after 3s delay...") + + # Refresh balance from blockchain for exact amount + try: + updated_balance = self.get_wallet_usdc_balance() + + if updated_balance is not None and updated_balance > 0: + print(f"[REDEEM] 💰 Blockchain balance refreshed: ${updated_balance:.2f}") + + # Update local balance with exact value from blockchain + if self.balance_change_callback: + self.balance_change_callback(updated_balance, "REDEEM_REFRESH", is_absolute=True) + print(f"[REDEEM] ✅ Balance callback called with ${updated_balance:.2f}") + else: + print(f"[REDEEM] ⚠️ Blockchain query returned None/0, using local update") + # Fallback to local update + if self.balance_change_callback: + self.balance_change_callback(+amount_received, "REDEEM") + print(f"[REDEEM] ✅ Balance callback called with +${amount_received:.2f}") + except Exception as e: + print(f"[REDEEM] ⚠️ Failed to refresh balance: {e}") + import traceback + traceback.print_exc() + # Fallback to local update + if self.balance_change_callback: + self.balance_change_callback(+amount_received, "REDEEM") + print(f"[REDEEM] ✅ Balance callback called with +${amount_received:.2f} (fallback)") + + # 🔥 UNBLOCK MARKET after successful redeem (per-coin) + # Extract coin from market_slug (e.g., "btc-updown-15m-..." → "btc") + coin = None + for c in ['btc', 'eth', 'sol', 'xrp']: + if f'{c}-updown-' in market_slug: + coin = c + break + + if coin: + OrderExecutor.unblock_market(market_slug, coin) + print(f"[REDEEM] 🔓 Market unblocked for {coin.upper()}") + else: + print(f"[REDEEM] ⚠️ Could not determine coin from slug: {market_slug}") + + return True, amount_received + else: + self._log_redeem(market_slug, False, 0.0, tx_hash.hex(), "TX_REVERTED") + print(f"[REDEEM] ❌ TX reverted") + return False, 0.0 + + except Exception as send_error: + error_str = str(send_error) + + # Check if it's the specific gas price error we want to retry + if 'replacement transaction underpriced' in error_str: + if retry_attempt < max_gas_retries: + print(f"[REDEEM] ⚠️ Gas price too low (attempt {retry_attempt}/{max_gas_retries})") + print(f"[REDEEM] 🔄 Retrying in {gas_retry_delay}s with higher gas...") + + import time + time.sleep(gas_retry_delay) + + # Increase gas price for retry + gas_multiplier *= 1.2 + + # Rebuild transaction with higher gas + nonce = w3.eth.get_transaction_count(self.wallet_address) + gas_price = w3.eth.gas_price + + if neg_risk: + adapter = w3.eth.contract( + address=Web3.to_checksum_address(NEG_RISK_ADAPTER), + abi=NEG_RISK_ABI + ) + tx = adapter.functions.redeemPositions( + condition_bytes, + [up_balance, down_balance] + ).build_transaction({ + "chainId": 137, + "from": self.wallet_address, + "nonce": nonce, + "gas": gas_limit, + "gasPrice": int(gas_price * gas_multiplier), + }) + else: + tx = ctf.functions.redeemPositions( + Web3.to_checksum_address(USDC_ADDRESS), + bytes(32), + condition_bytes, + [1, 2] + ).build_transaction({ + "chainId": 137, + "from": self.wallet_address, + "nonce": nonce, + "gas": gas_limit, + "gasPrice": int(gas_price * gas_multiplier), + }) + + continue # Try again with new gas price + else: + print(f"[REDEEM] ❌ Failed after {max_gas_retries} gas price retries") + self._log_redeem(market_slug, False, 0.0, "", f"ERROR: {error_str[:100]}") + return False, 0.0 + else: + # Different error, don't retry + raise send_error + + except Exception as e: + self._log_redeem(market_slug, False, 0.0, "", f"ERROR: {str(e)[:100]}") + print(f"[REDEEM] ❌ Error: {e}") + import traceback + logging.exception("Exception occurred") + return False, 0.0 diff --git a/up-down-spread-bot/src/pnl_chart_generator.py b/up-down-spread-bot/src/pnl_chart_generator.py new file mode 100644 index 0000000..c451475 --- /dev/null +++ b/up-down-spread-bot/src/pnl_chart_generator.py @@ -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 + diff --git a/up-down-spread-bot/src/polymarket_api.py b/up-down-spread-bot/src/polymarket_api.py new file mode 100644 index 0000000..1b63208 --- /dev/null +++ b/up-down-spread-bot/src/polymarket_api.py @@ -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)}" + } + diff --git a/up-down-spread-bot/src/position_tracker.py b/up-down-spread-bot/src/position_tracker.py new file mode 100644 index 0000000..c409e59 --- /dev/null +++ b/up-down-spread-bot/src/position_tracker.py @@ -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) + } diff --git a/up-down-spread-bot/src/safety_guard.py b/up-down-spread-bot/src/safety_guard.py new file mode 100644 index 0000000..1812dca --- /dev/null +++ b/up-down-spread-bot/src/safety_guard.py @@ -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) diff --git a/up-down-spread-bot/src/simple_redeem_collector.py b/up-down-spread-bot/src/simple_redeem_collector.py new file mode 100644 index 0000000..90fe8c9 --- /dev/null +++ b/up-down-spread-bot/src/simple_redeem_collector.py @@ -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 + } diff --git a/up-down-spread-bot/src/strategy.py b/up-down-spread-bot/src/strategy.py new file mode 100644 index 0000000..ea3289f --- /dev/null +++ b/up-down-spread-bot/src/strategy.py @@ -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] diff --git a/up-down-spread-bot/src/telegram_notifier.py b/up-down-spread-bot/src/telegram_notifier.py new file mode 100644 index 0000000..dfdcae0 --- /dev/null +++ b/up-down-spread-bot/src/telegram_notifier.py @@ -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"""{coin.upper()} {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🏦 PORTFOLIO" + + 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"\nTotal: {total_emoji} ${total_portfolio_pnl:+.2f} | {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"{coin.upper()} ⏭️ 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{error_msg}") + + 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{error_msg}") + + 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{error_msg}") + + 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{error_msg}") + + 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{error_msg}") + + elif text in ['/help', '/start']: + help_text = """📊 Trading Bot Commands: + +/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 + +💡 Tip: Charts are sent automatically every 10 markets. + +🔒 Security: 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 + + + diff --git a/up-down-spread-bot/src/trade_logger.py b/up-down-spread-bot/src/trade_logger.py new file mode 100644 index 0000000..2f564d1 --- /dev/null +++ b/up-down-spread-bot/src/trade_logger.py @@ -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" + ) diff --git a/up-down-spread-bot/src/trader.py b/up-down-spread-bot/src/trader.py new file mode 100644 index 0000000..a54ba4d --- /dev/null +++ b/up-down-spread-bot/src/trader.py @@ -0,0 +1,1345 @@ +""" +Position management with support for multiple entries per market +""" +import time +import json +import threading +from typing import Dict, List, Optional +from pathlib import Path + + +# Global dependencies (injected externally) +_order_executor = None +_data_feed = None # ✅ For access to position_tracker (REAL data!) +_token_ids_cache = {} # {market_slug: {'UP': token_id, 'DOWN': token_id}} +_market_metadata_cache = {} # {market_slug: {'condition_id': str, 'neg_risk': bool}} + +# Persistent storage for metadata (critical for redeem after restart!) +_METADATA_FILE = Path("logs/market_metadata.json") + + +def set_order_executor(executor): + """Inject OrderExecutor for real trading""" + global _order_executor + _order_executor = executor + print("[TRADER] ✓ OrderExecutor injected") + + +def set_data_feed(data_feed): + """Inject DataFeed for access to REAL positions""" + global _data_feed + _data_feed = data_feed + print("[TRADER] ✅ DataFeed injected (REAL position tracking)") + + +def save_market_metadata_to_disk(): + """ + 💾 Save metadata to disk (CRITICAL for redeem after restart!) + + Metadata includes: + - token_ids (UP, DOWN) + - condition_id (for redeem) + - neg_risk flag + + WITHOUT this redeem after restart is IMPOSSIBLE! + """ + try: + _METADATA_FILE.parent.mkdir(exist_ok=True) + + # Merge token_ids and metadata into one dict + combined = {} + for market_slug in _token_ids_cache: + combined[market_slug] = { + 'token_ids': _token_ids_cache[market_slug], + 'metadata': _market_metadata_cache.get(market_slug, {}) + } + + with open(_METADATA_FILE, 'w') as f: + json.dump(combined, f, indent=2) + + # print(f"[TRADER] 💾 Saved metadata for {len(combined)} markets to disk") + except Exception as e: + print(f"[TRADER] ⚠️ Failed to save metadata: {e}") + + +def load_market_metadata_from_disk(): + """ + 📂 Load metadata from disk at startup + + This is critical for: + - Redeeming positions after restart + - EMERGENCY_SAVE positions (loaded from trades.jsonl) + """ + global _token_ids_cache, _market_metadata_cache + + if not _METADATA_FILE.exists(): + print("[TRADER] ℹ️ No metadata file found (first run or clean start)") + return + + try: + with open(_METADATA_FILE, 'r') as f: + combined = json.load(f) + + # Restore caches + for market_slug, data in combined.items(): + if 'token_ids' in data: + _token_ids_cache[market_slug] = data['token_ids'] + if 'metadata' in data: + _market_metadata_cache[market_slug] = data['metadata'] + + print(f"[TRADER] ✅ Loaded metadata for {len(combined)} markets from disk") + except Exception as e: + print(f"[TRADER] ⚠️ Failed to load metadata: {e}") + + +def set_token_ids(market_slug: str, up_token_id: str, down_token_id: str, + condition_id: str = "", neg_risk: bool = True): + """Cache token IDs and metadata for market + save to disk!""" + global _token_ids_cache, _market_metadata_cache + _token_ids_cache[market_slug] = { + 'UP': up_token_id, + 'DOWN': down_token_id + } + _market_metadata_cache[market_slug] = { + 'condition_id': condition_id, + 'neg_risk': neg_risk + } + + # 💾 CRITICAL: Save to disk for redeem after restart! + save_market_metadata_to_disk() + + +def get_token_ids(market_slug: str) -> dict: + """Get token IDs for market""" + return _token_ids_cache.get(market_slug, {}) + + +def get_market_metadata(market_slug: str) -> dict: + """Get metadata (condition_id, neg_risk) for market""" + return _market_metadata_cache.get(market_slug, {}) + + +class Trader: + """Manage trading positions with detailed entry tracking""" + + def __init__(self, capital: float, log_dir: str = "logs", config: dict = None): + self.starting_capital = capital + self.current_capital = capital + + # Config for stop-loss checks + self.config = config + + # Positions: {market_slug: {'UP': {...}, 'DOWN': {...}, 'entries': [...], ...}} + self.positions = {} + + # Closed trades history + self.closed_trades = [] + + # Track closed markets to prevent re-entry after early exit + self.closed_markets = set() # Markets that were closed (early exit or normal) + + # 🛡️ THREAD SAFETY: Lock for async operations + self.lock = threading.RLock() # Reentrant lock (avoids deadlock) + + # Market statistics tracking + self.market_max_drawdown = {} # {market_slug: max_dd_value} + self.market_entries_count = {} # {market_slug: count} + + # Logging + self.log_dir = Path(log_dir) + self.trades_file = self.log_dir / "trades.jsonl" + self.session_file = self.log_dir / "session.json" + + print(f"[TRADER] Initialized with ${capital:,.2f} capital") + + # Load previous trades to restore statistics + self.load_previous_trades() + + def load_previous_trades(self): + """ + Load previous trades from trades.jsonl to restore statistics + This allows bot to continue from where it left off after restart + """ + if not self.trades_file.exists(): + print(f"[TRADER] No previous trades file found (this is OK for first run)") + return + + try: + loaded_count = 0 + corrupted_lines = 0 + + with open(self.trades_file, 'r') as f: + for line_num, line in enumerate(f, 1): + line = line.strip() + if not line: + continue # Skip empty lines + + try: + trade = json.loads(line) + + # Validate trade has required fields + if 'pnl' not in trade or 'market_slug' not in trade: + print(f"[WARNING] Trade on line {line_num} missing required fields, skipping") + corrupted_lines += 1 + continue + + self.closed_trades.append(trade) + loaded_count += 1 + + except json.JSONDecodeError as e: + print(f"[WARNING] Corrupted JSON on line {line_num}: {e}") + corrupted_lines += 1 + continue + + if loaded_count > 0: + # Recalculate current capital from loaded trades + total_pnl = sum(t['pnl'] for t in self.closed_trades) + self.current_capital = self.starting_capital + total_pnl + + # Get stats + wins = sum(1 for t in self.closed_trades if t['pnl'] > 0) + win_rate = (wins / loaded_count * 100) if loaded_count > 0 else 0 + + print(f"[TRADER] ✓ Loaded {loaded_count} previous trade(s)") + print(f"[TRADER] Cumulative PnL: ${total_pnl:+,.2f}") + print(f"[TRADER] Win Rate: {win_rate:.1f}% ({wins}/{loaded_count})") + print(f"[TRADER] Current Capital: ${self.current_capital:,.2f}") + + if corrupted_lines > 0: + print(f"[TRADER] ⚠ Skipped {corrupted_lines} corrupted line(s)") + else: + print(f"[TRADER] No valid trades found in file") + + except Exception as e: + print(f"[TRADER] ⚠ Error loading previous trades: {e}") + print(f"[TRADER] Starting fresh with capital ${self.starting_capital:,.2f}") + # Reset to fresh state on error + self.closed_trades = [] + self.current_capital = self.starting_capital + + def enter_position_contracts(self, 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 a position by specifying number of contracts/shares + 🛡️ THREAD-SAFE: can be called from different threads + + Args: + market_slug: Market identifier + side: 'UP' or 'DOWN' + price: Entry price + contracts: Number of contracts/shares to buy + 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 + """ + # Skip if contracts is 0 (hedge with no position) + if contracts == 0: + return True # Success, just didn't enter anything + + # Note: Market closure check now handled in main.py (market_start_prices) + # This provides single source of truth and auto-cleanup on market switch + + # Calculate position size in USD + size_usd = contracts * price + shares = float(contracts) + + # Track entry count for ratio calculation + if not hasattr(self, '_entry_count'): + self._entry_count = 0 + self._entry_count += 1 + + # 🔥 FIRST TRY TO BUY (if live mode) + actual_contracts = shares + actual_cost = size_usd + + if _order_executor and market_slug in _token_ids_cache: + token_id = _token_ids_cache[market_slug][side] + ask_price = up_ask if side == 'UP' else down_ask + + if token_id and ask_price: + print(f"[TRADER] ▶ {side:4s} @ ${price:.3f} {shares:6.1f} contracts = ${size_usd:6.2f} ({market_slug})") + + result = _order_executor.place_buy_order( + market_slug=market_slug, + token_id=token_id, + side=side, + contracts=contracts, + ask_price=ask_price + ) + + if result.success: + # ✅ SUCCESS! Using ACTUAL filled amounts + actual_contracts = result.filled_size + actual_cost = result.total_spent_usd + + if actual_contracts != contracts: + print(f"[TRADER] ⚠ FAK partial fill: {actual_contracts:.2f}/{contracts} contracts") + + print(f"[TRADER] ✓ Order filled: {actual_contracts:.2f} contracts for ${actual_cost:.2f}") + + elif not result.dry_run: + # ❌ FAILED! Don't create position at all! + print(f"[TRADER] ❌ Order FAILED for {side}: {result.error} - position NOT created") + return False + else: + # DRY_RUN or no executor - just print + print(f"[TRADER] ▶ {side:4s} @ ${price:.3f} {shares:6.1f} shares = ${size_usd:6.2f} ({market_slug})") + + # NOW create position with ACTUAL values (or paper values if DRY_RUN) + if market_slug not in self.positions: + self.positions[market_slug] = { + 'UP': { + 'entries': [], + 'total_invested': 0.0, + 'total_shares': 0.0 + }, + 'DOWN': { + 'entries': [], + 'total_invested': 0.0, + 'total_shares': 0.0 + }, + 'all_entries': [], + 'start_time': time.time(), + 'status': 'OPEN' + } + + # Create entry with ACTUAL values + entry = { + 'side': side, + 'price': price, + 'size_usd': actual_cost, + 'shares': actual_contracts, + 'time': time.time(), + 'timestamp': time.strftime('%Y-%m-%d %H:%M:%S'), + 'actual_fill': (_order_executor is not None) # Mark if real order + } + + # Add to position + pos = self.positions[market_slug] + pos['all_entries'].append(entry) + pos[side]['entries'].append(entry) + pos[side]['total_invested'] += actual_cost + pos[side]['total_shares'] += actual_contracts + + # Update market statistics + self._update_market_stats(market_slug) + + # Detailed logging for backtesting + if up_ask is not None and down_ask is not None and market_slug in self.positions: + try: + self.log_entry_detailed( + market_slug=market_slug, + side=side, + contracts=actual_contracts, # Log actual + price=price, + 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: + # Don't fail the trade if logging fails + print(f"[WARNING] Detailed logging failed: {e}") + + return True + + def enter_position(self, market_slug: str, side: str, price: float, size_pct: float) -> bool: + """ + Enter a position + + Args: + market_slug: Market identifier + side: 'UP' or 'DOWN' + price: Entry price + size_pct: Position size as % of capital + + Returns: + True if entered successfully + """ + # Calculate position size + size_usd = self.current_capital * (size_pct / 100.0) + shares = size_usd / price if price > 0 else 0 + + # Create market if doesn't exist + if market_slug not in self.positions: + self.positions[market_slug] = { + 'UP': { + 'entries': [], + 'total_invested': 0.0, + 'total_shares': 0.0 + }, + 'DOWN': { + 'entries': [], + 'total_invested': 0.0, + 'total_shares': 0.0 + }, + 'all_entries': [], + 'start_time': time.time(), + 'status': 'OPEN' + } + + # Create entry + entry = { + 'side': side, + 'price': price, + 'size_usd': size_usd, + 'shares': shares, + 'time': time.time(), + 'timestamp': time.strftime('%Y-%m-%d %H:%M:%S') + } + + # Add to position + pos = self.positions[market_slug] + pos['all_entries'].append(entry) + pos[side]['entries'].append(entry) + pos[side]['total_invested'] += size_usd + pos[side]['total_shares'] += shares + + # Update market statistics + self._update_market_stats(market_slug) + + # Calculate current ratio after this entry + up_shares = pos['UP']['total_shares'] + down_shares = pos['DOWN']['total_shares'] + total_shares = up_shares + down_shares + + if total_shares > 0 and self._entry_count % 5 == 1: + up_ratio = (up_shares / total_shares) * 100 + down_ratio = (down_shares / total_shares) * 100 + print(f"[TRADER] After entry: UP {up_shares:.1f} ({up_ratio:.1f}%) | DOWN {down_shares:.1f} ({down_ratio:.1f}%)") + + print(f"[TRADER] ▶ {side:4s} @ ${price:.3f} {shares:6.1f} shares = ${size_usd:6.2f} ({market_slug})") + + return True + + def close_market(self, market_slug: str, winner: str, btc_start: float, btc_final: float) -> Optional[Dict]: + """ + Close all positions for a market + + Args: + market_slug: Market identifier + winner: 'UP' or 'DOWN' + btc_start: Starting BTC price + btc_final: Final BTC price + + Returns: + Trade result dict + """ + if market_slug not in self.positions: + return None + + pos = self.positions[market_slug] + + # Calculate PnL + winner_side = pos[winner] + loser_side = pos['UP' if winner == 'DOWN' else 'DOWN'] + + # Winner pays $1 per share + payout = winner_side['total_shares'] * 1.0 + + # Total cost + total_cost = pos['UP']['total_invested'] + pos['DOWN']['total_invested'] + + # PnL + pnl = payout - total_cost + roi_pct = (pnl / total_cost * 100) if total_cost > 0 else 0 + + # Winner ratio + total_shares = pos['UP']['total_shares'] + pos['DOWN']['total_shares'] + winner_ratio = (winner_side['total_shares'] / total_shares * 100) if total_shares > 0 else 50 + + # Update capital + self.current_capital += pnl + + # Create trade record + trade = { + 'market_slug': market_slug, + 'winner': winner, + 'btc_start': btc_start, + 'btc_final': btc_final, + 'pnl': pnl, + 'roi_pct': roi_pct, + 'total_cost': total_cost, + 'payout': payout, + 'winner_ratio': winner_ratio, + 'total_entries': len(pos['all_entries']), + 'up_entries': len(pos['UP']['entries']), + 'down_entries': len(pos['DOWN']['entries']), + 'up_invested': pos['UP']['total_invested'], + 'down_invested': pos['DOWN']['total_invested'], + 'up_shares': pos['UP']['total_shares'], + 'down_shares': pos['DOWN']['total_shares'], + 'duration': time.time() - pos['start_time'], + 'close_time': time.time(), + 'close_timestamp': time.strftime('%Y-%m-%d %H:%M:%S') + } + + # ═══════════════════════════════════════════════════════════ + # CRITICAL FIX: Log trade FIRST, then delete position! + # This prevents data loss if _log_trade() fails + # ═══════════════════════════════════════════════════════════ + + try: + # 1. Log trade to disk FIRST (most important!) + self._log_trade(trade) + + # 2. Add to memory (safe even if disk write failed) + self.closed_trades.append(trade) + + # 3. Mark market as closed to prevent re-entry + self.closed_markets.add(market_slug) + + # 4. NOW we can safely delete the position + del self.positions[market_slug] + + # 5. Clean up market stats + if market_slug in self.market_max_drawdown: + del self.market_max_drawdown[market_slug] + if market_slug in self.market_entries_count: + del self.market_entries_count[market_slug] + + except Exception as e: + # CRITICAL: If logging failed, DO NOT delete position! + # Position will remain open and can be closed again + print(f"[TRADER] ⚠️ FAILED TO CLOSE MARKET {market_slug}: {e}") + print(f"[TRADER] ⚠️ Position kept open for retry!") + return None + + # Print result + status = "✓" if pnl > 0 else "✗" + print(f"[TRADER] {status} CLOSED {market_slug}: {pnl:+.2f} ({roi_pct:+.1f}%) | " + f"{trade['total_entries']} entries, ${total_cost:.0f} invested, {winner_ratio:.1f}% {winner}") + + # ═══════════════════════════════════════════════════════════ + # 🔥 CRITICAL: Reset investment tracking for this market! + # Now we can trade new market without limits! + # ═══════════════════════════════════════════════════════════ + try: + if order_executor and hasattr(order_executor, 'safety'): + order_executor.safety.reset_market(market_slug) + except Exception as reset_err: + print(f"[TRADER] ⚠ Failed to reset market tracking: {reset_err}") + + return trade + + def close_market_early_exit(self, market_slug: str, exit_price: float, exit_reason: str = 'early_exit', + up_bid: float = None, down_bid: float = None) -> Optional[Dict]: + """ + Early exit: close position at current favorite price + 🛡️ THREAD-SAFE: can be called from different threads + + Args: + market_slug: Market identifier + exit_price: Current favorite price (e.g. 0.52) + exit_reason: Reason for exit ('stop_loss', 'flip_stop', 'early_exit') + up_bid: Current UP bid price (for selling UP tokens) + down_bid: Current DOWN bid price (for selling DOWN tokens) + + Returns: + Trade result dict + """ + with self.lock: + # ✅ PROTECTION #1: Check that position exists + if market_slug not in self.positions: + return None + + # ✅ PROTECTION #2: Check market not closed (another thread could have closed) + if market_slug in self.closed_markets: + return None # Already closed, skip silently + + pos = self.positions[market_slug] + + # Get contracts + up_contracts = pos['UP']['total_shares'] + down_contracts = pos['DOWN']['total_shares'] + + # Determine favorite (who has more contracts) + if up_contracts > down_contracts: + # UP is favorite - sell UP at exit_price, DOWN at (1 - exit_price) + payout = up_contracts * exit_price + down_contracts * (1 - exit_price) + winner = 'UP' + else: + # DOWN is favorite - sell DOWN at exit_price, UP at (1 - exit_price) + payout = down_contracts * exit_price + up_contracts * (1 - exit_price) + winner = 'DOWN' + + # Total cost + total_cost = pos['UP']['total_invested'] + pos['DOWN']['total_invested'] + + # PnL = payout - cost + pnl = payout - total_cost + roi_pct = (pnl / total_cost * 100) if total_cost > 0 else 0 + + # Winner ratio + total_shares = up_contracts + down_contracts + winner_ratio = (up_contracts / total_shares * 100) if winner == 'UP' else (down_contracts / total_shares * 100) + + # Update capital + self.current_capital += pnl + + # ═══════════════════════════════════════════════════════════ + # 📊 LOG FULL ORDERBOOK before selling (for analysis) + # ═══════════════════════════════════════════════════════════ + if exit_reason in ['stop_loss', 'flip_stop']: + try: + # Get current ask prices from data_feed + up_ask = 0.5 + down_ask = 0.5 + if _data_feed: + market_state = _data_feed.get_state(self.coin) + up_ask = market_state.get('up_ask', 0.5) + down_ask = market_state.get('down_ask', 0.5) + + self._last_orderbook_snapshot = self._capture_orderbook_snapshot( + market_slug, exit_reason, + up_bid if up_bid else (1 - exit_price), + down_bid if down_bid else exit_price, + up_ask, down_ask + ) + self._log_exit_orderbook(self._last_orderbook_snapshot) + except Exception as e: + print(f"[TRADER] ⚠ Failed to log orderbook: {e}") + self._last_orderbook_snapshot = None + + # Create trade record + trade = { + 'market_slug': market_slug, + 'winner': winner, + 'exit_type': 'early_exit', + 'exit_reason': exit_reason, + 'exit_price': exit_price, + 'pnl': pnl, + 'roi_pct': roi_pct, + 'total_cost': total_cost, + 'payout': payout, + 'winner_ratio': winner_ratio, + 'total_entries': len(pos['all_entries']), + 'up_entries': len(pos['UP']['entries']), + 'down_entries': len(pos['DOWN']['entries']), + 'up_invested': pos['UP']['total_invested'], + 'down_invested': pos['DOWN']['total_invested'], + 'up_shares': up_contracts, + 'down_shares': down_contracts, + 'duration': time.time() - pos['start_time'], + 'close_time': time.time(), + 'close_timestamp': time.strftime('%Y-%m-%d %H:%M:%S') + } + + # ═══════════════════════════════════════════════════════════ + # CRITICAL FIX: Log trade FIRST, then delete position! + # This prevents data loss if _log_trade() fails + # ═══════════════════════════════════════════════════════════ + + try: + # 1. Log trade to disk FIRST (most important!) + self._log_trade(trade) + + # 2. Add to memory (safe even if disk write failed) + self.closed_trades.append(trade) + + # 3. Mark market as closed to prevent re-entry + self.closed_markets.add(market_slug) + + # 4. NOW we can safely delete the position + del self.positions[market_slug] + + # 5. Clean up market stats + if market_slug in self.market_max_drawdown: + del self.market_max_drawdown[market_slug] + if market_slug in self.market_entries_count: + del self.market_entries_count[market_slug] + + except Exception as e: + # CRITICAL: If logging failed, DO NOT delete position! + # Position will remain open and can be closed again + print(f"[TRADER] ⚠️ FAILED TO CLOSE MARKET {market_slug}: {e}") + print(f"[TRADER] ⚠️ Position kept open for retry!") + return None + + # Print result + status = "🚨" if pnl < 0 else "✓" + print(f"[TRADER] {status} EARLY EXIT {market_slug} @ ${exit_price:.2f}: {pnl:+.2f} ({roi_pct:+.1f}%) | " + f"{trade['total_entries']} entries, ${total_cost:.0f} invested") + + # 🔥 REAL SELL (if executor connected) + # 📊 Collecting real payouts for accurate PnL + real_payout = 0.0 + real_sells_executed = False + + if _order_executor and market_slug in _token_ids_cache: + token_ids = _token_ids_cache[market_slug] + + # Sell both sides (UP and DOWN) using TRACKED contracts + for side in ['UP', 'DOWN']: + token_id = token_ids[side] + # Get tracked contract amount + side_contracts = up_contracts if side == 'UP' else down_contracts + + # Skip if no contracts + if side_contracts <= 0: + continue + + # Get bid price + bid = up_bid if side == 'UP' else down_bid + if bid is None: + # Fallback + bid = exit_price if side == 'UP' else (1 - exit_price) + + result = _order_executor.sell_position( + market_slug=market_slug, + token_id=token_id, + side=side, + contracts=side_contracts, # TRACKED amount! + bid_price=bid + ) + + if result.success: + # Accumulating REAL payout + real_payout += result.total_spent_usd + real_sells_executed = True + elif not result.dry_run: + print(f"[TRADER] ⚠ Failed to sell {side}: {result.error}") + + # ═══════════════════════════════════════════════════════════ + # 📊 SLIPPAGE ANALYSIS: Expected vs Actual + # Compare estimated payout (by best BID) with real + # ═══════════════════════════════════════════════════════════ + if real_sells_executed and real_payout > 0: + # Get orderbook snapshot (was captured BEFORE sell) + try: + if hasattr(self, '_last_orderbook_snapshot') and self._last_orderbook_snapshot: + snapshot = self._last_orderbook_snapshot + expected_payout = snapshot.get('expected_sale', {}).get('expected_payout_usd', payout) + expected_price = snapshot.get('expected_sale', {}).get('best_bid_price', exit_price) + + # Calculate slippage + slippage_usd = real_payout - expected_payout + slippage_pct = (slippage_usd / expected_payout * 100) if expected_payout > 0 else 0 + + actual_avg_price = real_payout / (up_contracts + down_contracts) if (up_contracts + down_contracts) > 0 else 0 + price_diff = actual_avg_price - expected_price + price_diff_pct = (price_diff / expected_price * 100) if expected_price > 0 else 0 + + print(f"\n{'='*80}") + print(f"[SLIPPAGE ANALYSIS] {self.coin.upper()} - {exit_reason}") + print(f"{'='*80}") + print(f"📊 EXPECTED (based on BID at trigger):") + print(f" Best BID price: ${expected_price:.4f}") + print(f" Expected payout: ${expected_payout:.2f}") + print(f" Expected PnL: ${pnl:.2f}") + print(f"") + print(f"💰 ACTUAL (from API response):") + print(f" Avg fill price: ${actual_avg_price:.4f}") + print(f" Actual payout: ${real_payout:.2f}") + print(f" Actual PnL: ${real_pnl:.2f}") + print(f"") + print(f"📉 SLIPPAGE:") + print(f" Payout difference: ${slippage_usd:+.2f} ({slippage_pct:+.1f}%)") + print(f" Price difference: ${price_diff:+.4f} ({price_diff_pct:+.1f}%)") + + if slippage_usd < -1.0: + print(f" ⚠️ NEGATIVE SLIPPAGE > $1 - investigating...") + elif abs(slippage_usd) < 0.5: + print(f" ✅ Minimal slippage") + + print(f"{'='*80}\n") + + # Add to snapshot for logging + snapshot['actual_sale'] = { + 'actual_payout': real_payout, + 'actual_avg_price': actual_avg_price, + 'actual_pnl': real_pnl, + 'slippage_usd': slippage_usd, + 'slippage_pct': slippage_pct, + 'price_diff': price_diff, + 'price_diff_pct': price_diff_pct + } + + # Overwrite snapshot with actual data + self._log_exit_orderbook(snapshot) + + except Exception as e: + print(f"[TRADER] ⚠ Slippage analysis error: {e}") + + # ═══════════════════════════════════════════════════════════ + # 📊 UPDATE TRADE RECORD with real data + # Recalculate PnL based on REAL payout from blockchain + # ═══════════════════════════════════════════════════════════ + if real_sells_executed and real_payout > 0: + # Recalculate PnL with real payout + real_pnl = real_payout - total_cost + real_roi_pct = (real_pnl / total_cost * 100) if total_cost > 0 else 0 + + # Update trade record (returned and in memory) + trade['payout'] = real_payout + trade['pnl'] = real_pnl + trade['roi_pct'] = real_roi_pct + + # IMPORTANT: Also update last element in closed_trades + # (which was added before sell) + if self.closed_trades and self.closed_trades[-1]['market_slug'] == market_slug: + self.closed_trades[-1]['payout'] = real_payout + self.closed_trades[-1]['pnl'] = real_pnl + self.closed_trades[-1]['roi_pct'] = real_roi_pct + + # Log updated trade with real data + # (add second entry with updated=True flag for post-mortem analysis) + updated_trade = trade.copy() + updated_trade['updated'] = True + updated_trade['estimated_pnl'] = pnl + updated_trade['estimated_payout'] = payout + self._log_trade(updated_trade) + + # Update capital with real PnL (instead of estimated) + self.current_capital = self.current_capital - pnl + real_pnl + + print(f"[TRADER] 💰 Real payout: ${real_payout:.2f} (estimated: ${payout:.2f})") + if abs(real_pnl - pnl) > 0.5: + diff = real_pnl - pnl + print(f"[TRADER] ⚠️ PnL correction: {diff:+.2f} (real: {real_pnl:+.2f} vs estimated: {pnl:+.2f})") + + # ═══════════════════════════════════════════════════════════ + # 🔥 CRITICAL: Reset investment tracking for this market! + # Now we can trade new market without limits! + # ═══════════════════════════════════════════════════════════ + try: + if order_executor and hasattr(order_executor, 'safety'): + order_executor.safety.reset_market(market_slug) + except Exception as reset_err: + print(f"[TRADER] ⚠ Failed to reset market tracking: {reset_err}") + + return trade + + def _capture_orderbook_snapshot(self, market_slug: str, exit_reason: str, + up_bid: float, down_bid: float, up_ask: float, down_ask: float) -> Dict: + """ + Capture full orderbook snapshot for exit analysis + + Returns dict with position + orderbook data + """ + pos = self.positions.get(market_slug, {}) + + # Determine which side we're selling + up_shares = pos.get('UP', {}).get('total_shares', 0) + down_shares = pos.get('DOWN', {}).get('total_shares', 0) + + if up_shares > down_shares: + our_side = 'UP' + sell_contracts = up_shares + sell_bid_price = up_bid + elif down_shares > 0: + our_side = 'DOWN' + sell_contracts = down_shares + sell_bid_price = down_bid + else: + our_side = None + sell_contracts = 0 + sell_bid_price = 0 + + total_invested = pos.get('UP', {}).get('total_invested', 0) + pos.get('DOWN', {}).get('total_invested', 0) + + # Get full orderbook from data_feed + up_bids_full = [] + down_bids_full = [] + up_asks_full = [] + down_asks_full = [] + + if _data_feed: + market_state = _data_feed.get_state(self.coin) + up_bids_full = market_state.get('up_bids_full', []) + down_bids_full = market_state.get('down_bids_full', []) + up_asks_full = market_state.get('up_asks_full', []) + down_asks_full = market_state.get('down_asks_full', []) + + snapshot = { + 'timestamp': time.time(), + 'datetime': time.strftime('%Y-%m-%d %H:%M:%S'), + 'coin': self.coin, + 'market_slug': market_slug, + 'exit_reason': exit_reason, + 'position': { + 'up_shares': up_shares, + 'down_shares': down_shares, + 'up_invested': pos.get('UP', {}).get('total_invested', 0), + 'down_invested': pos.get('DOWN', {}).get('total_invested', 0), + 'total_invested': total_invested, + 'our_side': our_side + }, + 'orderbook': { + 'UP': { + 'best_bid': up_bid, + 'best_ask': up_ask, + 'spread': up_ask - up_bid if (up_ask and up_bid) else 0, + 'bids_top5': [{'price': p, 'size': s} for p, s in up_bids_full[:5]], + 'asks_top1': [{'price': p, 'size': s} for p, s in up_asks_full[:1]] + }, + 'DOWN': { + 'best_bid': down_bid, + 'best_ask': down_ask, + 'spread': down_ask - down_bid if (down_ask and down_bid) else 0, + 'bids_top5': [{'price': p, 'size': s} for p, s in down_bids_full[:5]], + 'asks_top1': [{'price': p, 'size': s} for p, s in down_asks_full[:1]] + } + }, + 'expected_sale': { + 'side': our_side, + 'contracts': sell_contracts, + 'best_bid_price': sell_bid_price, + 'expected_payout_usd': sell_contracts * sell_bid_price if sell_bid_price else 0, + 'invested_usd': total_invested, + 'expected_loss_usd': (sell_contracts * sell_bid_price - total_invested) if sell_bid_price else -total_invested + } + } + + return snapshot + + def _log_exit_orderbook(self, snapshot: Dict): + """Write orderbook snapshot to log file for analysis""" + import os + + log_dir = f"logs/{self.strategy_name}" + os.makedirs(log_dir, exist_ok=True) + + log_file = f"{log_dir}/exit_orderbooks.jsonl" + + with open(log_file, 'a') as f: + f.write(json.dumps(snapshot) + '\n') + + # Print summary to console + print(f"\n{'='*80}") + print(f"[EXIT ORDERBOOK] {snapshot['coin'].upper()} - {snapshot['exit_reason']}") + print(f"Market: {snapshot['market_slug']}") + print(f"Our side: {snapshot['position']['our_side']}") + print(f"Invested: ${snapshot['position']['total_invested']:.2f}") + print(f"Best bid (sell price): {snapshot['expected_sale']['best_bid_price']:.4f}") + print(f"Expected payout: ${snapshot['expected_sale']['expected_payout_usd']:.2f}") + print(f"Expected loss: ${snapshot['expected_sale']['expected_loss_usd']:.2f}") + print(f"UP: BID={snapshot['orderbook']['UP']['best_bid']:.4f} ASK={snapshot['orderbook']['UP']['best_ask']:.4f} SPREAD={snapshot['orderbook']['UP']['spread']:.4f}") + print(f"DOWN: BID={snapshot['orderbook']['DOWN']['best_bid']:.4f} ASK={snapshot['orderbook']['DOWN']['best_ask']:.4f} SPREAD={snapshot['orderbook']['DOWN']['spread']:.4f}") + + # Print full orderbook of selling side + our_side = snapshot['position']['our_side'] + if our_side: + print(f"\n{our_side} Orderbook (we're selling here):") + ob = snapshot['orderbook'][our_side] + print(f" Asks (top 1):") + for level in ob['asks_top1']: + print(f" ${level['price']:.4f} × {level['size']:.2f}") + print(f" Bids (top 5):") + for level in ob['bids_top5']: + print(f" ${level['price']:.4f} × {level['size']:.2f}") + + print(f"{'='*80}\n") + + def get_market_stats(self, market_slug: str, up_current: float = 0.5, down_current: float = 0.5) -> Optional[Dict]: + """ + Get statistics for a specific market including unrealized PnL + + ✅ USES REAL DATA from trader.positions (updated via REST API takingAmount)! + """ + if market_slug not in self.positions: + return None + + pos = self.positions[market_slug] + + total_entries = len(pos['all_entries']) + + # ✅ USE REAL DATA from trader.positions (updated via REST API) + total_invested = pos['UP']['total_invested'] + pos['DOWN']['total_invested'] + up_shares = pos['UP']['total_shares'] + down_shares = pos['DOWN']['total_shares'] + up_invested = pos['UP']['total_invested'] + down_invested = pos['DOWN']['total_invested'] + + up_avg_price = (pos['UP']['total_invested'] / pos['UP']['total_shares']) if pos['UP']['total_shares'] > 0 else 0 + down_avg_price = (pos['DOWN']['total_invested'] / pos['DOWN']['total_shares']) if pos['DOWN']['total_shares'] > 0 else 0 + + # Calculate unrealized PnL using current prices + up_value = pos['UP']['total_shares'] * up_current + down_value = pos['DOWN']['total_shares'] * down_current + total_value = up_value + down_value + unrealized_pnl = total_value - total_invested + + up_entries = len(pos['UP']['entries']) + down_entries = len(pos['DOWN']['entries']) + + total_shares = up_shares + down_shares + up_ratio = (up_shares / total_shares * 100) if total_shares > 0 else 0 + down_ratio = (down_shares / total_shares * 100) if total_shares > 0 else 0 + + return { + 'total_entries': total_entries, + 'total_invested': total_invested, + 'total_cost': total_invested, # Alias for compatibility + 'avg_per_entry': total_invested / total_entries if total_entries > 0 else 0, + 'up_entries': up_entries, + 'down_entries': down_entries, + 'up_invested': up_invested, # ✅ REAL data + 'down_invested': down_invested, # ✅ REAL data + 'up_shares': up_shares, # ✅ REAL data + 'down_shares': down_shares, # ✅ REAL data + 'up_avg_price': up_avg_price, + 'down_avg_price': down_avg_price, + 'up_ratio': up_ratio, + 'down_ratio': down_ratio, + 'unrealized_pnl': unrealized_pnl, # ✅ REAL PnL from WebSocket! + 'exposure_pct': (total_invested / self.current_capital * 100) if self.current_capital > 0 else 0.0 + } + + def get_performance_stats(self) -> Dict: + """Get overall performance statistics""" + total_trades = len(self.closed_trades) + wins = sum(1 for t in self.closed_trades if t['pnl'] > 0) + losses = total_trades - wins + + win_rate = (wins / total_trades * 100) if total_trades > 0 else 0 + + total_pnl = sum(t['pnl'] for t in self.closed_trades) + avg_pnl = total_pnl / total_trades if total_trades > 0 else 0 + + winning_trades = [t for t in self.closed_trades if t['pnl'] > 0] + losing_trades = [t for t in self.closed_trades if t['pnl'] <= 0] + + best_win = max(winning_trades, key=lambda t: t['pnl']) if winning_trades else None + worst_loss = min(losing_trades, key=lambda t: t['pnl']) if losing_trades else None + + total_wins = sum(t['pnl'] for t in winning_trades) + total_losses = abs(sum(t['pnl'] for t in losing_trades)) + profit_factor = (total_wins / total_losses) if total_losses > 0 else 0 + + avg_entries = sum(t.get('total_entries', 0) for t in self.closed_trades) / total_trades if total_trades > 0 else 0 + avg_invested = sum(t.get('total_cost', 0) for t in self.closed_trades) / total_trades if total_trades > 0 else 0 + + return { + 'total_trades': total_trades, + 'wins': wins, + 'losses': losses, + 'win_rate': win_rate, + 'total_pnl': total_pnl, + 'avg_pnl': avg_pnl, + 'best_win': best_win, + 'worst_loss': worst_loss, + 'profit_factor': profit_factor, + 'avg_entries': avg_entries, + 'avg_invested': avg_invested + } + + def _update_market_stats(self, market_slug: str): + """Update market statistics after entry""" + # Update entries count + if market_slug not in self.market_entries_count: + self.market_entries_count[market_slug] = 0 + self.market_entries_count[market_slug] += 1 + + # Initialize max drawdown if needed + if market_slug not in self.market_max_drawdown: + self.market_max_drawdown[market_slug] = 0.0 + + def update_market_drawdown(self, market_slug: str, unrealized_pnl: float): + """Update max drawdown for market if current is worse""" + if market_slug not in self.market_max_drawdown: + self.market_max_drawdown[market_slug] = 0.0 + + if unrealized_pnl < self.market_max_drawdown[market_slug]: + self.market_max_drawdown[market_slug] = unrealized_pnl + + def get_market_detailed_stats(self, market_slug: str, up_ask: float = 0.5, down_ask: float = 0.5) -> Optional[Dict]: + """ + Get detailed statistics for a market + + Args: + market_slug: Market identifier + up_ask: Current UP ask price + down_ask: Current DOWN ask price + + Returns: + Dict with detailed stats or None + """ + if market_slug not in self.positions: + return None + + pos = self.positions[market_slug] + + up_shares = pos['UP']['total_shares'] + down_shares = pos['DOWN']['total_shares'] + up_invested = pos['UP']['total_invested'] + down_invested = pos['DOWN']['total_invested'] + total_invested = up_invested + down_invested + + # Current value (unrealized) + current_value = (up_shares * up_ask) + (down_shares * down_ask) + unrealized_pnl = current_value - total_invested + unrealized_pct = (unrealized_pnl / total_invested * 100) if total_invested > 0 else 0 + + # ═══════════════════════════════════════════════════════════ + # 🚨 CHECK STOP-LOSS RIGHT HERE (where PnL is calculated!) + # ═══════════════════════════════════════════════════════════ + stop_loss_triggered = False + stop_loss_threshold = None + stop_loss_type = None + + # Get coin from market_slug (e.g., "btc-updown-15m-1768060800" -> "btc") + coin = market_slug.split('-')[0] if '-' in market_slug else '' + + # Check if we have config for stop-loss + if self.config and coin and total_invested > 0: + sl_config = self.config.get('exit', {}).get('stop_loss', {}).get('per_coin', {}).get(coin, {}) + sl_enabled = sl_config.get('enabled', False) + sl_type = sl_config.get('type', 'none') + sl_value = sl_config.get('value', None) + + if sl_enabled and sl_value is not None: + if sl_type == 'fixed': + # Fixed dollar amount (e.g., -$10) + stop_loss_threshold = sl_value + stop_loss_triggered = unrealized_pnl <= stop_loss_threshold + stop_loss_type = 'fixed' + elif sl_type == 'percent': + # Percentage of invested capital (e.g., -15%) + stop_loss_threshold = total_invested * (sl_value / 100.0) + stop_loss_triggered = unrealized_pnl <= stop_loss_threshold + stop_loss_type = 'percent' + + # ═══════════════════════════════════════════════════════════ + # 🚨 CHECK FLIP-STOP (price reversal protection) + # ═══════════════════════════════════════════════════════════ + flip_stop_triggered = False + flip_stop_price = None + + if self.config and coin and (up_shares > 0 or down_shares > 0): + flip_cfg = self.config.get('exit', {}).get('flip_stop', {}) + flip_stop_price = flip_cfg.get('price_threshold', 0.48) + + # Determine our side + our_side = 'UP' if up_shares > down_shares else 'DOWN' + our_price = up_ask if our_side == 'UP' else down_ask + + # Check if our side price dropped too low + if our_price <= flip_stop_price: + flip_stop_triggered = True + print(f"[FLIP-STOP] 🚨 {coin.upper()} {our_side} @ ${our_price:.4f} <= ${flip_stop_price:.4f} TRIGGERED!") + else: + # Log warning if price is getting close to flip-stop (within 25%) + if our_price < flip_stop_price * 1.25: + print(f"[FLIP-STOP] ⚠️ {coin.upper()} {our_side} @ ${our_price:.4f} close to ${flip_stop_price:.4f}") + + # Update drawdown with current unrealized PnL + self.update_market_drawdown(market_slug, unrealized_pnl) + + # Max drawdown + max_dd = self.market_max_drawdown.get(market_slug, 0.0) + max_dd_pct = (max_dd / total_invested * 100) if total_invested > 0 else 0 + + # Average entry prices + avg_up_price = up_invested / up_shares if up_shares > 0 else 0 + avg_down_price = down_invested / down_shares if down_shares > 0 else 0 + + # Entries count + entries_count = self.market_entries_count.get(market_slug, len(pos['all_entries'])) + + return { + 'up_shares': up_shares, + 'down_shares': down_shares, + 'up_invested': up_invested, + 'down_invested': down_invested, + 'total_invested': total_invested, + 'unrealized_pnl': unrealized_pnl, + 'unrealized_pct': unrealized_pct, + 'max_drawdown': max_dd, + 'max_drawdown_pct': max_dd_pct, + 'avg_up_price': avg_up_price, + 'avg_down_price': avg_down_price, + 'entries_count': entries_count, + 'stop_loss_triggered': stop_loss_triggered, + 'stop_loss_threshold': stop_loss_threshold, + 'stop_loss_type': stop_loss_type, + 'flip_stop_triggered': flip_stop_triggered, + 'flip_stop_price': flip_stop_price + } + + def _log_trade(self, trade: Dict): + """ + Log trade to file with maximum fault tolerance + + CRITICAL: This function MUST succeed or raise exception! + If it fails silently, we lose trade data! + """ + try: + # Ensure directory exists + self.trades_file.parent.mkdir(parents=True, exist_ok=True) + + # Write to file with explicit flush + with open(self.trades_file, 'a') as f: + f.write(json.dumps(trade) + '\n') + f.flush() # Force write to disk immediately + + except PermissionError as e: + print(f"[TRADER] ⚠️ PERMISSION ERROR logging trade: {e}") + print(f"[TRADER] ⚠️ Trade data: {trade}") + print(f"[TRADER] ⚠️ File: {self.trades_file}") + raise # Re-raise to prevent position deletion + + except OSError as e: + print(f"[TRADER] ⚠️ DISK ERROR logging trade: {e}") + print(f"[TRADER] ⚠️ Trade data: {trade}") + print(f"[TRADER] ⚠️ Check disk space: df -h") + raise # Re-raise to prevent position deletion + + except Exception as e: + print(f"[TRADER] ⚠️ UNKNOWN ERROR logging trade: {e}") + print(f"[TRADER] ⚠️ Trade data: {trade}") + import traceback + traceback.print_exc() + raise # Re-raise to prevent position deletion + + def save_session(self): + """Save current session state""" + try: + session = { + 'starting_capital': self.starting_capital, + 'current_capital': self.current_capital, + 'total_pnl': self.current_capital - self.starting_capital, + 'roi_pct': ((self.current_capital / self.starting_capital) - 1) * 100, + 'open_positions': len(self.positions), + 'closed_trades': len(self.closed_trades), + 'timestamp': time.strftime('%Y-%m-%d %H:%M:%S') + } + + with open(self.session_file, 'w') as f: + json.dump(session, f, indent=2) + + except Exception as e: + print(f"[TRADER] Error saving session: {e}") + + def log_entry_detailed(self, market_slug: str, side: str, contracts: int, + price: float, up_ask: float, down_ask: float, + winner_ratio: float, is_recovery: bool, + entry_reason: str, seconds_till_end: int, + time_from_start: int): + """ + Log detailed entry for backtesting analysis + + Args: + market_slug: Full market slug + side: 'UP' or 'DOWN' + contracts: Number of contracts + price: Entry price + up_ask: Current UP ask price + down_ask: Current DOWN ask price + winner_ratio: Current winner ratio (0.0-1.0) + is_recovery: Is this a recovery entry after WR < 40%? + entry_reason: 'normal' or 'recovery' + seconds_till_end: Seconds until market end + time_from_start: Seconds from market start + """ + import os + + # Create detailed logs directory + detailed_dir = str(self.log_dir).replace('/logs/', '/logs_detailed/') + Path(detailed_dir).mkdir(parents=True, exist_ok=True) + + # Get position data + if market_slug not in self.positions: + return + + pos = self.positions[market_slug] + + # Calculate current metrics + up_contracts = pos['UP']['total_shares'] + down_contracts = pos['DOWN']['total_shares'] + up_invested = pos['UP']['total_invested'] + down_invested = pos['DOWN']['total_invested'] + total_invested = up_invested + down_invested + total_contracts = up_contracts + down_contracts + entries_count = len(pos['all_entries']) + + # Calculate CORRECT unrealized PnL based on current market prices + current_value = (up_contracts * up_ask) + (down_contracts * down_ask) + unrealized_pnl = current_value - total_invested + unrealized_pnl_pct = (unrealized_pnl / total_invested * 100) if total_invested > 0 else 0 + + # Update max drawdown with current unrealized PnL BEFORE reading it + self.update_market_drawdown(market_slug, unrealized_pnl) + + # Calculate PnL scenarios if market resolves + if_up_wins = (up_contracts * 1.0) - total_invested + if_down_wins = (down_contracts * 1.0) - total_invested + + # Average prices + avg_up_price = (up_invested / up_contracts) if up_contracts > 0 else 0 + avg_down_price = (down_invested / down_contracts) if down_contracts > 0 else 0 + + # Get max drawdown for this market (after updating it above) + max_dd = self.market_max_drawdown.get(market_slug, 0.0) + max_dd_pct = (max_dd / total_invested * 100) if total_invested > 0 else 0 + + # Build entry data + entry_data = { + "timestamp": int(time.time()), + "market_slug": market_slug, + "seconds_till_end": seconds_till_end, + "time_from_start": time_from_start, + + "market_prices": { + "up_ask": round(up_ask, 3), + "down_ask": round(down_ask, 3), + "confidence": round(abs(down_ask - up_ask), 3) + }, + + "entry": { + "side": side, + "contracts": contracts, + "price": round(price, 3), + "cost": round(contracts * price, 2) + }, + + "position_after": { + "up_contracts": int(up_contracts), + "down_contracts": int(down_contracts), + "up_invested": round(up_invested, 2), + "down_invested": round(down_invested, 2), + "total_invested": round(total_invested, 2), + "total_contracts": int(total_contracts), + "entries_count": entries_count + }, + + "pnl_metrics": { + "unrealized_pnl": round(unrealized_pnl, 2), + "unrealized_pnl_pct": round(unrealized_pnl_pct, 2), + "max_drawdown": round(max_dd, 2), + "max_drawdown_pct": round(max_dd_pct, 2), + "if_up_wins": round(if_up_wins, 2), + "if_down_wins": round(if_down_wins, 2), + "avg_up_price": round(avg_up_price, 3), + "avg_down_price": round(avg_down_price, 3) + }, + + "strategy_state": { + "winner_ratio": round(winner_ratio, 3), + "is_recovery": is_recovery, + "entry_reason": entry_reason + } + } + + # Filename based on market slug + filename = f"{market_slug}_entries.jsonl" + filepath = os.path.join(detailed_dir, filename) + + # Append entry + with open(filepath, 'a') as f: + f.write(json.dumps(entry_data) + '\n') + + diff --git a/up-down-spread-bot/src/web_dashboard/__init__.py b/up-down-spread-bot/src/web_dashboard/__init__.py new file mode 100644 index 0000000..0254e1e --- /dev/null +++ b/up-down-spread-bot/src/web_dashboard/__init__.py @@ -0,0 +1 @@ +"""Web dashboard package (Flask UI + API).""" diff --git a/up-down-spread-bot/src/web_dashboard/server.py b/up-down-spread-bot/src/web_dashboard/server.py new file mode 100644 index 0000000..610c18d --- /dev/null +++ b/up-down-spread-bot/src/web_dashboard/server.py @@ -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) diff --git a/up-down-spread-bot/src/web_dashboard/snapshot_builder.py b/up-down-spread-bot/src/web_dashboard/snapshot_builder.py new file mode 100644 index 0000000..68bae61 --- /dev/null +++ b/up-down-spread-bot/src/web_dashboard/snapshot_builder.py @@ -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, + } diff --git a/up-down-spread-bot/src/web_dashboard/static/app.css b/up-down-spread-bot/src/web_dashboard/static/app.css new file mode 100644 index 0000000..c98e9c5 --- /dev/null +++ b/up-down-spread-bot/src/web_dashboard/static/app.css @@ -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); +} diff --git a/up-down-spread-bot/src/web_dashboard/static/app.js b/up-down-spread-bot/src/web_dashboard/static/app.js new file mode 100644 index 0000000..8eed151 --- /dev/null +++ b/up-down-spread-bot/src/web_dashboard/static/app.js @@ -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 `
${label}
${escapeHtml( + String(value) + )}
`; + } + + 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 = '
PositionNone
'; + if (x.position) { + const p = x.position; + posHtml = ` +
+
Unrealized${fmtUsd( + p.unrealized_pnl + )}
+
Invested$${p.total_invested}
+
Side / entries${p.our_side} · ${p.entries_count}
+
If UP wins${fmtUsd(p.if_up_wins)}
+
If DOWN wins${fmtUsd(p.if_down_wins)}
+
`; + } + return ` +
+

${c.toUpperCase()} + ${en ? "" : 'disabled'} +

+
Market${escapeHtml(slugShort)}
+
Time left${fmtTime(x.seconds_till_end)}
+
UP / DN ask${x.up_ask?.toFixed(3) ?? "—"} / ${x.down_ask?.toFixed( + 3 + ) ?? "—"}
+
Favorite · Conf${fav} · ${conf}
+
PnL (coin)${fmtUsd( + st.pnl + )}
+
W/L · WR${st.wins ?? 0}/${st.losses ?? 0} · ${st.win_rate ?? 0}%
+ ${posHtml} +
`; + }) + .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 ` + ${escapeHtml(t.strategy || "")} + ${escapeHtml(m || "")} + ${fmtUsd(pnl)} + ${escapeHtml(String(t.winner ?? ""))} + `; + }) + .join(""); + if (!rows.length) { + tbody.innerHTML = 'No closed trades this session yet'; + } + } + + 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); +})(); diff --git a/up-down-spread-bot/src/web_dashboard/templates/index.html b/up-down-spread-bot/src/web_dashboard/templates/index.html new file mode 100644 index 0000000..2b6649f --- /dev/null +++ b/up-down-spread-bot/src/web_dashboard/templates/index.html @@ -0,0 +1,66 @@ + + + + + + Meridian — Control & Analytics + + + +
+
+

Meridian

+

Polymarket desk · live status · settings · analytics

+
+ connecting… + + +
+
+ +
+

Session

+
+
+ +
+

Markets (live)

+
+
+ +
+

Recent closed trades

+
+ + + + + + + + + + +
StrategyMarketPnLWinner
+
+
+ +
+

Settings (edit JSON — restart bot to apply)

+
+ +
+ + +
+

+
+
+ +
+

API: /api/status, /api/config, POST /api/bot/stop · Start bot with python main.py --web

+
+
+ + + diff --git a/up-down-spread-bot/src/web_dashboard_state.py b/up-down-spread-bot/src/web_dashboard_state.py new file mode 100644 index 0000000..c5e2931 --- /dev/null +++ b/up-down-spread-bot/src/web_dashboard_state.py @@ -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