Seasonality Strategy
Implement a calendar-based seasonality trading strategy that systematically exploits well-documented historical return patterns in cryptocurrency markets by day of week, week of month, month of year, and around recurring known market events and expiry cycles.
Calendar Seasonality Trading Strategy — Macro & Cross-Asset
Category: Macro & Cross-Asset | Subcategory: Strategies
What This Notebook Does
Many financial markets exhibit calendar seasonality — systematic tendencies to rise or fall during specific months, days of the week, or time periods. Crypto is no exception. "Sell in May and go away", the "January Effect", "Uptober", and "Crypto winter in Q4" are widely discussed patterns.
This notebook:
- Fetches BTC and ETH daily prices and computes returns at multiple time frames
- Analyzes monthly seasonality: average return and win rate by calendar month
- Analyzes day-of-week seasonality: average return and win rate by weekday
- Tests statistical significance of each seasonal pattern
- Builds a seasonal trading calendar that shifts BTC exposure based on historical month strength
- Backtests the seasonal strategy vs buy-and-hold
- Overlays seasonality with halving cycle phase to test interaction effects
- Exports the seasonal signal calendar
Known Crypto Seasonal Patterns
| Period | Known as | Historical Tendency |
|---|---|---|
| January | 'Uptober' missed | Often positive — fresh allocation, tax-loss selling reversal |
| April | Q2 start | Often strong |
| May-June | 'Sell in May' | Mixed to negative — liquidity often drops |
| August | Summer doldrums | Low volatility, range-bound |
| October | 'Uptober' | Historically strong across multiple cycles |
| November-December | End-of-year rally | Often strong if bull market, capitulation if bear |
Critical caveat: With only ~10 years of BTC data (and 6–7 full calendar years of liquid market), seasonal statistics are based on very small samples (N ≈ 6–10 per month). Treat these patterns as weak priors, not reliable trading edges.
!pip install yfinance pandas numpy matplotlib seaborn scipy --quietimport yfinance as yf
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from scipy import stats
import calendar
import warnings
warnings.filterwarnings('ignore')
%matplotlib inline
plt.rcParams['figure.figsize'] = (14, 6)
plt.rcParams['axes.spines.top'] = False
plt.rcParams['axes.spines.right'] = False
sns.set_palette('husl')
print('Imports ready.')Imports ready.
Section 2 — Configuration
This section defines the configuration parameters for the notebook, such as start date, tickers, and months identified as historically strong or weak.
START_DATE = '2015-01-01'
TICKERS = {'BTC-USD': 'btc', 'ETH-USD': 'eth'}
USE_SYNTHETIC = False
# Months with historically positive returns → full 100% position
# Months with historically negative returns → 50% position
# Based on backtested data — re-compute from Section 4 results in production
STRONG_MONTHS = [1, 4, 10, 11] # Jan, Apr, Oct, Nov
WEAK_MONTHS = [6, 9] # Jun, SepSection 3 — Data Acquisition
This section handles the data acquisition. It includes functions to fetch real-world crypto prices from Yahoo Finance or generate synthetic data if real data fetching fails or is not desired.
def fetch_crypto_prices(tickers: dict, start: str) -> pd.DataFrame:
"""
Fetch daily close prices for multiple crypto assets.
Parameters
----------
tickers : dict
Mapping of Yahoo Finance symbol to friendly column name.
start : str
Start date in 'YYYY-MM-DD' format.
Returns
-------
pd.DataFrame
Daily close prices with friendly column names.
"""
raw = yf.download(list(tickers.keys()), start=start, progress=False, auto_adjust=True)
prices = raw['Close'].rename(columns=tickers)
prices.index = pd.to_datetime(prices.index)
prices = prices.ffill().dropna()
print(f'Fetched {len(prices)} days for {list(prices.columns)}')
return prices
def generate_synthetic_crypto(start: str, n_days: int = 3500) -> pd.DataFrame:
"""
Generate synthetic BTC and ETH prices with embedded seasonality.
Parameters
----------
start : str
Start date.
n_days : int
Number of calendar days.
Returns
-------
pd.DataFrame
Synthetic btc and eth daily prices.
"""
np.random.seed(7)
dates = pd.date_range(start, periods=n_days, freq='D')
months = dates.month
# Monthly drift adjustments (seasonal component)
month_drift = {1: 0.003, 2: 0.001, 3: 0.002, 4: 0.003,
5: -0.001, 6: -0.002, 7: 0.000, 8: -0.001,
9: -0.002, 10: 0.004, 11: 0.002, 12: 0.001}
btc_rets = np.array([month_drift.get(m, 0) + 0.025 * np.random.randn() for m in months])
eth_rets = btc_rets * 1.15 + 0.008 * np.random.randn(n_days)
return pd.DataFrame({
'btc': 5000 * np.exp(np.cumsum(btc_rets)),
'eth': 150 * np.exp(np.cumsum(eth_rets)),
}, index=dates)
if USE_SYNTHETIC:
prices = generate_synthetic_crypto(START_DATE)
print('Using synthetic data.')
else:
try:
prices = fetch_crypto_prices(TICKERS, START_DATE)
except Exception as e:
print(f'Live fetch failed ({e}). Using synthetic.')
prices = generate_synthetic_crypto(START_DATE)
print(prices.tail(3))Fetched 3138 days for ['btc', 'eth'] Ticker btc eth Date 2026-06-10 61449.289062 1620.137695 2026-06-11 63561.054688 1672.280640 2026-06-12 62966.718750 1657.819946
Section 4 — Seasonality Analysis
This section performs the core seasonality analysis. It computes monthly and day-of-week return statistics, including mean return, win rate, and statistical significance, for the specified crypto asset.
def compute_monthly_seasonality(
prices: pd.DataFrame,
asset: str = 'btc'
) -> pd.DataFrame:
"""
Compute monthly return statistics for a given asset.
Parameters
----------
prices : pd.DataFrame
Daily close prices.
asset : str
Column name to analyze.
Returns
-------
pd.DataFrame
Indexed by month (1-12), with columns:
mean_return, median_return, win_rate, n_years, t_stat, p_value, significant.
Notes
-----
Monthly returns are computed using the last price of each month.
With only ~8 years of data, even 'statistically significant' patterns
may have only N=8 observations — extremely low statistical power.
Always combine seasonal signals with other indicators before trading.
"""
monthly = prices[asset].resample('ME').last().pct_change().dropna() * 100
monthly.index = monthly.index.to_period('M')
results = []
for month_num in range(1, 13):
month_returns = monthly[monthly.index.month == month_num]
n = len(month_returns)
if n >= 3:
t_stat, p_val = stats.ttest_1samp(month_returns, 0)
results.append({
'month': month_num,
'month_name': calendar.month_abbr[month_num],
'mean_return': round(month_returns.mean(), 2),
'median_return': round(month_returns.median(), 2),
'std_return': round(month_returns.std(), 2),
'win_rate': round((month_returns > 0).mean() * 100, 1),
'n_years': n,
't_stat': round(t_stat, 3),
'p_value': round(p_val, 3),
'significant': 'Yes' if p_val < 0.1 else 'No'
})
return pd.DataFrame(results)
def compute_weekday_seasonality(
prices: pd.DataFrame,
asset: str = 'btc'
) -> pd.DataFrame:
"""
Compute day-of-week return statistics.
Parameters
----------
prices : pd.DataFrame
Daily close prices.
asset : str
Column name to analyze.
Returns
-------
pd.DataFrame
Mean return, win rate, and N per weekday.
"""
daily_rets = prices[asset].pct_change().dropna() * 100
day_names = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
results = []
for day_num in range(7):
day_rets = daily_rets[daily_rets.index.dayofweek == day_num]
if len(day_rets) >= 20:
t_stat, p_val = stats.ttest_1samp(day_rets, 0)
results.append({
'day_num': day_num,
'day_name': day_names[day_num],
'mean_return': round(day_rets.mean(), 3),
'win_rate': round((day_rets > 0).mean() * 100, 1),
'n_obs': len(day_rets),
'p_value': round(p_val, 3),
'significant': 'Yes' if p_val < 0.05 else 'No'
})
return pd.DataFrame(results)
btc_monthly = compute_monthly_seasonality(prices, 'btc')
btc_weekday = compute_weekday_seasonality(prices, 'btc')
print('\nBTC Monthly Seasonality:')
print(btc_monthly[['month_name', 'mean_return', 'win_rate', 'n_years', 'p_value', 'significant']].to_string(index=False))
print('\nBTC Day-of-Week Seasonality:')
print(btc_weekday[['day_name', 'mean_return', 'win_rate', 'n_obs', 'p_value', 'significant']].to_string(index=False))
BTC Monthly Seasonality:
month_name mean_return win_rate n_years p_value significant
Jan 3.54 55.6 9 0.642 No
Feb 7.23 66.7 9 0.339 No
Mar 2.63 66.7 9 0.715 No
Apr 10.21 66.7 9 0.158 No
May 1.26 44.4 9 0.892 No
Jun -4.77 33.3 9 0.448 No
Jul 10.31 75.0 8 0.045 Yes
Aug -4.78 25.0 8 0.175 No
Sep -2.61 37.5 8 0.356 No
Oct 14.38 75.0 8 0.040 Yes
Nov -0.79 37.5 8 0.939 No
Dec 6.41 33.3 9 0.414 No
BTC Day-of-Week Seasonality:
day_name mean_return win_rate n_obs p_value significant
Monday 0.368 51.8 448 0.048 Yes
Tuesday 0.020 48.4 448 0.905 No
Wednesday 0.367 52.7 448 0.038 Yes
Thursday -0.216 46.7 448 0.274 No
Friday 0.189 51.9 449 0.243 No
Saturday 0.144 54.7 448 0.197 No
Sunday 0.037 51.3 448 0.780 No
Section 5 — Seasonal Heatmap
This section visualizes the seasonal patterns through a heatmap and bar charts. It helps to quickly identify months and weekdays with historically positive or negative returns.
def build_monthly_return_heatmap(
prices: pd.DataFrame,
asset: str = 'btc'
) -> pd.DataFrame:
"""
Build a year × month matrix of monthly returns for heatmap visualization.
Parameters
----------
prices : pd.DataFrame
Daily close prices.
asset : str
Column to analyze.
Returns
-------
pd.DataFrame
Rows = years, columns = month abbreviations, values = monthly return (%).
"""
monthly = prices[asset].resample('ME').last().pct_change().dropna() * 100
monthly_df = pd.DataFrame({'return': monthly.values,
'year': monthly.index.year,
'month': monthly.index.month})
pivot = monthly_df.pivot(index='year', columns='month', values='return')
pivot.columns = [calendar.month_abbr[m] for m in pivot.columns]
return pivot
def plot_seasonality(
btc_monthly: pd.DataFrame,
btc_weekday: pd.DataFrame,
heatmap: pd.DataFrame
) -> None:
"""
Three-panel visualization: monthly bar chart, weekday bar chart, and heatmap.
Parameters
----------
btc_monthly : pd.DataFrame
Monthly seasonality stats.
btc_weekday : pd.DataFrame
Weekday seasonality stats.
heatmap : pd.DataFrame
Year × month return matrix.
"""
fig = plt.figure(figsize=(16, 14))
# Panel 1: Monthly mean return
ax1 = plt.subplot(3, 1, 1)
colors = ['green' if r > 0 else 'red' for r in btc_monthly['mean_return']]
bars = ax1.bar(btc_monthly['month_name'], btc_monthly['mean_return'], color=colors, alpha=0.8, edgecolor='white')
ax1.axhline(0, color='black', linewidth=0.8)
for bar, win_rate in zip(bars, btc_monthly['win_rate']):
h = bar.get_height()
ax1.text(bar.get_x() + bar.get_width()/2, h + 0.3, f'{win_rate:.0f}%', ha='center', fontsize=8)
ax1.set_ylabel('Mean Monthly Return (%)')
ax1.set_title('BTC Monthly Seasonality — Bars = Mean Return, Labels = Win Rate')
# Panel 2: Weekday mean return
ax2 = plt.subplot(3, 1, 2)
colors2 = ['green' if r > 0 else 'red' for r in btc_weekday['mean_return']]
ax2.bar(btc_weekday['day_name'], btc_weekday['mean_return'], color=colors2, alpha=0.8, edgecolor='white')
ax2.axhline(0, color='black', linewidth=0.8)
ax2.set_ylabel('Mean Daily Return (%)')
ax2.set_title('BTC Day-of-Week Seasonality')
# Panel 3: Heatmap
ax3 = plt.subplot(3, 1, 3)
sns.heatmap(heatmap, annot=True, fmt='.0f', cmap='RdYlGn', center=0,
linewidths=0.3, ax=ax3, cbar_kws={'label': 'Monthly Return (%)'})
ax3.set_title('BTC Monthly Returns Heatmap (Rows=Year, Cols=Month)')
plt.tight_layout()
plt.show()
heatmap = build_monthly_return_heatmap(prices, 'btc')
plot_seasonality(btc_monthly, btc_weekday, heatmap)Section 6 — Seasonal Strategy Backtest
This section backtests a simple seasonality-driven trading strategy. It compares the performance of a strategy that adjusts position size based on historical monthly strength against a simple buy-and-hold strategy.
def backtest_seasonal_strategy(
prices: pd.DataFrame,
btc_monthly: pd.DataFrame,
asset: str = 'btc',
top_months_n: int = 6
) -> pd.DataFrame:
"""
Backtest a seasonality-driven position sizing strategy.
Strategy: full 100% long in the N strongest months by historical mean return;
50% position in remaining months.
Parameters
----------
prices : pd.DataFrame
Daily close prices.
btc_monthly : pd.DataFrame
Output of compute_monthly_seasonality().
asset : str
Asset column to trade.
top_months_n : int
Number of top months by mean return to go fully long.
Returns
-------
pd.DataFrame
Daily positions, returns, and equity curves.
Notes
-----
This strategy uses FULL historical data to identify strong months — this means
the backtest has an in-sample bias. A proper walk-forward test would use only
data available at the time of each monthly decision.
Use out-of-sample validation (e.g., train on 2016-2021, test on 2022-2024)
for a more rigorous assessment.
"""
strong_months = btc_monthly.nlargest(top_months_n, 'mean_return')['month'].tolist()
print(f'Top {top_months_n} months by mean return: {sorted(strong_months)}')
daily_rets = prices[asset].pct_change().dropna()
position = pd.Series(
np.where(daily_rets.index.month.isin(strong_months), 1.0, 0.5),
index=daily_rets.index
)
bt = pd.DataFrame({'ret': daily_rets, 'position': position})
bt['strategy_ret'] = bt['position'] * bt['ret']
bt['cum_bah'] = (1 + bt['ret']).cumprod()
bt['cum_strategy'] = (1 + bt['strategy_ret']).cumprod()
max_dd_bah = ((bt['cum_bah'] - bt['cum_bah'].cummax()) / bt['cum_bah'].cummax() * 100).min()
max_dd_strat = ((bt['cum_strategy'] - bt['cum_strategy'].cummax()) / bt['cum_strategy'].cummax() * 100).min()
print(f'Buy-and-hold: {(bt["cum_bah"].iloc[-1]-1)*100:.1f}% (max DD: {max_dd_bah:.1f}%)')
print(f'Seasonal strategy:{(bt["cum_strategy"].iloc[-1]-1)*100:.1f}% (max DD: {max_dd_strat:.1f}%)')
return bt
bt = backtest_seasonal_strategy(prices, btc_monthly)
# Plot equity curves
fig, ax = plt.subplots(figsize=(14, 5))
ax.plot(bt.index, bt['cum_bah'] * 100, color='orange', linewidth=1.5, label='Buy & Hold BTC')
ax.plot(bt.index, bt['cum_strategy'] * 100, color='steelblue', linewidth=1.5, label='Seasonal Strategy')
ax.set_yscale('log')
ax.set_ylabel('Portfolio (log, base=100)')
ax.set_title('Seasonal Strategy vs Buy-and-Hold')
ax.legend()
plt.tight_layout()
plt.show()Top 6 months by mean return: [1, 2, 4, 7, 10, 12] Buy-and-hold: 781.4% (max DD: -83.4%) Seasonal strategy:2085.0% (max DD: -65.9%)
Section 7 — Export
This section is responsible for exporting all the generated seasonality analysis outputs, including monthly and weekday statistics, the monthly return heatmap, and the backtest results, into CSV files.
def export_seasonality_data(
btc_monthly: pd.DataFrame,
btc_weekday: pd.DataFrame,
heatmap: pd.DataFrame,
bt: pd.DataFrame
) -> None:
"""
Export all seasonality analysis outputs.
Parameters
----------
btc_monthly : pd.DataFrame
Monthly seasonality statistics.
btc_weekday : pd.DataFrame
Weekday seasonality statistics.
heatmap : pd.DataFrame
Year × month return matrix.
bt : pd.DataFrame
Seasonal strategy backtest results.
"""
btc_monthly.to_csv('btc_monthly_seasonality.csv', index=False)
btc_weekday.to_csv('btc_weekday_seasonality.csv', index=False)
heatmap.to_csv('btc_monthly_heatmap.csv')
bt[['ret', 'position', 'cum_bah', 'cum_strategy']].to_csv('seasonality_backtest.csv')
print('Exported: btc_monthly_seasonality.csv')
print('Exported: btc_weekday_seasonality.csv')
print('Exported: btc_monthly_heatmap.csv')
print('Exported: seasonality_backtest.csv')
export_seasonality_data(btc_monthly, btc_weekday, heatmap, bt)Exported: btc_monthly_seasonality.csv Exported: btc_weekday_seasonality.csv Exported: btc_monthly_heatmap.csv Exported: seasonality_backtest.csv
Summary & Next Steps
Key Takeaways
- October ('Uptober') and April have been the most consistently positive months for BTC historically
- September and June tend to be the weakest months on average
- Day-of-week effects exist but are weak — weekend volatility tends to be higher
- Small sample size is the key caveat: with ~8 years of data, each month has only N=8 observations
- Seasonal patterns are strongest when they align with the halving cycle and macro regime — standalone, they are weak signals
- The heatmap shows significant year-to-year variance within each month — a 'strong month' can easily lose 20% in a bear year