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.
Files changed (99) hide show
  1. aether_quant-0.0.1.dist-info/METADATA +7 -0
  2. aether_quant-0.0.1.dist-info/RECORD +99 -0
  3. aether_quant-0.0.1.dist-info/WHEEL +5 -0
  4. aether_quant-0.0.1.dist-info/entry_points.txt +2 -0
  5. aether_quant-0.0.1.dist-info/licenses/LICENSE +133 -0
  6. aether_quant-0.0.1.dist-info/top_level.txt +13 -0
  7. analyzer/README.md +69 -0
  8. analyzer/__init__.py +17 -0
  9. analyzer/market_analyzer.py +521 -0
  10. aq_cli.py +3633 -0
  11. audit/README.md +47 -0
  12. audit/__init__.py +38 -0
  13. audit/hash_chain.py +52 -0
  14. audit/postgres_audit.py +61 -0
  15. audit/postgres_worker.py +402 -0
  16. audit/redis_queue.py +124 -0
  17. audit/status_export.py +62 -0
  18. data_pipeline/README.md +257 -0
  19. data_pipeline/__init__.py +6 -0
  20. data_pipeline/bar_synthesis.py +97 -0
  21. data_pipeline/dividend_backfill.py +243 -0
  22. data_pipeline/factor_file_backfill.py +276 -0
  23. data_pipeline/fetch.py +206 -0
  24. data_pipeline/fred_backfill.py +435 -0
  25. data_pipeline/ib_backfill.py +287 -0
  26. data_pipeline/v2_manifest.py +75 -0
  27. data_pipeline/yfinance_backfill.py +501 -0
  28. evaluation/README.md +152 -0
  29. evaluation/__init__.py +73 -0
  30. evaluation/ablation.py +310 -0
  31. evaluation/benchmark_comparison.py +195 -0
  32. evaluation/confidence_threshold_calibration.py +157 -0
  33. evaluation/factor_neutralization_check.py +94 -0
  34. evaluation/feature_parity.py +321 -0
  35. evaluation/feature_reconciliation.py +286 -0
  36. evaluation/impact_model.py +77 -0
  37. evaluation/kill_switch_replay.py +186 -0
  38. evaluation/limit_fill_simulator.py +152 -0
  39. evaluation/model_predictions.py +192 -0
  40. evaluation/monte_carlo.py +161 -0
  41. evaluation/public_benchmarks.py +117 -0
  42. evaluation/rank_book_simulator.py +494 -0
  43. evaluation/rank_ic_core.py +204 -0
  44. evaluation/rank_signal_calibration.py +690 -0
  45. evaluation/rolling_ic_gate_calibration.py +190 -0
  46. evaluation/rolling_ic_gate_replay.py +138 -0
  47. execution/README.md +281 -0
  48. execution/__init__.py +80 -0
  49. execution/config_cache.py +48 -0
  50. execution/cost_model.py +213 -0
  51. execution/lean_config_render.py +131 -0
  52. execution/live_credentials.py +38 -0
  53. execution/live_credentials_io.py +52 -0
  54. execution/order_gate.py +388 -0
  55. execution/paper_readiness.py +181 -0
  56. execution/paper_readiness_io.py +54 -0
  57. execution/paper_readiness_report.py +103 -0
  58. execution/paper_readiness_scheduler.py +137 -0
  59. execution/reconciliation.py +160 -0
  60. execution/runtime_config_io.py +38 -0
  61. execution/secret_scan.py +73 -0
  62. features/README.md +77 -0
  63. features/__init__.py +137 -0
  64. features/alt_data_features.py +154 -0
  65. features/bond_features.py +275 -0
  66. features/cross_asset_sensitivity.py +79 -0
  67. features/derivatives_macro_features.py +72 -0
  68. features/macro_features.py +79 -0
  69. features/options_greeks.py +329 -0
  70. features/sector_map.py +53 -0
  71. features/technical_indicators.py +185 -0
  72. generate_backtest_report.py +311 -0
  73. generate_evaluation_report.py +712 -0
  74. inference/README.md +105 -0
  75. inference/__init__.py +36 -0
  76. inference/exported_model.py +929 -0
  77. inference/parallel_inference.py +160 -0
  78. inference/strategy_selector_inference.py +75 -0
  79. json_safety.py +130 -0
  80. portfolio/README.md +447 -0
  81. portfolio/__init__.py +41 -0
  82. portfolio/book_construction.py +786 -0
  83. portfolio/book_neutrality.py +178 -0
  84. portfolio/hrp_allocation.py +159 -0
  85. portfolio/options_arbitrage_detector.py +232 -0
  86. portfolio/options_assignment_risk.py +119 -0
  87. portfolio/options_margin_sizing.py +333 -0
  88. portfolio/options_strategy.py +1999 -0
  89. portfolio/rank_signal.py +219 -0
  90. portfolio/rolling_ic_gate.py +183 -0
  91. risk/README.md +275 -0
  92. risk/__init__.py +38 -0
  93. risk/asset_class_router.py +584 -0
  94. risk/forex_risk.py +158 -0
  95. risk/futures_risk.py +202 -0
  96. risk/kill_switch.py +261 -0
  97. risk/manual_override.py +118 -0
  98. risk/position_sizing.py +303 -0
  99. 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"![Backtest equity curve]({chart_relative_path})",
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())