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,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)
|