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.
- {tradingapi-0.3.5 → tradingapi-0.3.7}/PKG-INFO +1 -1
- {tradingapi-0.3.5 → tradingapi-0.3.7}/pyproject.toml +1 -1
- tradingapi-0.3.7/tests/test_calculate_delta_realtime_quotes.py +29 -0
- tradingapi-0.3.7/tests/test_find_option_with_delta.py +156 -0
- {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi/allocation.py +1 -1
- {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi/fivepaisa.py +176 -61
- {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi/utils.py +256 -142
- {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi.egg-info/PKG-INFO +1 -1
- {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi.egg-info/SOURCES.txt +1 -0
- tradingapi-0.3.5/tests/test_find_option_with_delta.py +0 -122
- {tradingapi-0.3.5 → tradingapi-0.3.7}/README.md +0 -0
- {tradingapi-0.3.5 → tradingapi-0.3.7}/setup.cfg +0 -0
- {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi/__init__.py +0 -0
- {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi/attribution.py +0 -0
- {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi/broker_base.py +0 -0
- {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi/config/commissions_20241216.yaml +0 -0
- {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi/config/config_sample.yaml +0 -0
- {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi/config.py +0 -0
- {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi/dhan.py +0 -0
- {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi/error_handling.py +0 -0
- {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi/exceptions.py +0 -0
- {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi/flattrade.py +0 -0
- {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi/globals.py +0 -0
- {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi/icicidirect.py +0 -0
- {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi/icicidirect_generate_session.py +0 -0
- {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi/market_data_exchanges.py +0 -0
- {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi/proxy_utils.py +0 -0
- {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi/shoonya.py +0 -0
- {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi/span.py +0 -0
- {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi.egg-info/dependency_links.txt +0 -0
- {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi.egg-info/entry_points.txt +0 -0
- {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi.egg-info/requires.txt +0 -0
- {tradingapi-0.3.5 → tradingapi-0.3.7}/tradingapi.egg-info/top_level.txt +0 -0
|
@@ -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.
|
|
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
|
|
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,49 +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",
|
|
3740
3856
|
)
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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"
|
|
3179
|
-
|
|
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
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
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=
|
|
3224
|
+
T=years_to_expiry,
|
|
3207
3225
|
OptionType=long_symbol.split("_")[3],
|
|
3208
3226
|
OptionPrice=price,
|
|
3209
3227
|
)
|
|
3210
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
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
|
-
|
|
3313
|
-
|
|
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
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
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
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
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
|
-
|
|
3349
|
+
if ad >= target_delta and ad < best_d:
|
|
3350
|
+
best_idx, best_d = i, ad
|
|
3351
|
+
return best_idx
|
|
3347
3352
|
|
|
3348
|
-
|
|
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
|
|
3351
|
-
f"price_f={price_f}
|
|
3352
|
-
f"
|
|
3353
|
-
|
|
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
|
-
|
|
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,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
|