Skip to content

Commit 18d57e6

Browse files
authored
Merge pull request #953 from Lumiwealth/version/4.4.39
Version 4.4.39
2 parents f91b572 + c36077c commit 18d57e6

15 files changed

+744
-13
lines changed

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
# Changelog
22

3-
## 4.4.39 - Unreleased
3+
## 4.4.39 - 2026-01-27
44

55
### Added
66

77
### Changed
88

99
### Fixed
10+
- Backtesting router (IBKR futures/cont_future/crypto): prefetch full backtest window once per series and slice from memory to avoid per-iteration history fetches (major warm-cache speedup).
11+
- Indicators: prevent `plot_indicators()` hovertext generation from crashing when `detail_text` is missing/NaN/NA (e.g., mixed indicator points with and without `detail_text`).
1012

1113
## 4.4.38 - 2026-01-26
1214

docs/ACCEPTANCE_BACKTESTS.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ This document is the **canonical manual acceptance suite** for LumiBot backtesti
1414

1515
**Speed:** acceptance warm-cache runs complete in bounded wall time and are **queue-free** (no downloader submits), proving the cache and data semantics are stable.
1616

17+
**Resilience:** acceptance runs must also prove the “end of backtest” pipeline is stable:
18+
- stats summary must not crash (CAGR/datetime edge cases, NaN handling, etc.),
19+
- tearsheet/plot generation should either succeed or fail in a controlled way (no masking simulation success with a generic “failed” run),
20+
- and the run must still emit actionable artifacts (`trades.csv`, `stats.csv`, `logs.csv`) even when optional post-processing fails.
21+
1722
## IBKR acceptance backtests (Crypto + Futures)
1823

1924
This repo’s acceptance harness (`tests/backtest/test_acceptance_backtests_ci.py`) includes deterministic, cache-backed:

docs/BACKTESTING_ARCHITECTURE.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ LumiBot is a trading and backtesting framework. This document focuses on the **b
1616

1717
**Speed:** warm-cache runs are queue-free and complete in bounded wall time, with evidence (request counts, cache hit rate, iterations/sec, and wall-time split: data wait vs compute vs artifacts).
1818

19+
**Resilience:** simulation completion must not be masked by post-processing failures (stats/tearsheets/plots). When post-processing fails, the run should still produce as many artifacts as possible and classify the failure (simulation vs postprocess vs upload), so operators can trust the trade stream even when reporting breaks.
20+
1921
If the backtest execution model (data semantics, fill model, order handling, fees, pricing) diverges meaningfully from how real brokers behave, the backtest is not trustworthy.
2022

2123
We optimize for:
@@ -27,7 +29,7 @@ We optimize for:
2729
- Handoffs: `docs/handoffs/`
2830
- Investigations: `docs/investigations/`
2931
- Performance + parity + startup: `docs/BACKTESTING_PERFORMANCE.md`
30-
- Latest session handoff: `docs/handoffs/2025-12-26_THETADATA_SESSION_HANDOFF.md`
32+
- Latest session handoff (IBKR speed + resilience): `docs/handoffs/2026-01-26_IBKR_SPEED_RESILIENCE_MASTER_HANDOFF.md`
3133

3234
## Directory Structure
3335

docs/BACKTESTING_PERFORMANCE.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
> A practical, evidence-driven guide to **measuring**, **debugging**, and **improving** backtesting performance end‑to‑end (strategy → data → cache → artifacts → UI), while preserving broker‑like correctness.
44
5-
**Last Updated:** 2026-01-07
5+
**Last Updated:** 2026-01-26
66
**Status:** Active
77
**Audience:** Developers, AI Agents (engineering docs)
88

@@ -20,6 +20,11 @@
2020

2121
**Speed:** warm-cache runs are queue-free and complete in bounded wall time, with evidence (request counts, cache hit rate, iterations/sec, and wall-time split: data wait vs compute vs artifacts).
2222

23+
**Resilience:** backtests should not “fail” solely because post-processing (stats/tearsheets/plots) crashed. When post-processing fails, the run should still:
24+
- preserve the trade stream (`trades.csv`) and portfolio stats (`stats.csv`) when available,
25+
- classify the failure (simulation vs postprocess vs upload),
26+
- and emit actionable diagnostics rather than silently omitting artifacts.
27+
2328
## Overview
2429

2530
Backtesting performance problems in LumiBot rarely have a single cause. “Slow backtests” usually come from one (or more) of:

docs/IBKR_FUTURES_BACKTESTING.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,11 @@ contracts (or for `cont_future` stitching over expired months), LumiBot relies o
7474

7575
See: `docs/investigations/2026-01-18_IBKR_EXPIRED_FUTURES_CONID_BACKFILL.md`.
7676

77+
Operational note:
78+
- If `ibkr/conids.json` in the active S3 cache namespace is only a few hundred bytes (or missing keys like
79+
`future|GC|USD|COMEX|20250226`), `cont_future` backtests will fail for “past year” windows once contracts expire.
80+
Seed/promote the registry in S3 using the runbook in the investigation doc above.
81+
7782
### Multiplier + minTick (mandatory for correct PnL and tick rounding)
7883

7984
For realistic futures accounting:

docs/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,15 @@ This folder contains **human-authored** documentation for the LumiBot trading an
1818

1919
**Speed:** a backtest is “fast” when warm-cache runs are queue-free and complete in bounded wall time, with evidence (request counts, cache hit rate, iterations/sec, and wall-time split: data wait vs compute vs artifacts).
2020

21+
**Resilience:** a backtest is “resilient” when:
22+
- simulation completion is not masked by post-processing failures (tearsheets/stats/plots),
23+
- artifacts are as complete as possible even after failures (e.g., `trades.csv` and `stats.csv` still upload),
24+
- failure modes are classified (simulation vs postprocess vs upload), and
25+
- run metadata makes debugging easy (include `lumibot_version` in `settings.json` / `completion.json` whenever possible).
26+
27+
If you’re coordinating IBKR speed + crash hardening work, start with:
28+
- `docs/handoffs/2026-01-26_IBKR_SPEED_RESILIENCE_MASTER_HANDOFF.md`
29+
2130
---
2231

2332
## File Index
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Release 4.4.38 + Start 4.4.39
2+
Release housekeeping after deploying 4.4.38 and creating the next shared version branch.
3+
4+
**Last Updated:** 2026-01-26
5+
**Status:** Active
6+
**Audience:** Developers + AI Agents
7+
8+
---
9+
10+
## Overview
11+
12+
4.4.38 has been merged to `dev` and deployed. The next shared collaboration branch `version/4.4.39` is now created off `dev` for ongoing work.
13+
14+
## What Shipped (4.4.38)
15+
16+
- PR: https://github.com/Lumiwealth/lumibot/pull/952
17+
- Merge commit on `dev`: `f91b5722`
18+
- Version bump commit (sets `setup.py` to `4.4.38` and finalizes `CHANGELOG.md` entry): `d4b5d50a`
19+
- Key feature: IBKR futures auto exchange routing + hardened conid registry updates (plus roll-rule fixes for GC/MGC/CL/MCL).
20+
21+
## Post-Merge Housekeeping (Done)
22+
23+
- Local `dev` fast-forwarded to `origin/dev`.
24+
- New shared branch created and pushed:
25+
- Branch: `version/4.4.39`
26+
- Create PR (if desired): https://github.com/Lumiwealth/lumibot/pull/new/version/4.4.39
27+
28+
## What’s Next (Known Issues)
29+
30+
- **IBKR futures performance**: still slower than desired; target improvements should preserve accuracy and avoid extra downloader calls when warm-cache is available.
31+
- **TQQQ bot crashes**: investigate separately (collect logs + repro interval, then patch platform code as needed).

docs/investigations/2026-01-18_IBKR_EXPIRED_FUTURES_CONID_BACKFILL.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,68 @@ This avoids needing TWS except for the initial historical backfill window.
129129
Note: IBKR’s public Symbol Lookup response includes `conid` + `localSymbol` for *currently listed* futures contracts.
130130
That can be used as an additional “no-auth” source for forward refresh, but it does not solve expired-contract discovery.
131131

132+
## Operations: seed the S3 conid registry (prod/dev) without rerunning TWS
133+
134+
If backtests are failing with errors like:
135+
136+
- `IBKR did not return a conid for <ROOT> expiring <YYYYMMDD> on <EXCHANGE>`
137+
138+
…and the target expiration is **expired** (no longer returned by `/ibkr/trsrv/futures`), you must ensure the S3-mirrored
139+
registry contains it. You do **not** need to rerun TWS if a backfill registry already exists (for example the one in
140+
`data/ibkr_tws_backfill_cache_dev_v2/ibkr/conids.json`).
141+
142+
### Safety checklist
143+
144+
- Always **download a backup** of the current S3 object before overwriting.
145+
- Always **union-merge** keys (do not replace blindly).
146+
- Prefer `aws --profile BotManager ...` when running from this machine.
147+
148+
### Targets (current)
149+
150+
- Prod conids: `s3://lumibot-cache-prod/prod/cache/v1/ibkr/conids.json`
151+
- Dev conids: `s3://lumibot-cache-dev/dev/cache/v1/ibkr/conids.json`
152+
153+
Additional cache namespaces may exist (for example `dev/cache/v44/...`). Seed every namespace that is actively used by
154+
backtests.
155+
156+
### Example (merge-before-upload)
157+
158+
```bash
159+
# Backup
160+
aws --profile BotManager s3 cp \
161+
s3://lumibot-cache-prod/prod/cache/v1/ibkr/conids.json \
162+
./prod_conids.before.json
163+
164+
# Merge (seed wins except where remote already has a key)
165+
python3 - <<'PY'
166+
import json
167+
from pathlib import Path
168+
169+
seed = json.loads(Path("data/ibkr_tws_backfill_cache_dev_v2/ibkr/conids.json").read_text())
170+
remote = json.loads(Path("prod_conids.before.json").read_text())
171+
172+
merged = dict(seed)
173+
merged.update(remote) # remote wins on conflict
174+
175+
Path("prod_conids.merged.json").write_text(json.dumps(merged, sort_keys=True, separators=(",", ":")))
176+
print("merged_keys", len(merged))
177+
PY
178+
179+
# Upload
180+
aws --profile BotManager s3 cp \
181+
./prod_conids.merged.json \
182+
s3://lumibot-cache-prod/prod/cache/v1/ibkr/conids.json
183+
```
184+
185+
### When you still need TWS
186+
187+
You still need a one-time TWS backfill when the desired expiration is **older than any conids you’ve captured** (for
188+
example, if your registry only starts in 2024 and customers want 2015). In that case:
189+
190+
- run `scripts/backfill_ibkr_futures_conids_tws.py` (with `includeExpired=True`)
191+
- upload the resulting `ibkr/conids.json` to a new cache namespace (e.g. `v2`, `v3`, …)
192+
- validate, then promote/seed the production namespace using the merge flow above
193+
132194
## Verification
133195

134196
### Correctness
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# Router IBKR Speed Investigation (Futures + Crypto) — 2026-01-27
2+
3+
Goal: make **IBKR through the router** (production routing JSON) **≥20× faster first**, then **50–100×** (warm-cache), without sacrificing correctness.
4+
5+
Primary symptom: router IBKR futures backtests were taking **hours for ~1 week** because the router path was calling the downloader `ibkr/iserver/marketdata/history` in a hot loop (often ~1 request per simulated bar).
6+
7+
This doc is a **speed ledger** + **methodology**. Every perf change must:
8+
- record benchmark results here (before/after),
9+
- include YAPPI evidence,
10+
- and add/adjust tests so the improvement sticks.
11+
12+
## 0) Alignment / invariants
13+
14+
**Production routing JSON (canonical)**
15+
```json
16+
{"default":"thetadata","crypto":"ibkr","future":"ibkr","cont_future":"ibkr"}
17+
```
18+
19+
Notes:
20+
- Router aliases `"futures"``"future"` but does **not** imply `"cont_future"`.
21+
- Success metric is not “feels faster”: we require **history submits ~O(1)** (single digits) for warm-cache runs.
22+
23+
**Hard perf targets (warm-cache)**
24+
- 1 day: ≤ 10s end-to-end
25+
- 1 week: ≤ 60s end-to-end
26+
- `ibkr/iserver/marketdata/history` submits: **single digits per run** (per symbol/timeframe), not proportional to bars
27+
28+
## 1) Standard benchmark suite
29+
30+
We iterate on **1-day windows** (fast feedback) and validate milestones on **1-week windows**.
31+
32+
Benchmarks:
33+
1) GC client strategy
34+
2) NQ client strategy
35+
36+
Profiling:
37+
- Always run a non-profile baseline and then a YAPPI run.
38+
- YAPPI time ≠ wall time (overhead), use it only for hotspot ranking.
39+
40+
## 2) Standard commands (prod-like runner)
41+
42+
We use `scripts/run_backtest_prodlike.py` for “production-like” runs (downloader + S3 caching).
43+
44+
Recommended investigation flags:
45+
- use the production routing JSON
46+
- set a dedicated cache folder under `~/Documents/Development/`
47+
- use S3 cache **read-only** during investigations to avoid mutating shared caches:
48+
- `env LUMIBOT_CACHE_MODE=readonly ...`
49+
50+
Example:
51+
```bash
52+
/Users/robertgrzesik/bin/safe-timeout 900s env LUMIBOT_CACHE_MODE=readonly \
53+
python3 scripts/run_backtest_prodlike.py \
54+
--main "/Users/robertgrzesik/Documents/Development/backtest_strategies/nq_double_ema_test/main.py" \
55+
--start 2026-01-20 --end 2026-01-27 \
56+
--data-source '{"default":"thetadata","crypto":"ibkr","future":"ibkr","cont_future":"ibkr"}' \
57+
--use-dotenv-s3-keys \
58+
--cache-folder "/Users/robertgrzesik/Documents/Development/backtest_cache/router_speed" \
59+
--profile yappi \
60+
--label nq_router_week1_yappi
61+
```
62+
63+
YAPPI analysis helper:
64+
- `scripts/analyze_yappi_csv.py`
65+
66+
## 3) Speed ledger
67+
68+
### Columns
69+
- `ts` (local wall clock)
70+
- `git` (short SHA)
71+
- `bench` (gc/nq)
72+
- `mode` (router-json/router-default)
73+
- `window` (1d/1w)
74+
- `elapsed_s`
75+
- `queue_submits`
76+
- `history_submits` (subset)
77+
- `top_paths` (top 3–5)
78+
- `yappi_csv`
79+
- `change`
80+
81+
### Baseline runs (pre-fix evidence; Jan 26, 2026)
82+
83+
These runs are preserved to show the “before” state: downloader-in-hot-loop behavior.
84+
85+
| ts | git | bench | mode | window | elapsed_s | queue_submits | history_submits | top_paths | yappi_csv | change |
86+
|---|---|---|---|---:|---:|---:|---:|---|---|---|
87+
| 2026-01-26 | (unknown) | gc | router-default | 1d | 1129 | 378 | 233 | `ibkr/iserver/marketdata/history` dominant | `.../20260126_180122_gc_ema_day1_yappi/..._profile_yappi.csv` | baseline (slow; queue wait dominates) |
88+
| 2026-01-26 | (unknown) | nq | router-default + S3 keys | 1d | timeout@1800s | 378 | 378 | all history | `.../20260126_201209_nq_2el_day1_s3warm_yappi/..._profile_yappi.csv` | baseline (timed out; ~1 history/minute) |
89+
90+
### Phase 1 results (router IBKR prefetch enabled; local changes on top of `version/4.4.39`)
91+
92+
These runs use:
93+
- routing: `{"default":"thetadata","crypto":"ibkr","future":"ibkr","cont_future":"ibkr"}`
94+
- local cache: `/Users/robertgrzesik/Documents/Development/backtest_cache/router_speed`
95+
- S3 cache: dev bucket/prefix, **read-only** (`LUMIBOT_CACHE_MODE=readonly`) during measurement
96+
97+
| ts | git | bench | mode | window | elapsed_s | queue_submits | history_submits | top_paths | yappi_csv | change |
98+
|---|---|---|---|---:|---:|---:|---:|---|---|---|
99+
| 2026-01-27 | a8f17429+local | nq | router-json | 1d (2026-01-20→21) | 26.6 | 1 | 0 | `ibkr/iserver/secdef/search` | (none) | warm-cache: effectively queue-free |
100+
| 2026-01-27 | a8f17429+local | nq | router-json | 1w (2026-01-20→27) | 51.0 | 2 | 2 | `ibkr/iserver/marketdata/history` | (none) | bounded history fetches only (no per-bar thrash) |
101+
| 2026-01-27 | a8f17429+local | nq | router-json | 1w (2026-01-20→27) | 25.5 | 0 | 0 | (none) | `/Users/robertgrzesik/Documents/Development/backtest_runs/20260127_001202_nq_router_20260120_week1_yappi/logs/NQDoubleEMATestStrategy_2026-01-27_00-12_VFcBmM_profile_yappi.csv` | YAPPI: ~0 network IO; dominated by pandas/numpy |
102+
| 2026-01-27 | a8f17429+local | gc | router-json | 1d (2026-01-20→21) | 14.7 | 1 | 0 | `ibkr/iserver/secdef/search` | (none) | warm-cache: bounded |
103+
| 2026-01-27 | a8f17429+local | gc | router-json | 1w (2026-01-20→27) | 163.0 | 5 | 5 | `ibkr/iserver/marketdata/history` | (none) | cold-ish: initial history fetches dominate |
104+
| 2026-01-27 | a8f17429+local | gc | router-json | 1w (2026-01-20→27) | 12.6 | 0 | 0 | (none) | `/Users/robertgrzesik/Documents/Development/backtest_runs/20260127_001638_gc_router_20260120_week1_yappi/logs/GoldFuturesEMACrossover_2026-01-27_00-16_o66T9X_profile_yappi.csv` | warm-cache: dominated by pandas/numpy |
105+
106+
## 4) Root cause + fix summary
107+
108+
**Root cause (router path, before fix):**
109+
- `_IbkrRoutingAdapter` fetched IBKR history per-window (often per simulated bar), instead of prefetching the full backtest window once.
110+
111+
**Fix (Phase 1):**
112+
- Router IBKR adapter now prefetches `(start - warmup) → backtest_end` once per series key for:
113+
- futures / cont_future (minute/hour/day)
114+
- crypto (minute/hour/day special cases)
115+
- Subsequent calls slice from the in-memory DataFrame.
116+
117+
See implementation: `lumibot/backtesting/routed_backtesting.py` (router IBKR adapter).
118+
119+
## 5) Tests / regression gates
120+
121+
Deterministic unit tests prevent regression back to “fetch in the hot loop”:
122+
- `tests/backtest/test_routed_backtesting_ibkr_prefetch.py`
123+
- futures/cont_future minute: prefetch once + slice
124+
- crypto minute: prefetch once + slice
125+

lumibot/backtesting/routed_backtesting.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,13 +254,75 @@ def _fetch_df(
254254
) -> pd.DataFrame | None:
255255
asset_type = str(getattr(asset, "asset_type", "") or "").lower()
256256

257+
# PERF: warm-cache minute strategies can call `get_historical_prices()` tens of thousands of
258+
# times. In the router data source, IBKR history fetches must be amortized by prefetching
259+
# the full backtest window once, then slicing in-memory thereafter (same principle as the
260+
# IBKR-only backtesting data source).
261+
262+
if (
263+
asset_type in {"future", "cont_future"}
264+
and ts_unit in {"minute", "hour", "day"}
265+
and canonical_key not in self._fully_loaded_series
266+
):
267+
try:
268+
from lumibot.backtesting.interactive_brokers_rest_backtesting import InteractiveBrokersRESTBacktesting
269+
270+
prev_open = InteractiveBrokersRESTBacktesting._previous_us_futures_session_open(self._router.datetime_start)
271+
except Exception:
272+
prev_open = None
273+
274+
try:
275+
if prev_open is not None:
276+
prefetch_start = min(start_datetime, prev_open)
277+
else:
278+
prefetch_start = min(start_datetime, self._router.datetime_start - timedelta(days=1))
279+
except Exception:
280+
prefetch_start = start_datetime
281+
282+
prefetch_end = self._router.datetime_end or end_dt
283+
284+
df = ibkr_helper.get_price_data(
285+
asset=asset,
286+
quote=quote_asset,
287+
timestep=ts_unit,
288+
start_dt=prefetch_start,
289+
end_dt=prefetch_end,
290+
exchange=None,
291+
include_after_hours=True,
292+
)
293+
if df is None or df.empty:
294+
return None
295+
self._fully_loaded_series.add(canonical_key)
296+
return df
297+
298+
if asset_type == "crypto" and ts_unit in {"minute", "hour"} and canonical_key not in self._fully_loaded_series:
299+
try:
300+
prefetch_start = min(start_datetime, self._router.datetime_start)
301+
except Exception:
302+
prefetch_start = start_datetime
303+
prefetch_end = self._router.datetime_end or end_dt
304+
305+
df = ibkr_helper.get_price_data(
306+
asset=asset,
307+
quote=quote_asset,
308+
timestep=ts_unit,
309+
start_dt=prefetch_start,
310+
end_dt=prefetch_end,
311+
exchange=None,
312+
include_after_hours=True,
313+
)
314+
if df is None or df.empty:
315+
return None
316+
self._fully_loaded_series.add(canonical_key)
317+
return df
318+
257319
if asset_type == "crypto" and ts_unit == "day" and canonical_key not in self._fully_loaded_series:
258320
try:
259321
lookback_days = max(7, int(length) + 5)
260322
except Exception:
261323
lookback_days = 7
262324
prefetch_start = min(start_datetime, self._router.datetime_start - timedelta(days=lookback_days))
263-
prefetch_end = self._router.datetime_end
325+
prefetch_end = self._router.datetime_end or end_dt
264326

265327
df = ibkr_helper.get_price_data(
266328
asset=asset,

0 commit comments

Comments
 (0)