aether-quant 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aether_quant-0.0.1.dist-info/METADATA +7 -0
- aether_quant-0.0.1.dist-info/RECORD +99 -0
- aether_quant-0.0.1.dist-info/WHEEL +5 -0
- aether_quant-0.0.1.dist-info/entry_points.txt +2 -0
- aether_quant-0.0.1.dist-info/licenses/LICENSE +133 -0
- aether_quant-0.0.1.dist-info/top_level.txt +13 -0
- analyzer/README.md +69 -0
- analyzer/__init__.py +17 -0
- analyzer/market_analyzer.py +521 -0
- aq_cli.py +3633 -0
- audit/README.md +47 -0
- audit/__init__.py +38 -0
- audit/hash_chain.py +52 -0
- audit/postgres_audit.py +61 -0
- audit/postgres_worker.py +402 -0
- audit/redis_queue.py +124 -0
- audit/status_export.py +62 -0
- data_pipeline/README.md +257 -0
- data_pipeline/__init__.py +6 -0
- data_pipeline/bar_synthesis.py +97 -0
- data_pipeline/dividend_backfill.py +243 -0
- data_pipeline/factor_file_backfill.py +276 -0
- data_pipeline/fetch.py +206 -0
- data_pipeline/fred_backfill.py +435 -0
- data_pipeline/ib_backfill.py +287 -0
- data_pipeline/v2_manifest.py +75 -0
- data_pipeline/yfinance_backfill.py +501 -0
- evaluation/README.md +152 -0
- evaluation/__init__.py +73 -0
- evaluation/ablation.py +310 -0
- evaluation/benchmark_comparison.py +195 -0
- evaluation/confidence_threshold_calibration.py +157 -0
- evaluation/factor_neutralization_check.py +94 -0
- evaluation/feature_parity.py +321 -0
- evaluation/feature_reconciliation.py +286 -0
- evaluation/impact_model.py +77 -0
- evaluation/kill_switch_replay.py +186 -0
- evaluation/limit_fill_simulator.py +152 -0
- evaluation/model_predictions.py +192 -0
- evaluation/monte_carlo.py +161 -0
- evaluation/public_benchmarks.py +117 -0
- evaluation/rank_book_simulator.py +494 -0
- evaluation/rank_ic_core.py +204 -0
- evaluation/rank_signal_calibration.py +690 -0
- evaluation/rolling_ic_gate_calibration.py +190 -0
- evaluation/rolling_ic_gate_replay.py +138 -0
- execution/README.md +281 -0
- execution/__init__.py +80 -0
- execution/config_cache.py +48 -0
- execution/cost_model.py +213 -0
- execution/lean_config_render.py +131 -0
- execution/live_credentials.py +38 -0
- execution/live_credentials_io.py +52 -0
- execution/order_gate.py +388 -0
- execution/paper_readiness.py +181 -0
- execution/paper_readiness_io.py +54 -0
- execution/paper_readiness_report.py +103 -0
- execution/paper_readiness_scheduler.py +137 -0
- execution/reconciliation.py +160 -0
- execution/runtime_config_io.py +38 -0
- execution/secret_scan.py +73 -0
- features/README.md +77 -0
- features/__init__.py +137 -0
- features/alt_data_features.py +154 -0
- features/bond_features.py +275 -0
- features/cross_asset_sensitivity.py +79 -0
- features/derivatives_macro_features.py +72 -0
- features/macro_features.py +79 -0
- features/options_greeks.py +329 -0
- features/sector_map.py +53 -0
- features/technical_indicators.py +185 -0
- generate_backtest_report.py +311 -0
- generate_evaluation_report.py +712 -0
- inference/README.md +105 -0
- inference/__init__.py +36 -0
- inference/exported_model.py +929 -0
- inference/parallel_inference.py +160 -0
- inference/strategy_selector_inference.py +75 -0
- json_safety.py +130 -0
- portfolio/README.md +447 -0
- portfolio/__init__.py +41 -0
- portfolio/book_construction.py +786 -0
- portfolio/book_neutrality.py +178 -0
- portfolio/hrp_allocation.py +159 -0
- portfolio/options_arbitrage_detector.py +232 -0
- portfolio/options_assignment_risk.py +119 -0
- portfolio/options_margin_sizing.py +333 -0
- portfolio/options_strategy.py +1999 -0
- portfolio/rank_signal.py +219 -0
- portfolio/rolling_ic_gate.py +183 -0
- risk/README.md +275 -0
- risk/__init__.py +38 -0
- risk/asset_class_router.py +584 -0
- risk/forex_risk.py +158 -0
- risk/futures_risk.py +202 -0
- risk/kill_switch.py +261 -0
- risk/manual_override.py +118 -0
- risk/position_sizing.py +303 -0
- risk/rl_sizing.py +189 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Shared, pure technical-indicator implementations (Phase 6) - imported by
|
|
2
|
+
BOTH train.py::engineer_features() (offline, full history via a Python
|
|
3
|
+
loop) and main.py::_build_model_input() (runtime, one bar at a time from a
|
|
4
|
+
rolling deque) so both sides compute every indicator identically by
|
|
5
|
+
construction, not by hand-matched duplicated formulas (the train/runtime
|
|
6
|
+
parity convention this codebase already uses for every other feature, see
|
|
7
|
+
development/Changelog.md).
|
|
8
|
+
|
|
9
|
+
Each function takes a plain list of trailing values (already sliced to the
|
|
10
|
+
right window by the caller) and returns a single scalar float, matching
|
|
11
|
+
how train.py::engineer_features()'s existing loop already computes
|
|
12
|
+
close_to_close_return_5d/rolling_volatility_20d/etc one row at a time from
|
|
13
|
+
Python lists, not vectorized pandas. Every function returns a documented
|
|
14
|
+
neutral default when there isn't enough history yet, so nothing ever
|
|
15
|
+
raises and no extra dropna is needed - the same "adaptive lookback"
|
|
16
|
+
contract every existing base feature already has.
|
|
17
|
+
|
|
18
|
+
Chosen to fit main.py's existing self.symbol_windows (deque(maxlen=25), up
|
|
19
|
+
to 24 trailing bars - see train.py::CROSS_SECTIONAL_WINDOW_BARS) except
|
|
20
|
+
macd_histogram_normalized()/distance_from_52w_high(), which need a longer
|
|
21
|
+
history and are read from a separate, longer buffer (main.py's
|
|
22
|
+
self.symbol_long_windows, deque(maxlen=260)) - main.py:264-268 explicitly
|
|
23
|
+
forbids resizing symbol_windows itself, so a second buffer is the only
|
|
24
|
+
option for indicators needing more than ~24 bars.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
RSI_NEUTRAL = 50.0
|
|
30
|
+
BOLLINGER_PCTB_NEUTRAL = 0.5
|
|
31
|
+
VOLUME_ZSCORE_NEUTRAL = 0.0
|
|
32
|
+
MACD_HISTOGRAM_NEUTRAL = 0.0
|
|
33
|
+
DIST_52W_HIGH_NEUTRAL = 0.0
|
|
34
|
+
CROSS_SECTIONAL_MOMENTUM_RANK_NEUTRAL = 0.5
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def relative_strength_index(closes: list[float], period: int = 14) -> float:
|
|
38
|
+
"""Wilder's RSI over the trailing `period` closes (needs period+1
|
|
39
|
+
closes for period price changes) - a bounded [0, 100] mean-reversion
|
|
40
|
+
state, a genuinely different signal shape from any existing
|
|
41
|
+
return/volatility feature (bounded and non-linear, vs. an unbounded
|
|
42
|
+
return). Returns RSI_NEUTRAL (50.0, "neither overbought nor oversold")
|
|
43
|
+
when fewer than period+1 closes are available."""
|
|
44
|
+
if len(closes) < period + 1:
|
|
45
|
+
return RSI_NEUTRAL
|
|
46
|
+
window = closes[-(period + 1):]
|
|
47
|
+
gains = []
|
|
48
|
+
losses = []
|
|
49
|
+
for index in range(1, len(window)):
|
|
50
|
+
change = window[index] - window[index - 1]
|
|
51
|
+
gains.append(max(change, 0.0))
|
|
52
|
+
losses.append(max(-change, 0.0))
|
|
53
|
+
average_gain = sum(gains) / period
|
|
54
|
+
average_loss = sum(losses) / period
|
|
55
|
+
if average_loss == 0:
|
|
56
|
+
return 100.0 if average_gain > 0 else RSI_NEUTRAL
|
|
57
|
+
relative_strength = average_gain / average_loss
|
|
58
|
+
return 100.0 - (100.0 / (1.0 + relative_strength))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def average_true_range_pct(highs: list[float], lows: list[float], closes: list[float], period: int = 14) -> float:
|
|
62
|
+
"""ATR (Wilder's simple-average variant) as a % of the latest close -
|
|
63
|
+
a gap-aware volatility measure distinct from rolling_volatility_20d's
|
|
64
|
+
close-to-close-only stddev (a true range captures overnight
|
|
65
|
+
gaps/intraday extremes a close-only measure misses entirely). Needs
|
|
66
|
+
period+1 bars (period true-range values, each needing a previous
|
|
67
|
+
close). Returns 0.0 when insufficient history or the latest close is
|
|
68
|
+
zero."""
|
|
69
|
+
if len(closes) < period + 1:
|
|
70
|
+
return 0.0
|
|
71
|
+
true_ranges = []
|
|
72
|
+
for index in range(len(closes) - period, len(closes)):
|
|
73
|
+
high = highs[index]
|
|
74
|
+
low = lows[index]
|
|
75
|
+
previous_close = closes[index - 1]
|
|
76
|
+
true_ranges.append(max(high - low, abs(high - previous_close), abs(low - previous_close)))
|
|
77
|
+
atr = sum(true_ranges) / period
|
|
78
|
+
latest_close = closes[-1]
|
|
79
|
+
return atr / latest_close if latest_close else 0.0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def bollinger_pctb(closes: list[float], period: int = 20, num_std: float = 2.0) -> float:
|
|
83
|
+
"""%B: where the latest close sits within its trailing `period`-bar
|
|
84
|
+
Bollinger Band, normalized so 0.0 = lower band, 1.0 = upper band, 0.5 =
|
|
85
|
+
the middle (SMA) - a bounded, self-normalizing measure of price
|
|
86
|
+
extension, distinct from the raw high_low_range_pct/rolling_volatility
|
|
87
|
+
features already in this codebase. Returns BOLLINGER_PCTB_NEUTRAL
|
|
88
|
+
(0.5) when fewer than `period` closes exist yet, or the band has zero
|
|
89
|
+
width (constant price - degenerate either way, not a real signal)."""
|
|
90
|
+
if len(closes) < period:
|
|
91
|
+
return BOLLINGER_PCTB_NEUTRAL
|
|
92
|
+
window = closes[-period:]
|
|
93
|
+
mean = sum(window) / period
|
|
94
|
+
variance = sum((value - mean) ** 2 for value in window) / period
|
|
95
|
+
std = variance ** 0.5
|
|
96
|
+
if std == 0:
|
|
97
|
+
return BOLLINGER_PCTB_NEUTRAL
|
|
98
|
+
upper = mean + num_std * std
|
|
99
|
+
lower = mean - num_std * std
|
|
100
|
+
return (closes[-1] - lower) / (upper - lower)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def volume_zscore(volumes: list[float], period: int = 20) -> float:
|
|
104
|
+
"""Latest volume's z-score against its own trailing `period`-bar
|
|
105
|
+
mean/std - a self-normalizing participation measure. Robust to the
|
|
106
|
+
exact class of raw-volume-unit discontinuity that broke
|
|
107
|
+
volume_change_1d before its clamp fix (see development/Problems.md):
|
|
108
|
+
a z-score compresses a single extreme outlier's effect on the bar it
|
|
109
|
+
occurs on relative to a raw ratio, though a persisting outlier would
|
|
110
|
+
still distort the trailing window's own mean/std for subsequent bars,
|
|
111
|
+
same as any rolling statistic. Returns VOLUME_ZSCORE_NEUTRAL (0.0) when
|
|
112
|
+
fewer than `period` volumes exist yet, or the trailing window has zero
|
|
113
|
+
variance (a flat volume series)."""
|
|
114
|
+
if len(volumes) < period:
|
|
115
|
+
return VOLUME_ZSCORE_NEUTRAL
|
|
116
|
+
window = volumes[-period:]
|
|
117
|
+
mean = sum(window) / period
|
|
118
|
+
variance = sum((value - mean) ** 2 for value in window) / period
|
|
119
|
+
std = variance ** 0.5
|
|
120
|
+
return (volumes[-1] - mean) / std if std else VOLUME_ZSCORE_NEUTRAL
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def cross_sectional_momentum_rank(momentum_by_symbol: dict[str, float], symbol: str) -> float:
|
|
124
|
+
"""Per-date percentile rank ([0, 1], ties averaged - matches pandas'
|
|
125
|
+
.rank(pct=True) convention, same as
|
|
126
|
+
build_cross_sectional_rank_targets()'s target-side ranking) of
|
|
127
|
+
`symbol`'s own momentum_20d among every symbol present in
|
|
128
|
+
`momentum_by_symbol` - the best-documented daily cross-sectional
|
|
129
|
+
anomaly (12-1 month momentum), reusing the exact percentile-rank
|
|
130
|
+
convention already established for the ranking targets. Returns
|
|
131
|
+
CROSS_SECTIONAL_MOMENTUM_RANK_NEUTRAL (0.5, the exact middle) when
|
|
132
|
+
`symbol` has fewer than 2 total entries to rank against (undefined
|
|
133
|
+
otherwise, e.g. a single-asset universe or a thin trading day)."""
|
|
134
|
+
if len(momentum_by_symbol) < 2 or symbol not in momentum_by_symbol:
|
|
135
|
+
return CROSS_SECTIONAL_MOMENTUM_RANK_NEUTRAL
|
|
136
|
+
own_value = momentum_by_symbol[symbol]
|
|
137
|
+
values = list(momentum_by_symbol.values())
|
|
138
|
+
count_less = sum(1 for value in values if value < own_value)
|
|
139
|
+
count_equal = sum(1 for value in values if value == own_value)
|
|
140
|
+
return (count_less + (count_equal + 1) / 2.0) / len(values)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def macd_histogram_normalized(closes: list[float], fast: int = 12, slow: int = 26, signal: int = 9) -> float:
|
|
144
|
+
"""MACD histogram (MACD line - its own signal line), normalized by the
|
|
145
|
+
latest close so its scale is comparable across assets at very
|
|
146
|
+
different price levels (a raw MACD histogram is in the asset's own
|
|
147
|
+
raw price units). Both EMAs are recomputed fresh from the trailing
|
|
148
|
+
window passed in each call (an approximation of a true "since
|
|
149
|
+
inception" EMA, which never fully forgets old data) - a deliberate
|
|
150
|
+
simplification so this is exactly reproducible from a bounded window
|
|
151
|
+
on both the offline (full history slice) and runtime
|
|
152
|
+
(self.symbol_long_windows, maxlen=260) sides, with no persistent EMA
|
|
153
|
+
state to keep in sync between them. Needs at least `slow + signal`
|
|
154
|
+
closes; returns MACD_HISTOGRAM_NEUTRAL (0.0) otherwise, or when the
|
|
155
|
+
latest close is zero."""
|
|
156
|
+
if len(closes) < slow + signal or closes[-1] == 0:
|
|
157
|
+
return MACD_HISTOGRAM_NEUTRAL
|
|
158
|
+
|
|
159
|
+
def _ema_series(values: list[float], period: int) -> list[float]:
|
|
160
|
+
multiplier = 2.0 / (period + 1)
|
|
161
|
+
ema_values = [values[0]]
|
|
162
|
+
for value in values[1:]:
|
|
163
|
+
ema_values.append((value - ema_values[-1]) * multiplier + ema_values[-1])
|
|
164
|
+
return ema_values
|
|
165
|
+
|
|
166
|
+
fast_ema = _ema_series(closes, fast)
|
|
167
|
+
slow_ema = _ema_series(closes, slow)
|
|
168
|
+
macd_line = [fast_value - slow_value for fast_value, slow_value in zip(fast_ema, slow_ema)]
|
|
169
|
+
signal_line = _ema_series(macd_line, signal)
|
|
170
|
+
histogram = macd_line[-1] - signal_line[-1]
|
|
171
|
+
return histogram / closes[-1]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def distance_from_52w_high(closes: list[float], window: int = 252) -> float:
|
|
175
|
+
"""close / trailing-`window`-bar max close - 1: how far the latest
|
|
176
|
+
close sits below its own trailing high (0.0 = at the high, negative
|
|
177
|
+
otherwise) - a genuine anchoring-effect measure distinct from
|
|
178
|
+
everything else in this codebase (every existing return/momentum
|
|
179
|
+
feature is relative to a bar N periods ago, not to a rolling extreme).
|
|
180
|
+
Returns DIST_52W_HIGH_NEUTRAL (0.0) when fewer than 2 closes exist yet,
|
|
181
|
+
or the trailing high is zero."""
|
|
182
|
+
if len(closes) < 2:
|
|
183
|
+
return DIST_52W_HIGH_NEUTRAL
|
|
184
|
+
trailing_high = max(closes[-window:])
|
|
185
|
+
return closes[-1] / trailing_high - 1.0 if trailing_high else DIST_52W_HIGH_NEUTRAL
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Regenerates README.md's "Backtest Results" section from the most recent
|
|
2
|
+
real Lean backtest run under backtests/.
|
|
3
|
+
|
|
4
|
+
A static markdown file can't truly "live update," so this is the practical
|
|
5
|
+
substitute: `aq backtest` calls this module after a successful `lean
|
|
6
|
+
backtest .` run, and it rewrites a chart image plus a small stats table
|
|
7
|
+
in-place between two HTML comment markers in README.md, so the README stays
|
|
8
|
+
current with whatever backtest you last ran without any manual copy-pasting.
|
|
9
|
+
|
|
10
|
+
Data source: Lean's own backtest result JSON (`backtests/<run>/<id>.json`)
|
|
11
|
+
already contains everything needed, natively time-aligned, in one file - no
|
|
12
|
+
cross-file join needed:
|
|
13
|
+
- `statistics`: a flat dict of ready-to-use strings (Sharpe Ratio, Net
|
|
14
|
+
Profit, Drawdown, ...).
|
|
15
|
+
- `charts["Strategy Equity"]["series"]["Equity"]["values"]`: the algorithm's
|
|
16
|
+
own dated equity curve.
|
|
17
|
+
- `charts["Benchmark"]["series"]["Benchmark"]["values"]`: Lean's own
|
|
18
|
+
benchmark series. `main.py`/`lean.json` never call `SetBenchmark(...)`, so
|
|
19
|
+
this is Lean's documented default - SPY - already time-aligned with
|
|
20
|
+
"Strategy Equity" in the same file.
|
|
21
|
+
|
|
22
|
+
Usage:
|
|
23
|
+
python generate_backtest_report.py [--backtests-dir backtests] [--readme README.md] [--chart-path development/backtest_equity_chart.png]
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import argparse
|
|
29
|
+
import json
|
|
30
|
+
import re
|
|
31
|
+
import sys
|
|
32
|
+
from collections.abc import Iterator
|
|
33
|
+
from datetime import datetime, timezone
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
import matplotlib
|
|
37
|
+
|
|
38
|
+
matplotlib.use("Agg")
|
|
39
|
+
import matplotlib.pyplot as plt
|
|
40
|
+
|
|
41
|
+
ROOT_DIR = Path(__file__).resolve().parent
|
|
42
|
+
BACKTESTS_DIR = ROOT_DIR / "backtests"
|
|
43
|
+
README_PATH = ROOT_DIR / "README.md"
|
|
44
|
+
CHART_PATH = ROOT_DIR / "development" / "backtest_equity_chart.png"
|
|
45
|
+
|
|
46
|
+
_RESULT_JSON_PATTERN = re.compile(r"^\d+\.json$")
|
|
47
|
+
BACKTEST_MARKER_START = "<!-- AQ:BACKTEST_START -->"
|
|
48
|
+
BACKTEST_MARKER_END = "<!-- AQ:BACKTEST_END -->"
|
|
49
|
+
FULL_STATS_MARKER_START = "<!-- AQ:BACKTEST_FULL_STATS_START -->"
|
|
50
|
+
FULL_STATS_MARKER_END = "<!-- AQ:BACKTEST_FULL_STATS_END -->"
|
|
51
|
+
|
|
52
|
+
_STAT_KEYS_FOR_TABLE = [
|
|
53
|
+
"Sharpe Ratio",
|
|
54
|
+
"Net Profit",
|
|
55
|
+
"Compounding Annual Return",
|
|
56
|
+
"Drawdown",
|
|
57
|
+
"Total Orders",
|
|
58
|
+
"Win Rate",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _iter_backtest_result_jsons(backtests_dir: Path) -> Iterator[Path]:
|
|
63
|
+
"""Yields every backtest run's bare <digits>.json result file (never
|
|
64
|
+
*-summary.json), newest folder first. Sorted by each folder's own mtime,
|
|
65
|
+
not by name: the standard `lean backtest .` folders are named
|
|
66
|
+
YYYY-MM-DD_HH-MM-SS (lexicographic == chronological), but this
|
|
67
|
+
directory can also contain differently-named ad-hoc/manual run folders
|
|
68
|
+
(e.g. `lean-local-test-N`) that would otherwise sort ahead of every
|
|
69
|
+
real timestamped folder and get picked as "latest" by mistake."""
|
|
70
|
+
if not backtests_dir.exists():
|
|
71
|
+
return
|
|
72
|
+
run_dirs = sorted(
|
|
73
|
+
(d for d in backtests_dir.iterdir() if d.is_dir()),
|
|
74
|
+
key=lambda d: d.stat().st_mtime,
|
|
75
|
+
reverse=True,
|
|
76
|
+
)
|
|
77
|
+
for run_dir in run_dirs:
|
|
78
|
+
for candidate in sorted(run_dir.iterdir()):
|
|
79
|
+
if candidate.is_file() and _RESULT_JSON_PATTERN.match(candidate.name):
|
|
80
|
+
yield candidate
|
|
81
|
+
break # at most one result JSON per backtest folder
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def find_latest_backtest_result_json(backtests_dir: Path = BACKTESTS_DIR) -> Path | None:
|
|
85
|
+
"""Returns the newest backtest run's result JSON that actually contains
|
|
86
|
+
both a populated Strategy Equity curve AND a populated statistics
|
|
87
|
+
block - skips runs that errored out or were interrupted before
|
|
88
|
+
finishing (Lean can write a result JSON with a partially-populated
|
|
89
|
+
equity curve but an empty `statistics` dict if the process didn't reach
|
|
90
|
+
the end of the run), so an incomplete run never clobbers the README
|
|
91
|
+
with a chart but no Sharpe/drawdown/etc. numbers."""
|
|
92
|
+
for candidate in _iter_backtest_result_jsons(backtests_dir):
|
|
93
|
+
try:
|
|
94
|
+
data = json.loads(candidate.read_text(encoding="utf-8"))
|
|
95
|
+
except (OSError, ValueError):
|
|
96
|
+
continue
|
|
97
|
+
values = data.get("charts", {}).get("Strategy Equity", {}).get("series", {}).get("Equity", {}).get("values", [])
|
|
98
|
+
statistics = data.get("statistics", {})
|
|
99
|
+
if values and statistics:
|
|
100
|
+
return candidate
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def load_backtest_summary(result_json_path: Path) -> dict:
|
|
105
|
+
"""Parses a Lean backtest result JSON into a clean summary: the
|
|
106
|
+
statistics dict, plus dated (timestamp, value) strategy/benchmark
|
|
107
|
+
series."""
|
|
108
|
+
data = json.loads(result_json_path.read_text(encoding="utf-8"))
|
|
109
|
+
statistics = data.get("statistics", {})
|
|
110
|
+
charts = data.get("charts", {})
|
|
111
|
+
|
|
112
|
+
strategy_points = charts.get("Strategy Equity", {}).get("series", {}).get("Equity", {}).get("values", [])
|
|
113
|
+
benchmark_points = charts.get("Benchmark", {}).get("series", {}).get("Benchmark", {}).get("values", [])
|
|
114
|
+
|
|
115
|
+
# Equity points are [timestamp, open, high, low, close] with all four
|
|
116
|
+
# OHLC values identical (an equity total, not a real price bar) -
|
|
117
|
+
# benchmark points are plain [timestamp, price]. The last element is
|
|
118
|
+
# the value we want in both cases.
|
|
119
|
+
strategy_series = [(int(point[0]), float(point[-1])) for point in strategy_points]
|
|
120
|
+
benchmark_series = [(int(point[0]), float(point[-1])) for point in benchmark_points]
|
|
121
|
+
|
|
122
|
+
start_date = datetime.fromtimestamp(strategy_series[0][0], tz=timezone.utc) if strategy_series else None
|
|
123
|
+
end_date = datetime.fromtimestamp(strategy_series[-1][0], tz=timezone.utc) if strategy_series else None
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
"statistics": statistics,
|
|
127
|
+
"strategy_series": strategy_series,
|
|
128
|
+
"benchmark_series": benchmark_series,
|
|
129
|
+
"start_date": start_date,
|
|
130
|
+
"end_date": end_date,
|
|
131
|
+
"result_json_path": result_json_path,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _rebase_to_100(series: list[tuple[int, float]]) -> list[tuple[int, float]]:
|
|
136
|
+
"""Both series need a common visual scale: strategy equity starts at
|
|
137
|
+
the algorithm's starting cash (e.g. $100,000), the benchmark is a raw
|
|
138
|
+
SPY price - neither is directly comparable to the other in absolute
|
|
139
|
+
terms, so both are indexed to start at 100."""
|
|
140
|
+
if not series:
|
|
141
|
+
return []
|
|
142
|
+
base = series[0][1]
|
|
143
|
+
if base == 0:
|
|
144
|
+
return [(timestamp, 100.0) for timestamp, _ in series]
|
|
145
|
+
return [(timestamp, value / base * 100.0) for timestamp, value in series]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def generate_equity_chart(summary: dict, output_path: Path = CHART_PATH) -> None:
|
|
149
|
+
"""Renders the strategy-vs-benchmark equity chart as a PNG, embedded in
|
|
150
|
+
README.md by update_readme_backtest_section()."""
|
|
151
|
+
strategy = _rebase_to_100(summary["strategy_series"])
|
|
152
|
+
benchmark = _rebase_to_100(summary["benchmark_series"])
|
|
153
|
+
|
|
154
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
155
|
+
|
|
156
|
+
fig, ax = plt.subplots(figsize=(9, 4.5), dpi=150)
|
|
157
|
+
if strategy:
|
|
158
|
+
strategy_dates = [datetime.fromtimestamp(ts, tz=timezone.utc) for ts, _ in strategy]
|
|
159
|
+
ax.plot(
|
|
160
|
+
strategy_dates,
|
|
161
|
+
[value for _, value in strategy],
|
|
162
|
+
label="Aether Quant Strategy",
|
|
163
|
+
color="#2E86AB",
|
|
164
|
+
linewidth=1.6,
|
|
165
|
+
)
|
|
166
|
+
if benchmark:
|
|
167
|
+
benchmark_dates = [datetime.fromtimestamp(ts, tz=timezone.utc) for ts, _ in benchmark]
|
|
168
|
+
ax.plot(
|
|
169
|
+
benchmark_dates,
|
|
170
|
+
[value for _, value in benchmark],
|
|
171
|
+
label="S&P 500 (SPY) Benchmark",
|
|
172
|
+
color="#A23B72",
|
|
173
|
+
linewidth=1.6,
|
|
174
|
+
linestyle="--",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
ax.set_title("Backtest Equity: Strategy vs. S&P 500 Benchmark (indexed to 100)")
|
|
178
|
+
ax.set_ylabel("Indexed value (start = 100)")
|
|
179
|
+
ax.legend(loc="best", frameon=False)
|
|
180
|
+
ax.grid(True, alpha=0.25)
|
|
181
|
+
fig.autofmt_xdate()
|
|
182
|
+
fig.tight_layout()
|
|
183
|
+
fig.savefig(output_path)
|
|
184
|
+
plt.close(fig)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _build_stats_markdown(summary: dict, chart_relative_path: str) -> str:
|
|
188
|
+
statistics = summary["statistics"]
|
|
189
|
+
start_date, end_date = summary["start_date"], summary["end_date"]
|
|
190
|
+
window = (
|
|
191
|
+
f"{start_date.date().isoformat()} to {end_date.date().isoformat()}" if start_date and end_date else "unknown"
|
|
192
|
+
)
|
|
193
|
+
updated_at = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
194
|
+
|
|
195
|
+
lines = [
|
|
196
|
+
f"",
|
|
197
|
+
"",
|
|
198
|
+
"| Metric | Value |",
|
|
199
|
+
"|---|---|",
|
|
200
|
+
f"| Backtest window | {window} |",
|
|
201
|
+
]
|
|
202
|
+
for key in _STAT_KEYS_FOR_TABLE:
|
|
203
|
+
if key in statistics:
|
|
204
|
+
lines.append(f"| {key} | {statistics[key]} |")
|
|
205
|
+
lines.append(f"| Last updated | {updated_at} (auto-generated by `aq backtest`) |")
|
|
206
|
+
return "\n".join(lines)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _build_full_stats_markdown(summary: dict) -> str:
|
|
210
|
+
"""Every statistic Lean's own backtest result reports, not just the
|
|
211
|
+
curated highlights in the compact table above it - Sharpe/Sortino/
|
|
212
|
+
Probabilistic Sharpe, Alpha/Beta, Information/Treynor Ratio, fees,
|
|
213
|
+
estimated strategy capacity, and everything else Lean computes."""
|
|
214
|
+
statistics = summary["statistics"]
|
|
215
|
+
if not statistics:
|
|
216
|
+
return "_No statistics available in this backtest result._"
|
|
217
|
+
|
|
218
|
+
lines = ["| Metric | Value |", "|---|---|"]
|
|
219
|
+
for key, value in statistics.items():
|
|
220
|
+
lines.append(f"| {key} | {value} |")
|
|
221
|
+
return "\n".join(lines)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _replace_between_markers(text: str, start_marker: str, end_marker: str, body: str) -> str | None:
|
|
225
|
+
"""Shared atomic marker-replace helper. Returns the updated text, or
|
|
226
|
+
None if the markers aren't present or nothing changed."""
|
|
227
|
+
if start_marker not in text or end_marker not in text:
|
|
228
|
+
return None
|
|
229
|
+
pattern = re.compile(re.escape(start_marker) + r".*?" + re.escape(end_marker), re.DOTALL)
|
|
230
|
+
replacement = f"{start_marker}\n{body}\n{end_marker}"
|
|
231
|
+
updated_text = pattern.sub(replacement, text, count=1)
|
|
232
|
+
return updated_text if updated_text != text else None
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _atomic_write(path: Path, text: str) -> None:
|
|
236
|
+
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
|
237
|
+
tmp_path.write_text(text, encoding="utf-8")
|
|
238
|
+
tmp_path.replace(path)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def update_readme_backtest_section(
|
|
242
|
+
summary: dict, readme_path: Path = README_PATH, chart_path: Path = CHART_PATH
|
|
243
|
+
) -> bool:
|
|
244
|
+
"""Atomically replaces the content between the AQ:BACKTEST markers in
|
|
245
|
+
README.md with a fresh stats table + chart image reference, and the
|
|
246
|
+
content between the AQ:BACKTEST_FULL_STATS markers with every statistic
|
|
247
|
+
Lean's result reports. Returns False (never raises) if the README or
|
|
248
|
+
its markers are missing - a report-generation bug must never break
|
|
249
|
+
`aq backtest` itself."""
|
|
250
|
+
if not readme_path.exists():
|
|
251
|
+
return False
|
|
252
|
+
text = readme_path.read_text(encoding="utf-8")
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
chart_relative_path = chart_path.relative_to(readme_path.resolve().parent).as_posix()
|
|
256
|
+
except ValueError:
|
|
257
|
+
chart_relative_path = chart_path.as_posix()
|
|
258
|
+
|
|
259
|
+
updated_text = _replace_between_markers(
|
|
260
|
+
text, BACKTEST_MARKER_START, BACKTEST_MARKER_END, _build_stats_markdown(summary, chart_relative_path)
|
|
261
|
+
)
|
|
262
|
+
if updated_text is None:
|
|
263
|
+
return False
|
|
264
|
+
text = updated_text
|
|
265
|
+
|
|
266
|
+
full_stats_text = _replace_between_markers(
|
|
267
|
+
text, FULL_STATS_MARKER_START, FULL_STATS_MARKER_END, _build_full_stats_markdown(summary)
|
|
268
|
+
)
|
|
269
|
+
if full_stats_text is not None:
|
|
270
|
+
text = full_stats_text
|
|
271
|
+
|
|
272
|
+
_atomic_write(readme_path, text)
|
|
273
|
+
return True
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def update_readme_from_latest_backtest(
|
|
277
|
+
backtests_dir: Path = BACKTESTS_DIR,
|
|
278
|
+
readme_path: Path = README_PATH,
|
|
279
|
+
chart_path: Path = CHART_PATH,
|
|
280
|
+
) -> bool:
|
|
281
|
+
"""Top-level entrypoint `aq_cli.py::cmd_backtest` calls after a
|
|
282
|
+
successful `lean backtest .` run. Callers should still wrap this in a
|
|
283
|
+
try/except - a report-generation bug must never fail the backtest
|
|
284
|
+
command itself."""
|
|
285
|
+
result_json_path = find_latest_backtest_result_json(backtests_dir)
|
|
286
|
+
if result_json_path is None:
|
|
287
|
+
return False
|
|
288
|
+
summary = load_backtest_summary(result_json_path)
|
|
289
|
+
generate_equity_chart(summary, chart_path)
|
|
290
|
+
return update_readme_backtest_section(summary, readme_path, chart_path)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def main() -> int:
|
|
294
|
+
parser = argparse.ArgumentParser(
|
|
295
|
+
description="Regenerate README.md's Backtest Results section from the latest Lean backtest run."
|
|
296
|
+
)
|
|
297
|
+
parser.add_argument("--backtests-dir", type=Path, default=BACKTESTS_DIR)
|
|
298
|
+
parser.add_argument("--readme", type=Path, default=README_PATH)
|
|
299
|
+
parser.add_argument("--chart-path", type=Path, default=CHART_PATH)
|
|
300
|
+
args = parser.parse_args()
|
|
301
|
+
|
|
302
|
+
updated = update_readme_from_latest_backtest(args.backtests_dir, args.readme, args.chart_path)
|
|
303
|
+
if updated:
|
|
304
|
+
print(f"Updated {args.readme} with the latest backtest results.")
|
|
305
|
+
return 0
|
|
306
|
+
print("No backtest results found (or README markers missing) - nothing updated.", file=sys.stderr)
|
|
307
|
+
return 1
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
if __name__ == "__main__":
|
|
311
|
+
sys.exit(main())
|