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.
- {tradingapi-0.3.6 → tradingapi-0.3.7}/PKG-INFO +1 -1
- {tradingapi-0.3.6 → tradingapi-0.3.7}/pyproject.toml +1 -1
- tradingapi-0.3.7/tests/test_find_option_with_delta.py +156 -0
- {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi/fivepaisa.py +177 -61
- {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi/utils.py +169 -125
- {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi.egg-info/PKG-INFO +1 -1
- tradingapi-0.3.6/tests/test_find_option_with_delta.py +0 -122
- {tradingapi-0.3.6 → tradingapi-0.3.7}/README.md +0 -0
- {tradingapi-0.3.6 → tradingapi-0.3.7}/setup.cfg +0 -0
- {tradingapi-0.3.6 → tradingapi-0.3.7}/tests/test_calculate_delta_realtime_quotes.py +0 -0
- {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi/__init__.py +0 -0
- {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi/allocation.py +0 -0
- {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi/attribution.py +0 -0
- {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi/broker_base.py +0 -0
- {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi/config/commissions_20241216.yaml +0 -0
- {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi/config/config_sample.yaml +0 -0
- {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi/config.py +0 -0
- {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi/dhan.py +0 -0
- {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi/error_handling.py +0 -0
- {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi/exceptions.py +0 -0
- {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi/flattrade.py +0 -0
- {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi/globals.py +0 -0
- {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi/icicidirect.py +0 -0
- {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi/icicidirect_generate_session.py +0 -0
- {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi/market_data_exchanges.py +0 -0
- {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi/proxy_utils.py +0 -0
- {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi/shoonya.py +0 -0
- {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi/span.py +0 -0
- {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi.egg-info/SOURCES.txt +0 -0
- {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi.egg-info/dependency_links.txt +0 -0
- {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi.egg-info/entry_points.txt +0 -0
- {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi.egg-info/requires.txt +0 -0
- {tradingapi-0.3.6 → tradingapi-0.3.7}/tradingapi.egg-info/top_level.txt +0 -0
|
@@ -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
|
|
3661
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
3686
|
-
|
|
3687
|
-
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
|
|
3691
|
-
|
|
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
|
-
|
|
3695
|
-
|
|
3696
|
-
|
|
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
|
-
|
|
3723
|
-
self.
|
|
3724
|
-
|
|
3725
|
-
|
|
3726
|
-
|
|
3727
|
-
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
|
|
3734
|
-
|
|
3735
|
-
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
"
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
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=
|
|
3224
|
+
T=years_to_expiry,
|
|
3213
3225
|
OptionType=long_symbol.split("_")[3],
|
|
3214
3226
|
OptionPrice=price,
|
|
3215
3227
|
)
|
|
3216
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
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
|
-
|
|
3303
|
-
|
|
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
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
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
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
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
|
-
|
|
3349
|
+
if ad >= target_delta and ad < best_d:
|
|
3350
|
+
best_idx, best_d = i, ad
|
|
3351
|
+
return best_idx
|
|
3337
3352
|
|
|
3338
|
-
|
|
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
|
|
3341
|
-
f"price_f={price_f}
|
|
3342
|
-
f"
|
|
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,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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|