Macro·Macro Strategy Implementations·Intermediate

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.

macrotrading-strategies

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:

  1. Fetches BTC and ETH daily prices and computes returns at multiple time frames
  2. Analyzes monthly seasonality: average return and win rate by calendar month
  3. Analyzes day-of-week seasonality: average return and win rate by weekday
  4. Tests statistical significance of each seasonal pattern
  5. Builds a seasonal trading calendar that shifts BTC exposure based on historical month strength
  6. Backtests the seasonal strategy vs buy-and-hold
  7. Overlays seasonality with halving cycle phase to test interaction effects
  8. Exports the seasonal signal calendar

Known Crypto Seasonal Patterns

PeriodKnown asHistorical Tendency
January'Uptober' missedOften positive — fresh allocation, tax-loss selling reversal
AprilQ2 startOften strong
May-June'Sell in May'Mixed to negative — liquidity often drops
AugustSummer doldrumsLow volatility, range-bound
October'Uptober'Historically strong across multiple cycles
November-DecemberEnd-of-year rallyOften 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 --quiet
[ ]
import 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, Sep

Section 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)
cell output

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%)
cell output

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