From d46efa60d55645db1755b918c19c254a27cc5b14 Mon Sep 17 00:00:00 2001 From: Brad Smith Date: Wed, 10 Jun 2026 15:57:33 -0500 Subject: [PATCH] fix(features): make IV estimate deterministic and symbol-aware ImpliedVolatilityEstimator.estimate_iv() multiplied a per-category base volatility by random.uniform(0.8, 1.2) seeded on int(time.time()) % 100, so the same symbol returned different IVs on consecutive calls and no Greek or POP derived from it was reproducible. get_iv_for_position() also called estimate_iv("SPY") unconditionally, applying a low-vol ETF base (0.15) to every position regardless of the real underlying. - Remove the wall-clock-seeded random factor; return the deterministic per-category base (ETF 0.15 / large-cap tech 0.25 / general 0.30). - Add an optional `symbol` argument to get_iv_for_position so the real underlying flows through; when it is unknown, fall back to the general single-stock base rather than assuming SPY. Verified: estimate_iv('AAPL') == estimate_iv('AAPL') == 0.25; position IV now differs by symbol. Adds a determinism test. Closes #9 Co-Authored-By: Claude Opus 4.8 --- src/features/greeks.py | 24 +++++++++++++----------- tests/test_greeks.py | 10 ++++++++++ 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/src/features/greeks.py b/src/features/greeks.py index e7cf6c5..ae7adfa 100644 --- a/src/features/greeks.py +++ b/src/features/greeks.py @@ -236,31 +236,33 @@ def estimate_iv(self, symbol: str, days: int = 30) -> float: else: base_vol = 0.30 # General stocks - # Add some randomness to simulate market conditions - import time - import random - random.seed(int(time.time()) % 100) - adjustment = random.uniform(0.8, 1.2) - - return base_vol * adjustment + # Deterministic estimate: return the per-category base volatility. + # Previously this was multiplied by a wall-clock-seeded random factor + # (random.seed(int(time.time()) % 100)), so the same symbol returned + # different IVs on consecutive calls and no Greek or POP derived from + # it was reproducible. + return base_vol except Exception: # Fallback to 30% volatility return 0.30 - def get_iv_for_position(self, position: Position) -> List[float]: + def get_iv_for_position(self, position: Position, symbol: Optional[str] = None) -> List[float]: """ Get IV estimates for all legs of a position. Args: position: Position to estimate IV for + symbol: Underlying symbol, when known, used to pick the base IV Returns: List of IV estimates for each leg """ - # In a real implementation, this would fetch option chain data - # For now, use the same IV for all legs with slight variations - base_iv = self.estimate_iv("SPY") # Use SPY as base + # In a real implementation, this would fetch option chain data. + # Use the underlying symbol when the caller knows it. Without it, assume + # a general single-stock volatility rather than SPY (a low-vol ETF), + # which previously underestimated IV for every individual name. + base_iv = self.estimate_iv(symbol) if symbol else self.estimate_iv("UNKNOWN") iv_estimates = [] for i, (strike, option_type) in enumerate(zip(position.strikes, position.option_types)): diff --git a/tests/test_greeks.py b/tests/test_greeks.py index 860460c..42585ce 100644 --- a/tests/test_greeks.py +++ b/tests/test_greeks.py @@ -135,6 +135,16 @@ def test_iv_estimation(self): self.assertGreater(iv, 0.05) self.assertLess(iv, 1.0) + def test_iv_estimation_is_deterministic(self): + """estimate_iv must return the same value on repeated calls (issue #9).""" + first = self.iv_estimator.estimate_iv('AAPL') + second = self.iv_estimator.estimate_iv('AAPL') + self.assertEqual(first, second) + # And distinct symbol categories get distinct, stable bases. + self.assertEqual(self.iv_estimator.estimate_iv('SPY'), 0.15) + self.assertEqual(self.iv_estimator.estimate_iv('AAPL'), 0.25) + self.assertEqual(self.iv_estimator.estimate_iv('XYZ'), 0.30) + def test_position_iv_estimation(self): """Test position-level IV estimation.""" position = Position(