Overall Statistics
Total Orders
684
Average Win
0.16%
Average Loss
-0.14%
Compounding Annual Return
-31.850%
Drawdown
3.500%
Expectancy
-0.126
Start Equity
100000
End Equity
96995
Net Profit
-3.005%
Sharpe Ratio
-3.36
Sortino Ratio
-4.097
Probabilistic Sharpe Ratio
14.070%
Loss Rate
59%
Win Rate
41%
Profit-Loss Ratio
1.12
Alpha
-0.114
Beta
-0.012
Annual Standard Deviation
0.037
Annual Variance
0.001
Information Ratio
-6.217
Tracking Error
0.145
Treynor Ratio
10.13
Total Fees
$0.00
Estimated Strategy Capacity
$1000000.00
Lowest Capacity Asset
SPXW Y59RE4SM15YM|SPX 31
Portfolio Turnover
1.65%
# region imports
from AlgorithmImports import *
from datetime import timedelta, datetime, time, date
from options import BeIronCondor, Option, OptionType
from dataclasses import dataclass
# endregion


def round_to_nearest_0_05(price):
    return round(round(price / 0.05) * 0.05, 1)

# P'_{SS} = P_{SS} + \frac{NSV - (P_{SS} - P_{LS})}{1 - \frac{\Delta_{LS}}{\Delta_{SS}}}
def estimate_pss(short_leg: OptionContract,
                 short_leg_fill_price: float,
                 long_leg: OptionContract,
                 long_leg_fill_price: float,
                 net_spread_value: float):
    return short_leg_fill_price + (net_spread_value - (short_leg_fill_price - long_leg_fill_price)) / (
        1 - long_leg.Greeks.Delta / short_leg.Greeks.Delta
    )
    


class IronCondor:
    class BeStopPrice:
        def __init__(self,
                     short_leg: OptionContract,
                     short_leg_fill_price: float,
                     long_leg: OptionContract,
                     long_leg_fill_price: float,
                     this_side_premium: float,
                     other_side_premium: float,
                     elapsed_stop_time: timedelta = None,
                     short_leg_stop_price: float = None,
                     stop_out_type: OrderType = None,
                     long_leg_sell_price: float = None,
                     short_leg_market_price_on_stop: float = None,
                     long_leg_market_price_on_stop: float = None
                     ):
            self.is_call = "C" in short_leg.symbol.value
            self.short_leg = short_leg
            self.long_leg = long_leg
            self.short_leg_fill_price = short_leg_fill_price
            self.long_leg_fill_price = long_leg_fill_price
            self.this_side_premium = this_side_premium
            self.other_side_premium = other_side_premium
            # tuning parameter
            # set stop price, stop limit price and stop market price
            # self.be_stop_price = round_to_nearest_0_05(
            #     self.long_leg_fill_price + self._get_short_leg_loss())
            self.be_stop_price = round_to_nearest_0_05(
                estimate_pss(short_leg, short_leg_fill_price, long_leg, long_leg_fill_price, this_side_premium + other_side_premium)
            )
            # self.be_stop_price = round_to_nearest_0_05(
            #    2 * self.short_leg_fill_price)
            self.stop_limit_price = round_to_nearest_0_05(
                self.be_stop_price + 0.2 if self.is_call else self.be_stop_price + 0.3)
            self.stop_market_price = round_to_nearest_0_05(
                self.stop_limit_price + 0.3)
            self.take_profit = 0.00
            self.elpased_stop_time = elapsed_stop_time
            self.short_leg_stop_price = short_leg_stop_price
            self.stop_out_type = stop_out_type
            self.long_leg_sell_price = long_leg_sell_price
            self.short_leg_market_price_on_stop = short_leg_market_price_on_stop
            self.long_leg_market_price_on_stop = long_leg_market_price_on_stop

        def _get_short_leg_loss(self):
            if self.this_side_premium < self.other_side_premium:
                return 2 * self.this_side_premium
            else:
                return self.other_side_premium + self.this_side_premium

        def get_long_leg_limit_price_on_stop(self):
            assert (self.short_leg_stop_price is not None)
            slippage = min(self.stop_slippage(), 0.3)
            # assume the long leg remains its entry price or is able to moved up a little bit to cover the stop slippage cost
            # return round_to_nearest_0_05(self.long_leg_fill_price + slippage)
            return round_to_nearest_0_05(self.short_leg_stop_price - self.this_side_premium - self.other_side_premium)
            # return round_to_nearest_0_05(self.short_leg_stop_price - self.this_side_premium - self.other_side_premium + slippage)

        def is_stopped_out(self):
            return self.short_leg_stop_price is not None

        def stop_slippage(self):
            assert (self.short_leg_stop_price is not None)
            return self.short_leg_stop_price - self.be_stop_price

        def realized_pl(self):
            pl = self.short_leg_fill_price - self.long_leg_fill_price
            if self.short_leg_stop_price is not None:
                pl -= self.short_leg_stop_price
            if self.long_leg_sell_price is not None:
                pl += self.long_leg_sell_price
            return pl

        def debug_messages(self):
            type = "CALL" if self.is_call else "PUT"
            message = [f"{type} realized p/l: {(self.realized_pl()):+.2f}"]
            if self.is_stopped_out():
                hours = self.elpased_stop_time.total_seconds() // 3600  # Total hours
                minutes = (self.elpased_stop_time.total_seconds() % 3600) // 60  # Remaining minutes
                message += [f", Stop-Out {int(hours)}h:{int(minutes)}m by {self.stop_out_type}, expected in [{self.be_stop_price}:{self.stop_limit_price}:{self.stop_market_price}], actual: {self.short_leg_stop_price}, stop_slippage: {(self.stop_slippage()):+.2f}"]
                message += [f", short_leg_market_price_on_stop: {self.short_leg_market_price_on_stop}, long_leg_market_price_on_stop: {self.long_leg_market_price_on_stop} "]
                if self.long_leg_sell_price is not None:
                    message += [f", long_leg_sell_price: {self.long_leg_sell_price:.2f}"]
                else:
                    message += [f"UNABLE to sell long leg at {self.get_long_leg_limit_price_on_stop()}!!!"]
            return message

    def __init__(self, quantity: int,
                 short_call: OptionContract,
                 long_call: OptionContract,
                 short_put: OptionContract,
                 long_put: OptionContract,
                 entry_time: datetime,
                 stop_limit_order_fn,
                 stop_market_order_fn,
                 limit_order_fn,
                 market_order_fn,
                 get_current_price
                 ):
        self.quantity = quantity
        self.short_call = short_call
        self.long_call = long_call
        self.short_put = short_put
        self.long_put = long_put
        self.entry_time = entry_time
        self.stop_limit_order_fn = stop_limit_order_fn
        self.stop_market_order_fn = stop_market_order_fn
        self.limit_order_fn = limit_order_fn
        self.market_order_fn = market_order_fn
        self.get_current_price = get_current_price
        self.call_stops_oco = []
        self.put_stops_oco = []

    def __eq__(self, other):
        return (
            isinstance(other, IronCondor) and
            self.short_call == other.short_call and
            self.long_call == other.long_call and
            self.short_put == other.short_put and
            self.long_put == other.long_put and
            self.entry_time == other.entry_time
        )

    def __hash__(self):
        return hash((
            self.quantity, self.short_call, self.long_call, self.short_put, self.long_put, self.entry_time
        ))

    def get_log_messages(self):
        basic = f"{self.entry_time.date()} {self.entry_time.time()} Iron Condor, quantity: {self.quantity} short_call: {self.short_call.strike}, long_call: {self.long_call.strike}, short_put: {self.short_put.strike}, long_put: {self.long_put.strike}"
        fill_price = f"Fill Price: short_call {self.be_short_call_stop.short_leg_fill_price}, long_call {self.be_short_call_stop.long_leg_fill_price}, short_put: {self.be_short_put_stop.short_leg_fill_price}, long_put: {self.be_short_put_stop.long_leg_fill_price}"
        stop_count = int(self.be_short_call_stop.is_stopped_out()) + \
            int(self.be_short_put_stop.is_stopped_out())
        status_map = {
            0: "**No Stop**",
            1: "!Single Stop!",
            2: "!!Double Stop!!"
        }
        return [
            basic,
            fill_price,
            status_map[stop_count],
            f"IC realized P/L: {(self.be_short_call_stop.realized_pl() + self.be_short_put_stop.realized_pl()):+.2f}"
        ] + self.be_short_call_stop.debug_messages() + self.be_short_put_stop.debug_messages()

    def limit_mid_price(self, contract: OptionContract):
        return round_to_nearest_0_05((contract.ask_price + contract.bid_price) / 2)

    def call_premium_est(self):
        return self.short_call.last_price - self.long_call.last_price

    def put_premium_est(self):
        return self.short_put.last_price - self.long_put.last_price

    def get_open_limit_legs(self):
        return [
            Leg.create(self.short_call.symbol, -1,
                       round_to_nearest_0_05(self.short_call.bid_price)),
            Leg.create(self.long_call.symbol, 1,
                       round_to_nearest_0_05(self.long_call.ask_price)),
            Leg.create(self.short_put.symbol, -1,
                       round_to_nearest_0_05(self.short_put.bid_price)),
            Leg.create(self.long_put.symbol, 1,
                       round_to_nearest_0_05(self.long_put.ask_price))
        ]

    def get_open_market_legs(self):
        return [
            Leg.create(self.short_call.symbol, -1),
            Leg.create(self.long_call.symbol, 1),
            Leg.create(self.short_put.symbol, -1),
            Leg.create(self.long_put.symbol, 1)
        ]

    def on_ic_filled(self, short_call_fill_price: float, long_call_fill_price: float, short_put_fill_price: float, long_put_fill_price: float):
        call_premium = short_call_fill_price - long_call_fill_price
        put_premium = short_put_fill_price - long_put_fill_price
        self.be_short_call_stop = IronCondor.BeStopPrice(
            short_leg=self.short_call, short_leg_fill_price=short_call_fill_price, long_leg=self.long_call, long_leg_fill_price=long_call_fill_price, this_side_premium=call_premium, other_side_premium=put_premium)
        self.be_short_put_stop = IronCondor.BeStopPrice(
            short_leg=self.short_put, short_leg_fill_price=short_put_fill_price, long_leg=self.long_put, long_leg_fill_price=long_put_fill_price, this_side_premium=put_premium, other_side_premium=call_premium
        )
        stop_limit_call = self.stop_limit_order_fn(self.short_call.symbol, self.quantity,
                                                   stop_price=self.be_short_call_stop.be_stop_price, limit_price=self.be_short_call_stop.stop_limit_price)
        stop_market_call = self.stop_market_order_fn(
            self.short_call.symbol, self.quantity, stop_price=self.be_short_call_stop.stop_market_price)
        stop_limit_put = self.stop_limit_order_fn(self.short_put.symbol, self.quantity,
                                                  stop_price=self.be_short_put_stop.be_stop_price, limit_price=self.be_short_put_stop.stop_limit_price)
        stop_market_put = self.stop_market_order_fn(
            self.short_put.symbol, self.quantity, stop_price=self.be_short_put_stop.stop_market_price)
        self.call_stops_oco = [stop_limit_call, stop_market_call]
        self.put_stops_oco = [stop_limit_put, stop_market_put]

    def tighten_stop_loss_order(self):
        current_long_call_price = self.get_current_price(self.long_call.symbol)
        current_long_put_price = self.get_current_price(self.long_put.symbol)

    def sell_long_leg_on_stop_out(self, order_ticket: OrderTicket, stop_time: datetime, use_market_order=False):
        type = order_ticket.order_type
        fill_price = order_ticket.average_fill_price
        is_call = "C" in order_ticket.symbol.value
        # collect current spx price, stop_order_type, stop_price (compared to be_stop_price), short_leg_market_price, long_leg_market_price, current_time - entry_time
        if is_call:
            self.be_short_call_stop.elpased_stop_time = stop_time - self.entry_time
            self.be_short_call_stop.short_leg_stop_price = fill_price
            self.be_short_call_stop.stop_out_type = OrderType(type)
            self.be_short_call_stop.short_leg_market_price_on_stop = self.get_current_price(self.short_call.symbol)
            self.be_short_call_stop.long_leg_market_price_on_stop = self.get_current_price(self.long_call.symbol)
        else:
            self.be_short_put_stop.elpased_stop_time = stop_time - self.entry_time
            self.be_short_put_stop.short_leg_stop_price = fill_price
            self.be_short_put_stop.stop_out_type = OrderType(type)
            self.be_short_put_stop.short_leg_market_price_on_stop = self.get_current_price(self.short_put.symbol)
            self.be_short_put_stop.long_leg_market_price_on_stop = self.get_current_price(self.long_put.symbol)
        if type == OrderType.STOP_LIMIT or type == OrderType.STOP_MARKET:
            if use_market_order:
                if is_call:
                    return self.market_order_fn(self.long_call.symbol, -self.quantity)
                else:
                    return self.market_order_fn(self.long_put.symbol, -self.quantity)
            else:
                if is_call:
                    return self.limit_order_fn(self.long_call.symbol, -self.quantity, self.be_short_call_stop.get_long_leg_limit_price_on_stop())
                else:
                    return self.limit_order_fn(self.long_put.symbol, -self.quantity, self.be_short_put_stop.get_long_leg_limit_price_on_stop())

    def on_long_leg_sell_filled(self, order_ticket: OrderTicket, time: datetime):
        type = order_ticket.order_type
        fill_price = order_ticket.average_fill_price
        is_call = "C" in order_ticket.symbol.value
        if is_call:
            self.be_short_call_stop.long_leg_sell_price = fill_price
        else:
            self.be_short_put_stop.long_leg_sell_price = fill_price


class BeIronCondorStrategy(QCAlgorithm):

    def initialize(self):
        first_trade_date = datetime(2023, 1, 4)
        last_trade_date = datetime(2023, 1, 31)
        self.last_backtest_day = date(
            last_trade_date.year, last_trade_date.month, last_trade_date.day)
        self.set_start_date(first_trade_date.year,
                            first_trade_date.month, first_trade_date.day)
        end_day = last_trade_date + timedelta(days=1)
        self.set_end_date(end_day.year, end_day.month, end_day.day)
        self.real_initial_capital = 100000  # 10w usd
        self.initial_capital = self.real_initial_capital
        self.set_cash(self.initial_capital)
        self.option_lookup_days = 0
        self.vix = self.add_index_option("VIX").symbol
        spx_option = self.add_index_option("SPX", "SPXW", Resolution.MINUTE)
        spx_option.set_filter(self._filter)
        self.spx = spx_option.symbol
        self.daily_traunch_limit = 6
        self.start_open_position_time = time(10 + 3, 0)
        self.stop_open_position_time = time(12 + 3, 0)
        self.target_premium_min = 0.5
        self.target_premium_max = 2
        self.premium_equal_eplison = 0.5
        self.wing_width = 10
        self.delta = 0.2
        self.allowed_loss = 0.02  # 2% at double stop loss
        self.cool_off = timedelta(minutes=30)
        # state tracker
        self.current_day_trades = 0
        self.order_id_to_ic = {}
        self.last_ic_trade = None
        self.end_day_log_date = None
        # be iron condor tuning parameter

    def _get_current_price(self, symbol):
        return self.securities[symbol].Close

    def _filter(self, universe):
        return universe.include_weeklys().expiration(0, 7).delta(-0.6, 0.6)

    def on_order_event(self, order_event: OrderEvent):
        oid = order_event.order_id
        if order_event.status == OrderStatus.FILLED:
            if oid in self.order_id_to_ic:
                order_type = order_event.ticket.order_type
                ic = self.order_id_to_ic[oid]
                is_call = "C" in order_event.symbol.value
                if order_type == OrderType.STOP_LIMIT or order_type == OrderType.STOP_MARKET:
                    oco = ic.call_stops_oco if is_call else ic.put_stops_oco
                    for order_ticket in oco:
                        # cancel rest of the order
                        if order_ticket.order_id != oid:
                            order_ticket.cancel()
                        else:
                            market_order = False # TODO: Optimize the long leg sell price
                            if market_order:
                                sell_long_leg_order = ic.sell_long_leg_on_stop_out(
                                    order_ticket, self.time, True)  # use market order
                                ic.on_long_leg_sell_filled(
                                    sell_long_leg_order, self.time)
                            else:
                                sell_long_leg_order = ic.sell_long_leg_on_stop_out(
                                    order_ticket, self.time, False)  # use limit order
                                self.order_id_to_ic[sell_long_leg_order.order_id] = ic
                elif order_type == OrderType.LIMIT:
                    ic.on_long_leg_sell_filled(order_event.ticket, self.time)

    def on_data(self, data: Slice):
        # no op on last backtest day
        if self.time.date() > self.last_backtest_day:
            return
        # tighten existing IronCondors
        # for ic in set(self.order_id_to_ic.values()):
        #    ic.tighten_stop_loss_order()

        cool_off = self.last_ic_trade is None or self.time - \
            self.last_ic_trade >= self.cool_off
        in_trading_window = self.time.time(
        ) >= self.start_open_position_time and self.time.time() <= self.stop_open_position_time
        if not cool_off or self.current_day_trades > self.daily_traunch_limit or not in_trading_window:
            return

        chain = data.option_chains.get(self.spx)
        if not chain:
            return
        expiry_date = self.time.date() + timedelta(days=self.option_lookup_days)
        put_contracts = sorted([
            x for x in chain
            if x.expiry.date() == expiry_date and self.securities[x.symbol].is_tradable
            and x.right == OptionRight.PUT
            and x.Volume > 0
        ], key=lambda x: -x.strike)  # sorted in descending
        call_contracts = sorted([
            x for x in chain
            if x.expiry.date() == expiry_date and self.securities[x.symbol].is_tradable
            and x.right == OptionRight.CALL
            and x.Volume > 0
        ], key=lambda x: x.strike)  # sorted in ascending

        # TODO - reduce risk at 12:30, and constantly tighten the short leg price

        put_vertical = None
        for i in range(len(put_contracts)):
            sell_put = put_contracts[i]
            if put_vertical is not None:
                break
            if abs(sell_put.Greeks.delta) <= self.delta:
                for j in range(i+1, len(put_contracts)):
                    buy_put = put_contracts[j]
                    if abs(sell_put.strike - buy_put.strike) >= self.wing_width:
                        put_vertical = (sell_put, buy_put)
                        break
        call_vertical = None
        for i in range(len(call_contracts)):
            sell_call = call_contracts[i]
            if call_vertical is not None:
                break
            if abs(sell_call.Greeks.delta) <= self.delta:
                for j in range(i+1, len(call_contracts)):
                    buy_call = call_contracts[j]
                    if abs(sell_call.strike - buy_call.strike) >= self.wing_width:
                        call_vertical = (sell_call, buy_call)
                        break
        if call_vertical and put_vertical:
            quantity = 1
            # TODO: don't accidentally close a open short leg
            ic = IronCondor(
                quantity, call_vertical[0], call_vertical[1], put_vertical[0], put_vertical[1], entry_time=self.time, stop_limit_order_fn=self.stop_limit_order, stop_market_order_fn=self.stop_market_order, limit_order_fn=self.limit_order, market_order_fn=self.market_order, get_current_price=self._get_current_price)

            def in_range(
                x): return x >= self.target_premium_min and x <= self.target_premium_max
            if not in_range(ic.call_premium_est()) and not in_range(ic.put_premium_est()):
                return
            # don't accept too much difference in put / call side premium
            if abs(ic.call_premium_est() - ic.put_premium_est()) >= self.premium_equal_eplison:
                return
            try:
                order_properties = OrderProperties()
                order_properties.time_in_force = TimeInForce.DAY
                ic_order_tickets = self.combo_market_order(legs=ic.get_open_market_legs(),
                                                           quantity=quantity,
                                                           order_properties=order_properties)
                short_call_fill_price = 0
                long_call_fill_price = 0
                short_put_fill_price = 0
                long_call_fill_price = 0
                for ticket in ic_order_tickets:
                    is_call = "C" in ticket.symbol.value
                    is_short = ticket.quantity < 0
                    if is_call:
                        if is_short:
                            short_call_fill_price = ticket.average_fill_price
                        else:
                            long_call_fill_price = ticket.average_fill_price
                    else:
                        if is_short:
                            short_put_fill_price = ticket.average_fill_price
                        else:
                            long_put_fill_price = ticket.average_fill_price
                ic.on_ic_filled(short_call_fill_price, long_call_fill_price,
                                short_put_fill_price, long_put_fill_price)
                for order_ticket in ic.call_stops_oco + ic.put_stops_oco:
                    self.order_id_to_ic[order_ticket.order_id] = ic
                self.last_ic_trade = self.time
                self.current_day_trades += 1
            except Exception as e:
                self.log(f"Exception when open IC")

    def on_end_of_day(self, symbol):
        if (not self.end_day_log_date or self.time.date() != self.end_day_log_date):
            total_profit_or_loss = self.portfolio.total_portfolio_value - self.initial_capital
            self.debug(
                f"end of day {self.time.date()} reset, P/L: {total_profit_or_loss}, symbol: {symbol}")
            for ic in set(self.order_id_to_ic.values()):
                for m in ic.get_log_messages():
                    self.debug(m)

            self.order_id_to_ic = {}
            self.current_day_trades = 0
            self.last_ic_trade = None
            self.end_day_log_date = self.time.date()
# region imports
from AlgorithmImports import *
# endregion
from enum import Enum
from datetime import datetime, date
from string import Template
from dataclasses import dataclass


UNEXPECTED_MARKET_SLIPPAGE = 0.1
EXPECTED_WIN_RATE = 0.39
DOUBLE_STOP_LOSS_PROB = 0.04
MARKET_SLIPPAGE_PROB = 0.02
SINGLE_STOP_LOSS_PROB = 0.57

EXPECTATNCY_TEMPLATE = Template(
    """Expectation = 
            EXPECTED_WIN_RATE * (call_rewards + put_rewards) 
                - (SINGLE_STOP_LOSS_PROB * single_worst_loss + DOUBLE_STOP_LOSS_PROB * double_worst_loss) * MARKET_SLIPPAGE_PROB 
                - (1 - MARKET_SLIPPAGE_PROB) * (SINGLE_STOP_LOSS_PROB * single_avg_loss + DOUBLE_STOP_LOSS_PROB * double_avg_loss)
            $EXPECTED_WIN_RATE * ($call_rewards + $put_rewards) 
                - ($SINGLE_STOP_LOSS_PROB * $single_worst_loss + $DOUBLE_STOP_LOSS_PROB * $double_worst_loss) * $MARKET_SLIPPAGE_PROB 
                - (1 - $MARKET_SLIPPAGE_PROB) * ($SINGLE_STOP_LOSS_PROB * $single_avg_loss + $DOUBLE_STOP_LOSS_PROB * $double_avg_loss)
            = $expectation_result
        """
)
WORST_LOSS_TEMPLATE = Template(
    "Worst Loss (double loss + market slippage on both side) =  $worst_loss_result")


def format_date(date: date):
    return date.strftime("%d %b %y").upper()


def contract_fee(symbol: str, premium: float, robinhood: bool = True):
    fee = 0.57 if premium < 1 else 0.66
    if symbol == "SPX":
        return fee + 0.5 - 0.15 if robinhood else 0.65 + 0.5
    else:
        return 0.03


def risk_reward(risk: float, reward: float):
    ratio = round(abs(risk) / abs(reward), 2)
    return f"{ratio:.1f}:1"


class OptionType(Enum):
    CALL = "CALL"
    PUT = "PUT"


class Option:
    def __init__(self, strike: float, premium: float, type: OptionType, contract_symbol: str, expiration_date: date = datetime.now(), symbol: str = "SPX"):
        self.type = type
        self.strike = strike
        self.premium = premium
        self.contract_symbol = contract_symbol
        self.expiration_date = expiration_date
        self.symbol = symbol
        self.fee = contract_fee(symbol, premium, True)

    def __eq__(self, other):
        return (
            isinstance(other, Option) and
            self.type == other.type and
            self.strike == other.strike and
            self.premium == other.premium and
            self.symbol == other.symbol and
            self.contract_symbol == other.contract_symbol and
            self.expiration_date == other.expiration_date
        )

    def __hash__(self):
        # Combine the hash of significant attributes
        return hash((self.type, self.strike, self.premium, self.quantity, self.expiration_date, self.symbol, self.contract_symbol))

    def _tos_code(self):
        suffix = "W" if self.symbol.upper() == "SPX" else ""
        d = self.expiration_date.strftime("%y%m%d")
        return f".{self.symbol.upper()}{suffix}{d}{self.type.name[0]}{self.strike}"


    def __str__(self):
        return "{}, {} strike: {}, type: {}, premium: {}, expiration_date: {}, fee: {}".format(self._tos_code(), self.symbol, self.strike, self.type.name, self.premium, format_date(self.expiration_date), round(self.fee, 2))


class CloseOrder:
    def __init__(self,
                 short_leg: Option, long_leg: Option,
                 quantity: int, acceptable_total_loss: float, profit_target=0):
        self.short_strike = short_leg.strike
        self.short_premium = short_leg.premium
        self.long_strike = long_leg.strike
        self.long_cost = long_leg.premium
        self.quantity = quantity
        self.acceptable_total_loss = acceptable_total_loss
        self.type = short_leg.type
        self.symbol = short_leg.symbol
        self.expiration_date = short_leg.expiration_date
        self.fees = short_leg.fee + long_leg.fee
        # take profit when vertical price is 0
        self.profit_target = profit_target
        self.first_stop_trigger_price = self.short_premium - \
            self.long_cost + self.acceptable_total_loss - 0.1
        self.stop_limit_price = self.first_stop_trigger_price + \
            0.4 if type == OptionType.PUT else self.first_stop_trigger_price + 0.3
        self.second_stop_trigger_price = self.stop_limit_price + 0.3

    def expected_reward(self):
        return self.quantity * \
            (self.short_premium - self.long_cost -
             self.profit_target) * 100 - self.fees

    def _get_loss(self, price):
        return self.quantity * (price - (self.short_premium - self.long_cost)) * 100 + self.fees

    def expected_loss(self):
        return 0.5 * self.loss_on_first_stop_price() + 0.3 * self.loss_on_stop_limit_price() + 0.15 * self.loss_on_market_slippage() + 0.05 * self.loss_on_second_stop_price()

    def loss_on_first_stop_price(self):
        return self._get_loss(self.first_stop_trigger_price)

    def loss_on_stop_limit_price(self):
        return self._get_loss(self.stop_limit_price)

    def loss_on_second_stop_price(self):
        return self._get_loss(self.second_stop_trigger_price)

    def loss_on_market_slippage(self):
        return self._get_loss(self.second_stop_trigger_price + UNEXPECTED_MARKET_SLIPPAGE)

    def _trim0(self, price):
        return f"{price:.2f}".lstrip('0')

    BUY_TO_CLOSE_VERTICAL_STOP_MARKET = Template(
        "BUY +$quantity VERTICAL $SYMBOL 100 (Weeklys) $exp_date $short_leg_strike/$long_leg_strike $type STP $last_stop_trigger_price MARK OCO")
    BUY_TO_CLOSE_VERTICAL_STOP_LIMIT = Template(
        "BUY +$quantity VERTICAL $SYMBOL 100 (Weeklys) $exp_date $short_leg_strike/$long_leg_strike $type @$stop_limit_price STPLMT $first_stop_trigger_price MARK OCO")
    BUY_TO_CLOSE_VERTICAL_PROFIT_TEMPLATE = Template(
        """BUY +$quantity VERTICAL $SYMBOL 100 (Weeklys) $exp_date $short_leg_strike/$long_leg_strike $type @$profit_target LMT MARK OCO""")

    def close_vertical_tos_oco(self):
        data = {
            "quantity": abs(self.quantity),
            "SYMBOL": self.symbol,
            "short_leg_strike": self.short_strike,
            "long_leg_strike": self.long_strike,
            "type": self.type.name,
            "last_stop_trigger_price": self._trim0(self.second_stop_trigger_price),
            "first_stop_trigger_price": self._trim0(self.first_stop_trigger_price),
            "stop_limit_price": self._trim0(self.stop_limit_price),
            "profit_target": self._trim0(self.profit_target),
            "exp_date": format_date(self.expiration_date)
        }
        return [
            CloseOrder.BUY_TO_CLOSE_VERTICAL_STOP_MARKET.substitute(data),
            CloseOrder.BUY_TO_CLOSE_VERTICAL_STOP_LIMIT.substitute(data),
            CloseOrder.BUY_TO_CLOSE_VERTICAL_PROFIT_TEMPLATE.substitute(data),
        ]
    SINGLE_LEG_STOP_LOSS_TEMPLATE_0 = Template(
        "BUY +$quantity $SYMBOL 100 (Weeklys) $exp_date $short_strike $type STP $last_stop_trigger_price OCO")
    # BUY +10 SPX 100 (Weeklys) 29 NOV 24 6025 CALL @1.50 STPLMT 1.49 OCO
    SINGLE_LEG_STOP_LOSS_TEMPLATE_1 = Template(
        "BUY +$quantity $SYMBOL 100 (Weeklys) $exp_date $short_strike $type @$stop_limit_price STPLMT $first_stop_trigger_price OCO")
    SINGLE_LEG_STOP_LOSS_TEMPLATE_2 = Template(
        "(Manual) Shorterly after the short leg is stopped out, close the long leg no less than $long_leg_sell_price.")
    SINGLE_LEG_STOP_LOSS_TEMPLATE_3 = Template(
        "SELL -$quantity $SYMBOL 100 (Weeklys) $exp_date $long_strike $type $long_leg_sell_price LMT MARK")

    def _round_to_nearest_0_05(self, value):
        return round(value / 0.05) * 0.05

    def close_short_leg_only_tos(self):
        stop_limit_price = self.short_premium + self.acceptable_total_loss
        # fee = contract_fee(self.symbol, stop_limit_price, True)
        # stop_limit_price -= fee / 100
        last_stop_trigger_price = stop_limit_price + 0.05
        first_stop_trigger_price = stop_limit_price - 0.05
        long_leg_sell_price = self.long_cost
        data = {
            "quantity": abs(self.quantity),
            "SYMBOL": self.symbol,
            "short_strike": self.short_strike,
            "long_strike": self.long_strike,
            "type": self.type.name,
            "exp_date": format_date(self.expiration_date),
            "last_stop_trigger_price": self._trim0(self._round_to_nearest_0_05(last_stop_trigger_price)),
            "first_stop_trigger_price": self._trim0(self._round_to_nearest_0_05(first_stop_trigger_price)),
            "stop_limit_price": self._trim0(self._round_to_nearest_0_05(stop_limit_price)),
            "long_leg_sell_price": self._trim0(self._round_to_nearest_0_05(long_leg_sell_price))
        }
        return [
            CloseOrder.SINGLE_LEG_STOP_LOSS_TEMPLATE_0.substitute(data),
            CloseOrder.SINGLE_LEG_STOP_LOSS_TEMPLATE_1.substitute(data),
            CloseOrder.SINGLE_LEG_STOP_LOSS_TEMPLATE_2.substitute(data),
            CloseOrder.SINGLE_LEG_STOP_LOSS_TEMPLATE_3.substitute(data)
        ]


class ChartData:
    def __init__(self, x_prices, y_total_pls, break_even_prices):
        self.x_prices = x_prices
        self.y_total_pls = y_total_pls
        self.break_even_prices = break_even_prices

    def max_profit(self):
        return round(max(self.y_total_pls), 2)

    def max_loss(self):
        return round(min(self.y_total_pls), 2)

    def risk_reward(self):
        ratio = abs(round(self.max_loss() / self.max_profit(), 4))
        return f"{ratio:.1f}:1"


class BeIronCondor:
    def __init__(self, quantity, short_call: Option, long_call: Option, short_put: Option, long_put: Option):
        # verify
        assert (short_call.strike <
                long_call.strike and short_call.premium > long_call.premium)
        assert (short_put.strike >
                long_put.strike and short_put.premium > long_put.premium)
        legs_verifier = [short_put, long_put, long_call, short_call]
        symbols = set([l.symbol for l in legs_verifier])
        assert (len(symbols) == 1)
        expiration_dates = set([l.expiration_date for l in legs_verifier])
        assert (len(expiration_dates) == 1)
        self.quantity = quantity
        self.short_call = short_call
        self.long_call = long_call
        self.short_put = short_put
        self.long_put = long_put
        self.put_premium = self.short_put.premium - \
            self.long_put.premium
        self.call_premium = self.short_call.premium - \
            self.long_call.premium
        self.close_call_order = CloseOrder(
            short_leg=self.short_call,
            long_leg=self.long_call,
            quantity=self.quantity,
            acceptable_total_loss=self.put_premium
        )
        self.close_put_order = CloseOrder(
            short_leg=self.short_put,
            long_leg=self.long_put,
            quantity=self.quantity,
            acceptable_total_loss=self.call_premium
        )

    def __str__(self):
        open_positions = [self.short_call,
                          self.long_call, self.short_put, self.long_put]
        return "\n".join(["-" + str(opt) for opt in open_positions])

    STATUS_TEMPALTE = Template(
        """Call Net Credit: $call_credit
Put Net Credit: $put_credit
Total Net Credit: $total_credit

Open Positions:
$open_positions

BreakEven Prices: $break_even_prices

Max Profit: $max_profit
Max Loss:  $max_loss
Risk-Reward: $risk_reward
Adjusted Risk-Reward: $adjusted_risk_reward

TOS Vertical Stop Loss Orders:
$tos_vertical_stop_loss_orders

TOS Short Leg Only Stop Loss Orders:
$tos_short_leg_only_stop_loss_orders""")

    def position_status(self):
        chart_data = self.get_chart_data()
        be_prices = [f"{p:.2f}" for p in chart_data.break_even_prices]
        open_positions = [self.short_call,
                          self.long_call, self.short_put, self.long_put]
        call_vertical_close_orders = "\n".join(
            self.close_call_order.close_vertical_tos_oco())
        put_vertical_close_orders = "\n".join(
            self.close_put_order.close_vertical_tos_oco())
        call_short_leg_only_close_orders = "\n".join(
            self.close_call_order.close_short_leg_only_tos())
        put_short_leg_only_close_orders = "\n".join(
            self.close_put_order.close_short_leg_only_tos())
        data = {
            "call_credit": round(self.call_premium, 2),
            "put_credit": round(self.put_premium, 2),
            "total_credit": round(self.put_premium + self.call_premium, 2),
            "break_even_prices": ", ".join(be_prices),
            "open_positions": "\n".join(["-" + str(opt) for opt in open_positions]),
            "max_profit": round(self.max_profit(), 2),
            "max_loss": round(self.max_loss(), 2),
            "risk_reward": risk_reward(chart_data.max_loss(), chart_data.max_profit()),
            "adjusted_risk_reward": risk_reward(self.max_loss(), self.max_profit()),
            "tos_vertical_stop_loss_orders": call_vertical_close_orders + "\n\n" + put_vertical_close_orders,
            "tos_short_leg_only_stop_loss_orders": call_short_leg_only_close_orders + "\n\n" + put_short_leg_only_close_orders,
            "expectation": round(self.expectation(), 2)
        }
        return BeIronCondor.STATUS_TEMPALTE.substitute(data)

    def get_chart_data(self):
        """Get chart data 

        Returns:
            ChartData: x axis, total p/l at x, and list of breakeven prices
        """
        min_price = self.long_put.strike * 0.8
        max_price = self.long_call.strike * 1.2
        prices = []
        while min_price <= max_price:
            prices.append(min_price)
            min_price += 0.01
        total_pl = [0] * len(prices)
        for opt in [self.short_call, self.long_call, self.long_put, self.short_put]:
            for i, price in enumerate(prices):
                payoff = (
                    max(price - opt.strike, 0) if opt.type == OptionType.CALL
                    else max(opt.strike - price, 0)
                )
                pl = (payoff - abs(opt.premium)) * self.quantity
                total_pl[i] += pl

        premium = self.call_premium + self.put_premium
        break_even_prices = [self.short_put.strike -
                             premium, self.short_call.strike + premium]
        return ChartData(prices, total_pl, break_even_prices)

    def max_profit(self):
        return self.close_call_order.expected_reward() + self.close_put_order.expected_reward()

    def max_loss(self):
        return self.close_call_order.loss_on_market_slippage() + self.close_put_order.loss_on_market_slippage()

    def expectation(self):
        return EXPECTED_WIN_RATE * self.max_profit() - SINGLE_STOP_LOSS_PROB * max(self.close_call_order.expected_loss(), self.close_put_order.expected_loss()) - DOUBLE_STOP_LOSS_PROB * (self.close_call_order.expected_loss() + self.close_put_order.expected_loss())