tradingapi 0.2.1__tar.gz → 0.3.0__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 (30) hide show
  1. {tradingapi-0.2.1 → tradingapi-0.3.0}/PKG-INFO +1 -1
  2. {tradingapi-0.2.1 → tradingapi-0.3.0}/pyproject.toml +1 -1
  3. tradingapi-0.3.0/tradingapi/allocation.py +163 -0
  4. {tradingapi-0.2.1 → tradingapi-0.3.0}/tradingapi/broker_base.py +1 -1
  5. {tradingapi-0.2.1 → tradingapi-0.3.0}/tradingapi/config.py +16 -0
  6. {tradingapi-0.2.1 → tradingapi-0.3.0}/tradingapi/dhan.py +3 -3
  7. {tradingapi-0.2.1 → tradingapi-0.3.0}/tradingapi/fivepaisa.py +179 -0
  8. {tradingapi-0.2.1 → tradingapi-0.3.0}/tradingapi/flattrade.py +82 -1
  9. {tradingapi-0.2.1 → tradingapi-0.3.0}/tradingapi/shoonya.py +100 -16
  10. tradingapi-0.3.0/tradingapi/span.py +837 -0
  11. {tradingapi-0.2.1 → tradingapi-0.3.0}/tradingapi/utils.py +223 -50
  12. {tradingapi-0.2.1 → tradingapi-0.3.0}/tradingapi.egg-info/PKG-INFO +1 -1
  13. {tradingapi-0.2.1 → tradingapi-0.3.0}/tradingapi.egg-info/SOURCES.txt +2 -0
  14. {tradingapi-0.2.1 → tradingapi-0.3.0}/README.md +0 -0
  15. {tradingapi-0.2.1 → tradingapi-0.3.0}/setup.cfg +0 -0
  16. {tradingapi-0.2.1 → tradingapi-0.3.0}/tradingapi/__init__.py +0 -0
  17. {tradingapi-0.2.1 → tradingapi-0.3.0}/tradingapi/attribution.py +0 -0
  18. {tradingapi-0.2.1 → tradingapi-0.3.0}/tradingapi/config/commissions_20241216.yaml +0 -0
  19. {tradingapi-0.2.1 → tradingapi-0.3.0}/tradingapi/config/config_sample.yaml +0 -0
  20. {tradingapi-0.2.1 → tradingapi-0.3.0}/tradingapi/error_handling.py +0 -0
  21. {tradingapi-0.2.1 → tradingapi-0.3.0}/tradingapi/exceptions.py +0 -0
  22. {tradingapi-0.2.1 → tradingapi-0.3.0}/tradingapi/globals.py +0 -0
  23. {tradingapi-0.2.1 → tradingapi-0.3.0}/tradingapi/icicidirect.py +0 -0
  24. {tradingapi-0.2.1 → tradingapi-0.3.0}/tradingapi/icicidirect_generate_session.py +0 -0
  25. {tradingapi-0.2.1 → tradingapi-0.3.0}/tradingapi/market_data_exchanges.py +0 -0
  26. {tradingapi-0.2.1 → tradingapi-0.3.0}/tradingapi/proxy_utils.py +0 -0
  27. {tradingapi-0.2.1 → tradingapi-0.3.0}/tradingapi.egg-info/dependency_links.txt +0 -0
  28. {tradingapi-0.2.1 → tradingapi-0.3.0}/tradingapi.egg-info/entry_points.txt +0 -0
  29. {tradingapi-0.2.1 → tradingapi-0.3.0}/tradingapi.egg-info/requires.txt +0 -0
  30. {tradingapi-0.2.1 → tradingapi-0.3.0}/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.0
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.0"
32
32
  description = "Trade integration with brokers"
33
33
  readme = "README.md"
34
34
  license = "MIT"
@@ -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:
@@ -778,7 +778,7 @@ class Dhan(BrokerBase):
778
778
  combo_symbol=lambda x: isinstance(x, str) and len(x.strip()) > 0,
779
779
  order_size=lambda x: isinstance(x, (int, float)) and int(x) != 0,
780
780
  )
781
- def get_margin_requirement(self, combo_symbol: str, order_size: int, exchange: str = "NSE", mds=None) -> float:
781
+ def get_margin_requirement(self, combo_symbol: str, order_size: int, exchange: str = "NSE", mds=None, net: bool = False) -> float:
782
782
  """Return basket margin required for a single-leg or multi-leg combo."""
783
783
  try:
784
784
  if self.api is None:
@@ -826,8 +826,8 @@ class Dhan(BrokerBase):
826
826
 
827
827
  payload = {
828
828
  "dhanClientId": self.api.client_id,
829
- "includePosition": False,
830
- "includeOrder": False,
829
+ "includePosition": net,
830
+ "includeOrder": net,
831
831
  "includeOrders": False,
832
832
  "scripList": scripts,
833
833
  "scripts": scripts,
@@ -35,10 +35,65 @@ def _filter_epoch_historical_rows(rows: List["HistoricalData"]) -> List["Histori
35
35
  return [row for row in rows if getattr(row, "date", None) != dt.datetime(1970, 1, 1)]
36
36
  from py5paisa import FivePaisaClient
37
37
 
38
+
39
+ def _patch_py5paisa_for_mom(api) -> None:
40
+ """Fix three upstream py5paisa bugs that break multi_order_Margin:
41
+
42
+ 1. session is httpx.Client with 5s default timeout. Bump to 10s as a
43
+ safety margin (normal MOM responses are sub-second).
44
+ 2. self.payload = GENERIC_PAYLOAD is a reference assignment, so each
45
+ call accumulates stale body keys across calls; the polluted body
46
+ makes the 5paisa server hang past 5s. Reset to a fresh deep copy
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.
55
+ """
56
+ if getattr(api, "_tradingapi_patched", False):
57
+ return
58
+
59
+ import copy
60
+ from py5paisa.const import GENERIC_PAYLOAD
61
+
62
+ try:
63
+ api.session.timeout = 10
64
+ except Exception:
65
+ pass
66
+
67
+ original_mom = api.multi_order_Margin
68
+ original_post = api.session.post
69
+
70
+ def post_with_message_key(*args, **kwargs):
71
+ response = original_post(*args, **kwargs)
72
+ 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
80
+
81
+ def fixed_multi_order_Margin(**order):
82
+ 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]
88
+
89
+ api.multi_order_Margin = fixed_multi_order_Margin
90
+ api._tradingapi_patched = True
91
+
38
92
  from .broker_base import BrokerBase, Brokers, HistoricalData, Order, OrderInfo, OrderStatus, Price, _normalize_as_of_date
39
93
  from .config import get_config
40
94
  from .utils import (
41
95
  delete_broker_order_id,
96
+ get_price,
42
97
  json_serializer_default,
43
98
  parse_combo_symbol,
44
99
  set_starting_internal_ids_int,
@@ -47,6 +102,7 @@ from .utils import (
47
102
  from .exceptions import (
48
103
  ConfigurationError,
49
104
  DataError,
105
+ MarginError,
50
106
  RedisError,
51
107
  SymbolError,
52
108
  TradingAPIError,
@@ -840,6 +896,129 @@ class FivePaisa(BrokerBase):
840
896
  except Exception:
841
897
  pass
842
898
 
899
+ @log_execution_time
900
+ @validate_inputs(
901
+ combo_symbol=lambda x: isinstance(x, str) and len(x.strip()) > 0,
902
+ order_size=lambda x: isinstance(x, (int, float)) and int(x) != 0,
903
+ )
904
+ def get_margin_requirement(self, combo_symbol: str, order_size: int, exchange: str = "NSE", mds=None, net: bool = False) -> float:
905
+ """Return margin requirement via py5paisa multi_order_Margin API."""
906
+ try:
907
+ if self.api is None:
908
+ raise BrokerConnectionError("FivePaisa API not connected")
909
+
910
+ exch_key = str(exchange or "").upper()[0]
911
+ if exch_key not in self.exchange_mappings:
912
+ raise MarginError(f"Exchange {exchange!r} not loaded in symbol map")
913
+
914
+ symbol_map = self.exchange_mappings[exch_key]["symbol_map"]
915
+ exchange_map = self.exchange_mappings[exch_key]["exchange_map"]
916
+ exchangetype_map = self.exchange_mappings[exch_key]["exchangetype_map"]
917
+
918
+ order_requests = []
919
+ for long_symbol, leg_ratio in parse_combo_symbol(combo_symbol).items():
920
+ net_qty = int(order_size) * int(leg_ratio)
921
+ if net_qty == 0:
922
+ continue
923
+
924
+ scrip_code = symbol_map.get(long_symbol)
925
+ if scrip_code is None:
926
+ raise MarginError(f"ScripCode not found for {long_symbol!r}")
927
+
928
+ exch = exchange_map.get(long_symbol, "N")
929
+ exch_type = exchangetype_map.get(long_symbol, "D")
930
+ order_type = "B" if net_qty > 0 else "S"
931
+
932
+ quote = get_price(self, long_symbol, checks=["bid", "ask"], exchange=exchange, mds=mds)
933
+ price_candidates = [quote.last, quote.ask, quote.bid, quote.prior_close]
934
+ price = next((float(v) for v in price_candidates if not pd.isna(v) and float(v) > 0), 0.0)
935
+
936
+ order_requests.append({
937
+ "Exch": exch,
938
+ "ExchType": exch_type,
939
+ "ScripCode": int(scrip_code),
940
+ "ScripData": "",
941
+ "PlaceModifyCancel": "P",
942
+ "OrderType": order_type,
943
+ "Price": price,
944
+ "Qty": abs(net_qty),
945
+ "IsIntraday": False,
946
+ })
947
+
948
+ if not order_requests:
949
+ raise MarginError(f"No valid legs resolved from combo_symbol={combo_symbol!r}")
950
+
951
+ _patch_py5paisa_for_mom(self.api)
952
+ body = self.api.multi_order_Margin(
953
+ CoverPositions="Y" if net else "N",
954
+ Orders=order_requests,
955
+ )
956
+ if body is None:
957
+ raise MarginError("multi_order_Margin returned None")
958
+ total = body.get("TotalMarginRequired")
959
+ if total is None:
960
+ raise MarginError(f"Unexpected MultiOrderMargin response: {body}")
961
+ if float(total) < 0:
962
+ raise MarginError(f"MultiOrderMargin returned {total} (market may be closed or scrip invalid)")
963
+
964
+ return float(total)
965
+
966
+ except (ValidationError, MarginError):
967
+ raise
968
+ except Exception as e:
969
+ context = create_error_context(
970
+ combo_symbol=combo_symbol,
971
+ order_size=order_size,
972
+ exchange=exchange,
973
+ error=str(e),
974
+ )
975
+ raise MarginError(f"Failed to calculate FivePaisa margin requirement: {str(e)}", context)
976
+
977
+ @log_execution_time
978
+ @validate_inputs(
979
+ combo_symbol=lambda x: isinstance(x, str) and len(x.strip()) > 0,
980
+ order_size=lambda x: isinstance(x, (int, float)) and int(x) != 0,
981
+ )
982
+ def get_margin_requirement_new(self, combo_symbol: str, order_size: int, exchange: str = "NSE", mds=None) -> float:
983
+ """Return local NSE SPAN margin for a single-leg or multi-leg combo."""
984
+ try:
985
+ if str(exchange or "").upper() not in ("NSE", "N"):
986
+ raise MarginError(f"FivePaisa SPAN margin currently supports NSE only, got exchange={exchange}")
987
+
988
+ from .span import SpanEngine
989
+
990
+ span_cache_dir = os.path.join(config.get("datapath", os.getcwd()), "span_cache")
991
+ engine = SpanEngine(
992
+ download_dir=span_cache_dir,
993
+ lot_size_lookup=lambda symbol: self.get_min_lot_size(symbol, exchange=exchange),
994
+ )
995
+
996
+ for long_symbol, leg_ratio in parse_combo_symbol(combo_symbol).items():
997
+ net_qty = int(order_size) * int(leg_ratio)
998
+ if net_qty == 0:
999
+ continue
1000
+
1001
+ quote = self.get_quote(long_symbol, exchange=exchange)
1002
+ price_candidates = [quote.last, quote.ask, quote.bid, quote.prior_close]
1003
+ price = next((float(v) for v in price_candidates if not pd.isna(v) and float(v) > 0), None)
1004
+ if price is None:
1005
+ raise MarginError(f"No usable market price found for {long_symbol}")
1006
+
1007
+ engine.add_position(long_symbol, net_qty, price)
1008
+
1009
+ return float(engine.calculate_margin()["total_margin"])
1010
+
1011
+ except (ValidationError, MarginError):
1012
+ raise
1013
+ except Exception as e:
1014
+ context = create_error_context(
1015
+ combo_symbol=combo_symbol,
1016
+ order_size=order_size,
1017
+ exchange=exchange,
1018
+ error=str(e),
1019
+ )
1020
+ raise MarginError(f"Failed to calculate FivePaisa margin requirement: {str(e)}", context)
1021
+
843
1022
  @log_execution_time
844
1023
  @retry_on_error(max_retries=2, delay=1.0, backoff_factor=2.0)
845
1024
  def is_connected(self):
@@ -40,7 +40,7 @@ def _validate_datetime_input(date_input):
40
40
 
41
41
  def _filter_epoch_historical_rows(rows: List["HistoricalData"]) -> List["HistoricalData"]:
42
42
  return [row for row in rows if getattr(row, "date", None) != dt.datetime(1970, 1, 1)]
43
- from NorenRestApiPy.NorenApi import NorenApi
43
+ from NorenRestApiPy.NorenApi import NorenApi, position as FlatTradePosition
44
44
 
45
45
  from .broker_base import BrokerBase, Brokers, HistoricalData, Order, OrderInfo, OrderStatus, Price, _normalize_as_of_date
46
46
  from .config import get_config
@@ -52,6 +52,7 @@ from .error_handling import validate_inputs, log_execution_time, retry_on_error
52
52
  from .exceptions import (
53
53
  ConfigurationError,
54
54
  DataError,
55
+ MarginError,
55
56
  RedisError,
56
57
  SymbolError,
57
58
  TradingAPIError,
@@ -1014,6 +1015,86 @@ class FlatTrade(BrokerBase):
1014
1015
  trading_logger.log_error("Error getting available capital", e, {"broker": self.broker.name})
1015
1016
  raise MarketDataError(f"Failed to get available capital: {str(e)}")
1016
1017
 
1018
+ def get_margin_requirement(self, combo_symbol: str, order_size: int, exchange: str = "NSE", mds=None, net: bool = False) -> float:
1019
+ """Return margin requirement via FlatTrade ``span_calculator`` (Noren API)."""
1020
+ if self.api is None:
1021
+ raise BrokerConnectionError("FlatTrade API not connected")
1022
+
1023
+ exch_key = str(exchange or "").upper()[0]
1024
+ if exch_key not in self.exchange_mappings:
1025
+ raise MarginError(f"Exchange {exchange!r} not loaded in symbol map")
1026
+
1027
+ exchange_map = self.exchange_mappings[exch_key]["exchange_map"]
1028
+
1029
+ # Build set of index base symbols from _IND___ entries across all exchanges
1030
+ index_bases: set[str] = set()
1031
+ for mapping in self.exchange_mappings.values():
1032
+ for sym in mapping["symbol_map"]:
1033
+ if sym.endswith("_IND___"):
1034
+ index_bases.add(sym.split("_")[0])
1035
+
1036
+ positions = []
1037
+ for long_symbol, leg_ratio in parse_combo_symbol(combo_symbol).items():
1038
+ net_qty = int(order_size) * int(leg_ratio)
1039
+ if net_qty == 0:
1040
+ continue
1041
+
1042
+ parts = long_symbol.split("_")
1043
+ if len(parts) < 5:
1044
+ raise MarginError(f"Cannot parse long_symbol {long_symbol!r} for span_calculator")
1045
+
1046
+ base, inst_type, expiry_str = parts[0], parts[1], parts[2]
1047
+ opt_side, strike = parts[3], parts[4]
1048
+
1049
+ try:
1050
+ dt.datetime.strptime(expiry_str, "%Y%m%d")
1051
+ exd_int = int(expiry_str)
1052
+ except ValueError:
1053
+ raise MarginError(f"Cannot parse expiry date in {long_symbol!r}")
1054
+
1055
+ is_index = base in index_bases
1056
+ if inst_type == "FUT":
1057
+ instname = "FUTIDX" if is_index else "FUTSTK"
1058
+ optt = "XX"
1059
+ strprc_val = -1.0
1060
+ elif inst_type == "OPT":
1061
+ instname = "OPTIDX" if is_index else "OPTSTK"
1062
+ optt = "PE" if opt_side == "PUT" else "CE"
1063
+ strprc_val = float(strike)
1064
+ else:
1065
+ raise MarginError(f"Unknown instrument type {inst_type!r} in {long_symbol!r}")
1066
+
1067
+ exch = exchange_map.get(long_symbol)
1068
+ if exch is None:
1069
+ raise MarginError(f"Exchange not found for {long_symbol!r}")
1070
+
1071
+ pos = FlatTradePosition()
1072
+ pos.prd = "M"
1073
+ pos.exch = exch
1074
+ pos.instname = instname
1075
+ pos.symname = base
1076
+ pos.exd = exd_int
1077
+ pos.optt = optt
1078
+ pos.strprc = strprc_val
1079
+ pos.buyqty = abs(net_qty) if net_qty > 0 else 0
1080
+ pos.sellqty = abs(net_qty) if net_qty < 0 else 0
1081
+ pos.netqty = net_qty
1082
+ positions.append(pos)
1083
+
1084
+ if not positions:
1085
+ raise MarginError(f"No valid legs resolved from combo_symbol={combo_symbol!r}")
1086
+
1087
+ actid = config.get(f"{self.account_key}.USER") or ""
1088
+ result = self.api.span_calculator(actid, positions)
1089
+
1090
+ if result is None or result.get("stat") != "Ok":
1091
+ raise MarginError(f"span_calculator failed: {result}")
1092
+
1093
+ span = float(result.get("span", 0) or 0)
1094
+ expo = float(result.get("expo", 0) or 0)
1095
+ return span + expo
1096
+
1097
+
1017
1098
  @log_execution_time
1018
1099
  @retry_on_error(max_retries=2, delay=1.0, backoff_factor=2.0)
1019
1100
  def disconnect(self):