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,178 @@
|
|
|
1
|
+
"""Cross-symbol book neutralization (V5.1 Phase 0/1, item 6 of the V5.1
|
|
2
|
+
roadmap - development/Problems.md).
|
|
3
|
+
|
|
4
|
+
Every existing weight computation in this codebase (risk/position_sizing.py,
|
|
5
|
+
main.py:1723-1726's inline book-role formula) is per-symbol: it never looks
|
|
6
|
+
at what any other symbol in the same bar's book is doing. This module is the
|
|
7
|
+
one deliberate cross-symbol pass, applied once per rebalance bar AFTER every
|
|
8
|
+
symbol's raw book weight is known - it never changes a symbol's role (long/
|
|
9
|
+
short), only rescales magnitudes so the book as a whole is dollar-neutral,
|
|
10
|
+
sector-neutral, and gross-capped.
|
|
11
|
+
|
|
12
|
+
Deliberately its own module, not folded into portfolio/book_construction.py:
|
|
13
|
+
build_rank_based_book() decides WHICH symbols and WHICH side (a per-bar,
|
|
14
|
+
whole-universe decision already); this module decides HOW MUCH, given that
|
|
15
|
+
selection is already final - same separation-of-concerns split
|
|
16
|
+
book_construction.py's own docstring draws between itself and
|
|
17
|
+
risk/position_sizing.py.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _clip(value: float, low: float, high: float) -> float:
|
|
24
|
+
return max(low, min(value, high))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def apply_book_neutrality(
|
|
28
|
+
raw_weights_by_symbol: dict[str, float],
|
|
29
|
+
*,
|
|
30
|
+
sector_by_symbol: dict[str, str],
|
|
31
|
+
dollar_neutral: bool,
|
|
32
|
+
sector_neutral: bool,
|
|
33
|
+
gross_exposure_cap: float,
|
|
34
|
+
max_weight_per_name: float,
|
|
35
|
+
sector_max_net_weight: float,
|
|
36
|
+
) -> tuple[dict[str, float], dict]:
|
|
37
|
+
"""Deterministic 4-step pipeline over an already-selected book's raw
|
|
38
|
+
per-symbol weights (main.py's today-formula:
|
|
39
|
+
min(max_position_weight, 0.10 + 0.15*confidence) * book_role_multiplier,
|
|
40
|
+
one entry per book-selected symbol only - not the whole universe).
|
|
41
|
+
|
|
42
|
+
Steps, always in this order:
|
|
43
|
+
1. Per-name cap: clip every weight to [-max_weight_per_name, +max_weight_per_name].
|
|
44
|
+
2. Sector neutrality (if sector_neutral): within each sector bucket,
|
|
45
|
+
if |bucket net| exceeds sector_max_net_weight, SHRINK the whole
|
|
46
|
+
bucket (every member, proportionally, sign preserved, never
|
|
47
|
+
amplified) so its net lands exactly at the cap. A bucket already
|
|
48
|
+
within the cap is left untouched.
|
|
49
|
+
|
|
50
|
+
Deliberately NOT "demean to exact zero" (the pre-V5.1.3 behavior -
|
|
51
|
+
see Problems.md #81). Demeaning only preserves information when a
|
|
52
|
+
bucket has genuine within-bucket weight dispersion to fall back
|
|
53
|
+
on. Both of this module's callers (main.py's book formula,
|
|
54
|
+
evaluation/rank_book_simulator.py) equal-weight every name within
|
|
55
|
+
a role, so a bucket that is monolithic to one role - e.g. every
|
|
56
|
+
Forex ticker maps to a single "Forex" bucket, and Forex can never
|
|
57
|
+
appear on the opposite leg - has NO within-bucket dispersion:
|
|
58
|
+
demeaning drove every member to exactly zero, silently erasing the
|
|
59
|
+
entire position instead of bounding a genuine sector tilt.
|
|
60
|
+
Shrink-toward-cap degrades gracefully in every case: an already-
|
|
61
|
+
balanced bucket is untouched, a lopsided or monolithic bucket is
|
|
62
|
+
shrunk (never erased) toward the cap, and a single-member bucket
|
|
63
|
+
(net == its own weight) needs no special case - same code path.
|
|
64
|
+
3. Dollar neutrality (if dollar_neutral): scale the LARGER leg down
|
|
65
|
+
so sum(long) == sum(|short|). Scale-down only, never up - this can
|
|
66
|
+
never increase gross exposure. A one-sided book (no shorts, or no
|
|
67
|
+
longs) has nothing to balance against; that step is skipped for
|
|
68
|
+
that bar rather than zeroing the book out, and the skip is
|
|
69
|
+
recorded in diagnostics.
|
|
70
|
+
4. Gross cap: scale everything by min(1, gross_exposure_cap / gross).
|
|
71
|
+
|
|
72
|
+
Returns (weights, diagnostics) where diagnostics is JSON-serializable
|
|
73
|
+
(state.json's state["book_neutrality"]):
|
|
74
|
+
{"pre_gross", "post_gross", "net_before", "net_after",
|
|
75
|
+
"per_sector_net": {sector: net_weight, ...}, "steps_applied": [...]}
|
|
76
|
+
|
|
77
|
+
Never raises. An empty input returns ({}, diagnostics-with-zeros). Pure
|
|
78
|
+
dict-in/dict-out - result does not depend on input dict iteration order
|
|
79
|
+
(every step is a symmetric sum/mean over the group, not a positional
|
|
80
|
+
operation)."""
|
|
81
|
+
steps_applied: list[str] = []
|
|
82
|
+
|
|
83
|
+
if not raw_weights_by_symbol:
|
|
84
|
+
return {}, {
|
|
85
|
+
"pre_gross": 0.0,
|
|
86
|
+
"post_gross": 0.0,
|
|
87
|
+
"net_before": 0.0,
|
|
88
|
+
"net_after": 0.0,
|
|
89
|
+
"per_sector_net": {},
|
|
90
|
+
"steps_applied": ["empty_book_no_op"],
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
net_before = sum(raw_weights_by_symbol.values())
|
|
94
|
+
|
|
95
|
+
# Step 1: per-name cap.
|
|
96
|
+
weights = {
|
|
97
|
+
symbol: _clip(weight, -max_weight_per_name, max_weight_per_name)
|
|
98
|
+
for symbol, weight in raw_weights_by_symbol.items()
|
|
99
|
+
}
|
|
100
|
+
steps_applied.append("per_name_cap")
|
|
101
|
+
|
|
102
|
+
# Step 2: sector neutrality - shrink each bucket's net toward the cap,
|
|
103
|
+
# never demean to exact zero. See the docstring above (Problems.md #81).
|
|
104
|
+
#
|
|
105
|
+
# GUARDRAIL: sector_max_net_weight <= 0.0 is skipped entirely rather
|
|
106
|
+
# than applied - a configured cap of exactly 0.0 would otherwise give
|
|
107
|
+
# shrink_factor = 0.0 / |bucket_net| = 0.0 for every non-flat bucket,
|
|
108
|
+
# scaling every member to nothing. That is precisely the "demean to
|
|
109
|
+
# exact zero" behavior Problems.md #81 fixed (see the docstring above),
|
|
110
|
+
# reintroduced through config instead of code. A non-positive value
|
|
111
|
+
# cannot be honestly enforced by shrink-toward-cap at all (there is no
|
|
112
|
+
# cap to shrink toward), so it is treated the same as sector_neutral
|
|
113
|
+
# being off for this step, not silently honored.
|
|
114
|
+
if sector_neutral and sector_max_net_weight > 0.0:
|
|
115
|
+
symbols_by_sector: dict[str, list[str]] = {}
|
|
116
|
+
for symbol in weights:
|
|
117
|
+
sector = sector_by_symbol.get(symbol, "Unknown")
|
|
118
|
+
symbols_by_sector.setdefault(sector, []).append(symbol)
|
|
119
|
+
|
|
120
|
+
for sector, members in symbols_by_sector.items():
|
|
121
|
+
bucket_net = sum(weights[symbol] for symbol in members)
|
|
122
|
+
if abs(bucket_net) > sector_max_net_weight:
|
|
123
|
+
shrink_factor = sector_max_net_weight / abs(bucket_net)
|
|
124
|
+
for symbol in members:
|
|
125
|
+
weights[symbol] *= shrink_factor
|
|
126
|
+
steps_applied.append("sector_neutral")
|
|
127
|
+
elif sector_neutral:
|
|
128
|
+
steps_applied.append("sector_neutral_skipped_invalid_max_net_weight")
|
|
129
|
+
|
|
130
|
+
# Step 3: dollar neutrality - scale the larger leg down only.
|
|
131
|
+
if dollar_neutral:
|
|
132
|
+
long_sum = sum(weight for weight in weights.values() if weight > 0.0)
|
|
133
|
+
short_sum = sum(-weight for weight in weights.values() if weight < 0.0)
|
|
134
|
+
if long_sum > 0.0 and short_sum > 0.0:
|
|
135
|
+
if long_sum > short_sum:
|
|
136
|
+
factor = short_sum / long_sum
|
|
137
|
+
weights = {
|
|
138
|
+
symbol: (weight * factor if weight > 0.0 else weight) for symbol, weight in weights.items()
|
|
139
|
+
}
|
|
140
|
+
elif short_sum > long_sum:
|
|
141
|
+
factor = long_sum / short_sum
|
|
142
|
+
weights = {
|
|
143
|
+
symbol: (weight * factor if weight < 0.0 else weight) for symbol, weight in weights.items()
|
|
144
|
+
}
|
|
145
|
+
steps_applied.append("dollar_neutral")
|
|
146
|
+
else:
|
|
147
|
+
steps_applied.append("dollar_neutral_skipped_one_sided_book")
|
|
148
|
+
|
|
149
|
+
# Step 4: gross exposure cap. A non-positive cap is a misconfiguration -
|
|
150
|
+
# V5.4.5 (#117 family): the old `gross > cap` comparison happily scaled
|
|
151
|
+
# the whole book by 0/cap == 0 when cap <= 0, silently erasing it
|
|
152
|
+
# (the same #81-class failure the sector step already guarded against).
|
|
153
|
+
# Skip with a diagnostic instead.
|
|
154
|
+
gross = sum(abs(weight) for weight in weights.values())
|
|
155
|
+
if gross_exposure_cap > 0.0 and gross > gross_exposure_cap:
|
|
156
|
+
factor = gross_exposure_cap / gross
|
|
157
|
+
weights = {symbol: weight * factor for symbol, weight in weights.items()}
|
|
158
|
+
steps_applied.append("gross_cap")
|
|
159
|
+
elif gross_exposure_cap <= 0.0:
|
|
160
|
+
steps_applied.append("gross_cap_skipped_nonpositive_cap")
|
|
161
|
+
|
|
162
|
+
per_sector_net: dict[str, float] = {}
|
|
163
|
+
for symbol, weight in weights.items():
|
|
164
|
+
sector = sector_by_symbol.get(symbol, "Unknown")
|
|
165
|
+
per_sector_net[sector] = per_sector_net.get(sector, 0.0) + weight
|
|
166
|
+
|
|
167
|
+
post_gross = sum(abs(weight) for weight in weights.values())
|
|
168
|
+
net_after = sum(weights.values())
|
|
169
|
+
|
|
170
|
+
diagnostics = {
|
|
171
|
+
"pre_gross": sum(abs(weight) for weight in raw_weights_by_symbol.values()),
|
|
172
|
+
"post_gross": post_gross,
|
|
173
|
+
"net_before": net_before,
|
|
174
|
+
"net_after": net_after,
|
|
175
|
+
"per_sector_net": per_sector_net,
|
|
176
|
+
"steps_applied": steps_applied,
|
|
177
|
+
}
|
|
178
|
+
return weights, diagnostics
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""V5.4.3 - Hierarchical Risk Parity (HRP) allocation.
|
|
2
|
+
|
|
3
|
+
Implements López de Prado (2016)'s HRP algorithm as an alternative to the
|
|
4
|
+
current equal-rank long/short allocation. Builds a correlation-distance
|
|
5
|
+
dendrogram via scipy's linkage, then allocates by inverse-variance within
|
|
6
|
+
each cluster using a top-down recursive bisection.
|
|
7
|
+
|
|
8
|
+
Config-gated: `phase_v2.portfolio_book.allocation_method: "hrp"` (default
|
|
9
|
+
`"rank"` = byte-identical equal-rank behavior). Pure numpy/scipy — no I/O,
|
|
10
|
+
no torch."""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
import pandas as pd
|
|
16
|
+
from scipy.cluster.hierarchy import leaves_list, linkage
|
|
17
|
+
from scipy.spatial.distance import squareform
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def compute_hrp_weights(returns: pd.DataFrame) -> pd.Series:
|
|
21
|
+
"""Computes HRP weights from a (dates × tickers) returns DataFrame.
|
|
22
|
+
|
|
23
|
+
Steps:
|
|
24
|
+
1. Correlation distance matrix: d = sqrt(0.5 * (1 - corr))
|
|
25
|
+
2. Hierarchical clustering (Ward linkage on condensed distance)
|
|
26
|
+
3. Quasi-diagonal ordering via dendrogram leaf order
|
|
27
|
+
4. Top-down recursive bisection: split each cluster by inverse-variance
|
|
28
|
+
|
|
29
|
+
Returns per-ticker weights summing to 1.0."""
|
|
30
|
+
tickers = returns.columns.tolist()
|
|
31
|
+
corr = returns.corr().fillna(0.0)
|
|
32
|
+
n = len(tickers)
|
|
33
|
+
if n < 2:
|
|
34
|
+
return pd.Series({t: 1.0 / max(n, 1) for t in tickers})
|
|
35
|
+
|
|
36
|
+
# Correlation distance
|
|
37
|
+
dist = np.sqrt(np.clip(0.5 * (1.0 - corr.values), 0.0, None))
|
|
38
|
+
np.fill_diagonal(dist, 0.0)
|
|
39
|
+
condensed = squareform(dist, checks=False)
|
|
40
|
+
|
|
41
|
+
# Ward linkage
|
|
42
|
+
link = linkage(condensed, method="ward")
|
|
43
|
+
|
|
44
|
+
# Quasi-diagonal ordering (dendrogram leaf order)
|
|
45
|
+
ordered = leaves_list(link).tolist()
|
|
46
|
+
|
|
47
|
+
# Recursive bisection
|
|
48
|
+
weights = pd.Series(1.0, index=[tickers[i] for i in ordered])
|
|
49
|
+
cluster_items = [ordered]
|
|
50
|
+
|
|
51
|
+
while len(cluster_items) > 0:
|
|
52
|
+
next_clusters = []
|
|
53
|
+
for items in cluster_items:
|
|
54
|
+
if len(items) <= 1:
|
|
55
|
+
continue
|
|
56
|
+
half = len(items) // 2
|
|
57
|
+
left, right = items[:half], items[half:]
|
|
58
|
+
|
|
59
|
+
left_var = _cluster_var(returns, [tickers[i] for i in left])
|
|
60
|
+
right_var = _cluster_var(returns, [tickers[i] for i in right])
|
|
61
|
+
total = left_var + right_var
|
|
62
|
+
if total <= 0:
|
|
63
|
+
split = 0.5
|
|
64
|
+
else:
|
|
65
|
+
split = 1.0 - left_var / total
|
|
66
|
+
|
|
67
|
+
for item in left:
|
|
68
|
+
weights[tickers[item]] *= split
|
|
69
|
+
for item in right:
|
|
70
|
+
weights[tickers[item]] *= (1.0 - split)
|
|
71
|
+
|
|
72
|
+
next_clusters.extend([left, right])
|
|
73
|
+
cluster_items = next_clusters
|
|
74
|
+
|
|
75
|
+
return weights / weights.sum()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _cluster_var(returns: pd.DataFrame, tickers_subset: list[str]) -> float:
|
|
79
|
+
"""Inverse-variance of a cluster = variance of the cluster's own
|
|
80
|
+
inverse-variance-weighted portfolio."""
|
|
81
|
+
sub = returns[tickers_subset]
|
|
82
|
+
ivp = 1.0 / np.diag(sub.cov().values)
|
|
83
|
+
ivp /= ivp.sum()
|
|
84
|
+
w = pd.Series(ivp, index=tickers_subset)
|
|
85
|
+
return float(w @ sub.cov().values @ w)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def build_hrp_allocation(
|
|
89
|
+
returns_by_ticker: dict[str, list[float]],
|
|
90
|
+
*,
|
|
91
|
+
selected_tickers_long: list[str],
|
|
92
|
+
selected_tickers_short: list[str],
|
|
93
|
+
gross_exposure: float = 1.0,
|
|
94
|
+
) -> dict[str, float]:
|
|
95
|
+
"""Builds an HRP-weighted allocation dict for the current book's long and
|
|
96
|
+
short legs. With BOTH legs populated, longs get positive HRP weights ×
|
|
97
|
+
gross/2 and shorts negative × gross/2 (dollar-neutral book). A
|
|
98
|
+
SINGLE-SIDED book (one leg empty - e.g. the long-only book
|
|
99
|
+
`phase5.backtest.strategy_mode: "long_flat"` produces, the shipped
|
|
100
|
+
default) gives the present leg the FULL gross - V5.4.4 fix: the
|
|
101
|
+
original unconditional gross/2 split silently halved a long-only
|
|
102
|
+
book's exposure. Falls back to equal weight within each leg when HRP
|
|
103
|
+
can't converge."""
|
|
104
|
+
all_selected = selected_tickers_long + selected_tickers_short
|
|
105
|
+
if not all_selected:
|
|
106
|
+
return {}
|
|
107
|
+
|
|
108
|
+
# V5.4.4's single-sided/two-sided gross split, shared by the HRP path
|
|
109
|
+
# below and both fallbacks here so all three can never disagree.
|
|
110
|
+
long_weight_total = gross_exposure / 2.0 if selected_tickers_short else gross_exposure
|
|
111
|
+
short_weight_total = -(gross_exposure / 2.0 if selected_tickers_long else gross_exposure)
|
|
112
|
+
|
|
113
|
+
def _equal_weight_fallback() -> dict[str, float]:
|
|
114
|
+
# Equal weight WITHIN EACH LEG - each leg sums exactly to its own
|
|
115
|
+
# leg total above. V5.4.5 (development/Problems.md #118): the
|
|
116
|
+
# original fallback divided gross across BOTH legs combined
|
|
117
|
+
# (`gross / len(longs + shorts)`), so an asymmetric two-sided book
|
|
118
|
+
# (e.g. 6 longs / 2 shorts) came out with net exposure
|
|
119
|
+
# +/-0.5*gross instead of dollar-neutral.
|
|
120
|
+
result = {
|
|
121
|
+
ticker: long_weight_total / max(len(selected_tickers_long), 1)
|
|
122
|
+
for ticker in selected_tickers_long
|
|
123
|
+
}
|
|
124
|
+
for ticker in selected_tickers_short:
|
|
125
|
+
result[ticker] = short_weight_total / max(len(selected_tickers_short), 1)
|
|
126
|
+
return result
|
|
127
|
+
|
|
128
|
+
returns_df = pd.DataFrame({
|
|
129
|
+
ticker: pd.Series(returns_by_ticker[ticker])
|
|
130
|
+
for ticker in all_selected
|
|
131
|
+
if ticker in returns_by_ticker and len(returns_by_ticker[ticker]) >= 2
|
|
132
|
+
})
|
|
133
|
+
if returns_df.shape[1] < 2 or returns_df.empty:
|
|
134
|
+
return _equal_weight_fallback()
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
hrp_all = compute_hrp_weights(returns_df)
|
|
138
|
+
except Exception:
|
|
139
|
+
return _equal_weight_fallback()
|
|
140
|
+
|
|
141
|
+
long_hrp_sum = sum(hrp_all.get(t, 0.0) for t in selected_tickers_long)
|
|
142
|
+
short_hrp_sum = sum(hrp_all.get(t, 0.0) for t in selected_tickers_short)
|
|
143
|
+
|
|
144
|
+
result = {}
|
|
145
|
+
if long_hrp_sum > 0:
|
|
146
|
+
for t in selected_tickers_long:
|
|
147
|
+
result[t] = hrp_all.get(t, 0.0) / long_hrp_sum * long_weight_total
|
|
148
|
+
else:
|
|
149
|
+
for t in selected_tickers_long:
|
|
150
|
+
result[t] = long_weight_total / max(len(selected_tickers_long), 1)
|
|
151
|
+
|
|
152
|
+
if short_hrp_sum > 0:
|
|
153
|
+
for t in selected_tickers_short:
|
|
154
|
+
result[t] = -hrp_all.get(t, 0.0) / short_hrp_sum * abs(short_weight_total)
|
|
155
|
+
else:
|
|
156
|
+
for t in selected_tickers_short:
|
|
157
|
+
result[t] = short_weight_total / max(len(selected_tickers_short), 1)
|
|
158
|
+
|
|
159
|
+
return result
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Mispricing detector for the 6 stubbed arbitrage strategies (box_spread,
|
|
2
|
+
short_box_spread, conversion, reverse_conversion, jelly_roll,
|
|
3
|
+
short_jelly_roll) - V4.6 (development/Problems.md #59/Roadmap).
|
|
4
|
+
|
|
5
|
+
These 6 strategies have had full registry entries, leg selectors, and
|
|
6
|
+
unit tests since V4.5, but were never invoked from the live signal path
|
|
7
|
+
by design: driving them correctly requires comparing an observed
|
|
8
|
+
structure's market price against its risk-free-rate-implied FAIR value
|
|
9
|
+
(a genuine arbitrage/mispricing signal), not a directional or volatility
|
|
10
|
+
view like every other strategy family in this codebase. This module is
|
|
11
|
+
that signal source.
|
|
12
|
+
|
|
13
|
+
Off by default (phase_v2.options_risk.arbitrage_detector.enabled) - an
|
|
14
|
+
explicit, SEPARATE opt-in from enabling these 6 names in
|
|
15
|
+
enabled_strategy_names, matching every other new-capability gate in this
|
|
16
|
+
project's history (ship available, default off, opt in per-item).
|
|
17
|
+
|
|
18
|
+
Every fair-value formula here is the standard, textbook closed-form
|
|
19
|
+
result for its structure - not a fitted/calibrated model - since the
|
|
20
|
+
whole premise of arbitrage detection is comparing an OBSERVED price
|
|
21
|
+
against a THEORETICALLY-derived one, not a statistical estimate. Ignores
|
|
22
|
+
dividends (dividend_yield=0.0), matching features/options_greeks.py's
|
|
23
|
+
own existing convention for this codebase.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import math
|
|
29
|
+
from datetime import date
|
|
30
|
+
|
|
31
|
+
from portfolio.options_strategy import (
|
|
32
|
+
MULTI_LEG_STRATEGY_REGISTRY,
|
|
33
|
+
OptionsMultiLegPositionDecision,
|
|
34
|
+
_expiries_tuple_from_roles,
|
|
35
|
+
_legs_tuple_from_roles,
|
|
36
|
+
_net_vega_and_delta_per_unit,
|
|
37
|
+
select_strategy_legs,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def box_spread_fair_value(
|
|
42
|
+
higher_strike: float, lower_strike: float, risk_free_rate: float, time_to_expiry_years: float,
|
|
43
|
+
) -> float:
|
|
44
|
+
"""A box spread (long put + short call at the higher strike, short
|
|
45
|
+
put + long call at the lower strike, same expiry) synthesizes a
|
|
46
|
+
risk-free zero-coupon bond paying (higher_strike - lower_strike) at
|
|
47
|
+
expiry - its fair value today is that payoff discounted at the
|
|
48
|
+
risk-free rate. Standard textbook result."""
|
|
49
|
+
return (higher_strike - lower_strike) * math.exp(-risk_free_rate * max(time_to_expiry_years, 0.0))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def conversion_parity_value(
|
|
53
|
+
call_price: float, put_price: float, spot: float, strike: float, risk_free_rate: float, time_to_expiry_years: float,
|
|
54
|
+
) -> float:
|
|
55
|
+
"""Put-call parity: call_price - put_price should equal
|
|
56
|
+
spot - strike * exp(-r*T) (ignoring dividends). Returns the SIGNED
|
|
57
|
+
discrepancy (actual minus theoretical) - a conversion (long stock,
|
|
58
|
+
long put, short call) profits when this is negative (options priced
|
|
59
|
+
cheap relative to parity); a reverse conversion profits when
|
|
60
|
+
positive."""
|
|
61
|
+
theoretical = spot - strike * math.exp(-risk_free_rate * max(time_to_expiry_years, 0.0))
|
|
62
|
+
actual = call_price - put_price
|
|
63
|
+
return actual - theoretical
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def jelly_roll_fair_value(
|
|
67
|
+
strike: float, risk_free_rate: float, near_time_to_expiry_years: float, far_time_to_expiry_years: float,
|
|
68
|
+
) -> float:
|
|
69
|
+
"""The interest-rate-implied fair cost of a jelly roll (rolling a
|
|
70
|
+
synthetic position from the near expiry to the far one) - the
|
|
71
|
+
discounted strike difference between the two expiries, the standard
|
|
72
|
+
cost-of-carry result for extending a synthetic forward's expiry."""
|
|
73
|
+
near_discount = math.exp(-risk_free_rate * max(near_time_to_expiry_years, 0.0))
|
|
74
|
+
far_discount = math.exp(-risk_free_rate * max(far_time_to_expiry_years, 0.0))
|
|
75
|
+
return strike * (near_discount - far_discount)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def detect_mispricing(actual_net_cost: float, fair_value: float, min_mispricing_bps: float, notional: float) -> bool:
|
|
79
|
+
"""Shared threshold predicate for all 3 families above: True when the
|
|
80
|
+
discrepancy between actual_net_cost and fair_value exceeds
|
|
81
|
+
min_mispricing_bps of notional - requiring a MINIMUM edge (not a bare
|
|
82
|
+
non-zero difference) accounts for real bid/ask spread and transaction
|
|
83
|
+
costs a genuine arbitrage must clear to be worth trading. Returns
|
|
84
|
+
False (never trades, never raises) when notional is non-positive."""
|
|
85
|
+
if notional <= 0.0:
|
|
86
|
+
return False
|
|
87
|
+
mispricing_bps = abs(actual_net_cost - fair_value) / notional * 10_000.0
|
|
88
|
+
return mispricing_bps >= min_mispricing_bps
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _years_between(current_date, expiry_date_str: str) -> float:
|
|
92
|
+
"""ISO-date-string expiry minus current_date, in years (365-day
|
|
93
|
+
convention, matching main.py::_build_options_chains_payload()'s own
|
|
94
|
+
time_to_expiry_years computation for greeks). Returns 0.0 (never
|
|
95
|
+
negative, never raises) for an unparseable/past expiry."""
|
|
96
|
+
try:
|
|
97
|
+
expiry_date = date.fromisoformat(expiry_date_str)
|
|
98
|
+
except (TypeError, ValueError):
|
|
99
|
+
return 0.0
|
|
100
|
+
return max((expiry_date - current_date).days, 0) / 365.0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _actual_net_cost(spec, legs_by_role: dict) -> float:
|
|
104
|
+
"""Signed cost to enter the FULL spread unit at current market
|
|
105
|
+
bid/ask: pay ask for every LONG leg, receive bid for every SHORT leg
|
|
106
|
+
- read from each leg's own `.side` (registry-driven), never hardcoded
|
|
107
|
+
per strategy_name, since box_spread/short_box_spread and jelly_roll/
|
|
108
|
+
short_jelly_roll invert which role is long vs. short while keeping
|
|
109
|
+
the SAME role names (see MULTI_LEG_STRATEGY_REGISTRY's own docstring)
|
|
110
|
+
- the exact bug this generic formula avoids versus hand-writing one
|
|
111
|
+
signed formula per strategy_name."""
|
|
112
|
+
net_cost = 0.0
|
|
113
|
+
for leg_spec in spec.legs:
|
|
114
|
+
row = legs_by_role[leg_spec.role]
|
|
115
|
+
price = float(row["ask"]) if leg_spec.side == "long" else float(row["bid"])
|
|
116
|
+
sign = 1.0 if leg_spec.side == "long" else -1.0
|
|
117
|
+
net_cost += sign * price
|
|
118
|
+
return net_cost
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def select_arbitrage_signal(
|
|
122
|
+
strategy_name: str,
|
|
123
|
+
available_chain: list[dict],
|
|
124
|
+
current_date,
|
|
125
|
+
risk_free_rate: float,
|
|
126
|
+
min_mispricing_bps: float,
|
|
127
|
+
target_delta: float = 0.5,
|
|
128
|
+
) -> bool:
|
|
129
|
+
"""Composes the existing V4.5 arbitrage leg selectors
|
|
130
|
+
(select_strategy_legs(), already registry-driven) with the fair-value/
|
|
131
|
+
threshold functions above: True iff THIS bar's chain shows an
|
|
132
|
+
exploitable mispricing for strategy_name, using each leg's own
|
|
133
|
+
bid/ask and days-to-expiry. Strike/expiry ROLES (which role is
|
|
134
|
+
"higher"/"lower"/"near"/"far") are resolved from
|
|
135
|
+
MULTI_LEG_STRATEGY_REGISTRY, never hardcoded per strategy_name - the
|
|
136
|
+
short_* variants invert which role holds which strike/side while
|
|
137
|
+
keeping the same role NAMES. Returns False (never a crash) on any
|
|
138
|
+
missing selection/pricing/date data, or for any strategy_name outside
|
|
139
|
+
the 6 arbitrage families - degrade-to-absent, matching every other
|
|
140
|
+
selector's convention in this module's sibling file."""
|
|
141
|
+
spec = MULTI_LEG_STRATEGY_REGISTRY.get(strategy_name)
|
|
142
|
+
if spec is None or current_date is None:
|
|
143
|
+
return False
|
|
144
|
+
legs_by_role = select_strategy_legs(strategy_name, available_chain, target_delta)
|
|
145
|
+
if legs_by_role is None:
|
|
146
|
+
return False
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
if strategy_name in ("box_spread", "short_box_spread"):
|
|
150
|
+
higher_role = next(leg.role for leg in spec.legs if leg.strike_role == "higher")
|
|
151
|
+
lower_role = next(leg.role for leg in spec.legs if leg.strike_role == "lower")
|
|
152
|
+
higher_row = legs_by_role[higher_role]
|
|
153
|
+
lower_row = legs_by_role[lower_role]
|
|
154
|
+
higher_strike = float(higher_row["strike"])
|
|
155
|
+
lower_strike = float(lower_row["strike"])
|
|
156
|
+
time_to_expiry_years = _years_between(current_date, higher_row["expiry"])
|
|
157
|
+
fair_value = box_spread_fair_value(higher_strike, lower_strike, risk_free_rate, time_to_expiry_years)
|
|
158
|
+
actual_net_cost = _actual_net_cost(spec, legs_by_role)
|
|
159
|
+
return detect_mispricing(actual_net_cost, fair_value, min_mispricing_bps, higher_strike - lower_strike)
|
|
160
|
+
|
|
161
|
+
if strategy_name in ("conversion", "reverse_conversion"):
|
|
162
|
+
call_role = next(leg.role for leg in spec.legs if leg.right == "call")
|
|
163
|
+
put_role = next(leg.role for leg in spec.legs if leg.right == "put")
|
|
164
|
+
call_row = legs_by_role[call_role]
|
|
165
|
+
put_row = legs_by_role[put_role]
|
|
166
|
+
strike = float(call_row["strike"])
|
|
167
|
+
spot = float(call_row.get("underlying_price") or strike)
|
|
168
|
+
time_to_expiry_years = _years_between(current_date, call_row["expiry"])
|
|
169
|
+
call_mid = (float(call_row["bid"]) + float(call_row["ask"])) / 2.0
|
|
170
|
+
put_mid = (float(put_row["bid"]) + float(put_row["ask"])) / 2.0
|
|
171
|
+
discrepancy = conversion_parity_value(call_mid, put_mid, spot, strike, risk_free_rate, time_to_expiry_years)
|
|
172
|
+
return detect_mispricing(discrepancy, 0.0, min_mispricing_bps, strike)
|
|
173
|
+
|
|
174
|
+
if strategy_name in ("jelly_roll", "short_jelly_roll"):
|
|
175
|
+
near_call_role = next(leg.role for leg in spec.legs if leg.right == "call" and leg.expiry_role == "near")
|
|
176
|
+
far_call_role = next(leg.role for leg in spec.legs if leg.right == "call" and leg.expiry_role == "far")
|
|
177
|
+
near_row = legs_by_role[near_call_role]
|
|
178
|
+
far_row = legs_by_role[far_call_role]
|
|
179
|
+
strike = float(near_row["strike"])
|
|
180
|
+
near_time_to_expiry_years = _years_between(current_date, near_row["expiry"])
|
|
181
|
+
far_time_to_expiry_years = _years_between(current_date, far_row["expiry"])
|
|
182
|
+
fair_value = jelly_roll_fair_value(strike, risk_free_rate, near_time_to_expiry_years, far_time_to_expiry_years)
|
|
183
|
+
actual_net_cost = _actual_net_cost(spec, legs_by_role)
|
|
184
|
+
return detect_mispricing(actual_net_cost, fair_value, min_mispricing_bps, strike)
|
|
185
|
+
except (KeyError, TypeError, ValueError):
|
|
186
|
+
return False
|
|
187
|
+
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def build_arbitrage_position_sizing(
|
|
192
|
+
strategy_name: str,
|
|
193
|
+
available_chain: list[dict],
|
|
194
|
+
current_date,
|
|
195
|
+
risk_free_rate: float,
|
|
196
|
+
min_mispricing_bps: float,
|
|
197
|
+
max_contracts_per_signal: int = 1,
|
|
198
|
+
target_delta: float = 0.5,
|
|
199
|
+
) -> OptionsMultiLegPositionDecision | None:
|
|
200
|
+
"""The ONLY sizing path for the 6 arbitrage strategies - deliberately
|
|
201
|
+
NOT vega-budget or margin-tier sized (an arbitrage structure is
|
|
202
|
+
delta/vega-neutral by construction, so a vega budget has nothing
|
|
203
|
+
meaningful to scale against). Sized instead to a small, FIXED
|
|
204
|
+
`max_contracts_per_signal` (default 1) whenever
|
|
205
|
+
select_arbitrage_signal() confirms a real mispricing this bar - a
|
|
206
|
+
deliberately conservative first-cut sizing model for a genuinely new
|
|
207
|
+
capability, not a risk-budgeted one; a real margin-aware arbitrage
|
|
208
|
+
sizer is a reasonable future follow-up once this is IB-verified.
|
|
209
|
+
Returns None when no mispricing is detected, selection fails, or
|
|
210
|
+
max_contracts_per_signal isn't positive - never raises."""
|
|
211
|
+
if max_contracts_per_signal <= 0:
|
|
212
|
+
return None
|
|
213
|
+
if not select_arbitrage_signal(strategy_name, available_chain, current_date, risk_free_rate, min_mispricing_bps, target_delta):
|
|
214
|
+
return None
|
|
215
|
+
spec = MULTI_LEG_STRATEGY_REGISTRY.get(strategy_name)
|
|
216
|
+
if spec is None:
|
|
217
|
+
return None
|
|
218
|
+
legs_by_role = select_strategy_legs(strategy_name, available_chain, target_delta)
|
|
219
|
+
if legs_by_role is None:
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
net_vega, net_delta = _net_vega_and_delta_per_unit(spec, legs_by_role)
|
|
223
|
+
return OptionsMultiLegPositionDecision(
|
|
224
|
+
strategy_name=strategy_name,
|
|
225
|
+
legs=_legs_tuple_from_roles(spec, legs_by_role),
|
|
226
|
+
expiries=_expiries_tuple_from_roles(spec, legs_by_role),
|
|
227
|
+
contracts=int(max_contracts_per_signal),
|
|
228
|
+
net_debit_or_credit=_actual_net_cost(spec, legs_by_role),
|
|
229
|
+
net_delta=net_delta,
|
|
230
|
+
net_vega=net_vega,
|
|
231
|
+
sizing_reason="arbitrage_mispricing_fixed_size_sizing",
|
|
232
|
+
)
|