Investment universe consists of 24 country ETFs (mostly iShares MSCI, along with iShares China Large-Cap ETF [FXI], and the U.S. is represented by SPDR S&P 500 ETF [SPY]).

I. STRATEGY IN A NUTSHELL

Trade 24 country ETFs (e.g., SPY, FXI, MSCI funds). Identify double bottoms in long-term uptrends using 30-day rolling windows. Entry via limit orders at bottoms, with 3% trailing stop-loss and 6% profit target (R:R = 1:2). Max 4 positions, 2:1 leverage. Monthly scanning, daily trade execution.

II. ECONOMIC RATIONALE

Technical analysis captures market psychology alongside fundamentals. While imperfect, price patterns like double bottoms reflect investor behavior and information assimilation. TA provides practical insights beyond fundamental analysis alone.

III. SOURCE PAPER

Double Bottom Country Trading Strategy [Click to Open PDF]

”Cyril Dujava”, ”Filip Kalús”, ”Radovan Vojtko”, ”Quantpedia”

<Abstract>

The article from Quantpedia discusses the methodology of technical analysis, mainly focusing on the double bottom and double top trading strategies. Technical analysis is often viewed skeptically, with some likening it to ”astrology for men.” However, the article emphasizes that while both fundamentals and psychology drive the market, it would be unwise to dismiss technical analysis entirely. The history of technical analysis is traced back to figures like Sokyu Honma in the 1600s in Japan and the Dow Theory from the modern world. The article then delves into various technical analysis terms, such as supports, resistances, and moving averages. The main focus is on the double top and double bottom patterns used as reversal indicators in charts. These patterns can indicate strong resistance or support in price charts. The methodology for the study was based on feedback and questions received over the years, indicating that technical analysis remains a popular subject.

IV. BACKTEST PERFORMANCE

Annualised Return7.79%
Volatility16.57%
Beta0.103
Sharpe Ratio0.47
Sortino Ratio0.181
Maximum Drawdown-30.38%
Win Rate53%

V. FULL PYTHON CODE

from AlgorithmImports import *
from pandas.core.frame import dataframe
from datetime import datetime
# endregion

class DoubleBottomCountryTradingStrategy(QCAlgorithm):

    def Initialize(self) -> None:
        self.SetStartDate(2000, 1, 1)
        self.SetCash(100000)

        self.country_etfs:List[str] = [
            "EWA",  # iShares MSCI Australia Index ETF
            "EWO",  # iShares MSCI Austria Investable Mkt Index ETF
            "EWK",  # iShares MSCI Belgium Investable Market Index ETF
            "EWZ",  # iShares MSCI Brazil Index ETF
            "EWC",  # iShares MSCI Canada Index ETF
            "FXI",  # iShares China Large-Cap ETF
            "EWQ",  # iShares MSCI France Index ETF
            "EWG",  # iShares MSCI Germany ETF 
            "EWH",  # iShares MSCI Hong Kong Index ETF
            "EWI",  # iShares MSCI Italy Index ETF
            "EWJ",  # iShares MSCI Japan Index ETF
            "EWM",  # iShares MSCI Malaysia Index ETF
            "EWW",  # iShares MSCI Mexico Inv. Mt. Idx
            "EWN",  # iShares MSCI Netherlands Index ETF
            "EWS",  # iShares MSCI Singapore Index ETF
            "EZA",  # iShares MSCI South Africe Index ETF
            "EWY",  # iShares MSCI South Korea ETF
            "EWP",  # iShares MSCI Spain Index ETF
            "EWD",  # iShares MSCI Sweden Index ETF
            "EWL",  # iShares MSCI Switzerland Index ETF
            "EWT",  # iShares MSCI Taiwan Index ETF
            "THD",  # iShares MSCI Thailand Index ETF
            "EWU",  # iShares MSCI United Kingdom Index ETF
            "SPY",  # SPDR S&P 500 ETF
        ]

        self.rolling_period:int = 30

        # two local highs/lows max and min distance
        self.min_distance:int = self.rolling_period
        self.max_distance:int = 1000

        # max vertical distance between two local highs/lows
        self.max_percentage:float = .02

        self.stop_loss_pct:float = .03
        self.take_profit_pct:float = .06

        self.trade_long = True
        self.trade_short = False
        self.max_positions_opened:int = 4

        self.SetWarmup(self.max_distance, Resolution.Daily)

        self.price_data:Dict[str, List[Tuple(datetime.date, float)]] = {}

        # add Forex data
        for etf in self.country_etfs:
            data:Equity = self.AddEquity(etf, Resolution.Daily)
            data.SetLeverage(10)
            self.price_data[etf] = []
        
        self.recent_month:int = -1

    def OnData(self, data: Slice) -> None:
        # trail long SL for each opened positions
        active_open_buy_orders:List = list(self.Transactions.GetOrderTickets(lambda ticket: 'LIMIT_BUY' in ticket.Tag and ticket.Status == OrderStatus.Filled))
        for ticket in active_open_buy_orders:
            if self.Portfolio[ticket.Symbol].IsLong:
                active_sl_orders:List = list(self.Transactions.GetOrderTickets(lambda sl_ticket: 'SL' in sl_ticket.Tag \
                                            and sl_ticket.Symbol == ticket.Symbol \
                                            and sl_ticket.Status not in [OrderStatus.Filled, OrderStatus.Invalid, OrderStatus.Canceled]))

                if len(active_sl_orders) != 0:
                    active_sl_order = active_sl_orders[0]
                    # price moved into profit
                    if self.Securities[ticket.Symbol].Price > ticket.Get(OrderField.LimitPrice):
                        new_sl_price:float = round(self.Securities[ticket.Symbol].Price - (self.Securities[ticket.Symbol].Price * self.stop_loss_pct), 2)
                        if new_sl_price > active_sl_order.Get(OrderField.StopPrice):
                            active_sl_order.UpdateStopPrice(new_sl_price)

        # trail short SL for each opened positions
        active_open_buy_orders:List = list(self.Transactions.GetOrderTickets(lambda ticket: 'LIMIT_SELL' in ticket.Tag and ticket.Status == OrderStatus.Filled))
        for ticket in active_open_buy_orders:
            if self.Portfolio[ticket.Symbol].IsShort:
                active_sl_orders:List = list(self.Transactions.GetOrderTickets(lambda sl_ticket: 'SL' in sl_ticket.Tag \
                                            and sl_ticket.Symbol == ticket.Symbol \
                                            and sl_ticket.Status not in [OrderStatus.Filled, OrderStatus.Invalid, OrderStatus.Canceled]))

                if len(active_sl_orders) != 0:
                    active_sl_order = active_sl_orders[0]
                    # price moved into profit
                    if self.Securities[ticket.Symbol].Price < ticket.Get(OrderField.LimitPrice):
                        new_sl_price:float = round(self.Securities[ticket.Symbol].Price + (self.Securities[ticket.Symbol].Price * self.stop_loss_pct), 2)
                        if new_sl_price < active_sl_order.Get(OrderField.StopPrice):
                            active_sl_order.UpdateStopPrice(new_sl_price)

        # look for new double top and double bottom once a month
        rebalance_flag = False
        if self.recent_month != self.Time.month:
            rebalance_flag = True
            self.recent_month = self.Time.month

        # store daily prices
        for etf in self.country_etfs:
            if etf in data and data[etf]:
                self.price_data[etf].append( (self.Time, data[etf].Value) )
            
            if not rebalance_flag:
                continue

            if len(self.price_data[etf]) >= self.max_distance:
                if not self.Portfolio[etf].Invested:
                    etf_hist:dataframe = pd.dataframe(self.price_data[etf], columns=['Date', etf])
                    etf_hist.set_index('Date', inplace=True)
                    current_datetime:datetime = etf_hist.index[-1]
                    etf_hist = self.find_support_and_resistance(etf, etf_hist, self.rolling_period, self.min_distance)
                    etf_hist = self.find_double_tops_and_bottoms(etf, etf_hist, self.min_distance, self.max_distance, self.max_percentage)                    

                    if self.trade_long:
                        # find not yet crossed bottom
                        last_bottom_datetime:dataframe = etf_hist[~etf_hist['Double_Bottom'].isnull()]
                        if not last_bottom_datetime.empty:
                            last_bottom_datetime:datetime = last_bottom_datetime.index[-1]
                            if all(x[1][etf] >= etf_hist.loc[last_bottom_datetime][etf] for x in etf_hist.loc[last_bottom_datetime:current_datetime].iterrows()):
                                limit_price:float = round(etf_hist.loc[last_bottom_datetime][etf], 2)
                                active_limit_orders:List = list(self.Transactions.GetOrderTickets(lambda order_ticket: order_ticket.Status not in [OrderStatus.Filled, OrderStatus.Invalid, OrderStatus.Canceled] and order_ticket.Symbol == etf and 'LIMIT_BUY' in order_ticket.Tag))

                                # submit orders
                                if len(active_limit_orders) != 0:
                                    active_order = active_limit_orders[0]
                                    # there is active order for different price
                                    if active_order.Get(OrderField.LimitPrice) != limit_price:
                                        new_quantity:int = self.Portfolio.TotalPortfolioValue // limit_price
                                        active_order.UpdateLimitPrice(limit_price)
                                        active_order.UpdateQuantity(new_quantity)
                                else:
                                    # submit new limit order
                                    if len([x for x in self.Portfolio if x.Value.Invested]) < self.max_positions_opened:
                                        quantity:int = self.Portfolio.TotalPortfolioValue // limit_price
                                        if abs(quantity) > 1:
                                            self.LimitOrder(etf, quantity, limit_price, tag='LIMIT_BUY')

                    if self.trade_short:
                        # find not yet crossed top
                        last_top_datetime = etf_hist[~etf_hist['Double_Top'].isnull()]
                        if not last_top_datetime.empty:
                            last_top_datetime = last_top_datetime.index[-1]
                            if all(x[1][etf] <= etf_hist.loc[last_top_datetime][etf] for x in etf_hist.loc[last_top_datetime:current_datetime].iterrows()):
                                limit_price:float = round(etf_hist.loc[last_top_datetime][etf], 2)
                                active_limit_orders:List = list(self.Transactions.GetOrderTickets(lambda order_ticket: order_ticket.Status not in [OrderStatus.Filled, OrderStatus.Invalid, OrderStatus.Canceled] and order_ticket.Symbol == etf and 'LIMIT_SELL' in order_ticket.Tag))

                                # submit orders
                                if len(active_limit_orders) != 0:
                                    active_order = active_limit_orders[0]
                                    # there is active order for different price
                                    if active_order.Get(OrderField.LimitPrice) != limit_price:
                                        new_quantity:int = self.Portfolio.TotalPortfolioValue // limit_price
                                        active_order.UpdateLimitPrice(limit_price)
                                        active_order.UpdateQuantity(-new_quantity)
                                else:
                                    # submit new limit order
                                    if len([x for x in self.Portfolio if x.Value.Invested]) < self.max_positions_opened:
                                        quantity:int = self.Portfolio.TotalPortfolioValue // limit_price
                                        if abs(quantity) > 1:
                                            self.LimitOrder(etf, -quantity, limit_price, tag='LIMIT_SELL')

    def cancel_all_limit_orders(self) -> None:
        # cancel all submitted limit orders for the symbol
        active_limit_orders:List = list(self.Transactions.GetOrderTickets(lambda ticket: ('LIMIT_SELL' in ticket.Tag or 'LIMIT_BUY' in ticket.Tag) and ticket.Status not in [OrderStatus.Filled, OrderStatus.Invalid, OrderStatus.Canceled]))
        for ticket in active_limit_orders:
            response = ticket.Cancel()

    def cancel_all_orders(self, symbol:Symbol) -> None:
        # cancel all submitted orders for the symbol
        active_limit_orders:List = list(self.Transactions.GetOrderTickets(lambda ticket: ticket.Symbol == symbol))
        for ticket in active_limit_orders:
            response = ticket.Cancel()

    def OnOrderEvent(self, orderEvent: OrderEvent) -> None:
        order = self.Transactions.GetOrderById(orderEvent.OrderId)
        if orderEvent.Status == OrderStatus.Filled:
            order_ticket = self.Transactions.GetOrderTicket(orderEvent.OrderId)
            
            # if TP or SL was hit
            if '_TP' in order_ticket.Tag or '_SL' in order_ticket.Tag:
                # cancel all submitted orders for the symbol
                self.cancel_all_orders(order_ticket.Symbol)

            # set TP and SL if limit was hit
            if orderEvent.Quantity > 0:
                if 'LIMIT_BUY' in order_ticket.Tag:
                    if len([x for x in self.Portfolio if x.Value.Invested]) == self.max_positions_opened:
                        self.cancel_all_limit_orders()

                    tp_price:float = round(orderEvent.FillPrice + (orderEvent.FillPrice * self.take_profit_pct), 2)
                    stop_price:float = round(orderEvent.FillPrice - (orderEvent.FillPrice * self.stop_loss_pct), 2)

                    self.LimitOrder(orderEvent.Symbol, -orderEvent.Quantity, tp_price, tag='BUY_TP')
                    self.StopMarketOrder(orderEvent.Symbol, -orderEvent.Quantity, stop_price, tag='BUY_SL')
            
            if orderEvent.Quantity < 0:
                if 'LIMIT_SELL' in order_ticket.Tag:
                    if len([x for x in self.Portfolio if x.Value.Invested]) == self.max_positions_opened:
                        self.cancel_all_limit_orders()

                    tp_price:float = round(orderEvent.FillPrice - (orderEvent.FillPrice * self.take_profit_pct), 2)
                    stop_price:float = round(orderEvent.FillPrice + (orderEvent.FillPrice * self.stop_loss_pct), 2)
                    
                    self.LimitOrder(orderEvent.Symbol, -orderEvent.Quantity, tp_price, tag='SELL_TP')
                    self.StopMarketOrder(orderEvent.Symbol, -orderEvent.Quantity, stop_price, tag='SELL_SL')

    def find_support_and_resistance(self, symbol:str, stock_data:dataframe, rolling_period:int, lookback_period:int) -> dataframe:
        for level_str, obj_function in [('Support', 'min'), ('Resistance', 'max')]:
            level_prices = stock_data[symbol]
            # TODO eval min and max
            if obj_function == 'min':
                pivot_level = level_prices.rolling(window=rolling_period*2+1, center=True).min().shift(-rolling_period)
            else:
                pivot_level = level_prices.rolling(window=rolling_period*2+1, center=True).max().shift(-rolling_period)
            stock_data[level_str] = None

            level_points = pivot_level.where(pivot_level == level_prices, None).dropna().index
            valid_indices = []

            for i, level_point in enumerate(level_points):
                if i == 0:
                    valid_indices.append(level_point)
                else:
                    timedelta = level_point - level_points[i - 1]
                    if timedelta.days > lookback_period:
                        valid_indices.append(level_point)

            stock_data.loc[valid_indices, level_str] = pivot_level.loc[valid_indices]

        return stock_data

    def find_double_tops_and_bottoms(self, symbol:str, stock_data:dataframe, min_distance:int, max_distance:int, max_percentage:float) -> dataframe:
        stock_data['Double_Top'] = None
        stock_data['Double_Bottom'] = None

        support_points = stock_data.dropna(subset=['Support']).index
        resistance_points = stock_data.dropna(subset=['Resistance']).index

        for i in range(len(resistance_points)):
            for j in range(i + 1, len(resistance_points)):
                timedelta = resistance_points[j] - resistance_points[i]
                if timedelta.days < min_distance or timedelta.days > max_distance:
                    continue
                price_pct_difference = abs(stock_data.loc[resistance_points[j], 'Resistance'] / stock_data.loc[resistance_points[i], 'Resistance'] - 1)
                if price_pct_difference <= max_percentage:
                    lower_top_index = resistance_points[j] if stock_data.loc[resistance_points[j], 'Resistance'] < stock_data.loc[resistance_points[i], 'Resistance'] else resistance_points[i]
                    if all(stock_data.loc[resistance_points[x], symbol] <= stock_data.loc[lower_top_index, 'Resistance'] for x in range(i+1,j)):
                        stock_data.loc[lower_top_index, 'Double_Top'] = stock_data.loc[lower_top_index, 'Resistance']

        for i in range(len(support_points)):
            for j in range(i + 1, len(support_points)):
                timedelta = support_points[j] - support_points[i]
                if timedelta.days < min_distance or timedelta.days > max_distance:
                    continue
                price_pct_difference = abs(stock_data.loc[support_points[j], 'Support'] / stock_data.loc[support_points[i], 'Support'] - 1)
                if price_pct_difference <= max_percentage:
                    higher_bottom_index = support_points[j] if stock_data.loc[support_points[j], 'Support'] > stock_data.loc[support_points[i], 'Support'] else support_points[i]
                    if all(stock_data.loc[support_points[x], symbol] >= stock_data.loc[higher_bottom_index, 'Support'] for x in range(i+1,j)):
                        stock_data.loc[higher_bottom_index, 'Double_Bottom'] = stock_data.loc[higher_bottom_index, 'Support']

        return stock_data

Leave a Reply

Discover more from Quant Buffet

Subscribe now to keep reading and get access to the full archive.

Continue reading