Hourly Seasonality Study - Bitcoin

Author

Rui Carvalho Caseiro

Published

March 17, 2026

Show the code
import pandas as pd
pd.options.display.float_format = '{:.2f}'.format
import numpy as np
import plotly.io as pio
pio.renderers.default = "plotly_mimetype+notebook_connected"

import warnings
warnings.filterwarnings("ignore")

import MyCustomLibrary.main as main
import MyCustomLibrary.backtester as bt
import MyCustomLibrary.backtester_tools as tools

1 Research

This study explores intraday seasonality in Bitcoin, examining whether returns and volatility exhibit consistent patterns across different hours of the day. Using historical UTC hourly market data from Binance (08/2017 - 01/2026) and a systematic backtesting framework, the analysis evaluates whether these time-based effects persist over time and whether they could form the basis of repeatable trading signals.

1.1 Data

Show the code
from MyCustomLibrary.data import BTC_Hourly

quotes_hourly = BTC_Hourly.droplevel(level=0, axis=1)
quotes_hourly.sort_index(inplace=True)
quotes_hourly
Open High Low Close Volume
Timestamp
2017-08-17 04:00:00 4261.48 4313.62 4261.32 4308.83 47.18
2017-08-17 05:00:00 4308.83 4328.69 4291.37 4315.32 23.23
2017-08-17 06:00:00 4330.29 4345.45 4309.37 4324.35 7.23
2017-08-17 07:00:00 4316.62 4349.99 4287.41 4349.99 4.44
2017-08-17 08:00:00 4333.32 4377.85 4333.32 4360.69 0.97
... ... ... ... ... ...
2026-01-30 05:00:00 82938.92 83329.36 82903.23 82914.19 818.21
2026-01-30 06:00:00 82914.20 82919.21 82056.08 82602.81 1199.37
2026-01-30 07:00:00 82602.81 83100.00 82483.27 82816.98 1624.46
2026-01-30 08:00:00 82816.98 82995.41 82438.80 82671.70 959.74
2026-01-30 09:00:00 82671.70 82834.19 82216.27 82471.58 593.30

75043 rows × 5 columns

Show the code
quotes_hourly.info()
<class 'pandas.core.frame.DataFrame'>
DatetimeIndex: 75043 entries, 2017-08-17 04:00:00 to 2026-01-30 09:00:00
Data columns (total 5 columns):
 #   Column  Non-Null Count  Dtype  
---  ------  --------------  -----  
 0   Open    75043 non-null  float64
 1   High    75043 non-null  float64
 2   Low     75043 non-null  float64
 3   Close   75043 non-null  float64
 4   Volume  75043 non-null  float64
dtypes: float64(5)
memory usage: 3.4 MB

All data is complete, Numbers formatting is correct!

Show the code
df_train, df_test = main.train_test_split(quotes_hourly, test_size=0.2)

hourly_seasonality = main.get_seasonality(df_train['Close'], timeframe='H')
hourly_seasonality
avg. return (%)
hour
0 0.02
1 -0.03
2 -0.03
3 -0.03
4 -0.01
5 0.00
6 0.03
7 0.01
8 0.00
9 -0.02
10 -0.01
11 0.02
12 0.03
13 0.02
14 -0.02
15 0.02
16 0.00
17 -0.01
18 0.01
19 0.02
20 0.00
21 0.04
22 0.05
23 -0.02

Return at 23 is from (22H:23H) = 23H Close Price/22H Close Price -1

Show the code
hourly_seasonality.plot.bar(figsize=(10,6), title='BTC/USD UTC Hourly Seasonality');

Returns from 21st and 22nd hours are extraordinary vs other time periods, a possible explanation for this abnormal deviation is given by “The Seasonality of Bitcoin” paper stating that these are the hours that no big traditional markets Exchange is open.

“All major markets are closed during this period of the day. For UTC +0, the NYSE is open between 14:30 and 21:00, Tokyo Stock Exchange is open from 00:00 to 06:00, Hong Kong from 01:30 to 08:00, same as India, which is open between 2:30 and 10:00, and Australia, which is open from 23:00 to 05:00. Both London and continental Europe are closed during these hours since it is night there. Therefore, the best time to trade (and hold) BTC is when every other major exchange is closed.” The Seasonality of Bitcoin (2023)

2 Backtest

2.1 Signals generation

Since we are aiming to get exposure at the 21st and 22nd hour, we should get in at 21:00H Open and exit at 23:00H Open.

Entry: 21:00H Exit: 23:00H

2.2 Strategy Implementation

Show the code
class SeasonalityStrategy(bt.Strategy):
    
    def init(self, **kwargs):
        
        self.symbol = 'BTCUSDT'

    def next(self, i, record):

        timestamp = self.data.index[i]
        try:
            ###------------Buy conditions------------###
            if timestamp.hour == 21:
                self.open_long(symbol=self.symbol, price=record['Open'])
            
            ###------------Sell conditions------------###
            elif timestamp.hour == 23:
                self.close(symbol=self.symbol, price=record['Open'])

        except Exception as e:
            import traceback
            print(traceback.format_exc())

3 In Sample Testing

Show the code
main.timer.start()
backtest = bt.Backtest(SeasonalityStrategy, df_train, df_train.loc[:, 'Close'], cash=100_000, commission=0.0)
result = backtest.run()
main.timer.stop()

[*] Total runtime: 527.10 ms

3.1 Strategy analysis

Show the code
tools.strategy_performance_metrics(pd.concat([result.returns, result.benchmark], axis=1), steps_per_year=(365*24))
Strategy Backtest Benchmark
CAGR % 40.00 47.29
Ann. Vola % 22.37 78.16
Max Drawdown % -22.59 -83.91
VaR 5% -0.00 -1.10
Time in Market % 11.37 99.95
Sharpe 1.79 0.61
Sortino 0.73 0.73
Calmar 1.77 0.56
Efficiency 3.52 0.47

Strategy has returned ~40% annualized vs B&H ~47%, with much lower annualized volatility (22% vs 78%) and also much lower MDD (-23% vs -84%). This reduced volatility and MDD explain how ratios like Sharpe Ratio and Calmar ratio are more than double vs B&H.

The biggest advantage of this strategy is being able to extract 40% annualized returns being exposed only 11% of the time. Allowing capital to be used in another endeavors while is not being utilized by this strategy.

Show the code
main.plot_strategy_performance(result.returns, result.benchmark)

Graph shows that Strategy has a smooth ride throughout the entire tested period.

Show the code
main.monthly_pos_neg_returns(result.returns)

3.2 Trades Analysis

Show the code
tools.metrics_trades(result.trades)
Trades Analysis
Nr trades 2504.00
Win Rate % 54.75
Profit Factor 1.29
Avg. Win % 0.69
Avg. Loss % -0.62
Reward/Risk 1.10
Expectancy % 0.09
Avg. Bars Held 2.00

This strategy has a positive expectancy per trade, this may be the single most important metric to look at in in this table.

3.3 Statistical Analysis

3.3.1 Is it significantly different from 0?

Show the code
main.compute_t_test(result.returns)
P-value: 2.2724816562329382e-05 Reject the null hypothesis (H₀). The strategy average return is significantly different from zero.

3.3.2 Does it pass Monte Carlo Permutation?

Show the code
mcpyt = main.monte_carlo_permutation_yt(result.returns, nr_simulations=1_000)

main.mcpyt_analysis(mcpyt, result.returns)

Statistically speaking this is not a sound strategy! Although it has proven itself against 0 returns(T Test), it has failed to pass Monte Carlo Permutation test, proving that it doesn’t have an edge, since Stregy’s Max DD is between Monte Carlo MDD 95% CI.

4 Final Findings

Bitcoin has shown an impressive abnormality of returns on the 21st and 22nd UTC+0 hour. The paper “The Seasonality of Bitcoin” suggests that this anomaly might be related to the moment when most large traditional exchanges historically perform operational resets or have reduced activity.

This sounded promising, so I decided to backtest the idea using an event-based backtester, which tends to be more reliable than vectorized approaches for strategy validation.

The first results of the strategy were quite encouraging. Although it did not outperform Buy & Hold in absolute return terms, it achieved a ~40% CAGR while being exposed to the market only about 11% of the time. Additionally, it delivered roughly double the Sharpe and Calmar ratios compared to B&H. Visually, the equity curve is also much smoother, which reinforces the idea that the strategy captures a consistent edge.

Looking deeper into the individual trades, the statistics support what the equity curve already suggests: the strategy appears profitable under the assumptions of the backtest (important to note that the analysis is conducted without costs or taxes). Both Reward/Risk and Expectancy are positive, indicating that the trade distribution is favorable and potentially robust enough to justify further investigation.

Before moving into Out-of-Sample testing, a set of statistical validation tests was performed. The T-Test confirms that the average return of the strategy is statistically different from zero. However, the strategy fails the Monte Carlo permutation test, producing a p-value of ~0.42, suggesting that the observed performance could reasonably occur by chance once the temporal structure of the trades is randomized.

Given this result, despite the appealing performance metrics, the evidence is not strong enough to confidently attribute the edge to a real market inefficiency. As a result, the most prudent decision is to abandon this strategy idea and continue searching for more statistically robust signals.