Quant BuffetRelax, Not Over Thinking

Double Bottom Country Trading Strategy

Log in to collect

Academic paper

Double Bottom Country Trading Strategy

AuthorsCyril Dujava; Filip Kalús; Radovan Vojtko; Quantpedia

Institute
  • ?Quantpedia
  • ?Quantpedia.com

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.

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.

Backtest performance

Annualised return7.79%
Volatility16.57%
Beta0.103
Sharpe ratio0.47
Sortino ratio0.181
Maximum drawdown-30.38%
Win rate53%

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