Skip to content

Commit 4530dea

Browse files
committed
add calendars, allow arbitrary complex positions
1 parent 0e5abe7 commit 4530dea

File tree

5 files changed

+448
-108
lines changed

5 files changed

+448
-108
lines changed

README.md

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
11
# margin-estimator
2-
Calculate estimated margin requirements for equities, options, futures, and futures options. Based on CBOE and CME margining.
2+
Calculate estimated margin requirements for equities options, based on CBOE margining.
33

44
> [!NOTE]
5-
> Not all features are available yet, pending further development.
6-
> Currently, equity/ETF/index options are supported, for any trade
7-
> type other than ratio spreads, box spreads, and jaze lizards.
8-
> Contributions welcome!
5+
> Box spreads and some complex multi-legged positions may be calculated too conservatively.
96
107
## Installation
118

@@ -15,7 +12,7 @@ $ pip install margin_estimator
1512

1613
## Usage
1714

18-
Simply pass a list of legs to the `calculate_margin` function along with an `Underlying` object containing information on the underlying, and you'll get back margin requirement estimates for cash and margin accounts!
15+
Simply pass an arbitrary list of legs to the `calculate_margin` function along with an `Underlying` object containing information on the underlying, and you'll get back margin requirement estimates for cash and margin accounts!
1916

2017
```python
2118
from datetime import date
@@ -116,4 +113,4 @@ print(margin)
116113
>>> cash_requirement=Decimal('7555.00') margin_requirement=Decimal('3661.00')
117114
```
118115

119-
Please note that all numbers are baseline estimates based on CBOE/CME guidelines and individual broker margins will likely vary significantly.
116+
Please note that all numbers are baseline minimums from CBOE guidelines and individual broker margins will likely vary significantly.

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "margin-estimator"
7-
version = "0.1"
8-
description = "Calculate estimated margin requirements for equities, options, futures, and futures options. Based on CBOE and CME margining."
7+
version = "0.2"
8+
description = "Calculate estimated margin requirements for equities options, based on CBOE margining."
99
readme = "README.md"
1010
requires-python = ">=3.11"
1111
license = {file = "LICENSE"}

src/margin_estimator/margin.py

Lines changed: 70 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from collections import deque
12
from datetime import date, timedelta
23
from decimal import Decimal
34

@@ -13,31 +14,76 @@
1314

1415

1516
def calculate_margin(legs: list[Option], underlying: Underlying) -> MarginRequirements:
16-
"""
17-
Calculate margin for an arbitrary order according to CBOE's Margin Manual.
18-
"""
19-
if len(legs) == 1:
20-
if legs[0].quantity > 0:
21-
return _calculate_margin_long_option(legs[0])
22-
return _calculate_margin_short_option(legs[0], underlying)
23-
if len(legs) == 2 and legs[0].quantity < 0 and legs[1].quantity < 0:
24-
return _calculate_margin_short_strangle(legs, underlying)
25-
if len(legs) == 2 and legs[0].expiration != legs[1].expiration:
26-
short = legs[0] if legs[0].quantity < 0 else legs[1]
27-
long = legs[1] if legs[1] != short else legs[0]
28-
if short.expiration > long.expiration:
29-
margin = _calculate_margin_short_option(short, underlying)
30-
return margin + _calculate_margin_long_option(long)
31-
calls = [leg for leg in legs if leg.type == OptionType.CALL]
32-
puts = [leg for leg in legs if leg.type == OptionType.PUT]
33-
extra_puts = sum(c.quantity for c in calls)
34-
extra_calls = sum(p.quantity for p in puts)
35-
if extra_puts or extra_calls:
36-
raise Exception(
37-
"Ratio spreads/complex orders not supported! Try splitting your order into "
38-
"smaller components and summing the results."
17+
# sort by expiry to cover near-term risk first
18+
shorts = sorted(leg for leg in legs if leg.quantity < 0)
19+
longs = {
20+
leg: leg.quantity for leg in sorted([leg for leg in legs if leg.quantity > 0])
21+
}
22+
covered: list[Option] = []
23+
naked_shorts: list[Option] = []
24+
25+
# step 1: match spreads
26+
for short in shorts:
27+
unmatched = abs(short.quantity)
28+
# iterate our long inventory to find a cover
29+
for long, available in longs.items():
30+
if unmatched <= 0:
31+
break
32+
if available <= 0:
33+
continue
34+
# constraint: same type, long expiry >= short expiry
35+
if long.type == short.type and long.expiration >= short.expiration:
36+
paired = min(unmatched, available)
37+
covered.append(short.model_copy(update={"quantity": -paired}))
38+
covered.append(long.model_copy(update={"quantity": paired}))
39+
unmatched -= paired
40+
longs[long] -= paired
41+
42+
# remaining short quantity is naked
43+
if unmatched > 0:
44+
naked_shorts.append(short.model_copy(update={"quantity": -unmatched}))
45+
46+
# step 2: match strangles
47+
strangles: list[tuple[Option, Option]] = []
48+
naked_calls = deque(leg for leg in naked_shorts if leg.type == OptionType.CALL)
49+
naked_puts = deque(leg for leg in naked_shorts if leg.type == OptionType.PUT)
50+
51+
while naked_calls and naked_puts:
52+
call = naked_calls.popleft()
53+
put = naked_puts.popleft()
54+
q = min(abs(call.quantity), abs(put.quantity))
55+
strangles.append(
56+
(
57+
call.model_copy(update={"quantity": -q}),
58+
put.model_copy(update={"quantity": -q}),
59+
)
3960
)
40-
return _calculate_margin_spread(legs)
61+
# handle remaining quantity
62+
if rem := abs(call.quantity) - q:
63+
naked_calls.appendleft(call.model_copy(update={"quantity": -rem}))
64+
if rem := abs(put.quantity) - q:
65+
naked_puts.appendleft(put.model_copy(update={"quantity": -rem}))
66+
67+
# all unmatched options at this point go here
68+
naked = list(naked_calls)
69+
naked.extend(naked_puts)
70+
naked.extend(
71+
leg.model_copy(update={"quantity": q}) for leg, q in longs.items() if q
72+
)
73+
74+
# step 3: calculate totals
75+
total = MarginRequirements(cash_requirement=ZERO, margin_requirement=ZERO)
76+
if covered:
77+
total += _calculate_margin_spread(covered)
78+
for call, put in strangles:
79+
total += _calculate_margin_short_strangle([call, put], underlying)
80+
for leg in naked:
81+
if leg.quantity > 0:
82+
total += _calculate_margin_long_option(leg)
83+
else:
84+
total += _calculate_margin_short_option(leg, underlying)
85+
86+
return total
4187

4288

4389
def _calculate_margin_long_option(option: Option) -> MarginRequirements:

src/margin_estimator/models.py

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
from datetime import date
1+
import re
2+
from datetime import date, datetime
23
from decimal import Decimal
34
from enum import StrEnum
45

5-
from pydantic import BaseModel
6+
from pydantic import BaseModel, ConfigDict
67

78

89
class ETFType(StrEnum):
@@ -17,12 +18,51 @@ class OptionType(StrEnum):
1718

1819

1920
class Option(BaseModel):
21+
model_config = ConfigDict(frozen=True)
22+
2023
expiration: date
2124
price: Decimal
2225
quantity: int
2326
strike: Decimal
2427
type: OptionType
2528

29+
def __lt__(self, other: "Option"):
30+
return (self.expiration, self.strike, self.type.value) < (
31+
other.expiration,
32+
other.strike,
33+
other.type.value,
34+
)
35+
36+
@classmethod
37+
def from_occ(cls, symbol: str, price: Decimal, quantity: int) -> "Option":
38+
match = re.match(r"(\d{6})([CP])(\d{5})(\d{3})", symbol[6:])
39+
assert match
40+
exp = datetime.strptime(match.group(1), "%y%m%d").date()
41+
option_type = match.group(2)
42+
strike = int(match.group(3)) + Decimal(match.group(4)) / 1000
43+
return cls(
44+
expiration=exp,
45+
price=price,
46+
quantity=quantity,
47+
strike=strike,
48+
type=OptionType(option_type),
49+
)
50+
51+
@classmethod
52+
def from_dxfeed(cls, symbol: str, price: Decimal, quantity: int) -> "Option":
53+
match = re.match(r"\.([A-Z]+)(\d{6})([CP])(\d+(\.\d+)?)", symbol)
54+
assert match
55+
exp = datetime.strptime(match.group(2), "%y%m%d").date()
56+
option_type = match.group(3)
57+
strike = Decimal(match.group(4))
58+
return cls(
59+
expiration=exp,
60+
price=price,
61+
quantity=quantity,
62+
strike=strike,
63+
type=OptionType(option_type),
64+
)
65+
2666

2767
class MarginRequirements(BaseModel):
2868
cash_requirement: Decimal
@@ -34,6 +74,12 @@ def __add__(self, other: "MarginRequirements"):
3474
margin_requirement=self.margin_requirement + other.margin_requirement,
3575
)
3676

77+
def __eq__(self, other):
78+
return (
79+
self.cash_requirement == other.cash_requirement
80+
and self.margin_requirement == other.margin_requirement
81+
)
82+
3783

3884
class Underlying(BaseModel):
3985
etf_type: ETFType | None = None

0 commit comments

Comments
 (0)