Fund flows: A powerful strategy that (actually) profits

Fund flows: A powerful strategy that (actually) profits
After having taught 2,000+ people algorithmic trading with Python, I can safely say there is one misconception that dominations all the misconceptions:
Profitable strategies need to be complicated.
For non-professional traders, we can exploit “messy” edges for profit.
In today’s newsletter, you get code for one such strategy. It involves buying and selling two ETFs at particular times of the month. That’s it.
Let’s go!
Fund flows: A powerful strategy that (actually) profits
The TLT/SPY long-short swap is a systematic ETF strategy that exploits predictable month-end portfolio rebalancing flows between equities and Treasury bonds. It’s built around going long TLT, short SPY at the end of each month and unwinding at the start of the new month to capture mean reversion from institutional “window dressing.”
This calendar-based anomaly traces back to decades-old research on predictable fund manager behaviors.
Calendar effects like window dressing were first highlighted in academic studies of the 1980s and 1990s, showing how institutional reporting cycles impacted asset flows. Researchers including Jeremy Siegel and groups like AQR formalized these effects in published backtests, linking them to practical trading signals. Since then, practitioners have used these anomalies for market-neutral, rules-based trades seeking consistent small edges, not big directional bets.
Today, systematic traders exploit the window dressing effect using automated ETF swaps that rotate long-short exposure in line with the calendar.
Let's see how it works with Python.
Imports and setup
We use yFinance to download historical price data, pandas for working with tables of data, numpy for numerical operations, and vectorbt for building and testing the strategy.
1import yfinance as yf
2import pandas as pd
3import numpy as np
4import vectorbt as vbt
5
6start_date = "2010-01-01"
7end_date = pd.Timestamp.today().strftime("%Y-%m-%d")
8symbols = ["TLT", "SPY"]
With these imports and settings, we have the tools to download price data for TLT (long-term US Treasuries) and SPY (S&P 500) and work with it in the cells that follow.
Download and organize price data
This cell downloads daily closing price data for TLT and SPY using yFinance and ensures all missing data is removed.
1price_data = yf.download(
2 symbols,
3 start=start_date,
4 end=end_date,
5 auto_adjust=False
6)["Close"]
7price_data = price_data.dropna()
Here, we use yFinance to fetch historical closing prices for both tickers over our chosen period. The code organizes this data into a table and then filters out any dates that are missing information for either TLT or SPY.
Then, the code pulls out the trading dates and arranges them on a monthly calendar. It then figures out which day is the first and last trading date of each month.
1dates = price_data.index
2year_month = price_data.index.to_period('M')
3grouped = pd.DataFrame({"dt": dates, "ym": year_month}).groupby("ym")["dt"]
4first_trading_days = grouped.first().values
5last_trading_days = grouped.last().values
This organization helps us mark important points in each month that we can use to define trade entry and exit points later on.
Create trading signal events
This cell defines two functions that help us find trading dates before or after certain monthly milestones.
1def get_prev_trading_day_idx(trading_dates, base_dates, offset):
2 idx = []
3 trading_dates = pd.Series(trading_dates)
4 for d in base_dates:
5 pos = trading_dates.searchsorted(d)
6 prev_idx = pos - offset
7 if prev_idx >= 0:
8 idx.append(trading_dates.iloc[prev_idx])
9 return pd.DatetimeIndex(idx)
10
11def get_offset_trading_day_idx(trading_dates, base_dates, offset):
12 idx = []
13 trading_dates = pd.Series(trading_dates)
14 for d in base_dates:
15 pos = trading_dates.searchsorted(d)
16 target_idx = pos + offset
17 if target_idx < len(trading_dates):
18 idx.append(trading_dates.iloc[target_idx])
19 return pd.DatetimeIndex(idx)
These two small tools let us find trading days just before or after specific dates. The first one moves backward from a list of monthly dates, returning dates a set number of days before. The second one moves forward by a set offset.
This cell calculates special dates like seven days before the end of each month, the first trading day of each new month, and one week after that.
1pre_end_idx = get_prev_trading_day_idx(dates, last_trading_days, 7)
2month_start_idx = get_offset_trading_day_idx(dates, last_trading_days, 1)
3week_after_start_idx = get_offset_trading_day_idx(dates, month_start_idx, 7)
By picking out dates just before month-end, right at the start of the month, and a week into the following month, we set up a schedule for trading decisions. These timing markers get used to assign buy and sell moments for both TLT and SPY, setting the stage for later trades.
This cell sets up blank templates with all dates for tracking trade entry and exit signals for both long and short positions on each asset.
1entries_long_tlt = pd.Series(False, index=dates)
2entries_short_spy = pd.Series(False, index=dates)
3exits_long_tlt = pd.Series(False, index=dates)
4exits_short_spy = pd.Series(False, index=dates)
5
6entries_short_tlt = pd.Series(False, index=dates)
7entries_long_spy = pd.Series(False, index=dates)
8exits_short_tlt = pd.Series(False, index=dates)
9exits_long_spy = pd.Series(False, index=dates)
This cell fills in our trading signal templates: opening long and short trades at the planned times.
1entries_long_tlt.loc[pre_end_idx] = True
2entries_short_tlt.loc[month_start_idx] = True
3
4entries_long_spy.loc[pre_end_idx] = True
5entries_short_spy.loc[month_start_idx] = True
This cell fills in our templates for when we will close out long and short trades.
1exits_long_tlt.loc[month_start_idx] = True
2exits_short_tlt.loc[week_after_start_idx] = True
3
4exits_long_spy.loc[month_start_idx] = True
5exits_short_spy.loc[week_after_start_idx] = True
This cell organizes all trading signals into a clear table, making further analysis more straightforward.
1signals = pd.DataFrame(
2 index=dates,
3 columns=pd.MultiIndex.from_product([symbols, ['long_entry', 'long_exit', 'short_entry', 'short_exit']]),
4 data=False
5)
6
7signals[("TLT", "long_entry")] = entries_long_tlt
8signals[("TLT", "long_exit")] = exits_long_tlt
9signals[("TLT", "short_entry")] = entries_short_tlt
10signals[("TLT", "short_exit")] = exits_short_tlt
11signals[("SPY", "long_entry")] = entries_long_spy
12signals[("SPY", "long_exit")] = exits_long_spy
13signals[("SPY", "short_entry")] = entries_short_spy
14signals[("SPY", "short_exit")] = exits_short_spy
15
16long_entry = pd.DataFrame({symbol: signals[(symbol, "long_entry")] for symbol in symbols})
17long_exit = pd.DataFrame({symbol: signals[(symbol, "long_exit")] for symbol in symbols})
18short_entry = pd.DataFrame({symbol: signals[(symbol, "short_entry")] for symbol in symbols})
19short_exit = pd.DataFrame({symbol: signals[(symbol, "short_exit")] for symbol in symbols})
All our trading signals are now collected into a well-labeled DataFrame, with separate columns for entries and exits on both tickers and both long and short directions. This is the input that vectorbt expects.
Test the trading strategy
This cell runs our strategy through the vectorbt engine to simulate performance, then summarizes key portfolio statistics.
1pf = vbt.Portfolio.from_signals(
2 price_data,
3 entries=long_entry,
4 exits=long_exit,
5 short_entries=short_entry,
6 short_exits=short_exit,
7 freq='D',
8 size_type=1,
9 size=np.inf,
10 init_cash=100_000,
11)
12
13pf.stats()
The code hands the historical price series and our trading signals to vectorbt, which runs a backtest over the whole period. It sets the initial cash to $100,000 and assumes trades happen in unlimited size to focus on signal logic. The strategy summary at the end delivers a breakdown of performance including return, risk, and other useful metrics, letting us judge how well this trading approach worked.
Your next steps
You’ve built a backtest for monthly-timed long and short trades on TLT and SPY. While the strategy performs well, it does not perform as well as a simple buy and hold. Can you improve it to beat the benchmark?
Try adjusting the “pre_end_idx” or “month_start_idx” values to shift your entry and exit timing. Swap in different ticker symbols in the "symbols" list to see how other assets perform with this structure.
