Initial commit — BTC 5-minute binary options edge study

Reconstructed strategy engine + execution layer, trained XGBoost models, a manual
trading tool, and the research writeup. Paper mode runs keyless over live WebSocket
feeds; live trading requires your own wallet. No secrets committed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Michelle
2026-07-24 22:49:05 -07:00
commit 4aa8789cba
35 changed files with 9793 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
# Secrets — never commit real keys
.env
.env.*
!.env.example
*.key
*.pem
# Runtime paper/collector output (regenerated on every run)
data/chainlink_predictor/*
!data/chainlink_predictor/.gitkeep
# Python
__pycache__/
*.pyc
.venv/
venv/
.pytest_cache/
# OS
.DS_Store
+233
View File
@@ -0,0 +1,233 @@
# BTC 5-Minute Binary Options — An Edge Feasibility Study
**Can a retail trader find a real, *capturable* edge in Polymarket's BTC up/down 5-minute binary options?**
I built a research system to answer that rigorously. The honest conclusion: **almost certainly not — and the reason why is more interesting than a "yes" would have been.**
*A market-microstructure research project in Python / asyncio: it reconstructs a Chainlink-style reference from 8 exchanges over live WebSockets, runs a family of XGBoost-gated strategies, and executes on Polymarket's CLOB. AI-assisted with Claude Code.*
---
## About this repository
This is a **reconstruction** — a runnable extraction of the strategy engine, the XGBoost models, and the execution layer from a larger private system I built and operated. It is provided **as-is and UNAUDITED**.
- **Paper mode runs with no keys** and reads live market data over WebSocket.
- The **execution layer places real orders with real funds** if you supply wallet credentials in `src/predictor/.env` — none are included (`.env.example` ships blank).
- If you intend to trade it: **audit the code yourself and accept the risk.** This is an engineering / research project, **not financial advice.**
Building it was AI-assisted with Claude Code: I designed the research questions, the market model, the strategy logic, and the architecture; Claude accelerated the implementation. The conclusions and the microstructure reasoning are mine.
---
## The market
Polymarket issues a fresh **BTC Up/Down** binary option every 5 minutes. At the close of each window it settles by a simple rule:
> If the **Chainlink BTC/USD reference price** at window close ≥ its value at the window **open** → **UP = $1.00, DOWN = $0.00**.
> Otherwise → **UP = $0.00, DOWN = $1.00**.
Each side trades as a token priced between **$0 and $1** on Polymarket's central limit order book (CLOB). The key fact that makes this analyzable:
> **A binary token's price *is* the market's implied probability of the outcome.**
> UP trading at **$0.60** means the market is pricing a **60%** chance BTC ends the window up.
So the whole game collapses to one question:
> **Can I estimate the true probability of "UP" more accurately than the market's price implies — *and act on it before the market reprices?***
That splits into three investigations. The first two ask *is there an edge?* The third asks *can you actually capture it?*
---
## Pillar 1 — Rebuilding the reference: 8 exchanges vs. the oracle
BTC does not have *one* price. It has a slightly different price on every exchange, updating at slightly different times — and settlement depends on the slowest of them all: the Chainlink oracle print.
So I rebuilt a **Chainlink-style reference in real time** by aggregating **8 exchanges**, while separately tracking the majors that move first and carry the most weight — **Coinbase, Binance, OKX**:
- **WebSocket trade streams:** Coinbase, Binance, OKX, Kraken, Bybit, Bitstamp
- **REST polls (1s — no free public trade WS):** Gemini, CryptoCompare
- **Polymarket itself, over two WebSockets:** the Chainlink price stream, and the CLOB order book (top 5 levels). REST is used only once per window, to discover the new market's token IDs.
The aggregation is deliberately not a naive average: sources stale by >2s are dropped, each venue gets a rolling bias correction, USDT-quoted venues (Binance/OKX/Bybit) are adjusted for the **USDT premium** (median USDT price median USD price), and the final reference is a **lead-weighted mean** with a freshness bonus for sources that ticked in the last 500ms.
The question: does the **leadlag between venues** create a *timing* edge? The measured hierarchy — the study's central empirical result:
```
1. The CLOB moves first — market makers reprice the book ~45s before
Coinbase's own tape fully reflects the move
2. Coinbase & majors follow
3. The Chainlink oracle prints last — the settlement reference is the
slowest object in the room
```
There *is* a measurable leadlag. The catch: the fastest thing in the market is the order book itself — which means the people you'd be racing have already moved.
---
## Pillar 2 — A directional signal from a rich parameter set
Beyond raw price, can BTC's behavior inside the window fold into a sharper directional estimate than the market's implied odds? The signal stack is deliberately wide — several families of parameters, not one:
- **Displacement & clock** — oracle move since window open (`pm_a`), distance to strike, time remaining
- **Cross-venue edge** — spot leaders vs. the oracle, both raw and EMA-smoothed (raw is faster; smoothed resists single-tick noise)
- **CLOB microstructure** — mid, spread, ask *velocity* (how fast makers are repricing), one-sided buy-volume imbalance
- **Momentum & intensity** — oracle-move acceleration, trade counts
- **ML layer** — XGBoost models scoring late-window outcomes (top features: own ask level, ask margin, |oracle move|, trade count, move acceleration, ask velocity)
The core intuition — and the cleanest math in the study:
> Late in the window, with a clear gap between spot and the strike and low remaining volatility, the outcome becomes highly predictable. To flip, the price would have to move a lot, fast, in the time left.
Modeling the price as a random walk to settlement, the probability of finishing on the current side of the strike is approximately:
```
P(UP) ≈ Φ( gap / ( σ · √(time_remaining) ) )
```
- `gap` = current spot the strike (the reference price at window open)
- `σ` = short-horizon volatility
- `Φ` = the normal CDF
Then the trade signal is simply the mispricing:
```
edge = P_estimate P_market # P_market = the token's current price
```
Trade only when `edge` is large **and** late in the window (small `time_remaining`), where the estimate is most reliable. That is exactly where a real edge, if one exists, would live.
### Strategy families: taker and maker
The signals feed two kinds of strategies, mirroring the two ways to trade a book:
- **Taker** (cross the spread when a signal fires): *edge reversal* — in the final seconds, a large cross-venue edge opposing the current leader → buy the reversal side; *CLOB momentum* — the book's ask rising fast means makers are repricing → follow them; *CLOB volume* — one side absorbing 5× the flow → follow it; *early lock* — oracle acceleration + volume doubling + book confirmation → buy the likely winner *early*, at ~0.67 instead of ~0.93, for 34× better payoff on the same thesis.
- **Maker** (rest passive limit bids and let the market come to you): cheap trailing-side bids with a GTD expiry — a $1 "option" on a late flip — and counter-consensus resting bids that only fill if a stop-loss cascade briefly dislocates the book. No fill, no risk.
Exits run a ladder of aggression: post the ask → step to the bid → cross at market.
One economics finding worth stating plainly: **low win-rate + cheap entry + high payoff beats high win-rate + expensive entry.** When market makers price accurately, the "safe" side's profit is compressed to zero — a 96%-win-rate strategy can still lose money at the odds you're offered.
---
## Machine learning — and what a weak model proved
To pressure-test whether the directional signal was more than eyeballed thresholds, I trained gradient-boosted trees (XGBoost) as the ML layer. Two model families (all shipped, trained, and loadable in `models/`):
- **Outcome / "lock" model** — 15 pre-trigger features (`ask`, `abs_pm_a`, `left`, `rv_5m`, `rv_15m`, `ask_margin`, `pm_a_accel`, `ask_vel`, `vol_ratio`, `total_vol`, `sign_changes`, …). Label: *does the current leader hold to settlement?* Used as a gate — only take the trade if the model's confidence clears a threshold.
- **Direction models** — a **separate 32-feature model per time-to-close slice** (models at 250s, 200s, …, 10s remaining), because late-window dynamics differ from early. Features span both the asset (`cb_a`, `bn_a`, `edge`, `rv_5m`, `funding`, `oi_chg`, `pm_a_z`) and the counterparty (`bid_ratio`, `ask_from_peak`, `vol_ratio`, `total_vol`).
**How I framed it, and read it honestly:** the label is the settlement outcome, the metric is AUC, and the result was **deliberately unimpressive — direction AUC ≈ 0.666** (0.50 is a coin flip; 0.70 is where a signal starts being useful). The lock gate had to be *lowered* from 0.90 to 0.75 before it would release enough trades to matter, and the lock strategy it gated still finished at a **96% win rate but negative PnL** — accurate predictions, wrong odds.
That weakness is the finding, not a failure. Two things fall out of the feature importances:
> **The most predictive features are the market maker's own quote** — `ask`, `ask_margin`, `ask_velocity` — not BTC's price dynamics. The model taught itself that the single best estimate of the outcome is *what the maker is already charging.* You cannot beat a signal that **is** the counterparty. A near-random AUC on BTC-derived features is direct evidence that the exploitable information isn't in the price — it's already priced.
**See it yourself:** `python ml/xgb_eval.py` loads the trained models and prints their feature importances (the maker's quote on top) plus the per-entry-price economics from real paper trades (every price bucket is net-negative).
**What I'd do next (unexplored):** every model here predicts the *outcome*. The open direction is to change the **label** — model the **counterparty's next action** (will the maker pull the quote, widen the spread, shade the book?) rather than the asset. That reframes the problem from *out-predicting BTC* (a race you lose to better infrastructure) to *detecting maker behavior* (where retail-observable microstructure might actually carry signal). The codebase has a seed of this — a `maker_bias` strategy built on "the maker knows the outcome, follow their bias" — but a proper counterparty model is the study's most interesting untested hypothesis.
---
## Pillar 3 — Execution: a faster manual trading interface
A correct signal is worthless if you can't act on it in time. Polymarket's native UI is **too slow** to place an order inside the seconds when an edge exists — and it's poor for building intuition about how the book behaves under time pressure.
So I built a **hotkey terminal interface** over the CLOB (`tools/quick_trade.py`): raw-keyboard mode where a single keystroke fires an order (buy up/down at the ask, sell at the bid, one-cent "lottery" limit bids), order parameters pre-warmed and cached so submission skips per-order lookups, prices streamed from a local feed instead of fetched per click — and every order prints its measured latency in milliseconds. Full product write-up: [docs/QUICK_TRADE_PRD.md](docs/QUICK_TRADE_PRD.md).
But building it surfaced the deeper execution problem — and it isn't clicking fast. **You don't buy; you post an order and hope.** Every trade is a little state machine:
```
quote (best ask) → post → { full fill | partial fill | no fill } → retry / cancel
```
On a thin binary book, partial fills and no-fills are the norm, not the exception. Each retry chases an ask that is *already moving* — the maker reprices the instant you show intent — so you either cross to a worse level (slippage, straight out of a margin that was pennies to begin with) or you miss and the window closes. Exits face the same gauntlet as a ladder of escalating aggression (post the ask → step to the bid → cross at market), with a circuit breaker that halts trading when round-trip latency spikes. That loop — quote → partial → retry → fill — routinely takes **longer than the edge itself exists**.
This pillar is where the thesis breaks: **the latency inside the fill lifecycle, plus the slippage from thin liquidity, eats essentially the entire edge.** A signal that's correct and a UI that's instant still lose money at the point of the fill.
---
## Operating reality — what actually broke
Running this against live funds is where the thesis stopped being theoretical. These failures are first-hand — and they are *why* an edge that looks real on paper doesn't survive contact with the chain:
- **Latency** — the round trip from signal to acknowledged order regularly outran the seconds an edge exists.
- **Quoted vs. filled** — the ask you decide on and the price you actually pay diverge, worst during the fast moves where the signal fires.
- **Can't buy / can't sell** — thin books mean orders partially fill or don't fill at all; exiting a position under time pressure is not guaranteed.
- **Opening-balance desync** — on-chain settled state and the bot's local view drift apart (a redemption not yet reflected, a fill counted twice), and a wrong balance quietly corrupts the next decision.
- **Fragile to change** — every strategy tweak surfaced a new on-chain edge case: nonce/gas, order expiry, reprice races, partial-fill accounting — none of it visible in backtest.
None of these are bugs to be fixed away; they are the structural tax of executing at retail on a chain-settled venue. Together they are the concrete form of the conclusion: the math can find an edge; the execution layer eats it.
---
## The honest conclusion: retail cannot win this
Even when the analysis surfaces a theoretical edge, **retail can't capture it.**
- The market makers consume the same exchange feeds this system does — and reprice the book in **milliseconds**. By the time a retail-latency system, let alone a human on hotkeys, detects the move and submits an order, the ask has already moved. The signal hierarchy in Pillar 1 is the proof: the book is *faster* than the exchanges it's pricing.
- The directional edge from Pillar 2 is real but thin, and it is **exactly what the market makers are also computing**, faster, with better infrastructure. Watching maker behavior over several weeks of tick data made this vivid: for a stretch, makers were slow to reprice late-window reversals — and within about three weeks they had adapted and that edge collapsed. The market *learns*.
- Pillar 3 and the operating reality above make the ceiling concrete: even a heavily optimized execution path is too slow, and the fill lifecycle plus thin-book slippage consumes what little edge survives.
**The math can find the edge. Market structure makes it uncapturable at retail speed and cost.** Compute, co-location, and latency consume the entire edge before it reaches you. This is structurally an **institutional** game — which is the single most useful finding of the study.
For anyone building consumer-facing trading products, that's the lesson worth carrying: **on any market where the edge is faster than the retail user, the honest product isn't "help them trade the edge" — it's designing for the game retail can actually win.**
---
## What's in the repo, and how to run it
```
tools/chainlink_predictor.py the live engine — reconstructs the reference, runs the
strategies, prints signals + (paper) fills
tools/quick_trade.py hotkey MANUAL trading terminal (live only) — see the PRD
src/predictor/ the real engine: strategy.py (the ~4k-line core), collector,
feeds, orderbook, pnl — and the live executor
ml/ XGBoost: offline model inspection, training scripts, a sample
models/ the trained XGBoost models (lock + per-slice direction + hold7s)
docs/QUICK_TRADE_PRD.md product write-up of the manual trading tool
```
Requires **Python 3.10+** (3.13 recommended). Set up once:
```bash
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
```
| What | Command | Wallet keys? |
|---|---|---|
| Strategy engine, **paper** — reads live data, simulates fills, prints records | `python tools/chainlink_predictor.py --shares 5` | no |
| Inspect the trained **XGBoost** models (offline, instant) | `python ml/xgb_eval.py` | no |
| Strategy engine, **live** — real automated orders | `python tools/chainlink_predictor.py --trade --shares 5` | yes |
| **Manual** trading terminal — real orders (run the engine in another terminal for prices) | `python tools/quick_trade.py` | yes |
**Paper mode** connects to Polymarket + 8 exchange WebSockets and needs nothing but internet and the market being open. (Binance blocks some regions with HTTP 451 — that source drops out and the reference uses the rest; non-fatal.)
**Live trading** needs your own wallet in `src/predictor/.env` (copy `.env.example` and fill it in) **and** a Polymarket CLOB client. The live path uses a client fork that is not on PyPI, so **live execution isn't reproducible out of the box; paper is.** No keys ship with this repo. Re-read [About this repository](#about-this-repository) before trading anything.
---
## System architecture
```
Coinbase ── WS ──┐
Binance ─── WS ──┤
OKX ──────── WS ──┤ 8-exchange reference
Kraken ───── WS ──┼ (staleness-filtered,
Bybit ────── WS ──┤ bias-corrected, ┌─► Strategy ─► Executor
Bitstamp ─── WS ──┤ lead-weighted mean) ──►│ (P_estimate, (risk gate, fills,
Gemini ──── REST ─┤ │ edge, maker paper PnL, or live
CryptoCompare REST┘ │ + taker orders on the CLOB)
Polymarket ─ WS (Chainlink price) ──► Merger ┘ signals)
Polymarket ─ WS (CLOB order book) ──► (one clock for many feeds)
```
- **Feeds** — independent async clients per venue (WebSocket where available, 1s REST polls otherwise); each normalizes into a common tick model.
- **Collector / Merger** — combines multi-source ticks into a single time-ordered market state (one clock for many feeds) and reconstructs the reference.
- **Strategy** — computes the reference, the gap, `P_estimate`, and the `edge`; runs the taker/maker strategies and the XGBoost gates; emits a signal only inside the gate (late window, edge threshold).
- **Executor** — simulates fills for paper mode, or (with `--trade` and your keys) signs and posts real orders to the CLOB and settles on-chain.
## Stack & authorship
Python · asyncio · aiohttp · numpy · XGBoost · web3 / py-clob-client (live path). Built **AI-assisted with Claude Code**: I designed the research questions, the market model, the strategy logic, and the architecture; Claude accelerated the implementation. The conclusions and the microstructure reasoning are mine. This public repo is a reconstruction of a larger private system I built and operated.
View File
+104
View File
@@ -0,0 +1,104 @@
# Mini-PRD — Quick-Trade: a hotkey execution interface for 5-minute binaries
> *Written retrospectively: this documents the product decisions behind a tool I built and used during the study. The tool itself belongs to the full (private) system — no live-trading code ships in this repo.*
## Problem
A 5-minute binary market prices in new information within seconds; the actionable moment for any late-window signal is the **final 1040 seconds**. Polymarket's native web UI needs multiple clicks and page-state changes to place one order, gives no feedback on how long an order actually took, and is unusable for building intuition about how the book behaves under time pressure.
If Pillar 3 of the study asks *"can a human capture the edge at all?"*, the native UI answers "no" for the wrong reason — **tool latency, not market latency**. To make the test fair, execution overhead had to be driven to its floor and *measured*, so that whatever latency remained was attributable to the market, not the mouse.
## User
One power user: me. (n = 1 by design — this is a research instrument, not a consumer product. That constraint drives most scope cuts below.)
## Goals & success metrics
| Goal | Metric |
|---|---|
| Minimal click-to-submit path | **One keystroke** = one signed, posted order (no Enter, no confirmation dialog) |
| Latency visible, not assumed | Every order prints its **measured sign-and-submit round-trip in ms** (`create_order` + `post_order` → API ack) |
| Fill outcome reported, not assumed | After the order: **filled or not, matched size, fill price, and PnL** — read from the order's `size_matched` |
| Zero per-click network overhead beyond the order itself | Price display and order parameters require **0 extra HTTP calls** at trade time |
| Usable across windows without operator work | New 5-minute market auto-discovered and re-warmed in the background |
## Non-goals (explicit cuts)
- **No variable order size** — fixed 10 shares per keystroke. Fewer keys to think about, and it caps fat-finger damage.
- **No order management** — no cancel/modify UI. GTD expiry does the cleanup.
- **No position dashboard, no charts** — the separate watch screen owns market display; this tool owns the trigger.
- **No multi-market support** — current window only.
## Functional requirements (the entire keyboard surface)
| Key | Action |
|---|---|
| `u` / `d` | Buy UP / DOWN, 10 shares, at market (ask + $0.02 buffer, capped at $0.99) |
| `Shift`+`1``6` | Resting limit "lottery" bids at $0.01 / $0.02 / $0.03 — cheap maker orders (`1``3` = UP, `4``6` = DOWN) |
| `i` / `o` | Sell UP / DOWN at best bid, most expensive lot first |
| `b` / `r` / `w` | Balance check · redeem settled tokens · force window refresh |
| `q` | Quit (restores terminal state) |
## Component structure
```
① PRICE FEED — always live in the background. THIS is why a click needs no network:
Polymarket WebSocket ──► collector ──► snapshots.jsonl ──► tail -F ──► Price panel
(chainlink price + (local file, the (live bid/ask,
CLOB book, real-time) collector streams it) countdown)
② HOT PATH — fires on ONE keystroke; it reads the current ask from the Price panel
above (a local read), so no network call is needed to price the order:
keystroke (raw tty, no Enter)
Key listener (select() loop)
Order router u/d = market · cheap limits · i/o = sell
Preflight cache tick_size + neg_risk, warmed once per window
Order builder + signer client-side signing; HTTP/1.1 keep-alive
REST post_order (GTD) ──► Polymarket CLOB
③ REPORTING — every order writes one line to a JSONL journal (the study's dataset):
• on submit: sign → ack latency (ms)
• at settle: poll order status (REST) ──► size_matched · fill price · PnL
Background: a window manager auto-detects each new 5-minute market, resolves its
token IDs (one REST call), and re-warms the Preflight cache before the window opens.
```
## Key decisions & tradeoffs
| Decision | Why | Cost accepted |
|---|---|---|
| Single keystroke, no confirmation | The confirmation dialog *is* the latency problem | Fat-finger risk — mitigated by fixed small size |
| Fixed 10-share size | Removes a decision (and keystrokes) from the hot path | No sizing flexibility |
| Prices from a local file tail, not REST | The collector already streams every tick to disk; reading it costs ~0ms and no rate limits | Depends on the collector running |
| Warm order params once per window | `tick_size`/`neg_risk` lookups are per-token constants; fetching them per order wastes a round trip inside the hot path | Stale cache on rare market config change |
| Market buys pay ask + $0.02 | In the last seconds, a missed fill costs more than 2¢ of price improvement | Slightly worse average entry |
| Sell most-expensive-lot-first | Realizes the best exit first when unwinding under time pressure | — |
| Terminal UI, not web | tty raw mode delivers a keypress in microseconds; a browser adds an event loop you don't control | No visual polish |
## The real cost center: the fill lifecycle
The interface optimizes the *submit* path — but a keystroke doesn't buy anything; it opens an order lifecycle:
```
quote (best ask) → post → { full fill | partial fill | no fill } → retry / cancel
```
This is deliberately **not** managed in the tool (fixed size, GTD auto-expiry does the cleanup — see Non-goals), and that scoping choice is exactly where the honesty lives: **order management is the pain, not a feature I could polish away.** On a thin binary book, partial fills and no-fills are routine. Each retry chases an ask the maker has already moved, so you cross to a worse price (slippage) or miss entirely. The exit runs the same lifecycle as a ladder — post the ask → step to the bid → cross at market — and a latency circuit breaker halts trading when the round trip degrades. The full loop regularly takes longer than the edge exists. **Latency inside this lifecycle plus thin-book slippage consumes essentially the entire margin** — which is why the fast-submit interface, though necessary, was never going to be sufficient.
## Outcome
The interface doubled as the study's **measurement instrument**: every order writes one line to a JSONL journal — sign-and-submit latency, whether it filled, matched size, fill price, PnL. That log is the raw dataset behind Pillar 3, turning "execution is slow" from a hunch into distributions you can read. Order latency became a **measured number printed after every trade** instead of a guess, and — together with the fill-lifecycle cost above — that is what makes Pillar 3's conclusion honest: even with the human reduced to a single keystroke and the software path stripped to its floor, the remaining round-trip is orders of magnitude slower than the market makers' millisecond repricing. The bottleneck was never the UI; it's the speed of light plus someone else's colo. **Knowing that changed the product question** — from "how do I trade this faster?" to "what game can retail actually win?" (see the study's conclusion in the [README](../README.md)).
+400
View File
@@ -0,0 +1,400 @@
{"strategy": "clob_volume", "ask": 0.59, "pm_a": 20.69, "left": 11.0, "rv_5m": 4.61, "rv_15m": 4.46, "sim_a_vol": 9.11, "direction": "UP", "won": false, "pnl_5sh": -2.95}
{"strategy": "cheap_winner", "ask": 0.67, "pm_a": -50.95, "left": 35.0, "rv_5m": 4.01, "rv_15m": 3.75, "sim_a_vol": 11.33, "direction": "DOWN", "won": true, "pnl_5sh": 1.65}
{"strategy": "xgb_dir_140", "ask": 0.83, "pm_a": -52.0, "left": 140.0, "rv_5m": 6.03, "rv_15m": 5.75, "sim_a_vol": 14.16, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.93, "pm_a": 53.71, "left": 140.0, "rv_5m": 2.35, "rv_15m": 1.85, "sim_a_vol": 4.8, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.92, "pm_a": -45.11, "left": 140.0, "rv_5m": 2.68, "rv_15m": 3.38, "sim_a_vol": 3.53, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "lock", "ask": 0.94, "pm_a": -63.11, "left": 119.0, "rv_5m": 3.32, "rv_15m": 5.03, "sim_a_vol": 22.97, "direction": "DOWN", "won": true, "pnl_5sh": 0.3}
{"strategy": "depth_cheap", "ask": 0.58, "pm_a": -9.31, "left": 250.0, "rv_5m": 2.52, "rv_15m": 2.85, "sim_a_vol": 9.98, "direction": "DOWN", "won": false, "pnl_5sh": -2.9}
{"strategy": "xgb_dir_140", "ask": 0.29, "pm_a": 4.84, "left": 140.0, "rv_5m": 3.37, "rv_15m": 3.61, "sim_a_vol": 8.23, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "depth_cheap", "ask": 0.59, "pm_a": -11.18, "left": 250.0, "rv_5m": 5.03, "rv_15m": 6.31, "sim_a_vol": 11.41, "direction": "DOWN", "won": true, "pnl_5sh": 2.05}
{"strategy": "xgb_dir_140", "ask": 0.98, "pm_a": -75.22, "left": 140.0, "rv_5m": 2.24, "rv_15m": 1.78, "sim_a_vol": 13.75, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.98, "pm_a": 86.39, "left": 140.0, "rv_5m": 3.81, "rv_15m": 3.81, "sim_a_vol": 6.44, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.64, "pm_a": 14.14, "left": 140.0, "rv_5m": 6.36, "rv_15m": 6.09, "sim_a_vol": 27.94, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "mid_flip", "ask": 0.42, "pm_a": -2.44, "left": 205.0, "rv_5m": 11.23, "rv_15m": 10.22, "sim_a_vol": 42.3, "direction": "DOWN", "won": false, "pnl_5sh": -2.1}
{"strategy": "depth_cheap", "ask": 0.5, "pm_a": 2.65, "left": 233.0, "rv_5m": 2.41, "rv_15m": 3.0, "sim_a_vol": 1.56, "direction": "DOWN", "won": true, "pnl_5sh": 2.5}
{"strategy": "depth_cheap", "ask": 0.45, "pm_a": 11.5, "left": 248.0, "rv_5m": 4.82, "rv_15m": 5.41, "sim_a_vol": 7.82, "direction": "DOWN", "won": false, "pnl_5sh": -2.25}
{"strategy": "xgb_dir_140", "ask": 0.74, "pm_a": -15.31, "left": 140.0, "rv_5m": 5.0, "rv_15m": 4.18, "sim_a_vol": 6.08, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "maker_bias", "ask": 0.13, "pm_a": -85.07, "left": 200.0, "rv_5m": 4.15, "rv_15m": 5.67, "sim_a_vol": 9.77, "direction": "UP", "won": true, "pnl_5sh": 4.35}
{"strategy": "maker_bias", "ask": 0.6, "pm_a": 14.65, "left": 200.0, "rv_5m": 2.13, "rv_15m": 2.23, "sim_a_vol": 3.91, "direction": "UP", "won": true, "pnl_5sh": 2.0}
{"strategy": "xgb_dir_140", "ask": 0.63, "pm_a": -14.76, "left": 140.0, "rv_5m": 0.0, "rv_15m": 0.0, "sim_a_vol": 15.83, "direction": "DOWN", "won": true, "pnl_5sh": 1.85}
{"strategy": "lock", "ask": 0.96, "pm_a": -248.13, "left": 138.7, "rv_5m": 12.32, "rv_15m": 12.3, "sim_a_vol": 33.05, "direction": "DOWN", "won": true, "pnl_5sh": 0.2}
{"strategy": "xgb_dir_140", "ask": 0.7, "pm_a": 10.22, "left": 140.0, "rv_5m": 2.87, "rv_15m": 2.76, "sim_a_vol": 1.55, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_50", "ask": 0.94, "pm_a": 68.14, "left": 50.0, "rv_5m": 5.52, "rv_15m": 5.85, "sim_a_vol": 23.35, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "depth_cheap", "ask": 0.59, "pm_a": 8.04, "left": 250.0, "rv_5m": 3.51, "rv_15m": 3.37, "sim_a_vol": 3.8, "direction": "UP", "won": true, "pnl_5sh": 2.05}
{"strategy": "xgb_dir_10", "ask": 0.79, "pm_a": 35.43, "left": 15.0, "rv_5m": 5.75, "rv_15m": 5.87, "sim_a_vol": 19.52, "direction": "UP", "won": true, "pnl_5sh": 1.05}
{"strategy": "xgb_dir_140", "ask": 0.89, "pm_a": 67.42, "left": 140.0, "rv_5m": 5.81, "rv_15m": 5.78, "sim_a_vol": 17.41, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "spread_arb", "ask": 0.3, "pm_a": -43.91, "left": 282.0, "rv_5m": 7.24, "rv_15m": 5.17, "sim_a_vol": 3.09, "direction": "UP", "won": false, "pnl_5sh": -1.5}
{"strategy": "depth_cheap", "ask": 0.59, "pm_a": -6.45, "left": 202.0, "rv_5m": 1.85, "rv_15m": 2.59, "sim_a_vol": 1.88, "direction": "DOWN", "won": false, "pnl_5sh": -2.95}
{"strategy": "xgb_dir_140", "ask": 0.85, "pm_a": -34.11, "left": 140.0, "rv_5m": 3.56, "rv_15m": 4.41, "sim_a_vol": 13.14, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.72, "pm_a": -23.78, "left": 106.9, "rv_5m": 20.01, "rv_15m": 20.55, "sim_a_vol": 14.69, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "depth_cheap", "ask": 0.59, "pm_a": -84.1, "left": 129.0, "rv_5m": 3.76, "rv_15m": 4.0, "sim_a_vol": 10.65, "direction": "DOWN", "won": true, "pnl_5sh": 2.05}
{"strategy": "xgb_dir_140", "ask": 0.61, "pm_a": -3.95, "left": 140.0, "rv_5m": 2.55, "rv_15m": 2.84, "sim_a_vol": 3.53, "direction": "DOWN", "won": false, "pnl_5sh": -3.05}
{"strategy": "depth_cheap", "ask": 0.55, "pm_a": -12.92, "left": 241.0, "rv_5m": 4.36, "rv_15m": 4.2, "sim_a_vol": 18.58, "direction": "DOWN", "won": true, "pnl_5sh": 2.25}
{"strategy": "clob_momentum", "ask": 0.51, "pm_a": 3.29, "left": 29.0, "rv_5m": 4.95, "rv_15m": 4.54, "sim_a_vol": 4.37, "direction": "DOWN", "won": true, "pnl_5sh": 2.45}
{"strategy": "xgb_dir_140", "ask": 0.53, "pm_a": 8.38, "left": 140.0, "rv_5m": 2.92, "rv_15m": 3.59, "sim_a_vol": 3.99, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "maker_bias", "ask": 0.81, "pm_a": -18.39, "left": 85.0, "rv_5m": 2.57, "rv_15m": 3.01, "sim_a_vol": 2.57, "direction": "DOWN", "won": false, "pnl_5sh": -4.05}
{"strategy": "xgb_dir_140", "ask": 0.95, "pm_a": -106.34, "left": 140.0, "rv_5m": 7.68, "rv_15m": 5.92, "sim_a_vol": 12.93, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "cheap_hours", "ask": 0.45, "pm_a": 0.52, "left": 296.0, "rv_5m": 3.92, "rv_15m": 3.81, "sim_a_vol": 9.8, "direction": "DOWN", "won": true, "pnl_5sh": 2.75}
{"strategy": "depth_cheap", "ask": 0.24, "pm_a": -11.34, "left": 241.0, "rv_5m": 2.52, "rv_15m": 4.03, "sim_a_vol": 19.06, "direction": "UP", "won": false, "pnl_5sh": -1.2}
{"strategy": "depth_cheap", "ask": 0.54, "pm_a": -6.07, "left": 227.0, "rv_5m": 2.44, "rv_15m": 2.96, "sim_a_vol": 6.1, "direction": "UP", "won": true, "pnl_5sh": 2.3}
{"strategy": "xgb_dir_140", "ask": 0.69, "pm_a": -9.21, "left": 140.0, "rv_5m": 1.38, "rv_15m": 1.96, "sim_a_vol": 1.2, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "break", "ask": 0.53, "pm_a": -2.94, "left": 20.0, "rv_5m": 6.17, "rv_15m": 6.25, "sim_a_vol": 19.42, "direction": "UP", "won": true, "pnl_5sh": 0.0}
{"strategy": "xgb_dir_140", "ask": 0.68, "pm_a": 18.4, "left": 140.0, "rv_5m": 4.45, "rv_15m": 4.08, "sim_a_vol": 16.32, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.72, "pm_a": -18.85, "left": 140.0, "rv_5m": 2.24, "rv_15m": 2.58, "sim_a_vol": 2.24, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.79, "pm_a": -27.73, "left": 140.0, "rv_5m": 3.32, "rv_15m": 3.41, "sim_a_vol": 2.01, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "depth_cheap", "ask": 0.09, "pm_a": -82.8, "left": 192.0, "rv_5m": 3.89, "rv_15m": 3.85, "sim_a_vol": 10.57, "direction": "UP", "won": false, "pnl_5sh": -0.45}
{"strategy": "xgb_dir_140", "ask": 0.82, "pm_a": 37.46, "left": 140.0, "rv_5m": 2.52, "rv_15m": 6.02, "sim_a_vol": 17.88, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.67, "pm_a": 19.86, "left": 140.0, "rv_5m": 4.54, "rv_15m": 5.1, "sim_a_vol": 1.51, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.83, "pm_a": -66.77, "left": 140.0, "rv_5m": 10.04, "rv_15m": 12.45, "sim_a_vol": 21.8, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "depth_cheap", "ask": 0.31, "pm_a": -54.9, "left": 241.0, "rv_5m": 9.16, "rv_15m": 9.23, "sim_a_vol": 17.78, "direction": "UP", "won": false, "pnl_5sh": -1.55}
{"strategy": "xgb_dir_140", "ask": 0.54, "pm_a": -3.98, "left": 140.0, "rv_5m": 1.7, "rv_15m": 2.57, "sim_a_vol": 5.58, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.93, "pm_a": 2.2, "left": 40.0, "rv_5m": 9.41, "rv_15m": 7.88, "sim_a_vol": 29.84, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.71, "pm_a": -12.78, "left": 140.0, "rv_5m": 3.09, "rv_15m": 3.32, "sim_a_vol": 10.86, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "depth_cheap", "ask": 0.52, "pm_a": 13.07, "left": 250.0, "rv_5m": 7.37, "rv_15m": 9.29, "sim_a_vol": 9.98, "direction": "UP", "won": true, "pnl_5sh": 2.4}
{"strategy": "xgb_dir_140", "ask": 0.93, "pm_a": -73.56, "left": 140.0, "rv_5m": 5.39, "rv_15m": 6.2, "sim_a_vol": 9.81, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "depth_cheap", "ask": 0.59, "pm_a": 6.54, "left": 241.0, "rv_5m": 4.19, "rv_15m": 3.42, "sim_a_vol": 24.27, "direction": "UP", "won": false, "pnl_5sh": -2.95}
{"strategy": "spread_arb", "ask": 0.64, "pm_a": -20.15, "left": 287.0, "rv_5m": 2.05, "rv_15m": 4.87, "sim_a_vol": 7.4, "direction": "DOWN", "won": true, "pnl_5sh": 1.8}
{"strategy": "xgb_dir_140", "ask": 0.4, "pm_a": 8.85, "left": 140.0, "rv_5m": 1.9, "rv_15m": 2.35, "sim_a_vol": 4.51, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "depth_cheap", "ask": 0.53, "pm_a": 4.47, "left": 248.0, "rv_5m": 4.98, "rv_15m": 4.43, "sim_a_vol": 61.24, "direction": "DOWN", "won": false, "pnl_5sh": -2.65}
{"strategy": "maker_bias", "ask": 0.62, "pm_a": 7.37, "left": 164.0, "rv_5m": 4.4, "rv_15m": 3.92, "sim_a_vol": 10.27, "direction": "UP", "won": true, "pnl_5sh": 1.9}
{"strategy": "clob_rev", "ask": 0.22, "pm_a": -29.72, "left": 42.0, "rv_5m": 6.74, "rv_15m": 5.94, "sim_a_vol": 5.49, "direction": "UP", "won": false, "pnl_5sh": -1.1}
{"strategy": "xgb_dir_140", "ask": 0.91, "pm_a": 92.49, "left": 140.0, "rv_5m": 8.0, "rv_15m": 9.98, "sim_a_vol": 28.18, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.71, "pm_a": -11.56, "left": 140.0, "rv_5m": 1.39, "rv_15m": 1.45, "sim_a_vol": 3.67, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.67, "pm_a": 25.82, "left": 140.0, "rv_5m": 4.57, "rv_15m": 7.68, "sim_a_vol": 7.69, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "early_lock", "ask": 0.69, "pm_a": 50.23, "left": 226.0, "rv_5m": 5.84, "rv_15m": 6.62, "sim_a_vol": 22.05, "direction": "UP", "won": false, "pnl_5sh": -3.45}
{"strategy": "xgb_dir_140", "ask": 0.89, "pm_a": -43.39, "left": 140.0, "rv_5m": 4.57, "rv_15m": 3.32, "sim_a_vol": 5.57, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "cheap_winner", "ask": 0.42, "pm_a": 34.39, "left": 7.0, "rv_5m": 2.8, "rv_15m": 1.98, "sim_a_vol": 1.62, "direction": "DOWN", "won": true, "pnl_5sh": 0.0}
{"strategy": "depth_cheap", "ask": 0.24, "pm_a": 54.35, "left": 230.0, "rv_5m": 25.95, "rv_15m": 19.8, "sim_a_vol": 22.84, "direction": "DOWN", "won": false, "pnl_5sh": 0.0}
{"strategy": "cb_confirm", "ask": 0.7, "pm_a": -20.59, "left": 271.0, "rv_5m": 4.23, "rv_15m": 5.58, "sim_a_vol": 6.98, "direction": "DOWN", "won": false, "pnl_5sh": -3.5}
{"strategy": "depth_cheap", "ask": 0.57, "pm_a": 18.31, "left": 227.0, "rv_5m": 4.91, "rv_15m": 4.26, "sim_a_vol": 9.89, "direction": "UP", "won": true, "pnl_5sh": 2.15}
{"strategy": "xgb_dir_140", "ask": 0.69, "pm_a": 28.01, "left": 137.0, "rv_5m": 3.16, "rv_15m": 4.21, "sim_a_vol": 5.96, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.36, "pm_a": -6.42, "left": 140.0, "rv_5m": 1.32, "rv_15m": 2.07, "sim_a_vol": 2.2, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "open_hedge", "ask": 0.38, "pm_a": 0.16, "left": 292.1, "rv_5m": 3.89, "rv_15m": 4.47, "sim_a_vol": 43.83, "direction": "DOWN", "won": false, "pnl_5sh": -1.9}
{"strategy": "depth_cheap", "ask": 0.47, "pm_a": 1.82, "left": 214.0, "rv_5m": 2.18, "rv_15m": 2.3, "sim_a_vol": 1.22, "direction": "DOWN", "won": true, "pnl_5sh": 2.65}
{"strategy": "depth_cheap", "ask": 0.59, "pm_a": 24.22, "left": 218.0, "rv_5m": 5.57, "rv_15m": 5.19, "sim_a_vol": 11.6, "direction": "UP", "won": true, "pnl_5sh": 2.05}
{"strategy": "edge_reversal", "ask": 0.12, "pm_a": 31.61, "left": 24.0, "rv_5m": 5.24, "rv_15m": 5.96, "sim_a_vol": 10.64, "direction": "DOWN", "won": false, "pnl_5sh": -0.6}
{"strategy": "depth_cheap", "ask": 0.59, "pm_a": -19.66, "left": 247.0, "rv_5m": 6.57, "rv_15m": 7.13, "sim_a_vol": 47.91, "direction": "DOWN", "won": true, "pnl_5sh": 2.05}
{"strategy": "open_hedge", "ask": 0.32, "pm_a": -37.68, "left": 245.5, "rv_5m": 5.52, "rv_15m": 8.52, "sim_a_vol": 33.3, "direction": "UP", "won": false, "pnl_5sh": -1.6}
{"strategy": "xgb_dir_140", "ask": 0.91, "pm_a": 46.12, "left": 140.0, "rv_5m": 2.68, "rv_15m": 3.23, "sim_a_vol": 5.09, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "lock", "ask": 0.9, "pm_a": 129.07, "left": 139.9, "rv_5m": 8.84, "rv_15m": 8.84, "sim_a_vol": 48.36, "direction": "UP", "won": false, "pnl_5sh": -4.5}
{"strategy": "depth_cheap", "ask": 0.56, "pm_a": -26.18, "left": 250.0, "rv_5m": 6.69, "rv_15m": 6.98, "sim_a_vol": 11.32, "direction": "DOWN", "won": true, "pnl_5sh": 2.2}
{"strategy": "xgb_dir_140", "ask": 0.83, "pm_a": -24.94, "left": 140.0, "rv_5m": 3.48, "rv_15m": 3.85, "sim_a_vol": 4.5, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.9, "pm_a": -38.15, "left": 140.0, "rv_5m": 3.3, "rv_15m": 2.48, "sim_a_vol": 5.45, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "lock", "ask": 0.99, "pm_a": -65.06, "left": 42.0, "rv_5m": 5.47, "rv_15m": 4.49, "sim_a_vol": 24.24, "direction": "DOWN", "won": true, "pnl_5sh": 0.0}
{"strategy": "xgb_dir_140", "ask": 0.96, "pm_a": 94.07, "left": 140.0, "rv_5m": 4.87, "rv_15m": 6.24, "sim_a_vol": 9.43, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "depth_cheap", "ask": 0.5, "pm_a": 17.4, "left": 224.0, "rv_5m": 5.39, "rv_15m": 4.03, "sim_a_vol": 9.14, "direction": "DOWN", "won": false, "pnl_5sh": -2.5}
{"strategy": "lock", "ask": 0.99, "pm_a": -63.09, "left": 41.0, "rv_5m": 3.22, "rv_15m": 5.49, "sim_a_vol": 4.38, "direction": "DOWN", "won": true, "pnl_5sh": 0.05}
{"strategy": "lock", "ask": 0.95, "pm_a": 120.81, "left": 140.0, "rv_5m": 5.99, "rv_15m": 5.8, "sim_a_vol": 16.2, "direction": "UP", "won": true, "pnl_5sh": 0.25}
{"strategy": "mid_flip", "ask": 0.25, "pm_a": -12.6, "left": 96.0, "rv_5m": 2.61, "rv_15m": 4.55, "sim_a_vol": 9.81, "direction": "UP", "won": true, "pnl_5sh": 3.75}
{"strategy": "xgb_dir_140", "ask": 0.59, "pm_a": 24.27, "left": 140.0, "rv_5m": 2.39, "rv_15m": 2.74, "sim_a_vol": 2.82, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.82, "pm_a": -51.98, "left": 140.0, "rv_5m": 5.03, "rv_15m": 6.73, "sim_a_vol": 3.49, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "cheap_winner", "ask": 0.78, "pm_a": 23.45, "left": 39.0, "rv_5m": 4.29, "rv_15m": 2.93, "sim_a_vol": 8.84, "direction": "UP", "won": true, "pnl_5sh": 1.1}
{"strategy": "xgb_dir_50", "ask": 0.83, "pm_a": -20.83, "left": 50.0, "rv_5m": 3.78, "rv_15m": 4.2, "sim_a_vol": 4.64, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "lock", "ask": 0.97, "pm_a": 166.57, "left": 140.0, "rv_5m": 8.75, "rv_15m": 6.94, "sim_a_vol": 14.67, "direction": "UP", "won": true, "pnl_5sh": 0.15}
{"strategy": "open_hedge", "ask": 0.38, "pm_a": 4.51, "left": 20.5, "rv_5m": 4.29, "rv_15m": 6.86, "sim_a_vol": 2.34, "direction": "UP", "won": true, "pnl_5sh": 3.1}
{"strategy": "xgb_dir_140", "ask": 0.9, "pm_a": 39.74, "left": 140.0, "rv_5m": 2.19, "rv_15m": 3.95, "sim_a_vol": 6.95, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "depth_cheap", "ask": 0.59, "pm_a": 41.06, "left": 169.0, "rv_5m": 7.36, "rv_15m": 5.67, "sim_a_vol": 7.46, "direction": "UP", "won": false, "pnl_5sh": -2.95}
{"strategy": "depth_cheap", "ask": 0.11, "pm_a": -54.43, "left": 122.0, "rv_5m": 4.72, "rv_15m": 5.33, "sim_a_vol": 8.06, "direction": "UP", "won": false, "pnl_5sh": -0.55}
{"strategy": "depth_cheap", "ask": 0.48, "pm_a": -6.88, "left": 249.0, "rv_5m": 3.79, "rv_15m": 4.23, "sim_a_vol": 25.6, "direction": "DOWN", "won": true, "pnl_5sh": 2.6}
{"strategy": "cheap_winner", "ask": 0.57, "pm_a": -32.94, "left": 24.0, "rv_5m": 2.55, "rv_15m": 2.57, "sim_a_vol": 0.73, "direction": "DOWN", "won": true, "pnl_5sh": 2.15}
{"strategy": "cheap_winner", "ask": 0.44, "pm_a": -13.76, "left": 33.0, "rv_5m": 4.28, "rv_15m": 4.12, "sim_a_vol": 7.63, "direction": "UP", "won": true, "pnl_5sh": 2.8}
{"strategy": "spread_arb", "ask": 0.63, "pm_a": -28.11, "left": 263.0, "rv_5m": 3.24, "rv_15m": 3.22, "sim_a_vol": 14.14, "direction": "DOWN", "won": true, "pnl_5sh": 1.85}
{"strategy": "depth_cheap", "ask": 0.54, "pm_a": 44.49, "left": 158.0, "rv_5m": 2.03, "rv_15m": 2.15, "sim_a_vol": 3.78, "direction": "UP", "won": true, "pnl_5sh": 2.3}
{"strategy": "xgb_dir_140", "ask": 0.59, "pm_a": -5.87, "left": 140.0, "rv_5m": 3.75, "rv_15m": 6.11, "sim_a_vol": 9.81, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "depth_cheap", "ask": 0.56, "pm_a": -27.18, "left": 250.0, "rv_5m": 4.43, "rv_15m": 4.06, "sim_a_vol": 30.14, "direction": "DOWN", "won": false, "pnl_5sh": -2.8}
{"strategy": "spread_arb", "ask": 0.6, "pm_a": -13.09, "left": 284.0, "rv_5m": 3.74, "rv_15m": 3.61, "sim_a_vol": 8.84, "direction": "DOWN", "won": true, "pnl_5sh": 2.0}
{"strategy": "last7s", "ask": 0.92, "pm_a": 44.4, "left": 19.9, "rv_5m": 2.76, "rv_15m": 3.6, "sim_a_vol": 18.56, "direction": "UP", "won": true, "pnl_5sh": 0.4}
{"strategy": "xgb_dir_140", "ask": 0.62, "pm_a": 5.8, "left": 140.0, "rv_5m": 3.5, "rv_15m": 3.6, "sim_a_vol": 5.57, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "depth_cheap", "ask": 0.46, "pm_a": -8.81, "left": 157.0, "rv_5m": 2.68, "rv_15m": 4.47, "sim_a_vol": 5.53, "direction": "UP", "won": false, "pnl_5sh": -2.3}
{"strategy": "xgb_dir_140", "ask": 0.67, "pm_a": -23.21, "left": 140.0, "rv_5m": 3.11, "rv_15m": 3.38, "sim_a_vol": 12.06, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "cb_flip", "ask": 0.52, "pm_a": -8.83, "left": 29.4, "rv_5m": 3.44, "rv_15m": 3.83, "sim_a_vol": 5.22, "direction": "UP", "won": false, "pnl_5sh": -3.6}
{"strategy": "xgb_dir_140", "ask": 0.92, "pm_a": 26.77, "left": 140.0, "rv_5m": 1.97, "rv_15m": 2.12, "sim_a_vol": 10.14, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "open_hedge", "ask": 1.0, "pm_a": 0.0, "left": 299.8, "rv_5m": 8.96, "rv_15m": 7.51, "sim_a_vol": 28.92, "direction": "DOWN", "won": true, "pnl_5sh": 0.0}
{"strategy": "last_flip", "ask": 0.51, "pm_a": 10.01, "left": 30.0, "rv_5m": 3.07, "rv_15m": 3.75, "sim_a_vol": 11.32, "direction": "UP", "won": true, "pnl_5sh": 2.45}
{"strategy": "last7s", "ask": 1.0, "pm_a": -192.28, "left": 19.7, "rv_5m": 5.05, "rv_15m": 4.69, "sim_a_vol": 15.06, "direction": "DOWN", "won": true, "pnl_5sh": 0.0}
{"strategy": "maker_bias", "ask": 0.93, "pm_a": -42.94, "left": 200.0, "rv_5m": 2.52, "rv_15m": 2.76, "sim_a_vol": 7.89, "direction": "DOWN", "won": true, "pnl_5sh": 0.35}
{"strategy": "depth_cheap", "ask": 0.51, "pm_a": 11.64, "left": 250.0, "rv_5m": 4.81, "rv_15m": 4.01, "sim_a_vol": 46.38, "direction": "UP", "won": false, "pnl_5sh": -2.55}
{"strategy": "depth_cheap", "ask": 0.58, "pm_a": -36.0, "left": 216.0, "rv_5m": 5.65, "rv_15m": 5.2, "sim_a_vol": 6.3, "direction": "DOWN", "won": true, "pnl_5sh": 2.1}
{"strategy": "depth_cheap", "ask": 0.52, "pm_a": 8.75, "left": 241.0, "rv_5m": 4.01, "rv_15m": 4.73, "sim_a_vol": 9.34, "direction": "DOWN", "won": false, "pnl_5sh": -2.6}
{"strategy": "depth_cheap", "ask": 0.23, "pm_a": 27.45, "left": 208.0, "rv_5m": 6.44, "rv_15m": 5.74, "sim_a_vol": 14.46, "direction": "DOWN", "won": true, "pnl_5sh": 3.85}
{"strategy": "depth_cheap", "ask": 0.58, "pm_a": 43.63, "left": 250.0, "rv_5m": 5.44, "rv_15m": 5.59, "sim_a_vol": 25.44, "direction": "UP", "won": false, "pnl_5sh": -2.9}
{"strategy": "depth_cheap", "ask": 0.58, "pm_a": -21.01, "left": 245.0, "rv_5m": 3.09, "rv_15m": 3.96, "sim_a_vol": 17.85, "direction": "DOWN", "won": false, "pnl_5sh": -2.9}
{"strategy": "xgb_dir_140", "ask": 0.9, "pm_a": 49.13, "left": 140.0, "rv_5m": 3.76, "rv_15m": 4.85, "sim_a_vol": 10.32, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "cb_confirm", "ask": 0.7, "pm_a": -7.87, "left": 226.0, "rv_5m": 3.47, "rv_15m": 4.29, "sim_a_vol": 6.68, "direction": "DOWN", "won": true, "pnl_5sh": 1.5}
{"strategy": "xgb_dir_140", "ask": 0.64, "pm_a": -8.19, "left": 140.0, "rv_5m": 1.43, "rv_15m": 1.81, "sim_a_vol": 1.49, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "lock", "ask": 1.0, "pm_a": 92.5, "left": 3.0, "rv_5m": 4.97, "rv_15m": 4.52, "sim_a_vol": 11.09, "direction": "UP", "won": true, "pnl_5sh": 0.0}
{"strategy": "xgb_dir_140", "ask": 0.84, "pm_a": -51.05, "left": 140.0, "rv_5m": 6.17, "rv_15m": 10.72, "sim_a_vol": 10.26, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "depth_cheap", "ask": 0.12, "pm_a": 47.9, "left": 192.0, "rv_5m": 3.23, "rv_15m": 2.88, "sim_a_vol": 6.19, "direction": "DOWN", "won": false, "pnl_5sh": -0.6}
{"strategy": "xgb_dir_140", "ask": 0.86, "pm_a": 49.1, "left": 140.0, "rv_5m": 3.45, "rv_15m": 3.41, "sim_a_vol": 10.46, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "spread_arb", "ask": 0.33, "pm_a": -32.75, "left": 269.0, "rv_5m": 5.47, "rv_15m": 6.9, "sim_a_vol": 26.04, "direction": "UP", "won": true, "pnl_5sh": 3.35}
{"strategy": "depth_cheap", "ask": 0.41, "pm_a": 16.39, "left": 239.0, "rv_5m": 2.76, "rv_15m": 2.97, "sim_a_vol": 6.87, "direction": "UP", "won": true, "pnl_5sh": 2.95}
{"strategy": "lock", "ask": 0.95, "pm_a": -62.38, "left": 116.0, "rv_5m": 5.25, "rv_15m": 4.53, "sim_a_vol": 8.02, "direction": "DOWN", "won": true, "pnl_5sh": 0.25}
{"strategy": "xgb_dir_140", "ask": 0.88, "pm_a": 60.78, "left": 140.0, "rv_5m": 2.54, "rv_15m": 2.6, "sim_a_vol": 11.37, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "last7s", "ask": 0.97, "pm_a": -52.32, "left": 19.3, "rv_5m": 4.25, "rv_15m": 4.78, "sim_a_vol": 7.37, "direction": "DOWN", "won": true, "pnl_5sh": 0.15}
{"strategy": "xgb_dir_140", "ask": 0.46, "pm_a": 3.37, "left": 140.0, "rv_5m": 3.09, "rv_15m": 3.37, "sim_a_vol": 2.82, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.97, "pm_a": 62.34, "left": 140.0, "rv_5m": 3.83, "rv_15m": 3.96, "sim_a_vol": 3.84, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "spread_arb", "ask": 0.32, "pm_a": -57.48, "left": 261.0, "rv_5m": 5.12, "rv_15m": 5.09, "sim_a_vol": 48.61, "direction": "UP", "won": false, "pnl_5sh": -1.6}
{"strategy": "depth_cheap", "ask": 0.55, "pm_a": -5.25, "left": 244.0, "rv_5m": 4.03, "rv_15m": 4.13, "sim_a_vol": 20.91, "direction": "DOWN", "won": true, "pnl_5sh": 2.25}
{"strategy": "depth_cheap", "ask": 0.59, "pm_a": 27.06, "left": 250.0, "rv_5m": 6.8, "rv_15m": 7.89, "sim_a_vol": 14.9, "direction": "UP", "won": false, "pnl_5sh": -2.95}
{"strategy": "mid_flip", "ask": 0.38, "pm_a": -17.38, "left": 123.0, "rv_5m": 6.62, "rv_15m": 4.51, "sim_a_vol": 13.36, "direction": "UP", "won": false, "pnl_5sh": -1.9}
{"strategy": "spread_arb", "ask": 0.34, "pm_a": -24.0, "left": 279.0, "rv_5m": 5.08, "rv_15m": 7.41, "sim_a_vol": 24.13, "direction": "UP", "won": true, "pnl_5sh": 3.3}
{"strategy": "depth_cheap", "ask": 0.55, "pm_a": 7.16, "left": 238.0, "rv_5m": 4.14, "rv_15m": 3.85, "sim_a_vol": 9.44, "direction": "UP", "won": true, "pnl_5sh": 2.25}
{"strategy": "depth_cheap", "ask": 0.57, "pm_a": -19.52, "left": 250.0, "rv_5m": 6.91, "rv_15m": 6.26, "sim_a_vol": 17.41, "direction": "DOWN", "won": false, "pnl_5sh": -2.85}
{"strategy": "depth_cheap", "ask": 0.58, "pm_a": 7.09, "left": 138.0, "rv_5m": 4.95, "rv_15m": 5.03, "sim_a_vol": 2.85, "direction": "UP", "won": true, "pnl_5sh": 2.1}
{"strategy": "xgb_dir_140", "ask": 0.98, "pm_a": -140.6, "left": 140.0, "rv_5m": 8.66, "rv_15m": 5.5, "sim_a_vol": 20.92, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.52, "pm_a": -8.79, "left": 140.0, "rv_5m": 5.97, "rv_15m": 4.72, "sim_a_vol": 13.12, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "lock", "ask": 0.94, "pm_a": 51.62, "left": 134.1, "rv_5m": 3.67, "rv_15m": 4.26, "sim_a_vol": 2.21, "direction": "UP", "won": true, "pnl_5sh": 0.3}
{"strategy": "depth_cheap", "ask": 0.56, "pm_a": 13.35, "left": 218.0, "rv_5m": 4.59, "rv_15m": 6.19, "sim_a_vol": 16.54, "direction": "UP", "won": true, "pnl_5sh": 2.2}
{"strategy": "xgb_dir_140", "ask": 0.76, "pm_a": -21.61, "left": 140.0, "rv_5m": 3.5, "rv_15m": 3.52, "sim_a_vol": 10.14, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.82, "pm_a": 36.24, "left": 140.0, "rv_5m": 4.22, "rv_15m": 4.39, "sim_a_vol": 7.2, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "depth_cheap", "ask": 0.35, "pm_a": 15.74, "left": 217.0, "rv_5m": 1.7, "rv_15m": 2.99, "sim_a_vol": 2.8, "direction": "DOWN", "won": false, "pnl_5sh": -1.75}
{"strategy": "xgb_dir_140", "ask": 0.96, "pm_a": 85.05, "left": 140.0, "rv_5m": 4.25, "rv_15m": 4.02, "sim_a_vol": 17.41, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.67, "pm_a": -38.08, "left": 140.0, "rv_5m": 9.7, "rv_15m": 8.05, "sim_a_vol": 12.57, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.63, "pm_a": 38.96, "left": 140.0, "rv_5m": 2.45, "rv_15m": 2.36, "sim_a_vol": 8.5, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "open_hedge", "ask": 1.0, "pm_a": 0.0, "left": 300.0, "rv_5m": 7.04, "rv_15m": 6.54, "sim_a_vol": 14.05, "direction": "UP", "won": true, "pnl_5sh": 0.0}
{"strategy": "depth_cheap", "ask": 0.51, "pm_a": -7.33, "left": 250.0, "rv_5m": 4.17, "rv_15m": 5.15, "sim_a_vol": 5.12, "direction": "DOWN", "won": true, "pnl_5sh": 2.45}
{"strategy": "xgb_dir_140", "ask": 0.73, "pm_a": -10.24, "left": 140.0, "rv_5m": 2.16, "rv_15m": 3.04, "sim_a_vol": 5.75, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "depth_cheap", "ask": 0.31, "pm_a": 27.2, "left": 199.0, "rv_5m": 1.11, "rv_15m": 1.14, "sim_a_vol": 4.56, "direction": "DOWN", "won": false, "pnl_5sh": -1.55}
{"strategy": "lock", "ask": 0.88, "pm_a": -62.91, "left": 116.0, "rv_5m": 5.1, "rv_15m": 7.54, "sim_a_vol": 15.18, "direction": "DOWN", "won": false, "pnl_5sh": -4.4}
{"strategy": "depth_cheap", "ask": 0.04, "pm_a": -149.41, "left": 218.0, "rv_5m": 4.2, "rv_15m": 3.31, "sim_a_vol": 47.4, "direction": "UP", "won": false, "pnl_5sh": -0.2}
{"strategy": "xgb_dir_140", "ask": 0.74, "pm_a": 12.57, "left": 140.0, "rv_5m": 1.73, "rv_15m": 2.7, "sim_a_vol": 5.85, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.54, "pm_a": -5.66, "left": 140.0, "rv_5m": 3.76, "rv_15m": 4.17, "sim_a_vol": 2.86, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 1.0, "pm_a": -8.46, "left": 136.0, "rv_5m": 12.65, "rv_15m": 20.59, "sim_a_vol": 19.67, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.73, "pm_a": 6.65, "left": 140.0, "rv_5m": 1.5, "rv_15m": 1.73, "sim_a_vol": 4.62, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.68, "pm_a": 10.42, "left": 140.0, "rv_5m": 2.46, "rv_15m": 2.56, "sim_a_vol": 6.18, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "depth_cheap", "ask": 0.29, "pm_a": 28.06, "left": 216.0, "rv_5m": 2.38, "rv_15m": 3.17, "sim_a_vol": 6.98, "direction": "DOWN", "won": false, "pnl_5sh": -1.45}
{"strategy": "depth_cheap", "ask": 0.32, "pm_a": -11.41, "left": 249.0, "rv_5m": 26.55, "rv_15m": 20.11, "sim_a_vol": 20.14, "direction": "DOWN", "won": false, "pnl_5sh": -1.6}
{"strategy": "mid_flip", "ask": 0.47, "pm_a": -15.06, "left": 240.0, "rv_5m": 5.65, "rv_15m": 4.58, "sim_a_vol": 15.32, "direction": "UP", "won": true, "pnl_5sh": 2.65}
{"strategy": "xgb_dir_140", "ask": 0.65, "pm_a": -23.72, "left": 140.0, "rv_5m": 7.6, "rv_15m": 7.15, "sim_a_vol": 11.1, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "spread_arb", "ask": 0.63, "pm_a": 21.58, "left": 260.0, "rv_5m": 4.03, "rv_15m": 3.83, "sim_a_vol": 30.22, "direction": "UP", "won": true, "pnl_5sh": 1.85}
{"strategy": "depth_cheap", "ask": 0.42, "pm_a": 4.28, "left": 186.0, "rv_5m": 3.14, "rv_15m": 3.23, "sim_a_vol": 2.95, "direction": "DOWN", "won": false, "pnl_5sh": -2.1}
{"strategy": "xgb_dir_250", "ask": 0.41, "pm_a": -7.53, "left": 260.0, "rv_5m": 3.58, "rv_15m": 4.07, "sim_a_vol": 30.11, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.55, "pm_a": 15.05, "left": 140.0, "rv_5m": 3.52, "rv_15m": 5.38, "sim_a_vol": 5.66, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "depth_cheap", "ask": 0.44, "pm_a": 4.34, "left": 198.0, "rv_5m": 3.0, "rv_15m": 2.72, "sim_a_vol": 3.82, "direction": "DOWN", "won": false, "pnl_5sh": -2.2}
{"strategy": "lock", "ask": 0.61, "pm_a": 60.01, "left": 134.0, "rv_5m": 19.73, "rv_15m": 18.16, "sim_a_vol": 25.04, "direction": "UP", "won": true, "pnl_5sh": 1.95}
{"strategy": "xgb_dir_140", "ask": 0.95, "pm_a": -97.38, "left": 140.0, "rv_5m": 13.62, "rv_15m": 9.36, "sim_a_vol": 12.35, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "maker_bias", "ask": 0.31, "pm_a": -47.61, "left": 176.0, "rv_5m": 6.61, "rv_15m": 5.89, "sim_a_vol": 9.03, "direction": "UP", "won": true, "pnl_5sh": 3.45}
{"strategy": "xgb_dir_140", "ask": 0.38, "pm_a": 15.8, "left": 140.0, "rv_5m": 3.76, "rv_15m": 2.75, "sim_a_vol": 1.97, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "depth_cheap", "ask": 0.52, "pm_a": -0.96, "left": 242.0, "rv_5m": 3.9, "rv_15m": 3.5, "sim_a_vol": 18.65, "direction": "DOWN", "won": true, "pnl_5sh": 2.4}
{"strategy": "xgb_dir_140", "ask": 0.68, "pm_a": -35.32, "left": 140.0, "rv_5m": 3.86, "rv_15m": 3.17, "sim_a_vol": 2.88, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.95, "pm_a": 52.15, "left": 140.0, "rv_5m": 3.04, "rv_15m": 2.46, "sim_a_vol": 5.56, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.97, "pm_a": 94.41, "left": 140.0, "rv_5m": 4.82, "rv_15m": 4.28, "sim_a_vol": 12.62, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.4, "pm_a": 6.93, "left": 140.0, "rv_5m": 2.25, "rv_15m": 2.19, "sim_a_vol": 1.37, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "open_hedge", "ask": 0.55, "pm_a": 12.35, "left": 294.5, "rv_5m": 9.04, "rv_15m": 7.46, "sim_a_vol": 52.54, "direction": "UP", "won": false, "pnl_5sh": -2.75}
{"strategy": "open_hedge", "ask": 0.58, "pm_a": 14.97, "left": 294.4, "rv_5m": 4.86, "rv_15m": 5.13, "sim_a_vol": 46.91, "direction": "UP", "won": true, "pnl_5sh": 2.1}
{"strategy": "xgb_dir_140", "ask": 0.73, "pm_a": -17.68, "left": 140.0, "rv_5m": 2.39, "rv_15m": 3.1, "sim_a_vol": 4.85, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.85, "pm_a": 30.31, "left": 140.0, "rv_5m": 3.22, "rv_15m": 3.24, "sim_a_vol": 3.93, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "depth_cheap", "ask": 0.52, "pm_a": 1.78, "left": 242.0, "rv_5m": 4.75, "rv_15m": 3.77, "sim_a_vol": 21.55, "direction": "UP", "won": true, "pnl_5sh": 2.4}
{"strategy": "xgb_dir_140", "ask": 0.93, "pm_a": -53.07, "left": 140.0, "rv_5m": 2.36, "rv_15m": 2.8, "sim_a_vol": 4.93, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "depth_cheap", "ask": 0.58, "pm_a": -21.33, "left": 240.0, "rv_5m": 3.77, "rv_15m": 3.78, "sim_a_vol": 31.56, "direction": "DOWN", "won": false, "pnl_5sh": -2.9}
{"strategy": "xgb_dir_140", "ask": 0.79, "pm_a": 15.47, "left": 140.0, "rv_5m": 1.15, "rv_15m": 1.25, "sim_a_vol": 2.25, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "maker_bias", "ask": 0.72, "pm_a": -37.52, "left": 200.0, "rv_5m": 5.35, "rv_15m": 6.24, "sim_a_vol": 23.14, "direction": "DOWN", "won": false, "pnl_5sh": -3.6}
{"strategy": "depth_cheap", "ask": 0.53, "pm_a": 11.38, "left": 216.0, "rv_5m": 1.85, "rv_15m": 1.33, "sim_a_vol": 3.29, "direction": "UP", "won": false, "pnl_5sh": -2.65}
{"strategy": "xgb_dir_140", "ask": 0.73, "pm_a": 19.97, "left": 140.0, "rv_5m": 3.63, "rv_15m": 2.87, "sim_a_vol": 4.57, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.58, "pm_a": -22.49, "left": 140.0, "rv_5m": 73.22, "rv_15m": 42.63, "sim_a_vol": 9.67, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.95, "pm_a": 71.62, "left": 140.0, "rv_5m": 4.25, "rv_15m": 3.68, "sim_a_vol": 9.76, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_100", "ask": 0.6, "pm_a": 9.25, "left": 110.0, "rv_5m": 3.93, "rv_15m": 3.45, "sim_a_vol": 5.4, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "depth_cheap", "ask": 0.59, "pm_a": -29.75, "left": 109.0, "rv_5m": 2.89, "rv_15m": 3.26, "sim_a_vol": 5.59, "direction": "DOWN", "won": true, "pnl_5sh": 2.05}
{"strategy": "depth_cheap", "ask": 0.49, "pm_a": -1.43, "left": 188.0, "rv_5m": 3.07, "rv_15m": 3.69, "sim_a_vol": 2.02, "direction": "UP", "won": true, "pnl_5sh": 2.55}
{"strategy": "xgb_dir_140", "ask": 0.95, "pm_a": -65.45, "left": 140.0, "rv_5m": 2.64, "rv_15m": 2.97, "sim_a_vol": 2.4, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.56, "pm_a": 7.66, "left": 140.0, "rv_5m": 2.4, "rv_15m": 2.61, "sim_a_vol": 3.33, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "depth_cheap", "ask": 0.43, "pm_a": 4.05, "left": 150.0, "rv_5m": 1.89, "rv_15m": 2.35, "sim_a_vol": 4.37, "direction": "UP", "won": false, "pnl_5sh": -2.15}
{"strategy": "lock", "ask": 0.98, "pm_a": -62.92, "left": 71.0, "rv_5m": 2.59, "rv_15m": 2.72, "sim_a_vol": 13.07, "direction": "DOWN", "won": true, "pnl_5sh": 0.1}
{"strategy": "xgb_dir_140", "ask": 0.84, "pm_a": 35.25, "left": 140.0, "rv_5m": 6.21, "rv_15m": 7.16, "sim_a_vol": 11.39, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "cheap_hours", "ask": 0.45, "pm_a": -5.08, "left": 288.0, "rv_5m": 5.56, "rv_15m": 4.68, "sim_a_vol": 22.92, "direction": "UP", "won": true, "pnl_5sh": 2.75}
{"strategy": "depth_cheap", "ask": 0.34, "pm_a": 14.56, "left": 146.0, "rv_5m": 4.14, "rv_15m": 4.14, "sim_a_vol": 22.06, "direction": "DOWN", "won": false, "pnl_5sh": -1.7}
{"strategy": "xgb_dir_140", "ask": 0.64, "pm_a": 2.72, "left": 140.0, "rv_5m": 4.3, "rv_15m": 4.02, "sim_a_vol": 8.18, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "bounce", "ask": 0.75, "pm_a": -27.6, "left": 50.0, "rv_5m": 2.3, "rv_15m": 2.73, "sim_a_vol": 4.12, "direction": "DOWN", "won": false, "pnl_5sh": -3.75}
{"strategy": "last_flip", "ask": 0.94, "pm_a": -0.95, "left": 3.0, "rv_5m": 5.19, "rv_15m": 4.58, "sim_a_vol": 3.14, "direction": "DOWN", "won": true, "pnl_5sh": 0.3}
{"strategy": "depth_cheap", "ask": 0.56, "pm_a": 7.05, "left": 235.0, "rv_5m": 4.08, "rv_15m": 4.15, "sim_a_vol": 16.94, "direction": "UP", "won": false, "pnl_5sh": -2.8}
{"strategy": "xgb_dir_140", "ask": 0.58, "pm_a": -8.1, "left": 140.0, "rv_5m": 4.02, "rv_15m": 3.82, "sim_a_vol": 8.66, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "open_hedge", "ask": 1.0, "pm_a": 0.0, "left": 299.8, "rv_5m": 7.39, "rv_15m": 8.2, "sim_a_vol": 14.23, "direction": "DOWN", "won": true, "pnl_5sh": 0.0}
{"strategy": "depth_cheap", "ask": 0.53, "pm_a": -12.41, "left": 244.0, "rv_5m": 8.19, "rv_15m": 9.64, "sim_a_vol": 107.62, "direction": "DOWN", "won": true, "pnl_5sh": 2.35}
{"strategy": "xgb_dir_140", "ask": 0.93, "pm_a": 31.15, "left": 140.0, "rv_5m": 1.6, "rv_15m": 2.09, "sim_a_vol": 10.94, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "depth_cheap", "ask": 0.12, "pm_a": 49.56, "left": 154.0, "rv_5m": 3.43, "rv_15m": 3.91, "sim_a_vol": 4.96, "direction": "DOWN", "won": false, "pnl_5sh": -0.6}
{"strategy": "xgb_dir_140", "ask": 0.98, "pm_a": 108.77, "left": 140.0, "rv_5m": 3.88, "rv_15m": 5.55, "sim_a_vol": 10.98, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "depth_cheap", "ask": 0.34, "pm_a": 33.6, "left": 242.0, "rv_5m": 4.02, "rv_15m": 3.65, "sim_a_vol": 33.63, "direction": "DOWN", "won": true, "pnl_5sh": 3.3}
{"strategy": "depth_cheap", "ask": 0.58, "pm_a": -12.21, "left": 214.0, "rv_5m": 2.15, "rv_15m": 2.55, "sim_a_vol": 4.11, "direction": "DOWN", "won": true, "pnl_5sh": 2.1}
{"strategy": "depth_cheap", "ask": 0.56, "pm_a": -3.44, "left": 246.0, "rv_5m": 5.03, "rv_15m": 6.8, "sim_a_vol": 46.38, "direction": "UP", "won": true, "pnl_5sh": 2.2}
{"strategy": "depth_cheap", "ask": 0.43, "pm_a": 29.52, "left": 249.0, "rv_5m": 3.37, "rv_15m": 3.66, "sim_a_vol": 19.42, "direction": "UP", "won": true, "pnl_5sh": 2.85}
{"strategy": "depth_cheap", "ask": 0.59, "pm_a": -20.04, "left": 248.0, "rv_5m": 5.1, "rv_15m": 5.64, "sim_a_vol": 34.78, "direction": "DOWN", "won": false, "pnl_5sh": -2.95}
{"strategy": "depth_cheap", "ask": 0.55, "pm_a": 0.61, "left": 231.0, "rv_5m": 3.61, "rv_15m": 3.91, "sim_a_vol": 7.61, "direction": "UP", "won": false, "pnl_5sh": -2.75}
{"strategy": "cb_flip", "ask": 0.19, "pm_a": -34.98, "left": 28.0, "rv_5m": 10.45, "rv_15m": 9.32, "sim_a_vol": 34.81, "direction": "UP", "won": false, "pnl_5sh": -1.95}
{"strategy": "xgb_dir_140", "ask": 0.83, "pm_a": 92.48, "left": 140.0, "rv_5m": 6.86, "rv_15m": 6.63, "sim_a_vol": 14.82, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.66, "pm_a": -11.89, "left": 140.0, "rv_5m": 3.1, "rv_15m": 2.82, "sim_a_vol": 4.26, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.93, "pm_a": -56.6, "left": 140.0, "rv_5m": 4.88, "rv_15m": 3.98, "sim_a_vol": 3.82, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "bounce", "ask": 0.73, "pm_a": -21.14, "left": 43.0, "rv_5m": 3.97, "rv_15m": 5.16, "sim_a_vol": 11.34, "direction": "DOWN", "won": true, "pnl_5sh": 1.35}
{"strategy": "xgb_dir_140", "ask": 0.94, "pm_a": 57.89, "left": 140.0, "rv_5m": 2.97, "rv_15m": 4.14, "sim_a_vol": 9.94, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "depth_cheap", "ask": 0.59, "pm_a": -9.31, "left": 209.0, "rv_5m": 3.14, "rv_15m": 4.28, "sim_a_vol": 2.03, "direction": "DOWN", "won": true, "pnl_5sh": 2.05}
{"strategy": "xgb_dir_140", "ask": 0.93, "pm_a": 53.65, "left": 140.0, "rv_5m": 3.65, "rv_15m": 3.9, "sim_a_vol": 4.69, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "depth_cheap", "ask": 0.59, "pm_a": -13.1, "left": 248.0, "rv_5m": 3.98, "rv_15m": 3.87, "sim_a_vol": 20.66, "direction": "DOWN", "won": true, "pnl_5sh": 2.05}
{"strategy": "lock", "ask": 0.93, "pm_a": 78.16, "left": 140.0, "rv_5m": 5.65, "rv_15m": 6.05, "sim_a_vol": 20.72, "direction": "UP", "won": true, "pnl_5sh": 0.35}
{"strategy": "xgb_dir_50", "ask": 0.96, "pm_a": -82.25, "left": 60.0, "rv_5m": 5.58, "rv_15m": 7.97, "sim_a_vol": 11.78, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.74, "pm_a": -26.96, "left": 140.0, "rv_5m": 5.51, "rv_15m": 5.98, "sim_a_vol": 6.41, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "depth_cheap", "ask": 0.55, "pm_a": -11.73, "left": 182.0, "rv_5m": 2.94, "rv_15m": 2.46, "sim_a_vol": 15.15, "direction": "DOWN", "won": true, "pnl_5sh": 2.25}
{"strategy": "xgb_dir_140", "ask": 0.98, "pm_a": 137.52, "left": 140.0, "rv_5m": 6.15, "rv_15m": 5.6, "sim_a_vol": 19.23, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.16, "pm_a": 19.73, "left": 26.0, "rv_5m": 36.74, "rv_15m": 33.82, "sim_a_vol": 137.31, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "mid_flip", "ask": 0.37, "pm_a": -10.59, "left": 52.0, "rv_5m": 3.89, "rv_15m": 4.03, "sim_a_vol": 15.96, "direction": "UP", "won": false, "pnl_5sh": -1.85}
{"strategy": "depth_cheap", "ask": 0.57, "pm_a": 5.84, "left": 245.0, "rv_5m": 3.07, "rv_15m": 2.62, "sim_a_vol": 21.57, "direction": "UP", "won": true, "pnl_5sh": 2.15}
{"strategy": "clob_rev", "ask": 0.25, "pm_a": -40.78, "left": 53.8, "rv_5m": 5.91, "rv_15m": 6.94, "sim_a_vol": 17.62, "direction": "UP", "won": false, "pnl_5sh": -1.25}
{"strategy": "xgb_dir_140", "ask": 0.76, "pm_a": -26.93, "left": 140.0, "rv_5m": 3.71, "rv_15m": 4.04, "sim_a_vol": 6.39, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.65, "pm_a": 15.96, "left": 140.0, "rv_5m": 1.96, "rv_15m": 2.73, "sim_a_vol": 3.86, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "cheap_hours", "ask": 0.44, "pm_a": 10.82, "left": 294.0, "rv_5m": 3.6, "rv_15m": 3.51, "sim_a_vol": 13.13, "direction": "DOWN", "won": false, "pnl_5sh": -2.2}
{"strategy": "open_hedge", "ask": 0.51, "pm_a": -4.81, "left": 294.6, "rv_5m": 3.29, "rv_15m": 4.33, "sim_a_vol": 6.68, "direction": "UP", "won": false, "pnl_5sh": -2.55}
{"strategy": "depth_cheap", "ask": 0.5, "pm_a": 6.03, "left": 117.0, "rv_5m": 3.84, "rv_15m": 3.68, "sim_a_vol": 12.24, "direction": "DOWN", "won": false, "pnl_5sh": -2.5}
{"strategy": "xgb_dir_140", "ask": 0.62, "pm_a": 10.4, "left": 140.0, "rv_5m": 3.2, "rv_15m": 3.5, "sim_a_vol": 8.49, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.93, "pm_a": 35.81, "left": 140.0, "rv_5m": 1.28, "rv_15m": 2.94, "sim_a_vol": 10.17, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "lock", "ask": 0.96, "pm_a": -60.06, "left": 58.0, "rv_5m": 4.8, "rv_15m": 4.53, "sim_a_vol": 11.29, "direction": "DOWN", "won": true, "pnl_5sh": 0.2}
{"strategy": "xgb_dir_140", "ask": 0.78, "pm_a": -17.36, "left": 140.0, "rv_5m": 2.36, "rv_15m": 2.59, "sim_a_vol": 4.73, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "cheap_winner", "ask": 0.59, "pm_a": 4.35, "left": 31.0, "rv_5m": 2.58, "rv_15m": 2.0, "sim_a_vol": 7.68, "direction": "UP", "won": false, "pnl_5sh": -2.95}
{"strategy": "xgb_dir_140", "ask": 0.96, "pm_a": -110.58, "left": 140.0, "rv_5m": 5.73, "rv_15m": 4.51, "sim_a_vol": 33.09, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.83, "pm_a": 52.36, "left": 140.0, "rv_5m": 4.02, "rv_15m": 4.49, "sim_a_vol": 10.15, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "open_hedge", "ask": 0.59, "pm_a": -25.6, "left": 294.3, "rv_5m": 7.58, "rv_15m": 8.79, "sim_a_vol": 36.7, "direction": "DOWN", "won": false, "pnl_5sh": -2.95}
{"strategy": "xgb_dir_140", "ask": 0.49, "pm_a": 2.02, "left": 124.0, "rv_5m": 2.79, "rv_15m": 3.68, "sim_a_vol": 1.1, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "depth_cheap", "ask": 0.58, "pm_a": 1.89, "left": 231.0, "rv_5m": 3.16, "rv_15m": 3.43, "sim_a_vol": 4.39, "direction": "UP", "won": true, "pnl_5sh": 2.1}
{"strategy": "depth_cheap", "ask": 0.45, "pm_a": 3.75, "left": 247.0, "rv_5m": 4.16, "rv_15m": 4.09, "sim_a_vol": 25.64, "direction": "DOWN", "won": false, "pnl_5sh": -2.25}
{"strategy": "spread_arb", "ask": 0.3, "pm_a": 47.99, "left": 268.0, "rv_5m": 5.4, "rv_15m": 5.4, "sim_a_vol": 14.55, "direction": "DOWN", "won": false, "pnl_5sh": -1.5}
{"strategy": "spread_arb", "ask": 0.69, "pm_a": -29.64, "left": 280.0, "rv_5m": 2.69, "rv_15m": 4.97, "sim_a_vol": 7.35, "direction": "DOWN", "won": true, "pnl_5sh": 1.55}
{"strategy": "depth_cheap", "ask": 0.59, "pm_a": 51.32, "left": 244.0, "rv_5m": 11.05, "rv_15m": 6.85, "sim_a_vol": 116.87, "direction": "UP", "won": true, "pnl_5sh": 2.05}
{"strategy": "mid_flip_v2", "ask": 0.48, "pm_a": -9.47, "left": 125.0, "rv_5m": 4.3, "rv_15m": 3.82, "sim_a_vol": 8.14, "direction": "UP", "won": false, "pnl_5sh": -2.4}
{"strategy": "lock", "ask": 0.82, "pm_a": 73.96, "left": 131.0, "rv_5m": 7.55, "rv_15m": 9.95, "sim_a_vol": 9.58, "direction": "UP", "won": true, "pnl_5sh": 0.9}
{"strategy": "xgb_dir_140", "ask": 0.48, "pm_a": 4.58, "left": 136.0, "rv_5m": 3.09, "rv_15m": 5.57, "sim_a_vol": 9.97, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.58, "pm_a": -11.59, "left": 140.0, "rv_5m": 2.96, "rv_15m": 3.13, "sim_a_vol": 13.0, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.61, "pm_a": -10.89, "left": 140.0, "rv_5m": 4.71, "rv_15m": 3.91, "sim_a_vol": 10.59, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "open_hedge", "ask": 0.37, "pm_a": 2.96, "left": 154.2, "rv_5m": 5.86, "rv_15m": 5.13, "sim_a_vol": 12.66, "direction": "DOWN", "won": false, "pnl_5sh": -1.85}
{"strategy": "xgb_dir_140", "ask": 0.91, "pm_a": -35.58, "left": 140.0, "rv_5m": 2.06, "rv_15m": 1.92, "sim_a_vol": 5.6, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.76, "pm_a": 14.01, "left": 140.0, "rv_5m": 2.29, "rv_15m": 3.49, "sim_a_vol": 8.51, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "cb_flip", "ask": 0.98, "pm_a": 12.37, "left": 4.9, "rv_5m": 3.01, "rv_15m": 3.17, "sim_a_vol": 7.13, "direction": "DOWN", "won": true, "pnl_5sh": 0.0}
{"strategy": "depth_cheap", "ask": 0.4, "pm_a": 40.24, "left": 227.0, "rv_5m": 9.14, "rv_15m": 7.59, "sim_a_vol": 18.0, "direction": "DOWN", "won": false, "pnl_5sh": -2.0}
{"strategy": "xgb_dir_140", "ask": 0.33, "pm_a": 23.86, "left": 140.0, "rv_5m": 5.48, "rv_15m": 5.45, "sim_a_vol": 13.91, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_50", "ask": 1.0, "pm_a": -161.29, "left": 60.0, "rv_5m": 4.97, "rv_15m": 4.63, "sim_a_vol": 16.01, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.53, "pm_a": -8.34, "left": 140.0, "rv_5m": 2.62, "rv_15m": 4.11, "sim_a_vol": 2.38, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.96, "pm_a": 54.19, "left": 140.0, "rv_5m": 2.66, "rv_15m": 3.04, "sim_a_vol": 10.48, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "lock_hedge", "ask": 0.09, "pm_a": -61.63, "left": 126.0, "rv_5m": 3.42, "rv_15m": 4.03, "sim_a_vol": 11.93, "direction": "UP", "won": false, "pnl_5sh": -0.45}
{"strategy": "lock", "ask": 0.94, "pm_a": 64.45, "left": 41.0, "rv_5m": 4.89, "rv_15m": 6.94, "sim_a_vol": 12.26, "direction": "UP", "won": true, "pnl_5sh": 0.3}
{"strategy": "xgb_dir_140", "ask": 0.98, "pm_a": 89.14, "left": 140.0, "rv_5m": 3.11, "rv_15m": 3.54, "sim_a_vol": 7.99, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "cheap_winner_strict", "ask": 0.78, "pm_a": 15.99, "left": 59.0, "rv_5m": 2.9, "rv_15m": 2.89, "sim_a_vol": 4.24, "direction": "UP", "won": false, "pnl_5sh": -3.9}
{"strategy": "depth_cheap", "ask": 0.4, "pm_a": 7.16, "left": 187.0, "rv_5m": 4.05, "rv_15m": 3.76, "sim_a_vol": 1.53, "direction": "DOWN", "won": true, "pnl_5sh": 3.0}
{"strategy": "cheap_hours", "ask": 0.42, "pm_a": -31.8, "left": 295.0, "rv_5m": 2.6, "rv_15m": 2.71, "sim_a_vol": 11.8, "direction": "UP", "won": false, "pnl_5sh": -2.1}
{"strategy": "xgb_dir_140", "ask": 0.47, "pm_a": -6.78, "left": 139.0, "rv_5m": 4.58, "rv_15m": 5.8, "sim_a_vol": 8.76, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "mid_flip", "ask": 0.31, "pm_a": -25.91, "left": 135.0, "rv_5m": 5.1, "rv_15m": 4.64, "sim_a_vol": 24.42, "direction": "UP", "won": false, "pnl_5sh": -1.55}
{"strategy": "lock", "ask": 0.92, "pm_a": -60.73, "left": 103.0, "rv_5m": 5.43, "rv_15m": 4.58, "sim_a_vol": 3.58, "direction": "DOWN", "won": true, "pnl_5sh": 0.4}
{"strategy": "xgb_dir_140", "ask": 0.72, "pm_a": 32.41, "left": 139.0, "rv_5m": 6.91, "rv_15m": 6.18, "sim_a_vol": 15.08, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "open_hedge", "ask": 0.55, "pm_a": 15.94, "left": 294.1, "rv_5m": 5.74, "rv_15m": 6.94, "sim_a_vol": 11.23, "direction": "UP", "won": false, "pnl_5sh": -2.75}
{"strategy": "mid_flip_v2", "ask": 0.49, "pm_a": 2.44, "left": 124.0, "rv_5m": 3.74, "rv_15m": 4.28, "sim_a_vol": 5.19, "direction": "DOWN", "won": true, "pnl_5sh": 0.0}
{"strategy": "mid_flip", "ask": 0.47, "pm_a": -24.77, "left": 124.0, "rv_5m": 4.97, "rv_15m": 4.95, "sim_a_vol": 4.69, "direction": "UP", "won": true, "pnl_5sh": 0.0}
{"strategy": "xgb_dir_140", "ask": 0.66, "pm_a": -32.25, "left": 140.0, "rv_5m": 6.57, "rv_15m": 8.06, "sim_a_vol": 8.83, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "depth_cheap", "ask": 0.18, "pm_a": 38.18, "left": 224.0, "rv_5m": 3.4, "rv_15m": 2.76, "sim_a_vol": 11.04, "direction": "DOWN", "won": true, "pnl_5sh": 4.1}
{"strategy": "xgb_dir_140", "ask": 0.98, "pm_a": 87.33, "left": 140.0, "rv_5m": 4.27, "rv_15m": 3.18, "sim_a_vol": 7.13, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "depth_cheap", "ask": 0.27, "pm_a": 42.92, "left": 235.0, "rv_5m": 6.81, "rv_15m": 6.31, "sim_a_vol": 28.08, "direction": "DOWN", "won": false, "pnl_5sh": -1.35}
{"strategy": "cheap_winner_strict", "ask": 0.8, "pm_a": 24.45, "left": 39.0, "rv_5m": 1.63, "rv_15m": 2.52, "sim_a_vol": 1.57, "direction": "UP", "won": true, "pnl_5sh": 1.0}
{"strategy": "xgb_dir_140", "ask": 0.56, "pm_a": 9.5, "left": 140.0, "rv_5m": 5.01, "rv_15m": 5.72, "sim_a_vol": 3.76, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.76, "pm_a": 23.01, "left": 140.0, "rv_5m": 2.2, "rv_15m": 2.18, "sim_a_vol": 7.92, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.96, "pm_a": -49.68, "left": 140.0, "rv_5m": 2.58, "rv_15m": 2.51, "sim_a_vol": 10.67, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.49, "pm_a": -3.7, "left": 140.0, "rv_5m": 6.48, "rv_15m": 16.93, "sim_a_vol": 20.65, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "cheap_winner", "ask": 0.8, "pm_a": 11.86, "left": 30.0, "rv_5m": 3.67, "rv_15m": 3.62, "sim_a_vol": 4.99, "direction": "UP", "won": true, "pnl_5sh": 1.0}
{"strategy": "xgb_dir_140", "ask": 0.61, "pm_a": 22.04, "left": 140.0, "rv_5m": 6.7, "rv_15m": 7.26, "sim_a_vol": 6.52, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "depth_cheap", "ask": 0.08, "pm_a": -32.04, "left": 127.0, "rv_5m": 1.99, "rv_15m": 2.52, "sim_a_vol": 8.44, "direction": "UP", "won": false, "pnl_5sh": -0.4}
{"strategy": "last7s", "ask": 1.0, "pm_a": 154.72, "left": 19.9, "rv_5m": 6.41, "rv_15m": 7.23, "sim_a_vol": 7.82, "direction": "UP", "won": true, "pnl_5sh": 0.0}
{"strategy": "maker_bias", "ask": 0.58, "pm_a": -1.37, "left": 195.0, "rv_5m": 4.89, "rv_15m": 4.67, "sim_a_vol": 7.1, "direction": "DOWN", "won": false, "pnl_5sh": -2.9}
{"strategy": "spread_arb", "ask": 0.66, "pm_a": 11.48, "left": 140.0, "rv_5m": 2.43, "rv_15m": 2.43, "sim_a_vol": 7.56, "direction": "UP", "won": true, "pnl_5sh": 1.7}
{"strategy": "cheap_hours", "ask": 0.44, "pm_a": -3.33, "left": 202.0, "rv_5m": 6.71, "rv_15m": 7.61, "sim_a_vol": 6.54, "direction": "UP", "won": false, "pnl_5sh": -2.2}
{"strategy": "xgb_dir_140", "ask": 0.8, "pm_a": -37.96, "left": 140.0, "rv_5m": 4.41, "rv_15m": 4.07, "sim_a_vol": 7.29, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.63, "pm_a": -9.22, "left": 140.0, "rv_5m": 3.63, "rv_15m": 4.97, "sim_a_vol": 8.84, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "open_hedge", "ask": 0.6, "pm_a": -11.54, "left": 294.6, "rv_5m": 6.26, "rv_15m": 6.25, "sim_a_vol": 15.22, "direction": "UP", "won": true, "pnl_5sh": 2.0}
{"strategy": "depth_cheap", "ask": 0.5, "pm_a": 0.28, "left": 107.0, "rv_5m": 2.37, "rv_15m": 2.74, "sim_a_vol": 6.3, "direction": "UP", "won": true, "pnl_5sh": 2.5}
{"strategy": "mid_flip", "ask": 0.46, "pm_a": -12.19, "left": 118.0, "rv_5m": 2.29, "rv_15m": 2.86, "sim_a_vol": 7.2, "direction": "UP", "won": false, "pnl_5sh": -2.3}
{"strategy": "xgb_dir_140", "ask": 0.89, "pm_a": -47.47, "left": 140.0, "rv_5m": 4.27, "rv_15m": 3.95, "sim_a_vol": 4.04, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.83, "pm_a": -32.14, "left": 140.0, "rv_5m": 3.89, "rv_15m": 4.58, "sim_a_vol": 4.62, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "depth_cheap", "ask": 0.54, "pm_a": -0.87, "left": 194.0, "rv_5m": 7.68, "rv_15m": 5.92, "sim_a_vol": 16.82, "direction": "DOWN", "won": false, "pnl_5sh": -2.7}
{"strategy": "spread_arb", "ask": 0.33, "pm_a": 18.55, "left": 284.0, "rv_5m": 5.72, "rv_15m": 5.14, "sim_a_vol": 9.54, "direction": "DOWN", "won": false, "pnl_5sh": -1.65}
{"strategy": "xgb_dir_140", "ask": 0.77, "pm_a": 15.49, "left": 140.0, "rv_5m": 2.03, "rv_15m": 2.21, "sim_a_vol": 7.88, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.66, "pm_a": 6.39, "left": 120.0, "rv_5m": 4.04, "rv_15m": 3.68, "sim_a_vol": 16.91, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "cheap_winner_v2", "ask": 0.58, "pm_a": -10.85, "left": 59.0, "rv_5m": 1.99, "rv_15m": 2.08, "sim_a_vol": 4.32, "direction": "DOWN", "won": false, "pnl_5sh": -2.9}
{"strategy": "mid_flip", "ask": 0.39, "pm_a": -9.21, "left": 149.0, "rv_5m": 3.89, "rv_15m": 3.75, "sim_a_vol": 17.49, "direction": "UP", "won": true, "pnl_5sh": 3.05}
{"strategy": "xgb_dir_140", "ask": 0.41, "pm_a": 11.7, "left": 140.0, "rv_5m": 1.5, "rv_15m": 1.3, "sim_a_vol": 1.43, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "open_hedge", "ask": 0.38, "pm_a": 30.4, "left": 264.7, "rv_5m": 5.37, "rv_15m": 4.78, "sim_a_vol": 6.56, "direction": "DOWN", "won": false, "pnl_5sh": -1.9}
{"strategy": "depth_cheap", "ask": 0.5, "pm_a": -3.53, "left": 250.0, "rv_5m": 4.58, "rv_15m": 3.69, "sim_a_vol": 7.06, "direction": "DOWN", "won": false, "pnl_5sh": -2.5}
{"strategy": "depth_cheap", "ask": 0.16, "pm_a": -34.18, "left": 237.0, "rv_5m": 2.04, "rv_15m": 2.81, "sim_a_vol": 9.26, "direction": "UP", "won": false, "pnl_5sh": -0.8}
{"strategy": "depth_cheap", "ask": 0.59, "pm_a": -27.67, "left": 250.0, "rv_5m": 9.12, "rv_15m": 7.31, "sim_a_vol": 99.56, "direction": "DOWN", "won": false, "pnl_5sh": -2.95}
{"strategy": "cheap_winner", "ask": 0.55, "pm_a": -2.42, "left": 51.0, "rv_5m": 4.8, "rv_15m": 5.4, "sim_a_vol": 13.92, "direction": "DOWN", "won": false, "pnl_5sh": -2.75}
{"strategy": "xgb_dir_140", "ask": 0.66, "pm_a": 3.73, "left": 133.0, "rv_5m": 2.04, "rv_15m": 1.92, "sim_a_vol": 1.5, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.76, "pm_a": 5.17, "left": 140.0, "rv_5m": 1.48, "rv_15m": 2.11, "sim_a_vol": 1.04, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.82, "pm_a": 2.18, "left": 140.0, "rv_5m": 3.17, "rv_15m": 3.15, "sim_a_vol": 2.99, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.48, "pm_a": 9.72, "left": 140.0, "rv_5m": 2.73, "rv_15m": 4.78, "sim_a_vol": 2.85, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "depth_cheap", "ask": 0.49, "pm_a": 0.94, "left": 240.0, "rv_5m": 14.08, "rv_15m": 10.42, "sim_a_vol": 36.64, "direction": "UP", "won": false, "pnl_5sh": -2.45}
{"strategy": "xgb_dir_140", "ask": 0.84, "pm_a": -36.28, "left": 140.0, "rv_5m": 3.56, "rv_15m": 3.27, "sim_a_vol": 11.63, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "lock", "ask": 0.9, "pm_a": 50.31, "left": 124.1, "rv_5m": 4.54, "rv_15m": 4.19, "sim_a_vol": 9.85, "direction": "UP", "won": true, "pnl_5sh": 0.5}
{"strategy": "xgb_dir_140", "ask": 0.79, "pm_a": -28.78, "left": 140.0, "rv_5m": 3.67, "rv_15m": 2.82, "sim_a_vol": 9.95, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.62, "pm_a": -13.32, "left": 140.0, "rv_5m": 3.37, "rv_15m": 3.03, "sim_a_vol": 1.93, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "depth_cheap", "ask": 0.48, "pm_a": -2.72, "left": 237.0, "rv_5m": 2.01, "rv_15m": 1.91, "sim_a_vol": 5.52, "direction": "UP", "won": false, "pnl_5sh": -2.4}
{"strategy": "open_hedge", "ask": 0.66, "pm_a": 70.12, "left": 295.0, "rv_5m": 11.42, "rv_15m": 12.44, "sim_a_vol": 50.14, "direction": "UP", "won": true, "pnl_5sh": 1.7}
{"strategy": "depth_cheap", "ask": 0.59, "pm_a": -21.25, "left": 250.0, "rv_5m": 7.0, "rv_15m": 5.11, "sim_a_vol": 77.42, "direction": "DOWN", "won": false, "pnl_5sh": -2.95}
{"strategy": "xgb_dir_140", "ask": 0.98, "pm_a": 90.89, "left": 140.0, "rv_5m": 2.61, "rv_15m": 3.25, "sim_a_vol": 10.8, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.38, "pm_a": 9.55, "left": 140.0, "rv_5m": 2.17, "rv_15m": 2.12, "sim_a_vol": 3.53, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "spread_arb", "ask": 0.32, "pm_a": -11.21, "left": 286.0, "rv_5m": 4.43, "rv_15m": 4.27, "sim_a_vol": 13.98, "direction": "UP", "won": true, "pnl_5sh": 3.4}
{"strategy": "bounce", "ask": 0.77, "pm_a": -44.49, "left": 49.0, "rv_5m": 6.62, "rv_15m": 5.98, "sim_a_vol": 11.98, "direction": "DOWN", "won": true, "pnl_5sh": 1.15}
{"strategy": "xgb_dir_140", "ask": 0.93, "pm_a": 67.12, "left": 140.0, "rv_5m": 4.09, "rv_15m": 3.82, "sim_a_vol": 6.95, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.8, "pm_a": -11.2, "left": 140.0, "rv_5m": 2.33, "rv_15m": 2.52, "sim_a_vol": 3.99, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.76, "pm_a": -60.37, "left": 140.0, "rv_5m": 7.29, "rv_15m": 9.39, "sim_a_vol": 10.67, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "depth_cheap", "ask": 0.58, "pm_a": 17.97, "left": 250.0, "rv_5m": 4.54, "rv_15m": 4.21, "sim_a_vol": 12.78, "direction": "UP", "won": true, "pnl_5sh": 2.1}
{"strategy": "depth_cheap", "ask": 0.49, "pm_a": -1.2, "left": 232.0, "rv_5m": 6.5, "rv_15m": 7.46, "sim_a_vol": 14.67, "direction": "UP", "won": false, "pnl_5sh": -2.45}
{"strategy": "xgb_dir_140", "ask": 0.56, "pm_a": -11.81, "left": 140.0, "rv_5m": 2.0, "rv_15m": 2.52, "sim_a_vol": 3.83, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 1.0, "pm_a": -178.58, "left": 140.0, "rv_5m": 5.83, "rv_15m": 4.4, "sim_a_vol": 24.94, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "cheap_winner", "ask": 0.49, "pm_a": 11.86, "left": 30.0, "rv_5m": 6.12, "rv_15m": 8.63, "sim_a_vol": 6.35, "direction": "UP", "won": true, "pnl_5sh": 0.0}
{"strategy": "xgb_dir_140", "ask": 0.81, "pm_a": 20.59, "left": 140.0, "rv_5m": 2.97, "rv_15m": 3.19, "sim_a_vol": 16.09, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "lock", "ask": 0.98, "pm_a": 52.44, "left": 42.3, "rv_5m": 4.76, "rv_15m": 4.12, "sim_a_vol": 5.17, "direction": "UP", "won": true, "pnl_5sh": 0.1}
{"strategy": "xgb_dir_140", "ask": 0.56, "pm_a": 8.01, "left": 140.0, "rv_5m": 3.42, "rv_15m": 3.26, "sim_a_vol": 5.59, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "cb_confirm", "ask": 0.75, "pm_a": 29.74, "left": 241.0, "rv_5m": 4.64, "rv_15m": 4.48, "sim_a_vol": 12.16, "direction": "UP", "won": false, "pnl_5sh": -3.75}
{"strategy": "xgb_dir_140", "ask": 0.68, "pm_a": 34.25, "left": 140.0, "rv_5m": 6.67, "rv_15m": 6.5, "sim_a_vol": 8.99, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.81, "pm_a": -54.68, "left": 140.0, "rv_5m": 7.94, "rv_15m": 8.97, "sim_a_vol": 6.7, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "last7s", "ask": 1.0, "pm_a": 114.48, "left": 20.0, "rv_5m": 6.91, "rv_15m": 7.54, "sim_a_vol": 23.92, "direction": "UP", "won": true, "pnl_5sh": 0.0}
{"strategy": "xgb_dir_140", "ask": 0.87, "pm_a": -52.5, "left": 140.0, "rv_5m": 6.93, "rv_15m": 6.36, "sim_a_vol": 7.74, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "cheap_winner", "ask": 0.63, "pm_a": 16.48, "left": 55.0, "rv_5m": 3.65, "rv_15m": 6.95, "sim_a_vol": 10.93, "direction": "UP", "won": false, "pnl_5sh": -3.15}
{"strategy": "xgb_dir_140", "ask": 0.96, "pm_a": -128.98, "left": 140.0, "rv_5m": 6.21, "rv_15m": 5.87, "sim_a_vol": 42.82, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "hold7s", "ask": 0.81, "pm_a": 10.41, "left": 10.0, "rv_5m": 5.39, "rv_15m": 6.36, "sim_a_vol": 9.58, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "depth_cheap", "ask": 0.59, "pm_a": 6.2, "left": 236.0, "rv_5m": 1.37, "rv_15m": 1.34, "sim_a_vol": 3.59, "direction": "UP", "won": false, "pnl_5sh": -2.95}
{"strategy": "xgb_dir_140", "ask": 0.63, "pm_a": 9.99, "left": 140.0, "rv_5m": 1.84, "rv_15m": 2.1, "sim_a_vol": 3.08, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.52, "pm_a": 2.43, "left": 139.0, "rv_5m": 6.39, "rv_15m": 6.13, "sim_a_vol": 36.51, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.67, "pm_a": -19.42, "left": 139.0, "rv_5m": 3.28, "rv_15m": 3.49, "sim_a_vol": 5.93, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "depth_cheap", "ask": 0.5, "pm_a": 9.48, "left": 235.0, "rv_5m": 2.75, "rv_15m": 3.53, "sim_a_vol": 7.66, "direction": "DOWN", "won": false, "pnl_5sh": -2.5}
{"strategy": "cb_flip", "ask": 0.03, "pm_a": -11.49, "left": 3.0, "rv_5m": 3.21, "rv_15m": 3.98, "sim_a_vol": 7.48, "direction": "UP", "won": false, "pnl_5sh": -1.15}
{"strategy": "xgb_dir_140", "ask": 0.79, "pm_a": -21.57, "left": 140.0, "rv_5m": 4.29, "rv_15m": 3.68, "sim_a_vol": 8.88, "direction": "DOWN", "won": false, "pnl_5sh": -3.95}
{"strategy": "cb_flip", "ask": 0.9, "pm_a": 18.44, "left": 30.0, "rv_5m": 3.17, "rv_15m": 3.19, "sim_a_vol": 4.3, "direction": "UP", "won": true, "pnl_5sh": 0.0}
{"strategy": "xgb_dir_140", "ask": 0.94, "pm_a": 109.64, "left": 140.0, "rv_5m": 8.32, "rv_15m": 8.54, "sim_a_vol": 14.22, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.87, "pm_a": 37.88, "left": 140.0, "rv_5m": 2.02, "rv_15m": 2.12, "sim_a_vol": 2.55, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.91, "pm_a": -63.65, "left": 140.0, "rv_5m": 5.04, "rv_15m": 5.54, "sim_a_vol": 9.08, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "depth_cheap", "ask": 0.5, "pm_a": -8.03, "left": 193.0, "rv_5m": 3.16, "rv_15m": 3.38, "sim_a_vol": 7.87, "direction": "UP", "won": true, "pnl_5sh": 2.5}
{"strategy": "xgb_dir_140", "ask": 0.96, "pm_a": -75.46, "left": 140.0, "rv_5m": 4.79, "rv_15m": 4.31, "sim_a_vol": 6.39, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "depth_cheap", "ask": 0.32, "pm_a": 33.71, "left": 210.0, "rv_5m": 3.7, "rv_15m": 3.67, "sim_a_vol": 7.78, "direction": "DOWN", "won": false, "pnl_5sh": -1.6}
{"strategy": "xgb_dir_140", "ask": 0.98, "pm_a": 123.27, "left": 140.0, "rv_5m": 5.13, "rv_15m": 5.67, "sim_a_vol": 16.64, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "depth_cheap", "ask": 0.54, "pm_a": -0.38, "left": 246.0, "rv_5m": 1.62, "rv_15m": 2.02, "sim_a_vol": 6.91, "direction": "DOWN", "won": true, "pnl_5sh": 2.3}
{"strategy": "xgb_dir_140", "ask": 0.64, "pm_a": 8.77, "left": 140.0, "rv_5m": 2.87, "rv_15m": 3.02, "sim_a_vol": 5.89, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.71, "pm_a": -8.54, "left": 140.0, "rv_5m": 1.97, "rv_15m": 2.13, "sim_a_vol": 1.57, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.53, "pm_a": 8.45, "left": 140.0, "rv_5m": 2.87, "rv_15m": 2.78, "sim_a_vol": 15.43, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.69, "pm_a": -41.2, "left": 140.0, "rv_5m": 4.05, "rv_15m": 3.8, "sim_a_vol": 4.74, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.9, "pm_a": -86.43, "left": 140.0, "rv_5m": 7.94, "rv_15m": 6.11, "sim_a_vol": 23.1, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "depth_cheap", "ask": 0.42, "pm_a": 30.98, "left": 212.0, "rv_5m": 10.25, "rv_15m": 8.73, "sim_a_vol": 17.37, "direction": "DOWN", "won": true, "pnl_5sh": 0.0}
{"strategy": "depth_cheap", "ask": 0.59, "pm_a": 34.18, "left": 213.0, "rv_5m": 0.0, "rv_15m": 0.0, "sim_a_vol": 47.77, "direction": "UP", "won": true, "pnl_5sh": 2.05}
{"strategy": "open_hedge", "ask": 0.64, "pm_a": -21.08, "left": 294.2, "rv_5m": 3.97, "rv_15m": 4.62, "sim_a_vol": 10.83, "direction": "DOWN", "won": false, "pnl_5sh": -3.2}
{"strategy": "open_hedge", "ask": 1.0, "pm_a": 0.0, "left": 299.6, "rv_5m": 7.16, "rv_15m": 7.96, "sim_a_vol": 27.76, "direction": "DOWN", "won": false, "pnl_5sh": -5.0}
{"strategy": "spread_arb", "ask": 0.34, "pm_a": -5.22, "left": 276.0, "rv_5m": 5.25, "rv_15m": 5.25, "sim_a_vol": 7.9, "direction": "UP", "won": false, "pnl_5sh": -1.7}
{"strategy": "depth_cheap", "ask": 0.34, "pm_a": 16.69, "left": 206.0, "rv_5m": 2.65, "rv_15m": 3.33, "sim_a_vol": 2.05, "direction": "DOWN", "won": false, "pnl_5sh": -1.7}
{"strategy": "xgb_dir_140", "ask": 0.99, "pm_a": -83.42, "left": 140.0, "rv_5m": 3.16, "rv_15m": 2.96, "sim_a_vol": 2.08, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "lock", "ask": 0.92, "pm_a": 71.87, "left": 20.0, "rv_5m": 3.84, "rv_15m": 4.02, "sim_a_vol": 17.33, "direction": "UP", "won": true, "pnl_5sh": 0.0}
{"strategy": "depth_cheap", "ask": 0.56, "pm_a": 20.21, "left": 250.0, "rv_5m": 4.33, "rv_15m": 3.01, "sim_a_vol": 10.49, "direction": "UP", "won": false, "pnl_5sh": -2.8}
{"strategy": "depth_cheap", "ask": 0.49, "pm_a": -4.31, "left": 234.0, "rv_5m": 4.63, "rv_15m": 4.13, "sim_a_vol": 7.42, "direction": "DOWN", "won": true, "pnl_5sh": 2.55}
{"strategy": "xgb_dir_140", "ask": 0.96, "pm_a": -40.58, "left": 140.0, "rv_5m": 1.24, "rv_15m": 1.77, "sim_a_vol": 11.79, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.74, "pm_a": -23.56, "left": 140.0, "rv_5m": 3.0, "rv_15m": 3.21, "sim_a_vol": 6.2, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.89, "pm_a": 41.94, "left": 140.0, "rv_5m": 1.98, "rv_15m": 1.66, "sim_a_vol": 1.89, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.7, "pm_a": -13.97, "left": 140.0, "rv_5m": 2.03, "rv_15m": 2.17, "sim_a_vol": 6.59, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.99, "pm_a": -72.04, "left": 140.0, "rv_5m": 3.55, "rv_15m": 3.3, "sim_a_vol": 4.58, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.94, "pm_a": 78.91, "left": 140.0, "rv_5m": 4.94, "rv_15m": 4.35, "sim_a_vol": 7.91, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.96, "pm_a": -73.86, "left": 140.0, "rv_5m": 3.24, "rv_15m": 3.82, "sim_a_vol": 7.07, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.58, "pm_a": -10.57, "left": 140.0, "rv_5m": 8.13, "rv_15m": 7.24, "sim_a_vol": 11.55, "direction": "DOWN", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.73, "pm_a": 32.47, "left": 140.0, "rv_5m": 4.23, "rv_15m": 4.16, "sim_a_vol": 5.56, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "xgb_dir_140", "ask": 0.73, "pm_a": 25.04, "left": 140.0, "rv_5m": 2.7, "rv_15m": 3.1, "sim_a_vol": 3.0, "direction": "UP", "won": false, "pnl_5sh": 0}
{"strategy": "depth_cheap", "ask": 0.31, "pm_a": -25.81, "left": 174.0, "rv_5m": 5.15, "rv_15m": 7.72, "sim_a_vol": 12.96, "direction": "UP", "won": false, "pnl_5sh": -1.55}
{"strategy": "depth_cheap", "ask": 0.57, "pm_a": -4.6, "left": 250.0, "rv_5m": 6.08, "rv_15m": 5.83, "sim_a_vol": 46.0, "direction": "DOWN", "won": true, "pnl_5sh": 2.15}
{"strategy": "spread_arb", "ask": 0.34, "pm_a": 59.55, "left": 282.0, "rv_5m": 5.67, "rv_15m": 6.97, "sim_a_vol": 32.24, "direction": "DOWN", "won": false, "pnl_5sh": -1.7}
{"strategy": "xgb_dir_140", "ask": 0.75, "pm_a": 32.64, "left": 140.0, "rv_5m": 5.97, "rv_15m": 17.17, "sim_a_vol": 17.44, "direction": "UP", "won": false, "pnl_5sh": 0}
+352
View File
@@ -0,0 +1,352 @@
#!/usr/bin/env python3
"""Train layered XGBoost direction models at different time points.
Layer 1: Predict UP/DOWN direction (no ask as feature)
Layer 2: Use confidence + ask to decide if trade is worth it
Saves models to models/direction_left{N}.json for use in paper strategy.
Usage:
cd ~/btc_15m_collab/rewrite
source .venv/bin/activate
python3 tools/xgb_direction_train.py
"""
import json
import numpy as np
import pandas as pd
import xgboost as xgb
from sklearn.metrics import roc_auc_score
from collections import defaultdict
import os
SNAPSHOT_FILE = "data/chainlink_predictor/snapshots.jsonl"
SUMMARY_FILE = "data/chainlink_predictor/window_summary.jsonl"
MODEL_DIR = "models"
FEATURE_NAMES = [
'abs_pm_a', 'pm_a_accel', 'cb_a_abs', 'bn_a_abs', 'edge',
'cb_same', 'bn_same', 'cb_stronger', 'bid_ratio',
'vol_ratio', 'total_vol', 'vol_growth', 'rv_5m', 'trades',
'open_vol_ratio', 'open_matches', 'diff_abs',
'pm_a_velocity', # pts per 10s — how fast pm_a built up
'pm_a_from_peak', # current |pm_a| / max |pm_a| this window (1.0=at peak, <1=dropping)
'ask_from_peak', # current ask / max ask this window (1.0=at peak, <1=dropping)
'direction_flips', # how many times pm_a flipped sign this window
'time_at_direction', # seconds pm_a has been in current direction
# --- BTC market state (cross-window) ---
'rv_ratio', # rv_5m / rv_15m — short-term vol expanding? >1 = heating up
'dvol', # Deribit implied vol — market fear/calm
'funding', # funding rate — long/short sentiment
'oi_chg', # open interest change — money flowing in/out
'cb_vol_60s', # CB spot volume last 60s
'vol_spike', # volume spike indicator
'flip_rate', # price flip rate — choppy vs trending
'left_sec', # seconds left in window — small pm_a at left=10 is valuable, not at left=140
'pm_a_z', # |pm_a| / rv_5m — volatility-normalized strength
'pm_a_vol_z', # |pm_a| / cb_vol_60s — volume-normalized strength
]
LEFT_TARGETS = [140, 50, 10] # Three models: trend (140s) + confirmation (50s) + last-second (10s)
def load_data():
outcomes = {}
with open(SUMMARY_FILE) as f:
for l in f:
try:
w = json.loads(l)
outcomes[str(w['window_id'])] = w.get('pm_direction', '')
except: pass
print("Loading snapshots...")
windows = defaultdict(list)
with open(SNAPSHOT_FILE, 'rb') as f:
f.seek(0, 2)
sz = f.tell()
for offset in range(0, sz, 200_000_000):
f.seek(offset)
f.readline()
chunk = f.read(200_000_000)
for line in chunk.split(b'\n'):
if not line: continue
try:
s = json.loads(line)
wid = str(s.get('window_id', ''))
if wid not in outcomes: continue
clob = s.get('clob', {})
ob = s.get('ob', {})
cb_ob = ob.get('coinbase', {})
bn_ob = ob.get('binance', {})
windows[wid].append({
'left': s.get('left_sec', 0),
'pm_a': s.get('pm_a', 0),
'diff': s.get('diff', 0),
'rv_5m': s.get('rv_5m', 0),
'rv_15m': s.get('rv_15m', 0),
'up_ask': clob.get('up_ask', 0),
'down_ask': clob.get('down_ask', 0),
'up_vol': clob.get('up_buy_usdc', 0),
'down_vol': clob.get('down_buy_usdc', 0),
'trades': clob.get('trades', 0),
'cb_gap': cb_ob.get('gap', 0) if cb_ob else 0,
'cb_bid': cb_ob.get('bid', 0) if cb_ob else 0,
'cb_ask': cb_ob.get('ask', 0) if cb_ob else 0,
'bn_gap': bn_ob.get('gap', 0) if bn_ob else 0,
'dvol': s.get('dvol', 0),
'funding': s.get('funding', 0),
'oi_chg': s.get('oi_chg', 0),
'cb_vol_60s': s.get('cb_vol_60s', 0),
'vol_spike': s.get('vol_spike', 0),
'flip_rate': s.get('flip_rate', 0),
})
except: pass
print(f'Windows: {len(windows)}')
return windows, outcomes
def build_features(snaps, left_target, wid_int=0, prev_dirs=None):
obs = [s for s in snaps if abs(s['left'] - left_target) < 5]
if not obs: return None
s = obs[0]
if abs(s['pm_a']) < 2: return None
if s['up_ask'] <= 0 or s['down_ask'] <= 0: return None
direction = 1 if s['pm_a'] > 0 else -1
earlier = [x for x in snaps if abs(x['left'] - (s['left'] + 30)) < 5]
if earlier:
e = earlier[0]
pm_a_accel = abs(s['pm_a']) - abs(e['pm_a'])
e_up_vol = e['up_vol']
e_down_vol = e['down_vol']
else:
pm_a_accel = 0
e_up_vol = 0
e_down_vol = 0
total_vol = s['up_vol'] + s['down_vol']
our_vol = s['up_vol'] if direction == 1 else s['down_vol']
vol_ratio = our_vol / (total_vol + 1)
e_total = e_up_vol + e_down_vol
vol_growth = total_vol / (e_total + 1) if e_total > 10 else 0
cb_a = s['cb_gap']
bn_a = s['bn_gap']
edge = cb_a - s['pm_a']
cb_same = 1 if (cb_a > 0 and s['pm_a'] > 0) or (cb_a < 0 and s['pm_a'] < 0) else 0
bn_same = 1 if (bn_a > 0 and s['pm_a'] > 0) or (bn_a < 0 and s['pm_a'] < 0) else 0
cb_total = s['cb_bid'] + s['cb_ask']
bid_ratio = s['cb_bid'] / (cb_total + 0.01) if cb_total > 0.1 else 0.5
first_snaps = [x for x in snaps if x['left'] > 260]
if first_snaps:
f = first_snaps[-1]
open_vol_dir = 1 if f['up_vol'] > f['down_vol'] else (-1 if f['down_vol'] > f['up_vol'] else 0)
open_vol_ratio = max(f['up_vol'], f['down_vol']) / (min(f['up_vol'], f['down_vol']) + 1) if f['up_vol'] + f['down_vol'] > 50 else 0
open_matches = 1 if open_vol_dir == direction else 0
else:
open_vol_ratio = 0
open_matches = 0
# All snaps before observation point (for trajectory analysis)
snaps_by_time = sorted(snaps, key=lambda x: -x['left'])
before = [x for x in snaps_by_time if x['left'] >= s['left']]
# pm_a velocity: how fast did pm_a build up (pts per 10s)
pm_a_velocity = 0
current_pm_a = abs(s['pm_a'])
if current_pm_a >= 15:
start_left = None
for snap in before:
if snap['left'] <= s['left']: continue
if direction == 1 and snap['pm_a'] < 10:
start_left = snap['left']
break
elif direction == -1 and snap['pm_a'] > -10:
start_left = snap['left']
break
if start_left is not None:
build_time = start_left - s['left']
if build_time >= 5:
pm_a_velocity = (current_pm_a - 10) / build_time * 10
# pm_a from peak: is pm_a at its highest or dropping back?
if before:
max_abs_pma = max(abs(x['pm_a']) for x in before)
pm_a_from_peak = current_pm_a / (max_abs_pma + 0.1)
else:
pm_a_from_peak = 1.0
# ask from peak: is our ask at its highest or dropping?
our_ask_key = 'up_ask' if direction == 1 else 'down_ask'
our_asks = [x[our_ask_key] for x in before if x[our_ask_key] > 0]
if our_asks:
max_ask = max(our_asks)
current_ask = s[our_ask_key]
ask_from_peak = current_ask / (max_ask + 0.001) if max_ask > 0 else 1.0
else:
ask_from_peak = 1.0
# direction flips: how many times pm_a changed sign
direction_flips = 0
prev_sign = 0
for snap in before:
cur_sign = 1 if snap['pm_a'] > 0 else (-1 if snap['pm_a'] < 0 else 0)
if cur_sign != 0 and prev_sign != 0 and cur_sign != prev_sign:
direction_flips += 1
if cur_sign != 0:
prev_sign = cur_sign
# time at current direction: how long has pm_a been in current sign?
time_at_direction = 0
for snap in before:
if snap['left'] <= s['left']: continue
snap_dir = 1 if snap['pm_a'] > 0 else -1
if snap_dir == direction:
time_at_direction = snap['left'] - s['left']
else:
break
# rv ratio: short-term vol vs medium-term vol
rv_5 = s['rv_5m']
rv_15 = s.get('rv_15m', 0)
rv_ratio = rv_5 / (rv_15 + 0.01) if rv_15 > 0.1 else 1.0
return {
'abs_pm_a': abs(s['pm_a']),
'pm_a_accel': pm_a_accel,
'cb_a_abs': abs(cb_a),
'bn_a_abs': abs(bn_a),
'edge': edge * direction,
'cb_same': cb_same,
'bn_same': bn_same,
'cb_stronger': 1 if abs(cb_a) > abs(s['pm_a']) else 0,
'bid_ratio': bid_ratio,
'vol_ratio': vol_ratio,
'total_vol': total_vol,
'vol_growth': vol_growth,
'rv_5m': rv_5,
'trades': s['trades'],
'open_vol_ratio': open_vol_ratio,
'open_matches': open_matches,
'diff_abs': abs(s['diff']),
'pm_a_velocity': pm_a_velocity,
'pm_a_from_peak': pm_a_from_peak,
'ask_from_peak': ask_from_peak,
'direction_flips': direction_flips,
'time_at_direction': time_at_direction,
# BTC market state
'rv_ratio': rv_ratio,
'dvol': s.get('dvol', 0),
'funding': s.get('funding', 0),
'oi_chg': s.get('oi_chg', 0),
'cb_vol_60s': s.get('cb_vol_60s', 0),
'vol_spike': s.get('vol_spike', 0),
'flip_rate': s.get('flip_rate', 0),
'left_sec': s['left'],
'pm_a_z': abs(s['pm_a']) / (rv_5 + 0.1) if rv_5 > 0.5 else 0,
'pm_a_vol_z': abs(s['pm_a']) / (s.get('cb_vol_60s', 0) + 0.1) if s.get('cb_vol_60s', 0) > 0.5 else 0,
# Extra for paper trade logging (not used as feature)
'_up_ask': s['up_ask'],
'_down_ask': s['down_ask'],
'_pm_a': s['pm_a'],
}
def main():
windows, outcomes = load_data()
os.makedirs(MODEL_DIR, exist_ok=True)
# Build consecutive-same-direction map
sorted_wids = sorted(windows.keys(), key=lambda x: int(x))
consec_map = {}
prev_outcome = ''
consec = 0
for wid in sorted_wids:
outcome = outcomes.get(wid, '')
if not outcome: continue
if outcome == prev_outcome:
consec += 1
else:
consec = 0
consec_map[wid] = consec
prev_outcome = outcome
# Build recent_flips map — how many of last 5 windows had pm_a sign change during window
flip_map = {}
recent_flips = []
for wid in sorted_wids:
snaps = windows.get(wid, [])
if not snaps: continue
# Check if pm_a changed sign during this window
signs = [1 if s['pm_a'] > 5 else (-1 if s['pm_a'] < -5 else 0) for s in snaps]
signs = [s for s in signs if s != 0]
had_flip = 0
if len(signs) > 5:
for i in range(1, len(signs)):
if signs[i] != signs[i-1]:
had_flip = 1
break
recent_flips.append(had_flip)
flip_map[wid] = sum(recent_flips[-5:])
# Define sample ranges for each model
# Model 140: sample from left 20-140 (every 20s)
# Model 50: sample from left 10-50 (every 10s)
sample_points = {
140: list(range(20, 141, 20)), # [20, 40, 60, 80, 100, 120, 140]
50: list(range(10, 51, 10)), # [10, 20, 30, 40, 50]
10: list(range(3, 16, 3)), # [3, 6, 9, 12, 15] — last 15s snapshots
}
for left_target in LEFT_TARGETS:
rows = []
points = sample_points.get(left_target, [left_target])
for wid, snaps in windows.items():
outcome = outcomes.get(wid, '')
if not outcome: continue
for pt in points:
feats = build_features(snaps, pt, wid_int=int(wid))
if feats is None: continue
direction_label = 'UP' if feats['_pm_a'] > 0 else 'DOWN'
feats['won'] = 1 if direction_label == outcome else 0
rows.append(feats)
df = pd.DataFrame(rows)
if len(df) < 100: continue
X = df[FEATURE_NAMES]
y = df['won']
# Train on all data for production
model = xgb.XGBClassifier(
n_estimators=200, max_depth=4, learning_rate=0.05,
subsample=0.8, colsample_bytree=0.7,
eval_metric='logloss', random_state=42,
)
model.fit(X, y, verbose=False)
# Validation
split = int(len(df) * 0.7)
X_test = X.iloc[split:]
y_test = y.iloc[split:]
proba = model.predict_proba(X_test)[:, 1]
auc = roc_auc_score(y_test, proba)
model_path = f"{MODEL_DIR}/direction_left{left_target}.json"
model.save_model(model_path)
print(f'LEFT={left_target}s: {len(df)} samples, AUC={auc:.3f} → saved {model_path}')
# Save feature names
with open(f"{MODEL_DIR}/direction_features.json", "w") as f:
json.dump(FEATURE_NAMES, f)
print(f'\nFeature names saved. Models ready for paper strategy.')
if __name__ == "__main__":
main()
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""Offline XGBoost inspection — load the REAL trained models and report them.
python ml/xgb_eval.py
Needs only `xgboost` + `numpy` + the committed model files under models/. No live
feeds, no keys, no 16 GB of ticks. Prints each model's feature-importance ranking,
and — if a held-out sample is present at ml/data/lock_sample.csv — the test AUC.
The point this makes, straight from the model: the strongest features are the
market maker's OWN quote (ask, ask_margin, ask_vel), not BTC's price dynamics.
The model taught itself that the best estimate of the outcome is what the maker is
already charging — which is exactly why the edge isn't capturable. And the AUC is
deliberately weak (~coin-flip-plus): that weakness IS the finding. See the README.
"""
from __future__ import annotations
import json
import os
import xgboost as xgb
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
def _importances(name: str, model_file: str, feat_file: str, top: int = 8) -> None:
# Native Booster API — no sklearn needed (XGBClassifier would require it).
try:
booster = xgb.Booster()
booster.load_model(os.path.join(ROOT, "models", model_file))
with open(os.path.join(ROOT, "models", feat_file)) as f:
names = json.load(f)
except Exception as e:
print(f" [{name}] could not load ({e})")
return
gain = booster.get_score(importance_type="gain") # {feature: score}, unused feats absent
# if the model stored generic f0/f1 names, remap them onto the real feature list
if gain and all(k[1:].isdigit() for k in gain if k.startswith("f")):
gain = {names[int(k[1:])]: v for k, v in gain.items()
if k.startswith("f") and int(k[1:]) < len(names)} or gain
total = sum(gain.values()) or 1.0
ranked = sorted(((n, gain.get(n, 0.0) / total) for n in names), key=lambda x: -x[1])
print(f"\n{name} ({model_file}, {len(names)} features)")
top1 = ranked[0][1] or 1.0
for feat, w in ranked[:top]:
bar = "" * int(round(w / top1 * 28)) if w > 0 else "·"
print(f" {feat:<14} {w:6.3f} {bar}")
def _economics_from_log() -> None:
"""The 'high win-rate trap', straight from real paper trades.
A trade filled at ask A pays (1 - A) on a win and loses A on a loss. Paying up
for a high win rate hands the payoff to the maker — high WR, negative PnL.
"""
path = os.path.join(HERE, "data", "paper_trades_sample.jsonl")
if not os.path.exists(path):
return
rows = []
with open(path) as f:
for line in f:
line = line.strip()
if not line:
continue
d = json.loads(line)
if d.get("ask") is not None and d.get("won") is not None and d.get("pnl_5sh") is not None:
rows.append(d)
if not rows:
return
wr_all = sum(r["won"] for r in rows) / len(rows)
pnl_all = sum(r["pnl_5sh"] for r in rows) / len(rows)
print("\n" + "=" * 68)
print(f"Real paper trades — no price bucket is profitable ({len(rows)} trades, ml/data/)")
print("=" * 68)
print(" fill at ask A → a win pays (1-A), a loss costs A. Split by entry price:\n")
print(f" {'ask range':<11} {'n':>4} {'win rate':>9} {'mean PnL / 5sh':>15}")
for lo, hi in [(0.0, 0.5), (0.5, 0.7), (0.7, 0.85), (0.85, 1.01)]:
b = [r for r in rows if lo <= r["ask"] < hi]
if not b:
continue
wr = sum(r["won"] for r in b) / len(b)
pnl = sum(r["pnl_5sh"] for r in b) / len(b)
print(f" {lo:.2f}-{hi:<6.2f} {len(b):>4} {wr:>8.0%} {pnl:>+15.2f}")
print(f" {'ALL':<11} {len(rows):>4} {wr_all:>8.0%} {pnl_all:>+15.2f}")
print("\n Mean PnL is negative in every bucket — cheap or expensive, high win rate or")
print(" low. Across this cross-section of 29 strategies there is no entry price that")
print(" comes out ahead: the maker's spread plus execution costs take the rest.")
def main() -> None:
print("=" * 68)
print("XGBoost models — feature importances (what the model actually learned)")
print("=" * 68)
_importances("Lock model — will the current leader hold to settlement?",
"lock_xgb.json", "lock_xgb_features.json")
_importances("Direction model (left=140s)", "direction_left140.json", "direction_features.json")
_importances("Hold-7s model", "direction_hold7s.json", "hold7s_features.json")
_economics_from_log()
print("\n" + "-" * 68)
print("Read the top features: `ask`, `ask_margin`, `ask_vel` are the maker's own")
print("price. The best predictor of the outcome is what the maker already charges —")
print("you can't beat a signal that IS the counterparty. See README, Pillar 2.")
if __name__ == "__main__":
main()
+249
View File
@@ -0,0 +1,249 @@
#!/usr/bin/env python3
"""Train XGBoost lock model V2 — pre-trigger features only (no data leakage).
Targets ask<0.85 trades where the real edge is.
Features computed ONLY from snapshots BEFORE trigger moment.
Usage:
cd ~/btc_15m_collab/rewrite
source .venv/bin/activate
python3 tools/xgb_lock_train_v2.py
"""
import json
import numpy as np
import pandas as pd
import xgboost as xgb
from sklearn.metrics import roc_auc_score
from collections import defaultdict
PAPER_FILE = "data/chainlink_predictor/paper_trades.jsonl"
SNAPSHOT_FILE = "data/chainlink_predictor/snapshots.jsonl"
MODEL_PATH = "models/lock_xgb.json"
FEATURES_PATH = "models/lock_xgb_features.json"
# New feature set — all computable from pre-trigger snapshots
FEATURE_NAMES = [
"ask", # our side's CLOB ask at trigger
"abs_pm_a", # |pm_a| at trigger
"left", # seconds remaining when triggered
"rv_5m", # 5-min realized volatility
"rv_15m", # 15-min realized volatility
"sim_a_vol", # exchange price divergence
"ask_margin", # our_ask - opp_ask (CLOB directional confidence)
"pm_a_accel", # |pm_a| growth in last 30s before trigger
"ask_vel", # ask price change rate before trigger
"vol_ratio", # our volume / total volume
"total_vol", # total CLOB volume (USDC)
"sign_changes", # pm_a sign flips before trigger (regime)
"pm_a_std", # pm_a standard deviation (stability)
"diff", # |sim - pm| at trigger (CB-PM divergence proxy)
"trades_count", # CLOB trade count
]
def build_snapshot_index():
"""Index snapshots by window_id."""
print("Loading snapshots...")
snaps_by_wid = defaultdict(list)
with open(SNAPSHOT_FILE, "rb") as f:
f.seek(0, 2)
sz = f.tell()
for offset in range(0, sz, 200_000_000):
f.seek(offset)
f.readline()
chunk = f.read(200_000_000)
for line in chunk.split(b"\n"):
if not line:
continue
try:
s = json.loads(line)
wid = str(s.get("window_id", ""))
if not wid:
continue
clob = s.get("clob", {})
snaps_by_wid[wid].append({
"left": s.get("left_sec", 0),
"pm_a": s.get("pm_a", 0),
"rv_5m": s.get("rv_5m", 0),
"rv_15m": s.get("rv_15m", 0),
"diff": s.get("diff", 0),
"sim_a_vol": s.get("sim_a_vol", 0),
"up_ask": clob.get("up_ask", 0),
"down_ask": clob.get("down_ask", 0),
"up_vol": clob.get("up_buy_usdc", 0),
"down_vol": clob.get("down_buy_usdc", 0),
"trades": clob.get("trades", 0),
})
except Exception:
pass
print(f" {len(snaps_by_wid)} windows indexed")
return snaps_by_wid
def compute_pretrigger_features(snaps, trigger_left, direction):
"""Compute features using ONLY snapshots BEFORE trigger moment."""
before = [s for s in snaps if s["left"] > trigger_left]
if len(before) < 5:
return None
our_ask_key = "up_ask" if direction == "UP" else "down_ask"
opp_ask_key = "down_ask" if direction == "UP" else "up_ask"
at_trig = before[-1] # closest to trigger
our_ask = at_trig[our_ask_key]
opp_ask = at_trig[opp_ask_key]
our_asks = [s[our_ask_key] for s in before if s[our_ask_key] > 0]
if not our_asks:
return None
pma_vals = [abs(s["pm_a"]) for s in before]
# pm_a acceleration (last 30s before trigger vs earlier)
recent = [s for s in before if s["left"] <= trigger_left + 35]
earlier = [s for s in before if s["left"] > trigger_left + 35]
if recent and earlier:
pm_a_accel = np.mean([abs(s["pm_a"]) for s in recent]) - np.mean([abs(s["pm_a"]) for s in earlier])
ask_vel = (our_asks[-1] - our_asks[0]) / max(len(our_asks), 1) if len(our_asks) > 3 else 0
else:
pm_a_accel = 0
ask_vel = 0
# Volume
our_vol = at_trig["up_vol"] if direction == "UP" else at_trig["down_vol"]
opp_vol = at_trig["down_vol"] if direction == "UP" else at_trig["up_vol"]
total_vol = our_vol + opp_vol
vol_ratio = our_vol / (total_vol + 1)
# Regime
signs = np.sign([s["pm_a"] for s in before])
sign_changes = int(np.sum(np.diff(signs) != 0))
pm_a_std = float(np.std(pma_vals))
return {
"ask_margin": our_ask - opp_ask,
"pm_a_accel": pm_a_accel,
"ask_vel": ask_vel,
"vol_ratio": vol_ratio,
"total_vol": total_vol,
"sign_changes": sign_changes,
"pm_a_std": pm_a_std,
"rv_5m": at_trig["rv_5m"],
"rv_15m": at_trig.get("rv_15m", 0),
"sim_a_vol": at_trig["sim_a_vol"],
"diff": abs(at_trig["diff"]),
"trades_count": at_trig["trades"],
}
def load_data(snaps_by_wid):
"""Load lock paper trades and compute pre-trigger features."""
rows = []
with open(PAPER_FILE) as f:
for line in f:
try:
d = json.loads(line)
except Exception:
continue
if d.get("strategy") != "lock" or "won" not in d:
continue
if abs(d.get("pm_a", 0)) < 60:
continue
wid = str(d.get("window_id", ""))
snaps = snaps_by_wid.get(wid, [])
trigger_left = d.get("left", 140)
direction = d.get("direction", "UP")
feats = compute_pretrigger_features(snaps, trigger_left, direction)
if feats is None:
continue
row = {
"won": int(bool(d["won"])),
"ask": d["ask"],
"abs_pm_a": abs(d["pm_a"]),
"left": trigger_left,
}
row.update(feats)
rows.append(row)
return pd.DataFrame(rows)
def main():
snaps = build_snapshot_index()
df = load_data(snaps)
print(f"\nLoaded {len(df)} lock trades (pm_a>=60), WR={df['won'].mean():.1%}")
# Show ask distribution
cheap = df[df["ask"] < 0.85]
expensive = df[df["ask"] >= 0.85]
print(f" ask<0.85: {len(cheap)} trades, WR={cheap['won'].mean():.1%}")
print(f" ask>=0.85: {len(expensive)} trades, WR={expensive['won'].mean():.1%}")
# Train on ALL trades (model learns ask is important feature)
X = df[FEATURE_NAMES]
y = df["won"]
# Time-based split
split = int(len(df) * 0.7)
X_train, X_test = X.iloc[:split], X.iloc[split:]
y_train, y_test = y.iloc[:split], y.iloc[split:]
print(f"\nTrain: {len(X_train)} (WR={y_train.mean():.1%})")
print(f"Test: {len(X_test)} (WR={y_test.mean():.1%})")
model = xgb.XGBClassifier(
n_estimators=200, max_depth=3, learning_rate=0.05,
subsample=0.8, colsample_bytree=0.7,
eval_metric="logloss", random_state=42,
)
model.fit(X_train, y_train, eval_set=[(X_test, y_test)], verbose=False)
proba = model.predict_proba(X_test)[:, 1]
auc = roc_auc_score(y_test, proba)
print(f"\nOut-of-sample AUC: {auc:.3f}")
# Threshold analysis — focus on ask<0.85 trades in test set
print(f"\nThreshold analysis (test set, ask<0.85 only):")
cheap_mask = X_test["ask"] < 0.85
cheap_proba = proba[cheap_mask]
cheap_y = y_test[cheap_mask]
cheap_X = X_test[cheap_mask]
print(f" Total cheap trades in test: {cheap_mask.sum()}")
for thresh in [0.50, 0.60, 0.70, 0.75, 0.80, 0.85, 0.90]:
keep = cheap_proba >= thresh
if keep.sum() == 0:
continue
wr = cheap_y[keep].mean()
n = keep.sum()
block_n = (~keep).sum()
asks = cheap_X.loc[keep, "ask"]
pnl = sum((1 - asks.iloc[i]) * 5 if cheap_y[keep].iloc[i] else -asks.iloc[i] * 5 for i in range(n))
print(f" >={thresh:.2f}: KEEP {n:3d} WR={wr:.0%} PnL=${pnl:+.1f} | BLOCK {block_n:3d}")
# Feature importance
print(f"\nTop features:")
importance = sorted(zip(FEATURE_NAMES, model.feature_importances_), key=lambda x: -x[1])
for name, score in importance:
print(f" {name:>15s} {score:.3f}")
# Train final model on ALL data
print(f"\nTraining final model on all {len(df)} samples...")
model_final = xgb.XGBClassifier(
n_estimators=200, max_depth=3, learning_rate=0.05,
subsample=0.8, colsample_bytree=0.7,
eval_metric="logloss", random_state=42,
)
model_final.fit(X, y, verbose=False)
model_final.save_model(MODEL_PATH)
print(f"Model saved to {MODEL_PATH}")
with open(FEATURES_PATH, "w") as f:
json.dump(FEATURE_NAMES, f)
print(f"Feature names saved to {FEATURES_PATH}")
if __name__ == "__main__":
main()
+1
View File
@@ -0,0 +1 @@
["abs_pm_a", "pm_a_accel", "cb_a_abs", "bn_a_abs", "edge", "cb_same", "bn_same", "cb_stronger", "bid_ratio", "vol_ratio", "total_vol", "vol_growth", "rv_5m", "trades", "open_vol_ratio", "open_matches", "diff_abs", "pm_a_velocity", "pm_a_from_peak", "ask_from_peak", "direction_flips", "time_at_direction", "rv_ratio", "dvol", "funding", "oi_chg", "cb_vol_60s", "vol_spike", "flip_rate", "left_sec", "pm_a_z", "pm_a_vol_z"]
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
["abs_pm_a", "z", "vz", "left_sec", "cb_same", "bn_same", "cb_a_abs", "bn_a_abs", "edge_signed", "rv_5m", "ask", "ask_ratio"]
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
["ask", "abs_pm_a", "left", "rv_5m", "rv_15m", "sim_a_vol", "ask_margin", "pm_a_accel", "ask_vel", "vol_ratio", "total_vol", "sign_changes", "pm_a_std", "diff", "trades_count"]
+15
View File
@@ -0,0 +1,15 @@
# ── Paper / data-collection + XGBoost inference (all you need to run the study) ──
aiohttp
orjson
numpy
xgboost
scikit-learn # the live engine loads its XGBoost gates via XGBClassifier
# ── Live trading only (--trade). NOT needed for paper mode. ──
# The live executor uses a specific Polymarket CLOB client fork (py_clob_client_v2)
# that is not on PyPI. Install Polymarket's py-clob-client and adapt, or wire your
# own. Left commented so paper mode installs clean.
# python-dotenv
# web3
# httpx
# py-clob-client
View File
+10
View File
@@ -0,0 +1,10 @@
# ─────────────────────────────────────────────────────────────────────────
# LIVE TRADING ONLY. Paper / data-collection mode needs NONE of this.
#
# To trade live: copy this file to src/predictor/.env and fill in your own
# values. This places REAL orders with REAL funds on Polymarket. The code is
# provided as-is and UNAUDITED — audit it yourself and trade at your own risk.
# ─────────────────────────────────────────────────────────────────────────
PREDICTOR_WALLET_KEY=
PREDICTOR_WALLET_ADDRESS=
POLYGON_RPC_URL=
View File
+387
View File
@@ -0,0 +1,387 @@
"""Polymarket CLOB feed for predictor — discovers current window tokens and tracks prices."""
from __future__ import annotations
import asyncio
import json
import logging
import time
from dataclasses import dataclass
import aiohttp
import orjson
logger = logging.getLogger(__name__)
BUCKET_SEC = 300 # 5-minute windows
BUCKET_15M_SEC = 900 # 15-minute windows
GAMMA_URL = "https://gamma-api.polymarket.com/events"
SLUG_PREFIX = "btc-updown-5m-"
SLUG_PREFIX_15M = "btc-updown-15m-"
CLOB_WS_URL = "wss://ws-subscriptions-clob.polymarket.com/ws/market"
@dataclass
class WindowTokens:
"""Current window's token IDs and CLOB prices."""
window_id: int = 0
slug: str = ""
token_up: str = ""
token_down: str = ""
up_bid: float = 0.0
up_ask: float = 0.0
down_bid: float = 0.0
down_ask: float = 0.0
last_update_mono: float = 0.0
# Volume tracking (cumulative per window, in USDC = size * price, BUY only)
up_buy_usdc: float = 0.0 # USDC spent buying UP tokens
down_buy_usdc: float = 0.0 # USDC spent buying DOWN tokens
trade_count: int = 0 # total BUY trade events this window
# Book depth (from latest snapshot)
up_bid_depth: float = 0.0 # total bid size for UP
up_ask_depth: float = 0.0 # total ask size for UP
down_bid_depth: float = 0.0
down_ask_depth: float = 0.0
# Shared state — accessible by strategy and executor
current_window = WindowTokens() # 5m current window
current_window_15m = WindowTokens() # 15m current window (parallel)
async def _discover_window(session: aiohttp.ClientSession) -> WindowTokens | None:
"""Find the current active window via Gamma API."""
now = time.time()
base = int(now // BUCKET_SEC) * BUCKET_SEC
candidates = [base, base + BUCKET_SEC, base - BUCKET_SEC]
for ts in candidates:
slug = f"{SLUG_PREFIX}{ts}"
try:
async with session.get(
GAMMA_URL, params={"slug": slug},
timeout=aiohttp.ClientTimeout(total=5),
) as resp:
if resp.status != 200:
continue
data = await resp.json()
if not isinstance(data, list) or not data:
continue
event = data[0]
markets = event.get("markets", [])
if not markets:
continue
market = markets[0]
raw_ids = market.get("clobTokenIds")
if not raw_ids:
continue
if isinstance(raw_ids, str):
token_ids = json.loads(raw_ids)
else:
token_ids = raw_ids
if not token_ids or len(token_ids) < 2:
continue
logger.info(f"[clob] discovered: {slug}, up={token_ids[0][:16]}..., down={token_ids[1][:16]}...")
return WindowTokens(
window_id=ts,
slug=slug,
token_up=token_ids[0],
token_down=token_ids[1],
)
except Exception as e:
logger.debug(f"[clob] discovery failed for {slug}: {e}")
continue
return None
def _update_prices(asset_id: str, token_up: str, token_down: str,
best_bid: float, best_ask: float) -> None:
"""Update current_window prices for a given asset."""
global current_window
if asset_id == token_up:
if best_bid > 0:
current_window.up_bid = best_bid
if best_ask > 0:
current_window.up_ask = best_ask
current_window.last_update_mono = time.monotonic()
elif asset_id == token_down:
if best_bid > 0:
current_window.down_bid = best_bid
if best_ask > 0:
current_window.down_ask = best_ask
current_window.last_update_mono = time.monotonic()
def _parse_clob_msg(data, token_up: str, token_down: str) -> None:
"""Parse CLOB WS message and update current_window prices.
Message formats:
1. Book snapshot (list): [{"asset_id":..., "bids":[{"price":"0.86","size":"10"}], "asks":[...]}]
2. Price change (dict): {"price_changes": [{"asset_id":..., "best_bid":"0.86", "best_ask":"0.87"}]}
3. Last trade (dict): {"asset_id":..., "price":"0.86", "event_type":"last_trade_price"}
"""
try:
d = orjson.loads(data) if isinstance(data, (bytes, str)) else data
except Exception:
return
# Handle list of book snapshots
items = d if isinstance(d, list) else [d]
for item in items:
if not isinstance(item, dict):
continue
# Type 1: Book snapshot — has "bids" and "asks" arrays with "asset_id"
asset_id = str(item.get("asset_id", ""))
if asset_id and "bids" in item and "asks" in item:
bids = item["bids"]
asks = item["asks"]
best_bid = 0.0
best_ask = 0.0
bid_depth = 0.0
ask_depth = 0.0
if bids:
try:
best_bid = max(float(b["price"]) for b in bids if isinstance(b, dict))
bid_depth = sum(float(b.get("size", 0)) for b in bids if isinstance(b, dict))
except Exception:
pass
if asks:
try:
best_ask = min(float(a["price"]) for a in asks if isinstance(a, dict))
ask_depth = sum(float(a.get("size", 0)) for a in asks if isinstance(a, dict))
except Exception:
pass
_update_prices(asset_id, token_up, token_down, best_bid, best_ask)
# Update book depth
if asset_id == token_up:
current_window.up_bid_depth = bid_depth
current_window.up_ask_depth = ask_depth
elif asset_id == token_down:
current_window.down_bid_depth = bid_depth
current_window.down_ask_depth = ask_depth
# Type 2: Price changes — has "price_changes" array (includes trade size/side)
for pc in item.get("price_changes", []):
if not isinstance(pc, dict):
continue
pc_asset = str(pc.get("asset_id", ""))
if pc_asset not in (token_up, token_down):
continue
bb = float(pc.get("best_bid", 0))
ba = float(pc.get("best_ask", 0))
_update_prices(pc_asset, token_up, token_down, bb, ba)
# Note: price_changes are orderbook update events (new/cancel/fill),
# NOT actual trades. Real trade volume is tracked via last_trade_price below.
# Type 3: Last trade price — actual executed trade
# Polymarket only broadcasts UP token trades (DOWN inferred from UP inverse):
# UP BUY @price → someone bought UP tokens (paid price)
# UP SELL @price → someone sold UP tokens = someone bought DOWN @(1-price)
if item.get("event_type") == "last_trade_price" and asset_id:
px = float(item.get("price", 0))
size = float(item.get("size", 0))
side = item.get("side", "")
if px > 0:
_update_prices(asset_id, token_up, token_down, px, 0.0)
if size > 0 and asset_id == token_up:
current_window.trade_count += 1
if side == "BUY":
# Someone bought UP at px
current_window.up_buy_usdc += size * px
elif side == "SELL":
# Someone sold UP at px = someone bought DOWN at (1-px)
current_window.down_buy_usdc += size * (1.0 - px)
async def clob_feed_task(shutdown: asyncio.Event):
"""Main CLOB feed loop — discover windows and subscribe to price updates."""
global current_window
logger.info("[clob] feed starting")
while not shutdown.is_set():
try:
async with aiohttp.ClientSession() as session:
# Discover current window
info = await _discover_window(session)
if info is None:
logger.warning("[clob] no active window found, retrying in 5s")
await asyncio.sleep(5)
continue
current_window = info
# Connect to CLOB WS
async with session.ws_connect(
CLOB_WS_URL,
heartbeat=20.0,
receive_timeout=40.0,
) as ws:
# Subscribe to both tokens
for token_id in [info.token_up, info.token_down]:
await ws.send_json({
"type": "Market",
"assets_ids": [token_id],
})
logger.info(f"[clob] subscribed: {info.slug}")
# Read messages until window expires
boundary = info.window_id + BUCKET_SEC
while not shutdown.is_set() and time.time() < boundary + 5:
msg = await ws.receive(timeout=10)
if msg.type in (aiohttp.WSMsgType.TEXT, aiohttp.WSMsgType.BINARY):
_parse_clob_msg(msg.data, info.token_up, info.token_down)
elif msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
break
logger.info(f"[clob] window expired: {info.slug}")
except asyncio.CancelledError:
raise
except Exception as e:
logger.warning(f"[clob] error: {e}, retrying in 2s")
await asyncio.sleep(2)
logger.info("[clob] feed stopped")
# ──────────────────────────────────────────────────────────────────────
# 15-minute window CLOB feed (parallel WebSocket subscription)
# ──────────────────────────────────────────────────────────────────────
async def _discover_window_15m(session: aiohttp.ClientSession) -> WindowTokens | None:
"""Find the current active 15m window via Gamma API."""
now = time.time()
base = int(now // BUCKET_15M_SEC) * BUCKET_15M_SEC
candidates = [base, base + BUCKET_15M_SEC, base - BUCKET_15M_SEC]
for ts in candidates:
slug = f"{SLUG_PREFIX_15M}{ts}"
try:
async with session.get(
GAMMA_URL, params={"slug": slug},
timeout=aiohttp.ClientTimeout(total=5),
) as resp:
if resp.status != 200:
continue
data = await resp.json()
if not isinstance(data, list) or not data:
continue
event = data[0]
markets = event.get("markets", [])
if not markets:
continue
market = markets[0]
raw_ids = market.get("clobTokenIds")
if not raw_ids:
continue
token_ids = json.loads(raw_ids) if isinstance(raw_ids, str) else raw_ids
if not token_ids or len(token_ids) < 2:
continue
logger.info(f"[clob15] discovered: {slug}, up={token_ids[0][:16]}..., down={token_ids[1][:16]}...")
return WindowTokens(
window_id=ts, slug=slug,
token_up=token_ids[0], token_down=token_ids[1],
)
except Exception as e:
logger.debug(f"[clob15] discovery failed for {slug}: {e}")
continue
return None
def _update_prices_15m(asset_id: str, token_up: str, token_down: str,
best_bid: float, best_ask: float) -> None:
"""Update current_window_15m prices for a given asset."""
global current_window_15m
if asset_id == token_up:
if best_bid > 0: current_window_15m.up_bid = best_bid
if best_ask > 0: current_window_15m.up_ask = best_ask
current_window_15m.last_update_mono = time.monotonic()
elif asset_id == token_down:
if best_bid > 0: current_window_15m.down_bid = best_bid
if best_ask > 0: current_window_15m.down_ask = best_ask
current_window_15m.last_update_mono = time.monotonic()
def _parse_clob_msg_15m(data, token_up: str, token_down: str) -> None:
"""Parse CLOB WS message and update current_window_15m prices."""
try:
d = orjson.loads(data) if isinstance(data, (bytes, str)) else data
except Exception:
return
items = d if isinstance(d, list) else [d]
for item in items:
if not isinstance(item, dict): continue
asset_id = str(item.get("asset_id", ""))
# Type 1: Book snapshot
if asset_id and "bids" in item and "asks" in item:
bids = item["bids"]; asks = item["asks"]
best_bid = 0.0; best_ask = 0.0
bid_depth = 0.0; ask_depth = 0.0
if bids:
try:
best_bid = max(float(b["price"]) for b in bids if isinstance(b, dict))
bid_depth = sum(float(b.get("size", 0)) for b in bids if isinstance(b, dict))
except Exception: pass
if asks:
try:
best_ask = min(float(a["price"]) for a in asks if isinstance(a, dict))
ask_depth = sum(float(a.get("size", 0)) for a in asks if isinstance(a, dict))
except Exception: pass
_update_prices_15m(asset_id, token_up, token_down, best_bid, best_ask)
if asset_id == token_up:
current_window_15m.up_bid_depth = bid_depth
current_window_15m.up_ask_depth = ask_depth
elif asset_id == token_down:
current_window_15m.down_bid_depth = bid_depth
current_window_15m.down_ask_depth = ask_depth
# Type 2: Price changes
for pc in item.get("price_changes", []):
if not isinstance(pc, dict): continue
pc_asset = str(pc.get("asset_id", ""))
if pc_asset not in (token_up, token_down): continue
bb = float(pc.get("best_bid", 0))
ba = float(pc.get("best_ask", 0))
_update_prices_15m(pc_asset, token_up, token_down, bb, ba)
async def clob_feed_15m_task(shutdown: asyncio.Event):
"""Parallel CLOB feed for 15m windows — same pattern as 5m feed."""
global current_window_15m
logger.info("[clob15] feed starting")
while not shutdown.is_set():
try:
async with aiohttp.ClientSession() as session:
info = await _discover_window_15m(session)
if info is None:
logger.warning("[clob15] no active 15m window, retry 5s")
await asyncio.sleep(5)
continue
current_window_15m = info
async with session.ws_connect(
CLOB_WS_URL, heartbeat=20.0, receive_timeout=40.0,
) as ws:
for token_id in [info.token_up, info.token_down]:
await ws.send_json({"type": "Market", "assets_ids": [token_id]})
logger.info(f"[clob15] subscribed: {info.slug}")
boundary = info.window_id + BUCKET_15M_SEC
while not shutdown.is_set() and time.time() < boundary + 5:
msg = await ws.receive(timeout=10)
if msg.type in (aiohttp.WSMsgType.TEXT, aiohttp.WSMsgType.BINARY):
_parse_clob_msg_15m(msg.data, info.token_up, info.token_down)
elif msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
break
logger.info(f"[clob15] window expired: {info.slug}")
except asyncio.CancelledError:
raise
except Exception as e:
logger.warning(f"[clob15] error: {e}, retry 2s")
await asyncio.sleep(2)
logger.info("[clob15] feed stopped")
File diff suppressed because it is too large Load Diff
+341
View File
@@ -0,0 +1,341 @@
"""Predictor Executor — places orders on Polymarket using the predictor's own wallet."""
from __future__ import annotations
import asyncio
import logging
import math
import os
import time
from dotenv import load_dotenv
from py_clob_client_v2.client import ClobClient
from py_clob_client_v2.clob_types import OrderArgs, OrderType, PartialCreateOrderOptions
from py_clob_client_v2.order_builder.constants import BUY
logger = logging.getLogger(__name__)
class PredictorExecutor:
"""Simple executor for the predictor strategy.
Uses its own wallet (from src/predictor/.env).
Stateless per-window: place one order, track if filled, done.
"""
def __init__(self):
# Load predictor's own .env
env_path = os.path.join(os.path.dirname(__file__), ".env")
load_dotenv(env_path, override=True)
key = os.environ.get("PREDICTOR_WALLET_KEY", "")
address = os.environ.get("PREDICTOR_WALLET_ADDRESS", "")
rpc_url = os.environ.get("POLYGON_RPC_URL", "")
if not key:
raise ValueError("PREDICTOR_WALLET_KEY not set in src/predictor/.env")
# Force HTTP/1.1
import httpx as _httpx
import py_clob_client_v2.http_helpers.helpers as _clob_helpers
_clob_helpers._http_client = _httpx.Client(http2=False, timeout=30)
self._client = ClobClient(
"https://clob.polymarket.com",
key=key,
chain_id=137,
signature_type=0,
funder=address,
)
self._client.set_api_creds(self._client.create_or_derive_api_key())
# Preflight caches (V2: fee_rate is computed by protocol at match time)
self._tick_size_cache: dict[str, str] = {}
self._neg_risk_cache: dict[str, bool] = {}
# Track current window's order
self._current_window_id: int = 0
self._order_placed_this_window: bool = False
self._last_order_id: str = ""
# Daily P&L tracking
self._daily_pnl: float = 0.0
self._daily_date: str = ""
logger.info(f"[executor] initialized: wallet={address}")
async def _resolve_preflight(self, token_id: str) -> tuple[str, bool]:
"""Cache tick_size, neg_risk per token."""
if token_id not in self._tick_size_cache:
self._tick_size_cache[token_id] = await asyncio.to_thread(
self._client.get_tick_size, token_id
)
if token_id not in self._neg_risk_cache:
self._neg_risk_cache[token_id] = await asyncio.to_thread(
self._client.get_neg_risk, token_id
)
return (
self._tick_size_cache[token_id],
self._neg_risk_cache[token_id],
)
def new_window(self, window_id: int):
"""Called when a new window starts."""
if window_id != self._current_window_id:
self._current_window_id = window_id
self._order_placed_this_window = False
self._last_order_id = ""
@property
def can_trade(self) -> bool:
"""Allow multiple trades per window (lock + cb_flip hedge)."""
return True
async def place_buy(
self,
token_id: str,
price: float,
amount_usd: float,
expiration_sec: int = 30,
) -> str | None:
"""Place a BUY order.
Args:
token_id: UP or DOWN token to buy
price: Limit price (e.g. 0.72)
amount_usd: Dollar amount to spend
expiration_sec: GTD expiration in seconds
Returns:
order_id if placed, None if failed
"""
try:
# Resolve preflight
tick_size, neg_risk = await self._resolve_preflight(token_id)
# Calculate quantity
qty = math.floor(amount_usd / price)
if qty < 1:
logger.warning(f"[executor] qty too small: {qty} (amount=${amount_usd}, price={price})")
return None
# Build order
order_args = OrderArgs(
price=price,
size=float(qty),
side=BUY,
token_id=token_id,
expiration=str(int(time.time()) + expiration_sec),
)
options = PartialCreateOrderOptions(
tick_size=tick_size,
neg_risk=neg_risk,
)
signed_order = await asyncio.to_thread(
self._client.create_order, order_args, options
)
t0 = time.monotonic()
result = await asyncio.to_thread(
self._client.post_order, signed_order, OrderType.GTD
)
latency = (time.monotonic() - t0) * 1000
order_id = result.get("orderID", "")
self._order_placed_this_window = True
self._last_order_id = order_id
logger.info(
f"[executor] ORDER PLACED: {order_id[:16]}... "
f"token={token_id[:16]}... price={price} qty={qty} "
f"cost=${qty * price:.2f} latency={latency:.0f}ms"
)
return order_id
except Exception as e:
logger.error(f"[executor] order failed: {e}")
return None
async def check_order_filled(self, order_id: str) -> dict:
"""Check order fill status. Returns actual avg fill price from trades.
Returns {'filled': shares_matched, 'limit_price': X, 'avg_fill_price': Y} or None on error.
avg_fill_price is the weighted average fill price (size-weighted across partial fills).
"""
try:
order = await asyncio.to_thread(self._client.get_order, order_id)
logger.info(f"[executor] order {order_id[:16]}... raw response: {order}")
matched = float(order.get("size_matched", 0))
limit_price = float(order.get("price", 0))
avg_fill_price = limit_price # fallback
if matched > 0:
# Query actual trades to get true fill prices
try:
trades = await asyncio.to_thread(self._client.get_trades, {"id": order_id})
if trades:
total_cost = 0.0
total_size = 0.0
for t in trades:
# Each trade has size and price
tsize = float(t.get("size", 0))
tprice = float(t.get("price", 0))
if tsize > 0 and tprice > 0:
total_cost += tsize * tprice
total_size += tsize
if total_size > 0:
avg_fill_price = total_cost / total_size
logger.info(f"[executor] {order_id[:16]}... avg_fill={avg_fill_price:.4f} (limit={limit_price:.4f}, {len(trades)} fills)")
except Exception as e:
logger.warning(f"[executor] get_trades failed: {e}, using limit price as fallback")
return {"filled": matched, "limit_price": limit_price, "avg_fill_price": avg_fill_price}
except Exception as e:
logger.warning(f"[executor] check_order failed: {e}")
return None
async def cancel_all(self) -> None:
"""Cancel all open orders."""
try:
await asyncio.to_thread(self._client.cancel_all)
logger.info("[executor] cancelled all orders")
except Exception as e:
logger.warning(f"[executor] cancel_all failed: {e}")
async def get_balance(self) -> float:
"""Get USDC balance."""
try:
from web3 import Web3
rpc = os.environ.get("POLYGON_RPC_URL", "")
address = os.environ.get("PREDICTOR_WALLET_ADDRESS", "")
w3 = Web3(Web3.HTTPProvider(rpc))
usdc = w3.eth.contract(
address=Web3.to_checksum_address("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"),
abi=[{"name": "balanceOf", "type": "function", "stateMutability": "view",
"inputs": [{"name": "account", "type": "address"}],
"outputs": [{"name": "", "type": "uint256"}]}],
)
bal = usdc.functions.balanceOf(Web3.to_checksum_address(address)).call()
return bal / 1e6
except Exception as e:
logger.error(f"[executor] balance check failed: {e}")
return 0.0
async def auto_redeem(self, slug: str) -> float:
"""Auto-redeem a settled window. Returns USDC gained."""
try:
from web3 import Web3
from web3.middleware import ExtraDataToPOAMiddleware
from eth_account import Account
import requests
rpc = os.environ.get("POLYGON_RPC_URL", "")
key = os.environ.get("PREDICTOR_WALLET_KEY", "")
address = os.environ.get("PREDICTOR_WALLET_ADDRESS", "")
w3 = Web3(Web3.HTTPProvider(rpc))
w3.middleware_onion.inject(ExtraDataToPOAMiddleware, layer=0)
acct = Account.from_key(key)
eoa = Web3.to_checksum_address(address)
CTF_ADDR = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"
USDC_ADDR = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
ZERO_PARENT = b"\x00" * 32
CTF_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": []},
{"name": "balanceOf", "type": "function", "stateMutability": "view",
"inputs": [
{"name": "account", "type": "address"},
{"name": "id", "type": "uint256"},
], "outputs": [{"type": "uint256"}]},
{"name": "payoutDenominator", "type": "function", "stateMutability": "view",
"inputs": [{"name": "conditionId", "type": "bytes32"}],
"outputs": [{"type": "uint256"}]},
]
ERC20_ABI = [
{"name": "balanceOf", "type": "function", "stateMutability": "view",
"inputs": [{"name": "account", "type": "address"}],
"outputs": [{"type": "uint256"}]},
]
ctf = w3.eth.contract(address=Web3.to_checksum_address(CTF_ADDR), abi=CTF_ABI)
usdc = w3.eth.contract(address=Web3.to_checksum_address(USDC_ADDR), abi=ERC20_ABI)
# Lookup market via events endpoint (markets endpoint doesn't find by slug)
r = await asyncio.to_thread(
requests.get, "https://gamma-api.polymarket.com/events",
params={"slug": slug}, timeout=10,
)
data = r.json()
if not data:
logger.warning(f"[redeem] event not found: {slug}")
return 0
event = data[0]
markets = event.get("markets", [])
if not markets:
logger.warning(f"[redeem] no markets in event: {slug}")
return 0
market = markets[0]
cid = market.get("conditionId") or market.get("conditionID")
if not cid:
return 0
condition_b32 = bytes.fromhex(cid.removeprefix("0x"))
collateral = Web3.to_checksum_address(USDC_ADDR)
# Check if resolved
den = ctf.functions.payoutDenominator(condition_b32).call()
if den == 0:
logger.info(f"[redeem] {slug}: not resolved yet")
return 0
# Check USDC before
usdc_before = usdc.functions.balanceOf(eoa).call()
# Build redeem tx
nonce = w3.eth.get_transaction_count(eoa, "pending")
latest = w3.eth.get_block("latest")
base_fee = latest.get("baseFeePerGas", 30_000_000_000)
max_priority = 50_000_000_000
max_fee = base_fee * 2 + max_priority
tx = ctf.functions.redeemPositions(
collateral, ZERO_PARENT, condition_b32, [1, 2]
).build_transaction({
"from": eoa,
"nonce": nonce,
"maxFeePerGas": max_fee,
"maxPriorityFeePerGas": max_priority,
"gas": 300_000,
"chainId": 137,
"type": 2,
})
signed = acct.sign_transaction(tx)
txh = w3.eth.send_raw_transaction(signed.raw_transaction)
receipt = await asyncio.to_thread(
w3.eth.wait_for_transaction_receipt, txh, timeout=60,
)
if receipt["status"] != 1:
logger.warning(f"[redeem] {slug}: tx reverted")
return 0
usdc_after = usdc.functions.balanceOf(eoa).call()
delta = (usdc_after - usdc_before) / 1e6
logger.info(f"[redeem] {slug}: +${delta:.2f} (balance: ${usdc_after/1e6:.2f})")
return delta
except Exception as e:
logger.error(f"[redeem] failed: {e}")
return 0
+426
View File
@@ -0,0 +1,426 @@
"""Multi-source WebSocket price feeds for chainlink predictor."""
from __future__ import annotations
import asyncio
import logging
import time
from typing import Callable
import aiohttp
import orjson
from src.predictor.models import SourceName, SourceTick
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Generic helpers
# ---------------------------------------------------------------------------
async def _ws_loop(
name: SourceName,
url: str,
on_connect: Callable | None,
parse_msg: Callable,
tick_queue: asyncio.Queue[SourceTick],
shutdown: asyncio.Event,
ping_interval: float = 20.0,
reconnect_base: float = 1.0,
reconnect_max: float = 30.0,
):
"""Generic reconnecting WebSocket loop."""
delay = reconnect_base
while not shutdown.is_set():
try:
async with aiohttp.ClientSession() as session:
async with session.ws_connect(
url,
heartbeat=ping_interval,
receive_timeout=ping_interval * 2,
) as ws:
logger.info(f"[{name.value}] connected: {url}")
delay = reconnect_base
if on_connect:
await on_connect(ws)
while not shutdown.is_set():
msg = await ws.receive(timeout=ping_interval)
if msg.type in (aiohttp.WSMsgType.TEXT, aiohttp.WSMsgType.BINARY):
ticks = parse_msg(msg.data)
for t in ticks:
try:
tick_queue.put_nowait(t)
except asyncio.QueueFull:
pass
elif msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
break
except asyncio.CancelledError:
raise
except Exception as e:
logger.warning(f"[{name.value}] error: {e}, reconnecting in {delay:.0f}s")
if shutdown.is_set():
break
await asyncio.sleep(delay)
delay = min(delay * 2, reconnect_max)
logger.info(f"[{name.value}] stopped")
# ---------------------------------------------------------------------------
# Coinbase
# ---------------------------------------------------------------------------
async def coinbase_feed(
tick_queue: asyncio.Queue[SourceTick],
shutdown: asyncio.Event,
):
async def on_connect(ws):
await ws.send_json({
"type": "subscribe",
"channels": [{"name": "ticker", "product_ids": ["BTC-USD"]}],
})
def parse(data):
recv_ns = time.time_ns()
mono_ns = time.monotonic_ns()
try:
d = orjson.loads(data) if isinstance(data, (bytes, str)) else data
if d.get("type") != "ticker":
return []
px = float(d["price"])
vol = float(d.get("last_size", 0))
return [SourceTick(
source=SourceName.COINBASE, price=px,
source_ts_ns=recv_ns, recv_mono_ns=mono_ns, recv_ts_ns=recv_ns,
volume=vol,
)]
except Exception:
return []
await _ws_loop(
SourceName.COINBASE,
"wss://ws-feed.exchange.coinbase.com",
on_connect, parse, tick_queue, shutdown,
)
# ---------------------------------------------------------------------------
# Kraken
# ---------------------------------------------------------------------------
async def kraken_feed(
tick_queue: asyncio.Queue[SourceTick],
shutdown: asyncio.Event,
):
async def on_connect(ws):
await ws.send_json({
"event": "subscribe",
"pair": ["XBT/USD"],
"subscription": {"name": "trade"},
})
def parse(data):
recv_ns = time.time_ns()
mono_ns = time.monotonic_ns()
try:
d = orjson.loads(data) if isinstance(data, (bytes, str)) else data
# Kraken trade format: [channelID, [[price, volume, time, side, type, misc], ...], "trade", "XBT/USD"]
if not isinstance(d, list) or len(d) < 4:
return []
if d[-1] != "XBT/USD" or d[-2] != "trade":
return []
trades = d[1]
if not trades:
return []
# Take last trade
last = trades[-1]
px = float(last[0])
vol = float(last[1])
src_ts = int(float(last[2]) * 1e9)
return [SourceTick(
source=SourceName.KRAKEN, price=px,
source_ts_ns=src_ts, recv_mono_ns=mono_ns, recv_ts_ns=recv_ns,
volume=vol,
)]
except Exception:
return []
await _ws_loop(
SourceName.KRAKEN,
"wss://ws.kraken.com",
on_connect, parse, tick_queue, shutdown,
)
# ---------------------------------------------------------------------------
# Bitstamp
# ---------------------------------------------------------------------------
async def bitstamp_feed(
tick_queue: asyncio.Queue[SourceTick],
shutdown: asyncio.Event,
):
async def on_connect(ws):
await ws.send_json({
"event": "bts:subscribe",
"data": {"channel": "live_trades_btcusd"},
})
def parse(data):
recv_ns = time.time_ns()
mono_ns = time.monotonic_ns()
try:
d = orjson.loads(data) if isinstance(data, (bytes, str)) else data
if d.get("event") != "trade":
return []
td = d.get("data", {})
px = float(td["price"])
vol = float(td.get("amount", 0))
src_ts = int(td.get("timestamp", 0)) * 1_000_000_000 or recv_ns
return [SourceTick(
source=SourceName.BITSTAMP, price=px,
source_ts_ns=src_ts, recv_mono_ns=mono_ns, recv_ts_ns=recv_ns,
volume=vol,
)]
except Exception:
return []
await _ws_loop(
SourceName.BITSTAMP,
"wss://ws.bitstamp.net",
on_connect, parse, tick_queue, shutdown,
)
# ---------------------------------------------------------------------------
# CryptoCompare
# ---------------------------------------------------------------------------
async def cryptocompare_feed(
tick_queue: asyncio.Queue[SourceTick],
shutdown: asyncio.Event,
api_key: str = "",
poll_interval: float = 1.0,
):
"""Poll CryptoCompare REST API (WS requires paid key)."""
url = "https://min-api.cryptocompare.com/data/price?fsym=BTC&tsyms=USD"
if api_key:
url += f"&api_key={api_key}"
logger.info(f"[cryptocompare] polling started: {url}")
async with aiohttp.ClientSession() as session:
while not shutdown.is_set():
try:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=3)) as r:
if r.status == 200:
d = await r.json()
recv_ns = time.time_ns()
mono_ns = time.monotonic_ns()
px = float(d["USD"])
tick = SourceTick(
source=SourceName.CRYPTOCOMPARE, price=px,
source_ts_ns=recv_ns, recv_mono_ns=mono_ns, recv_ts_ns=recv_ns,
)
try:
tick_queue.put_nowait(tick)
except asyncio.QueueFull:
pass
except asyncio.CancelledError:
raise
except Exception as e:
logger.warning(f"[cryptocompare] poll error: {e}")
try:
await asyncio.wait_for(shutdown.wait(), timeout=poll_interval)
break
except asyncio.TimeoutError:
pass
logger.info("[cryptocompare] stopped")
# ---------------------------------------------------------------------------
# Gemini (REST polling — no free WS trade feed without auth)
# ---------------------------------------------------------------------------
async def gemini_feed(
tick_queue: asyncio.Queue[SourceTick],
shutdown: asyncio.Event,
poll_interval: float = 1.0,
):
"""Poll Gemini REST API every second."""
url = "https://api.gemini.com/v1/pubticker/btcusd"
logger.info(f"[gemini] polling started: {url}")
async with aiohttp.ClientSession() as session:
while not shutdown.is_set():
try:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=3)) as r:
if r.status == 200:
d = await r.json()
recv_ns = time.time_ns()
mono_ns = time.monotonic_ns()
px = float(d["last"])
tick = SourceTick(
source=SourceName.GEMINI, price=px,
source_ts_ns=recv_ns, recv_mono_ns=mono_ns, recv_ts_ns=recv_ns,
)
try:
tick_queue.put_nowait(tick)
except asyncio.QueueFull:
pass
except asyncio.CancelledError:
raise
except Exception as e:
logger.warning(f"[gemini] poll error: {e}")
try:
await asyncio.wait_for(shutdown.wait(), timeout=poll_interval)
break
except asyncio.TimeoutError:
pass
logger.info("[gemini] stopped")
# ---------------------------------------------------------------------------
# OKX
# ---------------------------------------------------------------------------
async def okx_feed(
tick_queue: asyncio.Queue[SourceTick],
shutdown: asyncio.Event,
):
async def on_connect(ws):
await ws.send_json({
"op": "subscribe",
"args": [{"channel": "trades", "instId": "BTC-USDT"}],
})
def parse(data):
recv_ns = time.time_ns()
mono_ns = time.monotonic_ns()
try:
d = orjson.loads(data) if isinstance(data, (bytes, str)) else data
if "data" not in d:
return []
ticks = []
for trade in d["data"]:
px = float(trade["px"])
vol = float(trade.get("sz", 0))
src_ts = int(trade.get("ts", 0)) * 1_000_000 or recv_ns
ticks.append(SourceTick(
source=SourceName.OKX, price=px,
source_ts_ns=src_ts, recv_mono_ns=mono_ns, recv_ts_ns=recv_ns,
volume=vol,
))
return ticks[-1:] if ticks else [] # last trade only
except Exception:
return []
await _ws_loop(
SourceName.OKX,
"wss://ws.okx.com:8443/ws/v5/public",
on_connect, parse, tick_queue, shutdown,
)
# ---------------------------------------------------------------------------
# Bybit
# ---------------------------------------------------------------------------
async def bybit_feed(
tick_queue: asyncio.Queue[SourceTick],
shutdown: asyncio.Event,
):
async def on_connect(ws):
await ws.send_json({
"op": "subscribe",
"args": ["publicTrade.BTCUSDT"],
})
def parse(data):
recv_ns = time.time_ns()
mono_ns = time.monotonic_ns()
try:
d = orjson.loads(data) if isinstance(data, (bytes, str)) else data
if d.get("topic") != "publicTrade.BTCUSDT":
return []
trades = d.get("data", [])
if not trades:
return []
last = trades[-1]
px = float(last["p"])
vol = float(last.get("v", 0))
src_ts = int(last.get("T", 0)) * 1_000_000 or recv_ns
return [SourceTick(
source=SourceName.BYBIT, price=px,
source_ts_ns=src_ts, recv_mono_ns=mono_ns, recv_ts_ns=recv_ns,
volume=vol,
)]
except Exception:
return []
await _ws_loop(
SourceName.BYBIT,
"wss://stream.bybit.com/v5/public/spot",
on_connect, parse, tick_queue, shutdown,
)
# ---------------------------------------------------------------------------
# Binance
# ---------------------------------------------------------------------------
async def binance_feed(
tick_queue: asyncio.Queue[SourceTick],
shutdown: asyncio.Event,
):
async def on_connect(ws):
pass # No subscription needed, URL contains the stream
def parse(data):
recv_ns = time.time_ns()
mono_ns = time.monotonic_ns()
try:
d = orjson.loads(data) if isinstance(data, (bytes, str)) else data
if "p" not in d:
return []
px = float(d["p"])
vol = float(d.get("q", 0))
src_ts = int(d.get("T", 0)) * 1_000_000 or recv_ns
return [SourceTick(
source=SourceName.BINANCE, price=px,
source_ts_ns=src_ts, recv_mono_ns=mono_ns, recv_ts_ns=recv_ns,
volume=vol,
)]
except Exception:
return []
await _ws_loop(
SourceName.BINANCE,
"wss://stream.binance.com:9443/ws/btcusdt@trade",
on_connect, parse, tick_queue, shutdown,
)
# ---------------------------------------------------------------------------
# Feed registry
# ---------------------------------------------------------------------------
ALL_FEEDS = {
SourceName.COINBASE: coinbase_feed,
SourceName.KRAKEN: kraken_feed,
SourceName.BITSTAMP: bitstamp_feed,
SourceName.CRYPTOCOMPARE: cryptocompare_feed,
SourceName.GEMINI: gemini_feed,
SourceName.OKX: okx_feed,
SourceName.BYBIT: bybit_feed,
SourceName.BINANCE: binance_feed,
}
+104
View File
@@ -0,0 +1,104 @@
"""Data models for chainlink predictor."""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
class SourceName(str, Enum):
COINBASE = "coinbase"
KRAKEN = "kraken"
BITSTAMP = "bitstamp"
CRYPTOCOMPARE = "cryptocompare"
GEMINI = "gemini"
OKX = "okx"
BYBIT = "bybit"
BINANCE = "binance"
PM = "pm" # Polymarket / Chainlink reference
# Sources that trade in USDT (not USD) — have a persistent premium vs Chainlink
USDT_SOURCES = {SourceName.BINANCE, SourceName.OKX, SourceName.BYBIT}
USD_SOURCES = {SourceName.COINBASE, SourceName.KRAKEN, SourceName.BITSTAMP, SourceName.CRYPTOCOMPARE, SourceName.GEMINI}
@dataclass(slots=True)
class SourceTick:
"""A single price tick from a data source."""
source: SourceName
price: float
source_ts_ns: int # source-reported timestamp (ns)
recv_mono_ns: int # local monotonic (ns) — for lead-lag
recv_ts_ns: int # local wall clock (ns) — for logging
volume: float = 0.0 # trade volume (in BTC) — 0 if not available
# Extra fields from PM audit (only set for PM ticks)
pm_ptb: float = 0.0 # PM price at 5min bucket start (= 5m market threshold)
pm_ptb_15m: float = 0.0 # PM price at 15min bucket start (= 15m market threshold)
pm_a: float = 0.0 # PM movement from bucket start
left_sec: float = 0.0 # seconds left in window
bucket_start_sec: int = 0 # window ID from trading system
estimated_onchain_ptb: float = 0.0 # estimated on-chain CL price at window start
@dataclass
class OrderBookLevel:
"""A single price level in the order book."""
price: float
size: float # BTC
@dataclass
class OrderBookState:
"""Order book state for a source."""
source: SourceName
bids: list[OrderBookLevel] = field(default_factory=list) # sorted high→low
asks: list[OrderBookLevel] = field(default_factory=list) # sorted low→high
last_update_mono_ns: int = 0
def volume_between(self, price_a: float, price_b: float) -> tuple[float, float]:
"""
Calculate bid/ask volume between two prices.
Returns (bid_volume, ask_volume) in BTC.
"""
lo = min(price_a, price_b)
hi = max(price_a, price_b)
bid_vol = sum(l.size for l in self.bids if lo <= l.price <= hi)
ask_vol = sum(l.size for l in self.asks if lo <= l.price <= hi)
return bid_vol, ask_vol
# Sources that have order book feeds
ORDERBOOK_SOURCES = {SourceName.COINBASE, SourceName.KRAKEN, SourceName.BINANCE, SourceName.OKX}
@dataclass(slots=True)
class SourceState:
"""Current state of a data source."""
source: SourceName
price: float = 0.0
last_recv_mono_ns: int = 0
tick_count: int = 0
window_volume: float = 0.0 # cumulative volume this window (BTC)
disconnect_count: int = 0
last_disconnect_mono_ns: int = 0
stale_tick_count: int = 0 # ticks with interval > 2s
@dataclass(slots=True)
class WindowSummary:
"""Per-window summary for dashboard."""
window_id: str
window_start: str
pm_settle_price: float = 0.0
pm_ptb: float = 0.0
pm_direction: str = ""
pm_delta: float = 0.0
sim_price_at_settle: float = 0.0
sim_error: float = 0.0
sim_direction_correct: bool = False
leading_source: str = ""
lead_direction_correct: bool = False
lead_avg_seconds: float = 0.0
source_prices_at_settle: dict = field(default_factory=dict)
source_lead_pct: dict = field(default_factory=dict)
+297
View File
@@ -0,0 +1,297 @@
"""Order book WebSocket feeds for Coinbase, Kraken, Binance, and OKX."""
from __future__ import annotations
import asyncio
import logging
import time
import aiohttp
import orjson
from src.predictor.models import SourceName, OrderBookState, OrderBookLevel
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Shared state: order books accessible by collector
# ---------------------------------------------------------------------------
order_books: dict[SourceName, OrderBookState] = {}
def get_book(source: SourceName) -> OrderBookState | None:
return order_books.get(source)
# ---------------------------------------------------------------------------
# Generic reconnecting WS loop
# ---------------------------------------------------------------------------
async def _ob_ws_loop(
name: SourceName,
url: str,
on_connect,
on_message,
shutdown: asyncio.Event,
ping_interval: float = 20.0,
):
delay = 1.0
while not shutdown.is_set():
try:
async with aiohttp.ClientSession() as session:
async with session.ws_connect(
url, heartbeat=ping_interval, receive_timeout=ping_interval * 2,
) as ws:
logger.info(f"[{name.value}_ob] connected: {url}")
delay = 1.0
if on_connect:
await on_connect(ws)
while not shutdown.is_set():
msg = await ws.receive(timeout=ping_interval)
if msg.type in (aiohttp.WSMsgType.TEXT, aiohttp.WSMsgType.BINARY):
on_message(msg.data)
elif msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
break
except asyncio.CancelledError:
raise
except Exception as e:
logger.warning(f"[{name.value}_ob] error: {e}, reconnecting in {delay:.0f}s")
if shutdown.is_set():
break
await asyncio.sleep(delay)
delay = min(delay * 2, 30.0)
logger.info(f"[{name.value}_ob] stopped")
# ---------------------------------------------------------------------------
# Coinbase — level2 channel (top 50 levels)
# ---------------------------------------------------------------------------
async def coinbase_orderbook_feed(shutdown: asyncio.Event):
book = OrderBookState(source=SourceName.COINBASE)
order_books[SourceName.COINBASE] = book
async def on_connect(ws):
await ws.send_json({
"type": "subscribe",
"channels": [{"name": "level2_batch", "product_ids": ["BTC-USD"]}],
})
def on_message(data):
try:
d = orjson.loads(data) if isinstance(data, (bytes, str)) else data
msg_type = d.get("type")
if msg_type == "snapshot":
book.bids = [OrderBookLevel(float(p), float(s)) for p, s in d.get("bids", [])[:50]]
book.asks = [OrderBookLevel(float(p), float(s)) for p, s in d.get("asks", [])[:50]]
book.bids.sort(key=lambda x: x.price, reverse=True)
book.asks.sort(key=lambda x: x.price)
book.last_update_mono_ns = time.monotonic_ns()
elif msg_type == "l2update":
for side, price_str, size_str in d.get("changes", []):
price = float(price_str)
size = float(size_str)
if side == "buy":
# Update or remove bid
book.bids = [l for l in book.bids if l.price != price]
if size > 0:
book.bids.append(OrderBookLevel(price, size))
book.bids.sort(key=lambda x: x.price, reverse=True)
book.bids = book.bids[:50]
else:
book.asks = [l for l in book.asks if l.price != price]
if size > 0:
book.asks.append(OrderBookLevel(price, size))
book.asks.sort(key=lambda x: x.price)
book.asks = book.asks[:50]
book.last_update_mono_ns = time.monotonic_ns()
except Exception:
pass
await _ob_ws_loop(
SourceName.COINBASE,
"wss://ws-feed.exchange.coinbase.com",
on_connect, on_message, shutdown,
)
# ---------------------------------------------------------------------------
# Kraken — book channel (top 25 levels)
# ---------------------------------------------------------------------------
async def kraken_orderbook_feed(shutdown: asyncio.Event):
book = OrderBookState(source=SourceName.KRAKEN)
order_books[SourceName.KRAKEN] = book
def _parse_levels(raw: list) -> list[OrderBookLevel]:
levels = []
for item in raw:
if len(item) >= 2:
levels.append(OrderBookLevel(float(item[0]), float(item[1])))
return levels
async def on_connect(ws):
await ws.send_json({
"event": "subscribe",
"pair": ["XBT/USD"],
"subscription": {"name": "book", "depth": 25},
})
def on_message(data):
try:
d = orjson.loads(data) if isinstance(data, (bytes, str)) else data
# Kraken book format: [channelID, {"as":[], "bs":[]}, "book-25", "XBT/USD"]
# or update: [channelID, {"a":[]}, "book-25", "XBT/USD"]
if not isinstance(d, list) or len(d) < 4:
return
if "XBT/USD" not in str(d[-1]):
return
payload = d[1]
if not isinstance(payload, dict):
return
# Snapshot
if "as" in payload and "bs" in payload:
book.asks = _parse_levels(payload["as"])
book.bids = _parse_levels(payload["bs"])
book.asks.sort(key=lambda x: x.price)
book.bids.sort(key=lambda x: x.price, reverse=True)
book.last_update_mono_ns = time.monotonic_ns()
return
# Updates — can have "a" and/or "b" in payload or d[2] for second part
for part in ([d[1]] if isinstance(d[1], dict) else []) + ([d[2]] if len(d) > 4 and isinstance(d[2], dict) else []):
if "a" in part:
for item in part["a"]:
price = float(item[0])
size = float(item[1])
book.asks = [l for l in book.asks if l.price != price]
if size > 0:
book.asks.append(OrderBookLevel(price, size))
book.asks.sort(key=lambda x: x.price)
book.asks = book.asks[:25]
if "b" in part:
for item in part["b"]:
price = float(item[0])
size = float(item[1])
book.bids = [l for l in book.bids if l.price != price]
if size > 0:
book.bids.append(OrderBookLevel(price, size))
book.bids.sort(key=lambda x: x.price, reverse=True)
book.bids = book.bids[:25]
book.last_update_mono_ns = time.monotonic_ns()
except Exception:
pass
await _ob_ws_loop(
SourceName.KRAKEN,
"wss://ws.kraken.com",
on_connect, on_message, shutdown,
)
# ---------------------------------------------------------------------------
# Binance — depth stream (top 20 levels, 100ms updates)
# ---------------------------------------------------------------------------
async def binance_orderbook_feed(shutdown: asyncio.Event):
book = OrderBookState(source=SourceName.BINANCE)
order_books[SourceName.BINANCE] = book
async def on_connect(ws):
pass # stream is in the URL
def on_message(data):
try:
d = orjson.loads(data) if isinstance(data, (bytes, str)) else data
if "bids" not in d and "asks" not in d:
return
book.bids = [OrderBookLevel(float(p), float(s)) for p, s in d.get("bids", [])[:20]]
book.asks = [OrderBookLevel(float(p), float(s)) for p, s in d.get("asks", [])[:20]]
book.bids.sort(key=lambda x: x.price, reverse=True)
book.asks.sort(key=lambda x: x.price)
book.last_update_mono_ns = time.monotonic_ns()
except Exception:
pass
await _ob_ws_loop(
SourceName.BINANCE,
"wss://stream.binance.com:9443/ws/btcusdt@depth20@100ms",
on_connect, on_message, shutdown,
)
# ---------------------------------------------------------------------------
# OKX — books5 channel (top 5 levels, real-time, public, no auth)
# Spot BTC-USDT (matches okx_feed price feed). USDT-pegged source.
# ---------------------------------------------------------------------------
async def okx_orderbook_feed(shutdown: asyncio.Event):
book = OrderBookState(source=SourceName.OKX)
order_books[SourceName.OKX] = book
async def on_connect(ws):
await ws.send_json({
"op": "subscribe",
"args": [{"channel": "books5", "instId": "BTC-USDT"}],
})
def on_message(data):
try:
d = orjson.loads(data) if isinstance(data, (bytes, str)) else data
# Skip subscribe ack and other event messages
if d.get("event"):
return
arg = d.get("arg") or {}
if arg.get("channel") != "books5":
return
payload_list = d.get("data") or []
if not payload_list:
return
payload = payload_list[0]
# Each level: [price, size, _liquidations, n_orders] — strings
asks_raw = payload.get("asks", [])
bids_raw = payload.get("bids", [])
book.asks = [OrderBookLevel(float(lv[0]), float(lv[1])) for lv in asks_raw[:20]]
book.bids = [OrderBookLevel(float(lv[0]), float(lv[1])) for lv in bids_raw[:20]]
book.asks.sort(key=lambda x: x.price)
book.bids.sort(key=lambda x: x.price, reverse=True)
book.last_update_mono_ns = time.monotonic_ns()
except Exception:
pass
await _ob_ws_loop(
SourceName.OKX,
"wss://ws.okx.com:8443/ws/v5/public",
on_connect, on_message, shutdown,
)
# ---------------------------------------------------------------------------
# All order book feeds
# ---------------------------------------------------------------------------
ALL_ORDERBOOK_FEEDS = {
SourceName.COINBASE: coinbase_orderbook_feed,
SourceName.KRAKEN: kraken_orderbook_feed,
SourceName.BINANCE: binance_orderbook_feed,
SourceName.OKX: okx_orderbook_feed,
}
+255
View File
@@ -0,0 +1,255 @@
"""Direct PM/Chainlink WebSocket feed — independent of trading system."""
from __future__ import annotations
import asyncio
import logging
import time
import aiohttp
import orjson
from src.predictor.models import SourceName, SourceTick
logger = logging.getLogger(__name__)
# 5-minute window
WINDOW_SEC = 300
class PMDirectFeed:
"""
Connect directly to Polymarket's Chainlink price stream.
Tracks window boundaries (PTB, pm_a, left_sec) internally.
Also estimates on-chain PTB from price stability analysis.
"""
def __init__(self):
self._ptb: float = 0.0
self._bucket_start: int = 0
self._ptb_15m: float = 0.0 # PM price at 15min bucket start (= 15m market threshold)
self._bucket_15m_start: int = 0
self._first_15m_skipped: bool = False
self._seq: int = 0
self._first_bucket_skipped: bool = False # skip first window if started mid-window
# On-chain PTB estimation: ring buffer of (source_ts, price) for last 30s
from collections import deque
self._price_hist: deque = deque(maxlen=300) # ~30s at ~10 ticks/s
self._estimated_onchain_ptb: float = 0.0 # estimated on-chain CL price at window start
def _get_bucket(self, ts_sec: float) -> int:
t = int(ts_sec)
return t - (t % WINDOW_SEC)
def _estimate_onchain_ptb(self, source_ts: float) -> float:
"""Estimate on-chain CL price at window boundary.
On-chain Chainlink updates only when price changes > ~0.5% or heartbeat timeout.
If price moved fast in last few seconds, on-chain may still show the old "stable" price.
Use median of prices from 3-5 seconds before window boundary as estimate.
"""
if not self._price_hist:
return 0.0
# Get prices from 3-10 seconds before current time
stable_prices = [px for ts, px in self._price_hist if source_ts - 10 <= ts <= source_ts - 3]
if not stable_prices:
# Fall back to any prices from 1-5 seconds ago
stable_prices = [px for ts, px in self._price_hist if source_ts - 5 <= ts <= source_ts - 1]
if not stable_prices:
return 0.0
stable_prices.sort()
return stable_prices[len(stable_prices) // 2] # median
def _process_price(self, price: float, source_ts_ns: int) -> SourceTick:
recv_ns = time.time_ns()
mono_ns = time.monotonic_ns()
now = time.time()
# Window management — use oracle source timestamp for bucketing (not wall clock)
# Polymarket settles based on oracle timestamps, not our receive time
source_ts = source_ts_ns / 1e9 if source_ts_ns > 0 else now
# Track price history for on-chain estimation
self._price_hist.append((source_ts, price))
# Track 15min bucket (same logic, 900s instead of 300s) — for 15m market threshold
t = int(source_ts)
bucket_15m = t - (t % 900)
if bucket_15m != self._bucket_15m_start:
elapsed_15m = source_ts - bucket_15m
if not self._first_15m_skipped and elapsed_15m > 10:
self._first_15m_skipped = True
self._bucket_15m_start = bucket_15m
self._ptb_15m = 0.0
else:
self._first_15m_skipped = True
self._bucket_15m_start = bucket_15m
self._ptb_15m = price
bucket = self._get_bucket(source_ts)
if bucket != self._bucket_start:
elapsed_in_window = source_ts - bucket
# On first window after startup, check if we missed the window-open tick
if not self._first_bucket_skipped and elapsed_in_window > 10:
# Started mid-window — PTB would be wrong. Skip this window entirely.
self._first_bucket_skipped = True
self._bucket_start = bucket
self._ptb = 0.0 # mark as invalid; will set correctly at next window
self._estimated_onchain_ptb = 0.0
logger.warning(
f"[pm_direct] startup mid-window {bucket} (elapsed={elapsed_in_window:.0f}s), "
f"SKIPPING this window for PTB. Waiting for next window boundary."
)
else:
self._first_bucket_skipped = True
self._bucket_start = bucket
self._ptb = price
# Estimate what on-chain CL was at window boundary
self._estimated_onchain_ptb = self._estimate_onchain_ptb(source_ts)
ptb_diff = self._estimated_onchain_ptb - price if self._estimated_onchain_ptb > 0 else 0
logger.info(
f"[pm_direct] new window {bucket}, ptb=${price:,.2f}, "
f"est_onchain=${self._estimated_onchain_ptb:,.2f} (diff=${ptb_diff:+.1f}) "
f"(source_ts lag={now - source_ts:.1f}s)"
)
if abs(ptb_diff) > 30:
logger.warning(
f"[pm_direct] ⚠️ PTB vs on-chain estimate differs by ${ptb_diff:+.1f}! "
f"On-chain may not have updated. CLOB sanity check should catch this."
)
# If PTB is invalid (skipped window), pm_a is 0 and downstream strategies should treat as no signal
pm_a = (price - self._ptb) if self._ptb > 0 else 0.0
# Use source_ts for left_sec to be consistent with bucketing
left_sec = float(self._bucket_start + WINDOW_SEC) - source_ts
self._seq += 1
return SourceTick(
source=SourceName.PM,
price=price,
source_ts_ns=source_ts_ns,
recv_mono_ns=mono_ns,
recv_ts_ns=recv_ns,
pm_ptb=self._ptb,
pm_ptb_15m=self._ptb_15m,
pm_a=pm_a,
left_sec=left_sec,
bucket_start_sec=self._bucket_start,
estimated_onchain_ptb=self._estimated_onchain_ptb,
)
async def pm_direct_task(
tick_queue: asyncio.Queue[SourceTick],
shutdown: asyncio.Event,
url: str = "wss://ws-live-data.polymarket.com",
symbol: str = "btc/usd",
):
"""
Connect directly to Polymarket Chainlink stream.
No dependency on trading system.
"""
feed = PMDirectFeed()
reconnect_delay = 0.5
max_delay = 5.0
logger.info(f"[pm_direct] starting: {url}, symbol={symbol}")
try:
while not shutdown.is_set():
try:
async with aiohttp.ClientSession() as session:
async with session.ws_connect(
url,
heartbeat=20.0,
receive_timeout=40.0,
) as ws:
# Subscribe
subscribe_msg = {
"action": "subscribe",
"subscriptions": [{
"topic": "crypto_prices_chainlink",
"type": "*",
"filters": f'{{"symbol":"{symbol}"}}'
}]
}
await ws.send_json(subscribe_msg)
logger.info(f"[pm_direct] connected: {url}")
reconnect_delay = 0.5
last_data_mono = time.monotonic()
while not shutdown.is_set():
# Staleness check: force reconnect if no data for 3 seconds
if time.monotonic() - last_data_mono > 3.0:
logger.warning("[pm_direct] stale 3s, forcing reconnect")
break
try:
msg = await asyncio.wait_for(ws.receive(), timeout=2.0)
except asyncio.TimeoutError:
continue
if msg.type in (aiohttp.WSMsgType.TEXT, aiohttp.WSMsgType.BINARY):
last_data_mono = time.monotonic()
data = msg.data
try:
if isinstance(data, bytes):
parsed = orjson.loads(data)
else:
parsed = orjson.loads(data.encode())
except Exception:
continue
payload = parsed.get("payload") or {}
# Batch format
data_list = payload.get("data")
if isinstance(data_list, list) and data_list:
items = [data_list[-1]]
elif payload.get("symbol") and payload.get("value") is not None:
items = [payload]
else:
continue
for item in items:
px_raw = item.get("value")
if px_raw is None:
continue
px = float(px_raw)
source_ts_raw = item.get("timestamp")
if source_ts_raw:
source_ts_raw = int(source_ts_raw)
# Detect if timestamp is seconds vs milliseconds
# Seconds: ~1.7e9, Milliseconds: ~1.7e12
if source_ts_raw < 1e11: # seconds
source_ts_ns = source_ts_raw * 1_000_000_000
else: # milliseconds
source_ts_ns = source_ts_raw * 1_000_000
else:
source_ts_ns = time.time_ns()
tick = feed._process_price(px, source_ts_ns)
try:
tick_queue.put_nowait(tick)
except asyncio.QueueFull:
pass
elif msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
break
except asyncio.CancelledError:
raise
except Exception as e:
logger.warning(f"[pm_direct] error: {e}, reconnecting in {reconnect_delay:.0f}s")
if shutdown.is_set():
break
await asyncio.sleep(reconnect_delay)
reconnect_delay = min(reconnect_delay * 2, max_delay)
except asyncio.CancelledError:
pass
logger.info("[pm_direct] stopped")
+175
View File
@@ -0,0 +1,175 @@
"""P&L tracker for predictor trading system."""
from __future__ import annotations
import logging
import os
import time
from dataclasses import dataclass, asdict
import orjson
logger = logging.getLogger(__name__)
@dataclass
class Trade:
"""A single trade record."""
ts: int # unix timestamp
window_id: str
direction: str # UP or DOWN
entry_price: float # what we paid per share
shares: int # number of shares
cost: float # total cost
token_id: str
order_id: str
# Signal data
sim_a: float = 0.0
pm_a: float = 0.0
source_agreement: int = 0
ob_depth_btc: float = 0.0
reason: str = ""
# Settlement (filled in later)
settled: bool = False
settle_price: float = 0.0 # 0 or 1
pnl: float = 0.0
won: bool = False
filled: bool = False # whether order actually got filled (vs NFIL)
direction_correct: bool = False # whether trade direction matched chain settlement
chain_direction: str = "" # actual settlement direction from chain
class PnLTracker:
"""Track trades and P&L, write to JSONL."""
def __init__(self, output_dir: str = "data/chainlink_predictor"):
self.output_dir = output_dir
os.makedirs(output_dir, exist_ok=True)
self._file_path = os.path.join(output_dir, "predictor_trades.jsonl")
# Cumulative stats
self.total_trades: int = 0
self.wins: int = 0
self.losses: int = 0
self.total_pnl: float = 0.0
self.pending_trades: list[Trade] = []
# Restore state from existing file
self._restore_from_file()
self._f = open(self._file_path, "a")
def _restore_from_file(self):
"""Restore pending trades and cumulative stats from existing JSONL file."""
if not os.path.exists(self._file_path):
return
seen_settled = set() # (window_id, direction, order_id) — dedup by order_id
all_entries = []
try:
with open(self._file_path) as f:
for line in f:
try:
d = orjson.loads(line)
all_entries.append(d)
if d.get("settled"):
key = (d.get("window_id"), d.get("direction"), d.get("order_id", ""))
if key in seen_settled:
continue # already counted, skip duplicate settle records
seen_settled.add(key)
if d.get("won"):
self.wins += 1
else:
self.losses += 1
self.total_pnl += d.get("pnl", 0)
except Exception:
continue
# Find unsettled entries that were never settled (match by order_id)
settled_order_ids = {k[2] for k in seen_settled if k[2]}
for d in all_entries:
if not d.get("settled") and d.get("order_id", "") not in settled_order_ids:
trade = Trade(
ts=d["ts"], window_id=d["window_id"], direction=d["direction"],
entry_price=d["entry_price"], shares=d["shares"], cost=d["cost"],
token_id=d.get("token_id", ""), order_id=d.get("order_id", ""),
sim_a=d.get("sim_a", 0), pm_a=d.get("pm_a", 0),
reason=d.get("reason", ""),
)
self.pending_trades.append(trade)
self.total_trades += 1
logger.info(
f"[pnl] Restored: {self.wins}W/{self.losses}L pnl=${self.total_pnl:+.2f} "
f"pending={len(self.pending_trades)}"
)
except Exception as e:
logger.warning(f"[pnl] Restore failed: {e}")
def close(self):
self._f.close()
def record_entry(self, trade: Trade):
"""Record a new trade entry."""
self.pending_trades.append(trade)
self.total_trades += 1
self._write(trade)
logger.info(
f"[pnl] ENTRY: {trade.direction} {trade.shares}@{trade.entry_price:.2f} "
f"cost=${trade.cost:.2f} window={trade.window_id} reason={trade.reason}"
)
def record_settlement(self, window_id: str, direction: str, won: bool, order_id: str | None = None):
"""Record settlement for a single specific trade.
If order_id provided, matches exactly. Otherwise matches first window+direction unsettled."""
for trade in self.pending_trades:
if trade.settled:
continue
if trade.window_id != window_id or trade.direction != direction:
continue
if order_id and trade.order_id != order_id:
continue
# Found the trade — settle just this one
trade.settled = True
trade.settle_price = 1.0 if won else 0.0
if won:
trade.pnl = trade.shares * (1.0 - trade.entry_price)
trade.won = True
self.wins += 1
else:
trade.pnl = -trade.cost
trade.won = False
self.losses += 1
self.total_pnl += trade.pnl
self._write(trade)
color = "\033[92m" if won else "\033[91m"
rst = "\033[0m"
w = "WIN" if won else "LOSS"
total = self.wins + self.losses
wr = f"{self.wins/total*100:.0f}%" if total > 0 else "-"
logger.info(
f"[pnl] {color}{w}{rst}: {trade.direction} {trade.shares}@{trade.entry_price:.2f} "
f"pnl={trade.pnl:+.2f} cum={self.total_pnl:+.2f} | W:{self.wins} L:{self.losses} WR:{wr}"
)
# Clean up settled trades
self.pending_trades = [t for t in self.pending_trades if not t.settled]
return trade.pnl # return the actual pnl
return None # not found
def _write(self, trade: Trade):
"""Write trade to JSONL file."""
self._f.write(orjson.dumps(asdict(trade)).decode() + "\n")
self._f.flush()
@property
def win_rate(self) -> float:
total = self.wins + self.losses
return self.wins / total if total > 0 else 0.0
def summary(self) -> str:
total = self.wins + self.losses
wr = self.win_rate * 100
return (
f"Trades: {total} | W:{self.wins} L:{self.losses} | "
f"WR:{wr:.1f}% | PnL: ${self.total_pnl:+.2f}"
)
File diff suppressed because it is too large Load Diff
+469
View File
@@ -0,0 +1,469 @@
#!/usr/bin/env python3
"""
Chainlink Predictor Data Collector + Independent Trading System
Collects real-time price data from multiple exchanges + PM (Chainlink) feed,
computes SIM price, and trades on Polymarket using its own wallet.
Usage:
cd ~/btc_15m_collab/rewrite
source .venv/bin/activate
# Data collection only (no trading)
python3 tools/chainlink_predictor.py
# Data collection + live trading ($10/trade, $100 capital)
python3 tools/chainlink_predictor.py --trade
# Custom trade size
python3 tools/chainlink_predictor.py --trade --max-trade 5
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import signal
import sys
import os
import time
# Add project root to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from src.predictor.models import SourceName, SourceTick
from src.predictor.feeds import ALL_FEEDS
from src.predictor.pm_feed import pm_direct_task
from src.predictor.orderbook import ALL_ORDERBOOK_FEEDS
from src.predictor.collector import Collector
from src.predictor.clob_feed import clob_feed_task, clob_feed_15m_task, current_window as clob_window
from src.predictor.strategy import PredictorStrategy, TradeSignal
from src.predictor.pnl import PnLTracker, Trade
# NOTE: PredictorExecutor is imported lazily inside the --trade branch below, so
# paper/collect mode never pulls in the live-trading deps (clob client, web3, dotenv).
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s.%(msecs)03d [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger(__name__)
DEFAULT_SOURCES = [
SourceName.COINBASE,
SourceName.KRAKEN,
SourceName.BITSTAMP,
SourceName.CRYPTOCOMPARE,
SourceName.GEMINI,
SourceName.OKX,
SourceName.BYBIT,
SourceName.BINANCE,
]
def parse_args():
p = argparse.ArgumentParser(description="Chainlink Predictor + Trader")
p.add_argument(
"--sources",
default=",".join(s.value for s in DEFAULT_SOURCES),
help="Comma-separated source names",
)
p.add_argument(
"--output-dir",
default="data/chainlink_predictor",
help="Output directory (default: data/chainlink_predictor)",
)
p.add_argument(
"--cryptocompare-key",
default="",
help="CryptoCompare API key (optional)",
)
p.add_argument(
"--trade",
action="store_true",
help="Enable live trading (default: data collection only)",
)
p.add_argument(
"--max-trade",
type=float,
default=10.0,
help="Max USD per trade (default: $10)",
)
p.add_argument(
"--max-daily-loss",
type=float,
default=30.0,
help="Max daily loss before stopping (default: $30)",
)
p.add_argument(
"--capital",
type=float,
default=500.0,
help="Capital for dynamic sizing (default: $500)",
)
p.add_argument(
"--shares",
type=int,
default=0,
help="Fixed shares per trade (overrides capital sizing). e.g. --shares 10",
)
return p.parse_args()
async def collector_and_strategy_task(
tick_queue: asyncio.Queue[SourceTick],
shutdown: asyncio.Event,
output_dir: str,
trading_enabled: bool,
max_trade_usd: float,
max_daily_loss: float,
capital: float = 500.0,
fixed_shares: int = 0,
):
"""Combined collector + strategy evaluation loop."""
collector = Collector(output_dir=output_dir)
logger.info(f"[collector] started, output={output_dir}")
# Strategy and executor (only if trading enabled)
strategy = None
executor = None
pnl = None
# Always create strategy (for paper trade + evaluate)
strategy = PredictorStrategy(
max_trade_usd=max_trade_usd,
max_daily_loss=max_daily_loss,
capital=capital,
fixed_shares=fixed_shares,
)
if trading_enabled:
try:
from src.predictor.executor import PredictorExecutor # live-only import
executor = PredictorExecutor()
pnl = PnLTracker(output_dir=output_dir)
balance = await executor.get_balance()
logger.info(f"[trading] ENABLED: wallet balance=${balance:.2f}, max_trade=${max_trade_usd}")
except Exception as e:
logger.error(f"[trading] failed to initialize: {e}")
trading_enabled = False
last_window_id = 0
prev_window_pm_a = 0.0 # track pm_a at end of previous window
last_settle_check = 0.0
try:
while not shutdown.is_set():
try:
tick = await asyncio.wait_for(tick_queue.get(), timeout=0.5)
# Save pm_a before process_tick (it resets on new window)
prev_window_pm_a = collector._pm_a
collector.process_tick(tick)
except asyncio.TimeoutError:
pass
# Strategy evaluation — every tick for fast cb_flip detection
if strategy is None:
continue
# Check for new window — settle previous window
window_id = collector.current_window
if window_id != last_window_id:
# Collect all window IDs that need settlement (current + any backlog)
settle_wids = set()
if last_window_id > 0:
settle_wids.add(last_window_id)
if pnl:
for t in pnl.pending_trades:
settle_wids.add(int(t.window_id))
# Also check paper backlog
if strategy:
for p in strategy._paper_pending:
settle_wids.add(int(p.get("window_id", 0)))
# Query chain result for each window
chain_results = {}
for wid in settle_wids:
if wid <= 0:
continue
try:
result = await asyncio.to_thread(collector.check_settlement, wid)
if result:
chain_results[wid] = result
except Exception:
pass
chain_dir = chain_results.get(last_window_id)
# Settle real trades — only with chain result
if pnl and pnl.pending_trades:
for trade in list(pnl.pending_trades):
trade_wid = int(trade.window_id)
trade_chain_dir = chain_results.get(trade_wid)
if not trade_chain_dir:
logger.warning(f"[settle] {trade_wid} no chain result, keeping pending")
continue
# Check actual fill from chain
fill_info = None
if executor and trade.order_id:
try:
fill_info = await executor.check_order_filled(trade.order_id)
except Exception:
pass
if fill_info and fill_info["filled"] == 0:
# Not filled — but still record direction correctness for analysis
trade.settled = True
trade.pnl = 0.0
trade.won = False
trade.filled = False
trade.direction_correct = (trade.direction == trade_chain_dir)
trade.chain_direction = trade_chain_dir
pnl._write(trade)
pnl.pending_trades.remove(trade)
logger.info(f"[settle] {trade_wid} {trade.direction} NO FILL (chain={trade_chain_dir}, dir_correct={trade.direction_correct})")
elif fill_info and fill_info["filled"] > 0:
if trade.settled:
continue
# Use actual fill amount AND avg fill price from chain
actual_shares = fill_info["filled"]
actual_price = fill_info.get("avg_fill_price", trade.entry_price)
if actual_shares != trade.shares:
logger.info(f"[settle] partial fill: ordered={trade.shares} filled={actual_shares:.2f}")
if abs(actual_price - trade.entry_price) > 0.001:
logger.info(f"[settle] price improved: limit={trade.entry_price:.4f} fill={actual_price:.4f}")
trade.shares = actual_shares
trade.entry_price = round(actual_price, 4)
trade.cost = round(actual_shares * actual_price, 2)
won = trade.direction == trade_chain_dir
trade.filled = True
trade.direction_correct = won
trade.chain_direction = trade_chain_dir
settled_pnl = pnl.record_settlement(
window_id=str(trade_wid),
direction=trade.direction,
won=won,
order_id=trade.order_id,
)
if settled_pnl is not None:
strategy.record_pnl(settled_pnl)
# Daily-loss circuit breaker (shared across all strategies)
strategy.check_daily_loss_circuit_breaker()
# Lock cooldown after loss (Gate 2)
if 'lock' in trade.reason and actual_shares > 0:
strategy.record_lock_result(won)
logger.info(f"[settle] {trade_wid} chain→{trade_chain_dir}, trade={trade.direction} {'WIN' if won else 'LOSS'} (filled={actual_shares:.2f})")
else:
# fill_info is None (API error) — retry up to 10 times then give up
trade.settle_retries = getattr(trade, 'settle_retries', 0) + 1
if trade.settle_retries >= 10:
logger.warning(f"[settle] {trade_wid} giving up after {trade.settle_retries} retries, marking as NFIL")
trade.settled = True
trade.pnl = 0.0
trade.won = False
trade.filled = False
trade.direction_correct = False
trade.chain_direction = ""
pnl._write(trade)
pnl.pending_trades.remove(trade)
else:
logger.warning(f"[settle] {trade_wid} fill check failed (retry {trade.settle_retries}/10)")
continue
# Settle paper trade — only with chain result, no fallback
# Settle paper trades using chain results
if strategy:
strategy.settle_paper(prev_window_pm_a, chain_results)
last_window_id = window_id
strategy.new_window(window_id)
if executor:
executor.new_window(window_id)
# Evaluate strategy (returns list of signals)
try:
signals = strategy.evaluate(collector)
except Exception as e:
logger.error(f"[strategy] evaluate error: {e}", exc_info=True)
signals = []
if not signals and collector._pm_left_sec < 35:
_left_int = int(collector._pm_left_sec)
if _left_int != getattr(strategy, '_last_log_left', -1):
strategy._last_log_left = _left_int
print(f" [strategy] left={_left_int}s no signal", flush=True)
for sig in signals:
if not sig.should_trade or not executor or not executor.can_trade:
continue
G = "\033[92m"
R = "\033[91m"
B = "\033[1m"
RST = "\033[0m"
color = G if sig.direction == "UP" else R
print(f"\n{'='*60}")
print(f" {B}{color}TRADE SIGNAL [{sig.tier}]: {sig.direction}{RST}")
print(f" {sig.reason}")
print(f" CLOB ask={sig.price:.2f} Amount: ${sig.amount_usd:.2f}{int(sig.amount_usd / sig.price)} shares")
print(f"{'='*60}\n")
try:
order_id = await executor.place_buy(
token_id=sig.token_id,
price=sig.price,
amount_usd=sig.amount_usd,
expiration_sec=max(int(collector._pm_left_sec) + 60, 90),
)
except Exception as _ex:
order_id = None
print(f" [executor] FAILED: {_ex}", flush=True)
logger.error(f"[executor] place_buy exception: {_ex}")
if order_id is None:
print(f" [executor] order_id=None — order not placed", flush=True)
if order_id and pnl:
shares = int(sig.amount_usd / sig.price)
trade = Trade(
ts=int(time.time()),
window_id=str(window_id),
direction=sig.direction,
entry_price=sig.price,
shares=shares,
cost=round(shares * sig.price, 2),
token_id=sig.token_id,
order_id=order_id,
sim_a=sig.sim_a,
pm_a=sig.pm_a,
source_agreement=sig.source_agreement,
ob_depth_btc=sig.ob_depth_btc,
reason=sig.reason,
)
pnl.record_entry(trade)
strategy.mark_traded()
except asyncio.CancelledError:
pass
finally:
if collector.window_ticks:
collector._flush_window()
collector.close()
if pnl:
logger.info(f"[pnl] {pnl.summary()}")
pnl.close()
logger.info(f"[collector] stopped. {collector.total_windows} windows processed.")
async def main():
args = parse_args()
# Parse source names
source_names = []
for s in args.sources.split(","):
s = s.strip().lower()
try:
source_names.append(SourceName(s))
except ValueError:
logger.warning(f"Unknown source: {s}, skipping")
logger.info(f"Sources: {[s.value for s in source_names]}")
logger.info(f"Output dir: {args.output_dir}")
logger.info(f"Trading: {'ENABLED' if args.trade else 'DISABLED'}")
# Shared queue and shutdown event
tick_queue: asyncio.Queue[SourceTick] = asyncio.Queue(maxsize=5000)
shutdown = asyncio.Event()
# Handle SIGINT/SIGTERM
loop = asyncio.get_event_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, lambda: shutdown.set())
# Build task list
tasks = []
# PM direct feed
tasks.append(asyncio.create_task(
pm_direct_task(tick_queue, shutdown),
name="pm_direct",
))
# Exchange feeds
for src in source_names:
feed_fn = ALL_FEEDS.get(src)
if feed_fn is None:
continue
if src == SourceName.CRYPTOCOMPARE:
tasks.append(asyncio.create_task(
feed_fn(tick_queue, shutdown, api_key=args.cryptocompare_key),
name=f"feed_{src.value}",
))
else:
tasks.append(asyncio.create_task(
feed_fn(tick_queue, shutdown),
name=f"feed_{src.value}",
))
# Order book feeds
for ob_src, ob_fn in ALL_ORDERBOOK_FEEDS.items():
tasks.append(asyncio.create_task(
ob_fn(shutdown),
name=f"ob_{ob_src.value}",
))
# CLOB feed (for Polymarket token prices — needed for trading + paper trade)
tasks.append(asyncio.create_task(
clob_feed_task(shutdown),
name="clob_feed",
))
# 15m CLOB feed (parallel) — exposes current_window_15m for arb/magic combo sum
tasks.append(asyncio.create_task(
clob_feed_15m_task(shutdown),
name="clob_feed_15m",
))
# Collector + Strategy + Executor
tasks.append(asyncio.create_task(
collector_and_strategy_task(
tick_queue, shutdown, args.output_dir,
trading_enabled=args.trade,
max_trade_usd=args.max_trade,
max_daily_loss=args.max_daily_loss,
capital=args.capital,
fixed_shares=args.shares,
),
name="collector_strategy",
))
mode = "COLLECT + TRADE" if args.trade else "COLLECT ONLY"
print()
print("=" * 60)
print(f" Chainlink Predictor — {mode}")
print(f" Sources: {', '.join(s.value for s in source_names)}")
print(f" PM: direct WebSocket")
if args.trade:
print(f" Trading: ${args.max_trade:.0f}/trade, capital=${args.capital:.0f}, daily limit -${args.max_daily_loss:.0f}")
print(f" Output: {args.output_dir}")
print(" Press Ctrl+C to stop")
print("=" * 60)
print()
# Wait for shutdown
await shutdown.wait()
logger.info("Shutdown signal received")
for t in tasks:
t.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
logger.info("All tasks stopped.")
if __name__ == "__main__":
asyncio.run(main())
+665
View File
@@ -0,0 +1,665 @@
#!/usr/bin/env python3
"""Quick trade — press U/D to instantly buy. Watch the chainlink_watch for prices.
!! LIVE, REAL MONEY. Every keystroke places a REAL order on Polymarket with the
wallet in src/predictor/.env. There is NO paper mode and NO confirmation dialog.
Provided as-is and UNAUDITED read the code first. Needs the collector
(tools/chainlink_predictor.py) running for live prices. Use at your own risk.
Keys:
u Buy UP 10sh d Buy DOWN 10sh (market ask + 0.02)
! Buy UP @0.01 $ Buy DOWN @0.01 (Shift+1/4, limit order)
@ Buy UP @0.02 % Buy DOWN @0.02 (Shift+2/5)
# → Buy UP @0.03 ^ → Buy DOWN @0.03 (Shift+3/6)
i Sell UP o Sell DOWN (sells most expensive first)
b Balance r Redeem w Refresh window
q Quit
"""
from __future__ import annotations
import asyncio
import json
import math
import os
import sys
import time
import tty
import termios
import subprocess
import select
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'src', 'predictor', '.env'), override=True)
import aiohttp
from web3 import Web3
from py_clob_client_v2.client import ClobClient
from py_clob_client_v2.clob_types import OrderArgs, OrderType, PartialCreateOrderOptions
from py_clob_client_v2.order_builder.constants import BUY
import httpx
import py_clob_client_v2.http_helpers.helpers as _clob_helpers
G = "\033[92m"
R = "\033[91m"
B = "\033[1m"
C = "\033[96m"
Y = "\033[93m"
DIM = "\033[2m"
RST = "\033[0m"
BUCKET_SEC = 300
TRADE_SHARES = 10 # fixed 10 shares per click
MAX_ASK = 0.99
SNAPSHOT_FILE = "data/chainlink_predictor/snapshots.jsonl"
MANUAL_TRADES_FILE = "data/chainlink_predictor/manual_trades.jsonl"
class QuickTrader:
def __init__(self):
_clob_helpers._http_client = httpx.Client(http2=False, timeout=30)
key = os.environ.get("PREDICTOR_WALLET_KEY", "")
address = os.environ.get("PREDICTOR_WALLET_ADDRESS", "")
self.address = address
self.client = ClobClient("https://clob.polymarket.com", key=key, chain_id=137, signature_type=0, funder=address)
self.client.set_api_creds(self.client.create_or_derive_api_key())
self.token_up = ""
self.token_down = ""
self.window_id = 0
self._preflight_cache: dict[str, tuple] = {}
self.pending_trades: list[dict] = []
# Tail snapshots for live prices
self._snap_proc = None
self._last_clob = {}
self._last_left = 0.0
self._last_pm_a = 0.0
self._last_sim_a = 0.0
if os.path.exists(SNAPSHOT_FILE):
self._snap_proc = subprocess.Popen(
["tail", "-F", "-n", "1", SNAPSHOT_FILE],
stdout=subprocess.PIPE, text=True,
)
def read_snapshot(self):
if not self._snap_proc:
return
while True:
ready, _, _ = select.select([self._snap_proc.stdout], [], [], 0)
if not ready:
break
line = self._snap_proc.stdout.readline()
if not line:
break
try:
d = json.loads(line)
self._last_clob = d.get("clob", {})
self._last_left = d.get("left_sec", 0)
self._last_pm_a = d.get("pm_a", 0)
sim = d.get("sim", 0)
pm = d.get("pm", 0)
pm_a = d.get("pm_a", 0)
ptb = pm - pm_a if pm_a else pm
self._last_sim_a = sim - ptb if sim > 0 and ptb > 0 else 0
except Exception:
pass
async def discover(self):
now = time.time()
base = int(now // BUCKET_SEC) * BUCKET_SEC
async with aiohttp.ClientSession() as session:
for ts in [base, base + BUCKET_SEC, base - BUCKET_SEC]:
slug = f"btc-updown-5m-{ts}"
try:
async with session.get("https://gamma-api.polymarket.com/events",
params={"slug": slug}, timeout=aiohttp.ClientTimeout(total=5)) as resp:
if resp.status != 200:
continue
data = await resp.json()
if not data:
continue
market = data[0].get("markets", [{}])[0]
raw_ids = market.get("clobTokenIds")
token_ids = json.loads(raw_ids) if isinstance(raw_ids, str) else raw_ids
if token_ids and len(token_ids) >= 2:
self.token_up = token_ids[0]
self.token_down = token_ids[1]
self.window_id = ts
await self._warm(self.token_up)
await self._warm(self.token_down)
return True
except Exception:
continue
return False
async def _warm(self, token_id: str):
if token_id in self._preflight_cache:
return
try:
tick = await asyncio.to_thread(self.client.get_tick_size, token_id)
neg = await asyncio.to_thread(self.client.get_neg_risk, token_id)
self._preflight_cache[token_id] = (tick, neg)
except Exception:
pass
async def buy(self, direction: str) -> str:
token_id = self.token_up if direction == "UP" else self.token_down
if not token_id:
return f"{R}No token — press W to refresh{RST}"
self.read_snapshot()
ua = self._last_clob.get("up_ask", 0)
da = self._last_clob.get("down_ask", 0)
if direction == "UP":
price = ua
else:
price = da
if price <= 0:
other = da if direction == "UP" else ua
if other > 0:
price = round(1.0 - other, 2)
if price <= 0:
return f"{R}No price{RST}"
if price > MAX_ASK:
return f"{R}Too expensive: {price:.2f}{RST}"
# Add $0.02 buffer for fill, cap at MAX_ASK
limit = min(round(price, 2), MAX_ASK)
qty = TRADE_SHARES
# Auto-adjust qty to meet $1 minimum
if qty * limit < 1.0:
qty = math.ceil(1.0 / limit)
cost = round(qty * limit, 2)
try:
preflight = self._preflight_cache.get(token_id)
if not preflight:
await self._warm(token_id)
preflight = self._preflight_cache.get(token_id)
tick_size, neg_risk = preflight
left = (self.window_id + BUCKET_SEC) - time.time()
exp = int(time.time()) + 90 # GTD requires at least now+60s
order_args = OrderArgs(price=limit, size=float(qty), side=BUY,
token_id=token_id, expiration=str(exp))
options = PartialCreateOrderOptions(tick_size=tick_size, neg_risk=neg_risk)
t0 = time.monotonic()
signed = await asyncio.to_thread(self.client.create_order, order_args, options)
result = await asyncio.to_thread(self.client.post_order, signed, OrderType.GTD)
latency = (time.monotonic() - t0) * 1000
oid_full = result.get("orderID", "")
trade = {
"ts": int(time.time()),
"window_id": self.window_id,
"order_id": oid_full,
"direction": direction,
"qty": qty,
"price": limit,
"display_price": price,
"cost": round(qty * limit, 2),
"left_sec": round(left, 1),
"pm_a": self._last_pm_a if hasattr(self, '_last_pm_a') else 0,
"settled": False,
"won": False,
"pnl": 0,
}
self.pending_trades.append(trade)
with open(MANUAL_TRADES_FILE, "a") as f:
f.write(json.dumps(trade) + "\n")
color = G if direction == "UP" else R
return f"{color}{B}{direction}{RST} {qty}@{price:.2f} (limit {limit:.2f}) = ${qty*limit:.2f} | {latency:.0f}ms wid={self.window_id}"
except Exception as e:
return f"{R}Failed: {e}{RST}"
async def buy_fixed(self, direction: str, price: float, qty: int) -> str:
"""Buy at a fixed price and quantity (for cheap lottery tickets)."""
token_id = self.token_up if direction == "UP" else self.token_down
if not token_id:
return f"{R}No token — press W to refresh{RST}"
# Auto-adjust qty to meet $1 minimum
if qty * price < 1.0:
qty = math.ceil(1.0 / price)
cost = round(qty * price, 2)
try:
preflight = self._preflight_cache.get(token_id)
if not preflight:
await self._warm(token_id)
preflight = self._preflight_cache.get(token_id)
tick_size, neg_risk = preflight
left = (self.window_id + BUCKET_SEC) - time.time()
# GTD with minimum 90s expiration (API requires at least 60s)
# Won't actually last — window settles and tokens become worthless
exp = int(time.time()) + 90
order_args = OrderArgs(price=price, size=float(qty), side=BUY,
token_id=token_id, expiration=str(exp))
options = PartialCreateOrderOptions(tick_size=tick_size, neg_risk=neg_risk)
t0 = time.monotonic()
signed = await asyncio.to_thread(self.client.create_order, order_args, options)
result = await asyncio.to_thread(self.client.post_order, signed, OrderType.GTD)
latency = (time.monotonic() - t0) * 1000
oid_full = result.get("orderID", "")
trade = {
"ts": int(time.time()),
"window_id": self.window_id,
"order_id": oid_full,
"direction": direction,
"qty": qty,
"price": price,
"display_price": price,
"cost": cost,
"left_sec": round(left, 1),
"pm_a": self._last_pm_a if hasattr(self, '_last_pm_a') else 0,
"settled": False,
"won": False,
"pnl": 0,
}
self.pending_trades.append(trade)
with open(MANUAL_TRADES_FILE, "a") as f:
f.write(json.dumps(trade) + "\n")
color = G if direction == "UP" else R
return f"{color}{B}{direction}{RST} {qty}@{price:.2f} = ${cost:.2f} | {latency:.0f}ms wid={self.window_id}"
except Exception as e:
return f"{R}Failed: {e}{RST}"
async def sell(self, sell_dir: str = "") -> str:
"""Sell tokens. sell_dir='UP'/'DOWN' to choose, or '' to sell most recent."""
trade = None
if sell_dir:
# Find matching trade — most recent first
matching = [t for t in self.pending_trades if t["direction"] == sell_dir]
if matching:
trade = matching[-1]
else:
# No pending trade, but check chain balance directly (e.g. from cb_lead_live)
direction = sell_dir
token_id = self.token_up if direction == "UP" else self.token_down
if token_id:
trade = {"direction": direction, "price": 0, "ts": int(time.time()), "_chain_only": True}
elif self.pending_trades:
trade = self.pending_trades[-1]
if not trade:
return f"{R}No position to sell{RST}"
direction = trade["direction"]
token_id = self.token_up if direction == "UP" else self.token_down
if not token_id:
return f"{R}No token{RST}"
# Check on-chain balance for this token
try:
from web3 import Web3 as _W3
rpc = os.environ.get("POLYGON_RPC_URL", "")
w3 = _W3(_W3.HTTPProvider(rpc))
eoa = _W3.to_checksum_address(self.address)
CTF = _W3.to_checksum_address("0x4D97DCd97eC945f40cF65F87097ACe5EA0476045")
ctf = w3.eth.contract(address=CTF, abi=[
{"name": "balanceOf", "type": "function", "stateMutability": "view",
"inputs": [{"name": "account", "type": "address"}, {"name": "id", "type": "uint256"}],
"outputs": [{"type": "uint256"}]}])
bal = ctf.functions.balanceOf(eoa, int(token_id)).call()
shares = bal / 1e6
except Exception as e:
return f"{R}Balance check failed: {e}{RST}"
if shares < 1:
return f"{R}No shares to sell (balance={shares:.2f}){RST}"
# Get best bid
try:
book = await asyncio.to_thread(self.client.get_order_book, token_id)
# V2: get_order_book returns dict (was OrderBookSummary in V1)
bids = (book.get("bids") or []) if isinstance(book, dict) else (getattr(book, "bids", None) or [])
if not bids:
return f"{R}No bids available{RST}"
first = bids[0]
best_bid = float(first["price"]) if isinstance(first, dict) else float(first.price)
except Exception as e:
return f"{R}Order book error: {e}{RST}"
if best_bid <= 0:
return f"{R}No bid price{RST}"
# Place SELL order
try:
from py_clob_client_v2.order_builder.constants import SELL
preflight = self._preflight_cache.get(token_id)
if not preflight:
await self._warm(token_id)
preflight = self._preflight_cache.get(token_id)
tick_size, neg_risk = preflight
sell_qty = math.floor(shares * 100) / 100 # truncate to 2dp
exp = int(time.time()) + 90
order_args = OrderArgs(price=best_bid, size=sell_qty, side=SELL,
token_id=token_id, expiration=str(exp))
options = PartialCreateOrderOptions(tick_size=tick_size, neg_risk=neg_risk)
t0 = time.monotonic()
signed = await asyncio.to_thread(self.client.create_order, order_args, options)
result = await asyncio.to_thread(self.client.post_order, signed, OrderType.GTD)
latency = (time.monotonic() - t0) * 1000
order_id = result.get("orderID", "")
# API confirm — get actual fill price from trades history
import asyncio as _aio
await _aio.sleep(2)
matched = 0
status = ""
actual_price = best_bid
try:
raw_client = getattr(self.client, '_client', None) or self.client
order_info = await asyncio.to_thread(raw_client.get_order, order_id)
if order_info:
matched = float(order_info.get("size_matched", 0))
status = order_info.get("status", "")
# get_order.price is the LIMIT price, not fill price
# Use get_trades to find actual fill price
from py_clob_client_v2.clob_types import TradeParams
recent_trades = await asyncio.to_thread(
raw_client.get_trades,
TradeParams(asset_id=token_id, after=int(time.time()) - 30)
)
if recent_trades:
for tr in recent_trades:
if abs(float(tr.get("size", 0)) - matched) < 1:
actual_price = float(tr.get("price", best_bid))
break
except Exception:
pass
if matched <= 0:
return f"{R}SELL NOT FILLED{RST} {direction} {sell_qty}@{best_bid:.2f} status={status} | {latency:.0f}ms"
sell_value = matched * actual_price
buy_price = trade.get("price", 0)
buy_cost = trade.get("cost", matched * buy_price)
pnl = sell_value - buy_cost
sell_record = {
"ts": int(time.time()),
"window_id": trade["window_id"],
"order_id": order_id,
"action": "SELL",
"direction": direction,
"qty": matched,
"sell_price": actual_price,
"limit_price": best_bid,
"buy_price": buy_price,
"pnl": round(pnl, 2),
"api_status": status,
"api_matched": matched,
}
with open(MANUAL_TRADES_FILE, "a") as f:
f.write(json.dumps(sell_record) + "\n")
if trade in self.pending_trades:
self.pending_trades.remove(trade)
color = G if pnl >= 0 else R
return (
f"{G}✓ SOLD{RST} {direction} {matched:.0f}@{actual_price:.2f} "
f"(bought @{buy_price:.2f}) "
f"pnl={color}${pnl:+.2f}{RST} | {latency:.0f}ms"
)
except Exception as e:
return f"{R}Sell failed: {e}{RST}"
async def check_results(self):
import requests
settled = []
now = time.time()
for trade in list(self.pending_trades):
# Step 1: verify fill via API (once, a few seconds after order)
order_id = trade.get("order_id", "")
if order_id and not trade.get("fill_verified") and now - trade.get("ts", 0) > 3:
try:
raw_client = getattr(self.client, '_client', None) or self.client
order = await asyncio.to_thread(raw_client.get_order, order_id)
if order:
matched = float(order.get("size_matched", 0))
status = order.get("status", "")
trade["fill_verified"] = True
trade["order_status"] = status
trade["matched"] = matched
if matched > 0:
trade["filled"] = True
trade["fill_qty"] = matched
settled.append(f"{G}✓ FILLED{RST} {trade['direction']} {matched:.0f}@{trade['price']:.2f} (status={status})")
elif status in ("EXPIRED", "CANCELLED"):
trade["filled"] = False
settled.append(f"{R}✗ NOT FILLED{RST} {trade['direction']} @{trade['price']:.2f}{status}")
self.pending_trades.remove(trade)
# else LIVE/MATCHED — check again later
except Exception:
pass
# Step 2: settle after window ends (window_id + 300s + 30s grace)
window_end = trade["window_id"] + BUCKET_SEC + 30
if now < window_end:
continue
# Skip settlement if we know it didn't fill
if trade.get("fill_verified") and not trade.get("filled"):
continue
slug = f"btc-updown-5m-{trade['window_id']}"
try:
r = requests.get("https://gamma-api.polymarket.com/events",
params={"slug": slug}, timeout=5)
data = r.json()
if not data:
continue
market = data[0].get("markets", [{}])[0]
prices = market.get("outcomePrices", "")
if isinstance(prices, str) and prices:
prices = json.loads(prices)
if prices and len(prices) >= 2:
up_p = float(prices[0])
# Only settle when price is definitively 0 or 1
is_settled = up_p >= 0.99 or up_p <= 0.01
if is_settled:
actual = "UP" if up_p > 0.5 else "DOWN"
won = trade["direction"] == actual
fill_qty = trade.get("fill_qty", trade["qty"])
fill_price = trade.get("price", 0)
pnl = fill_qty * (1.0 - fill_price) if won else -fill_qty * fill_price
color = G if won else R
w = "WIN" if won else "LOSS"
filled_str = f" (filled {fill_qty:.0f})" if trade.get("fill_verified") else ""
settled.append(f"{color}{B}{w}{RST} {trade['direction']} {fill_qty:.0f}@{fill_price:.2f} pnl={color}${pnl:+.2f}{RST}{filled_str}")
trade["settled"] = True
trade["won"] = won
trade["pnl"] = round(pnl, 2)
trade["actual"] = actual
with open(MANUAL_TRADES_FILE, "a") as f:
f.write(json.dumps(trade) + "\n")
self.pending_trades.remove(trade)
except Exception:
pass
return settled
async def get_balance(self) -> float:
rpc = os.environ.get("POLYGON_RPC_URL", "")
w3 = Web3(Web3.HTTPProvider(rpc))
usdc = w3.eth.contract(
address=Web3.to_checksum_address("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"),
abi=[{"name": "balanceOf", "type": "function", "stateMutability": "view",
"inputs": [{"name": "account", "type": "address"}],
"outputs": [{"name": "", "type": "uint256"}]}],
)
bal = usdc.functions.balanceOf(Web3.to_checksum_address(self.address)).call()
return bal / 1e6
def cleanup(self):
if self._snap_proc:
self._snap_proc.terminate()
async def main():
trader = QuickTrader()
print(f"{B}Quick Trade{RST} — initializing...")
await trader.discover()
bal = await trader.get_balance()
print(f"{B}Quick Trade{RST} — READY Balance: {G}${bal:.2f}{RST}")
print(f" {G}U{RST}=Buy UP 5 shares {R}D{RST}=Buy DOWN 5 shares (press multiple times to add)")
print(f" {C}B{RST}=Balance {C}R{RST}=Redeem {C}Q{RST}=Quit\n")
fd = sys.stdin.fileno()
old_settings = termios.tcgetattr(fd)
_window_count = 0 # count windows for auto-redeem
_last_redeem_wid = 0
try:
tty.setcbreak(fd)
while True:
# Read snapshots in background
trader.read_snapshot()
# Auto-refresh window
left = (trader.window_id + BUCKET_SEC) - time.time()
if left < -5:
await trader.discover()
_window_count += 1
print(f" {DIM}[new window wid={trader.window_id}]{RST}")
# Auto-redeem disabled — conflicts with manual trading nonce
# Press R to redeem manually
if False and _window_count % 3 == 0 and trader.window_id != _last_redeem_wid:
_last_redeem_wid = trader.window_id
try:
from src.predictor.executor import PredictorExecutor
executor = PredictorExecutor()
now = int(time.time())
base = now - (now % BUCKET_SEC)
total = 0
for i in range(20):
wid = str(base - i * BUCKET_SEC)
delta = await executor.auto_redeem(f"btc-updown-5m-{wid}")
if delta > 0:
total += delta
# Also redeem pending manual trades
for t in trader.pending_trades:
wid = str(t["window_id"])
delta = await executor.auto_redeem(f"btc-updown-5m-{wid}")
if delta > 0:
total += delta
if total > 0:
print(f" {G}[auto-redeem +${total:.2f}]{RST}")
except Exception as e:
pass # silent fail
# Check results
if trader.pending_trades:
results = await trader.check_results()
for r in results:
print(f" {r}")
# Non-blocking key check
ready, _, _ = select.select([sys.stdin], [], [], 0.5)
if not ready:
continue
key = sys.stdin.read(1)
if key in ("q", "Q", "\x03"):
print("\n Bye.")
break
elif key in ("u", "U"):
result = await trader.buy("UP")
print(f" {result}")
elif key in ("d", "D"):
result = await trader.buy("DOWN")
print(f" {result}")
elif key in ("b", "B"):
bal = await trader.get_balance()
print(f" Balance: {G}${bal:.2f}{RST}")
elif key == "!":
result = await trader.buy_fixed("UP", 0.01, 100)
print(f" {result}")
elif key == "$":
result = await trader.buy_fixed("DOWN", 0.01, 100)
print(f" {result}")
elif key == "@":
result = await trader.buy_fixed("UP", 0.02, 50)
print(f" {result}")
elif key == "%":
result = await trader.buy_fixed("DOWN", 0.02, 50)
print(f" {result}")
elif key == "#":
result = await trader.buy_fixed("UP", 0.03, 35)
print(f" {result}")
elif key == "^":
result = await trader.buy_fixed("DOWN", 0.03, 35)
print(f" {result}")
elif key == "i":
result = await trader.sell("UP")
print(f" {result}")
elif key == "o":
result = await trader.sell("DOWN")
print(f" {result}")
elif key in ("w", "W"):
await trader.discover()
print(f" Window refreshed: {trader.window_id}")
elif key in ("r", "R"):
print(f" Redeeming...")
from src.predictor.executor import PredictorExecutor
executor = PredictorExecutor()
# Redeem from pending trades + recent windows
seen = set()
# 1. Pending manual trades
for t in trader.pending_trades:
seen.add(str(t["window_id"]))
# 2. Recent windows (last 2 hours)
now = int(time.time())
base = now - (now % BUCKET_SEC)
for i in range(24): # last 24 windows = 2 hours
seen.add(str(base - i * BUCKET_SEC))
# 3. From predictor trades file
trades_file = "data/chainlink_predictor/predictor_trades.jsonl"
if os.path.exists(trades_file):
with open(trades_file) as f:
for line in f:
try:
d = json.loads(line)
wid = d.get("window_id", "")
if wid:
seen.add(wid)
except Exception:
pass
total = 0
for wid in seen:
delta = await executor.auto_redeem(f"btc-updown-5m-{wid}")
if delta > 0:
print(f" {G}+${delta:.2f}{RST}")
total += delta
if total > 0:
print(f" Total: {G}+${total:.2f}{RST}")
bal = await trader.get_balance()
print(f" Balance: {G}${bal:.2f}{RST}")
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
trader.cleanup()
if __name__ == "__main__":
asyncio.run(main())