ETF Search Party

tradingview
trading
ETFs
Author

im@johnho.ca

Published

Thursday, April 10, 2025

Abstract
using tradingview-screener to search for a low risk safe investment

Intro

with the investment evaluation metrics introduced here and the Trading View Screener previously introduced here, we are set to look for ETFs that:

  • beat the market (SPY)
  • with less downside risk, and
  • consistently: over 10y,5y, and 3y

ETFs

with our tradingview-screener we are going to screen for ETFs that are:

  • non-leveraged
  • not-OTC traded
  • and either actively or passively managed
  • listed in the US
Code
from tradingview_screener import Query, col
from datetime import datetime

# create the screener
screen = (Query()
    .select('name', 'exchange', 'close', 'average_volume_10d_calc', 
            'nav_discount_premium', 'description', 'Perf.Y', 
            'dividend_treatment', 'dividends_frequency', 'launch_date')
    .where(
        col('typespecs').has(['etf']),
        col('leveraged_flag') == "Non-leveraged",
        col('exchange')!= 'OTC', 
#         col('actively_managed')=='1',
        )
    .order_by('Perf.Y', ascending=False)
    .limit(5000)
    )
screen.set_markets('america')

# fetch data
r = screen.get_scanner_data()
df_screen = r[1]

# let's feature engineer some new variables here
df_screen['dollar_traded_1d'] = df_screen['close'] * df_screen['average_volume_10d_calc']
df_screen['launch_date'] = df_screen['launch_date'].apply(lambda d: datetime.fromtimestamp(d))
df_screen['age'] = df_screen['launch_date'].apply(lambda x: (datetime.today()- x).days/365.25)

print(f'found {len(df_screen)} ETFs')
found 3741 ETFs

3000+ ETFs and years of data… looks like we got ourselves an ETF Search Party!!!

Age and Dollar Traded

there are lots of ETFs out there… let’s see the average “age” and dollar-traded

Code
import plotly.express as px
import plotly.io as pio
import pandas as pd
import numpy as np
pio.renderers.default = "notebook"

# plot Age
fig_age = px.histogram(df_screen, x = "age", title = f"ETFs' Age")
fig_age.add_vline(x= df_screen['age'].mean(), 
              annotation_text=f"Average Age ({df_screen['age'].mean():.2f})", 
              annotation_position="bottom right")
age_bins = [0,3,5,10,float('inf')]
age_labels = ['0 to 3', '3 to 5', '5 to 10', '>10']
df_screen['age_cat'] = pd.cut(df_screen['age'], bins = age_bins, labels = age_labels, right = False)
age_cat_vc = df_screen['age_cat'].value_counts()
pie_age = px.pie(age_cat_vc, values = 'count', names = age_cat_vc.index, title = f"ETFs' Age by Categories")

# plot dollar traded
dollar_bins = [0,1e5, 1e5 * 5, 1e6, 1e6 * 100, 1e6*1000, float('inf')]
dollar_labels = ['less than 100k', '100k to 500k', '500k to 1mm', '1mm to 100mm', '100mm to 1B', 'above 1B']
df_screen['dollar_traded_1d_cat'] = pd.cut(df_screen['dollar_traded_1d'], 
                                           bins = dollar_bins, labels = dollar_labels, right = False)
dollar_cat_vc = df_screen['dollar_traded_1d_cat'].value_counts()
fig_dollar = px.pie(dollar_cat_vc, values = "count", names = dollar_cat_vc.index,
                    title = f"ETFs' Average 10-d Dollar Traded")

display(fig_age)
display(pie_age)
display(fig_dollar)
(a) ETF’s Age Distribution
(b) ETF’s Age by Categories
(c) Average 10d Dollar Traded
Figure 1: ETFs’ Age and Dollar Traded

Filter by volume and age

we are going to separate these ETFs by Age and filter out those with less than 500k dollar trade.

Code
df_screen_vol = df_screen[df_screen['dollar_traded_1d']>5*1e5]
df_10y = df_screen_vol[df_screen_vol['age']>=10]
df_5y = df_screen_vol[(df_screen_vol['age']>=5) & ~df_screen_vol['ticker'].isin(df_10y['ticker'].tolist()) ]
df_3y = df_screen_vol[(df_screen_vol['age']>=3) & 
                      ~df_screen_vol['ticker'].isin(df_10y['ticker'].tolist())&
                      ~df_screen_vol['ticker'].isin(df_5y['ticker'].tolist())]

10-year Club

let’s take a look at ETFs that’s been around for 10 years

Code
import os, sys, warnings
import yfinance as yf
from businessdate import BusinessDate
from tqdm import tqdm
warnings.filterwarnings('ignore')

# for loading local modules
cwdir = os.path.dirname(os.path.realpath("__file__"))
sys.path.insert(1, os.path.join(cwdir, "../"))
from toolbox.yf_utils import get_stocks_ohlc


#--- performance metrics code from previous post ---#
def annual_rate_to_daily(annual_rate, trading_days = 252):
    return (1 + annual_rate) ** (1/trading_days) - 1

def sortino_ratio(returns, adjustment_factor=0.0, debug = False):
    """
    Determines the Sortino ratio of a strategy.
    
    Parameters
    ----------
    returns : pd.Series or np.ndarray
        Daily returns of the strategy, noncumulative.
        adjustment_factor : int, float
        Constant daily benchmark return throughout the period.

    Returns
    -------
    sortino_ratio : float

    Note
    -----
    See `<https://www.sunrisecapital.com/wp-content/uploads/2014/06/Futures_
    Mag_Sortino_0213.pdf>`__ for more details.
    """
    
    # compute annualized return
    returns_risk_adj = np.asanyarray(returns - adjustment_factor)
    mean_annual_return = returns_risk_adj.mean() * 252

    # compute the downside deviation
    downside_diff = np.clip(returns_risk_adj, np.NINF, 0)
    np.square(downside_diff, out=downside_diff)
    annualized_downside_deviation = np.sqrt(downside_diff.mean()) * np.sqrt(252)
    if debug:
        print(f'avg annual return: {mean_annual_return}')
        print(f'annualized downside std: {annualized_downside_deviation}')
    
    return mean_annual_return / annualized_downside_deviation

def calculate_performance_metrics(equity_curve: pd.DataFrame, 
    risk_free_rate: float = 0.05, trading_days: int = 252, price_col = "Close",
    exclude_dates:list = [], start_date= None
    ) -> dict:
    """
    Calculate performance metrics for an equity curve.
    
    Parameters:
    equity_curve (pd.DataFrame): DataFrame with a 'returns' column representing daily returns
    risk_free_rate (float): Annual risk-free rate, default is 2%
    trading_days (int): Number of trading days in a year, default is 252
    
    Returns:
    dict: A dictionary containing the calculated metrics
    """
    
    # Ensure 'returns' column exists
    if 'returns' not in equity_curve.columns:
        equity_curve['returns'] = equity_curve[[price_col]].pct_change()
    if exclude_dates:
        equity_curve = equity_curve[~equity_curve.index.isin(exclude_dates)]
    equity_curve = equity_curve[equity_curve.index.date > start_date] if start_date else equity_curve
    
    # Annualized Return
    total_return = (equity_curve[price_col].iloc[-1] / equity_curve[price_col].iloc[0]) - 1
    years = len(equity_curve) / trading_days
    annualized_return = (1 + total_return) ** (1 / years) - 1
    
    # Sortino Ratio
    downside_returns = equity_curve['returns'][equity_curve['returns'] < 0]
    sr = sortino_ratio(returns = equity_curve['returns'].dropna(), 
                       adjustment_factor= annual_rate_to_daily(risk_free_rate, trading_days= trading_days)
                      )
    
    # Maximum Drawdown
    cumulative_returns = (1 + equity_curve['returns']).cumprod()
    peak = cumulative_returns.expanding(min_periods=1).max()
    drawdown = (cumulative_returns / peak) - 1
    max_drawdown = drawdown.min()
    
    # Gain-to-Pain Ratio
    pain = [r for r in equity_curve['returns'].tolist() if r <0]
    gain = [r for r in equity_curve['returns'].tolist() if r >0]
    GPR = sum(gain)/ abs(sum(pain))
    
    return {
        'Annualized Return': annualized_return,
        'Sortino Ratio': sr, #sortino_ratio,
        'Max Drawdown': max_drawdown,
        'Gain-to-Pain Ratio': GPR 
    }

def compute_performance_metrics(df_stocks, risk_free_rate = 0, 
        trading_days = 252, price_col = "Close", min_bar_count = 10,
        check_div: bool = False, start_date = None
    ):
    ''' return a dataframe of metrics for each ticker in df_stocks
    Args:
        df_stocks: a ohlc dataframe with data for multiple tickers
    '''
    data = []
    tickers = list(set(df_stocks.columns.get_level_values('Ticker')))
    if check_div:
        stock_objs = yf.Tickers(tickers)
        
    for ticker in tqdm(tickers, desc = 'computing metrics'):
        df_t = df_stocks[ticker].dropna() # in case the ETF did not have data
        if len(df_t)<min_bar_count:
            continue

        div_dates = stock_objs.tickers[ticker].dividends.index.date.tolist()if check_div else []
        m = calculate_performance_metrics(equity_curve= df_t, 
                                          risk_free_rate= risk_free_rate, 
                                          trading_days= trading_days, 
                                          price_col= price_col, 
                                          exclude_dates= div_dates,
                                          start_date= start_date
                                         )
        m['ticker'] = ticker
        m['bar_count'] = len(df_t)
        if check_div and start_date:
            m['div_count'] = len([d for d in div_dates if d > start_date])
        data.append(m)
    return pd.DataFrame(data)

According to my research assistant, we will use a risk-free rate of 2.48%.

We also added three reference investments (highlighted with blue circles below):

  1. BRK-B representing the GOAT investor: Warren Buffett
  2. BOXX represents the “risk-free” investment, but it has only been around since Dec 2022
  3. GLD represents our safe-haven asset

here are the top-10 as ranked by Sortino Ratio

Code
#--- loading market data---#
# our benchmarks to compare
bm_tickers = ['BOXX', 'SPY', 'BRK-B', 'GLD']
today = BusinessDate('2025-04-10')
today_10y = BusinessDate('2015-04-10')

tickers_10y = list(set(df_10y['name'].tolist() + bm_tickers))
df_stocks_10y = get_stocks_ohlc(tickers = tickers_10y,
                            interval = '1d',
                            start_date = today_10y,
                            end_date = today
                           )

# compute metrics for all 10y ETFs
df_10y_metrics = compute_performance_metrics(df_stocks_10y, risk_free_rate = 0.02958, check_div = True)
df_10y_metrics.sort_values(by = ["Sortino Ratio"], ascending = False).head(10)
computing metrics: 100%|███████████████████████████████████████████████████████████| 832/832 [04:56<00:00,  2.81it/s]
Annualized Return Sortino Ratio Max Drawdown Gain-to-Pain Ratio ticker bar_count
258 0.050582 9.847647 -0.001159 9.519356 BOXX 573
215 0.180645 1.085015 -0.366629 1.176517 FTEC 2517
700 0.182234 1.068923 -0.393112 1.174192 IGM 2517
780 0.186290 1.063025 -0.389528 1.171912 IYW 2517
718 0.157936 1.036899 -0.321345 1.178058 IWY 2517
355 0.183103 1.008767 -0.356096 1.166010 XLK 2517
233 0.229383 1.006058 -0.453026 1.149927 SMH 2517
758 0.197504 0.969768 -0.458113 1.145669 SOXX 2517
748 0.144320 0.954093 -0.317081 1.167209 IWF 2517
98 0.164577 0.944499 -0.370293 1.158650 QQQ 2517

Return vs Risk

re-using the visualization function here we are going to plot all these ETFs against SPY

Figure 2 (a) shows that it’s hard to beat the market in returns and have a higher Sortino Ratio. FTEC has the highest Sortino ratio in the top-right quadrant but that’s only 0.18 higher than the SPY.

when thinking in terms of Gain-to-Pain, it seems that all funds converges at about 1.17 in Figure 2 (b). But there are a few reliable funds that can preserve capital close to the risk-free rate! Those are the typical money market funds like BOXX, SHV, and BIL

Code
import plotly.graph_objects as go

def visualize_metrics(df, x: str, y: str, size: str = None, color: str = None, 
                      text:str = None, textposition = "top center",
                      color_continuous_scale: str = 'rdbu', color_continuous_midpoint: float = None,
                      ref_ticker: str = None, ref_ticker_marker_symbol: str = "circle-open-dot",
                      ref_ticker_marker_color: str = 'green',
                      hl_tickers: list = [], hl_tickers_color: str = 'yellow', hl_marker_size: int = 15
                     ):
    ''' return a plotly figure object
    Args:
        ref_ticker_marker_symbol: get help on marker styling here https://plotly.com/python/marker-style/#color-opacity
        color_continuous_scale: any in https://plotly.com/python/colorscales/#color-scales-in-plotly-express
    '''
    fig = px.scatter(df, x = x, y = y, 
                 size = size, color = color, 
                text = text,
                 hover_data =  {'ticker': df.index},
                 title = f"{y} vs {x}",
                 color_continuous_midpoint= color_continuous_midpoint,
                 color_continuous_scale= color_continuous_scale, 
                )
    fig.update_traces(textposition = textposition)
    
    other_trace_order = -1
    if ref_ticker:
        fig.add_trace(go.Scatter(
            x= [df.at[ref_ticker, x]], 
            y = [df.at[ref_ticker, y]], 
            mode = "markers", marker_symbol = ref_ticker_marker_symbol, marker_size = 10, 
            marker = {'color': ref_ticker_marker_color},
            hoverinfo = "skip",
            zorder = other_trace_order # order this trace behind the original scatter
        ))
        fig.add_hline(y = df.at[ref_ticker, y], line_dash = "dot", opacity = 0.5)
        fig.add_vline(x = df.at[ref_ticker, x], line_dash = "dot", opacity = 0.5)
    for t in hl_tickers:
        other_trace_order -=1
        fig.add_trace(go.Scatter(
            x= [df.at[t, x]], 
            y = [df.at[t, y]], 
            mode = "markers", marker_symbol = ref_ticker_marker_symbol, marker_size = hl_marker_size, 
            marker = {'color': hl_tickers_color},
            hoverinfo = "skip",
            zorder = other_trace_order # order this trace behind the original scatter
        ))
    fig.update_layout(showlegend=False)
    fig.data = fig.data[::-1] # reorder trace
    return fig

df_10y_metrics = df_10y_metrics.set_index('ticker', drop = True) if 'ticker' in df_10y_metrics.columns else df_10y_metrics
display(
    visualize_metrics(df_10y_metrics, x = "Sortino Ratio", y= "Annualized Return",
                     color = "Max Drawdown", text= None,
                    color_continuous_scale = 'reds_r', color_continuous_midpoint = 0,
                    ref_ticker='SPY', hl_tickers=['BRK-B', 'GLD', "BOXX"], hl_tickers_color= 'blue', hl_marker_size= 13
                 )
)
display(
    visualize_metrics(df_10y_metrics, x = "Gain-to-Pain Ratio", y= "Annualized Return",
                     color = "Max Drawdown", text= None,
                    color_continuous_scale = 'reds_r', color_continuous_midpoint = 0,
                    ref_ticker='SPY', hl_tickers=['BRK-B', 'GLD', "BOXX"], hl_tickers_color= 'blue', hl_marker_size= 13
                 )
)
(a) Sortino Ratio vs Returns
(b) GPR vs Returns
Figure 2: 10-year club: Annualized Return vs GPR or Sortino Ratio

Drawdown vs Returns

Figure 3 shows that it’s pretty hard to beat the market and also have less draw-down. The GOAT BRK-B definitely did it on that front. And so did CLSE but with both lower sortino and Gain-to-Pain ratio1.

Code
display(
    visualize_metrics(df_10y_metrics, x = "Max Drawdown", y= "Annualized Return",
                     color = "Sortino Ratio", text= None,
                    color_continuous_scale = 'viridis', color_continuous_midpoint = 1,
                    ref_ticker='SPY', hl_tickers=['BRK-B', 'GLD', 'BOXX'], hl_tickers_color= 'blue', hl_marker_size= 13
                 )
)
Figure 3: it’s pretty hard to beat the market and have a smaller drawn-down

Leaderboard

we are going to use pandas’ ranking function to help create a leaderboard for the ETF with the highest return, highest Gain-to-Pain Ratio, highest Sortino Ratio, and lowest Max Draw-down.

Here are the top 10 and as you can see, only 6 investments out of 832 ranks above SPY

Code
def df_to_leaderboard(data, rank_dict, debug:bool = False, out_col_name = 'ranking'):
    df = data.copy(deep = True)
    s_ranks = []
    f_rank = None
    for k,v in rank_dict.items():
        s_rank = df[k].rank(**v)
        s_ranks.append(s_rank)
        f_rank = f_rank + s_rank if isinstance(f_rank, pd.Series) else s_rank
        if debug:
            df[f'{k}:rank'] = s_rank
    if debug:
        df[out_col_name + ":points"] = f_rank
    df[out_col_name] = f_rank.rank(ascending= True)
    return df.sort_values(out_col_name, ascending = True)

df_10y_leaderboard = df_to_leaderboard(df_10y_metrics, 
                         rank_dict = {
                            'Gain-to-Pain Ratio': {"ascending": False},
                            'Annualized Return': {"ascending": False},
                            'Sortino Ratio': {'ascending': False},
                             'Max Drawdown': {'ascending': False}
                        })

# adding details for each ETF
df_10y_leaderboard['ETF Desc'] = [ df_screen[df_screen['name']==x]['description'].tolist()
                                for x in df_10y_leaderboard.index.tolist()]
df_10y_leaderboard['ETF Desc']= [x[0] if x else "" for x in df_10y_leaderboard['ETF Desc'].tolist()]
df_10y_leaderboard['launch_date'] = [ df_screen[df_screen['name']==x]['launch_date'].tolist()
                                for x in df_10y_leaderboard.index.tolist()]
df_10y_leaderboard['launch_date']= [x[0] if x else "" for x in df_10y_leaderboard['launch_date'].tolist()]
df_10y_leaderboard['url'] = [ df_screen[df_screen['name']==x]['ticker'].tolist()
                                for x in df_10y_leaderboard.index.tolist()]
df_10y_leaderboard['url']= [f'https://www.tradingview.com/symbols/{x[0].replace(":","-")}/' 
                            if x else "" for x in df_10y_leaderboard['url'].tolist()]

for t in bm_tickers:
    print(f'{t} is ranked {df_10y_leaderboard.at[t, "ranking"]}')
df_10y_leaderboard.drop(columns = ['ETF Desc', 'launch_date', 'url']).head(10)
BOXX is ranked 21.0
SPY is ranked 7.0
BRK-B is ranked 6.0
GLD is ranked 33.5
Annualized Return Sortino Ratio Max Drawdown Gain-to-Pain Ratio bar_count ranking
ticker
CLSE 0.119273 0.872539 -0.170736 1.151976 787 1.0
IWY 0.157936 1.036899 -0.321345 1.178058 2517 2.0
IWF 0.144320 0.954093 -0.317081 1.167209 2517 3.0
OEF 0.128231 0.932230 -0.314355 1.173177 2517 4.0
XLG 0.136418 0.865103 -0.304963 1.157646 2517 5.0
BRK-B 0.136595 0.869941 -0.295699 1.149261 2517 6.0
SPY 0.117075 0.903195 -0.307322 1.170085 2517 7.0
SPHQ 0.121310 0.830261 -0.289488 1.156425 2517 8.0
IVW 0.133561 0.896730 -0.318637 1.160998 2517 9.0
RTH 0.123469 0.818123 -0.248585 1.146301 2517 10.0
Code
n = 10
r = 1

print('::: {.callout-caution title = "Expand to see Top 10 by Name:" collapse="true"}')
for i, row in df_10y_leaderboard.head(n).iterrows():
    if i in df_screen['name'].tolist():
        md_str = f'{r}. [{i}](https://www.tradingview.com/symbols/{df_screen[df_screen["name"]==i]["ticker"].tolist()[0].replace(":","-")}/)'
        md_str += f'(est. {datetime.strftime(df_screen[df_screen["name"]==i]["launch_date"].tolist()[0].date(), "%b %Y")}'
        md_str += f",  Returns: {df_10y_leaderboard.at[i,'Annualized Return']*100:.2f}%)"

        md_str += f' - {row["ETF Desc"]}'
        print(md_str)
    else:
        print(f'{r}. {i}')
    r+=1
print(f':::')
  1. CLSE(est. Dec 2009, Returns: 11.93%) - Trust for Professional Managers Convergence Long/Short Equity ETF
  2. IWY(est. Sep 2009, Returns: 15.79%) - iShares Russell Top 200 Growth ETF
  3. IWF(est. May 2000, Returns: 14.43%) - iShares Russell 1000 Growth ETF
  4. OEF(est. Oct 2000, Returns: 12.82%) - iShares S&P 100 ETF
  5. XLG(est. May 2005, Returns: 13.64%) - Invesco S&P 500 Top 50 ETF
  6. BRK-B
  7. SPY(est. Jan 1993, Returns: 11.71%) - SPDR S&P 500 ETF TRUST
  8. SPHQ(est. Dec 2005, Returns: 12.13%) - Invesco S&P 500 Quality ETF
  9. IVW(est. May 2000, Returns: 13.36%) - iShares S&P 500 Growth ETF
  10. RTH(est. May 2001, Returns: 12.35%) - VanEck Retail ETF

5-year Club

5-year is not a long time but as Figure 1 (b) shows, just under 20% of all actively traded ETFs are between 5 to 10 years old. In this past 5-year, we saw the rally back from the pandemic crash of early 2020 and a bad year for the SPY in 2022. For this period, we will use a risk-free rate of 2.69%2.

Along with the 832 ETFs form the 10-year Club, we now have a universe of 1,331 ETFs to look at.

Here are the top 10 funds by Sortino Ratio

Code
today_5y = BusinessDate('2020-04-10')

tickers_5y = df_5y['name'].tolist()
df_stocks_5y = get_stocks_ohlc(tickers = tickers_5y,
                            interval = '1d',
                            start_date = today_5y,
                            end_date = today
                           )

# compute metrics for all 10y ETFs
df_5y_metrics = compute_performance_metrics(
                    df_stocks= pd.concat([df_stocks_5y, df_stocks_10y]), 
                    risk_free_rate = 0.0269, check_div = True, start_date= today_5y)
df_5y_metrics.sort_values(by = ["Sortino Ratio"], ascending = False).head(10)
computing metrics: 100%|█████████████████████████████████████████████████████████| 1331/1331 [05:55<00:00,  3.74it/s]
Annualized Return Sortino Ratio Max Drawdown Gain-to-Pain Ratio ticker bar_count
428 0.050582 11.500246 -0.001159 9.519356 BOXX 573
501 0.050925 1.845245 -0.023967 2.088322 VRIG 1257
753 0.067521 1.831637 -0.079252 1.617311 FLRT 2517
423 0.323535 1.713700 -0.214130 1.245183 USAI 1257
1089 0.314930 1.673405 -0.320750 1.250650 ATMP 2517
871 0.277550 1.659404 -0.248008 1.239549 ENFR 2517
198 0.295545 1.596655 -0.360489 1.226253 AMLP 2517
405 0.362932 1.582108 -0.349030 1.209738 ARGT 2517
780 0.233657 1.562684 -0.170997 1.226821 TPYP 1257
830 0.271734 1.557217 -0.360505 1.223176 MLPA 2517

Returns vs Risk

Code
df_5y_metrics = df_5y_metrics.set_index('ticker', drop = True) if 'ticker' in df_5y_metrics.columns else df_5y_metrics
display(
    visualize_metrics(df_5y_metrics, x = "Sortino Ratio", y= "Annualized Return",
                     color = "Max Drawdown", text= None,
                    color_continuous_scale = 'reds_r', color_continuous_midpoint = 0,
                    ref_ticker='SPY', hl_tickers=['BRK-B', 'GLD', "BOXX"], hl_tickers_color= 'blue', hl_marker_size= 13
                 )
)
display(
    visualize_metrics(df_5y_metrics, x = "Gain-to-Pain Ratio", y= "Annualized Return",
                     color = "Max Drawdown", text= None,
                    color_continuous_scale = 'reds_r', color_continuous_midpoint = 0,
                    ref_ticker='SPY', hl_tickers=['BRK-B', 'GLD', "BOXX"], hl_tickers_color= 'blue', hl_marker_size= 13
                 )
)
(a) Sortino Ratio vs Returns
(b) GPR vs Returns
Figure 4: 5-year club: Annualized Return vs GPR or Sortino Ratio

Drawdown vs Returns

Code
display(
    visualize_metrics(df_5y_metrics, x = "Max Drawdown", y= "Annualized Return",
                     color = "Sortino Ratio", text= None,
                    color_continuous_scale = 'viridis', color_continuous_midpoint = 1,
                    ref_ticker='SPY', hl_tickers=['BRK-B', 'GLD', 'BOXX'], hl_tickers_color= 'blue', hl_marker_size= 13
                 )
)
Figure 5: seems that beating the market and having less draw-down is easier in the last 5-y than over 10

Leaderboard

looking at the top 20 (~top 1%) on our leaderboard, we found that 9 were ETFs from the 10-y club but interestingly their 10-year performance ranking wise were quite low.

However, our reference BRK-B performance during both period were equally impressive:

  • 5-year rank: 42th (top 3%)
  • 10-year rank: 6th (top 1%)
Code
df_5y_leaderboard = df_to_leaderboard(df_5y_metrics, 
                     rank_dict = {
                        'Gain-to-Pain Ratio': {"ascending": False},
                        'Annualized Return': {"ascending": False},
                        'Sortino Ratio': {'ascending': False},
                         'Max Drawdown': {'ascending': False}
                    })
df_5y_leaderboard['10y_club_ranking'] = [df_10y_leaderboard.at[x,"ranking"] if x in df_10y_leaderboard.index else None
                                    for x in  df_5y_leaderboard.index.tolist()]
df_5y_leaderboard['ETF Desc'] = [ df_screen[df_screen['name']==x]['description'].tolist()
                                for x in df_5y_leaderboard.index.tolist()]
df_5y_leaderboard['ETF Desc']= [x[0] if x else "" for x in df_5y_leaderboard['ETF Desc'].tolist()]
df_5y_leaderboard['launch_date'] = [ df_screen[df_screen['name']==x]['launch_date'].tolist()
                                for x in df_5y_leaderboard.index.tolist()]
df_5y_leaderboard['launch_date']= [x[0] if x else "" for x in df_5y_leaderboard['launch_date'].tolist()]
df_5y_leaderboard['url'] = [ df_screen[df_screen['name']==x]['ticker'].tolist()
                                for x in df_5y_leaderboard.index.tolist()]
df_5y_leaderboard['url']= [f'https://www.tradingview.com/symbols/{x[0].replace(":","-")}/' 
                            if x else "" for x in df_5y_leaderboard['url'].tolist()]

for t in bm_tickers:
    print(f'{t} is ranked {df_5y_leaderboard.at[t, "ranking"]}')
df_5y_leaderboard.drop(columns=["ETF Desc", "launch_date", "url"]).head(20)
BOXX is ranked 83.0
SPY is ranked 89.0
BRK-B is ranked 42.0
GLD is ranked 244.0
Annualized Return Sortino Ratio Max Drawdown Gain-to-Pain Ratio bar_count ranking 10y_club_ranking
ticker
AUSF 0.193447 1.251066 -0.146535 1.196563 1257 1.0 NaN
TPYP 0.233657 1.562684 -0.170997 1.226821 1257 2.0 NaN
IAK 0.220112 1.438219 -0.169390 1.213091 2517 3.0 122.0
SDCI 0.222137 1.442262 -0.188271 1.230980 1257 4.0 NaN
USCI 0.208252 1.368932 -0.188408 1.227467 2517 5.0 634.5
IYK 0.152852 1.282406 -0.131536 1.202332 2517 6.0 61.0
USAI 0.323535 1.713700 -0.214130 1.245183 1257 7.0 NaN
DBA 0.158424 1.291786 -0.159408 1.196004 2517 8.0 695.0
CEFS 0.156491 1.321315 -0.161861 1.215522 1257 9.0 NaN
FLIN 0.183966 1.448071 -0.196180 1.218308 1257 10.0 NaN
EPI 0.225338 1.548842 -0.212186 1.237671 2517 11.0 395.0
FDVV 0.168454 1.436817 -0.189884 1.224328 1257 12.0 NaN
CDL 0.156263 1.254956 -0.163621 1.194549 1257 13.0 NaN
FCPI 0.164801 1.214822 -0.178916 1.194403 1257 14.0 NaN
FNDX 0.158695 1.227043 -0.172099 1.191250 2517 15.0 110.0
NFTY 0.199864 1.288201 -0.209061 1.189343 2517 16.0 537.0
VUSE 0.190301 1.247887 -0.203583 1.183790 2517 17.0 289.0
DIVO 0.143004 1.482278 -0.168663 1.235777 1257 18.0 NaN
DXJ 0.221022 1.267594 -0.221939 1.198037 2517 19.0 216.0
PTLC 0.135140 1.195819 -0.134277 1.214713 1257 20.0 NaN
Code
n = 20
r = 1

print('::: {.callout-caution title = "Expand to see Top 20 by Name:" collapse="true"}')
for i, row in df_5y_leaderboard.head(n).iterrows():
    if i in df_screen['name'].tolist():
        md_str = f'{r}. [{i}](https://www.tradingview.com/symbols/{df_screen[df_screen["name"]==i]["ticker"].tolist()[0].replace(":","-")}/)'
        md_str += f'(est. {datetime.strftime(df_screen[df_screen["name"]==i]["launch_date"].tolist()[0].date(), "%b %Y")}'
        md_str += f",  Returns: {df_5y_leaderboard.at[i,'Annualized Return']*100:.2f}%)"

        md_str += f' - {row["ETF Desc"]}'
        print(md_str)
    else:
        print(f'{r}. {i}')
    r+=1
print(f':::')
  1. AUSF(est. Aug 2018, Returns: 19.34%) - Global X Funds Global X Adaptive U.S. Factor ETF
  2. TPYP(est. Jun 2015, Returns: 23.37%) - Tortoise North American Pipeline ETF
  3. IAK(est. Apr 2006, Returns: 22.01%) - iShares U.S. Insurance ETF
  4. SDCI(est. May 2018, Returns: 22.21%) - USCF SummerHaven Dynamic Commodity Strategy No K-1 Fund
  5. USCI(est. Aug 2010, Returns: 20.83%) - United States Commodity Index Fund ETV
  6. IYK(est. Jun 2000, Returns: 15.29%) - iShares U.S. Consumer Staples ETF
  7. USAI(est. Dec 2017, Returns: 32.35%) - Pacer American Energy Independence ETF
  8. DBA(est. Jan 2007, Returns: 15.84%) - Invesco DB Agriculture Fund
  9. CEFS(est. Mar 2017, Returns: 15.65%) - Saba Closed-End Funds ETF
  10. FLIN(est. Feb 2018, Returns: 18.40%) - Franklin FTSE India ETF
  11. EPI(est. Feb 2008, Returns: 22.53%) - WisdomTree India Earnings Fund
  12. FDVV(est. Sep 2016, Returns: 16.85%) - Fidelity High Dividend ETF
  13. CDL(est. Jul 2015, Returns: 15.63%) - VictoryShares US Large Cap High Div Volatility Wtd ETF
  14. FCPI(est. Nov 2019, Returns: 16.48%) - Fidelity Stocks for Inflation ETF
  15. FNDX(est. Aug 2013, Returns: 15.87%) - Schwab Fundamental U.S. Large Company ETF
  16. NFTY(est. Feb 2012, Returns: 19.99%) - First Trust India Nifty 50 Equal Weight ETF
  17. VUSE(est. Jan 2014, Returns: 19.03%) - Vident Core US Equity ETF
  18. DIVO(est. Dec 2016, Returns: 14.30%) - Amplify CWP Enhanced Dividend Income ETF
  19. DXJ(est. Jun 2006, Returns: 22.10%) - WisdomTree Japan Hedged Equity Fund
  20. PTLC(est. Jun 2015, Returns: 13.51%) - Pacer Fund Trust Pacer Trendpilot US Large Cap ETF

3-year Club

in the 3-5 year category we have additional of 571 ETFs which brings our universe of investment to evaluate over the last 3-year to 1,902. For the 3-year period, we saw the market rallied in 2023 and 2024 led by tech stocks. But we also experienced some pretty big negative days in the tarrifs sell off in Apr 2025.

We will use a risk-free rate of 3.71%3.

Code
today_3y = BusinessDate('2022-04-10')

tickers_3y = df_3y['name'].tolist()
df_stocks_3y = get_stocks_ohlc(tickers = tickers_3y,
                            interval = '1d',
                            start_date = today_3y,
                            end_date = today
                           )

# compute metrics for all 10y ETFs
df_3y_metrics = compute_performance_metrics(
                    df_stocks= pd.concat([df_stocks_3y, df_stocks_5y, df_stocks_10y]), 
                    risk_free_rate = 0.0371, check_div = True, start_date= today_3y)
df_3y_metrics.sort_values(by = ["Sortino Ratio"], ascending = False).head(10)
  3%|██                                                                | 41/1330 [163:40:22<5145:43:15, 14371.29s/it]
computing metrics: 100%|█████████████████████████████████████████████████████████| 1638/1638 [06:20<00:00,  4.31it/s]
Annualized Return Sortino Ratio Max Drawdown Gain-to-Pain Ratio ticker bar_count div_count
526 0.050582 5.734312 -0.001159 9.519356 BOXX 573 1
257 0.046049 3.791190 -0.000299 36.355631 SGOV 753 36
1056 0.044449 3.047087 -0.000328 20.141535 BIL 2517 35
698 0.043555 2.929560 -0.002001 8.907267 GBIL 1257 36
445 0.046979 2.511089 -0.001387 7.369236 TFLO 2517 36
883 0.049828 2.207438 -0.005089 3.367343 GSST 1257 36
815 0.048956 2.128984 -0.004421 3.247476 GSY 2517 36
1584 0.053072 2.071176 -0.007899 3.314056 PULS 1257 36
218 0.043671 1.904832 -0.002268 11.294428 SHV 2517 36
1632 0.046886 1.773243 -0.002970 3.411023 JPST 1257 37

Returns vs Risk

Code
df_3y_metrics = df_3y_metrics.set_index('ticker', drop = True) if 'ticker' in df_3y_metrics.columns else df_3y_metrics
display(
    visualize_metrics(df_3y_metrics, x = "Sortino Ratio", y= "Annualized Return",
                     color = "Max Drawdown", text= None,
                    color_continuous_scale = 'reds_r', color_continuous_midpoint = 0,
                    ref_ticker='SPY', hl_tickers=['BRK-B', 'GLD', "BOXX"], hl_tickers_color= 'blue', hl_marker_size= 13
                 )
)
display(
    visualize_metrics(df_3y_metrics, x = "Gain-to-Pain Ratio", y= "Annualized Return",
                     color = "Max Drawdown", text= None,
                    color_continuous_scale = 'reds_r', color_continuous_midpoint = 0,
                    ref_ticker='SPY', hl_tickers=['BRK-B', 'GLD', "BOXX"], hl_tickers_color= 'blue', hl_marker_size= 13
                 )
)
(a) Sortino Ratio vs Returns
(b) GPR vs Returns
Figure 6: 3-year club: Annualized Return vs GPR or Sortino Ratio

Drawdown vs Returns

Code
display(
    visualize_metrics(df_3y_metrics, x = "Max Drawdown", y= "Annualized Return",
                     color = "Sortino Ratio", text= None,
                    color_continuous_scale = 'viridis', color_continuous_midpoint = 1,
                    ref_ticker='SPY', hl_tickers=['BRK-B', 'GLD', 'BOXX'], hl_tickers_color= 'blue', hl_marker_size= 13
                 )
)
Figure 7: the top-right quadrant reveals a few more actively managed funds

Leaderboard

Code
df_3y_leaderboard = df_to_leaderboard(df_3y_metrics, 
                     rank_dict = {
                        'Gain-to-Pain Ratio': {"ascending": False},
                        'Annualized Return': {"ascending": False},
                        'Sortino Ratio': {'ascending': False},
                         'Max Drawdown': {'ascending': False}
                    })
df_3y_leaderboard['10y_club_ranking'] = [df_10y_leaderboard.at[x,"ranking"] if x in df_10y_leaderboard.index else None
                                    for x in  df_3y_leaderboard.index.tolist()]
df_3y_leaderboard['5y_club_ranking'] = [df_5y_leaderboard.at[x,"ranking"] if x in df_5y_leaderboard.index else None
                                    for x in  df_3y_leaderboard.index.tolist()]

df_3y_leaderboard['ETF Desc'] = [ df_screen[df_screen['name']==x]['description'].tolist()
                                for x in df_3y_leaderboard.index.tolist()]
df_3y_leaderboard['ETF Desc']= [x[0] if x else "" for x in df_3y_leaderboard['ETF Desc'].tolist()]
df_3y_leaderboard['launch_date'] = [ df_screen[df_screen['name']==x]['launch_date'].tolist()
                                for x in df_3y_leaderboard.index.tolist()]
df_3y_leaderboard['launch_date']= [x[0] if x else "" for x in df_3y_leaderboard['launch_date'].tolist()]
df_3y_leaderboard['url'] = [ df_screen[df_screen['name']==x]['ticker'].tolist()
                                for x in df_3y_leaderboard.index.tolist()]
df_3y_leaderboard['url']= [f'https://www.tradingview.com/symbols/{x[0].replace(":","-")}/' 
                            if x else "" for x in df_3y_leaderboard['url'].tolist()]

for t in bm_tickers:
    print(f'{t} is ranked {df_3y_leaderboard.at[t, "ranking"]}')
    
df_3y_leaderboard.head(30).drop(columns = ["ETF Desc", 'launch_date', 'url'])
BOXX is ranked 11.5
SPY is ranked 232.0
BRK-B is ranked 215.0
GLD is ranked 56.5
Annualized Return Sortino Ratio Max Drawdown Gain-to-Pain Ratio bar_count ranking 10y_club_ranking 5y_club_ranking
ticker
FLRT 0.069053 1.332329 -0.071796 1.627367 2517 1.0 279.0 82.0
IGHG 0.068666 0.856732 -0.051349 1.237450 2517 2.0 333.0 411.0
OCTW 0.076108 0.789919 -0.083793 1.252515 753 3.0 NaN NaN
SIXH 0.081064 0.932971 -0.100578 1.208382 753 4.0 NaN NaN
JANW 0.076447 0.760075 -0.086614 1.219663 753 5.0 NaN NaN
BGLD 0.145539 1.456350 -0.146925 1.261416 753 6.0 NaN NaN
BALT 0.061212 0.900420 -0.048918 1.365610 753 7.0 NaN NaN
VRIG 0.056020 1.064912 -0.020842 2.103360 1257 8.0 NaN 90.0
PULS 0.053072 2.071176 -0.007899 3.314056 1257 9.0 NaN 113.0
IAK 0.139727 1.135163 -0.143834 1.186429 2517 10.0 122.0 3.0
BOXX 0.050582 5.734312 -0.001159 9.519356 573 11.5 21.0 83.0
SIXA 0.100613 0.974649 -0.124871 1.185202 753 11.5 NaN NaN
HEQT 0.084612 0.753923 -0.101625 1.175581 753 13.0 NaN NaN
BFIX 0.057763 1.158105 -0.050203 1.400234 753 14.0 NaN NaN
PTNQ 0.103609 0.868129 -0.125163 1.166248 1257 15.0 NaN 33.0
NEAR 0.052572 1.239426 -0.008235 1.954934 2517 16.5 299.0 186.0
RISR 0.130929 1.100260 -0.149944 1.196075 753 16.5 NaN NaN
GLDI 0.108806 0.990762 -0.138939 1.204813 2517 18.0 95.0 362.5
GSST 0.049828 2.207438 -0.005089 3.367343 1257 19.0 NaN 138.0
BSJP 0.062637 0.717038 -0.064020 1.259180 1257 20.0 NaN 97.0
JAAA 0.053291 0.993421 -0.021841 1.721282 753 21.0 NaN NaN
GSY 0.048956 2.128984 -0.004421 3.247476 2517 22.0 300.5 174.0
TFLO 0.046979 2.511089 -0.001387 7.369236 2517 23.5 348.0 535.0
PJUL 0.083790 0.691950 -0.106948 1.169280 1257 23.5 NaN 143.0
SGOV 0.046049 3.791378 -0.000300 36.355921 753 25.0 NaN NaN
POCT 0.078019 0.659162 -0.102248 1.194244 1257 26.5 NaN 57.0
JULW 0.073419 0.695999 -0.094951 1.193857 753 26.5 NaN NaN
ICSH 0.047515 1.625441 -0.004186 3.843889 2517 28.0 327.5 457.0
JPST 0.046886 1.773243 -0.002970 3.411023 1257 29.0 NaN 198.0
MINT 0.047845 1.687724 -0.008405 3.442114 2517 30.0 327.5 427.0
Code
n = 20
r = 1

print('::: {.callout-caution title = "Expand to see Top 20 by Name:" collapse="true"}')
for i, row in df_3y_leaderboard.head(n).iterrows():
    if i in df_screen['name'].tolist():
        md_str = f'{r}. [{i}](https://www.tradingview.com/symbols/{df_screen[df_screen["name"]==i]["ticker"].tolist()[0].replace(":","-")}/)'
        md_str += f'(est. {datetime.strftime(df_screen[df_screen["name"]==i]["launch_date"].tolist()[0].date(), "%b %Y")}'
        md_str += f",  Returns: {df_3y_leaderboard.at[i,'Annualized Return']*100:.2f}%)"

        md_str += f' - {row["ETF Desc"]}'
        print(md_str)
    else:
        print(f'{r}. {i}')
    r+=1
print(f':::')
  1. FLRT(est. Feb 2015, Returns: 6.91%) - Pacer Funds Pacer Pacific Asset Floating Rate High Income ETF
  2. IGHG(est. Nov 2013, Returns: 6.87%) - ProShares Investment Grade-Interest Rate Hedged
  3. OCTW(est. Sep 2020, Returns: 7.61%) - AllianzIM U.S. Large Cap Buffer20 Oct ETF
  4. SIXH(est. May 2020, Returns: 8.11%) - ETC 6 Meridian Hedged Equity Index Option ETF
  5. JANW(est. Dec 2020, Returns: 7.64%) - AllianzIM U.S. Large Cap Buffer20 Jan ETF
  6. BGLD(est. Jan 2021, Returns: 14.55%) - FT Vest Gold Strategy Quarterly Buffer ETF
  7. BALT(est. Jun 2021, Returns: 6.12%) - Innovator Defined Wealth Shield ETF
  8. VRIG(est. Sep 2016, Returns: 5.60%) - Invesco Variable Rate Investment Grade ETF
  9. PULS(est. Apr 2018, Returns: 5.31%) - PGIM Ultra Short Bond ETF
  10. IAK(est. Apr 2006, Returns: 13.97%) - iShares U.S. Insurance ETF
  11. BOXX(est. Dec 2022, Returns: 5.06%) - EA Series Trust Alpha Architect 1-3 Month Box ETF
  12. SIXA(est. May 2020, Returns: 10.06%) - ETC 6 Meridian Mega Cap Equity ETF
  13. HEQT(est. Oct 2021, Returns: 8.46%) - Simplify Hedged Equity ETF
  14. BFIX(est. Feb 2022, Returns: 5.78%) - Build Funds Trust Build Bond Innovation ETF
  15. PTNQ(est. Jun 2015, Returns: 10.36%) - Pacer Trendpilot 100 ETF
  16. NEAR(est. Sep 2013, Returns: 5.26%) - iShares Short Duration Bond Active ETF
  17. RISR(est. Sep 2021, Returns: 13.09%) - FolioBeyond Alternative Income and Interest Rate Hedge ETF
  18. GLDI(est. Jan 2013, Returns: 10.88%) - ETRACS Gold Shares Covered Call ETNs due February 2, 2033
  19. GSST(est. Apr 2019, Returns: 4.98%) - Goldman Sachs Ultra Short Bond ETF
  20. BSJP(est. Sep 2017, Returns: 6.26%) - Invesco BulletShares 2025 High Yield Corporate Bond ETF

multi-period leaderboard

our search found a lot of candidates for different time frame!

And so there’s one more way to evaluate performance and that’s to look at multi-period result. Investing might be a game of average and consistency… the fastest sprinter won’t be the fastest Marathon runner and vice versa. But can we find a decently good runner who excel at both distance? Think of this as the search for the hybrid athlete.

remember when Jason Khalipa won BOTH the 2k and half-marathon at the 2013 Crossfit Game’s Row Event4

10-year: 3 periods

Code
rank_data = []
for t in df_10y_leaderboard.index.tolist():
    if t not in ['BOXX']:
        rank_data.append(
            {'ticker': t,
             'rank_10y': df_10y_leaderboard.at[t,"ranking"],
             'rank_5y': df_5y_leaderboard.at[t,"ranking"],
             'rank_3y': df_3y_leaderboard.at[t,"ranking"],
             'ETF Desc': df_10y_leaderboard.at[t, 'ETF Desc'],
             'url': df_10y_leaderboard.at[t, 'url'],
             'launch_date': df_10y_leaderboard.at[t, 'launch_date']
            }
        )
rank_data = pd.DataFrame(rank_data)
rank_data['points'] = rank_data['rank_10y'] + rank_data['rank_5y'] + rank_data['rank_3y']
rank_data['rank'] = rank_data['points'].rank(ascending = True)
rank_data = rank_data.set_index('ticker', drop = True)
rank_data = rank_data.sort_values(by = ['rank'], ascending= True)
for t in bm_tickers:
    if t not in ['BOXX']:
        print(f'{t} is ranked {rank_data.at[t, "rank"]}')
rank_data.drop(columns = ['ETF Desc','url', 'launch_date']).head(20)
SPY is ranked 14.0
BRK-B is ranked 5.0
GLD is ranked 16.0
rank_10y rank_5y rank_3y points rank
ticker
IAK 122.0 3.0 10.0 135.0 1.0
OEF 4.0 73.5 133.0 210.5 2.0
IWL 11.0 64.0 144.0 219.0 3.0
IVV 19.5 68.0 173.0 260.5 4.0
BRK-B 6.0 42.0 215.0 263.0 5.0
SGOL 23.0 196.5 47.5 267.0 6.0
DXJS 207.0 49.0 32.0 288.0 7.0
OUNZ 28.0 214.0 54.0 296.0 8.0
IAU 25.0 220.0 53.0 298.0 9.0
KBWP 65.0 53.0 182.5 300.5 10.0
LGLV 24.0 47.5 233.0 304.5 11.0
IYF 128.0 92.0 86.0 306.0 12.0
IOO 42.0 66.0 214.0 322.0 13.0
SPY 7.0 89.0 232.0 328.0 14.0
IAI 68.0 75.0 186.0 329.0 15.0
GLD 33.5 244.0 56.5 334.0 16.0
QDEF 49.0 112.0 182.5 343.5 17.0
IWB 39.0 96.0 210.0 345.0 18.0
PPA 93.0 110.5 143.0 346.5 19.0
FIDU 116.5 47.5 184.0 348.0 20.0
Figure 8: top 1% of all ETFs looking at 10y, 5y, and 3y period performances
Code
n = 20
r = 1

print('::: {.callout-caution title = "Expand to see Top 20 by Name:" collapse="true"}')
for i, row in rank_data.head(n).iterrows():
    if i in df_screen['name'].tolist():
        md_str = f'{r}. [{i}](https://www.tradingview.com/symbols/{df_screen[df_screen["name"]==i]["ticker"].tolist()[0].replace(":","-")}/)'
        md_str += f'(est. {datetime.strftime(df_screen[df_screen["name"]==i]["launch_date"].tolist()[0].date(), "%b %Y")}'
        md_str += f", Return: {df_10y_leaderboard.at[i,'Annualized Return']*100:.2f}%)"

        md_str += f' - {row["ETF Desc"]}'
        print(md_str)
    else:
        print(f'{r}. {i}')
    r+=1
print(":::")
  1. IAK(est. Apr 2006, Return: 12.28%) - iShares U.S. Insurance ETF
  2. OEF(est. Oct 2000, Return: 12.82%) - iShares S&P 100 ETF
  3. IWL(est. Sep 2009, Return: 12.52%) - iShares Russell Top 200 ETF
  4. IVV(est. May 2000, Return: 11.71%) - iShares Core S&P 500 ETF
  5. BRK-B
  6. SGOL(est. Sep 2009, Return: 9.86%) - abrdn Physical Gold Shares ETF
  7. DXJS(est. Jun 2013, Return: 9.24%) - WisdomTree Japan Hedged SmallCap Equity Fund
  8. OUNZ(est. May 2014, Return: 9.78%) - VanEck Merk Gold ETF
  9. IAU(est. Jan 2005, Return: 9.87%) - iShares Gold Trust Shares of the iShares Gold Trust
  10. KBWP(est. Dec 2010, Return: 12.76%) - Invesco KBW Property & Casualty Insurance ETF
  11. LGLV(est. Feb 2013, Return: 10.97%) - SPDR SSGA US Large Cap Low Volatility Index ETF
  12. IYF(est. May 2000, Return: 10.85%) - iShares U.S. Financial ETF
  13. IOO(est. Dec 2000, Return: 11.02%) - iShares Global 100 ETF
  14. SPY(est. Jan 1993, Return: 11.71%) - SPDR S&P 500 ETF TRUST
  15. IAI(est. Apr 2006, Return: 13.77%) - iShares U.S. Broker-Dealers & Securities Exchanges ETF
  16. GLD(est. Nov 2004, Return: 9.70%) - SPDR Gold Trust
  17. QDEF(est. Dec 2012, Return: 9.33%) - FlexShares Quality Dividend Defensive Index Fund
  18. IWB(est. May 2000, Return: 11.34%) - iShares Russell 1000 ETF
  19. PPA(est. Oct 2005, Return: 13.09%) - Invesco Aerospace & Defense ETF
  20. FIDU(est. Oct 2013, Return: 10.12%) - Fidelity MSCI Industrials Index ETF

5-year: 2 periods

Code
rank_data = []
for t in df_5y_leaderboard.index.tolist():
    if t not in ['BOXX']:
        rank_data.append(
            {'ticker': t,
             'rank_5y': df_5y_leaderboard.at[t,"ranking"],
             'rank_3y': df_3y_leaderboard.at[t,"ranking"],
             'ETF Desc': df_5y_leaderboard.at[t, 'ETF Desc'],
             'url': df_5y_leaderboard.at[t, 'url'],
             'launch_date': df_5y_leaderboard.at[t, 'launch_date']
            }
        )
rank_data = pd.DataFrame(rank_data)
rank_data['points'] = rank_data['rank_5y'] + rank_data['rank_3y']
rank_data['rank'] = rank_data['points'].rank(ascending = True)
rank_data = rank_data.set_index('ticker', drop = True)
rank_data = rank_data.sort_values(by = ['rank'], ascending= True)
for t in bm_tickers:
    if t not in ['BOXX']:
        print(f'{t} is ranked {rank_data.at[t, "rank"]}')
rank_data.drop(columns = ['ETF Desc', 'url', 'launch_date']).head(20)
SPY is ranked 95.0
BRK-B is ranked 63.5
GLD is ranked 89.0
rank_5y rank_3y points rank
ticker
IAK 3.0 10.0 13.0 1.0
CEFS 9.0 38.0 47.0 2.0
PTNQ 33.0 15.0 48.0 3.0
PTLC 20.0 34.0 54.0 4.0
TPYP 2.0 73.0 75.0 5.0
DXJS 49.0 32.0 81.0 6.0
FLRT 82.0 1.0 83.0 7.0
POCT 57.0 26.5 83.5 8.0
LVHI 22.0 65.5 87.5 9.0
VRIG 90.0 8.0 98.0 10.0
USAI 7.0 92.5 99.5 11.0
EMLP 27.0 75.0 102.0 12.0
AUSF 1.0 102.0 103.0 13.0
BSJP 97.0 20.0 117.0 14.0
PULS 113.0 9.0 122.0 15.0
FLJH 30.0 98.0 128.0 16.0
EINC 38.0 104.0 142.0 17.0
DYNF 72.0 72.0 144.0 18.0
DXJ 19.0 134.0 153.0 19.0
GSST 138.0 19.0 157.0 20.0
Figure 9: top 1% of all ETFs looking at both 5y and 3y period performances
Code
n = 20
r = 1
print('::: {.callout-caution title = "Expand to see Top 20 by Name:" collapse="true"}')
for i, row in rank_data.head(n).iterrows():
    if i in df_screen['name'].tolist():
        md_str = f'{r}. [{i}](https://www.tradingview.com/symbols/{df_screen[df_screen["name"]==i]["ticker"].tolist()[0].replace(":","-")}/)'
        md_str += f'(est. {datetime.strftime(df_screen[df_screen["name"]==i]["launch_date"].tolist()[0].date(), "%b %Y")}'
        md_str += f", Return: {df_5y_leaderboard.at[i,'Annualized Return']*100:.2f}%)"

        md_str += f' - {row["ETF Desc"]}'
        print(md_str)
    else:
        print(f'{r}. {i}')
    r+=1
print(":::")
  1. IAK(est. Apr 2006, Return: 22.01%) - iShares U.S. Insurance ETF
  2. CEFS(est. Mar 2017, Return: 15.65%) - Saba Closed-End Funds ETF
  3. PTNQ(est. Jun 2015, Return: 14.36%) - Pacer Trendpilot 100 ETF
  4. PTLC(est. Jun 2015, Return: 13.51%) - Pacer Fund Trust Pacer Trendpilot US Large Cap ETF
  5. TPYP(est. Jun 2015, Return: 23.37%) - Tortoise North American Pipeline ETF
  6. DXJS(est. Jun 2013, Return: 17.73%) - WisdomTree Japan Hedged SmallCap Equity Fund
  7. FLRT(est. Feb 2015, Return: 6.75%) - Pacer Funds Pacer Pacific Asset Floating Rate High Income ETF
  8. POCT(est. Sep 2018, Return: 9.80%) - Innovator U.S. Equity Power Buffer ETF - October
  9. LVHI(est. Jul 2016, Return: 13.36%) - Franklin International Low Volatility High Dividend Index ETF
  10. VRIG(est. Sep 2016, Return: 5.09%) - Invesco Variable Rate Investment Grade ETF
  11. USAI(est. Dec 2017, Return: 32.35%) - Pacer American Energy Independence ETF
  12. EMLP(est. Jun 2012, Return: 16.80%) - First Trust North American Energy Infrastructure Fund
  13. AUSF(est. Aug 2018, Return: 19.34%) - Global X Funds Global X Adaptive U.S. Factor ETF
  14. BSJP(est. Sep 2017, Return: 6.97%) - Invesco BulletShares 2025 High Yield Corporate Bond ETF
  15. PULS(est. Apr 2018, Return: 3.78%) - PGIM Ultra Short Bond ETF
  16. FLJH(est. Nov 2017, Return: 16.45%) - Franklin FTSE Japan Hedged ETF
  17. EINC(est. Mar 2012, Return: 29.01%) - VanEck Energy Income ETF
  18. DYNF(est. Mar 2019, Return: 16.91%) - iShares U.S. Equity Factor Rotation Active ETF
  19. DXJ(est. Jun 2006, Return: 22.10%) - WisdomTree Japan Hedged Equity Fund
  20. GSST(est. Apr 2019, Return: 3.49%) - Goldman Sachs Ultra Short Bond ETF

Conclusion

This is a lot of data to look at and we found a lot of interseting ETFs along the way. We’ll do a deep dive on a different post but for now, some key observations are:

Beating the market

beating the market and doing so with less downside risk is possible! It’s definitely harder to do it in the long run and consistently when looking at multiple periods.

Mr Buffett’s advice for the common investors

One such funds that have done that is Mr Buffett’s BRK-B, and as this leaderboard revealed, there are >10 investments over the last 10-year (BRK-B included) that have “beaten” the market. So the index funds that’s most likely “Warren approved” would be IAK, OEF, IWL, and IVV.

Innovation or Luck

Buying NVDA in the early 2010s to become a millionaire like this guy is luck. Making >300% return in a year like this other guy5 is skill. Putting that skill to work in creating an investment product is innovation.

Looking at Figure 9, you’d see a lot of these innovation that have came out in the recent years6. Tickers like PTNQ, PTLC, POCT, LVHI, AUSF, and DYNF all follow some pretty complex strategies from using FLEX options, incorporating technical factors, to factor rotation.

Looking at the top-right quadrant of Figure 7 we can find a few more interesting ones:

  1. RISR7 stands out in Figure 7 because it bet the SPY with 13% annualized returns while only seeing a Max Drawdown of 15%. But it was able to do that because it’s a hedge for rising interest rates, which was the trend for the last 3 years. So the outperformance vs SPY is somewhat “luck”, the “innovation” part is that it’s an actively managed fund that primarily invests in interest-only MBSs and US Treasury (definitely not something that’s accessible to the retail investors). It’s unlikely thatRISR will continue to out perform the market but it has proven it’s own marketing as a rising interest rate hedge.

  2. BGLD is invested in Gold (like GLD) but with downside protection using FLEX options. Over the 3-year period, it achieved a GPR of 1.26 vs GLD’s 1.21

  3. BALT is like BGLD but reference the S&P 500. The result is impressive, GPR of 1.36 vs SPY’s 1.10

Code
df_3y_leaderboard[df_3y_leaderboard.index.isin(['SPY', 'RISR','BGLD','BALT','GLD', 
                                                'PTNQ', 'PTLC', 'POCT', 'LVHI', 'AUSF', 'DYNF'
                                               ])].drop(
    columns = ['url', 'ETF Desc','bar_count', 'launch_date']
)
Annualized Return Sortino Ratio Max Drawdown Gain-to-Pain Ratio ranking 10y_club_ranking 5y_club_ranking
ticker
BGLD 0.145539 1.456350 -0.146925 1.261416 6.0 NaN NaN
BALT 0.061212 0.900420 -0.048918 1.365610 7.0 NaN NaN
PTNQ 0.103609 0.868129 -0.125163 1.166248 15.0 NaN 33.0
RISR 0.130929 1.100260 -0.149944 1.196075 16.5 NaN NaN
POCT 0.078019 0.659162 -0.102248 1.194244 26.5 NaN 57.0
PTLC 0.098586 0.762654 -0.131485 1.177505 34.0 NaN 20.0
GLD 0.171051 1.355718 -0.180991 1.212034 56.5 33.5 244.0
LVHI 0.097358 0.585607 -0.120745 1.137791 65.5 NaN 22.0
DYNF 0.123378 0.867801 -0.175672 1.150942 72.0 NaN 72.0
AUSF 0.109668 0.537518 -0.146535 1.113406 102.0 NaN 1.0
SPY 0.077709 0.494573 -0.187975 1.103419 232.0 7.0 89.0
Figure 10: innovative ETFs

Thinking Correlations

the innovations in the ETF space also got me thinking about ways to potentially just capture the relative performance8 without the underlying risk.

For example, it’s well known that Cathie Wood’s ARKK consistently underperforms. So how would I capitalize on that without being exposed to swings in the tech sector?

Perhaps by buying a fairly consistent tech ETF from Figure 11, like IYW, and staying “market neutral” by shorting ARKK?

Or similarly, buying SMH and shorting XSD in the semi-conductor space.

Code
df_3y_leaderboard[df_3y_leaderboard.index.isin(['QQQ', 'MAGS', 'IYW', 'ARKK', 'KOMP', 'XT', 'BTEK'])].drop(
    columns = ['url', 'launch_date', 'bar_count']
)
Annualized Return Sortino Ratio Max Drawdown Gain-to-Pain Ratio ranking 10y_club_ranking 5y_club_ranking ETF Desc
ticker
IYW 0.122713 0.645414 -0.251664 1.105641 311.0 44.0 313.0 iShares U.S. Technology ETF
QQQ 0.103636 0.364888 -0.269366 1.073687 566.0 33.5 454.0 Invesco QQQ Trust, Series 1
XT -0.016842 -0.069011 -0.220937 1.019013 1272.0 143.5 960.0 iShares Exponential Technologies ETF
KOMP -0.031018 -0.273069 -0.266685 0.991590 1465.0 NaN 1050.0 SPDR S&P Kensho New Economies Composite ETF
ARKK -0.091826 -0.050621 -0.519844 1.006534 1480.0 401.5 1278.0 ARK Innovation ETF
Figure 11: Technology Theme ETFs
Code
df_3y_leaderboard[df_3y_leaderboard.index.isin(['SMH','SMHX','SOXX','SOXQ','FTXL', 'XSD'])].drop(
    columns = ['url', 'launch_date','bar_count']
)
Annualized Return Sortino Ratio Max Drawdown Gain-to-Pain Ratio ranking 10y_club_ranking 5y_club_ranking ETF Desc
ticker
SMH 0.186132 0.740643 -0.376935 1.109409 377.5 96.5 338.5 VanEck Semiconductor ETF
SOXQ 0.097283 0.411520 -0.429791 1.067058 681.0 NaN NaN Invesco PHLX Semiconductor ETF
SOXX 0.075000 0.444975 -0.401324 1.071815 687.0 105.0 545.0 iShares Semiconductor ETF
FTXL 0.036124 0.078999 -0.436925 1.026629 1172.5 NaN 882.0 First Trust Nasdaq Semiconductor ETF
XSD -0.006894 0.035301 -0.447812 1.019827 1383.0 169.0 822.5 SPDR S&P Semiconductor ETF
Figure 12: Semi-conductor Theme ETFs

Some ETFs are Purpose Built

if you look close at the bottom left quadrant of Figure 3, Figure 5, and Figure 7, you will consistently see VIXY which is the VIX short-term futures ETF. It’s definitely NOT meant as an investment product but more of a hedge for black swan events9.

But as an investment, it looks like it has consistently capture the negative roll yield of the VIX futures market, which is beyond the scope of this post10. But this is an example to show that while there are more innovative ETFs, it’s always worth looking at some data to see if they confirm what the strategies claim to do.

Code
df_3y_leaderboard[df_3y_leaderboard.index.isin(['SPY', 'VIXY', 'VXX'])].drop(
    columns = ['url', 'ETF Desc','bar_count','launch_date']
)
Annualized Return Sortino Ratio Max Drawdown Gain-to-Pain Ratio ranking 10y_club_ranking 5y_club_ranking
ticker
SPY 0.077709 0.494573 -0.187975 1.103419 232.0 7.0 89.0
VIXY -0.392153 -0.622708 -0.907848 0.940554 1610.0 832.0 1329.0
VXX -0.426725 -0.851582 -0.909318 0.915044 1625.0 NaN 1330.0
Figure 13: Volatility Futures ETFs are meant more for hedging than for buy-and-hold investing

Dividends

one other consideration for the non-Americans is withholding tax on dividend. Our analysis here is focused on capital gains only. But just as a disclaimer, here’s the number of ETFs paying dividends on a regular basis.

Code
div_summary = [
    {"label": "dividend paying", 'count': sum(df_3y_metrics['div_count']>0)},
    {"label": "no dividend", 'count': sum(df_3y_metrics['div_count']==0)},
]
pie_div = px.pie(pd.DataFrame(div_summary), values = "count", names = 'label', 
                title = "Dividend Paying vs Non-Paying ETFs")
display(pie_div)
Figure 14: Percentage of ETFs paying dividends in the 3-year Club

Footnotes

  1. GPR of 1.15 vs 1.17 for SPY↩︎

  2. risk-free rate for the last 3, 5, and 10 years is backed by research ;)↩︎

  3. risk-free rate for the last 3, 5, and 10 years is backed by research ;)↩︎

  4. relive the moment here↩︎

  5. JLaw is actually a student of Mark Minervini↩︎

  6. just contrast these with the list at Figure 8↩︎

  7. launched in Sept 2021, all the details for the fund is available here↩︎

  8. like pairs trading with a twist↩︎

  9. actually this article makes the case for when this might not even be a good hedge at all during periods of high market volatility but some (like this guy) might still recommend it.↩︎

  10. this and this post goes into details of how these VIX futures ETFs need to roll futures contracts on a regular basis and how value has consistently be lost to negative roll yield since the VIX futures curve is in contango 82% of the time (since VXX’s inception)↩︎