Skip to content

Commit ac04aec

Browse files
michaelchuclaudeCopilot
authored
Add butterfly, iron condor, and covered strategies (#73)
* Add 10 new options strategies: butterflies, iron condors, and covered strategies New strategies implemented: - Long/short call butterfly (3-leg) - Long/short put butterfly (3-leg) - Iron condor and reverse iron condor (4-leg) - Iron butterfly and reverse iron butterfly (4-leg) - Covered call (synthetic, 2-leg) - Protective put (synthetic, 2-leg) Key changes: - Extended core.py to support 3+ leg strategies with proper column renaming - Added quantity multipliers per leg for butterfly strategies (1:2:1 ratio) - Added new rules for butterfly and iron condor strike ordering validation - Updated definitions.py with correct column definitions for multi-leg strategies - Added comprehensive test coverage with new multi-strike test fixture https://claude.ai/code/session_01J51zgxv7jGDSscXN8sWEdX * Remove unused reduce import from core.py https://claude.ai/code/session_01J51zgxv7jGDSscXN8sWEdX * Add calculated value assertions to new strategy tests Updated tests to verify actual P&L calculations: - Long call butterfly: entry=0.40, exit=0.05, pct=-0.88 (at 210/212.5/215) - Long put butterfly: entry=0.40, exit=0.0, pct=-1.0 (puts expired worthless) - Iron condor: entry=-1.90 (credit), exit=-0.025, pct=0.99 (kept premium) - Iron butterfly: entry=-5.00 (credit), exit=-2.475, pct=0.50 - Covered call: entry=1.50, exit=2.45, pct=0.63 - Protective put: entry=8.40, exit=7.525, pct=-0.10 https://claude.ai/code/session_01J51zgxv7jGDSscXN8sWEdX * Update README with new supported strategies Added documentation for: - Butterfly strategies (long/short call/put butterfly) - Iron condor and reverse iron condor - Iron butterfly and reverse iron butterfly - Covered call and protective put (synthetic) Also updated pip install version to 2.0.3. https://claude.ai/code/session_01J51zgxv7jGDSscXN8sWEdX * Address Copilot review comments 1. Fix lambda late binding in _apply_ratios (core.py) - Capture entry_col and exit_col as default arguments to avoid late binding issues in lambda functions 2. Add dte_range to triple/quadruple strike internal columns (definitions.py) - Restores dte_range field that was present in original code - Provides users with DTE range information in raw output https://claude.ai/code/session_01J51zgxv7jGDSscXN8sWEdX * Update tests/test_strategies.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update tests/test_strategies.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent c061c85 commit ac04aec

File tree

8 files changed

+885
-34
lines changed

8 files changed

+885
-34
lines changed

README.md

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,26 @@ Use cases for Optopsy:
1212
* Discover performance statistics on **percentage change** for various options strategies on a given stock
1313

1414
## Supported Option Strategies
15-
* Calls/Puts
16-
* Straddles/Strangles
17-
* Vertical Call/Put Spreads
15+
16+
### Single Leg
17+
* Long/Short Calls
18+
* Long/Short Puts
19+
20+
### Two Leg
21+
* Long/Short Straddles
22+
* Long/Short Strangles
23+
* Vertical Call Spreads (Bull/Bear)
24+
* Vertical Put Spreads (Bull/Bear)
25+
* Covered Call (synthetic)
26+
* Protective Put (synthetic)
27+
28+
### Three Leg (Butterflies)
29+
* Long/Short Call Butterfly
30+
* Long/Short Put Butterfly
31+
32+
### Four Leg
33+
* Iron Condor / Reverse Iron Condor
34+
* Iron Butterfly / Reverse Iron Butterfly
1835

1936
## Documentation
2037
Please see the [wiki](https://github.com/michaelchu/optopsy/wiki) for API reference.
@@ -29,7 +46,7 @@ You will need Python 3.8 or newer and Pandas 2.0.0 or newer and Numpy 1.26.0 or
2946

3047
### Installation
3148
```
32-
pip install optopsy==2.0.2
49+
pip install optopsy==2.0.3
3350
```
3451

3552
### Example

optopsy/__init__.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,19 @@
1313
short_call_spread,
1414
long_put_spread,
1515
short_put_spread,
16+
# Butterfly strategies
17+
long_call_butterfly,
18+
short_call_butterfly,
19+
long_put_butterfly,
20+
short_put_butterfly,
21+
# Iron condor and iron butterfly strategies
22+
iron_condor,
23+
reverse_iron_condor,
24+
iron_butterfly,
25+
reverse_iron_butterfly,
26+
# Covered strategies
27+
covered_call,
28+
protective_put,
1629
)
1730
from .datafeeds import csv_data
1831

@@ -30,5 +43,18 @@
3043
"short_call_spread",
3144
"long_put_spread",
3245
"short_put_spread",
46+
# Butterfly strategies
47+
"long_call_butterfly",
48+
"short_call_butterfly",
49+
"long_put_butterfly",
50+
"short_put_butterfly",
51+
# Iron condor and iron butterfly strategies
52+
"iron_condor",
53+
"reverse_iron_condor",
54+
"iron_butterfly",
55+
"reverse_iron_butterfly",
56+
# Covered strategies
57+
"covered_call",
58+
"protective_put",
3359
"csv_data",
3460
]

optopsy/core.py

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
from typing import Any, Callable, Dict, List, Optional, Tuple
22
import pandas as pd
33
import numpy as np
4-
from functools import reduce
54
from .definitions import evaluated_cols
65
from .checks import _run_checks
76

@@ -170,13 +169,25 @@ def _calculate_otm_pct(data: pd.DataFrame) -> pd.DataFrame:
170169
)
171170

172171

172+
def _get_leg_quantity(leg: Tuple) -> int:
173+
"""Get quantity for a leg, defaulting to 1 if not specified."""
174+
return leg[2] if len(leg) > 2 else 1
175+
176+
173177
def _apply_ratios(data: pd.DataFrame, leg_def: List[Tuple]) -> pd.DataFrame:
174-
"""Apply position ratios (long/short multipliers) to entry and exit prices."""
178+
"""Apply position ratios (long/short multipliers) and quantities to entry and exit prices."""
175179
for idx in range(1, len(leg_def) + 1):
176180
entry_col = f"entry_leg{idx}"
177181
exit_col = f"exit_leg{idx}"
178-
entry_kwargs = {entry_col: lambda r: r[entry_col] * leg_def[idx - 1][0].value}
179-
exit_kwargs = {exit_col: lambda r: r[exit_col] * leg_def[idx - 1][0].value}
182+
leg = leg_def[idx - 1]
183+
multiplier = leg[0].value * _get_leg_quantity(leg)
184+
# Use default arguments to capture values at each iteration (avoid late binding)
185+
entry_kwargs = {
186+
entry_col: lambda r, col=entry_col, m=multiplier: r[col] * m
187+
}
188+
exit_kwargs = {
189+
exit_col: lambda r, col=exit_col, m=multiplier: r[col] * m
190+
}
180191
data = data.assign(**entry_kwargs).assign(**exit_kwargs)
181192

182193
return data
@@ -206,6 +217,16 @@ def _assign_profit(
206217
return data
207218

208219

220+
def _rename_leg_columns(
221+
data: pd.DataFrame, leg_idx: int, join_on: List[str]
222+
) -> pd.DataFrame:
223+
"""Rename columns with leg suffix, excluding join columns."""
224+
rename_map = {
225+
col: f"{col}_leg{leg_idx}" for col in data.columns if col not in join_on
226+
}
227+
return data.rename(columns=rename_map)
228+
229+
209230
def _strategy_engine(
210231
data: pd.DataFrame,
211232
leg_def: List[Tuple],
@@ -237,19 +258,19 @@ def _rule_func(
237258
) -> pd.DataFrame:
238259
return d if r is None else r(d, ld)
239260

240-
partials = [leg[1](data) for leg in leg_def]
261+
# Pre-rename columns for each leg to avoid suffix issues with 3+ legs
262+
partials = [
263+
_rename_leg_columns(leg[1](data).copy(), idx, join_on or [])
264+
for idx, leg in enumerate(leg_def, start=1)
265+
]
241266
suffixes = [f"_leg{idx}" for idx in range(1, len(leg_def) + 1)]
242267

243-
return (
244-
reduce(
245-
lambda left, right: pd.merge(
246-
left, right, on=join_on, how="inner", suffixes=suffixes
247-
),
248-
partials,
249-
)
250-
.pipe(_rule_func, rules, leg_def)
251-
.pipe(_assign_profit, leg_def, suffixes)
252-
)
268+
# Merge all legs sequentially
269+
result = partials[0]
270+
for partial in partials[1:]:
271+
result = pd.merge(result, partial, on=join_on, how="inner")
272+
273+
return result.pipe(_rule_func, rules, leg_def).pipe(_assign_profit, leg_def, suffixes)
253274

254275

255276
def _process_strategy(data: pd.DataFrame, **context: Any) -> pd.DataFrame:

optopsy/definitions.py

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -56,26 +56,24 @@
5656

5757
triple_strike_internal_cols = [
5858
"underlying_symbol",
59-
"underlying_price_entry",
59+
"underlying_price_entry_leg1",
6060
"expiration",
6161
"dte_entry",
62+
"dte_range",
6263
"option_type_leg1",
6364
"strike_leg1",
6465
"option_type_leg2",
6566
"strike_leg2",
6667
"option_type_leg3",
6768
"strike_leg3",
68-
"entry",
69-
"exit",
70-
"long_profit",
71-
"short_profit",
72-
"long_pct_change",
73-
"short_pct_change",
69+
"total_entry_cost",
70+
"total_exit_proceeds",
71+
"pct_change",
7472
]
7573

7674
quadruple_strike_internal_cols = [
7775
"underlying_symbol",
78-
"underlying_price_entry",
76+
"underlying_price_entry_leg1",
7977
"expiration",
8078
"dte_entry",
8179
"dte_range",
@@ -87,12 +85,9 @@
8785
"strike_leg3",
8886
"option_type_leg4",
8987
"strike_leg4",
90-
"entry",
91-
"exit",
92-
"long_profit",
93-
"short_profit",
94-
"long_pct_change",
95-
"short_pct_change",
88+
"total_entry_cost",
89+
"total_exit_proceeds",
90+
"pct_change",
9691
]
9792

9893
# base columns of dataframe after aggregation(minus the calculated columns)

optopsy/rules.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,78 @@ def _rule_non_overlapping_strike(
2626
)
2727

2828
return data.query(query)
29+
30+
31+
def _rule_butterfly_strikes(
32+
data: pd.DataFrame, leg_def: List[Tuple]
33+
) -> pd.DataFrame:
34+
"""
35+
Filter butterfly strategies to ensure proper strike ordering and equal width.
36+
37+
A butterfly requires:
38+
- strike_leg1 < strike_leg2 < strike_leg3
39+
- Equal wing widths: (strike_leg2 - strike_leg1) == (strike_leg3 - strike_leg2)
40+
41+
Args:
42+
data: DataFrame containing butterfly strategy data
43+
leg_def: List of tuples defining strategy legs
44+
45+
Returns:
46+
Filtered DataFrame with valid butterfly strike configurations
47+
"""
48+
if len(leg_def) != 3:
49+
return data
50+
51+
return data.query(
52+
"strike_leg1 < strike_leg2 < strike_leg3 & "
53+
"(strike_leg2 - strike_leg1) == (strike_leg3 - strike_leg2)"
54+
)
55+
56+
57+
def _rule_iron_condor_strikes(
58+
data: pd.DataFrame, leg_def: List[Tuple]
59+
) -> pd.DataFrame:
60+
"""
61+
Filter iron condor strategies to ensure proper strike ordering.
62+
63+
An iron condor requires 4 strikes in ascending order:
64+
- strike_leg1 (long put) < strike_leg2 (short put) < strike_leg3 (short call) < strike_leg4 (long call)
65+
66+
Args:
67+
data: DataFrame containing iron condor strategy data
68+
leg_def: List of tuples defining strategy legs
69+
70+
Returns:
71+
Filtered DataFrame with valid iron condor strike configurations
72+
"""
73+
if len(leg_def) != 4:
74+
return data
75+
76+
return data.query(
77+
"strike_leg1 < strike_leg2 < strike_leg3 < strike_leg4"
78+
)
79+
80+
81+
def _rule_iron_butterfly_strikes(
82+
data: pd.DataFrame, leg_def: List[Tuple]
83+
) -> pd.DataFrame:
84+
"""
85+
Filter iron butterfly strategies to ensure proper strike ordering.
86+
87+
An iron butterfly requires:
88+
- strike_leg1 (long put) < strike_leg2 (short put) = strike_leg3 (short call) < strike_leg4 (long call)
89+
- The short put and short call share the same strike (ATM)
90+
91+
Args:
92+
data: DataFrame containing iron butterfly strategy data
93+
leg_def: List of tuples defining strategy legs
94+
95+
Returns:
96+
Filtered DataFrame with valid iron butterfly strike configurations
97+
"""
98+
if len(leg_def) != 4:
99+
return data
100+
101+
return data.query(
102+
"strike_leg1 < strike_leg2 & strike_leg2 == strike_leg3 & strike_leg3 < strike_leg4"
103+
)

0 commit comments

Comments
 (0)