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,72 @@
1
+ """Shared, pure futures-term-structure / options-sentiment "macro" feature
2
+ implementations - additive third sibling to features/macro_features.py and
3
+ features/bond_features.py, broadcast identically to EVERY asset's model
4
+ input (not just futures/options assets themselves).
5
+
6
+ This is the concrete mechanism satisfying the "the model's cross-asset
7
+ macro features should include options/futures/bond-derived signals so any
8
+ asset's prediction can be informed by them" requirement - generalized well
9
+ beyond any single hardcoded pair (e.g. "predict SPY from QQQ options" was
10
+ only ever an illustrative example, not a special case to hardcode).
11
+
12
+ Every function neutral-defaults to 0.0 on missing input - never raises -
13
+ same convention as features/macro_features.py. Since real futures
14
+ continuous-contract prices and options chain volumes only exist once IB is
15
+ connected (phase_v2.ib.enabled), these features are 0.0 for every asset,
16
+ every date, until then - a correctness property, not a bug: "no derivatives
17
+ data configured" must be indistinguishable in the model input from "the
18
+ signal happens to be neutral today," matching every other missing-
19
+ reference-ticker case in this codebase.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ FUTURES_TERM_STRUCTURE_SLOPE_NEUTRAL = 0.0
25
+ OPTIONS_PUT_CALL_RATIO_NEUTRAL = 0.0
26
+ OPTIONS_IMPLIED_VOL_SKEW_NEUTRAL = 0.0
27
+
28
+ FUTURES_MACRO_FEATURE_NAMES = ["futures_term_structure_slope"]
29
+ OPTIONS_MACRO_FEATURE_NAMES = ["options_put_call_ratio", "options_implied_vol_skew"]
30
+ DERIVATIVES_MACRO_FEATURE_NAMES = FUTURES_MACRO_FEATURE_NAMES + OPTIONS_MACRO_FEATURE_NAMES
31
+
32
+
33
+ def futures_term_structure_slope(front_month_price: float | None, next_month_price: float | None) -> float:
34
+ """(next_month - front_month) / front_month - positive = contango
35
+ (next-month more expensive, typical/"normal" market), negative =
36
+ backwardation (front-month more expensive, often a supply-stress or
37
+ high-carry-cost signal). Returns
38
+ FUTURES_TERM_STRUCTURE_SLOPE_NEUTRAL (0.0) when either input is
39
+ missing or front_month_price is zero."""
40
+ if front_month_price is None or next_month_price is None or front_month_price == 0:
41
+ return FUTURES_TERM_STRUCTURE_SLOPE_NEUTRAL
42
+ return (next_month_price - front_month_price) / front_month_price
43
+
44
+
45
+ def options_put_call_ratio(put_volume: float | None, call_volume: float | None) -> float:
46
+ """A bounded [-1, 1] put/call VOLUME skew - (put - call) / (put +
47
+ call), not the raw put/call ratio - chosen specifically so 0.0 is a
48
+ genuinely meaningful neutral value (balanced put/call activity OR no
49
+ chain data available, both correctly represented as "no signal"),
50
+ unlike a raw ratio where 0.0 would ambiguously also mean "zero puts,
51
+ some calls" (a real, non-neutral market state). Positive = put-heavy
52
+ (bearish/hedging-heavy sentiment), negative = call-heavy (bullish
53
+ sentiment). Returns OPTIONS_PUT_CALL_RATIO_NEUTRAL (0.0) when either
54
+ input is missing or both volumes are zero."""
55
+ if put_volume is None or call_volume is None:
56
+ return OPTIONS_PUT_CALL_RATIO_NEUTRAL
57
+ total_volume = put_volume + call_volume
58
+ if total_volume <= 0:
59
+ return OPTIONS_PUT_CALL_RATIO_NEUTRAL
60
+ return (put_volume - call_volume) / total_volume
61
+
62
+
63
+ def options_implied_vol_skew(otm_put_iv: float | None, otm_call_iv: float | None) -> float:
64
+ """OTM put implied vol minus OTM call implied vol (both at a matched
65
+ delta, e.g. 25-delta each, per features/options_greeks.py's
66
+ implied_volatility()) - the standard equity-index "smirk" measure;
67
+ positive (the typical case) means downside protection carries a
68
+ volatility premium over upside calls. Returns
69
+ OPTIONS_IMPLIED_VOL_SKEW_NEUTRAL (0.0) when either input is missing."""
70
+ if otm_put_iv is None or otm_call_iv is None:
71
+ return OPTIONS_IMPLIED_VOL_SKEW_NEUTRAL
72
+ return otm_put_iv - otm_call_iv
@@ -0,0 +1,79 @@
1
+ """Shared, pure cross-asset "macro" feature implementations (Phase 1b of
2
+ the 5/10 -> 9/10 roadmap, see development/Changelog.md) - deliberate,
3
+ explicit signals computed once per bar/date from a small set of fixed
4
+ reference tickers (the Phase 1a bond ETF sleeve + the existing crypto
5
+ sleeve) and broadcast identically to every asset's model input that
6
+ bar/date, mirroring how regime state is already a shared, once-per-bar
7
+ feature.
8
+
9
+ This is distinct from - and additive to - the existing generic
10
+ correlation-based peer mechanism (topology/market_topology.py's
11
+ top_peers/top_peer_returns): that mechanism already lets a bond or crypto
12
+ asset surface as an equity's correlated peer purely incidentally, with no
13
+ code change needed. These functions instead compute a small, fixed,
14
+ always-present macro state (yield-curve slope, credit spread, crypto risk
15
+ appetite) that every asset sees identically regardless of whether it
16
+ happens to correlate with any one specific reference ticker that day.
17
+
18
+ Imported by BOTH train.py (offline, per-date lookup across the whole
19
+ universe) and main.py (runtime, once per bar) for parity by construction -
20
+ same convention as features/technical_indicators.py.
21
+
22
+ Each function takes plain optional-float 20-day-momentum inputs (reusing
23
+ the existing momentum_20d column/computation already produced by
24
+ train.py::engineer_features()/main.py's topology-payload step, rather than
25
+ re-deriving returns from scratch) and returns a single scalar float. A
26
+ missing/unavailable reference ticker (still warming up, or absent from
27
+ this particular universe configuration) neutral-defaults its proxy to 0.0
28
+ - never raises - same "missing peer -> 0.0" convention peer-return
29
+ features already established.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ YIELD_CURVE_SLOPE_NEUTRAL = 0.0
35
+ CREDIT_SPREAD_NEUTRAL = 0.0
36
+ CRYPTO_RISK_APPETITE_NEUTRAL = 0.0
37
+
38
+
39
+ def yield_curve_slope_proxy(
40
+ long_duration_momentum_20d: float | None,
41
+ short_duration_momentum_20d: float | None,
42
+ ) -> float:
43
+ """Relative 20-day momentum spread between a long-duration Treasury
44
+ ETF (e.g. TLT) and a short-duration one (e.g. SHY) - a proxy for
45
+ curve-steepening (positive: long duration outperforming, typically
46
+ falling long yields relative to short) vs. flattening/inverting
47
+ (negative) sentiment, computable from price data alone with no
48
+ separate yield-level data feed. Returns YIELD_CURVE_SLOPE_NEUTRAL (0.0)
49
+ when either input is unavailable (reference ticker missing or still
50
+ warming up)."""
51
+ if long_duration_momentum_20d is None or short_duration_momentum_20d is None:
52
+ return YIELD_CURVE_SLOPE_NEUTRAL
53
+ return long_duration_momentum_20d - short_duration_momentum_20d
54
+
55
+
56
+ def credit_spread_proxy(
57
+ high_yield_momentum_20d: float | None,
58
+ investment_grade_momentum_20d: float | None,
59
+ ) -> float:
60
+ """Relative 20-day momentum spread between a high-yield corporate bond
61
+ ETF (e.g. HYG) and an investment-grade one (e.g. LQD) - widens
62
+ (negative: high-yield underperforming investment-grade) when credit
63
+ risk appetite deteriorates, a standard risk-off signal. Returns
64
+ CREDIT_SPREAD_NEUTRAL (0.0) when either input is unavailable."""
65
+ if high_yield_momentum_20d is None or investment_grade_momentum_20d is None:
66
+ return CREDIT_SPREAD_NEUTRAL
67
+ return high_yield_momentum_20d - investment_grade_momentum_20d
68
+
69
+
70
+ def crypto_risk_appetite_proxy(crypto_momentum_20d: float | None) -> float:
71
+ """20-day momentum of a liquid crypto reference asset (e.g. BTCUSD) -
72
+ a fast-moving risk-on/risk-off signal distinct from the slower-moving
73
+ bond-based proxies above, given crypto's comparatively higher
74
+ volatility and shorter mean-reversion cycles. Returns
75
+ CRYPTO_RISK_APPETITE_NEUTRAL (0.0) when the reference asset's momentum
76
+ is unavailable."""
77
+ if crypto_momentum_20d is None:
78
+ return CRYPTO_RISK_APPETITE_NEUTRAL
79
+ return crypto_momentum_20d
@@ -0,0 +1,329 @@
1
+ """Pure Black-Scholes-Merton option pricing/greeks/implied-volatility - the
2
+ real-math foundation for greeks-aware options position sizing
3
+ (portfolio/options_strategy.py), per the user's explicit requirement: real
4
+ Black-Scholes greeks/IV computed from actual option chain data (when IB is
5
+ enabled), not proxies.
6
+
7
+ Stdlib only (math.erf for the normal CDF - no scipy dependency), same
8
+ "pure function, no I/O" convention as features/bond_features.py and
9
+ features/macro_features.py. Every function is a straight textbook
10
+ implementation, unit-tested against known closed-form values
11
+ (tests/test_options_greeks.py) and the put-call parity invariant.
12
+
13
+ `right` is always "call" or "put" (case-insensitive), matching the
14
+ vocabulary portfolio/options_strategy.py's chain rows use.
15
+
16
+ V4.7 (development/Problems.md - early-assignment/corporate-action
17
+ modeling) added baw_american_price(), a Barone-Adesi-Whaley American-
18
+ exercise approximation built entirely from this module's own European
19
+ bs_price()/_d1_d2()/_normal_cdf() - see its own docstring for the
20
+ degrade-to-European safety contract. Everything above it in this file
21
+ remains pure European BSM, unchanged.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import math
27
+
28
+ _SQRT_2PI = math.sqrt(2.0 * math.pi)
29
+
30
+
31
+ def _normal_pdf(x: float) -> float:
32
+ return math.exp(-0.5 * x * x) / _SQRT_2PI
33
+
34
+
35
+ def _normal_cdf(x: float) -> float:
36
+ return 0.5 * (1.0 + math.erf(x / math.sqrt(2.0)))
37
+
38
+
39
+ def _d1_d2(spot: float, strike: float, time_to_expiry_years: float, risk_free_rate: float, volatility: float, dividend_yield: float) -> tuple[float, float]:
40
+ variance_term = volatility * math.sqrt(time_to_expiry_years)
41
+ d1 = (
42
+ math.log(spot / strike) + (risk_free_rate - dividend_yield + 0.5 * volatility * volatility) * time_to_expiry_years
43
+ ) / variance_term
44
+ d2 = d1 - variance_term
45
+ return d1, d2
46
+
47
+
48
+ def bs_price(
49
+ spot: float,
50
+ strike: float,
51
+ time_to_expiry_years: float,
52
+ risk_free_rate: float,
53
+ volatility: float,
54
+ dividend_yield: float,
55
+ right: str,
56
+ ) -> float:
57
+ """European option price under Black-Scholes-Merton with a continuous
58
+ dividend yield. Callers must ensure spot/strike/time_to_expiry_years/
59
+ volatility are all strictly positive - this function does not
60
+ neutral-default (unlike features/bond_features.py's pure functions):
61
+ an option price is meaningless for a degenerate input, so callers
62
+ (options_greeks.implied_volatility(), portfolio/options_strategy.py)
63
+ are responsible for filtering those cases before calling in."""
64
+ d1, d2 = _d1_d2(spot, strike, time_to_expiry_years, risk_free_rate, volatility, dividend_yield)
65
+ discounted_spot = spot * math.exp(-dividend_yield * time_to_expiry_years)
66
+ discounted_strike = strike * math.exp(-risk_free_rate * time_to_expiry_years)
67
+ if right.lower() == "call":
68
+ return discounted_spot * _normal_cdf(d1) - discounted_strike * _normal_cdf(d2)
69
+ return discounted_strike * _normal_cdf(-d2) - discounted_spot * _normal_cdf(-d1)
70
+
71
+
72
+ def delta(spot: float, strike: float, time_to_expiry_years: float, risk_free_rate: float, volatility: float, dividend_yield: float, right: str) -> float:
73
+ """dPrice/dSpot. Calls: [0, 1]. Puts: [-1, 0]."""
74
+ d1, _ = _d1_d2(spot, strike, time_to_expiry_years, risk_free_rate, volatility, dividend_yield)
75
+ discount = math.exp(-dividend_yield * time_to_expiry_years)
76
+ if right.lower() == "call":
77
+ return discount * _normal_cdf(d1)
78
+ return discount * (_normal_cdf(d1) - 1.0)
79
+
80
+
81
+ def gamma(spot: float, strike: float, time_to_expiry_years: float, risk_free_rate: float, volatility: float, dividend_yield: float) -> float:
82
+ """d^2Price/dSpot^2 - identical for calls and puts (no `right` param)."""
83
+ d1, _ = _d1_d2(spot, strike, time_to_expiry_years, risk_free_rate, volatility, dividend_yield)
84
+ return math.exp(-dividend_yield * time_to_expiry_years) * _normal_pdf(d1) / (spot * volatility * math.sqrt(time_to_expiry_years))
85
+
86
+
87
+ def vega(spot: float, strike: float, time_to_expiry_years: float, risk_free_rate: float, volatility: float, dividend_yield: float) -> float:
88
+ """dPrice/dVolatility, per 1.0 (100 percentage points) of volatility -
89
+ identical for calls and puts (no `right` param). Divide by 100 for the
90
+ conventional "per 1 vol point" quoting convention if needed."""
91
+ d1, _ = _d1_d2(spot, strike, time_to_expiry_years, risk_free_rate, volatility, dividend_yield)
92
+ return spot * math.exp(-dividend_yield * time_to_expiry_years) * _normal_pdf(d1) * math.sqrt(time_to_expiry_years)
93
+
94
+
95
+ def theta(spot: float, strike: float, time_to_expiry_years: float, risk_free_rate: float, volatility: float, dividend_yield: float, right: str) -> float:
96
+ """dPrice/dTime, annualized (divide by 365 for a per-calendar-day
97
+ figure)."""
98
+ d1, d2 = _d1_d2(spot, strike, time_to_expiry_years, risk_free_rate, volatility, dividend_yield)
99
+ discounted_spot = spot * math.exp(-dividend_yield * time_to_expiry_years)
100
+ discounted_strike = strike * math.exp(-risk_free_rate * time_to_expiry_years)
101
+ decay_term = -(discounted_spot * _normal_pdf(d1) * volatility) / (2.0 * math.sqrt(time_to_expiry_years))
102
+ if right.lower() == "call":
103
+ return decay_term - risk_free_rate * discounted_strike * _normal_cdf(d2) + dividend_yield * discounted_spot * _normal_cdf(d1)
104
+ return decay_term + risk_free_rate * discounted_strike * _normal_cdf(-d2) - dividend_yield * discounted_spot * _normal_cdf(-d1)
105
+
106
+
107
+ def rho(spot: float, strike: float, time_to_expiry_years: float, risk_free_rate: float, volatility: float, dividend_yield: float, right: str) -> float:
108
+ """dPrice/dRiskFreeRate, per 1.0 (100 percentage points) of rate."""
109
+ _, d2 = _d1_d2(spot, strike, time_to_expiry_years, risk_free_rate, volatility, dividend_yield)
110
+ discounted_strike = strike * math.exp(-risk_free_rate * time_to_expiry_years)
111
+ if right.lower() == "call":
112
+ return discounted_strike * time_to_expiry_years * _normal_cdf(d2)
113
+ return -discounted_strike * time_to_expiry_years * _normal_cdf(-d2)
114
+
115
+
116
+ def compute_greeks(
117
+ spot: float,
118
+ strike: float,
119
+ time_to_expiry_years: float,
120
+ risk_free_rate: float,
121
+ volatility: float,
122
+ dividend_yield: float,
123
+ right: str,
124
+ ) -> dict:
125
+ """Single call site wrapping the 5 functions above, for
126
+ portfolio/options_strategy.py/main.py call-site brevity."""
127
+ return {
128
+ "delta": delta(spot, strike, time_to_expiry_years, risk_free_rate, volatility, dividend_yield, right),
129
+ "gamma": gamma(spot, strike, time_to_expiry_years, risk_free_rate, volatility, dividend_yield),
130
+ "theta": theta(spot, strike, time_to_expiry_years, risk_free_rate, volatility, dividend_yield, right),
131
+ "vega": vega(spot, strike, time_to_expiry_years, risk_free_rate, volatility, dividend_yield),
132
+ "rho": rho(spot, strike, time_to_expiry_years, risk_free_rate, volatility, dividend_yield, right),
133
+ "iv": volatility,
134
+ }
135
+
136
+
137
+ def implied_volatility(
138
+ option_price: float,
139
+ spot: float,
140
+ strike: float,
141
+ time_to_expiry_years: float,
142
+ risk_free_rate: float,
143
+ dividend_yield: float,
144
+ right: str,
145
+ max_iterations: int = 100,
146
+ tolerance: float = 1e-6,
147
+ ) -> float | None:
148
+ """Safeguarded Newton-Raphson (Newton step when it stays inside the
149
+ current bracket, bisection step otherwise) - vega can be ~0 deep
150
+ ITM/OTM or near expiry, which would blow up pure Newton-Raphson.
151
+ bs_price() is monotonically increasing in volatility for T>0, so the
152
+ [low, high] bracket only ever narrows and bisection alone is already
153
+ guaranteed to converge; Newton just speeds that up when it's safe to
154
+ use.
155
+
156
+ Returns None (never raises, never NaN) when: option_price is
157
+ non-positive, any of spot/strike/time_to_expiry_years is non-positive,
158
+ option_price violates the no-arbitrage intrinsic-value bound, the
159
+ market price falls outside the achievable range for the search
160
+ bracket [1e-6, 5.0] (500% vol), or the solver fails to converge within
161
+ max_iterations - same "None = failed to compute, never a crash"
162
+ convention as features/bond_features.py::empirical_duration_beta()."""
163
+ if option_price is None or option_price <= 0.0 or spot <= 0.0 or strike <= 0.0 or time_to_expiry_years <= 0.0:
164
+ return None
165
+
166
+ right_normalized = right.lower()
167
+ discounted_spot = spot * math.exp(-dividend_yield * time_to_expiry_years)
168
+ discounted_strike = strike * math.exp(-risk_free_rate * time_to_expiry_years)
169
+ intrinsic = max(discounted_spot - discounted_strike, 0.0) if right_normalized == "call" else max(discounted_strike - discounted_spot, 0.0)
170
+ if option_price < intrinsic - tolerance:
171
+ return None
172
+
173
+ low, high = 1e-6, 5.0
174
+ price_at_low = bs_price(spot, strike, time_to_expiry_years, risk_free_rate, low, dividend_yield, right_normalized) - option_price
175
+ price_at_high = bs_price(spot, strike, time_to_expiry_years, risk_free_rate, high, dividend_yield, right_normalized) - option_price
176
+ if price_at_low > 0.0 or price_at_high < 0.0:
177
+ return None
178
+
179
+ sigma = 0.2
180
+ for _ in range(max_iterations):
181
+ price_diff = bs_price(spot, strike, time_to_expiry_years, risk_free_rate, sigma, dividend_yield, right_normalized) - option_price
182
+ if abs(price_diff) < tolerance:
183
+ return sigma
184
+
185
+ if price_diff > 0.0:
186
+ high = sigma
187
+ else:
188
+ low = sigma
189
+
190
+ option_vega = vega(spot, strike, time_to_expiry_years, risk_free_rate, sigma, dividend_yield)
191
+ newton_sigma = sigma - price_diff / option_vega if option_vega > 1e-10 else None
192
+ if newton_sigma is not None and low < newton_sigma < high:
193
+ sigma = newton_sigma
194
+ else:
195
+ sigma = (low + high) / 2.0
196
+
197
+ return None
198
+
199
+
200
+ def baw_american_price(
201
+ spot: float,
202
+ strike: float,
203
+ time_to_expiry_years: float,
204
+ risk_free_rate: float,
205
+ dividend_yield: float,
206
+ volatility: float,
207
+ right: str,
208
+ max_iterations: int = 100,
209
+ tolerance: float = 1e-6,
210
+ ) -> float:
211
+ """Barone-Adesi & Whaley (1987) quadratic approximation for American
212
+ option prices - "Efficient Analytic Approximation of American Option
213
+ Values," Journal of Finance 42(2). Built entirely from this module's
214
+ existing bs_price()/_d1_d2()/_normal_cdf() - the European price IS the
215
+ approximation's own building block, not a separately-derived formula.
216
+ Verified numerically against a 3000-step CRR binomial tree across
217
+ several call/put/ITM/OTM/dividend combinations during development
218
+ (differences of a few cents on option prices in the $6-14 range,
219
+ consistent with BAW's known ~1-2% approximation error).
220
+
221
+ Never raises. Degrades to the European bs_price() value whenever: the
222
+ dividend yield is non-positive for a call (a call should never be
223
+ exercised early with zero dividend drag - the standard textbook
224
+ shortcut, not an approximation gap), any input is degenerate
225
+ (time/vol/spot/strike non-positive - returns intrinsic value in that
226
+ case instead), or the critical-price search fails to find a
227
+ bracketing sign change / doesn't converge within max_iterations. This
228
+ is a SAFE direction to degrade in: the American price is always >= the
229
+ European price, so under-pricing by falling back to the European
230
+ value never overstates risk in the direction that matters for a short
231
+ option holder (the whole reason this function exists).
232
+
233
+ Uses a safeguarded bisection search for the critical price (rather
234
+ than implied_volatility()'s Newton-primary approach) - the BAW
235
+ matching condition has no simple closed-form derivative the way vega
236
+ is for the IV solve, so bisection (guaranteed convergence given a
237
+ verified sign-changing bracket, checked explicitly before iterating)
238
+ is the more directly verifiable choice for a per-bar risk signal that
239
+ must never silently mis-converge."""
240
+ right_normalized = right.lower()
241
+ if time_to_expiry_years <= 0.0 or volatility <= 0.0 or spot <= 0.0 or strike <= 0.0:
242
+ return max(spot - strike, 0.0) if right_normalized == "call" else max(strike - spot, 0.0)
243
+
244
+ european_price = bs_price(spot, strike, time_to_expiry_years, risk_free_rate, volatility, dividend_yield, right_normalized)
245
+
246
+ if right_normalized == "call" and dividend_yield <= 0.0:
247
+ return european_price
248
+
249
+ cost_of_carry = risk_free_rate - dividend_yield
250
+ variance = volatility * volatility
251
+ big_m = 2.0 * risk_free_rate / variance
252
+ big_n = 2.0 * cost_of_carry / variance
253
+ big_k = 1.0 - math.exp(-risk_free_rate * time_to_expiry_years)
254
+ if big_k <= 0.0:
255
+ return european_price
256
+
257
+ discriminant = (big_n - 1.0) ** 2 + 4.0 * big_m / big_k
258
+ if discriminant < 0.0:
259
+ return european_price
260
+
261
+ if right_normalized == "call":
262
+ q_root = (-(big_n - 1.0) + math.sqrt(discriminant)) / 2.0
263
+ if q_root <= 0.0:
264
+ return european_price
265
+
266
+ def matching_residual(critical_price: float) -> float:
267
+ d1, _ = _d1_d2(critical_price, strike, time_to_expiry_years, risk_free_rate, volatility, dividend_yield)
268
+ euro_at_critical = bs_price(
269
+ critical_price, strike, time_to_expiry_years, risk_free_rate, volatility, dividend_yield, "call"
270
+ )
271
+ term = (1.0 - math.exp(-dividend_yield * time_to_expiry_years) * _normal_cdf(d1)) * critical_price / q_root
272
+ return critical_price - strike - euro_at_critical - term
273
+
274
+ low, high = strike, max(strike, spot) * 50.0
275
+ else:
276
+ q_root = (-(big_n - 1.0) - math.sqrt(discriminant)) / 2.0
277
+ if q_root >= 0.0:
278
+ return european_price
279
+
280
+ def matching_residual(critical_price: float) -> float:
281
+ d1, _ = _d1_d2(critical_price, strike, time_to_expiry_years, risk_free_rate, volatility, dividend_yield)
282
+ euro_at_critical = bs_price(
283
+ critical_price, strike, time_to_expiry_years, risk_free_rate, volatility, dividend_yield, "put"
284
+ )
285
+ term = (1.0 - math.exp(-dividend_yield * time_to_expiry_years) * _normal_cdf(-d1)) * critical_price / q_root
286
+ return strike - critical_price - euro_at_critical + term
287
+
288
+ low, high = 1e-6, strike
289
+
290
+ residual_low = matching_residual(low)
291
+ residual_high = matching_residual(high)
292
+ if residual_low == 0.0:
293
+ critical_price = low
294
+ elif residual_high == 0.0:
295
+ critical_price = high
296
+ elif (residual_low > 0.0) == (residual_high > 0.0):
297
+ return european_price
298
+ else:
299
+ critical_price = None
300
+ for _ in range(max_iterations):
301
+ midpoint = (low + high) / 2.0
302
+ residual_mid = matching_residual(midpoint)
303
+ if abs(residual_mid) < tolerance:
304
+ critical_price = midpoint
305
+ break
306
+ if (residual_mid > 0.0) == (residual_low > 0.0):
307
+ low = midpoint
308
+ residual_low = residual_mid
309
+ else:
310
+ high = midpoint
311
+ if critical_price is None:
312
+ return european_price
313
+
314
+ if right_normalized == "call":
315
+ if spot >= critical_price:
316
+ return max(spot - strike, 0.0)
317
+ d1, _ = _d1_d2(critical_price, strike, time_to_expiry_years, risk_free_rate, volatility, dividend_yield)
318
+ coefficient = (critical_price / q_root) * (
319
+ 1.0 - math.exp(-dividend_yield * time_to_expiry_years) * _normal_cdf(d1)
320
+ )
321
+ return european_price + coefficient * (spot / critical_price) ** q_root
322
+
323
+ if spot <= critical_price:
324
+ return max(strike - spot, 0.0)
325
+ d1, _ = _d1_d2(critical_price, strike, time_to_expiry_years, risk_free_rate, volatility, dividend_yield)
326
+ coefficient = -(critical_price / q_root) * (
327
+ 1.0 - math.exp(-dividend_yield * time_to_expiry_years) * _normal_cdf(-d1)
328
+ )
329
+ return european_price + coefficient * (spot / critical_price) ** q_root
features/sector_map.py ADDED
@@ -0,0 +1,53 @@
1
+ """Ticker -> sector-neutral-ranking bucket (V5.1 Phase 0, Problems.md #75).
2
+
3
+ Pure, dependency-light, imported by BOTH train.py (torch-bound) and main.py
4
+ (the Lean runtime, which cannot import train.py) - the same train/inference
5
+ parity convention every other module in features/ follows. This module
6
+ existed only inside train.py before V5.1; train.py::load_sector_mapping()
7
+ is now a thin delegate to load_sector_mapping() here so both callers share
8
+ one implementation instead of main.py duplicating the file-read/strip logic.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from pathlib import Path
15
+
16
+ UNKNOWN_SECTOR_LABEL = "Unknown"
17
+
18
+ # Mirrors train.py::SECTOR_MAPPING_PATH (DATA_DIR / "reference" / "sector_mapping.json") -
19
+ # this file lives at <repo_root>/features/sector_map.py, so .parent.parent is <repo_root>.
20
+ DEFAULT_SECTOR_MAPPING_PATH = Path(__file__).resolve().parent.parent / "data" / "reference" / "sector_mapping.json"
21
+
22
+
23
+ def load_sector_mapping(config: dict, *, default_path: Path = DEFAULT_SECTOR_MAPPING_PATH) -> dict[str, str]:
24
+ """Ticker -> sector bucket, from the checked-in data/reference/sector_mapping.json
25
+ (or an override path via config phase1.target.ranking.sector_neutral.mapping_path).
26
+
27
+ Same defensive posture as every other reference-file loader in this
28
+ codebase: returns {} (never raises) when the file is missing or
29
+ unparseable, so a caller degrades to every ticker resolving to
30
+ UNKNOWN_SECTOR_LABEL rather than crashing over a missing reference file.
31
+ `_`-prefixed keys are comments and are stripped.
32
+ """
33
+ mapping_path_str = (
34
+ config.get("phase1", {}).get("target", {}).get("ranking", {}).get("sector_neutral", {}).get("mapping_path")
35
+ )
36
+ mapping_path = Path(mapping_path_str) if mapping_path_str else default_path
37
+ if not mapping_path.exists():
38
+ return {}
39
+
40
+ try:
41
+ raw = json.loads(mapping_path.read_text(encoding="utf-8"))
42
+ except (json.JSONDecodeError, OSError):
43
+ return {}
44
+
45
+ return {ticker: sector for ticker, sector in raw.items() if not ticker.startswith("_")}
46
+
47
+
48
+ def resolve_sector(ticker: str, sector_by_ticker: dict[str, str]) -> str:
49
+ """sector_by_ticker.get(ticker, UNKNOWN_SECTOR_LABEL) - the one-line
50
+ lookup every caller (book_neutrality.py, rank_book_simulator.py,
51
+ train.py's residual-rank sector dummies) needs, kept in one place so
52
+ the UNKNOWN_SECTOR_LABEL default can never drift between callers."""
53
+ return sector_by_ticker.get(ticker, UNKNOWN_SECTOR_LABEL)