tradingapi 0.2.1__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.2.1 → tradingapi-0.3.1}/PKG-INFO +1 -1
  2. {tradingapi-0.2.1 → 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.1/tradingapi/allocation.py +163 -0
  5. {tradingapi-0.2.1 → tradingapi-0.3.1}/tradingapi/broker_base.py +1 -1
  6. {tradingapi-0.2.1 → tradingapi-0.3.1}/tradingapi/config.py +16 -0
  7. {tradingapi-0.2.1 → tradingapi-0.3.1}/tradingapi/dhan.py +102 -51
  8. {tradingapi-0.2.1 → tradingapi-0.3.1}/tradingapi/fivepaisa.py +184 -0
  9. {tradingapi-0.2.1 → tradingapi-0.3.1}/tradingapi/flattrade.py +82 -1
  10. {tradingapi-0.2.1 → tradingapi-0.3.1}/tradingapi/shoonya.py +100 -16
  11. tradingapi-0.3.1/tradingapi/span.py +837 -0
  12. {tradingapi-0.2.1 → tradingapi-0.3.1}/tradingapi/utils.py +324 -135
  13. {tradingapi-0.2.1 → tradingapi-0.3.1}/tradingapi.egg-info/PKG-INFO +1 -1
  14. {tradingapi-0.2.1 → tradingapi-0.3.1}/tradingapi.egg-info/SOURCES.txt +3 -0
  15. {tradingapi-0.2.1 → tradingapi-0.3.1}/README.md +0 -0
  16. {tradingapi-0.2.1 → tradingapi-0.3.1}/setup.cfg +0 -0
  17. {tradingapi-0.2.1 → tradingapi-0.3.1}/tradingapi/__init__.py +0 -0
  18. {tradingapi-0.2.1 → tradingapi-0.3.1}/tradingapi/attribution.py +0 -0
  19. {tradingapi-0.2.1 → tradingapi-0.3.1}/tradingapi/config/commissions_20241216.yaml +0 -0
  20. {tradingapi-0.2.1 → tradingapi-0.3.1}/tradingapi/config/config_sample.yaml +0 -0
  21. {tradingapi-0.2.1 → tradingapi-0.3.1}/tradingapi/error_handling.py +0 -0
  22. {tradingapi-0.2.1 → tradingapi-0.3.1}/tradingapi/exceptions.py +0 -0
  23. {tradingapi-0.2.1 → tradingapi-0.3.1}/tradingapi/globals.py +0 -0
  24. {tradingapi-0.2.1 → tradingapi-0.3.1}/tradingapi/icicidirect.py +0 -0
  25. {tradingapi-0.2.1 → tradingapi-0.3.1}/tradingapi/icicidirect_generate_session.py +0 -0
  26. {tradingapi-0.2.1 → tradingapi-0.3.1}/tradingapi/market_data_exchanges.py +0 -0
  27. {tradingapi-0.2.1 → tradingapi-0.3.1}/tradingapi/proxy_utils.py +0 -0
  28. {tradingapi-0.2.1 → tradingapi-0.3.1}/tradingapi.egg-info/dependency_links.txt +0 -0
  29. {tradingapi-0.2.1 → tradingapi-0.3.1}/tradingapi.egg-info/entry_points.txt +0 -0
  30. {tradingapi-0.2.1 → tradingapi-0.3.1}/tradingapi.egg-info/requires.txt +0 -0
  31. {tradingapi-0.2.1 → 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.2.1
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.2.1"
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()
@@ -0,0 +1,163 @@
1
+ """
2
+ Allocation helpers for budget-based position sizing.
3
+
4
+ Strategies read their daily pct from ~/onedrive/.tradingapi/allocations/master_allocation_today.yaml,
5
+ then call these helpers at each entry decision to derive a live per-entry target margin.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import datetime as dt
11
+ import os
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING
14
+
15
+ import yaml
16
+
17
+ if TYPE_CHECKING:
18
+ pass
19
+
20
+ _ALLOCATIONS_DIR = Path(os.path.expanduser("~/onedrive/.tradingapi/allocations"))
21
+ _TODAY_SYMLINK = _ALLOCATIONS_DIR / "master_allocation_today.yaml"
22
+
23
+
24
+ def load_today_allocation(broker_name: str, strategy_name: str) -> float:
25
+ """Return the pct (0.0–1.0) allocated to strategy_name on broker_name today.
26
+
27
+ Reads master_allocation_today.yaml (a symlink to today's dated file).
28
+ Raises FileNotFoundError if the symlink/file is missing.
29
+ Raises ValueError if the date in the file does not match today.
30
+ Returns 0.0 if the strategy is listed with pct == 0 (inactive today).
31
+ Raises KeyError if the broker/strategy is not found in the file at all.
32
+ """
33
+ if not _TODAY_SYMLINK.exists():
34
+ raise FileNotFoundError(
35
+ f"Master allocation file not found: {_TODAY_SYMLINK}. "
36
+ "Run generate_master_allocation.py before starting strategies."
37
+ )
38
+
39
+ with open(_TODAY_SYMLINK) as f:
40
+ data = yaml.safe_load(f)
41
+
42
+ file_date = str(data.get("date", "")).strip()
43
+ today_str = dt.date.today().isoformat()
44
+ if file_date != today_str:
45
+ raise ValueError(
46
+ f"Master allocation file is stale: file date={file_date!r}, today={today_str!r}. "
47
+ "Regenerate with generate_master_allocation.py."
48
+ )
49
+
50
+ broker_key = broker_name.strip().upper()
51
+ brokers = data.get("brokers", {})
52
+ if broker_key not in brokers:
53
+ raise KeyError(
54
+ f"Broker {broker_key!r} not found in master allocation. "
55
+ f"Available brokers: {list(brokers.keys())}"
56
+ )
57
+
58
+ strategies = brokers[broker_key].get("strategies", {})
59
+ strategy_key = strategy_name.strip().upper()
60
+ if strategy_key not in strategies:
61
+ raise KeyError(
62
+ f"Strategy {strategy_key!r} not found under broker {broker_key!r} in master allocation. "
63
+ f"Available strategies: {list(strategies.keys())}"
64
+ )
65
+
66
+ entry = strategies[strategy_key]
67
+ return float(entry.get("pct", 0.0))
68
+
69
+
70
+ def broker_capital(broker) -> float:
71
+ """Fetch live total capital from broker: cash + collateral."""
72
+ caps = broker.get_available_capital()
73
+ return float(caps.get("cash", 0.0)) + float(caps.get("collateral", 0.0))
74
+
75
+
76
+ def current_utilisation(broker, strategy_name: str) -> float:
77
+ """Sum of margin currently consumed by open positions of this strategy.
78
+
79
+ Calls broker.get_margin_requirement() for each open position (net=False).
80
+ Exchange is read from Redis per position; defaults to NSE if missing.
81
+ """
82
+ from tradingapi.utils import get_pnl_table, hget_with_default
83
+
84
+ try:
85
+ pnl = get_pnl_table(broker, strategy_name, refresh_status=True)
86
+ except Exception:
87
+ return 0.0
88
+
89
+ if pnl.empty:
90
+ return 0.0
91
+
92
+ open_mask = (pnl["entry_quantity"].fillna(0) + pnl["exit_quantity"].fillna(0)) != 0
93
+ exit_empty = pnl["exit_time"].fillna("").astype(str).str.strip().isin(["", "0", "NaT"])
94
+ open_pnl = pnl.loc[open_mask | exit_empty]
95
+
96
+ total = 0.0
97
+ for _, row in open_pnl.iterrows():
98
+ combo_symbol = str(row.get("symbol", "")).strip()
99
+ if not combo_symbol:
100
+ continue
101
+ net_qty = float(row.get("entry_quantity", 0) or 0) + float(row.get("exit_quantity", 0) or 0)
102
+ if net_qty == 0:
103
+ continue
104
+ # Signed: positive = long, negative = short.
105
+ # parse_combo_symbol handles both simple and combo symbols uniformly.
106
+ order_size = int(net_qty)
107
+
108
+ entry_keys_str = str(row.get("entry_keys", "")).strip()
109
+ first_key = entry_keys_str.split()[0] if entry_keys_str else ""
110
+ exchange = hget_with_default(broker, first_key, "exchange", "NSE") if first_key else "NSE"
111
+
112
+ try:
113
+ margin = broker.get_margin_requirement(combo_symbol, order_size, exchange)
114
+ if margin is not None:
115
+ total += float(margin)
116
+ except Exception:
117
+ pass
118
+ return total
119
+
120
+
121
+ def remaining_budget(budget: float, utilised: float) -> float:
122
+ return max(0.0, budget - utilised)
123
+
124
+
125
+ def _remaining_factor_sum(open_positions: int, max_positions: int, decay: float, min_frac: float) -> float:
126
+ """Sum of decay fractions for future legs [open_positions, max_positions)."""
127
+ return float(sum(
128
+ max(min_frac, decay ** k)
129
+ for k in range(open_positions, max_positions)
130
+ ))
131
+
132
+
133
+ def scalping_entry_target(
134
+ remaining: float,
135
+ open_positions: int,
136
+ max_positions: int,
137
+ decay: float,
138
+ min_frac: float,
139
+ ) -> float:
140
+ """Target margin for the next scalping entry given remaining budget.
141
+
142
+ Entry 1 (0 open): remaining / sum([1.0, 0.75, 0.5625]) ≈ remaining / 2.3125
143
+ Entry 2 (1 open): remaining / sum([0.75, 0.5625]) ≈ remaining / 1.3125
144
+ Entry 3 (2 open): remaining / 0.5625 → clamped to remaining (last leg gets all that's left)
145
+ """
146
+ if open_positions >= max_positions:
147
+ return 0.0
148
+ factor_sum = _remaining_factor_sum(open_positions, max_positions, decay, min_frac)
149
+ if factor_sum <= 0:
150
+ return remaining
151
+ raw = remaining / factor_sum
152
+ return min(raw, remaining)
153
+
154
+
155
+ def strangle_entry_target(budget_now: float, remaining: float, scheduled_entries: int) -> float:
156
+ """Target margin for the next strangle entry.
157
+
158
+ Equal weighting across scheduled entries, clamped to what's actually left.
159
+ """
160
+ if scheduled_entries <= 0:
161
+ return remaining
162
+ equal_share = budget_now / scheduled_entries
163
+ return min(equal_share, remaining)
@@ -1076,7 +1076,7 @@ class BrokerBase(ABC):
1076
1076
  pass
1077
1077
 
1078
1078
  def get_margin_requirement(
1079
- self, combo_symbol: str, order_size: int, exchange: str = "NSE", mds: Optional[str] = None
1079
+ self, combo_symbol: str, order_size: int, exchange: str = "NSE", mds: Optional[str] = None, net: bool = False
1080
1080
  ) -> Optional[float]:
1081
1081
  """Default broker margin hook. Brokers can override if supported."""
1082
1082
  _get_trading_logger().log_warning(
@@ -541,6 +541,22 @@ def get_config() -> Config:
541
541
  return cast(Config, _config_instance)
542
542
 
543
543
 
544
+ def get_fno_freeze_limit(broker_name: str, underlying: str) -> Optional[int]:
545
+ """Return max contracts per FNO order for (broker, underlying).
546
+
547
+ Values in tradingapi.yaml are total contracts (= lots × lot_size).
548
+ Returns None if no limit is configured for the broker/underlying.
549
+ """
550
+ try:
551
+ cfg = get_config()
552
+ freeze_cfg = cfg.get("freeze_limits") or {}
553
+ broker_map = (freeze_cfg.get("brokers") or {}).get(str(broker_name).strip().upper(), {})
554
+ val = broker_map.get(str(underlying).strip().upper())
555
+ return int(val) if val is not None else None
556
+ except Exception:
557
+ return None
558
+
559
+
544
560
  # Example usage with enhanced error handling
545
561
  if __name__ == "__main__":
546
562
  try:
@@ -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(
@@ -778,7 +782,7 @@ class Dhan(BrokerBase):
778
782
  combo_symbol=lambda x: isinstance(x, str) and len(x.strip()) > 0,
779
783
  order_size=lambda x: isinstance(x, (int, float)) and int(x) != 0,
780
784
  )
781
- def get_margin_requirement(self, combo_symbol: str, order_size: int, exchange: str = "NSE", mds=None) -> float:
785
+ def get_margin_requirement(self, combo_symbol: str, order_size: int, exchange: str = "NSE", mds=None, net: bool = False) -> float:
782
786
  """Return basket margin required for a single-leg or multi-leg combo."""
783
787
  try:
784
788
  if self.api is None:
@@ -826,8 +830,8 @@ class Dhan(BrokerBase):
826
830
 
827
831
  payload = {
828
832
  "dhanClientId": self.api.client_id,
829
- "includePosition": False,
830
- "includeOrder": False,
833
+ "includePosition": net,
834
+ "includeOrder": net,
831
835
  "includeOrders": False,
832
836
  "scripList": scripts,
833
837
  "scripts": scripts,
@@ -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)