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
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)
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)
}
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 Saturday
or Sunday
and
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 |
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}
}