From b5f79e52bfde05399c5f6e21fdbff748c7bcf75d Mon Sep 17 00:00:00 2001 From: Luca Date: Tue, 4 Aug 2026 18:08:55 +0100 Subject: [PATCH] Fix startegies --- quantflow/options/docs/butterfly.md | 62 +++++++++-- quantflow/options/docs/ladder.md | 83 ++++++++++++++ quantflow/options/docs/strangle.md | 4 +- quantflow/options/strategies/__init__.py | 2 + quantflow/options/strategies/butterfly.py | 6 -- .../options/strategies/calendar_spread.py | 5 - quantflow/options/strategies/ladder.py | 101 ++++++++++++++++++ quantflow/options/strategies/spread.py | 7 -- quantflow/options/strategies/straddle.py | 5 - quantflow/options/strategies/strangle.py | 5 - quantflow_tests/test_strategies.py | 52 +++++++++ 11 files changed, 297 insertions(+), 35 deletions(-) create mode 100644 quantflow/options/docs/ladder.md create mode 100644 quantflow/options/strategies/ladder.py diff --git a/quantflow/options/docs/butterfly.md b/quantflow/options/docs/butterfly.md index a873ef85..3d4d2446 100644 --- a/quantflow/options/docs/butterfly.md +++ b/quantflow/options/docs/butterfly.md @@ -1,31 +1,64 @@ # Butterfly -A butterfly consists of three strikes: a lower wing, a body, and an upper wing with equal -log-spacing. It is constructed by buying the wings and selling twice the body (long butterfly) +A butterfly consists of three strikes: a lower wing, a body, and an upper wing. +It is constructed by buying the wings and selling twice the body (long butterfly) or the reverse (short butterfly). ## Structure +All three legs share the same maturity and the same option type. Only the strikes differ. + - quantity option at K_low (lower wing) - -2 * quantity option at K_mid (body) - quantity option at K_high (upper wing) -The three strikes are symmetric in log space: log(K_mid/K_low) = log(K_high/K_mid). - A positive quantity is a long butterfly. A negative quantity is a short butterfly. +## Wing symmetry + +The strikes are usually equally spaced in price, so that the body sits midway between +the wings: + + K_mid - K_low = K_high - K_mid + +This is the conventional construction, but it is not enforced. Any three increasing strikes +are accepted, which allows unequal wing widths (a structure sometimes called a broken wing +or skip strike butterfly). + +The difference between the two wing widths drives the behaviour of the unbalanced case: + + residual = (K_mid - K_low) - (K_high - K_mid) + +This is zero when the wings are equally spaced, and two properties of the butterfly hold +only in that case. + +The first is that the payoff closes. Outside the wings a balanced butterfly expires worthless, +giving the familiar tent shaped payoff peaking at K_mid. With unequal wings a constant value +equal to the residual remains beyond the outer strikes: a loss when the upper wing is wider, +a gain when the lower wing is wider. + +The second is that the call and put constructions coincide (see below). + ## Call vs Put construction By put-call parity, a butterfly built entirely with calls is equivalent in price to one built -entirely with puts. The choice is purely a liquidity consideration: +entirely with puts, provided the wings are equally spaced. The price difference between the two +constructions is exactly the residual defined above, so it vanishes only in the balanced case. + +For a balanced butterfly the choice is therefore purely a liquidity consideration: - Body above ATM (moneyness > 0): use calls, which are more liquid OTM on the upside - Body below ATM (moneyness < 0): use puts, which are more liquid OTM on the downside - Body at ATM (moneyness = 0): either works +With unequal wings the two constructions no longer price the same, so the option type becomes +a pricing decision rather than a liquidity one and should be chosen explicitly. + ## Greeks -- Delta: near zero for log-symmetric strikes around ATM +The Greeks below describe the balanced case. + +- Delta: near zero when the wings are equally spaced and the body sits close to the forward - Gamma: small and negative when long, small and positive when short. The gamma of the wings and body largely cancel out, leaving low net exposure. - Vega: small and negative when long, small and positive when short. The vega of the three @@ -36,6 +69,23 @@ The low vega and gamma distinguish the butterfly from outright vol strategies su and strangles. The butterfly is primarily sensitive to the curvature of the vol smile across strikes, not to the overall level of volatility. +### Gamma near expiry + +The cancellation above holds only while the wings sit close to the body relative to the size of +a typical move, that is, while the wing width is small compared with sigma * sqrt(ttm). + +When the body sits at the forward and expiry approaches, the wings move far away in those units +and their gamma decays to zero, while the body gamma rises. What remains is the body alone. + +A long butterfly then carries the gamma of a short straddle struck at the body, and a short +butterfly the gamma of a long straddle. The near cancellation of the longer dated case is gone. + +The counterpart is theta: the same position earns time decay for as long as the underlying stays +pinned to the body. + +Away from the body the sign reverses. Close to expiry the position is long gamma near each wing, +where the wing option is itself at the money. + ## Use case A long butterfly profits when the underlying stays close to the body strike at expiry. diff --git a/quantflow/options/docs/ladder.md b/quantflow/options/docs/ladder.md new file mode 100644 index 00000000..7afd12b6 --- /dev/null +++ b/quantflow/options/docs/ladder.md @@ -0,0 +1,83 @@ +# Ladder + +A ladder combines three options of the same type and the same maturity: one bought at the +strike closest to the money and two sold further out of the money. It is sometimes called a +Christmas tree. + +The position is net short one option, so a long ladder is short volatility and short gamma. + +## Structure + +All three legs share the same maturity and the same option type. Only the strikes differ. + +A positive quantity is a long ladder. A negative quantity is a short ladder. + +### Call ladder + +Requires K_low < K_mid <= K_high. + +- quantity call at K_low (bought) +- -quantity call at K_mid (sold) +- -quantity call at K_high (sold) + +### Put ladder + +Requires K_low <= K_mid < K_high. + +- quantity put at K_high (bought) +- -quantity put at K_mid (sold) +- -quantity put at K_low (sold) + +## Relation to the ratio spread + +When the two sold strikes coincide (K_mid = K_high for a call ladder, K_low = K_mid for a put +ladder) the two short legs fall on the same strike and the structure becomes a ratio spread, +quoted as a 1 by 2: one option bought against two sold. + +In that case the strategy holds two legs rather than three, the sold leg carrying twice the +quantity, so the position is represented the same way a hand built 1 by 2 would be. + +The ladder is therefore the generalisation of the 1 by 2 in which the two short legs are spread +across different strikes, which softens the loss profile beyond the outer strike at the cost of +a lower premium received. + +## Payoff + +A long call ladder at expiry, ignoring the premium: + +- below K_low: zero +- between K_low and K_mid: rises one for one with the underlying +- between K_mid and K_high: flat at its maximum of K_mid - K_low +- above K_high: falls one for one with the underlying, without limit + +The upper breakeven sits at K_mid + K_high - K_low. Above that level the position loses without +limit, so a ladder is not a defined risk structure. + +The put ladder mirrors this. Its maximum is K_high - K_mid, reached between K_low and K_mid, and +its lower breakeven sits at K_low + K_mid - K_high. When that level is at or below zero the +position stays profitable all the way down. + +## Greeks + +- Delta: slightly positive for a long call ladder while the underlying sits below K_low, turning + negative as the underlying rallies through the sold strikes, and approaching -1 once all three + options are deep in the money. The put ladder mirrors this. +- Gamma: positive near the bought strike, where its gamma peaks, and negative around and beyond + the two sold strikes. The position is net short gamma overall. +- Vega: negative when long, since two options are sold against one bought. The exposure grows as + the underlying approaches the sold strikes. +- Theta: positive when long. The ladder collects time decay, which is the counterpart of its + short gamma. + +## Use case + +A long call ladder expresses a moderately bullish view with a ceiling: it pays most when the +underlying settles between K_mid and K_high, and it is often opened for a net credit because two +options are sold against one bought. The premium collected is compensation for uncapped risk +above the upper breakeven. + +A long put ladder is the mirror image, expressing a moderately bearish view with a floor. + +Selling two wing options makes the ladder attractive when the wings of the smile are rich +relative to the strike being bought, so it is sensitive to the slope and the level of the smile, +not only to the level of volatility. diff --git a/quantflow/options/docs/strangle.md b/quantflow/options/docs/strangle.md index 7a290f3a..5b4f0753 100644 --- a/quantflow/options/docs/strangle.md +++ b/quantflow/options/docs/strangle.md @@ -11,7 +11,9 @@ A positive quantity is a long strangle (long vol). A negative quantity is a shor ## Greeks -- Delta: near zero for log-symmetric strikes +- Delta: near zero when the two legs carry equal and opposite delta, which is what quoting the + strikes by delta (for example a 25 delta put against a 25 delta call) gives directly. Strikes + placed symmetrically around the forward only approximate this. - Gamma: positive when long, negative when short - Vega: positive when long, negative when short; lower magnitude than a straddle for the same notional diff --git a/quantflow/options/strategies/__init__.py b/quantflow/options/strategies/__init__.py index b26eb223..c91fc07c 100644 --- a/quantflow/options/strategies/__init__.py +++ b/quantflow/options/strategies/__init__.py @@ -1,6 +1,7 @@ from .base import Strategy, StrategyError, StrategyLeg, StrategyPrice from .butterfly import Butterfly from .calendar_spread import CalendarSpread +from .ladder import Ladder from .spread import Spread from .straddle import Straddle from .strangle import Strangle @@ -8,6 +9,7 @@ __all__ = [ "Butterfly", "CalendarSpread", + "Ladder", "Spread", "Strategy", "StrategyError", diff --git a/quantflow/options/strategies/butterfly.py b/quantflow/options/strategies/butterfly.py index 0b7679ec..9ad84689 100644 --- a/quantflow/options/strategies/butterfly.py +++ b/quantflow/options/strategies/butterfly.py @@ -21,12 +21,6 @@ def _option_type_for_log_strike(mid_log_strike: float) -> OptionType: class Butterfly(Strategy, frozen=True): - """Three-strike strategy: long wings, short body. - - Long butterfly when quantity > 0, short butterfly when quantity < 0. - Can be constructed with calls or puts, both are equivalent by put-call parity. - """ - description: ClassVar[str] = load_description("butterfly.md") @classmethod diff --git a/quantflow/options/strategies/calendar_spread.py b/quantflow/options/strategies/calendar_spread.py index 2a571f76..4a6ca3b1 100644 --- a/quantflow/options/strategies/calendar_spread.py +++ b/quantflow/options/strategies/calendar_spread.py @@ -12,11 +12,6 @@ class CalendarSpread(Strategy, frozen=True): - """Same strike, same option type, two maturities. - - Long the far maturity, short the near maturity when quantity > 0. - """ - description: ClassVar[str] = load_description("calendar_spread.md") @property diff --git a/quantflow/options/strategies/ladder.py b/quantflow/options/strategies/ladder.py new file mode 100644 index 00000000..648b85c4 --- /dev/null +++ b/quantflow/options/strategies/ladder.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal +from typing import ClassVar + +from typing_extensions import Self + +from quantflow.options.inputs import OptionMetadata, OptionType +from quantflow.utils.numbers import Number, to_decimal + +from .base import Strategy, StrategyError, StrategyLeg, load_description + +Positions = tuple[tuple[Decimal, Decimal], ...] + + +class Ladder(Strategy, frozen=True): + description: ClassVar[str] = load_description("ladder.md") + + @property + def option_type(self) -> OptionType: + """Option type of the ladder.""" + return self.legs[0].meta.option_type + + @classmethod + def _from_positions( + cls, option_type: OptionType, maturity: datetime, positions: Positions + ) -> Self: + return cls( + legs=tuple( + StrategyLeg( + meta=OptionMetadata( + option_type=option_type, + strike=strike, + maturity=maturity, + ), + quantity=quantity, + ) + for strike, quantity in positions + ) + ) + + @classmethod + def call( + cls, + low_strike: Number, + mid_strike: Number, + high_strike: Number, + maturity: datetime, + quantity: Number = 1.0, + ) -> Self: + """Long call at low_strike, short calls at mid_strike and high_strike. + + When mid_strike equals high_strike the two short legs collapse onto a + single leg of twice the quantity and the ladder becomes a 1 by 2 call + ratio spread. + """ + low = to_decimal(low_strike) + mid = to_decimal(mid_strike) + high = to_decimal(high_strike) + if not (low < mid <= high): + raise StrategyError( + "Strikes must satisfy low_strike < mid_strike <= high_strike." + ) + q = to_decimal(quantity) + positions: Positions = ( + ((low, q), (mid, to_decimal(-2) * q)) + if mid == high + else ((low, q), (mid, -q), (high, -q)) + ) + return cls._from_positions(OptionType.CALL, maturity, positions) + + @classmethod + def put( + cls, + low_strike: Number, + mid_strike: Number, + high_strike: Number, + maturity: datetime, + quantity: Number = 1.0, + ) -> Self: + """Long put at high_strike, short puts at mid_strike and low_strike. + + When low_strike equals mid_strike the two short legs collapse onto a + single leg of twice the quantity and the ladder becomes a 1 by 2 put + ratio spread. + """ + low = to_decimal(low_strike) + mid = to_decimal(mid_strike) + high = to_decimal(high_strike) + if not (low <= mid < high): + raise StrategyError( + "Strikes must satisfy low_strike <= mid_strike < high_strike." + ) + q = to_decimal(quantity) + positions: Positions = ( + ((high, q), (mid, to_decimal(-2) * q)) + if low == mid + else ((high, q), (mid, -q), (low, -q)) + ) + return cls._from_positions(OptionType.PUT, maturity, positions) diff --git a/quantflow/options/strategies/spread.py b/quantflow/options/strategies/spread.py index 26535324..199d0212 100644 --- a/quantflow/options/strategies/spread.py +++ b/quantflow/options/strategies/spread.py @@ -12,13 +12,6 @@ class Spread(Strategy, frozen=True): - """Vertical spread: same option type, two strikes, same maturity. - - Long the spread when quantity > 0 (debit), short when quantity < 0 (credit). - Call spread: long low strike, short high strike. - Put spread: long high strike, short low strike. - """ - description: ClassVar[str] = load_description("spread.md") @classmethod diff --git a/quantflow/options/strategies/straddle.py b/quantflow/options/strategies/straddle.py index 1c3d94e0..156ba069 100644 --- a/quantflow/options/strategies/straddle.py +++ b/quantflow/options/strategies/straddle.py @@ -12,11 +12,6 @@ class Straddle(Strategy, frozen=True): - """Call and put at the same strike. - - Long vol when quantity > 0, short vol when quantity < 0. - """ - description: ClassVar[str] = load_description("straddle.md") @classmethod diff --git a/quantflow/options/strategies/strangle.py b/quantflow/options/strategies/strangle.py index 23f18a0f..044ad68d 100644 --- a/quantflow/options/strategies/strangle.py +++ b/quantflow/options/strategies/strangle.py @@ -12,11 +12,6 @@ class Strangle(Strategy, frozen=True): - """Call and put at different OTM strikes. - - Long vol when quantity > 0, short vol when quantity < 0. - """ - description: ClassVar[str] = load_description("strangle.md") @classmethod diff --git a/quantflow_tests/test_strategies.py b/quantflow_tests/test_strategies.py index 800de1e7..b03f49e8 100644 --- a/quantflow_tests/test_strategies.py +++ b/quantflow_tests/test_strategies.py @@ -2,13 +2,16 @@ import pytest +from quantflow.options.inputs import OptionType from quantflow.options.pricer import OptionPricer from quantflow.options.strategies import ( Butterfly, CalendarSpread, + Ladder, Spread, Straddle, Strangle, + StrategyError, ) from quantflow.sp.wiener import WienerProcess @@ -109,3 +112,52 @@ def test_calendar_spread_put_above_forward(pricer: OptionPricer) -> None: ) assert p.price > 0 assert p.delta > 0 + + +def test_call_ladder(pricer: OptionPricer) -> None: + ladder = Ladder.call(100.0, 110.0, 120.0, MATURITY) + assert ladder.option_type is OptionType.CALL + assert [float(leg.quantity) for leg in ladder.legs] == [1.0, -1.0, -1.0] + assert [float(leg.meta.strike) for leg in ladder.legs] == [100.0, 110.0, 120.0] + # net short one option: short gamma when long the ladder + assert ladder.price(pricer, FORWARD, REF_DATE).gamma < 0 + assert Ladder.description != "" + + +def test_put_ladder(pricer: OptionPricer) -> None: + ladder = Ladder.put(80.0, 90.0, 100.0, MATURITY) + assert ladder.option_type is OptionType.PUT + # long leg first, at the high strike + assert [float(leg.quantity) for leg in ladder.legs] == [1.0, -1.0, -1.0] + assert [float(leg.meta.strike) for leg in ladder.legs] == [100.0, 90.0, 80.0] + assert ladder.price(pricer, FORWARD, REF_DATE).gamma < 0 + + +def test_call_ladder_one_by_two(pricer: OptionPricer) -> None: + # mid == high collapses the two short legs into one of twice the quantity + ladder = Ladder.call(100.0, 110.0, 110.0, MATURITY) + assert len(ladder.legs) == 2 + assert [float(leg.quantity) for leg in ladder.legs] == [1.0, -2.0] + assert [float(leg.meta.strike) for leg in ladder.legs] == [100.0, 110.0] + assert ladder.price(pricer, FORWARD, REF_DATE).gamma < 0 + + +def test_put_ladder_one_by_two() -> None: + # low == mid collapses the two short legs into one of twice the quantity + ladder = Ladder.put(90.0, 90.0, 100.0, MATURITY) + assert len(ladder.legs) == 2 + assert [float(leg.quantity) for leg in ladder.legs] == [1.0, -2.0] + assert [float(leg.meta.strike) for leg in ladder.legs] == [100.0, 90.0] + + +def test_ladder_strike_ordering() -> None: + # a call ladder may repeat the two short strikes but not the long one + with pytest.raises(StrategyError): + Ladder.call(100.0, 100.0, 110.0, MATURITY) + with pytest.raises(StrategyError): + Ladder.call(100.0, 120.0, 110.0, MATURITY) + # a put ladder mirrors the constraint + with pytest.raises(StrategyError): + Ladder.put(90.0, 100.0, 100.0, MATURITY) + with pytest.raises(StrategyError): + Ladder.put(100.0, 90.0, 110.0, MATURITY)