tradingapi 0.3.5__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.5 → tradingapi-0.3.7}/PKG-INFO +1 -1
  2. {tradingapi-0.3.5 → tradingapi-0.3.7}/pyproject.toml +1 -1
  3. tradingapi-0.3.7/tests/test_calculate_delta_realtime_quotes.py +29 -0
  4. tradingapi-0.3.7/tests/test_find_option_with_delta.py +156 -0
  5. {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi/allocation.py +1 -1
  6. {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi/fivepaisa.py +176 -61
  7. {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi/utils.py +256 -142
  8. {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi.egg-info/PKG-INFO +1 -1
  9. {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi.egg-info/SOURCES.txt +1 -0
  10. tradingapi-0.3.5/tests/test_find_option_with_delta.py +0 -122
  11. {tradingapi-0.3.5 → tradingapi-0.3.7}/README.md +0 -0
  12. {tradingapi-0.3.5 → tradingapi-0.3.7}/setup.cfg +0 -0
  13. {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi/__init__.py +0 -0
  14. {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi/attribution.py +0 -0
  15. {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi/broker_base.py +0 -0
  16. {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi/config/commissions_20241216.yaml +0 -0
  17. {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi/config/config_sample.yaml +0 -0
  18. {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi/config.py +0 -0
  19. {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi/dhan.py +0 -0
  20. {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi/error_handling.py +0 -0
  21. {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi/exceptions.py +0 -0
  22. {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi/flattrade.py +0 -0
  23. {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi/globals.py +0 -0
  24. {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi/icicidirect.py +0 -0
  25. {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi/icicidirect_generate_session.py +0 -0
  26. {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi/market_data_exchanges.py +0 -0
  27. {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi/proxy_utils.py +0 -0
  28. {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi/shoonya.py +0 -0
  29. {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi/span.py +0 -0
  30. {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi.egg-info/dependency_links.txt +0 -0
  31. {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi.egg-info/entry_points.txt +0 -0
  32. {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi.egg-info/requires.txt +0 -0
  33. {tradingapi-0.3.5 → 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.5
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.5"
31
+ version = "0.3.7"
32
32
  description = "Trade integration with brokers"
33
33
  readme = "README.md"
34
34
  license = "MIT"
@@ -0,0 +1,29 @@
1
+ import math
2
+ import sys
3
+ from types import SimpleNamespace
4
+ import unittest
5
+ from unittest.mock import patch
6
+
7
+ sys.path.insert(0, "/home/psharma/onedrive/code/tradingapi2")
8
+
9
+ from tradingapi import utils
10
+
11
+
12
+ class CalculateDeltaRealtimeQuotesTest(unittest.TestCase):
13
+ def test_calculate_delta_returns_nan_without_live_bid_ask(self):
14
+ ticker = SimpleNamespace(bid=0.0, ask=0.0, prior_close=123.45)
15
+
16
+ with patch.object(utils, "get_price", return_value=ticker):
17
+ delta = utils.calculate_delta(
18
+ brokers=[],
19
+ long_symbol="TCS_OPT_20260630_PUT_2160",
20
+ price_f=2325.1,
21
+ exchange="NFO",
22
+ mds="mds",
23
+ )
24
+
25
+ self.assertTrue(math.isnan(delta))
26
+
27
+
28
+ if __name__ == "__main__":
29
+ unittest.main()
@@ -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()
@@ -24,7 +24,7 @@ _TODAY_SYMLINK = _ALLOCATIONS_DIR / "master_allocation_today.yaml"
24
24
  def load_today_allocation_entry(broker_name: str, strategy_name: str) -> dict:
25
25
  """Return the full allocation entry dict for strategy_name on broker_name today.
26
26
 
27
- Example return value: {'pct': 0.275, 'redis_db': 8}
27
+ Example return value: {'pct': 0.225, 'redis_db': 8}
28
28
  SCALPING strategies include a 'redis_db' key; others have only 'pct'.
29
29
 
30
30
  Reads master_allocation_today.yaml (a symlink to today's dated file).
@@ -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,49 +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",
3740
3856
  )
3741
- if not restore_fn(token_path):
3742
- trading_logger.log_warning(
3743
- "Token restore failed during reconnect, attempting fresh login",
3744
- {"broker": self.broker.name},
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},
3745
3876
  )
3746
- fresh_fn(token_path)
3747
- req_list_full = expand_symbols_to_request(self.subscribed_symbols)
3748
- if not req_list_full:
3749
- context = create_error_context(operation=operation, symbols=symbols, exchange=exchange)
3750
- raise MarketDataError("No symbols to reconnect after socket closure", context)
3751
- if self.api is None:
3752
- raise BrokerConnectionError("API client not initialized")
3753
- req_data_full = self.api.Request_Feed("mf", "s", req_list_full)
3754
- self.subscribe_thread = threading.Thread(
3755
- target=connect_and_receive,
3756
- args=(req_data_full,),
3757
- name="MarketDataStreamer",
3758
- )
3759
- self.subscribe_thread.start()
3760
- time.sleep(2)
3761
- trading_logger.log_info(
3762
- "Reconnected after socket closure",
3763
- {"subscribed_count": len(self.subscribed_symbols)},
3764
- )
3765
3877
 
3766
3878
  def send_stream_request(req_data):
3767
3879
  """Send an incremental subscribe/unsubscribe over an existing websocket."""
@@ -3904,12 +4016,15 @@ class FivePaisa(BrokerBase):
3904
4016
  trading_logger.log_info("Stopping quotes streaming")
3905
4017
 
3906
4018
  try:
4019
+ self._suppress_stream_reconnect = True
3907
4020
  self._safe_close_streaming_ws()
3908
4021
  self.subscribe_thread = None
3909
4022
  trading_logger.log_info("Streaming stopped successfully")
3910
4023
  except Exception as e:
3911
4024
  context = create_error_context(error=str(e))
3912
4025
  raise BrokerConnectionError(f"Failed to stop streaming: {str(e)}", context)
4026
+ finally:
4027
+ self._suppress_stream_reconnect = False
3913
4028
 
3914
4029
  except BrokerConnectionError:
3915
4030
  raise
@@ -2000,10 +2000,14 @@ def update_order_status(
2000
2000
  for attr in required_attributes:
2001
2001
  if not hasattr(fills, attr) or getattr(fills, attr) in [None, "0"]:
2002
2002
  attr_value = getattr(fills, attr, "<missing>")
2003
- trading_logger.log_error(
2003
+ msg = (
2004
2004
  f"Missing or invalid attribute {attr} in order information for broker_order_id: {broker_order_id}. "
2005
2005
  f"value={attr_value!r}, value_type={type(attr_value).__name__}"
2006
2006
  )
2007
+ if attr == "exchange_order_id":
2008
+ trading_logger.log_info(msg)
2009
+ else:
2010
+ trading_logger.log_error(msg)
2007
2011
  return fills
2008
2012
 
2009
2013
  if broker.broker != fills.broker:
@@ -3164,50 +3168,79 @@ def _asof_to_ref_time_naive(as_of: Union[dt.datetime, dt.date, str, pd.Timestamp
3164
3168
  return ref
3165
3169
 
3166
3170
 
3167
- def calculate_delta(
3171
+ def _option_mark_price(
3168
3172
  brokers: list[BrokerBase],
3169
- long_symbol,
3170
- price_f,
3171
- market_close_time="15:30:00",
3173
+ long_symbol: str,
3172
3174
  exchange="NSE",
3173
3175
  mds: Optional[str] = None,
3174
3176
  as_of: Optional[Union[dt.datetime, dt.date, str, pd.Timestamp]] = None,
3175
- ):
3176
- 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
+ """
3177
3184
  if as_of is None:
3178
- ticker = get_price(brokers, long_symbol, checks=["bid", "ask", "prior_close"], exchange=exchange, mds=mds)
3179
- price = (ticker.bid + ticker.ask) / 2 if ticker.bid > 0 and ticker.ask > 0 else ticker.prior_close
3180
- t = (
3181
- calc_fractional_business_days(
3182
- get_tradingapi_now(),
3183
- dt.datetime.strptime(long_symbol.split("_")[2] + " " + market_close_time, "%Y%m%d %H:%M:%S"),
3184
- )
3185
- / 252
3186
- ) # convert days number of years
3187
- else:
3188
- br: List[BrokerBase] = [brokers] if isinstance(brokers, BrokerBase) else brokers
3189
- broker = br[0]
3190
- price_at = get_price_at_time(broker, long_symbol, exchange, as_of=as_of, mds=mds, last=True)
3191
- if price_at is None or (isinstance(price_at, float) and (math.isnan(price_at) or price_at <= 0)):
3185
+ ticker = get_price(brokers, long_symbol, checks=["bid", "ask"], exchange=exchange, mds=mds)
3186
+ if not (ticker.bid > 0 and ticker.ask > 0) or ticker.ask < ticker.bid:
3192
3187
  return float("nan")
3193
- price = float(price_at)
3194
- ref_time = _asof_to_ref_time_naive(as_of)
3195
- t = (
3196
- calc_fractional_business_days(
3197
- ref_time,
3198
- dt.datetime.strptime(long_symbol.split("_")[2] + " " + market_close_time, "%Y%m%d %H:%M:%S"),
3199
- )
3200
- / 252
3201
- )
3202
- 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(
3203
3221
  S=price_f,
3204
3222
  X=float(long_symbol.split("_")[4]),
3205
3223
  r=0,
3206
- T=t,
3224
+ T=years_to_expiry,
3207
3225
  OptionType=long_symbol.split("_")[3],
3208
3226
  OptionPrice=price,
3209
3227
  )
3210
- 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(
3211
3244
  S=price_f,
3212
3245
  X=float(long_symbol.split("_")[4]),
3213
3246
  r=0,
@@ -3215,7 +3248,15 @@ def calculate_delta(
3215
3248
  T=t,
3216
3249
  OptionType=long_symbol.split("_")[3],
3217
3250
  )
3218
- 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
3219
3260
 
3220
3261
 
3221
3262
  def find_option_with_delta(
@@ -3229,129 +3270,122 @@ def find_option_with_delta(
3229
3270
  mds: Optional[str] = "mds",
3230
3271
  as_of: Optional[Union[dt.datetime, dt.date, str, pd.Timestamp]] = None,
3231
3272
  ):
3232
- # 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
+ """
3233
3287
  opt_exchange = "NFO" if exchange == "NSE" else "BFO" if exchange == "BSE" else exchange
3234
-
3235
- left, right = 0, len(option_chain) - 1
3236
- best_index = -1 # Default to -1 if no valid option is found
3237
- best_delta = float("-inf") if return_lower_delta else float("inf") # Best delta found so far
3238
-
3239
- def _strike(sym: str) -> float:
3240
- return float(sym.split("_")[4]) if "_" in sym and len(sym.split("_")) > 4 else float("nan")
3241
-
3242
- chain_strikes = [_strike(s) for s in option_chain] if option_chain else []
3243
- strike_lo = min(chain_strikes) if chain_strikes else float("nan")
3244
- strike_hi = max(chain_strikes) if chain_strikes else float("nan")
3245
3288
  n = len(option_chain)
3246
- total_delta_checks = 0
3247
- valid_delta_count = 0
3248
- nan_delta_count = 0
3249
- nan_delta_strikes = [] # track strikes where delta could not be calculated
3250
- # Cache so seeding sweep + binary search never recompute the same strike.
3251
- delta_cache: dict[int, float] = {}
3252
-
3253
- def _delta_at(idx: int) -> float:
3254
- nonlocal total_delta_checks, valid_delta_count, nan_delta_count
3255
- if idx in delta_cache:
3256
- return delta_cache[idx]
3257
- d = calculate_delta(
3258
- brokers, option_chain[idx], price_f, market_close_time, exchange=opt_exchange, mds=mds, as_of=as_of
3259
- )
3260
- total_delta_checks += 1
3261
- if d is None or math.isnan(d):
3262
- d = float("nan")
3263
- nan_delta_count += 1
3264
- nan_delta_strikes.append(_strike(option_chain[idx]))
3265
- else:
3266
- valid_delta_count += 1
3267
- delta_cache[idx] = d
3268
- return d
3269
-
3270
- def _consider(idx: int, abs_delta: float) -> None:
3271
- nonlocal best_index, best_delta
3272
- if return_lower_delta:
3273
- if abs_delta <= target_delta and abs_delta > best_delta:
3274
- best_delta = abs_delta
3275
- best_index = idx
3276
- else:
3277
- if abs_delta >= target_delta and abs_delta < best_delta:
3278
- best_delta = abs_delta
3279
- best_index = idx
3280
-
3281
3289
  if n == 0:
3282
3290
  trading_logger.log_warning(
3283
3291
  f"find_option_with_delta: empty option_chain. price_f={price_f} target_delta={target_delta}"
3284
3292
  )
3285
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
3286
3299
 
3287
- # --- Change 1: robust direction seeding via spread-out probes ---
3288
- seed_fractions = [0.1, 0.25, 0.5, 0.75, 0.9]
3289
- seed_indices = sorted({min(n - 1, max(0, int(round(f * (n - 1))))) for f in seed_fractions})
3290
- seed_samples: list[tuple[int, float]] = [] # (idx, |delta|)
3291
- for idx in seed_indices:
3292
- d = _delta_at(idx)
3293
- if not math.isnan(d):
3294
- ad = abs(d)
3295
- seed_samples.append((idx, ad))
3296
- _consider(idx, ad)
3297
-
3298
- if len(seed_samples) < 2:
3299
- if best_index < 0:
3300
- trading_logger.log_warning(
3301
- f"find_option_with_delta: no valid option strike found (seed failure). "
3302
- f"price_f={price_f} target_delta={target_delta} return_lower_delta={return_lower_delta} "
3303
- f"chain_len={n} strike_range=[{strike_lo}, {strike_hi}] "
3304
- f"valid_delta_count={valid_delta_count} nan_delta_count={nan_delta_count} "
3305
- f"total_delta_checks={total_delta_checks} nan_delta_strikes={sorted(set(nan_delta_strikes))}"
3306
- )
3307
- return best_index
3308
-
3309
- # Determine monotonicity from lowest- vs. highest-strike valid seeds.
3310
- increasing = seed_samples[-1][1] > seed_samples[0][1]
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
3311
3308
 
3312
- # --- Change 2: NaN at mid -> scan to nearest valid neighbor instead of shrinking range ---
3313
- 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
+ )
3314
3313
 
3315
- def _nearest_valid(mid: int, lo: int, hi: int) -> int:
3316
- # Returns idx of nearest valid delta within [lo, hi], or -1 if none within cap.
3317
- for step in range(1, NAN_SCAN_CAP + 1):
3318
- for cand in (mid - step, mid + step):
3319
- if lo <= cand <= hi and not math.isnan(_delta_at(cand)):
3320
- 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
+ )
3321
3332
  return -1
3322
3333
 
3323
- while left <= right:
3324
- mid = (left + right) // 2
3325
- d = _delta_at(mid)
3326
- eff_idx = mid
3327
- if math.isnan(d):
3328
- alt = _nearest_valid(mid, left, right)
3329
- if alt < 0:
3330
- # Wide NaN band: stop here instead of expanding into a full-chain scan.
3331
- break
3332
- eff_idx = alt
3333
- d = delta_cache[alt]
3334
- ad = abs(d)
3335
- _consider(eff_idx, ad)
3336
-
3337
- if increasing:
3338
- if ad > target_delta:
3339
- right = eff_idx - 1
3340
- else:
3341
- left = eff_idx + 1
3342
- else:
3343
- if ad > target_delta:
3344
- 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
3345
3348
  else:
3346
- right = eff_idx - 1
3349
+ if ad >= target_delta and ad < best_d:
3350
+ best_idx, best_d = i, ad
3351
+ return best_idx
3347
3352
 
3348
- if best_index < 0:
3353
+ model_idx = _pick_best(abs_deltas)
3354
+ if model_idx < 0:
3349
3355
  trading_logger.log_warning(
3350
- f"find_option_with_delta: no valid option strike found. "
3351
- f"price_f={price_f} chain_len={n} strike_range=[{strike_lo}, {strike_hi}] "
3352
- f"valid_delta_count={valid_delta_count} nan_delta_count={nan_delta_count} total_delta_checks={total_delta_checks} "
3353
- 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}"
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)
3354
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
+ )
3355
3389
  return best_index
3356
3390
 
3357
3391
 
@@ -3382,6 +3416,7 @@ def get_delta_strike(
3382
3416
  exchange="NSE",
3383
3417
  mds: Optional[str] = None,
3384
3418
  as_of: Optional[Union[dt.datetime, dt.date, str, pd.Timestamp]] = None,
3419
+ max_moneyness: float = 0.07,
3385
3420
  ) -> Union[str, None]:
3386
3421
  """Get option strike price for a given delta with enhanced error handling.
3387
3422
 
@@ -3403,6 +3438,10 @@ def get_delta_strike(
3403
3438
  as_of: Optional. When omitted (default), behavior matches pre-change realtime paths.
3404
3439
  When set, underlying and option marks use historical OHLC at that time; T for IV/delta
3405
3440
  uses ``as_of`` instead of now.
3441
+ max_moneyness: Maximum allowed deviation of the found strike from the underlying price,
3442
+ as a fraction (e.g. 0.07 = 7%). Applies symmetrically to ITM and OTM strikes.
3443
+ Rejects results where stale market data produced a plausible-looking but wrong delta.
3444
+ Defaults to 0.07.
3406
3445
 
3407
3446
  Returns:
3408
3447
  Union[str, None]: Option symbol or None if not found
@@ -3473,7 +3512,17 @@ def get_delta_strike(
3473
3512
  as_of=as_of,
3474
3513
  )
3475
3514
  if index >= 0:
3476
- return option_chain[index]
3515
+ sym = option_chain[index]
3516
+ strike = float(sym.split("_")[4])
3517
+ deviation = abs(strike / price_f - 1.0)
3518
+ if deviation > max_moneyness:
3519
+ trading_logger.log_warning(
3520
+ f"get_delta_strike: moneyness sanity check failed. "
3521
+ f"strike={strike} price_f={price_f} deviation={deviation:.1%} max_moneyness={max_moneyness:.1%} "
3522
+ f"delta={delta} option_type={option_type} symbol={sym}"
3523
+ )
3524
+ return None
3525
+ return sym
3477
3526
  else:
3478
3527
  trading_logger.log_debug(f"get_delta_strike: no option found (index={index})")
3479
3528
  return None
@@ -4597,3 +4646,68 @@ def register_strategy_capital(
4597
4646
  except Exception as e:
4598
4647
  trading_logger.log_error(f"Failed to register strategy capital: {e}", exc_info=True)
4599
4648
  return False
4649
+
4650
+
4651
+ def close_all_positions(
4652
+ broker: BrokerBase,
4653
+ strategy: str,
4654
+ exchange: str,
4655
+ paper: bool,
4656
+ refresh_status: bool = True,
4657
+ price_types: list[str] = ["LMT"],
4658
+ exclude_today_expiry: bool = False,
4659
+ publish: bool = True,
4660
+ additional_infos: list[str] | None = None,
4661
+ ) -> list[str]:
4662
+ """Close all open positions for a strategy by placing exit orders.
4663
+
4664
+ Reads open positions from get_pnl_table (authoritative Redis state, not in-memory cache),
4665
+ places one exit order per open position, then publishes updated trades to Redis.
4666
+
4667
+ Args:
4668
+ broker: Broker instance used for order placement and Redis access.
4669
+ strategy: Strategy name.
4670
+ exchange: Exchange string (e.g. "NSE", "BSE") passed to place_combo_order.
4671
+ paper: Paper trading flag passed through to place_combo_order.
4672
+ price_types: Order price type(s), e.g. ["LMT"], ["MKT"], ["BID+0.05"].
4673
+ exclude_today_expiry: If True, skip symbols whose expiry date is today or earlier.
4674
+ publish: Whether to publish trades to Redis after placing orders.
4675
+ additional_infos: Per-order metadata list passed to place_combo_order (e.g. [json.dumps({"message": "..."})]).
4676
+
4677
+ Returns:
4678
+ List of symbols for which exit orders were successfully placed.
4679
+ """
4680
+ closed_symbols: list[str] = []
4681
+ pnl = get_pnl_table(broker, strategy, refresh_status=refresh_status)
4682
+ if pnl.empty:
4683
+ return closed_symbols
4684
+ open_rows = pnl[(pnl["entry_quantity"].fillna(0) + pnl["exit_quantity"].fillna(0)) != 0]
4685
+ for _, row in open_rows.iterrows():
4686
+ symbol = str(row["symbol"])
4687
+ if exclude_today_expiry:
4688
+ parts = symbol.split("_")
4689
+ if len(parts) > 2 and parts[2] <= dt.datetime.today().strftime("%Y%m%d"):
4690
+ trading_logger.log_info(f"close_all_positions: skipping today-expiry symbol {symbol}")
4691
+ continue
4692
+ qty = int(float(row["entry_quantity"]) + float(row["exit_quantity"]))
4693
+ if qty == 0:
4694
+ continue
4695
+ try:
4696
+ place_combo_order(
4697
+ execution_broker=broker,
4698
+ strategy=strategy,
4699
+ symbols=[symbol],
4700
+ quantities=[-qty],
4701
+ entry=False,
4702
+ exchanges=[exchange],
4703
+ price_types=price_types,
4704
+ paper=paper,
4705
+ additional_infos=additional_infos if additional_infos is not None else [""],
4706
+ )
4707
+ trading_logger.log_info(f"close_all_positions: exit placed {symbol} qty={qty}")
4708
+ closed_symbols.append(symbol)
4709
+ except Exception as e:
4710
+ trading_logger.log_error(f"close_all_positions: failed to place exit for {symbol} qty={qty}: {e}")
4711
+ if publish:
4712
+ publish_trades_to_redis(broker, strategy, publish=not paper)
4713
+ return closed_symbols
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tradingapi
3
- Version: 0.3.5
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,5 +1,6 @@
1
1
  README.md
2
2
  pyproject.toml
3
+ tests/test_calculate_delta_realtime_quotes.py
3
4
  tests/test_find_option_with_delta.py
4
5
  tradingapi/__init__.py
5
6
  tradingapi/allocation.py
@@ -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