From Theory to Code: Automating the “Lazy” Strategy (part I)
This article serves as a technical follow-up to the introductory guide on the Global Equity Momentum (GEM) strategy. While the first part…
From Theory to Code: Automating the “Lazy” Strategy (part I)
This article serves as a technical follow-up to the introductory guide on the Global Equity Momentum (GEM) strategy. While the first part explored the “why” and the historical performance of Gary Antonacci’s approach, this guide focuses on the “how” — specifically, how to implement a Minimum Viable Product (MVP) of the GEM strategy using **Python and the [yfinance](https://pypi.org/project/yfinance/)** library.

A Quick Note: The thoughts and opinions shared in this article are purely for educational purposes and should not be taken as investment advice. Investing carries risk, so always do your own homework (and maybe talk to a certified financial advisor) before putting your money into anything!
The goal is to build a tool that performs the analytical heavy lifting for you. Instead of manually checking prices every month, we can write a script that tells us exactly where to allocate capital based on the latest market data. To speed up development, this implementation leverages modern AI assistance (specifically Claude) to structure the boilerplate code.
1. The Project Architecture
To keep the code clean and maintainable, the project is organized into a modular structure:
- config.py: Stores the ETF universe and strategy parameters.
- data/fetcher.py: Handles downloading and cleaning data via
yfinance. - strategy/gem.py: The “brain” of the operation containing the GEM signal algorithm.
- main.py: The entry point that executes the script and provides a user-friendly CLI output.
2. Defining the “Universe”
In config.py, we define the specific tickers that represent the different asset classes required by the GEM algorithm:
# EQUITY_US → S&P 500 proxy
# EQUITY_INTL → MSCI ACWI ex-US proxy (all non-US developed + emerging)
# SAFE_HAVEN → US Aggregate Bond Index proxy (defensive hold)
# RISK_FREE → 3-Month T-Bill proxy (absolute-momentum benchmark)
EQUITY_US = {
"ticker": "SPY",
"name": "SPDR S&P 500 ETF",
}
EQUITY_INTL = {
"ticker": "ACWX",
"name": "iShares MSCI ACWI ex-US ETF",
}
SAFE_HAVEN = {
"ticker": "AGG",
"name": "iShares Core U.S. Aggregate Bond ETF",
}
RISK_FREE = {
"ticker": "BIL",
"name": "SPDR Bloomberg 1-3 Month T-Bill ETF",
}
- US Equity (SPY): A proxy for the S&P 500.
- International Equity (ACWX): Covers non-US developed and emerging markets.
- Safe Haven (AGG): US Aggregate Bond Index for defensive holdings.
- Risk-Free Rate (BIL): 1–3 Month T-Bills, used as the benchmark for absolute momentum.
3. Reliability in Data Fetching
The fetcher.py module uses the yfinance API to retrieve adjusted-close prices. One critical insight when building this is handling "real-world" data issues: stock markets are closed on weekends and holidays, and APIs occasionally return gaps or missing days. The implementation uses forward-filling (ffill) to bridge these small gaps, ensuring the strategy always has a continuous price history to analyze.
def fetch_prices(tickers: List[str]) -> pd.DataFrame:
"""
Download adjusted-close prices for *tickers*.
Returns
-------
pd.DataFrame
Columns = tickers, index = pd.DatetimeIndex (ascending),
NaN-forward-filled then remaining NaNs dropped.
"""
end = datetime.date.today()
start = end - datetime.timedelta(days=_required_calendar_days())
raw = yf.download(
tickers=tickers,
start=str(start),
end=str(end),
auto_adjust=True,
progress=False,
threads=True,
)
# yfinance returns MultiIndex columns when >1 ticker
if isinstance(raw.columns, pd.MultiIndex):
prices = raw["Close"]
else:
# single ticker edge-case
prices = raw[["Close"]]
prices.columns = tickers
# Ensure all requested tickers are present
missing = set(tickers) - set(prices.columns)
if missing:
raise ValueError(f"Could not download data for: {missing}")
# Forward-fill small gaps (weekends already absent, but some ETFs have
# occasional missing days); then drop rows where any price is still NaN
prices = prices.ffill().dropna()
prices.index = pd.to_datetime(prices.index)
prices.sort_index(inplace=True)
return prices
4. The Core Logic: Calculating Momentum
The strategy hinges on 12-month momentum. However, the code implements a “skip-1-month” rule (calculating momentum over the last 12 months but excluding the most recent month). This is a common academic practice designed to avoid short-term reversals, where an asset that spiked in the last few weeks might be due for a temporary pullback.
The compute_signal function follows the 3-step GEM algorithm:
def _momentum(prices: pd.DataFrame, ticker: str) -> MomentumScore:
"""
12-month (skip-1-month) momentum for a single ticker.
We look back LOOKBACK_DAYS trading days, but skip the most recent
SKIP_DAYS to avoid short-term reversal (Jegadeesh & Titman, 1993).
Returns price 'then' (= LOOKBACK_DAYS ago) and price 'now'
(= SKIP_DAYS ago, i.e. ~1 month before today).
"""
col = prices[ticker]
if len(col) < LOOKBACK_DAYS + SKIP_DAYS:
raise ValueError(
f"Not enough price history for {ticker}. "
f"Need {LOOKBACK_DAYS + SKIP_DAYS} rows, got {len(col)}."
)
price_now = col.iloc[-(SKIP_DAYS + 1)] # ~1 month ago (skip-1)
price_then = col.iloc[-(LOOKBACK_DAYS + SKIP_DAYS + 1)] # ~13 months ago
momentum_pct = (price_now / price_then - 1) * 100
# Map ticker → name from config
name_map = {
EQUITY_US["ticker"]: EQUITY_US["name"],
EQUITY_INTL["ticker"]: EQUITY_INTL["name"],
SAFE_HAVEN["ticker"]: SAFE_HAVEN["name"],
RISK_FREE["ticker"]: RISK_FREE["name"],
}
return MomentumScore(
ticker = ticker,
name = name_map.get(ticker, ticker),
price_now = round(price_now, 2),
price_then = round(price_then, 2),
momentum_pct = round(momentum_pct, 2),
)
def compute_signal(prices: pd.DataFrame) -> GEMSignal:
"""
Run the full GEM algorithm on a price DataFrame and return a GEMSignal.
"""
us_ticker = EQUITY_US["ticker"]
intl_ticker = EQUITY_INTL["ticker"]
rf_ticker = RISK_FREE["ticker"]
sh_ticker = SAFE_HAVEN["ticker"]
# ── Step 1: Compute momentum for all candidates ───────────────────────────
scores: Dict[str, MomentumScore] = {}
for ticker in [us_ticker, intl_ticker, rf_ticker]:
scores[ticker] = _momentum(prices, ticker)
# If safe-haven ≠ risk-free, score it too (in this config they're both BIL)
if sh_ticker not in scores:
scores[sh_ticker] = _momentum(prices, sh_ticker)
as_of_date = prices.index[-1].strftime("%Y-%m-%d")
# ── Step 2: Relative momentum – US vs International ───────────────────────
us_mom = scores[us_ticker].momentum_pct
intl_mom = scores[intl_ticker].momentum_pct
if us_mom >= intl_mom:
equity_winner = us_ticker
equity_winner_name = EQUITY_US["name"]
else:
equity_winner = intl_ticker
equity_winner_name = EQUITY_INTL["name"]
# ── Step 3: Absolute momentum – equity winner vs risk-free ────────────────
equity_mom = scores[equity_winner].momentum_pct
rf_mom = scores[rf_ticker].momentum_pct
beat_rf = equity_mom > rf_mom
if beat_rf:
hold_ticker = equity_winner
hold_name = equity_winner_name
else:
hold_ticker = sh_ticker
hold_name = SAFE_HAVEN["name"]
return GEMSignal(
hold_ticker = hold_ticker,
hold_name = hold_name,
scores = scores,
equity_winner = equity_winner,
beat_risk_free = beat_rf,
as_of_date = as_of_date,
)
- Relative Momentum: Compare the 12-month returns of US Stocks vs. International Stocks.
- Absolute Momentum: Take the winner of Step 1 and compare its return against the “risk-free” T-Bill return.
- The Decision: If the winning equity beats T-Bills, buy that equity. If not, retreat to the safety of bonds (AGG).
The Result: A “One-Click” Investment Signal
When the script is executed, it provides a clear, formatted summary of the current market regime. Below is an example of what the output looks like:
$ python main.py
Fetching price data from Yahoo Finance… done
GEM STRATEGY SIGNAL
Global Equity Momentum · Antonacci (2014)
──────────────────────────────────────────────────────────────
ETF Then Now 12M Mom
────────────────────────────── ──────── ──────── ─────────
US Equity (SPY) $ 527.89 $ 699.94 +32.59%
Intl Equity (ACWX) $ 51.94 $ 73.49 +41.49% ◀ relative winner
Risk-Free (BIL) $ 87.75 $ 91.24 +3.97%
Safe Haven (AGG) $ 92.99 $ 99.29 +6.77%
──────────────────────────────────────────────────────────────
Step 1 – Relative momentum
Equity winner: iShares MSCI ACWI ex-US ETF
Step 2 – Absolute momentum
✓ Equity beats risk-free → stay in equities
──────────────────────────────────────────────────────────────
CURRENT SIGNAL
HOLD → ACWX iShares MSCI ACWI ex-US ETF
As of: 2026-05-14
──────────────────────────────────────────────────────────────
The rest of the code you can find on my repo.
Final Thoughts & Next Steps
This Python tool simplifies the GEM strategy, making it an ideal companion for long-term investors or those managing retirement accounts who only want to check their portfolio once a month.
While this MVP is a powerful start, I wouldn’t put a real money based on this tool :). Of course, there is always room to grow. Future iterations of this project will aim to include:
- Monthly Backtesting: To see how different ticker combinations would have performed historically.
- Web Interface: Moving the tool from the command line to a React-based UI.
- Enhanced Universe: Testing different instruments to see if they can generate a higher CAGR than the standard GEM tickers.
By automating the “lazy” part of the strategy, you ensure that discipline — the hardest part of momentum investing — is built directly into your workflow.
메타데이터
- post_id
- 4e2717507237
- slug
- from-theory-to-code-automating-the-lazy-strategy-part-i-4e2717507237
- url
- https://medium.com/@pawelgedlek/from-theory-to-code-automating-the-lazy-strategy-part-i-4e2717507237
- canonical_url
- https://medium.com/@pawelgedlek/from-theory-to-code-automating-the-lazy-strategy-part-i-4e2717507237
- author_url
- https://medium.com/@pawelgedlek
- status
- ok
- fetched_at
- 2026-06-11 11:25:07