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