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
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.
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
| 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
<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
| 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)
Backtest
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
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())
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
Strategy analysis
Show the code
tools.strategy_performance_metrics(pd.concat([result.returns, result.benchmark], axis=1), steps_per_year=(365*24))
| 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)
Trades Analysis
Show the code
tools.metrics_trades(result.trades)
| 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.
Statistical Analysis
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.
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.
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.