tradingapi 0.3.0__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. {tradingapi-0.3.0 → tradingapi-0.3.1}/PKG-INFO +1 -1
  2. {tradingapi-0.3.0 → tradingapi-0.3.1}/pyproject.toml +1 -1
  3. tradingapi-0.3.1/tests/test_find_option_with_delta.py +122 -0
  4. {tradingapi-0.3.0 → tradingapi-0.3.1}/tradingapi/dhan.py +99 -48
  5. {tradingapi-0.3.0 → tradingapi-0.3.1}/tradingapi/fivepaisa.py +28 -23
  6. {tradingapi-0.3.0 → tradingapi-0.3.1}/tradingapi/utils.py +101 -85
  7. {tradingapi-0.3.0 → tradingapi-0.3.1}/tradingapi.egg-info/PKG-INFO +1 -1
  8. {tradingapi-0.3.0 → tradingapi-0.3.1}/tradingapi.egg-info/SOURCES.txt +1 -0
  9. {tradingapi-0.3.0 → tradingapi-0.3.1}/README.md +0 -0
  10. {tradingapi-0.3.0 → tradingapi-0.3.1}/setup.cfg +0 -0
  11. {tradingapi-0.3.0 → tradingapi-0.3.1}/tradingapi/__init__.py +0 -0
  12. {tradingapi-0.3.0 → tradingapi-0.3.1}/tradingapi/allocation.py +0 -0
  13. {tradingapi-0.3.0 → tradingapi-0.3.1}/tradingapi/attribution.py +0 -0
  14. {tradingapi-0.3.0 → tradingapi-0.3.1}/tradingapi/broker_base.py +0 -0
  15. {tradingapi-0.3.0 → tradingapi-0.3.1}/tradingapi/config/commissions_20241216.yaml +0 -0
  16. {tradingapi-0.3.0 → tradingapi-0.3.1}/tradingapi/config/config_sample.yaml +0 -0
  17. {tradingapi-0.3.0 → tradingapi-0.3.1}/tradingapi/config.py +0 -0
  18. {tradingapi-0.3.0 → tradingapi-0.3.1}/tradingapi/error_handling.py +0 -0
  19. {tradingapi-0.3.0 → tradingapi-0.3.1}/tradingapi/exceptions.py +0 -0
  20. {tradingapi-0.3.0 → tradingapi-0.3.1}/tradingapi/flattrade.py +0 -0
  21. {tradingapi-0.3.0 → tradingapi-0.3.1}/tradingapi/globals.py +0 -0
  22. {tradingapi-0.3.0 → tradingapi-0.3.1}/tradingapi/icicidirect.py +0 -0
  23. {tradingapi-0.3.0 → tradingapi-0.3.1}/tradingapi/icicidirect_generate_session.py +0 -0
  24. {tradingapi-0.3.0 → tradingapi-0.3.1}/tradingapi/market_data_exchanges.py +0 -0
  25. {tradingapi-0.3.0 → tradingapi-0.3.1}/tradingapi/proxy_utils.py +0 -0
  26. {tradingapi-0.3.0 → tradingapi-0.3.1}/tradingapi/shoonya.py +0 -0
  27. {tradingapi-0.3.0 → tradingapi-0.3.1}/tradingapi/span.py +0 -0
  28. {tradingapi-0.3.0 → tradingapi-0.3.1}/tradingapi.egg-info/dependency_links.txt +0 -0
  29. {tradingapi-0.3.0 → tradingapi-0.3.1}/tradingapi.egg-info/entry_points.txt +0 -0
  30. {tradingapi-0.3.0 → tradingapi-0.3.1}/tradingapi.egg-info/requires.txt +0 -0
  31. {tradingapi-0.3.0 → tradingapi-0.3.1}/tradingapi.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tradingapi
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Trade integration with brokers
5
5
  Author-email: Pankaj Sharma <sharma.pankaj.kumar@gmail.com>
6
6
  License-Expression: MIT
@@ -28,7 +28,7 @@ packages = ["tradingapi"]
28
28
 
29
29
  [project]
30
30
  name = "tradingapi"
31
- version = "0.3.0"
31
+ version = "0.3.1"
32
32
  description = "Trade integration with brokers"
33
33
  readme = "README.md"
34
34
  license = "MIT"
@@ -0,0 +1,122 @@
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()
@@ -402,6 +402,10 @@ class Dhan(BrokerBase):
402
402
  self._global_market_data_rate_limit_lock_key = "dhan:market_data:lock"
403
403
  self._historical_audit_redis = None
404
404
  self._historical_audit_redis_db = 1
405
+ self._audit_enabled_cache: bool = False
406
+ self._audit_enabled_cache_ts: float = 0.0
407
+ self._audit_pid: str = str(os.getpid())
408
+ self._audit_proc_name: str = os.path.basename(sys.argv[0]) if sys.argv and sys.argv[0] else ""
405
409
  atexit.register(self._mark_process_shutting_down)
406
410
 
407
411
  trading_logger.log_info(
@@ -979,53 +983,51 @@ class Dhan(BrokerBase):
979
983
  parsed = parsed.replace(hour=23, minute=59, second=59, microsecond=999000)
980
984
  return parsed.isoformat(timespec="milliseconds")
981
985
 
982
- def _log_historical_request_audit(
983
- self,
984
- symbol: Optional[str],
985
- security_id: int,
986
- interval: str,
987
- from_date: str,
988
- to_date: str,
989
- ) -> None:
986
+ def _audit_dhan_call(self, call_type: str, fields: dict) -> None:
987
+ """Unified Redis stream audit for all Dhan API call types and errors.
988
+
989
+ call_type: "historical" | "quote" | "stream" | "error" | "stream_disconnect"
990
+ Toggle live (no restart): redis-cli -n 1 SET audit:dhan:enabled 1|0
991
+ """
992
+ now = time.monotonic()
993
+ if now - self._audit_enabled_cache_ts < 5.0:
994
+ if not self._audit_enabled_cache:
995
+ return
996
+ else:
997
+ try:
998
+ val = self._get_historical_audit_redis().get("audit:dhan:enabled")
999
+ self._audit_enabled_cache = val in (b"1", "1")
1000
+ except Exception:
1001
+ self._audit_enabled_cache = False
1002
+ self._audit_enabled_cache_ts = now
1003
+ if not self._audit_enabled_cache:
1004
+ return
990
1005
  try:
991
1006
  audit_redis = self._get_historical_audit_redis()
992
1007
  tz_ist = pytz.timezone("Asia/Kolkata")
993
1008
  now_ist = dt.datetime.now(tz_ist)
994
1009
  day_key = now_ist.strftime("%Y%m%d")
995
- ts_ist = now_ist.isoformat(timespec="milliseconds")
996
- symbol_str = str(symbol or "")
997
-
998
- total_key = f"audit:dhan:historical:req:count:day:{day_key}"
999
- per_symbol_key = f"audit:dhan:historical:req:count:symbol:{day_key}"
1000
- stream_key = f"audit:dhan:historical:req:v1:{day_key}"
1001
- ttl_seconds = 60 * 60 * 24 * 45
1002
-
1003
- from_dt_ist = self._format_ist_datetime_string(from_date, is_end=False)
1004
- to_dt_ist = self._format_ist_datetime_string(to_date, is_end=True)
1005
-
1010
+ ttl = 60 * 60 * 24 * 45
1011
+ stream_key = f"audit:dhan:{call_type}:req:v1:{day_key}"
1012
+ combined_key = f"audit:dhan:all:req:v1:{day_key}"
1013
+ count_key = f"audit:dhan:{call_type}:req:count:day:{day_key}"
1014
+ entry = {
1015
+ "ts_ist": now_ist.isoformat(timespec="milliseconds"),
1016
+ "call_type": call_type,
1017
+ "pid": self._audit_pid,
1018
+ "proc": self._audit_proc_name,
1019
+ **{k: str(v) for k, v in fields.items()},
1020
+ }
1006
1021
  pipe = audit_redis.pipeline()
1007
- pipe.incr(total_key)
1008
- if symbol_str:
1009
- pipe.hincrby(per_symbol_key, symbol_str, 1)
1010
- pipe.xadd(
1011
- stream_key,
1012
- {
1013
- "ts_ist": ts_ist,
1014
- "symbol": symbol_str,
1015
- "security_id": str(security_id),
1016
- "interval": str(interval),
1017
- "from_date": from_dt_ist,
1018
- "to_date": to_dt_ist,
1019
- },
1020
- maxlen=500000,
1021
- approximate=True,
1022
- )
1023
- pipe.expire(total_key, ttl_seconds)
1024
- pipe.expire(per_symbol_key, ttl_seconds)
1025
- pipe.expire(stream_key, ttl_seconds)
1022
+ pipe.incr(count_key)
1023
+ pipe.xadd(stream_key, entry, maxlen=500000, approximate=True)
1024
+ pipe.xadd(combined_key, entry, maxlen=1000000, approximate=True)
1025
+ pipe.expire(count_key, ttl)
1026
+ pipe.expire(stream_key, ttl)
1027
+ pipe.expire(combined_key, ttl)
1026
1028
  pipe.execute()
1027
1029
  except Exception as e:
1028
- trading_logger.log_warning("Failed to audit Dhan historical request", {"error": str(e), "symbol": symbol})
1030
+ trading_logger.log_warning(f"Failed to audit Dhan {call_type} call", {"error": str(e)})
1029
1031
 
1030
1032
  def _fetch_dhan_historical(
1031
1033
  self,
@@ -1060,13 +1062,13 @@ class Dhan(BrokerBase):
1060
1062
  url = self.api.base_url + "/charts/intraday"
1061
1063
 
1062
1064
  self._wait_for_historical_rate_limit()
1063
- self._log_historical_request_audit(
1064
- symbol=symbol,
1065
- security_id=security_id,
1066
- interval=periodicity,
1067
- from_date=from_date,
1068
- to_date=to_date,
1069
- )
1065
+ self._audit_dhan_call("historical", {
1066
+ "symbol": symbol or "",
1067
+ "security_id": security_id,
1068
+ "interval": periodicity,
1069
+ "from_date": from_date,
1070
+ "to_date": to_date,
1071
+ })
1070
1072
  response = self.api.session.post(url, headers=self.api.header, timeout=self.api.timeout, data=json.dumps(payload))
1071
1073
  return self.api._parse_response(response)
1072
1074
 
@@ -1809,14 +1811,22 @@ class Dhan(BrokerBase):
1809
1811
  continue
1810
1812
  historical_data_list.sort(key=lambda x: x.date)
1811
1813
  else:
1814
+ _hist_err = _extract_dhan_error_details(data)
1812
1815
  trading_logger.log_error(
1813
1816
  "No data from Dhan historical API",
1814
1817
  None,
1815
1818
  {
1816
1819
  "symbol": long_symbol,
1817
- **_extract_dhan_error_details(data),
1820
+ **_hist_err,
1818
1821
  },
1819
1822
  )
1823
+ self._audit_dhan_call("error", {
1824
+ "call_type": "historical",
1825
+ "symbol": long_symbol,
1826
+ "security_id": security_id,
1827
+ "exchange_segment": exchange_segment,
1828
+ **_hist_err,
1829
+ })
1820
1830
 
1821
1831
  if periodicity in {"1d", "d"} and date_end_dt.date() == get_tradingapi_now().date():
1822
1832
  # Refresh today's daily bar from 1m data (up-to-date partial OHLC), matching Shoonya-style behavior.
@@ -1949,6 +1959,11 @@ class Dhan(BrokerBase):
1949
1959
  raise BrokerConnectionError("Dhan API not initialized")
1950
1960
 
1951
1961
  self._wait_for_quote_rate_limit()
1962
+ self._audit_dhan_call("quote", {
1963
+ "symbol": long_symbol,
1964
+ "security_id": security_id,
1965
+ "exchange_segment": exchange_segment,
1966
+ })
1952
1967
  out = self.api.quote_data(securities={exchange_segment: [int(security_id)]})
1953
1968
  if out and out.get("status") == "success":
1954
1969
  response_data = out.get("data", {}) or {}
@@ -1971,9 +1986,17 @@ class Dhan(BrokerBase):
1971
1986
  market_feed.ask_volume = int(sell_qty[0].get("quantity", 0) or 0)
1972
1987
  return market_feed
1973
1988
 
1989
+ _quote_err = _extract_dhan_error_details(out)
1990
+ self._audit_dhan_call("error", {
1991
+ "call_type": "quote",
1992
+ "symbol": long_symbol,
1993
+ "security_id": security_id,
1994
+ "exchange_segment": exchange_segment,
1995
+ **_quote_err,
1996
+ })
1974
1997
  trading_logger.log_warning(
1975
1998
  "Dhan quote_data unavailable",
1976
- {"long_symbol": long_symbol, **_extract_dhan_error_details(out)},
1999
+ {"long_symbol": long_symbol, **_quote_err},
1977
2000
  )
1978
2001
 
1979
2002
  return market_feed
@@ -2438,6 +2461,18 @@ class Dhan(BrokerBase):
2438
2461
  # Call synchronously; avoids RuntimeError from asyncio.to_thread()
2439
2462
  # when concurrent.futures thread pool is torn down by another component.
2440
2463
  self._wait_for_stream_request_rate_limit()
2464
+ self._audit_dhan_call("stream", {
2465
+ "request_code": message.get("RequestCode", ""),
2466
+ "instrument_count": len(message.get("InstrumentList", [])),
2467
+ "operation": "subscribe",
2468
+ "symbols": ",".join(
2469
+ stream_symbol_map.get(
2470
+ (_DHAN_FEED_SEGMENT_MAP.get(instr.get("ExchangeSegment", ""), ("", -1))[1], int(instr.get("SecurityId", -1))),
2471
+ (instr.get("SecurityId", ""), ""),
2472
+ )[0]
2473
+ for instr in message.get("InstrumentList", [])
2474
+ ),
2475
+ })
2441
2476
  await ws.send(json.dumps(message))
2442
2477
  while self._streaming:
2443
2478
  raw = await ws.recv()
@@ -2462,6 +2497,10 @@ class Dhan(BrokerBase):
2462
2497
  trading_logger.log_info("Dhan streaming thread closed cleanly")
2463
2498
  else:
2464
2499
  trading_logger.log_error("Dhan streaming thread error", e, {})
2500
+ self._audit_dhan_call("stream_disconnect", {
2501
+ "error": str(e),
2502
+ "error_type": type(e).__name__,
2503
+ })
2465
2504
  finally:
2466
2505
  try:
2467
2506
  if loop is not None and not loop.is_closed():
@@ -2500,6 +2539,18 @@ class Dhan(BrokerBase):
2500
2539
  async def _send_incremental_messages():
2501
2540
  for message in delta_messages:
2502
2541
  self._wait_for_stream_request_rate_limit()
2542
+ self._audit_dhan_call("stream", {
2543
+ "request_code": message.get("RequestCode", ""),
2544
+ "instrument_count": len(message.get("InstrumentList", [])),
2545
+ "operation": "subscribe_delta",
2546
+ "symbols": ",".join(
2547
+ stream_symbol_map.get(
2548
+ (_DHAN_FEED_SEGMENT_MAP.get(instr.get("ExchangeSegment", ""), ("", -1))[1], int(instr.get("SecurityId", -1))),
2549
+ (instr.get("SecurityId", ""), ""),
2550
+ )[0]
2551
+ for instr in message.get("InstrumentList", [])
2552
+ ),
2553
+ })
2503
2554
  await ws.send(json.dumps(message))
2504
2555
 
2505
2556
  future = asyncio.run_coroutine_threadsafe(_send_incremental_messages(), loop)
@@ -45,13 +45,11 @@ def _patch_py5paisa_for_mom(api) -> None:
45
45
  call accumulates stale body keys across calls; the polluted body
46
46
  makes the 5paisa server hang past 5s. Reset to a fresh deep copy
47
47
  before each MOM call.
48
- 3. order_request() does log_response(res["body"]["Message"]) for the
49
- MOM branch, but the MOM response has no "Message" key — the KeyError
50
- is raised at the subscript (not inside log_response), is swallowed
51
- by the bare except, and the function silently returns None. Inject
52
- an empty "Message" into the response by wrapping session.post for
53
- the duration of the MOM call so the subscript succeeds and
54
- res["body"] is returned normally.
48
+ 3. order_request() does res["body"]["Message"] for MOM, but the MOM
49
+ response has no "Message" key — KeyError is raised at the subscript,
50
+ swallowed by the bare except, and the function silently returns None.
51
+ Replace order_request for the MOM case to extract res["body"] directly
52
+ without touching "Message".
55
53
  """
56
54
  if getattr(api, "_tradingapi_patched", False):
57
55
  return
@@ -64,28 +62,35 @@ def _patch_py5paisa_for_mom(api) -> None:
64
62
  except Exception:
65
63
  pass
66
64
 
67
- original_mom = api.multi_order_Margin
68
- original_post = api.session.post
65
+ original_order_request = api.order_request
69
66
 
70
- def post_with_message_key(*args, **kwargs):
71
- response = original_post(*args, **kwargs)
67
+ def safe_order_request(req_type):
68
+ if req_type != "MOM":
69
+ return original_order_request(req_type)
72
70
  try:
73
- data = response.json()
74
- if isinstance(data, dict) and isinstance(data.get("body"), dict):
75
- data["body"].setdefault("Message", "")
76
- response.json = lambda: data # type: ignore[method-assign]
77
- except Exception:
78
- pass
79
- return response
71
+ api.payload["body"]["ClientCode"] = api.client_code
72
+ api.payload["head"]["key"] = api.USER_KEY
73
+ from py5paisa.const import HEADERS
74
+ token = api.access_token or api.Jwt_token
75
+ headers = dict(HEADERS)
76
+ headers["Authorization"] = f"Bearer {token}"
77
+ headers["5Paisa-API-Uid"] = api.APIUID
78
+ res = api.session.post(api.MULTIORDERMARGIN_ROUTE, json=api.payload, headers=headers).json()
79
+ api.payload = copy.deepcopy(GENERIC_PAYLOAD)
80
+ return res.get("body")
81
+ except Exception as e:
82
+ from . import trading_logger
83
+ trading_logger.log_warning("py5paisa MOM request failed", {"error": repr(e)})
84
+ api.payload = copy.deepcopy(GENERIC_PAYLOAD)
85
+ return None
80
86
 
81
87
  def fixed_multi_order_Margin(**order):
82
88
  api.payload = copy.deepcopy(GENERIC_PAYLOAD)
83
- api.session.post = post_with_message_key # type: ignore[method-assign]
84
- try:
85
- return original_mom(**order)
86
- finally:
87
- api.session.post = original_post # type: ignore[method-assign]
89
+ for k, v in order.items():
90
+ api.payload["body"][k] = v
91
+ return safe_order_request("MOM")
88
92
 
93
+ api.order_request = safe_order_request
89
94
  api.multi_order_Margin = fixed_multi_order_Margin
90
95
  api._tradingapi_patched = True
91
96
 
@@ -3242,116 +3242,132 @@ def find_option_with_delta(
3242
3242
  chain_strikes = [_strike(s) for s in option_chain] if option_chain else []
3243
3243
  strike_lo = min(chain_strikes) if chain_strikes else float("nan")
3244
3244
  strike_hi = max(chain_strikes) if chain_strikes else float("nan")
3245
+ n = len(option_chain)
3245
3246
  total_delta_checks = 0
3246
3247
  valid_delta_count = 0
3247
3248
  nan_delta_count = 0
3248
- # Determine if delta is increasing or decreasing
3249
- mid = (left + right) // 2
3250
- delta_1, delta_2 = float("nan"), float("nan")
3251
-
3252
- # Find the first valid left-side delta
3253
- i = mid
3254
- while i >= left and math.isnan(delta_1):
3255
- total_delta_checks += 1
3256
- delta_1 = calculate_delta(
3257
- brokers, option_chain[i], price_f, market_close_time, exchange=opt_exchange, mds=mds, as_of=as_of
3249
+ nan_delta_strikes = [] # track strikes where delta could not be calculated
3250
+ # Cache so seeding sweep + binary search + linear fallback 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
3258
3259
  )
3259
- if math.isnan(delta_1):
3260
+ total_delta_checks += 1
3261
+ if d is None or math.isnan(d):
3262
+ d = float("nan")
3260
3263
  nan_delta_count += 1
3264
+ nan_delta_strikes.append(_strike(option_chain[idx]))
3261
3265
  else:
3262
3266
  valid_delta_count += 1
3263
- i -= 1
3267
+ delta_cache[idx] = d
3268
+ return d
3264
3269
 
3265
- # Find the first valid right-side delta
3266
- i = mid + 1
3267
- while i <= right and math.isnan(delta_2):
3268
- total_delta_checks += 1
3269
- delta_2 = calculate_delta(
3270
- brokers, option_chain[i], price_f, market_close_time, exchange=opt_exchange, mds=mds, as_of=as_of
3271
- )
3272
- if math.isnan(delta_2):
3273
- nan_delta_count += 1
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
3274
3276
  else:
3275
- valid_delta_count += 1
3276
- i += 1
3277
+ if abs_delta >= target_delta and abs_delta < best_delta:
3278
+ best_delta = abs_delta
3279
+ best_index = idx
3277
3280
 
3278
- # If we cannot determine a valid direction, return -1
3279
- if math.isnan(delta_1) or math.isnan(delta_2):
3280
- trading_logger.log_info(
3281
- f"find_option_with_delta: no valid option strike found (direction inference failed). "
3282
- f"price_f={price_f} target_delta={target_delta} return_lower_delta={return_lower_delta} "
3283
- f"chain_len={len(option_chain)} strike_range=[{strike_lo}, {strike_hi}] "
3284
- f"delta_1={delta_1} delta_2={delta_2} "
3285
- f"mid_strike={_strike(option_chain[mid]) if option_chain and mid < len(option_chain) else None} "
3286
- f"valid_delta_count={valid_delta_count} nan_delta_count={nan_delta_count} total_delta_checks={total_delta_checks}"
3281
+ if n == 0:
3282
+ trading_logger.log_warning(
3283
+ f"find_option_with_delta: empty option_chain. price_f={price_f} target_delta={target_delta}"
3287
3284
  )
3285
+ return -1
3286
+
3287
+ # --- Change 1: robust direction seeding via spread-out probes ---
3288
+ seed_fractions = [0.1, 0.25, 0.5, 0.75, 0.9]
3289
+ seed_indices = sorted({min(n - 1, max(0, int(round(f * (n - 1))))) for f in seed_fractions})
3290
+ seed_samples: list[tuple[int, float]] = [] # (idx, |delta|)
3291
+ for idx in seed_indices:
3292
+ d = _delta_at(idx)
3293
+ if not math.isnan(d):
3294
+ ad = abs(d)
3295
+ seed_samples.append((idx, ad))
3296
+ _consider(idx, ad)
3297
+
3298
+ if len(seed_samples) < 2:
3299
+ # Too few seeds; fall through to linear scan over the whole chain.
3300
+ for idx in range(n):
3301
+ d = _delta_at(idx)
3302
+ if math.isnan(d):
3303
+ continue
3304
+ _consider(idx, abs(d))
3305
+ if best_index < 0:
3306
+ trading_logger.log_warning(
3307
+ f"find_option_with_delta: no valid option strike found (linear fallback after seed failure). "
3308
+ f"price_f={price_f} target_delta={target_delta} return_lower_delta={return_lower_delta} "
3309
+ f"chain_len={n} strike_range=[{strike_lo}, {strike_hi}] "
3310
+ f"valid_delta_count={valid_delta_count} nan_delta_count={nan_delta_count} "
3311
+ f"total_delta_checks={total_delta_checks} nan_delta_strikes={sorted(set(nan_delta_strikes))}"
3312
+ )
3288
3313
  return best_index
3289
3314
 
3290
- increasing = abs(delta_2) > abs(delta_1) # True if deltas increase with strike price
3291
- # if delta is nan, we need to decide if the search range is to the left or right of the present strike
3292
- # and use delta_2's position vis_a-vis target_delta to determine the direction of move to identity next best delta.
3293
- move_right_on_nan_delta = True
3294
- if abs(delta_2) > target_delta and increasing:
3295
- move_right_on_nan_delta = False
3296
- elif abs(delta_2) > target_delta and not increasing:
3297
- move_right_on_nan_delta = True
3298
- elif abs(delta_2) < target_delta and increasing:
3299
- move_right_on_nan_delta = True
3300
- elif abs(delta_2) < target_delta and not increasing:
3301
- move_right_on_nan_delta = False
3315
+ # Determine monotonicity from lowest- vs. highest-strike valid seeds.
3316
+ increasing = seed_samples[-1][1] > seed_samples[0][1]
3302
3317
 
3303
- while left <= right:
3304
- mid = (left + right) // 2
3305
- delta = calculate_delta(
3306
- brokers, option_chain[mid], price_f, market_close_time, exchange=opt_exchange, mds=mds, as_of=as_of
3307
- )
3308
- total_delta_checks += 1
3309
- delta = abs(delta) # always select an option using the absolute value of delta.
3318
+ # --- Change 2: NaN at mid -> scan to nearest valid neighbor instead of shrinking range ---
3319
+ NAN_SCAN_CAP = 5
3310
3320
 
3311
- if math.isnan(delta):
3312
- nan_delta_count += 1
3313
- # Don't exclude the range containing current best (where target delta likely is).
3314
- # Otherwise e.g. NaN at mid=59 leads to right=58 and we exclude [59,119], losing index 110.
3315
- if best_index >= 0:
3316
- if mid < best_index:
3317
- left = mid + 1 # Keep [mid+1, right] so we don't drop best_index
3318
- else:
3319
- right = mid - 1 # Keep [left, mid-1]
3320
- else:
3321
- if move_right_on_nan_delta:
3322
- left = mid + 1
3323
- else:
3324
- right = mid - 1
3325
- continue
3326
- valid_delta_count += 1
3321
+ def _nearest_valid(mid: int, lo: int, hi: int) -> int:
3322
+ # Returns idx of nearest valid delta within [lo, hi], or -1 if none within cap.
3323
+ for step in range(1, NAN_SCAN_CAP + 1):
3324
+ for cand in (mid - step, mid + step):
3325
+ if lo <= cand <= hi and not math.isnan(_delta_at(cand)):
3326
+ return cand
3327
+ return -1
3327
3328
 
3328
- # Update best index if this delta is a better fit
3329
- if return_lower_delta:
3330
- if delta <= target_delta and delta > best_delta:
3331
- best_delta = delta
3332
- best_index = mid
3333
- else:
3334
- if delta >= target_delta and delta < best_delta:
3335
- best_delta = delta
3336
- best_index = mid
3329
+ while left <= right:
3330
+ mid = (left + right) // 2
3331
+ d = _delta_at(mid)
3332
+ eff_idx = mid
3333
+ if math.isnan(d):
3334
+ alt = _nearest_valid(mid, left, right)
3335
+ if alt < 0:
3336
+ # Wide NaN band — bail to linear fallback below.
3337
+ break
3338
+ eff_idx = alt
3339
+ d = delta_cache[alt]
3340
+ ad = abs(d)
3341
+ _consider(eff_idx, ad)
3337
3342
 
3338
- # Adjust binary search range based on delta trend
3339
3343
  if increasing:
3340
- if delta > target_delta:
3341
- right = mid - 1 # Search lower strikes for smaller deltas
3344
+ if ad > target_delta:
3345
+ right = eff_idx - 1
3342
3346
  else:
3343
- left = mid + 1 # Search higher strikes for bigger deltas
3347
+ left = eff_idx + 1
3344
3348
  else:
3345
- if delta > target_delta:
3346
- left = mid + 1 # Search higher strikes for smaller deltas
3349
+ if ad > target_delta:
3350
+ left = eff_idx + 1
3347
3351
  else:
3348
- right = mid - 1 # Search lower strikes for bigger deltas
3352
+ right = eff_idx - 1
3353
+
3354
+ # --- Change 3: linear-scan fallback if binary search failed to land a best ---
3355
+ if best_index < 0:
3356
+ for idx in range(n):
3357
+ if idx in delta_cache:
3358
+ d = delta_cache[idx]
3359
+ else:
3360
+ d = _delta_at(idx)
3361
+ if math.isnan(d):
3362
+ continue
3363
+ _consider(idx, abs(d))
3349
3364
 
3350
3365
  if best_index < 0:
3351
3366
  trading_logger.log_warning(
3352
3367
  f"find_option_with_delta: no valid option strike found. "
3353
- f"price_f={price_f} chain_len={len(option_chain)} strike_range=[{strike_lo}, {strike_hi}] "
3354
- f"valid_delta_count={valid_delta_count} nan_delta_count={nan_delta_count} total_delta_checks={total_delta_checks}"
3368
+ f"price_f={price_f} chain_len={n} strike_range=[{strike_lo}, {strike_hi}] "
3369
+ f"valid_delta_count={valid_delta_count} nan_delta_count={nan_delta_count} total_delta_checks={total_delta_checks} "
3370
+ f"nan_delta_strikes={sorted(set(nan_delta_strikes))}"
3355
3371
  )
3356
3372
  return best_index
3357
3373
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tradingapi
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Trade integration with brokers
5
5
  Author-email: Pankaj Sharma <sharma.pankaj.kumar@gmail.com>
6
6
  License-Expression: MIT
@@ -1,5 +1,6 @@
1
1
  README.md
2
2
  pyproject.toml
3
+ tests/test_find_option_with_delta.py
3
4
  tradingapi/__init__.py
4
5
  tradingapi/allocation.py
5
6
  tradingapi/attribution.py
File without changes
File without changes