Seasonality of Bitcoin returns?

Bitcoin
Author

Beniamino Sartini

Published

June 22, 2024

The seasonal effect on Bitcoin (BTC) returns refers to the observed patterns and trends that occur during specific periods within a year. Researchers have found evidence suggesting the presence of seasonality in BTC returns, indicating recurring patterns that investors and traders can potentially exploit. These patterns can help inform investment strategies and improve market timing.

Code
library(tidyverse)
library(DT)
BTCUSDT <- read_csv("BTCUSDT-spot-1d.csv")
label_days <- c("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday")
Code
date_to_plot <- max(BTCUSDT$date)
date_from_plot <- date_to_plot - lubridate::days(60)
# Candle chart plot of last 60 days 
BTCUSDT %>%
  filter(date >= date_from_plot & date <= date_to_plot) %>%
  ggplot()+
  binancer::geom_candlechart(aes(x = date, close = close, open = open, low = low, high = high))+
  theme_bw()+
  theme(legend.position = "none")+
  labs(x = NULL)

Figure 1: BTCUSDT daily prices

One well-known seasonal effect is the January effect, where BTC returns tend to be above average during the month of January. This phenomenon has been observed over multiple years, suggesting a consistent pattern. Another notable seasonal effect is the December effect, which indicates higher BTC returns during the month of December. Market activity during the holiday season and investor sentiment are factors that may contribute to this effect.

Code
# Compute cumulated return for each year 
df_years <- list()
df <- BTCUSDT
df$Year <- lubridate::year(df$date)
df$Year_ <- factor(df$Year, levels = c(min(df$Year):max(df$Year)), ordered = TRUE)
df$n <- solarr::number_of_day(df$date)
for(y in unique(df$Year)){
  df_y <- dplyr::filter(df, Year == y)
  df_y$Rt <- c(0, diff(log(df_y$close)))
  df_y$cum_ret <- cumprod(exp(df_y$Rt))
  df_years[[y]] <- df_y
}
df_years <- bind_rows(df_years)

x_breaks <- seq(1, 365, 40)
x_labels <- dplyr::filter(df, Year == 2020)[x_breaks,]$date
x_labels <- str_remove_all(as.Date(x_labels), "2020-")
ggplot(df_years)+
  geom_line(aes(n, cum_ret, group = Year_, color = as.character(Year_)))+
  theme_bw()+
  theme(legend.position = "top")+
  scale_x_continuous(breaks = x_breaks, labels = x_labels)+
  scale_y_continuous(breaks = seq(0, 4, 0.5), labels = paste0((seq(0, 4, 0.5)-1)*100, "%"))+
  labs(color = NULL, y = "Cumulated return", x = NULL)

0.1 Monthly seasonality

Code
# Compute cumulated return for each year 
df_years <- list()
df <- BTCUSDT
df$Year <- lubridate::year(df$date)
df$Year_ <- factor(df$Year, levels = c(min(df$Year):max(df$Year)), ordered = TRUE)
df$Month <- lubridate::month(df$date)
df$Month_ <- factor(df$Month,
                    levels = c(1:12), 
                    labels = lubridate::month(1:12, label = TRUE),
                    ordered = TRUE)
df$Day <- lubridate::day(df$date)
df$n <- solarr::number_of_day(df$date)
k <- 1
for(y in unique(df$Year)){
  for(m in unique(df$Month)){
    df_y <- filter(df, Year == y & Month == m)
    df_y$Rt <- c(0, diff(log(df_y$close)))
    df_y$cum_ret <- cumprod(exp(df_y$Rt))
    df_years[[k]] <- df_y
    k <- k + 1
  }
}
plot_cumret_month <- function(df_years, m = 1){
  
  df_years <- bind_rows(df_years)
  x_breaks <- seq(1, 30, 5)
  y_breaks <- seq(0, 2, 0.25)
  y_labels <- paste0((seq(0, 2, 0.25)-1)*100, "%")
  
  df_years %>%
    filter(Month == m) %>%
    ggplot()+
    geom_line(aes(n, cum_ret, group = Year_, color = as.character(Year_)))+
    theme_bw()+
    theme(legend.position = "top")+
    scale_x_continuous(breaks = x_breaks)+
    scale_y_continuous(breaks = y_breaks, labels = y_labels)+
    labs(color = NULL, y = "Cumulated return", x = NULL)
}
Code
plot_cumret_month(df_years, m = 1)

Code
plot_cumret_month(df_years, m = 2)

Code
plot_cumret_month(df_years, m = 3)

Code
plot_cumret_month(df_years, m = 4)

Code
plot_cumret_month(df_years, m = 5)

Code
plot_cumret_month(df_years, m = 6)

Code
plot_cumret_month(df_years, m = 7)

Code
plot_cumret_month(df_years, m = 8)

Code
plot_cumret_month(df_years, m = 9)

Code
plot_cumret_month(df_years, m = 10)

Code
plot_cumret_month(df_years, m = 11)

Code
plot_cumret_month(df_years, m = 12)

0.2 Mean return by day and month

The dataset is composed by OHLCV data from binance.com starting from 2018-01-01 up to 2024-06-20.

Code
labels_day <- c("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday",  "Sunday")
df <- BTCUSDT
# log-price 
df$log_close <- log(df$close)
# log-return 
df$ret <- c(0, diff(df$log_close))

df <- df %>%
  mutate(
    pos_ret = ifelse(ret >= 0, 1, 0),
    Month = lubridate::month(date, label = TRUE),
    weekday = weekdays(df$date),
    weekday = factor(weekday, ordered = TRUE, levels = labels_day),
    Monday = ifelse(weekday == labels_day[1], 1, 0),
    Tuesday = ifelse(weekday == labels_day[2], 1, 0),
    Wednesday = ifelse(weekday == labels_day[3], 1, 0),
    Thursday = ifelse(weekday == labels_day[4], 1, 0),
    Friday = ifelse(weekday == labels_day[5], 1, 0),
    Saturday = ifelse(weekday == labels_day[6], 1, 0),
    Sunday = ifelse(weekday == labels_day[7], 1, 0), 
    weekend =  ifelse(weekday %in% labels_day[6:7], 1, 0),
    workday =  ifelse(!(weekday %in% labels_day[6:7]), 1, 0))

head(df, n = 5) %>%
  bind_rows() %>%
  DT::datatable(rownames = FALSE, 
                options = list(
                  dom = 't',
                  pageLength = 12, 
                  searching = FALSE, 
                  paging = FALSE,
                  initComplete = DT::JS(
                        "function(settings, json) {",
                        "$(this.api().table().header()).css({'background-color':
                        '#000', 'color': '#FFC525'});", "}"))) %>% 
    DT::formatStyle(c(1:12),target='row', backgroundColor = "#F3F7F9")%>%
    DT::formatDate(c("date", "date_close"))

0.3 Mean returns

Code
df_pos_ret <- df %>%
  group_by(Month, weekday) %>%
  summarise(p = mean(pos_ret)) %>%
  spread(weekday, p) 

# create 19 breaks and 20 rgb color values ranging from white to red
brks <- quantile(df_pos_ret[,-1], probs = seq(.01, .95, .01), na.rm = TRUE)
clrs <- colorRampPalette(c("red", "white", "green"))(length(brks) + 1)
df_pos_ret[,1] <- lubridate::month(1:12, label = TRUE, abbr = FALSE)
datatable(df_pos_ret, rownames = FALSE, 
          options = list(pageLength = 12, searching = FALSE, paging = FALSE)) %>% 
  formatPercentage(columns = label_days, digits = 2) %>% 
  formatStyle(label_days, backgroundColor = styleInterval(brks, clrs)) 

1 Statistical evidence of a weekend effect?

Here weekend is 1 if the day is Saturday or Sunday and 0 otherwise.

term estimate std.error statistic p.value
(Intercept) 0.50148 0.01216 41.23956 0.00000
weekend 0.03858 0.02277 1.69438 0.09032
r.squared adj.r.squared sigma statistic p.value df df.residual nobs
0.00121 0.00079 0.49975 2.87093 0.09032 1 2361 2363
Back to top

Citation

BibTeX citation:
@online{sartini2024,
  author = {Sartini, Beniamino},
  title = {Seasonality of {Bitcoin} Returns?},
  date = {2024-06-22},
  url = {https://cryptoverser.org/articles/bitcoin-seasonality/bitcoin-seasonality.html},
  langid = {en}
}
For attribution, please cite this work as:
Sartini, Beniamino. 2024. “Seasonality of Bitcoin Returns?” June 22, 2024. https://cryptoverser.org/articles/bitcoin-seasonality/bitcoin-seasonality.html.