Created with Highcharts 12.1.2Equity2012201320142015201620172018201920202021202220232024202520260200k400k-20-10000.20.4010200M0100M200M30405060
Overall Statistics
Total Orders
1115
Average Win
0.64%
Average Loss
-0.67%
Compounding Annual Return
8.717%
Drawdown
20.200%
Expectancy
0.229
Start Equity
100000
End Equity
302055.28
Net Profit
202.055%
Sharpe Ratio
0.489
Sortino Ratio
0.426
Probabilistic Sharpe Ratio
7.934%
Loss Rate
37%
Win Rate
63%
Profit-Loss Ratio
0.96
Alpha
0
Beta
0
Annual Standard Deviation
0.091
Annual Variance
0.008
Information Ratio
0.7
Tracking Error
0.091
Treynor Ratio
0
Total Fees
$2812.37
Estimated Strategy Capacity
$100000000.00
Lowest Capacity Asset
SPY R735QTJ8XC9X
Portfolio Turnover
15.56%
#region imports
from AlgorithmImports import *
from strategy_calendar import StrategyCalendar, FOMC
from pandas.core.frame import DataFrame
#endregion

class SeasonalityComposite(QCAlgorithm):

    _trade_TOM: bool = True
    _trade_FOMC: bool = True
    _trade_expiration: bool = True
    _trade_payday: bool = True

    _before_tom_d_offset: int = 5
    _after_tom_sell_days: List[int] = [4,5,6,9]

    _min_offset: int = 5
    _option_exp_week_holding_period: int = 5

    _traded_weight: float = 1

    def initialize(self) -> None:
        self.set_start_date(2012, 1, 1)
        self._init_cash: int = 100_000
        self.set_cash(self._init_cash)

        self.set_brokerage_model(BrokerageName.INTERACTIVE_BROKERS_BROKERAGE, AccountType.MARGIN)
        self.settings.minimum_order_margin_portfolio_percentage = 0
        self.settings.daily_precise_end_time = True        

        assert max(self._after_tom_sell_days) <= 10, 'maximum day offset is 10'
        assert all(np.diff(self._after_tom_sell_days) > 0), 'exit day series (self._after_tom_sell_days) should be ascending and number should be repeated'

        self._sell_day_count: float = len(self._after_tom_sell_days)
        self._quantity_portion_to_sell: int = 0

        ticker: str = 'SPY'

        self._market: Symbol = self.add_equity(ticker, Resolution.MINUTE).symbol

        # benchmark
        self._benchmark_values: List[float] = []

        self._FOMC_obj = FOMC(self)

        self._liquidation_time: Optional[datetime] = None

        self._after_open_flag: bool = False
        self.schedule.on(
            self.date_rules.every_day(self._market),
            self.time_rules.after_market_open(self._market, self._min_offset),
            self._after_open
        )

        self.schedule.on(
            self.date_rules.month_end(self._market, self._before_tom_d_offset - 1),
            self.time_rules.before_market_close(self._market, self._min_offset),
            self._open_TOM
        )

        for d_offset in self._after_tom_sell_days:
            self.schedule.on(
                self.date_rules.month_start(self._market, d_offset - 1),
                self.time_rules.before_market_close(self._market, self._min_offset),
                self._close_TOM
            )

    def _open_TOM(self) -> None:
        # open new trade
        if self._trade_TOM:
            if not self.portfolio[self._market].invested:
                self._quantity_portion_to_sell = 0

                q_to_trade: int = self.calculate_order_quantity(self._market, 1.)
                adjusted_q: int = (q_to_trade // self._sell_day_count) * self._sell_day_count   # adjust to nearest lower divisable quantity
                self.market_order(self._market, adjusted_q, tag='entry_TOM')
        
    def _close_TOM(self) -> None:
        # close trade partially
        if self.portfolio[self._market].invested:
            self.market_order(
                self._market, 
                -self._quantity_portion_to_sell, 
                tag=f'portional exit weight: {1. / self._sell_day_count} of Q ({self._sell_day_count * self._quantity_portion_to_sell})'
            )

    def on_order_event(self, order_event: OrderEvent) -> None:
        order = self.transactions.get_order_by_id(order_event.order_id)
        if order_event.status == OrderStatus.FILLED:
            if 'entry_TOM' in order.tag:
                self._quantity_portion_to_sell = order.quantity / self._sell_day_count

    def on_data(self, slice: Slice) -> None:
        # try to liquidate
        if self._liquidation_time is not None:
            if self.time >= self._liquidation_time:
                self._liquidation_time = None
                self.liquidate(self._market)

        if not self._after_open_flag:
            return
        self._after_open_flag = False

        # open new trade
        if not self.portfolio.invested:
            now: datetime.date = self.time.date()
            
            fomc_signal: bool = StrategyCalendar.is_FOMC(now, self._FOMC_obj.dates) if self._trade_FOMC else False
            expiration_signal: bool = StrategyCalendar.is_option_expiration_week(now) if self._trade_expiration else False
            payday_signal: bool = StrategyCalendar.is_payday(now) if self._trade_payday else False

            # join two strategy signals; option expiration week and TOM is separate signal
            joined_signal: List[bool] = [
                s for s in [
                    fomc_signal,
                    payday_signal
                ]
            ]
            
            holding_period_days: int = -1
            # expiration week signal has priority
            if expiration_signal:
                holding_period_days: int = self._option_exp_week_holding_period
            elif any(s == True for s in joined_signal):
                # execute only on one signal if multiple signals occured
                holding_period_days: int = 0

            if holding_period_days != -1:
                liquidation_time: int = self.securities[self._market].exchange.hours.get_next_market_close(
                    self.time + timedelta(days=holding_period_days), extended_market_hours=False
                ) - timedelta(minutes=self._min_offset)

                self._liquidation_time = liquidation_time

                # open new position
                self.set_holdings(self._market, self._traded_weight)

    def _after_open(self) -> None:
        self._after_open_flag = True

    def on_end_of_day(self) -> None:
        # wait for available data
        if not (self.securities.contains_key(self._market) and self.securities[self._market].get_last_data()):
            return

        # print benchmark in main equity plot
        mkt_price_df: DataFrame = self.history(self._market, 2, Resolution.DAILY)
        if not mkt_price_df.empty:
            benchmark_price: float = mkt_price_df['close'].unstack(level= 0).iloc[-1]
            if len(self._benchmark_values) == 2:
                self._benchmark_values[-1] = benchmark_price
                benchmark_perf: float = self._init_cash * (self._benchmark_values[-1] / self._benchmark_values[0])
                self.plot('Strategy Equity', self._market, benchmark_perf)
            else:
                self._benchmark_values.append(benchmark_price)
#region imports
from AlgorithmImports import *
from pandas.tseries.offsets import BDay, BMonthEnd, BMonthBegin
from dateutil.relativedelta import relativedelta, WE, FR, SA
#endregion

class FOMC():
    def __init__(self, algorithm: QCAlgorithm) -> None:
        csv_string_file = algorithm.Download(
            'data.quantpedia.com/backtesting_data/economic/fed_days.csv'
        )
        dates = csv_string_file.split('\r\n')
        
        self._dates: List[datetime.date] = [
            (datetime.strptime(x, "%Y-%m-%d")).date() for x in dates
        ]
    
    @property
    def dates(self) -> List[datetime.date]:
        return self._dates

class StrategyCalendar():
    @staticmethod
    def _get_friday_before_options_expiration(now: datetime.date) -> datetime.date:
        # find thrid saturday and get friday before that
        return (date(now.year, now.month, 1) + relativedelta(weekday=SA(3))) - timedelta(days=1)

    @staticmethod
    def _get_payday(now: datetime.date) -> datetime.date:
        payday = date(now.year, now.month, 1) + relativedelta(day=15)
        
        if payday.weekday() == 5: # saturday
            payday = payday - timedelta(days=1)
        elif payday.weekday() == 6: # sunday
            payday = payday - timedelta(days=2)
        
        return payday

    @staticmethod
    def is_TOM(now: datetime.date) -> bool:
        offset = BMonthEnd()
        return now == offset.rollforward(now).date() - timedelta(days=1)
    
    @staticmethod
    def is_FOMC(now: datetime.date, fomc_days: List[datetime.date]) -> bool:
        return now in fomc_days

    @staticmethod
    def is_option_expiration_week(now: datetime.date) -> bool:
        second_friday_of_month: datetime.date = StrategyCalendar._get_friday_before_options_expiration(now)
        return now == second_friday_of_month

    @staticmethod
    def is_payday(now: datetime.date) -> bool:
        paydate: datetime.date = StrategyCalendar._get_payday(now)
        return now == paydate - timedelta(days=1)