tradingapi 0.3.6__tar.gz → 0.3.7__tar.gz

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 (33) hide show
  1. {tradingapi-0.3.6 → tradingapi-0.3.7}/PKG-INFO +1 -1
  2. {tradingapi-0.3.6 → tradingapi-0.3.7}/pyproject.toml +1 -1
  3. tradingapi-0.3.7/tests/test_find_option_with_delta.py +156 -0
  4. {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi/fivepaisa.py +177 -61
  5. {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi/utils.py +169 -125
  6. {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi.egg-info/PKG-INFO +1 -1
  7. tradingapi-0.3.6/tests/test_find_option_with_delta.py +0 -122
  8. {tradingapi-0.3.6 → tradingapi-0.3.7}/README.md +0 -0
  9. {tradingapi-0.3.6 → tradingapi-0.3.7}/setup.cfg +0 -0
  10. {tradingapi-0.3.6 → tradingapi-0.3.7}/tests/test_calculate_delta_realtime_quotes.py +0 -0
  11. {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi/__init__.py +0 -0
  12. {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi/allocation.py +0 -0
  13. {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi/attribution.py +0 -0
  14. {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi/broker_base.py +0 -0
  15. {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi/config/commissions_20241216.yaml +0 -0
  16. {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi/config/config_sample.yaml +0 -0
  17. {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi/config.py +0 -0
  18. {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi/dhan.py +0 -0
  19. {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi/error_handling.py +0 -0
  20. {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi/exceptions.py +0 -0
  21. {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi/flattrade.py +0 -0
  22. {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi/globals.py +0 -0
  23. {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi/icicidirect.py +0 -0
  24. {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi/icicidirect_generate_session.py +0 -0
  25. {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi/market_data_exchanges.py +0 -0
  26. {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi/proxy_utils.py +0 -0
  27. {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi/shoonya.py +0 -0
  28. {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi/span.py +0 -0
  29. {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi.egg-info/SOURCES.txt +0 -0
  30. {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi.egg-info/dependency_links.txt +0 -0
  31. {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi.egg-info/entry_points.txt +0 -0
  32. {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi.egg-info/requires.txt +0 -0
  33. {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tradingapi
3
- Version: 0.3.6
3
+ Version: 0.3.7
4
4
  Summary: Trade integration with brokers
5
5
  Author-email: Pankaj Sharma <sharma.pankaj.kumar@gmail.com>
6
6
  License-Expression: MIT
@@ -28,7 +28,7 @@ packages = ["tradingapi"]
28
28
 
29
29
  [project]
30
30
  name = "tradingapi"
31
- version = "0.3.6"
31
+ version = "0.3.7"
32
32
  description = "Trade integration with brokers"
33
33
  readme = "README.md"
34
34
  license = "MIT"
@@ -0,0 +1,156 @@
1
+ """Tests for the anchor-IV based find_option_with_delta.
2
+
3
+ Quotes are simulated by patching utils._option_mark_price with Black-Scholes
4
+ prices, so implied vols and deltas flow through the real production code path.
5
+
6
+ Run: /home/psharma/envs/base/bin/python3 tradingapi2/tests/test_find_option_with_delta.py
7
+ """
8
+ import datetime as dt
9
+ import math
10
+ import sys
11
+ from unittest.mock import patch
12
+
13
+ # Force import from source repo, not site-packages installed copy.
14
+ sys.path.insert(0, "/home/psharma/onedrive/code/tradingapi2")
15
+
16
+ from chameli.europeanoptions import BlackScholesPrice
17
+
18
+ from tradingapi import utils
19
+
20
+ assert utils.__file__.startswith("/home/psharma/onedrive/code/tradingapi2/"), (
21
+ f"Wrong tradingapi loaded: {utils.__file__}"
22
+ )
23
+
24
+ EXPIRY = (dt.date.today() + dt.timedelta(days=30)).strftime("%Y%m%d")
25
+ TRUE_IV = 0.12
26
+
27
+
28
+ def make_chain(strikes, opt_type="CALL", underlying="NIFTY", expiry=EXPIRY):
29
+ return [f"{underlying}_OPT_{expiry}_{opt_type}_{int(s)}_X" for s in strikes]
30
+
31
+
32
+ def make_quote_fn(spot, stale_prices=None, nan_strikes=()):
33
+ """Return a fake _option_mark_price pricing each strike at TRUE_IV.
34
+
35
+ stale_prices: {strike: price} overrides simulating stale MDS quotes.
36
+ nan_strikes: strikes with no usable quote.
37
+ """
38
+ stale_prices = stale_prices or {}
39
+ nan_set = set(nan_strikes)
40
+
41
+ def fake_mark(brokers, long_symbol, exchange="NSE", mds=None, as_of=None):
42
+ strike = float(long_symbol.split("_")[4])
43
+ opt_type = long_symbol.split("_")[3]
44
+ if strike in nan_set:
45
+ return float("nan")
46
+ if strike in stale_prices:
47
+ return stale_prices[strike]
48
+ years = utils._years_to_expiry(long_symbol)
49
+ return BlackScholesPrice(S=spot, X=strike, r=0, sigma=TRUE_IV, T=years, OptionType=opt_type)
50
+
51
+ return fake_mark
52
+
53
+
54
+ def run_case(name, chain, spot, target_delta, quote_fn, return_lower=True, expect_found=True):
55
+ with patch.object(utils, "_option_mark_price", side_effect=quote_fn):
56
+ idx = utils.find_option_with_delta(
57
+ brokers=[],
58
+ price_f=spot,
59
+ option_chain=chain,
60
+ target_delta=target_delta,
61
+ return_lower_delta=return_lower,
62
+ )
63
+ found = idx >= 0
64
+ chosen = float(chain[idx].split("_")[4]) if found else None
65
+ return name, found, chosen, idx
66
+
67
+
68
+ def true_delta_strike(strikes, spot, opt_type, target_delta, chain, return_lower=True):
69
+ """Reference answer: best strike under the true (flat) IV."""
70
+ years = utils._years_to_expiry(chain[0])
71
+ from chameli.europeanoptions import BlackScholesDelta
72
+
73
+ best, best_d = None, float("-inf") if return_lower else float("inf")
74
+ for k in strikes:
75
+ ad = abs(BlackScholesDelta(S=spot, X=k, r=0, sigma=TRUE_IV, T=years, OptionType=opt_type))
76
+ if return_lower and ad <= target_delta and ad > best_d:
77
+ best, best_d = k, ad
78
+ if not return_lower and ad >= target_delta and ad < best_d:
79
+ best, best_d = k, ad
80
+ return best
81
+
82
+
83
+ def main():
84
+ spot = 74350.0
85
+ strikes = [70000 + 100 * i for i in range(90)] # 70000..78900
86
+ results = []
87
+
88
+ def check(name, chosen, expected, found=True, expect_found=True, tolerance=100):
89
+ ok = (found == expect_found) and (
90
+ not expect_found or (chosen is not None and abs(chosen - expected) <= tolerance)
91
+ )
92
+ results.append(ok)
93
+ print(f"[{'PASS' if ok else 'FAIL'}] {name}: chosen={chosen} expected~{expected}")
94
+
95
+ # Case 1 — clean chain, 0.5-delta call.
96
+ chain = make_chain(strikes)
97
+ expected = true_delta_strike(strikes, spot, "CALL", 0.5, chain)
98
+ name, found, chosen, _ = run_case("clean 0.5d call", chain, spot, 0.5, make_quote_fn(spot))
99
+ check(name, chosen, expected, found)
100
+
101
+ # Case 2 — regression for 2026-06-11: stale high quote on far-OTM 75700 call
102
+ # made it look like 0.5 delta. Must not be picked; answer must stay near spot.
103
+ stale = {75700.0: 850.0} # absurdly rich quote -> fake IV/delta
104
+ name, found, chosen, _ = run_case(
105
+ "stale far-OTM quote ignored", chain, spot, 0.5, make_quote_fn(spot, stale_prices=stale)
106
+ )
107
+ check(name, chosen, expected, found)
108
+ results.append(chosen != 75700.0)
109
+ print(f"[{'PASS' if chosen != 75700.0 else 'FAIL'}] stale strike 75700 not selected")
110
+
111
+ # Case 3 — NaN cluster at/near ATM: anchor scan must walk outward and still work.
112
+ nan_atm = [k for k in strikes if abs(k - spot) <= 200]
113
+ name, found, chosen, _ = run_case(
114
+ "NaN cluster at ATM", chain, spot, 0.5, make_quote_fn(spot, nan_strikes=nan_atm)
115
+ )
116
+ check(name, chosen, expected, found, tolerance=200)
117
+
118
+ # Case 4 — whole chain NaN: no anchor -> -1.
119
+ name, found, chosen, _ = run_case(
120
+ "all NaN -> -1", chain, spot, 0.5, make_quote_fn(spot, nan_strikes=strikes), expect_found=False
121
+ )
122
+ check(name, chosen, None, found, expect_found=False)
123
+
124
+ # Case 5 — PUT chain, 0.2 delta.
125
+ put_chain = make_chain(strikes, opt_type="PUT")
126
+ expected_put = true_delta_strike(strikes, spot, "PUT", 0.2, put_chain)
127
+ name, found, chosen, _ = run_case("put 0.2d", put_chain, spot, 0.2, make_quote_fn(spot))
128
+ check(name, chosen, expected_put, found)
129
+
130
+ # Case 6 — empty chain.
131
+ name, found, chosen, _ = run_case("empty chain -> -1", [], spot, 0.5, make_quote_fn(spot), expect_found=False)
132
+ check(name, chosen, None, found, expect_found=False)
133
+
134
+ # Case 7 — return_lower_delta=False picks from the >= side.
135
+ expected_hi = true_delta_strike(strikes, spot, "CALL", 0.3, chain, return_lower=False)
136
+ name, found, chosen, _ = run_case(
137
+ "0.3d call upper side", chain, spot, 0.3, make_quote_fn(spot), return_lower=False
138
+ )
139
+ check(name, chosen, expected_hi, found)
140
+
141
+ # Case 8 — stale quote adjacent to the true answer (inside refinement window)
142
+ # with wildly inconsistent IV must be rejected by the IV band guard.
143
+ true_strike = expected
144
+ stale_adjacent = {true_strike + 100: 2500.0}
145
+ name, found, chosen, _ = run_case(
146
+ "stale adjacent quote rejected", chain, spot, 0.5, make_quote_fn(spot, stale_prices=stale_adjacent)
147
+ )
148
+ check(name, chosen, expected, found)
149
+
150
+ passed, total = sum(results), len(results)
151
+ print(f"\n{passed}/{total} passed")
152
+ sys.exit(0 if passed == total else 1)
153
+
154
+
155
+ if __name__ == "__main__":
156
+ main()
@@ -336,6 +336,11 @@ class FivePaisa(BrokerBase):
336
336
  self._fp_susertoken_path: Optional[str] = None
337
337
  self._fp_restore_session_from_token: Optional[Callable[[str], bool]] = None
338
338
  self._fp_fresh_login: Optional[Callable[[str], None]] = None
339
+ self._stream_reconnect_lock = threading.Lock()
340
+ self._stream_reconnect_serial_lock = threading.Lock()
341
+ self._stream_reconnect_active = False
342
+ self._suppress_stream_reconnect = False
343
+ self._last_stream_tick_ts = 0.0
339
344
 
340
345
  trading_logger.log_info(
341
346
  "FivePaisa broker initialized", {"broker_type": "FivePaisa", "config_keys": list(kwargs.keys())}
@@ -3640,8 +3645,10 @@ class FivePaisa(BrokerBase):
3640
3645
  "Skipping price data with unmapped token",
3641
3646
  {
3642
3647
  "token": json_data.get("Token"),
3648
+ "scrip_code": json_data.get("ScripCode"),
3643
3649
  "exchange": json_data.get("Exch"),
3644
3650
  "exchange_type": json_data.get("ExchType"),
3651
+ "json_data": json_data,
3645
3652
  },
3646
3653
  )
3647
3654
  return None
@@ -3657,43 +3664,126 @@ class FivePaisa(BrokerBase):
3657
3664
  try:
3658
3665
  data_str = message.replace(r"\/", "/")
3659
3666
  json_data = json.loads(data_str)
3660
- if len(json_data) == 1:
3661
- price = map_to_price(json_data[0])
3667
+ if isinstance(json_data, list):
3668
+ for tick_data in json_data:
3669
+ price = map_to_price(tick_data)
3670
+ if price is not None:
3671
+ self._last_stream_tick_ts = time.time()
3672
+ if price is not None and ext_callback:
3673
+ ext_callback(price)
3674
+ elif isinstance(json_data, dict):
3675
+ price = map_to_price(json_data)
3676
+ if price is not None:
3677
+ self._last_stream_tick_ts = time.time()
3662
3678
  if price is not None and ext_callback:
3663
3679
  ext_callback(price)
3664
3680
  except Exception as e:
3665
3681
  trading_logger.log_error("Error processing WebSocket message", e, {"ws_message": message})
3666
3682
 
3683
+ def schedule_reconnect(reason: str, **details):
3684
+ if self._suppress_stream_reconnect:
3685
+ trading_logger.log_info(
3686
+ "Skipping WebSocket reconnect during planned close",
3687
+ {"reason": reason, **details},
3688
+ )
3689
+ return
3690
+ with self._stream_reconnect_lock:
3691
+ if self._stream_reconnect_active:
3692
+ trading_logger.log_info(
3693
+ "WebSocket reconnect already in progress",
3694
+ {"reason": reason, **details},
3695
+ )
3696
+ return
3697
+ self._stream_reconnect_active = True
3698
+ reconnect_thread = threading.Thread(
3699
+ target=reconnect,
3700
+ args=(reason, details),
3701
+ name="FivePaisaReconnect",
3702
+ daemon=True,
3703
+ )
3704
+ reconnect_thread.start()
3705
+
3667
3706
  def error_data(ws, err):
3668
3707
  """Handle WebSocket errors."""
3669
3708
  try:
3709
+ if ws is not getattr(self.api, "ws", None):
3710
+ trading_logger.log_info("Ignoring WebSocket error from stale socket", {"error": str(err)})
3711
+ return
3670
3712
  trading_logger.log_error("WebSocket error", None, {"error": str(err)})
3671
- reconnect()
3713
+ schedule_reconnect("error", error=str(err))
3672
3714
  except Exception as e:
3673
3715
  trading_logger.log_error("Error handling WebSocket error", e, {"original_error": str(err)})
3674
3716
 
3717
+ def close_data(ws, close_status_code, close_msg):
3718
+ """Handle WebSocket closures that do not surface through on_error."""
3719
+ try:
3720
+ if ws is not getattr(self.api, "ws", None):
3721
+ trading_logger.log_info(
3722
+ "Ignoring WebSocket close from stale socket",
3723
+ {"close_status_code": close_status_code, "close_msg": close_msg},
3724
+ )
3725
+ return
3726
+ trading_logger.log_warning(
3727
+ "WebSocket closed",
3728
+ {"close_status_code": close_status_code, "close_msg": close_msg},
3729
+ )
3730
+ schedule_reconnect(
3731
+ "close",
3732
+ close_status_code=close_status_code,
3733
+ close_msg=str(close_msg) if close_msg is not None else None,
3734
+ )
3735
+ except Exception as e:
3736
+ trading_logger.log_error(
3737
+ "Error handling WebSocket close",
3738
+ e,
3739
+ {"close_status_code": close_status_code, "close_msg": close_msg},
3740
+ )
3741
+
3675
3742
  _ws_reconnect_state: Dict[str, int] = {"failures": 0}
3676
3743
  _ws_reconnect_max = 10
3744
+ _stream_reconnect_verify_timeout_secs = 8.0
3677
3745
 
3678
- def reconnect():
3746
+ def reconnect(reason: str, details: Dict[str, Any]):
3679
3747
  """Reconnect after WebSocket errors — same path as send_stream_request on closed socket."""
3680
- trading_logger.log_info("Attempting to reconnect after WebSocket error...")
3681
- time.sleep(5) # Wait before reconnecting to avoid hammering the broker
3682
3748
  try:
3683
- reconnect_stream()
3684
- _ws_reconnect_state["failures"] = 0
3685
- except Exception as e:
3686
- _ws_reconnect_state["failures"] += 1
3687
- trading_logger.log_error("Reconnection failed", e, {"subscribed_symbols": self.subscribed_symbols})
3688
- if _ws_reconnect_state["failures"] >= _ws_reconnect_max:
3689
- trading_logger.log_error(
3690
- "WebSocket reconnect giving up after repeated failures",
3691
- None,
3692
- {"failures": _ws_reconnect_state["failures"], "subscribed_symbols": self.subscribed_symbols},
3749
+ while True:
3750
+ trading_logger.log_info(
3751
+ "Attempting to reconnect after WebSocket event",
3752
+ {
3753
+ "reason": reason,
3754
+ **details,
3755
+ "attempt": _ws_reconnect_state["failures"] + 1,
3756
+ "max_attempts": _ws_reconnect_max,
3757
+ },
3693
3758
  )
3694
- _ws_reconnect_state["failures"] = 0
3695
- return
3696
- reconnect()
3759
+ time.sleep(5)
3760
+ try:
3761
+ reconnect_stream()
3762
+ _ws_reconnect_state["failures"] = 0
3763
+ return
3764
+ except Exception as e:
3765
+ _ws_reconnect_state["failures"] += 1
3766
+ trading_logger.log_error(
3767
+ "Reconnection failed",
3768
+ e,
3769
+ {"subscribed_symbols": self.subscribed_symbols, "reason": reason, **details},
3770
+ )
3771
+ if _ws_reconnect_state["failures"] >= _ws_reconnect_max:
3772
+ trading_logger.log_error(
3773
+ "WebSocket reconnect giving up after repeated failures",
3774
+ None,
3775
+ {
3776
+ "failures": _ws_reconnect_state["failures"],
3777
+ "subscribed_symbols": self.subscribed_symbols,
3778
+ "reason": reason,
3779
+ **details,
3780
+ },
3781
+ )
3782
+ _ws_reconnect_state["failures"] = 0
3783
+ return
3784
+ finally:
3785
+ with self._stream_reconnect_lock:
3786
+ self._stream_reconnect_active = False
3697
3787
 
3698
3788
  def connect_and_receive(req_data):
3699
3789
  """Connect and receive data."""
@@ -3701,8 +3791,11 @@ class FivePaisa(BrokerBase):
3701
3791
  if self.api is None:
3702
3792
  raise BrokerConnectionError("API client not initialized")
3703
3793
  self.api.connect(req_data)
3704
- self.api.receive_data(on_message)
3705
3794
  self.api.error_data(error_data)
3795
+ ws = getattr(self.api, "ws", None)
3796
+ if ws is not None:
3797
+ ws.on_close = close_data
3798
+ self.api.receive_data(on_message)
3706
3799
  except Exception as e:
3707
3800
  trading_logger.log_error("Error in connect_and_receive", e, {"req_data": req_data})
3708
3801
  raise
@@ -3719,48 +3812,68 @@ class FivePaisa(BrokerBase):
3719
3812
 
3720
3813
  def reconnect_stream():
3721
3814
  """Start a fresh websocket and restore all subscriptions."""
3722
- try:
3723
- self._safe_close_streaming_ws()
3724
- except Exception:
3725
- pass
3726
- self.subscribe_thread = None
3727
- # Re-authenticate at broker level before reconnecting the websocket.
3728
- # fivepaisa's server requires a fresh session after a connection drop;
3729
- # reusing the old api object without re-auth results in a silent connection
3730
- # that accepts subscriptions but never streams data.
3731
- trading_logger.log_info("Re-authenticating before stream reconnect", {"broker": self.broker.name})
3732
- token_path = self._fp_susertoken_path or config.get(f"{self.account_key}.USERTOKEN")
3733
- if not token_path:
3734
- raise BrokerConnectionError("USERTOKEN path not configured")
3735
- restore_fn = self._fp_restore_session_from_token
3736
- fresh_fn = self._fp_fresh_login
3737
- if restore_fn is None or fresh_fn is None:
3738
- raise BrokerConnectionError(
3739
- "FivePaisa stream session helpers not initialized; connect() must succeed before streaming"
3815
+ with self._stream_reconnect_serial_lock:
3816
+ previous_tick_ts = self._last_stream_tick_ts
3817
+ try:
3818
+ self._suppress_stream_reconnect = True
3819
+ self._safe_close_streaming_ws()
3820
+ except Exception:
3821
+ pass
3822
+ finally:
3823
+ self._suppress_stream_reconnect = False
3824
+ self.subscribe_thread = None
3825
+ # Re-authenticate at broker level before reconnecting the websocket.
3826
+ # fivepaisa's server requires a fresh session after a connection drop;
3827
+ # reusing the old api object without re-auth results in a silent connection
3828
+ # that accepts subscriptions but never streams data.
3829
+ trading_logger.log_info("Re-authenticating before stream reconnect", {"broker": self.broker.name})
3830
+ token_path = self._fp_susertoken_path or config.get(f"{self.account_key}.USERTOKEN")
3831
+ if not token_path:
3832
+ raise BrokerConnectionError("USERTOKEN path not configured")
3833
+ restore_fn = self._fp_restore_session_from_token
3834
+ fresh_fn = self._fp_fresh_login
3835
+ if restore_fn is None or fresh_fn is None:
3836
+ raise BrokerConnectionError(
3837
+ "FivePaisa stream session helpers not initialized; connect() must succeed before streaming"
3838
+ )
3839
+ # Always do a full TOTP re-login for stream reconnects.
3840
+ # restore_fn succeeds (REST verifies OK) but FivePaisa's WS server silently
3841
+ # rejects streaming on a token-restored session — subscriptions are accepted
3842
+ # but no ticks are ever delivered. fresh_fn gets a genuine new session.
3843
+ fresh_fn(token_path)
3844
+ req_list_full = expand_symbols_to_request(self.subscribed_symbols)
3845
+ if not req_list_full:
3846
+ context = create_error_context(operation=operation, symbols=symbols, exchange=exchange)
3847
+ raise MarketDataError("No symbols to reconnect after socket closure", context)
3848
+ if self.api is None:
3849
+ raise BrokerConnectionError("API client not initialized")
3850
+ req_data_full = self.api.Request_Feed("mf", "s", req_list_full)
3851
+ reconnect_started_ts = time.time()
3852
+ self.subscribe_thread = threading.Thread(
3853
+ target=connect_and_receive,
3854
+ args=(req_data_full,),
3855
+ name="MarketDataStreamer",
3856
+ )
3857
+ self.subscribe_thread.start()
3858
+ should_verify_ticks = previous_tick_ts > 0 and bool(self.subscribed_symbols)
3859
+ if should_verify_ticks:
3860
+ deadline = time.time() + _stream_reconnect_verify_timeout_secs
3861
+ while time.time() < deadline:
3862
+ if self._last_stream_tick_ts >= reconnect_started_ts:
3863
+ break
3864
+ if not has_live_stream():
3865
+ raise BrokerConnectionError("WebSocket thread died during reconnect verification")
3866
+ time.sleep(0.5)
3867
+ else:
3868
+ raise BrokerConnectionError(
3869
+ "WebSocket reconnected but remained silent after reconnect verification"
3870
+ )
3871
+ else:
3872
+ time.sleep(2)
3873
+ trading_logger.log_info(
3874
+ "Reconnected after socket closure",
3875
+ {"subscribed_count": len(self.subscribed_symbols), "verified_ticks": should_verify_ticks},
3740
3876
  )
3741
- # Always do a full TOTP re-login for stream reconnects.
3742
- # restore_fn succeeds (REST verifies OK) but FivePaisa's WS server silently
3743
- # rejects streaming on a token-restored session — subscriptions are accepted
3744
- # but no ticks are ever delivered. fresh_fn gets a genuine new session.
3745
- fresh_fn(token_path)
3746
- req_list_full = expand_symbols_to_request(self.subscribed_symbols)
3747
- if not req_list_full:
3748
- context = create_error_context(operation=operation, symbols=symbols, exchange=exchange)
3749
- raise MarketDataError("No symbols to reconnect after socket closure", context)
3750
- if self.api is None:
3751
- raise BrokerConnectionError("API client not initialized")
3752
- req_data_full = self.api.Request_Feed("mf", "s", req_list_full)
3753
- self.subscribe_thread = threading.Thread(
3754
- target=connect_and_receive,
3755
- args=(req_data_full,),
3756
- name="MarketDataStreamer",
3757
- )
3758
- self.subscribe_thread.start()
3759
- time.sleep(2)
3760
- trading_logger.log_info(
3761
- "Reconnected after socket closure",
3762
- {"subscribed_count": len(self.subscribed_symbols)},
3763
- )
3764
3877
 
3765
3878
  def send_stream_request(req_data):
3766
3879
  """Send an incremental subscribe/unsubscribe over an existing websocket."""
@@ -3903,12 +4016,15 @@ class FivePaisa(BrokerBase):
3903
4016
  trading_logger.log_info("Stopping quotes streaming")
3904
4017
 
3905
4018
  try:
4019
+ self._suppress_stream_reconnect = True
3906
4020
  self._safe_close_streaming_ws()
3907
4021
  self.subscribe_thread = None
3908
4022
  trading_logger.log_info("Streaming stopped successfully")
3909
4023
  except Exception as e:
3910
4024
  context = create_error_context(error=str(e))
3911
4025
  raise BrokerConnectionError(f"Failed to stop streaming: {str(e)}", context)
4026
+ finally:
4027
+ self._suppress_stream_reconnect = False
3912
4028
 
3913
4029
  except BrokerConnectionError:
3914
4030
  raise
@@ -3168,52 +3168,79 @@ def _asof_to_ref_time_naive(as_of: Union[dt.datetime, dt.date, str, pd.Timestamp
3168
3168
  return ref
3169
3169
 
3170
3170
 
3171
- def calculate_delta(
3171
+ def _option_mark_price(
3172
3172
  brokers: list[BrokerBase],
3173
- long_symbol,
3174
- price_f,
3175
- market_close_time="15:30:00",
3173
+ long_symbol: str,
3176
3174
  exchange="NSE",
3177
3175
  mds: Optional[str] = None,
3178
3176
  as_of: Optional[Union[dt.datetime, dt.date, str, pd.Timestamp]] = None,
3179
- ):
3180
- delta = float("nan")
3177
+ ) -> float:
3178
+ """Mark price for an option: live bid/ask mid, or historical last when as_of is set.
3179
+
3180
+ Returns NaN unless there is a usable two-sided quote (live path) or a positive
3181
+ historical price (as_of path). Never falls back to prior_close: stale marks
3182
+ are the root cause of bad delta-strike picks.
3183
+ """
3181
3184
  if as_of is None:
3182
3185
  ticker = get_price(brokers, long_symbol, checks=["bid", "ask"], exchange=exchange, mds=mds)
3183
- if not (ticker.bid > 0 and ticker.ask > 0):
3186
+ if not (ticker.bid > 0 and ticker.ask > 0) or ticker.ask < ticker.bid:
3184
3187
  return float("nan")
3185
- price = (ticker.bid + ticker.ask) / 2
3186
- t = (
3187
- calc_fractional_business_days(
3188
- get_tradingapi_now(),
3189
- dt.datetime.strptime(long_symbol.split("_")[2] + " " + market_close_time, "%Y%m%d %H:%M:%S"),
3190
- )
3191
- / 252
3192
- ) # convert days number of years
3193
- else:
3194
- br: List[BrokerBase] = [brokers] if isinstance(brokers, BrokerBase) else brokers
3195
- broker = br[0]
3196
- price_at = get_price_at_time(broker, long_symbol, exchange, as_of=as_of, mds=mds, last=True)
3197
- if price_at is None or (isinstance(price_at, float) and (math.isnan(price_at) or price_at <= 0)):
3198
- return float("nan")
3199
- price = float(price_at)
3200
- ref_time = _asof_to_ref_time_naive(as_of)
3201
- t = (
3202
- calc_fractional_business_days(
3203
- ref_time,
3204
- dt.datetime.strptime(long_symbol.split("_")[2] + " " + market_close_time, "%Y%m%d %H:%M:%S"),
3205
- )
3206
- / 252
3207
- )
3208
- vol = BlackScholesIV(
3188
+ return (ticker.bid + ticker.ask) / 2
3189
+ br: List[BrokerBase] = [brokers] if isinstance(brokers, BrokerBase) else brokers
3190
+ if not br:
3191
+ return float("nan")
3192
+ price_at = get_price_at_time(br[0], long_symbol, exchange, as_of=as_of, mds=mds, last=True)
3193
+ if price_at is None or (isinstance(price_at, float) and (math.isnan(price_at) or price_at <= 0)):
3194
+ return float("nan")
3195
+ return float(price_at)
3196
+
3197
+
3198
+ def _years_to_expiry(
3199
+ long_symbol: str,
3200
+ market_close_time="15:30:00",
3201
+ as_of: Optional[Union[dt.datetime, dt.date, str, pd.Timestamp]] = None,
3202
+ ) -> float:
3203
+ ref_time = get_tradingapi_now() if as_of is None else _asof_to_ref_time_naive(as_of)
3204
+ expiry = dt.datetime.strptime(long_symbol.split("_")[2] + " " + market_close_time, "%Y%m%d %H:%M:%S")
3205
+ return calc_fractional_business_days(ref_time, expiry) / 252
3206
+
3207
+
3208
+ def _implied_vol_for_option(
3209
+ brokers: list[BrokerBase],
3210
+ long_symbol: str,
3211
+ price_f: float,
3212
+ years_to_expiry: float,
3213
+ exchange="NSE",
3214
+ mds: Optional[str] = None,
3215
+ as_of: Optional[Union[dt.datetime, dt.date, str, pd.Timestamp]] = None,
3216
+ ) -> float:
3217
+ price = _option_mark_price(brokers, long_symbol, exchange=exchange, mds=mds, as_of=as_of)
3218
+ if math.isnan(price):
3219
+ return float("nan")
3220
+ return BlackScholesIV(
3209
3221
  S=price_f,
3210
3222
  X=float(long_symbol.split("_")[4]),
3211
3223
  r=0,
3212
- T=t,
3224
+ T=years_to_expiry,
3213
3225
  OptionType=long_symbol.split("_")[3],
3214
3226
  OptionPrice=price,
3215
3227
  )
3216
- delta = BlackScholesDelta(
3228
+
3229
+
3230
+ def calculate_delta(
3231
+ brokers: list[BrokerBase],
3232
+ long_symbol,
3233
+ price_f,
3234
+ market_close_time="15:30:00",
3235
+ exchange="NSE",
3236
+ mds: Optional[str] = None,
3237
+ as_of: Optional[Union[dt.datetime, dt.date, str, pd.Timestamp]] = None,
3238
+ ):
3239
+ t = _years_to_expiry(long_symbol, market_close_time, as_of=as_of)
3240
+ vol = _implied_vol_for_option(brokers, long_symbol, price_f, t, exchange=exchange, mds=mds, as_of=as_of)
3241
+ if math.isnan(vol):
3242
+ return float("nan")
3243
+ return BlackScholesDelta(
3217
3244
  S=price_f,
3218
3245
  X=float(long_symbol.split("_")[4]),
3219
3246
  r=0,
@@ -3221,7 +3248,15 @@ def calculate_delta(
3221
3248
  T=t,
3222
3249
  OptionType=long_symbol.split("_")[3],
3223
3250
  )
3224
- return delta
3251
+
3252
+
3253
+ # find_option_with_delta tuning. Anchor IV is taken from the strike nearest the
3254
+ # underlying (most liquid, freshest data); a stale quote elsewhere in the chain
3255
+ # can no longer steer the result on its own.
3256
+ _ANCHOR_IV_SCAN_STRIKES = 5 # how far from ATM to look for a usable anchor quote
3257
+ _ANCHOR_IV_BOUNDS = (0.01, 3.0) # sane annualized IV range for the anchor
3258
+ _REFINE_WINDOW = 2 # strikes around the model pick refined with market quotes
3259
+ _REFINE_IV_BAND = (0.5, 2.0) # market IV / anchor IV ratio accepted during refinement
3225
3260
 
3226
3261
 
3227
3262
  def find_option_with_delta(
@@ -3235,113 +3270,122 @@ def find_option_with_delta(
3235
3270
  mds: Optional[str] = "mds",
3236
3271
  as_of: Optional[Union[dt.datetime, dt.date, str, pd.Timestamp]] = None,
3237
3272
  ):
3238
- # Determine the correct option exchange
3273
+ """Find the chain index whose |delta| best matches target_delta.
3274
+
3275
+ Approach: anchor a single implied vol at the strike nearest the underlying,
3276
+ compute model deltas for the whole chain from that one vol, pick the best
3277
+ strike, then refine locally with market quotes whose implied vol is
3278
+ consistent with the anchor.
3279
+
3280
+ Rationale: the previous binary search trusted each strike's market quote
3281
+ independently; one stale quote produced a plausible delta and a grossly
3282
+ wrong strike (e.g. ATM reported 1350 points away from spot). Model deltas
3283
+ from a single near-ATM vol cannot be corrupted by stale wings, and the
3284
+ refinement step recovers smile accuracy only from quotes that pass an
3285
+ IV-consistency guard.
3286
+ """
3239
3287
  opt_exchange = "NFO" if exchange == "NSE" else "BFO" if exchange == "BSE" else exchange
3240
-
3241
- left, right = 0, len(option_chain) - 1
3242
- best_index = -1 # Default to -1 if no valid option is found
3243
- best_delta = float("-inf") if return_lower_delta else float("inf") # Best delta found so far
3244
-
3245
- def _strike(sym: str) -> float:
3246
- return float(sym.split("_")[4]) if "_" in sym and len(sym.split("_")) > 4 else float("nan")
3247
-
3248
- chain_strikes = [_strike(s) for s in option_chain] if option_chain else []
3249
- strike_lo = min(chain_strikes) if chain_strikes else float("nan")
3250
- strike_hi = max(chain_strikes) if chain_strikes else float("nan")
3251
3288
  n = len(option_chain)
3252
- total_delta_checks = 0
3253
- valid_delta_count = 0
3254
- nan_delta_count = 0
3255
- nan_delta_strikes = [] # track strikes where delta could not be calculated
3256
- # Cache so seeding sweep + binary search never recompute the same strike.
3257
- delta_cache: dict[int, float] = {}
3258
-
3259
- def _delta_at(idx: int) -> float:
3260
- nonlocal total_delta_checks, valid_delta_count, nan_delta_count
3261
- if idx in delta_cache:
3262
- return delta_cache[idx]
3263
- d = calculate_delta(
3264
- brokers, option_chain[idx], price_f, market_close_time, exchange=opt_exchange, mds=mds, as_of=as_of
3265
- )
3266
- total_delta_checks += 1
3267
- if d is None or math.isnan(d):
3268
- d = float("nan")
3269
- nan_delta_count += 1
3270
- nan_delta_strikes.append(_strike(option_chain[idx]))
3271
- else:
3272
- valid_delta_count += 1
3273
- delta_cache[idx] = d
3274
- return d
3275
-
3276
- def _consider(idx: int, abs_delta: float) -> None:
3277
- nonlocal best_index, best_delta
3278
- if return_lower_delta:
3279
- if abs_delta <= target_delta and abs_delta > best_delta:
3280
- best_delta = abs_delta
3281
- best_index = idx
3282
- else:
3283
- if abs_delta >= target_delta and abs_delta < best_delta:
3284
- best_delta = abs_delta
3285
- best_index = idx
3286
-
3287
3289
  if n == 0:
3288
3290
  trading_logger.log_warning(
3289
3291
  f"find_option_with_delta: empty option_chain. price_f={price_f} target_delta={target_delta}"
3290
3292
  )
3291
3293
  return -1
3294
+ if price_f is None or (isinstance(price_f, float) and math.isnan(price_f)) or price_f <= 0:
3295
+ trading_logger.log_warning(
3296
+ f"find_option_with_delta: invalid underlying price_f={price_f} target_delta={target_delta}"
3297
+ )
3298
+ return -1
3292
3299
 
3293
- # Monotonicity is deterministic from option type: calls lose delta as strike rises, puts gain it.
3294
- increasing = "_PUT_" in option_chain[0]
3295
-
3296
- # Seed at midpoint (near ATM) — most liquid, least likely to have stale data.
3297
- mid_seed = n // 2
3298
- d = _delta_at(mid_seed)
3299
- if not math.isnan(d):
3300
- _consider(mid_seed, abs(d))
3300
+ strikes = [float(sym.split("_")[4]) for sym in option_chain]
3301
+ option_type = option_chain[0].split("_")[3]
3302
+ years = _years_to_expiry(option_chain[0], market_close_time, as_of=as_of)
3303
+ if years <= 0:
3304
+ trading_logger.log_warning(
3305
+ f"find_option_with_delta: option expired or T<=0. symbol={option_chain[0]} years={years}"
3306
+ )
3307
+ return -1
3301
3308
 
3302
- # --- Change 2: NaN at mid -> scan to nearest valid neighbor instead of shrinking range ---
3303
- NAN_SCAN_CAP = 5
3309
+ def _iv_at(idx: int) -> float:
3310
+ return _implied_vol_for_option(
3311
+ brokers, option_chain[idx], price_f, years, exchange=opt_exchange, mds=mds, as_of=as_of
3312
+ )
3304
3313
 
3305
- def _nearest_valid(mid: int, lo: int, hi: int) -> int:
3306
- # Returns idx of nearest valid delta within [lo, hi], or -1 if none within cap.
3307
- for step in range(1, NAN_SCAN_CAP + 1):
3308
- for cand in (mid - step, mid + step):
3309
- if lo <= cand <= hi and not math.isnan(_delta_at(cand)):
3310
- return cand
3314
+ # 1. Anchor IV at (or near) the ATM strike.
3315
+ atm_idx = min(range(n), key=lambda i: abs(strikes[i] - price_f))
3316
+ lo_iv, hi_iv = _ANCHOR_IV_BOUNDS
3317
+ anchor_iv, anchor_idx = float("nan"), -1
3318
+ scan = sorted(
3319
+ range(max(0, atm_idx - _ANCHOR_IV_SCAN_STRIKES), min(n, atm_idx + _ANCHOR_IV_SCAN_STRIKES + 1)),
3320
+ key=lambda i: abs(i - atm_idx),
3321
+ )
3322
+ for i in scan:
3323
+ iv = _iv_at(i)
3324
+ if not math.isnan(iv) and lo_iv <= iv <= hi_iv:
3325
+ anchor_iv, anchor_idx = iv, i
3326
+ break
3327
+ if math.isnan(anchor_iv):
3328
+ trading_logger.log_warning(
3329
+ f"find_option_with_delta: no usable anchor IV near ATM. price_f={price_f} "
3330
+ f"atm_strike={strikes[atm_idx]} scanned_strikes={[strikes[i] for i in scan]} target_delta={target_delta}"
3331
+ )
3311
3332
  return -1
3312
3333
 
3313
- while left <= right:
3314
- mid = (left + right) // 2
3315
- d = _delta_at(mid)
3316
- eff_idx = mid
3317
- if math.isnan(d):
3318
- alt = _nearest_valid(mid, left, right)
3319
- if alt < 0:
3320
- # Wide NaN band: stop here instead of expanding into a full-chain scan.
3321
- break
3322
- eff_idx = alt
3323
- d = delta_cache[alt]
3324
- ad = abs(d)
3325
- _consider(eff_idx, ad)
3326
-
3327
- if increasing:
3328
- if ad > target_delta:
3329
- right = eff_idx - 1
3330
- else:
3331
- left = eff_idx + 1
3332
- else:
3333
- if ad > target_delta:
3334
- left = eff_idx + 1
3334
+ # 2. Model deltas for the whole chain from the single anchor IV (pure math, no quotes).
3335
+ abs_deltas = [
3336
+ abs(BlackScholesDelta(S=price_f, X=k, r=0, sigma=anchor_iv, T=years, OptionType=option_type))
3337
+ for k in strikes
3338
+ ]
3339
+
3340
+ def _pick_best(deltas: list) -> int:
3341
+ best_idx, best_d = -1, float("-inf") if return_lower_delta else float("inf")
3342
+ for i, ad in enumerate(deltas):
3343
+ if math.isnan(ad):
3344
+ continue
3345
+ if return_lower_delta:
3346
+ if ad <= target_delta and ad > best_d:
3347
+ best_idx, best_d = i, ad
3335
3348
  else:
3336
- right = eff_idx - 1
3349
+ if ad >= target_delta and ad < best_d:
3350
+ best_idx, best_d = i, ad
3351
+ return best_idx
3337
3352
 
3338
- if best_index < 0:
3353
+ model_idx = _pick_best(abs_deltas)
3354
+ if model_idx < 0:
3339
3355
  trading_logger.log_warning(
3340
- f"find_option_with_delta: no valid option strike found. "
3341
- f"price_f={price_f} chain_len={n} strike_range=[{strike_lo}, {strike_hi}] "
3342
- f"valid_delta_count={valid_delta_count} nan_delta_count={nan_delta_count} total_delta_checks={total_delta_checks} "
3343
- f"nan_delta_strikes={sorted(set(nan_delta_strikes))}"
3356
+ f"find_option_with_delta: no strike satisfies target under model deltas. "
3357
+ f"price_f={price_f} target_delta={target_delta} anchor_iv={anchor_iv:.4f} "
3358
+ f"strike_range=[{strikes[0]}, {strikes[-1]}] return_lower_delta={return_lower_delta}"
3344
3359
  )
3360
+ return -1
3361
+
3362
+ # 3. Local refinement: overwrite model deltas with market deltas for strikes
3363
+ # around the model pick, but only where the market IV is consistent with the
3364
+ # anchor (stale-quote guard).
3365
+ refined = list(abs_deltas)
3366
+ for i in range(max(0, model_idx - _REFINE_WINDOW), min(n, model_idx + _REFINE_WINDOW + 1)):
3367
+ iv = anchor_iv if i == anchor_idx else _iv_at(i)
3368
+ if math.isnan(iv) or iv <= 0:
3369
+ continue
3370
+ ratio = iv / anchor_iv
3371
+ if not (_REFINE_IV_BAND[0] <= ratio <= _REFINE_IV_BAND[1]):
3372
+ trading_logger.log_debug(
3373
+ f"find_option_with_delta: refinement rejected strike={strikes[i]} "
3374
+ f"iv={iv:.4f} anchor_iv={anchor_iv:.4f} ratio={ratio:.2f}"
3375
+ )
3376
+ continue
3377
+ refined[i] = abs(
3378
+ BlackScholesDelta(S=price_f, X=strikes[i], r=0, sigma=iv, T=years, OptionType=option_type)
3379
+ )
3380
+ best_index = _pick_best(refined)
3381
+ if best_index < 0:
3382
+ best_index = model_idx
3383
+
3384
+ trading_logger.log_debug(
3385
+ f"find_option_with_delta: price_f={price_f} target_delta={target_delta} "
3386
+ f"anchor_strike={strikes[anchor_idx]} anchor_iv={anchor_iv:.4f} "
3387
+ f"model_strike={strikes[model_idx]} final_strike={strikes[best_index]}"
3388
+ )
3345
3389
  return best_index
3346
3390
 
3347
3391
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tradingapi
3
- Version: 0.3.6
3
+ Version: 0.3.7
4
4
  Summary: Trade integration with brokers
5
5
  Author-email: Pankaj Sharma <sharma.pankaj.kumar@gmail.com>
6
6
  License-Expression: MIT
@@ -1,122 +0,0 @@
1
- """Standalone test for find_option_with_delta NaN-robustness fix.
2
-
3
- Run: /home/psharma/envs/base/bin/python3 tradingapi2/tests/test_find_option_with_delta.py
4
- """
5
- import math
6
- import sys
7
- from unittest.mock import patch
8
-
9
- # Force import from source repo, not site-packages installed copy.
10
- sys.path.insert(0, "/home/psharma/onedrive/code/tradingapi2")
11
-
12
- from tradingapi import utils
13
-
14
- assert utils.__file__.startswith("/home/psharma/onedrive/code/tradingapi2/"), (
15
- f"Wrong tradingapi loaded: {utils.__file__}"
16
- )
17
-
18
-
19
- def make_chain(strikes, expiry="20260529", opt_type="CALL", underlying="NIFTY"):
20
- # Symbol format expected by _strike() and calculate_delta: "<u>_<x>_<expiry>_<type>_<strike>_..."
21
- return [f"{underlying}_X_{expiry}_{opt_type}_{int(s) if s == int(s) else s}_X" for s in strikes]
22
-
23
-
24
- def bs_like_delta(strike, spot, opt_type="CALL"):
25
- """Smooth monotone proxy for |delta| as a function of strike. Just needs to be monotone."""
26
- # CALL delta ~ N(d1); approximate with logistic in (spot - strike)
27
- x = (spot - strike) / (spot * 0.05)
28
- sig = 1.0 / (1.0 + math.exp(-x))
29
- return sig if opt_type == "CALL" else -(1.0 - sig)
30
-
31
-
32
- def run_case(name, chain, price_f, target_delta, nan_strikes=(), return_lower=True, expect_index=True):
33
- nan_set = set(nan_strikes)
34
- call_log = []
35
-
36
- def fake_calc_delta(brokers, sym, pf, *a, **kw):
37
- strike = float(sym.split("_")[4])
38
- call_log.append(strike)
39
- if strike in nan_set:
40
- return float("nan")
41
- return bs_like_delta(strike, pf, opt_type=sym.split("_")[3])
42
-
43
- with patch.object(utils, "calculate_delta", side_effect=fake_calc_delta):
44
- idx = utils.find_option_with_delta(
45
- brokers=[],
46
- price_f=price_f,
47
- option_chain=chain,
48
- target_delta=target_delta,
49
- return_lower_delta=return_lower,
50
- )
51
-
52
- found = idx >= 0
53
- status = "PASS" if found == expect_index else "FAIL"
54
- chosen_strike = float(chain[idx].split("_")[4]) if found else None
55
- chosen_delta = bs_like_delta(chosen_strike, price_f) if chosen_strike is not None else None
56
- delta_str = f"{abs(chosen_delta):.4f}" if chosen_delta is not None else "n/a"
57
- print(
58
- f"[{status}] {name}: idx={idx} strike={chosen_strike} |delta|≈{delta_str} "
59
- f"calls={len(call_log)} unique={len(set(call_log))} target={target_delta}"
60
- )
61
- return status == "PASS", idx, len(call_log)
62
-
63
-
64
- def main():
65
- results = []
66
-
67
- # Case 1 — clean chain, no NaN. Should land near target_delta=0.3 and be efficient.
68
- strikes = [64900 + 100 * i for i in range(215)] # 64900..86300
69
- chain = make_chain(strikes)
70
- ok, idx, calls = run_case("clean chain, target=0.3", chain, 76956.7, 0.3)
71
- results.append(ok)
72
- assert calls < 30, f"clean chain should be cheap, got {calls} calls"
73
-
74
- # Case 2 — exact repro of warning: NaN at deep ITM call strikes.
75
- nan_strikes = [64900.0, 65100.0, 65400.0, 66100.0, 67500.0]
76
- ok, idx, calls = run_case(
77
- "warning repro: 5 NaN deep-ITM, target=0.3", chain, 76956.7, 0.3, nan_strikes=nan_strikes
78
- )
79
- results.append(ok)
80
-
81
- # Case 3 — NaN cluster centered on mid (chain mid ~ index 107, strike ~75600).
82
- mid_nans = [strikes[i] for i in range(100, 115)]
83
- ok, _, _ = run_case("NaN cluster around mid", chain, 76956.7, 0.3, nan_strikes=mid_nans)
84
- results.append(ok)
85
-
86
- # Case 4 — NaN around the seeding fractions (0.1, 0.25, 0.5, 0.75, 0.9).
87
- seed_idxs = [int(round(f * 214)) for f in [0.1, 0.25, 0.5, 0.75]] # leave 0.9 valid
88
- seed_nans = [strikes[i] for i in seed_idxs]
89
- ok, _, _ = run_case("NaN at most seed fractions", chain, 76956.7, 0.3, nan_strikes=seed_nans)
90
- results.append(ok)
91
-
92
- # Case 5 — all but two strikes NaN; should still find via linear fallback.
93
- valid_strikes = {strikes[50], strikes[160]}
94
- all_nan = [s for s in strikes if s not in valid_strikes]
95
- ok, _, _ = run_case(
96
- "only 2 valid strikes (linear fallback)", chain, 76956.7, 0.3, nan_strikes=all_nan
97
- )
98
- results.append(ok)
99
-
100
- # Case 6 — entire chain NaN: must return -1 with warning, not crash.
101
- ok, idx, _ = run_case(
102
- "all NaN -> expect -1", chain, 76956.7, 0.3, nan_strikes=strikes, expect_index=False
103
- )
104
- results.append(ok)
105
-
106
- # Case 7 — PUT chain (decreasing |delta| with strike).
107
- put_chain = make_chain(strikes, opt_type="PUT")
108
- ok, _, _ = run_case("PUT chain target=0.3", put_chain, 76956.7, 0.3)
109
- results.append(ok)
110
-
111
- # Case 8 — empty chain.
112
- ok, idx, _ = run_case("empty chain -> -1", [], 76956.7, 0.3, expect_index=False)
113
- results.append(ok)
114
-
115
- passed = sum(results)
116
- total = len(results)
117
- print(f"\n{passed}/{total} passed")
118
- sys.exit(0 if passed == total else 1)
119
-
120
-
121
- if __name__ == "__main__":
122
- main()
File without changes
File without changes