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