Pairs trading between Brent and WTI crude oil involves exploiting the price relationship between these two globally significant crude oil benchmarks. The strategy focuses on identifying deviations in their spread and capitalizing on the tendency for the relationship to revert to a mean.
Why Brent and WTI?
Highly Correlated Assets: Both crude types are substitutes and are influenced by global supply-demand factors. As such, they tend to move in similar directions, making them good candidates for pairs trading.
Spread Dynamics: The price spread (spread = Brent - WTI) often exhibits mean-reverting behavior due to arbitrage and relative demand-supply dynamics.
Transportation and Logistics: WTI prices can diverge due to storage bottlenecks or pipeline issues in the U.S., especially at the delivery hub in Cushing, Oklahoma. Also transporation cost (tanker prices) play an important role in price divergence and convergence/price volatility of the spread. transportation cost will be treated as fundamental factor for the quantitative model.
Geo-politics: Brent is more sensitive to geopolitical tensions in Europe, the Middle East, and Africa. WTI is more influenced by U.S. domestic production and storage conditions. Although this model would not rely on geo-politics, it is noteworthy to bring up its relevance in the report. However the scope of this report will remain to transportation cost as fundamental factor.
Note: For this model front month continuous contract is used and adjusted for rollover. It is noteworthy to mention that only the closing price is used instead of OHLC.
Code
s_date <-"2020-01-01"CL25F <- RTL::getPrice(feed ="CME_NymexFutures_EOD_continuous",contract ="CL_001_Month",from = s_date,iuser = iuser,ipassword = ipassword) %>% RTL::rolladjust(commodityname =c("cmewti"),rolltype =c("Last.Trade"))BRN25F <- RTL::getPrice(feed ="ICE_EuroFutures_continuous",contract ="BRN_001_Month",from = s_date,iuser = iuser,ipassword = ipassword) %>% RTL::rolladjust(commodityname =c("icebrent"),rolltype =c("Last.Trade"))futures_data <- BRN25F %>%inner_join(CL25F, by ="date")names(futures_data)[c(2, 3)] <-c("BRNF", "CLF")futures_data02 <- futures_data %>%transmute(date, BRNF, CLF, spreadF = BRNF - CLF) %>%mutate(return_spreadF = spreadF/lag(spreadF)-1) %>%mutate(normalized_z_spreadF =scale(spreadF, center =TRUE, scale =TRUE))adf_test <- futures_data02$spreadF %>%na.omit() %>% urca::ur.df(type ="trend", selectlags ="AIC")############################################################################### detrending normalzied data.lm_fit <-lm(normalized_z_spreadF ~seq_along(normalized_z_spreadF), data = futures_data02)# Subtract the fitted values (the trend) from the original 'values_spread25F' column to get the detrended datafutures_data02$normalized_z_detrended_spreadF <- futures_data02$normalized_z_spreadF - lm_fit$fitted.values##############################################################################file_path <-"https://raw.githubusercontent.com/Aftikharmnz/Baltic/refs/heads/main/Baltic%20Dirty%20Tanker%20Historical%20Data.csv"### If you're reading this and wondering why I stored data in github and imported it here, the reason is Website that provides historical data for baltic dirty tanker index prices is protected and I could not webscrape it.baltic_data <-read_csv(file_path, col_types =cols(.default =col_character()))baltic_data <- baltic_data %>%transmute(Date =as.Date(Date, format ="%m/%d/%Y"),index =as.numeric(Price),change = index/lag(index)-1)O1 <- futures_data %>%transmute(Date = date,spread = BRNF - CLF,spread_ratio = spread / dplyr::lag(spread) -1) %>%na.omit()comparsion <- baltic_data %>%inner_join(O1, by ="Date")
Code
library(slider)library(dplyr)# Prepare your data by joining datasetscorr_data <- baltic_data %>%inner_join(futures_data02 %>%mutate(Date = date), by ="Date") %>%select(Date, change, return_spreadF)# Fit the linear regression modellm_model <-lm(return_spreadF ~ change, data = corr_data)# Plot the data pointslibrary(ggplot2)spread_changeplot <-ggplot(corr_data, aes(x = change, y = return_spreadF)) +geom_point(color ="blue") +# scatter plot of data pointsgeom_smooth(method ="lm", color ="red", se =FALSE) +# regression linelabs(title ="Linear Regression: Return SpreadF vs Change",x ="Change",y ="Return SpreadF") +theme_minimal()
plot_stationaty <-plot_ly(futures_data02, x =~date, y =~normalized_z_detrended_spreadF, type ='scatter', mode ='lines', name ='Normalized Spread_F', line =list(color ='blue', width =1)) %>%add_trace(x = futures_data02$date, y =rep(0.5, length(futures_data02$date)),type ='scatter', mode ='lines', name ='Positive 0.5 SD',line =list(color ='green', dash ='dash', width =1) ) %>%add_trace(x = futures_data02$date, y =rep(-0.5, length(futures_data02$date)),type ='scatter', mode ='lines', name ='Negative 0.5 SD',line =list(color ='red', dash ='dash', width =1) ) %>%layout(legend =list(orientation ='h', # Horizontal orientationx =0.5, # Center the legendy =-0.2, # Position below the chartxanchor ='center', # Align horizontally to the centeryanchor ='top'# Align vertically to the top of the legend ) )
ADF test: Baltic dirty Tanker index changes and Brent-WTI spread changes
Augmented Dickey-Fuller (ADF) Test Results for Brent-WTI Spread
Reasoning: The Augmented Dickey-Fuller (ADF) test is a statistical test used to determine whether a time series is stationary. A stationary time series has a constant mean, variance, and auto-covariance over time, which is a critical assumption for many time series models. Given that spread between Brent and WTI (or in the case of Baltic dirty tanker index changes) is stationary we can confidently continue building our quantitative model under assumption that the spread will mean revert. In other words since there would be fluctuation from mean we can capitalize on it assuming that it will revert back to mean, and build a quantitative model around it while optimizing for standard deviations as benchmarks to short or long the spread.
Conclusion from stationarity in spread/Baltic changes:
Confirms a strong long-term equilibrium relationship.
Provides a foundation for statistical arbitrage (mean-reversion-based) pairs trading.
Indicates that deviations in the spread are trade-able anomalies rather than signs of a fundamental divergence in the two markets (in the context of WTI-Brent spread).
Normalized and de-trended Baltic dirty tanker index changes and Brent - WTI spread
Graphic below shows that spread volatility is around mean.
The reasoning behind detrending the normalized z-scored lies in ensuring that the spread between prices/index reflect only short-term deviations from its historical equilibrium, free from any residual linear trends. Even though the spread/index changes has been found to be stationary, slight directional drifts may still be present due to market dynamics or structural shifts, which could obscure the true mean-reverting nature of the spread. By fitting a linear regression model to the normalized data and subtracting the fitted trend, we effectively remove these long-term drifts, isolating the oscillatory component of the spread. This detrended series provides a clearer signal for identifying mean-reversion opportunities, which is the cornerstone of pairs trading strategies. In this refined form, the data better represents temporary anomalies that can be exploited for trading, enabling more reliable and robust signal generation while minimizing the risk of misinterpreting persistent trends as trading opportunities. This process enhances both the quality and interpret-ability of the data for market-neutral strategies focused on statistical arbitrage. We will use the standard deviation as benchmark/signal for when to enter and exit a trade and will optimize for it.
(please zoom for better view of mean reverting behavior)
Here is a visual representation of mean revesion behaviour.
The composite indicator I developed combines two critical factors—detrended spread values and changes in the Baltic index—into a single metric designed to capture market dynamics comprehensively. By assigning weights to these components, the indicator balances the localized imbalances reflected in the spread with the global shipping demand trends represented by the Baltic index. The rationale behind this approach is to create a nuanced measure of market conditions, where the weighted contributions of these variables adjust the sensitivity of trade signals. The indicator is further optimized by setting thresholds to identify overvalued or undervalued conditions, enabling systematic buy and sell decisions. Moving forward, I plan to optimize the weights and thresholds to improve the indicator’s predictive power and adapt it to different market environments. The baltic weight contributes to composite indicator by increasing the indicator when price of tanker increases, and vice versa, providing a better signal rooted with fundamentals.
The weights (spread weight and baltic weight2) allow for flexibility in emphasizing either the spread or the Baltic index changes based on market dynamics. The thresholds (Upper standard deviation and Lower standard deviation) for spread can be adjusted to control the sensitivity of the strategy.
Baltic weight: The Baltic weight is particularly impactful as it highlights discrepancies in transportation costs and logistical bottlenecks, which are significant drivers of the price differential between the two benchmarks. Brent prices are more influenced by seaborne trade, while WTI, tied to inland U.S. production, is more sensitive to domestic transportation constraints. The Baltic weight bridges this gap by quantifying global transport demand, making it the most critical component of the composite indicator. Its inclusion ensures the model captures broader macroeconomic and fundamental factors that directly affect the spread, providing a robust basis for trading and forecasting.
Highlight of the model logic
Critical: while calculating the returns I ensured I took the returns from actual spread. It is noteworthy because the model is working with normalized and actual prices/index.
For more practicality the model will charge a 1% transaction cost and there is a stop loss of -10% (both embedded in the logic of this model)
Par1value - upper standard deviation (above mean/zero)
Par2value - lower standard deviation (below mean/zero)
Code
digraphG{size="25,20";ratio=compress;node[fontsize=45, width=6];edge[fontsize=45];nodesep=2;ranksep=1.5;graph[rankdir=TB, fontsize=45, labelloc=top, label="Trading Strategy Decision Flowchart: Signal Generation and Execution Logic"]node[shape=rectangle, style=filled, fillcolor=lightblue]Start[label="Start: Input Composite Indicator"]Detrend[label="Detrending and Normalizing Data"]Check_Thresholds[label="Check Composite Indicator\nvs. Thresholds:\npar1value (Upper), par2value (Lower)"]Buy_Signal[label="Buy Signal:\nIndicator < par2value (Lower Threshold)"]Sell_Signal[label="Sell Signal:\nIndicator > par1value (Upper Threshold)"]Hold[label="Hold Signal:\npar2value ≤ Indicator ≤ par1value"]Stop_Loss_Check[label="Stop Loss Check:\nIs Loss > Threshold?"]Close_Position[label="Close Position: Stop Loss Triggered"]Trade_Execution[label="Trade Execution:\nBuy/Sell Spread at Next Open"]Trade_Cost[label="Adjust for 1% Trade Charge"]Position_Update[label="Update Position:\nLong, Short, Flat"]End[label="End"]Start->Detrend->Check_ThresholdsCheck_Thresholds->Buy_Signal[label="Indicator < par2value"]Check_Thresholds->Sell_Signal[label="Indicator > par1value"]Check_Thresholds->Hold[label="Within Thresholds"]Buy_Signal->Stop_Loss_CheckSell_Signal->Stop_Loss_CheckStop_Loss_Check->Close_Position[label="Loss > Threshold"]Stop_Loss_Check->Trade_Execution[label="No Stop Loss Triggered"]Close_Position->Trade_CostTrade_Execution->Trade_CostTrade_Cost->Position_Update->EndHold->End}
A Check of the model before doing optimization: Used the following constraints:
par1value = 0.7
par2value = -0.7
spreadweight = 0.7
balticweight = 0.3
The model is working fine –> executing trade, taking positions, with a cumulative performance. All looks good
Code
initial_strategy <-function(data = train,par1value =0.7,par2value =-0.7,spreadweight =0.7,balticweight =0.3) { data <- data %>%select( Date, spreadF, return_spreadF, normalized_z_detrended_spreadF, normalized_z_index_baltic ) %>%mutate(# Create composite indicator using weighted spread and Baltic changescomposite_indicator = spreadweight * normalized_z_detrended_spreadF + balticweight * normalized_z_index_baltic,# Generate trade signals based on the composite indicatorsignal = dplyr::case_when( composite_indicator < par2value ~1, # Buy signal composite_indicator > par1value ~-1, # Sell signalTRUE~0# No trade signal ) )# Create trade orders based on the change in signal withtrades <- data %>% dplyr::mutate(trade = tidyr::replace_na(dplyr::lag(signal) - dplyr::lag(signal, n =2L), 0) )# Track positions and calculate returns withposition <- withtrades %>%mutate(pos =cumsum(trade), # Cumulative position based on trade signals# Return on new tradesret_new =ifelse(trade !=0, trade * return_spreadF, 0),# Return on existing trades (carry the same position)ret_exist =ifelse(trade ==0& pos !=0, pos * return_spreadF, 0),# Total return considering new and existing tradesret = ret_new + ret_exist,ret = ret -abs(trade) *0.01# transaction cost )# Calculate cumulative equity (cumulative product of total returns) final <- withposition %>% dplyr::mutate(cumeq =cumprod(1+ ret) # Cumulative return )return(final)}strategy <-function(data = train,par1value =0.7,par2value =-0.7,spreadweight =0.7,balticweight =0.3,stop_loss_threshold =-0.1) { # Stop-loss threshold (3%) data <- data %>%select( Date, spreadF, return_spreadF, normalized_z_detrended_spreadF, normalized_z_index_baltic ) %>%mutate(# composite indicator using weighted spread and Baltic changescomposite_indicator = spreadweight * normalized_z_detrended_spreadF + balticweight * normalized_z_index_baltic,# Generate trade signals based on the composite indicatorsignal = dplyr::case_when( composite_indicator < par2value ~1, # Buy signal composite_indicator > par1value ~-1, # Sell signalTRUE~0# No trade signal ) )# trade orders based on the change in signal withtrades <- data %>% dplyr::mutate(trade = tidyr::replace_na(dplyr::lag(signal) - dplyr::lag(signal, n =2L), 0) )# Track positions and calculate returns withposition <- withtrades %>%mutate(pos =cumsum(trade), # Cumulative position based on trade signals# Track entry price when trade is executedentry_price =ifelse(trade !=0, spreadF, NA),# Initialize a column to track the stop-loss conditionstop_loss_triggered =FALSE,# For each trade, checking if stop-loss is triggeredstop_loss =ifelse(!is.na(entry_price) & pos !=0, abs((spreadF - entry_price) / entry_price) >= stop_loss_threshold, FALSE ),# Update the trade position based on stop-loss logicadjusted_trade =ifelse(stop_loss, 0, trade),# Calculate returns (new, existing, or stopped out)ret_new =ifelse(adjusted_trade !=0, adjusted_trade * return_spreadF, 0),ret_exist =ifelse(adjusted_trade ==0& pos !=0, pos * return_spreadF, 0),# Total return considering new, existing, and stop-loss tradesret = ret_new + ret_exist,ret = ret -abs(adjusted_trade) *0.01# transaction cost )# Calculate cumulative equity (cumulative product of total returns) final <- withposition %>% dplyr::mutate(cumeq =cumprod(1+ ret) # Cumulative return )return(final)}par1value =seq(from =0, to =2, by =0.05)par2value <-seq(from =-2, to =0, by =0.05)param_grid <-expand.grid(par1value = par1value, par2value = par2value)
Justification for using spread rather than buying/selling WTI/Brent seperately
Statistical Validity: spread is stationary and mean-reverting; hence the strategy aligns perfectly with statistical arbitrage principles (cointegration & mean reversion).
Risk Mitigation: Spread trading reduces systemic risk and focuses on relative value, making it more robust in volatile oil market. (risk neutral hedging).
Simplicity and Execution: Trading the spread simplifies both modeling and execution, while still capturing the trading edge identified by this model.
common industry practice: Trading the spread as a unit is a well-established strategy in the commodity markets, particularly in the oil industry. Many institutional investors and hedge funds use spread trading to exploit inefficiencies between related assets
My risk appetite
As an investor/trader with defined limitations, my primary concern is determining the maximum duration to lock capital into a trade without incurring significant losses/ or when I have to lock capital. If the market reverts within a week, the risk is minimal; however, if it extends beyond three or more weeks, the opportunity cost of potentially higher returns elsewhere becomes significant, specially if contracts are expiring.
Other key considerations include:
Time Horizon & Reinvestment Risk: A short reversion (within a week) poses minimal risk, while longer durations increase the opportunity cost of better returns elsewhere.
Risk Perception: I have a high tolerance for market volatility and can handle fluctuations without significant emotional impact, although I assess whether I would lose sleep over substantial market movements—my answer: not typically. In the context of this model, I consider my risk perception to be low
Confidence: When data and analytics fails provide inside I tend to follow my guts. Although I must guard against excessive risk-taking and ensure my market calls are sound and rooted with fundamentals. One question I struggle with is: How to make better decisions without complete information? Is there any principled approach?
Investment/Trading Knowledge: My trading knowledge, especially in commodities, is less than that of seasoned professionals, so I must carefully balance risk with my level of expertise. Yet, it does not mean I don’t have any knowledge of how markets work.
Here is a visual of how to understand my risk appetite and navigate it. The diagram is not exactly accurate in numbers but gives me a general sense of where I stand. Based on this I chose the stop loss of -10%.
Code
# Install and load the fmsb packageif (!require(fmsb)) install.packages("fmsb")library(fmsb)# Define the categories and scorescategories <-c("Time Horizon & Reinvestment Risk", "Risk Perception", "Confidence in Decision-Making", "Investment/Trading Knowledge")# Create a data frame with scoresscores <-data.frame(`Time Horizon & Reinvestment Risk`=c(0, 10, 7), # Min, Max, Self-Assessment`Risk Perception`=c(0, 10, 9),`Confidence in Decision-Making`=c(0, 10, 4),`Investment/Trading Knowledge`=c(0, 10, 5))# Add row names for clarityrownames(scores) <-c("Min", "Max", "Self_Assessment")# Create the radar chartradarchart(scores, axistype =1, # Axis typepcol ="blue", # Line colorpfcol =rgb(0.1, 0.2, 0.8, 0.3), # Fill color with transparencyplwd =2, # Line widthcglcol ="grey", # Grid line colorcglty =1, # Grid line typecglwd =0.8, # Grid line widthvlcex =0.8# Label text size)title(main ="Risk Appetite Assessment", col.main ="darkblue", font.main =2, cex.main =1.5)
Model performance
The graph below (cumulative return on all permutations vs spreadweight) highlights that the model performs well on spread weight of 0.75 and above. As the baltic weight increase model performs better, and when baltic weight is zero the model is not as good. At this point we realize that choosing baltic index as a fundamental factor adds value to our model. Therefore, a risk profile of weightage on spread = 0.85 (Baltic = 0.15) will be used as a sample to check the spread of performance against other factors. Moreover, being more cautious we recognize that there are risks associated with such measurement and will also analyze what the risk looks like in more details.