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,152 @@
1
+ """V5.3.1 (development/Problems.md #34/#96) - an offline, counterfactual
2
+ answer to "how often would a real limit order actually fill" without a
3
+ live Lean run: for every (ticker, date) row in an existing dataset, this
4
+ computes a limit price the exact same way main.py's real order placement
5
+ does (execution.order_gate.resolve_limit_price(), the SAME pure function,
6
+ not a re-derived approximation), then scans that ticker's own next
7
+ `unfilled_timeout_bars` rows' high/low columns to determine whether a real
8
+ Lean fill engine plausibly would have crossed it.
9
+
10
+ APPROXIMATION, NOT A REPLAY (read before trusting these numbers): this
11
+ simulates a signal firing on EVERY row, independent of whether main.py's
12
+ book/gates would have actually placed an order that day - it measures "if
13
+ an order were placed here, would it fill", not "how many of the real
14
+ orders in a given backtest filled" (development/Problems.md #34/#96's own
15
+ real order-events.json evidence is the ground truth for the latter
16
+ question; this module is deliberately a different, complementary
17
+ question). It also has no intrabar depth/tick data (only daily OHLC), so
18
+ it can only ever answer "did price trade through the limit", never
19
+ simulate a genuine partial fill - the same historical-bar-CSV ceiling
20
+ that makes PartiallyFilled unverifiable offline at all (see #34/#96's
21
+ verification notes)."""
22
+
23
+ from __future__ import annotations
24
+
25
+ import pandas as pd
26
+
27
+ from execution.order_gate import resolve_limit_price
28
+
29
+
30
+ def simulate_limit_fills(
31
+ dataset: pd.DataFrame,
32
+ *,
33
+ unfilled_timeout_bars: int,
34
+ offset_multiplier: float = 1.0,
35
+ ticker_column: str = "ticker",
36
+ date_column: str = "date",
37
+ security_type_column: str = "security_type",
38
+ ) -> dict:
39
+ """Per ticker (grouped, sorted by date_column): for every row with at
40
+ least `unfilled_timeout_bars` rows of data remaining for that ticker
41
+ (rows too close to the end of a ticker's history are EXCLUDED from the
42
+ denominator entirely, not counted as a timeout - a thin-data edge
43
+ effect, not a real timeout, matching calibrate_book_confidence_spread()'s
44
+ own "excluded, not counted as 0" convention), simulates BOTH a buy and
45
+ a sell limit order (the real book trades both long and short, so
46
+ simulating only one side would under-cover the calibration question).
47
+
48
+ A buy fills the first bar (within the timeout window) whose `low`
49
+ reaches at/below the buy limit price; a sell fills the first bar whose
50
+ `high` reaches at/above the sell limit price - otherwise it's a
51
+ timeout. `liquidity_spread_proxy` (already in ml/datasets/*.csv, the
52
+ same column execution.order_gate.resolve_limit_price() expects as
53
+ spread_fraction) supplies the spread input.
54
+
55
+ Returns {"overall": {...}, "by_asset_class": {security_type: {...}},
56
+ "by_side": {"buy": {...}, "sell": {...}}}, each a dict of
57
+ {"num_signals", "fill_rate", "timeout_rate", "mean_bars_to_fill"}.
58
+ An empty/all-thin dataset returns all-zero stats, never raises."""
59
+ records: list[dict] = []
60
+
61
+ has_security_type = security_type_column in dataset.columns
62
+ for _ticker, group in dataset.groupby(ticker_column, sort=False):
63
+ group = group.sort_values(date_column).reset_index(drop=True)
64
+ closes = group["close"].to_numpy()
65
+ highs = group["high"].to_numpy()
66
+ lows = group["low"].to_numpy()
67
+ spreads = group["liquidity_spread_proxy"].to_numpy()
68
+ security_types = group[security_type_column].to_numpy() if has_security_type else [None] * len(group)
69
+ num_rows = len(group)
70
+
71
+ for i in range(num_rows):
72
+ if i + unfilled_timeout_bars >= num_rows:
73
+ continue # too close to the end of this ticker's data - excluded, not a timeout
74
+ reference_price = float(closes[i])
75
+ raw_spread = spreads[i]
76
+ spread_fraction = 0.0 if pd.isna(raw_spread) else float(raw_spread)
77
+ security_type = security_types[i]
78
+
79
+ for is_buy in (True, False):
80
+ limit_price = resolve_limit_price(reference_price, spread_fraction, is_buy, offset_multiplier)
81
+ bars_to_fill = None
82
+ for offset in range(1, unfilled_timeout_bars + 1):
83
+ j = i + offset
84
+ crossed = lows[j] <= limit_price if is_buy else highs[j] >= limit_price
85
+ if crossed:
86
+ bars_to_fill = offset
87
+ break
88
+ records.append(
89
+ {
90
+ "side": "buy" if is_buy else "sell",
91
+ "security_type": security_type,
92
+ "filled": bars_to_fill is not None,
93
+ "bars_to_fill": bars_to_fill,
94
+ }
95
+ )
96
+
97
+ return {
98
+ "overall": _summarize_fill_records(records),
99
+ "by_asset_class": {
100
+ str(security_type): _summarize_fill_records(group_records)
101
+ for security_type, group_records in _group_by(records, "security_type").items()
102
+ },
103
+ "by_side": {
104
+ side: _summarize_fill_records(group_records) for side, group_records in _group_by(records, "side").items()
105
+ },
106
+ }
107
+
108
+
109
+ def sweep_limit_fill_offsets(
110
+ dataset: pd.DataFrame,
111
+ *,
112
+ unfilled_timeout_bars: int,
113
+ offset_multipliers: list[float],
114
+ ticker_column: str = "ticker",
115
+ date_column: str = "date",
116
+ security_type_column: str = "security_type",
117
+ ) -> dict:
118
+ """Runs simulate_limit_fills() once per offset_multiplier - calibration
119
+ insight into how a more/less passive limit placement trades off fill
120
+ rate vs. price improvement. Returns {str(multiplier): result}."""
121
+ return {
122
+ str(multiplier): simulate_limit_fills(
123
+ dataset,
124
+ unfilled_timeout_bars=unfilled_timeout_bars,
125
+ offset_multiplier=multiplier,
126
+ ticker_column=ticker_column,
127
+ date_column=date_column,
128
+ security_type_column=security_type_column,
129
+ )
130
+ for multiplier in offset_multipliers
131
+ }
132
+
133
+
134
+ def _group_by(records: list[dict], key: str) -> dict:
135
+ groups: dict = {}
136
+ for record in records:
137
+ groups.setdefault(record[key], []).append(record)
138
+ return groups
139
+
140
+
141
+ def _summarize_fill_records(records: list[dict]) -> dict:
142
+ num_signals = len(records)
143
+ if num_signals == 0:
144
+ return {"num_signals": 0, "fill_rate": 0.0, "timeout_rate": 0.0, "mean_bars_to_fill": None}
145
+ num_filled = sum(1 for record in records if record["filled"])
146
+ bars_to_fill_values = [record["bars_to_fill"] for record in records if record["bars_to_fill"] is not None]
147
+ return {
148
+ "num_signals": num_signals,
149
+ "fill_rate": num_filled / num_signals,
150
+ "timeout_rate": (num_signals - num_filled) / num_signals,
151
+ "mean_bars_to_fill": (sum(bars_to_fill_values) / len(bars_to_fill_values)) if bars_to_fill_values else None,
152
+ }
@@ -0,0 +1,192 @@
1
+ """Loads an exported model (ml/{multitask,sequence}_model.json) plus the
2
+ active dataset, and runs every row through inference/exported_model.py -
3
+ the EXACT torch-free interpreter main.py's live decision path uses - to
4
+ produce a predicted-<head> column. Deliberately its own module, not
5
+ train.py: train.py imports torch, and `aq evaluate` must stay fast to
6
+ start for commands that never need training-time code (this package's
7
+ torch-free contract - see evaluation/__init__.py's docstring).
8
+
9
+ Because this reuses inference/exported_model.py verbatim (not a
10
+ re-derived approximation), `aq evaluate --rank-book` reports the SAME
11
+ numbers a live/backtest run would produce from the same artifacts -
12
+ including any live quirk in those artifacts (e.g. Problems.md #73's
13
+ sigmoid-vs-raw mismatch), which is the point: this is meant to be an
14
+ offline mirror of live behavior, not a separate idealized model.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import numpy as np
20
+ import pandas as pd
21
+
22
+ from inference.exported_model import (
23
+ resolve_sequence_window_size,
24
+ run_exported_multitask_model,
25
+ run_exported_sequence_multitask_model,
26
+ )
27
+
28
+
29
+ def build_sequence_windows(
30
+ dataset: pd.DataFrame,
31
+ model_input_names: list[str],
32
+ window_size: int,
33
+ ticker_column: str = "ticker",
34
+ ) -> np.ndarray:
35
+ """Torch-free port of train.py::build_sequence_tensor_dataset() - same
36
+ algorithm (each ticker's own trailing window, left-padded with zeros
37
+ for rows with fewer than window_size preceding rows, row-order-aligned
38
+ with `dataset`), deliberately duplicated rather than imported since
39
+ train.py pulls in torch and this module must stay import-light.
40
+ `dataset` must already be sorted the way build_feature_dataset() leaves
41
+ it (each ticker's own rows in chronological order) - the same
42
+ precondition train.py's version documents.
43
+
44
+ float32, not float64 (V5.1 Phase 5, Problems.md #83) - halves peak
45
+ memory for no correctness cost: every consumer (run_exported_sequence_
46
+ multitask_model()) takes `sequence: list[list[float]]` and converts via
47
+ .tolist() before use, so the underlying numpy dtype is invisible to it
48
+ either way. Matches train.py::build_sequence_tensor_dataset()'s own
49
+ dtype, which was already float32."""
50
+ feature_matrix = dataset[model_input_names].to_numpy(dtype=np.float32)
51
+ tickers = dataset[ticker_column].to_numpy()
52
+
53
+ windows = np.zeros((len(dataset), window_size, len(model_input_names)), dtype=np.float32)
54
+ positions_by_ticker: dict[object, list[int]] = {}
55
+ for position, ticker in enumerate(tickers):
56
+ positions_by_ticker.setdefault(ticker, []).append(position)
57
+
58
+ for positions in positions_by_ticker.values():
59
+ for index_within_ticker, position in enumerate(positions):
60
+ window_start = max(0, index_within_ticker + 1 - window_size)
61
+ window_positions = positions[window_start : index_within_ticker + 1]
62
+ window_values = feature_matrix[window_positions]
63
+ windows[position, -len(window_values) :, :] = window_values
64
+
65
+ return windows
66
+
67
+
68
+ def select_context_date_range(
69
+ dataset: pd.DataFrame,
70
+ recorded_dates: list[str],
71
+ *,
72
+ window_size: int,
73
+ ticker_column: str = "ticker",
74
+ date_column: str = "date",
75
+ ) -> tuple[str, str]:
76
+ """V5.2.2 (development/Problems.md) - computes the inclusive
77
+ `(min_date, max_date)` date-string bounds a caller must keep every
78
+ row for (per ticker, in `dataset`'s own row order) before restricting
79
+ the dataset to a specific set of `recorded_dates` and still getting
80
+ CORRECT `build_sequence_windows()` output for those dates.
81
+
82
+ GUARDRAIL: `build_sequence_windows()` builds each ticker's trailing
83
+ window from ORDINAL ROW POSITION within whatever frame it's given,
84
+ not from calendar dates - it has no way to know rows were dropped.
85
+ Naively filtering `dataset` down to only `recorded_dates` before
86
+ calling it does not raise; it silently builds each window from
87
+ whichever OTHER kept rows happen to be ordinally adjacent (e.g. 10
88
+ calendar days apart instead of 1), producing wrong predictions with
89
+ no error. This function exists so a caller can safely narrow a large
90
+ dataset to just the date range a reconciliation actually needs
91
+ (`aq evaluate --reconcile-book-history`'s whole reason for existing)
92
+ without corrupting sequence-model context: keep every row inside
93
+ `[min(recorded_dates), max(recorded_dates)]` UNCHANGED, plus enough
94
+ additional rows per ticker immediately preceding that span (by row
95
+ position, not calendar-day subtraction, since non-trading days never
96
+ appear in `dataset` to begin with) to fully support a `window_size`
97
+ trailing window for the earliest recorded date. Only rows entirely
98
+ outside the returned bounds may be safely dropped.
99
+
100
+ Per ticker: finds the first row at/after `min(recorded_dates)`, then
101
+ walks back `window_size` positions (mirroring `build_sequence_windows()`'s
102
+ own `max(0, index_within_ticker + 1 - window_size)` formula exactly)
103
+ to find that ticker's own earliest-needed date. The overall
104
+ `min_date` returned is the EARLIEST such date across all tickers, so
105
+ every ticker's window is fully supported, not just the first one
106
+ encountered. A ticker with no rows at/after `min(recorded_dates)` at
107
+ all is skipped (nothing to support for it).
108
+
109
+ Raises `ValueError` if `recorded_dates` is empty - there is no
110
+ context range to compute without at least one target date."""
111
+ if not recorded_dates:
112
+ raise ValueError("select_context_date_range() requires at least one recorded date")
113
+
114
+ target_min = min(recorded_dates)
115
+ target_max = max(recorded_dates)
116
+
117
+ earliest_needed_dates: list[str] = []
118
+ for _ticker, ticker_frame in dataset.groupby(ticker_column, sort=False):
119
+ ticker_dates = ticker_frame[date_column].tolist()
120
+ boundary_index = next((i for i, d in enumerate(ticker_dates) if d >= target_min), None)
121
+ if boundary_index is None:
122
+ continue
123
+ lookback_start = max(0, boundary_index + 1 - window_size)
124
+ earliest_needed_dates.append(ticker_dates[lookback_start])
125
+
126
+ min_date = min(earliest_needed_dates) if earliest_needed_dates else target_min
127
+ return min_date, target_max
128
+
129
+
130
+ def predict_multitask_head(
131
+ dataset: pd.DataFrame, model_export: dict, feature_names: list[str], head: str
132
+ ) -> np.ndarray:
133
+ """One run_exported_multitask_model() call per row - the flat-MLP
134
+ trunk needs no windowing, unlike the sequence model below."""
135
+ predictions = np.full(len(dataset), np.nan, dtype=np.float64)
136
+ feature_matrix = dataset[feature_names].to_numpy(dtype=np.float64)
137
+ for row_index in range(len(dataset)):
138
+ outputs = run_exported_multitask_model(model_export, feature_matrix[row_index].tolist())
139
+ predictions[row_index] = outputs.get(head, np.nan)
140
+ return predictions
141
+
142
+
143
+ def predict_sequence_head(
144
+ dataset: pd.DataFrame,
145
+ model_export: dict,
146
+ feature_names: list[str],
147
+ head: str,
148
+ *,
149
+ sequence_feature_schema: dict | None = None,
150
+ configured_window_size: int = 30,
151
+ ticker_column: str = "ticker",
152
+ ) -> np.ndarray:
153
+ """Builds each row's trailing window via build_sequence_windows() then
154
+ runs run_exported_sequence_multitask_model() per row. window_size comes
155
+ from resolve_sequence_window_size() - the trained model's OWN schema
156
+ wins over any configured default, the same shape-mismatch-avoidance
157
+ contract main.py's live sequence-model path already follows."""
158
+ window_size = resolve_sequence_window_size(sequence_feature_schema, configured_window_size)
159
+ windows = build_sequence_windows(dataset, feature_names, window_size, ticker_column)
160
+ predictions = np.full(len(dataset), np.nan, dtype=np.float64)
161
+ for row_index in range(len(dataset)):
162
+ outputs = run_exported_sequence_multitask_model(model_export, windows[row_index].tolist())
163
+ predictions[row_index] = outputs.get(head, np.nan)
164
+ return predictions
165
+
166
+
167
+ def predict_head(
168
+ dataset: pd.DataFrame,
169
+ model_export: dict,
170
+ feature_names: list[str],
171
+ head: str,
172
+ *,
173
+ model_kind: str,
174
+ sequence_feature_schema: dict | None = None,
175
+ configured_window_size: int = 30,
176
+ ticker_column: str = "ticker",
177
+ ) -> np.ndarray:
178
+ """Single dispatch point for `aq evaluate` - model_kind in
179
+ {"multitask", "sequence"} picks the right predictor above. Any other
180
+ value raises ValueError (a genuinely programmer-facing error, not a
181
+ runtime degradation case - unlike every other function in this
182
+ package, this one is not on main.py's live path)."""
183
+ if model_kind == "multitask":
184
+ return predict_multitask_head(dataset, model_export, feature_names, head)
185
+ if model_kind == "sequence":
186
+ return predict_sequence_head(
187
+ dataset, model_export, feature_names, head,
188
+ sequence_feature_schema=sequence_feature_schema,
189
+ configured_window_size=configured_window_size,
190
+ ticker_column=ticker_column,
191
+ )
192
+ raise ValueError(f"unknown model_kind {model_kind!r} - expected 'multitask' or 'sequence'")
@@ -0,0 +1,161 @@
1
+ """V5.3.8 - Monte Carlo simulation testing layer (pure core).
2
+
3
+ Resamples the offline rank book's daily NET return series into N
4
+ alternative equity histories so risk can be stated as a DISTRIBUTION
5
+ instead of a single path: percentile bands for total return / Sharpe /
6
+ max drawdown, probability of loss, and the average compounded curve that
7
+ the README visual draws as its red center line.
8
+
9
+ Two resampling methods:
10
+ - "block" (default): stationary block bootstrap - contiguous chunks of
11
+ `block_size` days are resampled with replacement, preserving the
12
+ short-horizon autocorrelation/volatility clustering a naive i.i.d.
13
+ shuffle would erase.
14
+ - "iid": element-wise resampling - the classical Monte Carlo baseline.
15
+
16
+ Pure: numpy only, no I/O, no torch, deterministic given (`seed`, inputs).
17
+ The heavy per-run curve matrix is returned DOWNSAMPLED (`n_plot_points`)
18
+ so callers can persist it without multi-megabyte payloads.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from typing import Any
24
+
25
+ import numpy as np
26
+
27
+ TRADING_DAYS_PER_YEAR = 252
28
+
29
+
30
+ def _max_drawdown(equity: np.ndarray) -> float:
31
+ peak = np.maximum.accumulate(equity)
32
+ drawdown = 1.0 - equity / peak
33
+ return float(drawdown.max()) if drawdown.size else 0.0
34
+
35
+
36
+ def _annualized_sharpe(sampled_returns: np.ndarray) -> float:
37
+ if sampled_returns.size < 2:
38
+ return 0.0
39
+ std = sampled_returns.std(ddof=1)
40
+ if std == 0:
41
+ return 0.0
42
+ return float(sampled_returns.mean() / std * np.sqrt(TRADING_DAYS_PER_YEAR))
43
+
44
+
45
+ def _resample_indices(n: int, block_size: int, method: str, rng: np.random.Generator) -> np.ndarray:
46
+ if method == "iid":
47
+ return rng.integers(0, n, n)
48
+ n_blocks = int(np.ceil(n / block_size))
49
+ starts = rng.integers(0, n, size=n_blocks)
50
+ idx = (starts[:, None] + np.arange(block_size)[None, :]).ravel()
51
+ return idx[:n] % n
52
+
53
+
54
+ def _percentiles(values: np.ndarray) -> dict[str, float]:
55
+ pct = np.percentile(values, [5, 25, 50, 75, 95])
56
+ keys = ["p5", "p25", "p50", "p75", "p95"]
57
+ return {k: round(float(v), 6) for k, v in zip(keys, pct)}
58
+
59
+
60
+ def run_monte_carlo(
61
+ per_date_net_returns,
62
+ *,
63
+ n_runs: int = 1000,
64
+ block_size: int = 20,
65
+ seed: int = 42,
66
+ method: str = "block",
67
+ n_plot_points: int = 500,
68
+ ) -> dict[str, Any]:
69
+ """Runs the bootstrap and returns a self-describing result dict:
70
+
71
+ {"config": {...},
72
+ "status": "OK" | "SKIPPED: <reason>",
73
+ "avg_curve": [...], # downsampled, red line
74
+ "pct_band": {"p5": [...], "p25": [...], "p75": [...], "p95": [...]},
75
+ "runs_curves": np.ndarray (n_runs x points), # downsampled, for PNG
76
+ "final_return_pct": {...percentiles}, "sharpe": {...}, "max_drawdown": {...},
77
+ "prob_negative_return": float, "best_run_total_return": ..., "worst_run_total_return": ...}
78
+
79
+ Equity curves are compounded from resampled daily returns and start at
80
+ 1.0. The average curve is the element-wise MEAN over all runs (the red
81
+ line), NOT the mean of final values.
82
+ """
83
+ config = {
84
+ "n_runs": int(n_runs),
85
+ "block_size": int(block_size),
86
+ "seed": int(seed),
87
+ "method": str(method),
88
+ "trading_days_per_year": TRADING_DAYS_PER_YEAR,
89
+ }
90
+ empty = {
91
+ "config": config,
92
+ "status": "",
93
+ "avg_curve": [],
94
+ "pct_band": {},
95
+ "final_return_pct": {},
96
+ "sharpe": {},
97
+ "max_drawdown": {},
98
+ "prob_negative_return": None,
99
+ "best_run_total_return": None,
100
+ "worst_run_total_return": None,
101
+ }
102
+ returns = np.asarray(list(per_date_net_returns), dtype=float)
103
+ returns = returns[~np.isnan(returns)]
104
+ if returns.size < 30:
105
+ empty["status"] = f"SKIPPED: need >= 30 daily returns, got {returns.size}"
106
+ return empty
107
+ if n_runs < 1:
108
+ empty["status"] = "SKIPPED: n_runs < 1"
109
+ return empty
110
+ if method not in ("block", "iid"):
111
+ empty["status"] = f"SKIPPED: unknown method {method}"
112
+ return empty
113
+
114
+ n = returns.size
115
+ rng = np.random.default_rng(seed)
116
+
117
+ plot_points = min(n, max(int(n_plot_points), 2))
118
+ plot_idx = np.linspace(0, n - 1, plot_points).astype(int)
119
+
120
+ curves = np.empty((int(n_runs), plot_points), dtype=float)
121
+ final_returns = np.empty(int(n_runs))
122
+ sharpes = np.empty(int(n_runs))
123
+ drawdowns = np.empty(int(n_runs))
124
+
125
+ for run in range(int(n_runs)):
126
+ sampled = returns[_resample_indices(n, block_size, method, rng)]
127
+ equity = np.cumprod(1.0 + sampled)
128
+ sharpes[run] = _annualized_sharpe(sampled)
129
+ drawdowns[run] = _max_drawdown(equity)
130
+ final_returns[run] = equity[-1] - 1.0
131
+ curves[run] = equity[plot_idx]
132
+
133
+ # Normalize every plotted curve to start exactly at 1.0 for readability.
134
+ starts = curves[:, [0]]
135
+ starts[starts == 0] = 1.0
136
+ curves = curves / starts
137
+
138
+ avg_curve = curves.mean(axis=0)
139
+ pct_band = {
140
+ "p5": np.percentile(curves, 5, axis=0),
141
+ "p25": np.percentile(curves, 25, axis=0),
142
+ "p75": np.percentile(curves, 75, axis=0),
143
+ "p95": np.percentile(curves, 95, axis=0),
144
+ }
145
+
146
+ def _round_list(arr: np.ndarray, digits: int = 6) -> list[float]:
147
+ return [round(float(v), digits) for v in arr]
148
+
149
+ return {
150
+ "config": config,
151
+ "status": "OK",
152
+ "avg_curve": _round_list(avg_curve),
153
+ "pct_band": {k: _round_list(v) for k, v in pct_band.items()},
154
+ "runs_curves": curves,
155
+ "final_return_pct": _percentiles(final_returns * 100.0),
156
+ "sharpe": _percentiles(sharpes),
157
+ "max_drawdown": _percentiles(drawdowns * 100.0),
158
+ "prob_negative_return": round(float((final_returns < 0).mean()), 4),
159
+ "best_run_total_return": round(float(final_returns.max()) * 100.0, 4),
160
+ "worst_run_total_return": round(float(final_returns.min()) * 100.0, 4),
161
+ }
@@ -0,0 +1,117 @@
1
+ """V5.4.4 - public benchmark baselines computed from the universe's own
2
+ index/bond proxies (SPY, TLT), so the rank book's edge can be stated
3
+ against well-known public benchmarks - not just against the naive
4
+ strategy baselines in evaluation/benchmark_comparison.py.
5
+
6
+ No external API and no new data dependencies: both functions accept the
7
+ exact same (dates × tickers) close-price pivot that
8
+ benchmark_comparison::momentum_baseline() already consumes - SPY/TLT are
9
+ ordinary columns of `ml/datasets/full_dataset.csv`'s wide close table.
10
+
11
+ - compute_sp500_baseline(): buy-and-hold SPY, close-to-close daily
12
+ returns over the pivot's own window.
13
+ - compute_60_40_baseline(): the classic 60% SPY / 40% TLT portfolio,
14
+ DAILY rebalanced (weights reset to 60/40 every day, so the portfolio
15
+ return is just the fixed-weight blend of the two legs' daily returns -
16
+ no drift compounding of the weights themselves).
17
+
18
+ Pure pandas/numpy, no I/O, no torch. Missing tickers degrade to a
19
+ SKIPPED status (None metrics) instead of raising - the same
20
+ "missing data never changes behavior, never crashes" convention the
21
+ rest of this package follows. Output keys mirror the existing baselines'
22
+ `{"strategy", "net_sharpe", "total_return_pct"}` shape, plus an
23
+ additive `status` field ("OK" / "SKIPPED: <reason>") since - unlike the
24
+ strategy baselines, which work on any universe - these two CAN be
25
+ uncomputable (a universe without SPY, or without TLT for the 60-40).
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import numpy as np
31
+ import pandas as pd
32
+
33
+ SP500_PROXY_TICKER = "SPY"
34
+ BOND_PROXY_TICKER = "TLT"
35
+ SP500_WEIGHT = 0.60
36
+ BOND_WEIGHT = 0.40
37
+
38
+
39
+ def _daily_close_returns(close_pivot: pd.DataFrame, ticker: str) -> pd.Series:
40
+ """Close-to-close daily returns for one ticker, non-finite/zero-close
41
+ rows degraded to NaN and dropped (a 0 close would otherwise produce
42
+ inf via division)."""
43
+ if ticker not in close_pivot.columns:
44
+ return pd.Series(dtype=float)
45
+ closes = pd.to_numeric(close_pivot[ticker], errors="coerce")
46
+ closes = closes.where(closes > 0.0)
47
+ # fill_method=None (never pad): a NaN close must produce NaN returns
48
+ # that drop out below, not silently extend the last real close across
49
+ # a gap (which would fabricate flat 0.0% return days).
50
+ returns = closes.pct_change(fill_method=None)
51
+ return returns.replace([np.inf, -np.inf], np.nan).dropna()
52
+
53
+
54
+ def _sharpe(daily_returns: pd.Series) -> float:
55
+ """Annualized Sharpe of a daily-return series - the identical formula
56
+ every baseline in evaluation/benchmark_comparison.py uses
57
+ (mean / std(ddof=1) * sqrt(252), 0.0 when fewer than 2 observations),
58
+ so benchmark numbers produced by the two modules can never disagree
59
+ on what "Sharpe" means."""
60
+ arr = np.asarray(daily_returns, dtype=float)
61
+ if arr.size < 2:
62
+ return 0.0
63
+ std = float(np.std(arr, ddof=1))
64
+ if std <= 0.0:
65
+ return 0.0
66
+ return float(np.mean(arr) / std * np.sqrt(252.0))
67
+
68
+
69
+ def _result(strategy: str, daily_returns: pd.Series) -> dict:
70
+ sharpe = _sharpe(daily_returns)
71
+ total_return = float(np.prod(1.0 + np.asarray(daily_returns, dtype=float)) - 1.0) if daily_returns.size else 0.0
72
+ return {
73
+ "strategy": strategy,
74
+ "net_sharpe": round(sharpe, 4),
75
+ "total_return_pct": round(total_return * 100.0, 2),
76
+ "status": "OK",
77
+ }
78
+
79
+
80
+ def _skipped(strategy: str, reason: str) -> dict:
81
+ return {
82
+ "strategy": strategy,
83
+ "net_sharpe": None,
84
+ "total_return_pct": None,
85
+ "status": f"SKIPPED: {reason}",
86
+ }
87
+
88
+
89
+ def compute_sp500_baseline(close_pivot: pd.DataFrame) -> dict:
90
+ """Buy-and-hold SPY over close_pivot's own window: close-to-close
91
+ daily returns, annualized Sharpe + cumulative total return. Same
92
+ window, same Sharpe formula as the strategy baselines - the numbers
93
+ are directly comparable by construction."""
94
+ returns = _daily_close_returns(close_pivot, SP500_PROXY_TICKER)
95
+ if returns.empty:
96
+ return _skipped("sp500", f"no usable {SP500_PROXY_TICKER} closes in close_pivot")
97
+ return _result("sp500", returns)
98
+
99
+
100
+ def compute_60_40_baseline(close_pivot: pd.DataFrame) -> dict:
101
+ """60% SPY + 40% TLT, daily rebalanced: each day's portfolio return is
102
+ `0.6 * spy_ret + 0.4 * tlt_ret` (fixed-weight blend, weights reset
103
+ every day - the textbook daily-rebalanced 60-40). Requires BOTH
104
+ proxies present; either one missing skips the whole benchmark rather
105
+ than silently computing a different allocation."""
106
+ spy_returns = _daily_close_returns(close_pivot, SP500_PROXY_TICKER)
107
+ tlt_returns = _daily_close_returns(close_pivot, BOND_PROXY_TICKER)
108
+ if spy_returns.empty or tlt_returns.empty:
109
+ missing = SP500_PROXY_TICKER if spy_returns.empty else BOND_PROXY_TICKER
110
+ return _skipped("60_40", f"no usable {missing} closes in close_pivot")
111
+ aligned = pd.concat(
112
+ {"spy": spy_returns, "tlt": tlt_returns}, axis=1, join="inner"
113
+ ).dropna()
114
+ if aligned.empty:
115
+ return _skipped("60_40", f"no overlapping {SP500_PROXY_TICKER}/{BOND_PROXY_TICKER} return dates")
116
+ portfolio_returns = SP500_WEIGHT * aligned["spy"] + BOND_WEIGHT * aligned["tlt"]
117
+ return _result("60_40", portfolio_returns)