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 +193 -0
- marginism/__init__.py +62 -0
- marginism/__main__.py +4 -0
- marginism/algorithm.py +175 -0
- marginism/api.py +198 -0
- marginism/calculator.py +192 -0
- marginism/cli.py +93 -0
- marginism/exposure.py +66 -0
- marginism/model.py +179 -0
- marginism/parser.py +263 -0
- marginism/portfolio.py +98 -0
- marginism/symbols.py +107 -0
- marginism-0.1.0.dist-info/METADATA +209 -0
- marginism-0.1.0.dist-info/RECORD +18 -0
- marginism-0.1.0.dist-info/WHEEL +5 -0
- marginism-0.1.0.dist-info/entry_points.txt +2 -0
- marginism-0.1.0.dist-info/licenses/LICENSE +21 -0
- marginism-0.1.0.dist-info/top_level.txt +1 -0
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
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}
|