marginism 0.1.0__py3-none-any.whl

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.
marginism/README.md ADDED
@@ -0,0 +1,193 @@
1
+ # marginism
2
+
3
+ Compute **NSE / NSCCL SPAN margins** directly from the exchange's daily
4
+ CME-SPAN risk-parameter files (`.spn`, XML `fileFormat 4.00`), the same inputs a
5
+ broker's margin calculator uses.
6
+
7
+ `spanrisk.xml` in this folder is the **XSD schema** that documents the `.spn`
8
+ format; this library implements the SPAN algorithm against files that conform to
9
+ it (e.g. `nsccl.YYYYMMDD.s.spn`).
10
+
11
+ ## Why this works without an option pricer
12
+
13
+ Each contract in a `.spn` file ships a **precomputed 16-scenario risk array** —
14
+ the per-unit profit/loss under 16 combinations of price move (±1/3, ±2/3, ±3/3
15
+ of the scan range, plus two "extreme" moves) and volatility up/down. SPAN margin
16
+ is therefore pure arithmetic over those arrays; no Black-Scholes is needed at
17
+ calculation time.
18
+
19
+ ## The SPAN calculation
20
+
21
+ Per **combined commodity** (one underlying — futures + options margined
22
+ together):
23
+
24
+ ```
25
+ span_risk = max( scan_risk
26
+ + calendar (intra-commodity) spread charge
27
+ + spot/delivery charge # 0 in NSCCL files
28
+ - inter-commodity spread credit # 0 in NSCCL files
29
+ , short_option_minimum )
30
+ ```
31
+
32
+ * **Scan risk** — the largest portfolio loss across the 16 scenarios:
33
+ `max_j Σ (signed_qty × risk_array[j])`.
34
+ * **Calendar spread charge** — scan assumes all expiries move together, so a
35
+ flat charge is added back for the basis risk of long-near / short-far
36
+ positions (`dSpread` definitions, method `F`).
37
+ * **Short option minimum (SOM)** — a floor for short-option books
38
+ (`som_rate = 0` in these NSCCL files).
39
+
40
+ A broker's **initial margin = SPAN margin + Exposure (ELM) margin**. Exposure
41
+ margin is *not* in the SPAN file (it's an exchange % of notional), so it is
42
+ configured in `ExposureConfig` and applied to futures and short options.
43
+
44
+ ## Install / layout
45
+
46
+ Pure standard library (Python 3.8+), no dependencies. Drop the `marginism/`
47
+ folder on your path (or `pip install -e .`).
48
+
49
+ ## Pointing to your `.spn` file
50
+
51
+ You just give the path. Same folder or a different folder, macOS or Windows:
52
+
53
+ ```python
54
+ # Same folder as your script
55
+ SPN = "nsccl.20260529.s.spn"
56
+
57
+ # Different folder — macOS / Linux (forward slashes)
58
+ SPN = "/Users/you/Downloads/nsccl.20260529.s.spn"
59
+
60
+ # Different folder — Windows (use a raw string r"..." or forward slashes)
61
+ SPN = r"C:\Users\you\Downloads\nsccl.20260529.s.spn"
62
+ SPN = "C:/Users/you/Downloads/nsccl.20260529.s.spn"
63
+ ```
64
+
65
+ ## Quick start
66
+
67
+ ```python
68
+ from marginism import SpanCalculator, Position
69
+
70
+ calc = SpanCalculator.from_file(
71
+ SPN,
72
+ symbols=["NIFTY", "RELIANCE"], # parse only what you need (fast / light)
73
+ )
74
+
75
+ result = calc.calculate([
76
+ # quantity is entered DIRECTLY in units: NIFTY lot size 65 -> 65 = 1 lot,
77
+ # 130 = 2 lots. long +, short -
78
+ Position("NIFTY", "CE", quantity=-65, expiry="20260630", strike=24000),
79
+ Position("NIFTY", "PE", quantity=-65, expiry="20260630", strike=24000),
80
+ ])
81
+
82
+ print(result.summary())
83
+ print(result.marginism, result.exposure_margin, result.total_margin)
84
+ ```
85
+
86
+ ## Order-style API (single or multiple legs)
87
+
88
+ `RiskEngine` accepts orders by `tradingsymbol` and returns a broker-style dict
89
+ (per-leg + consolidated `initial`/`final` + `margin_benefit`). **Pure local
90
+ computation — no network, no service.**
91
+
92
+ ```python
93
+ from marginism import RiskEngine
94
+
95
+ eng = RiskEngine.from_file(SPN)
96
+
97
+ # one leg or many — a single order is just a basket of size one
98
+ res = eng.basket([
99
+ {"exchange": "NFO", "tradingsymbol": "NIFTY26JUNFUT",
100
+ "transaction_type": "BUY", "quantity": 65},
101
+ {"exchange": "NFO", "tradingsymbol": "NIFTY26JUN23000PE",
102
+ "transaction_type": "BUY", "quantity": 65},
103
+ ])
104
+ data = res["data"]
105
+ print(data["final"]["total"], data["margin_benefit"])
106
+ ```
107
+
108
+ `quantity` is entered **directly in units** (e.g. 65 for one NIFTY lot, 130 for
109
+ two); `transaction_type` is `BUY`/`SELL`. The engine is exchange-agnostic — load
110
+ an NFO, CDS, or MCX `.spn` file.
111
+
112
+ ### Two symbol formats, plus explicit fields
113
+
114
+ A contract can be named two equivalent ways, and both resolve automatically:
115
+
116
+ | Style | Future | Option |
117
+ |---|---|---|
118
+ | compact | `NIFTY26JUNFUT` | `NIFTY26JUN23700CE` (monthly), `NIFTY2660223700CE` (weekly) |
119
+ | full-date | `NIFTY30JUN26FUT` | `NIFTY30JUN2623700CE` |
120
+
121
+ Or skip tradingsymbols and pass fields directly:
122
+
123
+ ```python
124
+ eng.basket([
125
+ {"symbol": "NIFTY", "instrument": "CE", "expiry": "2026-06-30",
126
+ "strike": 23700, "transaction_type": "SELL", "quantity": 65},
127
+ ])
128
+ ```
129
+
130
+ ## Command line
131
+
132
+ ```bash
133
+ python -m marginism <file.spn> --list # all symbols
134
+ python -m marginism <file.spn> --info NIFTY # contracts/expiries
135
+ python -m marginism <file.spn> \
136
+ --pos NIFTY:FUT:-65:20260630 \
137
+ --pos NIFTY:CE:65:20260630:24000 # margin for positions
138
+ ```
139
+
140
+ ## Important notes
141
+
142
+ * **Lot sizes are not in the SPAN file.** It works in underlying units, so enter
143
+ `quantity` directly in units (NIFTY 65 = 1 lot, 130 = 2 lots).
144
+ * **No exchange tokens in the file.** Instruments are keyed by *trading symbol*
145
+ (`cc` / `pfCode`, e.g. `RELIANCE`), with internal `pfId`/`cId` ids that are
146
+ **not** NSE tokens. Map a token (e.g. `2885` → `RELIANCE`) via a separate
147
+ instrument master before calling this library.
148
+ * **Exposure rates** in `ExposureConfig` are NSE defaults (index 2%, stock
149
+ ~3.5%); override per circular via `overrides={"RELIANCE": 0.05}`.
150
+ * **Long options** carry no exposure margin (risk capped at premium); their
151
+ risk array still participates in the portfolio scan so hedges net correctly.
152
+ * `net_option_value` is the mark-to-market value of option legs (premium):
153
+ negative when net short (premium received), positive when net long.
154
+
155
+ ## Module map
156
+
157
+ | Module | Responsibility |
158
+ |-----------------|-----------------------------------------------------------|
159
+ | `parser.py` | streaming `iterparse` of `.spn` → data model (symbol filter) |
160
+ | `model.py` | dataclasses: `SpanFile`, `CombinedCommodity`, contracts, risk arrays |
161
+ | `algorithm.py` | SPAN math: scan risk, calendar spreads, SOM, net option value |
162
+ | `portfolio.py` | `Position` input + expiry normalisation |
163
+ | `exposure.py` | exposure / ELM configuration (index 2% / stock 3.5%) |
164
+ | `calculator.py` | `SpanCalculator` — load once, evaluate many portfolios |
165
+ | `symbols.py` | tradingsymbol ⇄ SPAN contract resolution |
166
+ | `api.py` | `RiskEngine` — `basket()`/`orders()`, single or many legs |
167
+ | `cli.py` | `python -m marginism` |
168
+
169
+ 100% standard library, runs fully offline — give it a `.spn` file and call a
170
+ function.
171
+
172
+ ## Reference
173
+
174
+ - NSE Clearing — NSCCL SPAN:
175
+ https://www.nseclearing.in/risk-management/equity-derivatives/nsccl-span
176
+
177
+ ## Disclaimer
178
+
179
+ The software is provided "as is", without warranty of any kind. The author
180
+ accepts **no responsibility or liability for any errors or inaccuracies in the
181
+ calculations, or for any trading losses, damages, or decisions** arising from its
182
+ use. Margins depend on the SPAN file and exposure rates you supply, may differ
183
+ from your broker's, and must be independently verified before trading. Not
184
+ financial advice; use at your own risk.
185
+
186
+ This is an independent project built by an independent developer. It is **not
187
+ affiliated with, sponsored by, endorsed by, or connected to** NSE, NSE Clearing
188
+ (NSCCL), the Chicago Mercantile Exchange (CME), or any broker or exchange.
189
+
190
+ SPAN® is a registered trademark of Chicago Mercantile Exchange Inc. All other
191
+ trademarks are the property of their respective owners. Any names are used only
192
+ for identification/descriptive purposes (nominative use) and do not imply any
193
+ affiliation, endorsement, or license.
marginism/__init__.py ADDED
@@ -0,0 +1,62 @@
1
+ """marginism — compute NSE/NSCCL SPAN margins from CME-SPAN ``.spn`` files.
2
+
3
+ Quick start
4
+ -----------
5
+ >>> from marginism import SpanCalculator, Position
6
+ >>> calc = SpanCalculator.from_file(
7
+ ... "nsccl.20260529.s/nsccl.20260529.s.spn", symbols=["NIFTY"])
8
+ >>> res = calc.calculate([
9
+ ... Position("NIFTY", "FUT", quantity=65, expiry="20260630"),
10
+ ... ])
11
+ >>> print(res.summary())
12
+
13
+ The ``.spn`` file ships precomputed 16-scenario risk arrays, so margin is pure
14
+ arithmetic — no option pricing involved. ``quantity`` is in underlying units
15
+ that you enter directly (NIFTY lot size 65 -> pass 65 for one lot, 130 for two);
16
+ long is positive, short negative.
17
+ """
18
+
19
+ from .algorithm import CommodityResult, ResolvedPosition, compute_commodity
20
+ from .calculator import MarginResult, PositionResult, SpanCalculator
21
+ from .exposure import ExposureConfig
22
+ from .model import (
23
+ CalendarSpread,
24
+ CombinedCommodity,
25
+ Contract,
26
+ FuturesContract,
27
+ OptionContract,
28
+ RiskArray,
29
+ SpanFile,
30
+ SCENARIO_LABELS,
31
+ )
32
+ from .parser import parse_spn
33
+ from .portfolio import Position, normalize_expiry
34
+ from .api import RiskEngine
35
+ from .symbols import SymbolResolver, build_symbol_index
36
+
37
+ __version__ = "0.1.0"
38
+
39
+ __all__ = [
40
+ "SpanCalculator",
41
+ "MarginResult",
42
+ "PositionResult",
43
+ "Position",
44
+ "ExposureConfig",
45
+ "RiskEngine",
46
+ "SymbolResolver",
47
+ "build_symbol_index",
48
+ "SpanFile",
49
+ "CombinedCommodity",
50
+ "Contract",
51
+ "FuturesContract",
52
+ "OptionContract",
53
+ "CalendarSpread",
54
+ "RiskArray",
55
+ "SCENARIO_LABELS",
56
+ "ResolvedPosition",
57
+ "CommodityResult",
58
+ "compute_commodity",
59
+ "parse_spn",
60
+ "normalize_expiry",
61
+ "__version__",
62
+ ]
marginism/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
marginism/algorithm.py ADDED
@@ -0,0 +1,175 @@
1
+ """The SPAN risk algorithm, evaluated over precomputed risk arrays.
2
+
3
+ Per combined commodity the SPAN risk requirement is::
4
+
5
+ span_risk = max( scan_risk
6
+ + intracommodity (calendar) spread charge
7
+ + spot/delivery charge
8
+ - intercommodity spread credit ,
9
+ short_option_minimum )
10
+
11
+ For NSCCL equity & index F&O the file carries no intercommodity credits and a
12
+ zero spot charge, so those terms are zero unless present. Scan risk and the
13
+ calendar-spread charge are the active components.
14
+
15
+ Inputs are *resolved positions*: each open position already matched to a
16
+ contract's risk array, with a signed quantity in underlying units.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass, field
22
+ from typing import Dict, List, Tuple
23
+
24
+ from .model import (
25
+ CalendarSpread,
26
+ CombinedCommodity,
27
+ Contract,
28
+ OptionContract,
29
+ SCENARIO_LABELS,
30
+ )
31
+
32
+
33
+ @dataclass
34
+ class ResolvedPosition:
35
+ contract: Contract
36
+ quantity: float # signed, in underlying units
37
+
38
+
39
+ @dataclass
40
+ class CommodityResult:
41
+ symbol: str
42
+ scan_risk: float = 0.0
43
+ worst_scenario: int = 0
44
+ worst_scenario_label: str = ""
45
+ calendar_spread_charge: float = 0.0
46
+ spot_charge: float = 0.0
47
+ intercommodity_credit: float = 0.0
48
+ short_option_minimum: float = 0.0
49
+ span_risk: float = 0.0 # the SPAN margin for this commodity
50
+ net_option_value: float = 0.0 # market value of option positions
51
+ scenario_losses: List[float] = field(default_factory=list)
52
+ notes: List[str] = field(default_factory=list)
53
+
54
+
55
+ def _scan_risk(positions: List[ResolvedPosition]) -> Tuple[float, int, List[float]]:
56
+ """Largest portfolio loss across the 16 scenarios.
57
+
58
+ Returns (scan_risk, worst_scenario_index, per_scenario_losses).
59
+ """
60
+ losses = [0.0] * 16
61
+ for p in positions:
62
+ ra = p.contract.risk_array.values
63
+ q = p.quantity
64
+ for j in range(16):
65
+ losses[j] += q * ra[j]
66
+ worst = max(range(16), key=lambda j: losses[j])
67
+ scan = max(0.0, losses[worst])
68
+ return scan, worst, losses
69
+
70
+
71
+ def _net_delta_by_expiry(positions: List[ResolvedPosition]) -> Dict[str, float]:
72
+ """Composite-delta-weighted net position per expiry (in delta units)."""
73
+ deltas: Dict[str, float] = {}
74
+ for p in positions:
75
+ pe = p.contract.expiry
76
+ cd = p.contract.risk_array.composite_delta
77
+ deltas[pe] = deltas.get(pe, 0.0) + p.quantity * cd
78
+ return deltas
79
+
80
+
81
+ def _calendar_spread_charge(
82
+ spreads: List[CalendarSpread], net_delta: Dict[str, float]
83
+ ) -> float:
84
+ """Flat-rate intra-commodity (calendar) spread charge.
85
+
86
+ Spreads are evaluated in priority order. A spread between expiry A and B
87
+ forms only when the two legs carry *opposite* signed delta (one net long,
88
+ one net short). The number of spreads is the smaller of the two
89
+ ratio-adjusted leg deltas; the charge is ``spreads * rate`` (method 'F').
90
+ Matched delta is consumed so later spreads see the remainder.
91
+ """
92
+ remaining = dict(net_delta)
93
+ total = 0.0
94
+ for spread in spreads:
95
+ if spread.charge_method not in ("F", "P"):
96
+ # 'W' (weighted-price) not used by NSCCL files; skip if it appears.
97
+ continue
98
+ if len(spread.legs) < 2:
99
+ continue
100
+ leg_a = next((l for l in spread.legs if l.side == "A"), spread.legs[0])
101
+ leg_b = next((l for l in spread.legs if l.side == "B"), spread.legs[1])
102
+ da = remaining.get(leg_a.expiry, 0.0)
103
+ db = remaining.get(leg_b.expiry, 0.0)
104
+ if da == 0.0 or db == 0.0:
105
+ continue
106
+ # opposite signs => a genuine calendar spread
107
+ if (da > 0) == (db > 0):
108
+ continue
109
+ ratio_a = leg_a.ratio or 1.0
110
+ ratio_b = leg_b.ratio or 1.0
111
+ n = min(abs(da) / ratio_a, abs(db) / ratio_b)
112
+ if n <= 0:
113
+ continue
114
+ total += n * spread.rate
115
+ # consume matched delta toward zero
116
+ remaining[leg_a.expiry] = da - (1 if da > 0 else -1) * n * ratio_a
117
+ remaining[leg_b.expiry] = db - (1 if db > 0 else -1) * n * ratio_b
118
+ return total
119
+
120
+
121
+ def _short_option_minimum(
122
+ commodity: CombinedCommodity, positions: List[ResolvedPosition]
123
+ ) -> float:
124
+ """SOM = som_rate * total short option units (NSCCL ships som_rate=0)."""
125
+ if commodity.som_rate <= 0:
126
+ return 0.0
127
+ short_units = 0.0
128
+ for p in positions:
129
+ if isinstance(p.contract, OptionContract) and p.quantity < 0:
130
+ short_units += abs(p.quantity)
131
+ return commodity.som_rate * short_units
132
+
133
+
134
+ def _net_option_value(positions: List[ResolvedPosition]) -> float:
135
+ nov = 0.0
136
+ for p in positions:
137
+ if isinstance(p.contract, OptionContract):
138
+ nov += p.quantity * p.contract.price * p.contract.cvf
139
+ return nov
140
+
141
+
142
+ def compute_commodity(
143
+ commodity: CombinedCommodity, positions: List[ResolvedPosition]
144
+ ) -> CommodityResult:
145
+ """Run the SPAN algorithm for one combined commodity."""
146
+ res = CommodityResult(symbol=commodity.cc)
147
+ if not positions:
148
+ return res
149
+
150
+ scan, worst, losses = _scan_risk(positions)
151
+ res.scan_risk = scan
152
+ res.worst_scenario = worst + 1 # 1-based for reporting
153
+ res.worst_scenario_label = SCENARIO_LABELS[worst]
154
+ res.scenario_losses = losses
155
+
156
+ net_delta = _net_delta_by_expiry(positions)
157
+ res.calendar_spread_charge = _calendar_spread_charge(
158
+ commodity.spreads, net_delta
159
+ )
160
+ res.short_option_minimum = _short_option_minimum(commodity, positions)
161
+ res.net_option_value = _net_option_value(positions)
162
+
163
+ risk = (
164
+ res.scan_risk
165
+ + res.calendar_spread_charge
166
+ + res.spot_charge
167
+ - res.intercommodity_credit
168
+ )
169
+ risk = max(risk, res.short_option_minimum)
170
+ # CME SPAN: Total requirement = SPAN risk - Net Option Value.
171
+ # NOV is negative for net-short options (premium owed) -> raises margin;
172
+ # positive for net-long options (premium owned) -> lowers margin toward 0,
173
+ # which is why long options need only the premium, not SPAN.
174
+ res.span_risk = max(0.0, risk - res.net_option_value)
175
+ return res
marginism/api.py ADDED
@@ -0,0 +1,198 @@
1
+ """High-level margins API — plain Python functions, computed locally.
2
+
3
+ Anyone with a SPAN ``.spn`` file can compute margins offline:
4
+
5
+ from marginism import RiskEngine
6
+ eng = RiskEngine.from_file("nsccl.20260529.s.spn")
7
+ result = eng.basket([
8
+ {"tradingsymbol": "NIFTY26JUN23700CE", "transaction_type": "SELL",
9
+ "quantity": 65},
10
+ ])
11
+
12
+ Works for a **single leg or many legs** — a single order is just a basket of
13
+ size one. ``basket()``/``orders()`` take a list of order dicts (``exchange``,
14
+ ``tradingsymbol``, ``transaction_type``, ``quantity`` ...) and return per-leg
15
+ figures plus consolidated ``initial``/``final`` and the hedging
16
+ ``margin_benefit``. The result dict uses a standard, broker-style field layout
17
+ (``span``/``exposure``/``option_premium``/``additional``/``total``) so it slots
18
+ into existing tooling — but it is pure local computation, no network, no service.
19
+
20
+ The engine is exchange-agnostic: point it at any CME-SPAN ``.spn`` file
21
+ (NFO / CDS / MCX) — the algorithm is the same; only the file differs.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from typing import Any, Dict, List, Optional
27
+
28
+ from .calculator import SpanCalculator
29
+ from .portfolio import Position
30
+ from .symbols import SymbolResolver
31
+
32
+ _EMPTY_CHARGES = {
33
+ "transaction_tax": 0.0,
34
+ "transaction_tax_type": "",
35
+ "exchange_turnover_charge": 0.0,
36
+ "sebi_turnover_charge": 0.0,
37
+ "brokerage": 0.0,
38
+ "stamp_duty": 0.0,
39
+ "gst": {"igst": 0.0, "cgst": 0.0, "sgst": 0.0, "total": 0.0},
40
+ "total": 0.0,
41
+ }
42
+
43
+
44
+ def _leg_block(
45
+ tradingsymbol: str,
46
+ exchange: str,
47
+ span: float,
48
+ exposure: float,
49
+ option_premium: float,
50
+ additional: float,
51
+ total: float,
52
+ ) -> Dict[str, Any]:
53
+ """One entry in the margins response (per-order or consolidated)."""
54
+ return {
55
+ "type": "equity",
56
+ "tradingsymbol": tradingsymbol,
57
+ "exchange": exchange,
58
+ "span": round(span, 2),
59
+ "exposure": round(exposure, 2),
60
+ "option_premium": round(option_premium, 2),
61
+ "additional": round(additional, 2),
62
+ "bo": 0.0,
63
+ "cash": 0.0,
64
+ "var": 0.0,
65
+ "pnl": {"realised": 0.0, "unrealised": 0.0},
66
+ "leverage": 1.0,
67
+ "charges": dict(_EMPTY_CHARGES),
68
+ "total": round(total, 2),
69
+ }
70
+
71
+
72
+ class RiskEngine:
73
+ """Compute broker-style margins for baskets of orders against a SPAN file."""
74
+
75
+ def __init__(self, calculator: SpanCalculator) -> None:
76
+ self.calc = calculator
77
+ self.resolver = SymbolResolver(calculator.span_file)
78
+
79
+ @classmethod
80
+ def from_file(cls, spn_path: str, **kw) -> "RiskEngine":
81
+ return cls(SpanCalculator.from_file(spn_path, **kw))
82
+
83
+ # -- order -> internal Position ------------------------------------
84
+ def _to_position(self, order: Dict[str, Any]) -> Position:
85
+ """Accept either a tradingsymbol OR explicit (symbol, expiry, ...).
86
+
87
+ Two equivalent ways to specify a leg:
88
+
89
+ {"tradingsymbol": "NIFTY26JUN23700CE", "transaction_type": "SELL",
90
+ "quantity": 65}
91
+
92
+ {"symbol": "NIFTY", "instrument": "CE", "expiry": "2026-06-30",
93
+ "strike": 23700, "transaction_type": "SELL", "quantity": 65}
94
+ """
95
+ side = str(order.get("transaction_type", "BUY")).upper()
96
+ sign = -1 if side in ("SELL", "S") else 1
97
+ qty = abs(float(order["quantity"])) * sign # quantity is in units
98
+
99
+ ts = order.get("tradingsymbol")
100
+ if ts:
101
+ rs = self.resolver.resolve(ts)
102
+ if rs is None:
103
+ raise KeyError(f"tradingsymbol not found in SPAN file: {ts}")
104
+ instr = "FUT" if rs.instrument == "FUT" else (
105
+ "CE" if rs.instrument == "C" else "PE"
106
+ )
107
+ return Position(rs.symbol, instr, quantity=qty,
108
+ expiry=rs.expiry, strike=rs.strike)
109
+
110
+ # explicit fields (trader-friendly: symbol + expiry + strike + type)
111
+ symbol = order.get("symbol")
112
+ if not symbol:
113
+ raise KeyError("order needs either 'tradingsymbol' or 'symbol'")
114
+ instr = str(order.get("instrument") or order.get("option_type")
115
+ or "FUT").upper()
116
+ return Position(symbol, instr, quantity=qty,
117
+ expiry=order.get("expiry"),
118
+ strike=float(order.get("strike", 0) or 0))
119
+
120
+ @staticmethod
121
+ def _label(order: Dict[str, Any]) -> str:
122
+ """Display name for a leg (tradingsymbol, or built from fields)."""
123
+ ts = order.get("tradingsymbol")
124
+ if ts:
125
+ return ts
126
+ parts = [str(order.get("symbol", "")), str(order.get("expiry", "")),
127
+ str(order.get("strike", "") or ""),
128
+ str(order.get("instrument") or order.get("option_type") or "")]
129
+ return " ".join(p for p in parts if p)
130
+
131
+ @staticmethod
132
+ def _split(result):
133
+ """Map a MarginResult to (span, exposure, option_premium, additional)."""
134
+ span = result.marginism
135
+ exposure = result.exposure_margin
136
+ additional = result.adhoc_margin
137
+ # premium *payable* only for net-long options (a debit, not margin)
138
+ option_premium = max(0.0, result.net_option_value)
139
+ return span, exposure, option_premium, additional
140
+
141
+ def basket(
142
+ self,
143
+ orders: List[Dict[str, Any]],
144
+ consider_positions: bool = True,
145
+ ) -> Dict[str, Any]:
146
+ """Consolidated basket margin with hedging benefit (1..N legs)."""
147
+ positions: List[Position] = []
148
+ leg_blocks: List[Dict[str, Any]] = []
149
+
150
+ # ---- per-leg (standalone) margins -> orders[] ----------------
151
+ sum_margin_only = 0.0 # span+exposure+additional, excl. premium
152
+ for order in orders:
153
+ pos = self._to_position(order)
154
+ positions.append(pos)
155
+ r = self.calc.calculate([pos])
156
+ span, exposure, prem, add = self._split(r)
157
+ # per-order: a long option's "total" is its premium payable
158
+ total = span + exposure + add + prem
159
+ leg = _leg_block(self._label(order), order.get("exchange", "NFO"),
160
+ span, exposure, prem, add, total)
161
+ leg_blocks.append(leg)
162
+ sum_margin_only += span + exposure + add
163
+
164
+ # ---- consolidated basket (with hedging benefit) -> final -----
165
+ R = self.calc.calculate(positions)
166
+ c_span, c_exp, c_prem, c_add = self._split(R)
167
+ # Basket margin = SPAN + exposure + additional. Long-option premium is
168
+ # already netted into SPAN via -NOV (matches the broker basket total);
169
+ # it is reported separately in `option_premium`.
170
+ final_total = c_span + c_exp + c_add
171
+ final = _leg_block("", "", c_span, c_exp, c_prem, c_add, final_total)
172
+ initial = dict(final) # single-snapshot file: initial == final
173
+
174
+ # Hedging benefit = margin saved vs holding each leg outright
175
+ # (margin-only; option premium is a cost, not a margin, so excluded).
176
+ margin_benefit = round(sum_margin_only - final_total, 2)
177
+
178
+ return {
179
+ "status": "success",
180
+ "data": {
181
+ "initial": initial,
182
+ "final": final,
183
+ "orders": leg_blocks,
184
+ "margin_benefit": margin_benefit,
185
+ },
186
+ }
187
+
188
+ def orders(self, orders: List[Dict[str, Any]]) -> Dict[str, Any]:
189
+ """Per-order margins, no netting (each leg standalone)."""
190
+ out = []
191
+ for order in orders:
192
+ pos = self._to_position(order)
193
+ r = self.calc.calculate([pos])
194
+ span, exposure, prem, add = self._split(r)
195
+ total = span + exposure + add + prem
196
+ out.append(_leg_block(self._label(order), order.get("exchange", "NFO"),
197
+ span, exposure, prem, add, total))
198
+ return {"status": "success", "data": out}