Author

Aftikhar Mominzada

Code
digraph presentation_flow {
  graph [rankdir=LR,
         fontname="Helvetica",
         nodesep=0.3,
         ranksep=0.2,
         bgcolor="#F8F9FA",
         splines=ortho]

  node [shape=rectangle,
        style="filled",
        fillcolor="#E9ECEF",
        fontsize=11,
        width=2.8,
        height=0.9,
        margin=0.1]

  // Compact node definitions
  intro [label="Introduction\nStrategy Overview", fillcolor="#B3E5FC"]
  data [label="Data Preparation\nSources & Preprocessing", fillcolor="#C8E6C9"]
  analysis [label="Analysis\nADF Tests & Modeling", fillcolor="#C8E6C9"]
  risk [label="Risk Management\nStop-loss Mechanisms", fillcolor="#C8E6C9"]
  results [label="Results\nMetrics & Backtesting", fillcolor="#C8E6C9"]
  limitations [label="Limitations &\nImprovements", fillcolor="#FFECB3"]
  conclusion [label="Conclusion\nFindings & Future Work", fillcolor="#B3E5FC"]
  learning [label="Learning Outcomes\nKey Insights", fillcolor="#FFECB3"]

  // Main horizontal flow
  intro -> data -> analysis -> risk -> results -> limitations -> conclusion -> learning [weight=10]

  // Vertical relationships for compact layout
  {rank=same; intro}
  {rank=same; data analysis risk results}
  {rank=same; limitations conclusion learning}
  
  // Shortened edge paths
  edge [color="#666666", penwidth=1, arrowsize=0.7]
}

presentation_flow intro Introduction Strategy Overview data Data Preparation Sources & Preprocessing intro->data analysis Analysis ADF Tests & Modeling data->analysis risk Risk Management Stop-loss Mechanisms analysis->risk results Results Metrics & Backtesting risk->results limitations Limitations & Improvements results->limitations conclusion Conclusion Findings & Future Work limitations->conclusion learning Learning Outcomes Key Insights conclusion->learning

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 data
futures_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 datasets
corr_data <- baltic_data %>% 
  inner_join(futures_data02 %>% mutate(Date = date), by = "Date") %>% select(Date, change, return_spreadF)

# Fit the linear regression model
lm_model <- lm(return_spreadF ~ change, data = corr_data)




# Plot the data points
library(ggplot2)
spread_changeplot <- ggplot(corr_data, aes(x = change, y = return_spreadF)) +
  geom_point(color = "blue") +  # scatter plot of data points
  geom_smooth(method = "lm", color = "red", se = FALSE) +  # regression line
  labs(title = "Linear Regression: Return SpreadF vs Change",
       x = "Change",
       y = "Return SpreadF") +
  theme_minimal()
Code
url02 <- "https://raw.githubusercontent.com/Aftikharmnz/Baltic_test01/refs/heads/main/Baltic_test%20data_2018_2020.csv"
url02_data <- read.csv(url02)[, c(1, 2)]

# Convert the Date column to Date format
library(dplyr)
comparsion_test <- url02_data %>%
  mutate(Date = as.Date(Date, "%m/%d/%Y"),
         change = Price/lag(Price)-1)


comparsion_test <- comparsion_test %>% mutate(normalized_z_index_baltic_test = scale(change, center = TRUE, scale = TRUE)) %>% arrange(as.Date(Date))


s_date_test <- "2018-01-01"
CLF_test <- RTL::getPrice(
  feed = "CME_NymexFutures_EOD_continuous",
  contract = "CL_001_Month",
  from = s_date_test,
  iuser = iuser,
  ipassword = ipassword
) %>% RTL::rolladjust(
  commodityname = c("cmewti"),
  rolltype = c("Last.Trade")
)


BRNF_test <- RTL::getPrice(
  feed = "ICE_EuroFutures_continuous",
  contract = "BRN_001_Month",
  from = s_date_test,
  iuser = iuser,
  ipassword = ipassword
) %>% RTL::rolladjust(
  commodityname = c("icebrent"),
  rolltype = c("Last.Trade"))
  
futures_data_test <- BRNF_test %>% inner_join(CLF_test, by ="date")
names(futures_data_test)[c(2, 3)] <- c("BRNF_test", "CLF_test")
Code
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 orientation
      x = 0.5,              # Center the legend
      y = -0.2,             # Position below the chart
      xanchor = 'center',   # Align horizontally to the center
      yanchor = '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
------------------------------------------
Test Statistic:  -13.303 
Critical Values (tau3):
  1%:  -3.96 
  5%:  -3.41 
 10%:  -3.12 
Null Hypothesis: Series is not stationary
Alternative Hypothesis: Series is stationary
P-Value (Confidence level 99%):  < 0.01 (reject null) 
P-Value (Confidence level 95%):  < 0.05 (reject null) 
P-Value (Confidence level 90%):  < 0.10 (reject null) 
Augmented Dickey-Fuller (ADF) Test Results for Baltic Dirty Tanker index changes
-----------------------------------------------------------
Test Statistic:  -13.926 
Critical Values (tau3):
  1%:  -3.96 
  5%:  -3.41 
 10%:  -3.12 
Null Hypothesis: Series is not stationary
Alternative Hypothesis: Series is stationary
P-Value (confidence level 99%):  < 0.01 (reject null) 
P-Value (Confidence level 95%):  < 0.05 (reject null) 
P-Value (Confidence level 90%):  < 0.10 (reject null) 

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.

Code
plot_stationaty
Code
plott0011

Composite indicator1

\[ \text{composite\_indicator} = (\text{spreadweight} \times \text{normalized\_z\_detrended\_spreadF}) + (\text{balticweight} \times \text{normalized\_z\_index\_baltic}) \]

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
digraph G {

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_Thresholds
  Check_Thresholds -> Buy_Signal [label="Indicator < par2value"]
  Check_Thresholds -> Sell_Signal [label="Indicator > par1value"]
  Check_Thresholds -> Hold [label="Within Thresholds"]
  Buy_Signal -> Stop_Loss_Check
  Sell_Signal -> Stop_Loss_Check
  Stop_Loss_Check -> Close_Position [label="Loss > Threshold"]
  Stop_Loss_Check -> Trade_Execution [label="No Stop Loss Triggered"]
  Close_Position -> Trade_Cost
  Trade_Execution -> Trade_Cost
  Trade_Cost -> Position_Update -> End
  Hold -> End
  

}

G Trading Strategy Decision Flowchart: Signal Generation and Execution Logic Start Start: Input Composite Indicator Detrend Detrending and Normalizing Data Start->Detrend Check_Thresholds Check Composite Indicator vs. Thresholds: par1value (Upper), par2value (Lower) Detrend->Check_Thresholds Buy_Signal Buy Signal: Indicator < par2value (Lower Threshold) Check_Thresholds->Buy_Signal Indicator < par2value Sell_Signal Sell Signal: Indicator > par1value (Upper Threshold) Check_Thresholds->Sell_Signal Indicator > par1value Hold Hold Signal: par2value ≤ Indicator ≤ par1value Check_Thresholds->Hold Within Thresholds Stop_Loss_Check Stop Loss Check: Is Loss > Threshold? Buy_Signal->Stop_Loss_Check Sell_Signal->Stop_Loss_Check End End Hold->End Close_Position Close Position: Stop Loss Triggered Stop_Loss_Check->Close_Position Loss > Threshold Trade_Execution Trade Execution: Buy/Sell Spread at Next Open Stop_Loss_Check->Trade_Execution No Stop Loss Triggered Trade_Cost Adjust for 1% Trade Charge Close_Position->Trade_Cost Trade_Execution->Trade_Cost Position_Update Update Position: Long, Short, Flat Trade_Cost->Position_Update Position_Update->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 changes
      composite_indicator = spreadweight * normalized_z_detrended_spreadF + balticweight * normalized_z_index_baltic,
      
      # Generate trade signals based on the composite indicator
      signal = dplyr::case_when(
        composite_indicator < par2value ~ 1,  # Buy signal
        composite_indicator > par1value ~ -1, # Sell signal
        TRUE ~ 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 trades
      ret_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 trades
    ret = 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 changes
      composite_indicator = spreadweight * normalized_z_detrended_spreadF + balticweight * normalized_z_index_baltic,
      
      # Generate trade signals based on the composite indicator
      signal = dplyr::case_when(
        composite_indicator < par2value ~ 1,  # Buy signal
        composite_indicator > par1value ~ -1, # Sell signal
        TRUE ~ 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 executed
      entry_price = ifelse(trade != 0, spreadF, NA),
      
      # Initialize a column to track the stop-loss condition
      stop_loss_triggered = FALSE,
      
      # For each trade, checking if stop-loss is triggered
      stop_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 logic
      adjusted_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 trades
      ret = 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

  1. Statistical Validity: spread is stationary and mean-reverting; hence the strategy aligns perfectly with statistical arbitrage principles (cointegration & mean reversion).

  2. Risk Mitigation: Spread trading reduces systemic risk and focuses on relative value, making it more robust in volatile oil market. (risk neutral hedging).

  3. Simplicity and Execution: Trading the spread simplifies both modeling and execution, while still capturing the trading edge identified by this model.

  4. 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:

  1. Time Horizon & Reinvestment Risk: A short reversion (within a week) poses minimal risk, while longer durations increase the opportunity cost of better returns elsewhere.

  2. 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

  3. 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?

  4. 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 package
if (!require(fmsb)) install.packages("fmsb")
library(fmsb)

# Define the categories and scores
categories <- c("Time Horizon & Reinvestment Risk", 
                "Risk Perception", 
                "Confidence in Decision-Making", 
                "Investment/Trading Knowledge")

# Create a data frame with scores
scores <- 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 clarity
rownames(scores) <- c("Min", "Max", "Self_Assessment")

# Create the radar chart
radarchart(scores, 
           axistype = 1,             # Axis type
           pcol = "blue",            # Line color
           pfcol = rgb(0.1, 0.2, 0.8, 0.3), # Fill color with transparency
           plwd = 2,                 # Line width
           cglcol = "grey",          # Grid line color
           cglty = 1,                # Grid line type
           cglwd = 0.8,              # Grid line width
           vlcex = 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.

Understanding the risk!

Distributions with low lower_standard_deviations are concentrated around zero, with negative skewdness , as seen in the graph below. Even if a model shows high returns with a low lower_standard_deviation (as in two dimensional dotted plot for spreadweight you just saw), it does not guarantee consistency due to the concentration of returns around zero and low-probability of extremely high returns, which can distort the Omega ratio (which we will show in a bit). For instance, when weight of baltic is 0.4 and lower_STD is -1 there is a very high positive return with low probability. That will distort omega ratio. For this model the focus/interest is on positively skewed distributions that has higher lower_standard_deviations to improve risk-adjusted performance.

Low Consistency and High Volatility: The presence of rare, large positive returns (even if they are substantial in magnitude) introduces a disproportionate positive skew in the performance data, leading to an overstatement of the strategy’s long-term profitability. However, the strategy may suffer from low consistency in more typical scenarios, with returns clustered around zero or exhibiting negative skewness. This can result in a high risk of drawdowns despite an attractive Omega ratio (which will be discussed next). Specially in ideal case when the underlying returns are concentrated around a narrow range such as peaked distributions (on positive returns) with negative skewness (on negative returns). The distribution is indicative of high concentration risk rather than true efficiency in the strategy. Not recognizing it and using ordinary risk measuring tools like Sharpe (or even omega) can bring the elephant to the kneels. Coincidentally, this particular model is dealing with exact issue.

Without looking at this graph spread weight 0.8 (Baltic weight 0.2) seems to provide the most desirable return profile with lower downside and higher upside return. However we know that standard deviations below -1 has very low probability high/low returns distribution (you will see next how and why). Omega ratio cannot detect it because it is essentially the sum of all profits divided by the sum of all losses given a threshold (in our case 4%). There might be a very high return occuring onces (ie with spreadweigh of 0.7) that would dwarf smaller consistent losses, and from a risk management perspective we do not want that. With this knowledge we can make more informed decision.

Statistical Sensitivity and Misleading Optimism: The Omega ratio’s sensitivity to tail events means that strategies relying on outlier-driven returns will distort the signal-to-noise ratio of risk-adjusted performance. In practical terms, this could indicate that a strategy is superior in terms of its risk-adjusted return, when, in fact, it is simply relying on an asymmetric payoff structure that is unlikely to be repeatable. The risk of non-normality and heavy tails often leads to incorrect inferences about the distribution’s predictability and sustainability. All the omega ratios have attractive returns that are associated with very large up/down STD (mostly +-2) and we just learned that returns with large STD are not consistent.

Omega Ratio and Distribution Shape: The Omega ratio does not account for the underlying shape of the return distribution, focusing primarily on the ratio of gains above a defined threshold relative to losses below it. As such, it remains agnostic to the frequency and magnitude of these gains and losses, and is particularly vulnerable to distortion from heavy tails or outliers in the return distribution.

Impact of Rare Outliers: In scenarios where the return distribution is highly skewed with a small probability of extreme positive outliers (i.e., Pareto-like tail behavior), the Omega ratio can be disproportionately influenced by these rare occurrences. This results in a hyperbolic inflation of the ratio, which masks the volatility clustering and mean reversion that may characterize the bulk of the return series. Consequently, a strategy exhibiting such distributional characteristics may appear far more attractive in terms of the Omega ratio than it is in practice, as the ratio is driven by non-representative events that are not indicative of the strategy’s day-to-day performance.
So what to do? lets move next –>

Code
heatmap_omega

Code
fig02

Therefore, direct comparison of the risk profile (VaR) as a function of Lower_STD for varying spreadweight allocations is required. This helps in identifying how different spreadweights, such as 0.8, which seemed optimal initially, perform under different levels of risk. Refering to the graph below, one thing that is consistent is with lower lower_STD (mostly around -1) the var is consistent across different spreadweights at that level of STD. In other words if I have a constraint of lower_STD ~ -1 I might do well but if stuff hit the fun I will be the one ending up shirtless. The reason there is no var for values of lower_STD below -1 is because in those scenario the model did not execute any trade, and for the ones which it did trade (ie lower_STD -1) it had a disastrous loss.
Therefore, an analysis involved considering both VaR and Omega we can conclude that a spread-weight 0.5 var at 90% CI with lower_STD of -0.7, Upper_STD 1.3 looks appropriate. Next we will test it in other dataset.

Code
VaR_plot_facet

From the graphs below, with the chosen constraint, first is the test model, and second is the training model.

How good is my returns? Could I rely on them?

Code
strategy_test <- 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 changes
      composite_indicator = spreadweight * normalized_z_detrended_spreadF + balticweight * normalized_z_index_baltic,
      
      # Generate trade signals based on the composite indicator
      signal = dplyr::case_when(
        composite_indicator < par2value ~ 1,  # Buy signal
        composite_indicator > par1value ~ -1, # Sell signal
        TRUE ~ 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 executed
      entry_price = ifelse(trade != 0, spreadF, NA),
      
      # Initialize a column to track the stop-loss condition
      stop_loss_triggered = FALSE,
      
      # For each trade, checking if stop-loss is triggered
      stop_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 logic
      adjusted_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 trades
      ret = ret_new + ret_exist,
      ret = ret - abs(adjusted_trade) * 0.01  # Transaction cost
    )
  
  # Calculate cumulative equity (cumulative product of total returns)
  final_test <- withposition %>%
    dplyr::mutate(
      cumeq = cumprod(1 + ret)  # Cumulative return
    )
  
  return(final_test)
}

# Create a new dataset with cleaned column names for final_test
final_clean <- strategy_test(train)  # Assuming you use 'train' here
colnames(final_clean) <- gsub("_test$", "", colnames(final_clean))  # Clean column names

# Detect available cores and register the parallel cluster
n_cores <- detectCores() - 1
cl <- makeCluster(n_cores)
registerDoParallel(cl)

# Define the updated optimize_strategy function for `final_test`
optimize_strategy_test <- function(test_data, par1value, par2value, spreadweight, balticweight) {
  
  balticweight <- 1 - spreadweight
  # Run the strategy with the given parameters
  results_test <- strategy_test(test_data, 
                      par1value = par1value, 
                      par2value = par2value, 
                      spreadweight = spreadweight, 
                      balticweight = balticweight)
  
  # Calculate the performance metric (e.g., final cumulative equity)
  performance <- last(results_test$cumeq)  # Assuming 'cumeq' is the final cumulative equity column
  
  return(performance)
}

# Set parameter grid for optimization
par1value <- seq(from = 0, to = 2, by = 0.05)
par2value <- seq(from = -2, to = 0, by = 0.05)
spreadweight <- seq(from = 0.05, to = 1, by = 0.05)
param_grid <- expand.grid(spreadweight = spreadweight, par2value = par2value, par1value = par1value)

# Parallel optimization using foreach for the `final_test` dataset
results_test <- foreach(i = 1:nrow(param_grid), .combine = rbind, .packages = c("dplyr", "tidyr")) %dopar% {
  par1 <- param_grid$par1value[i]
  par2 <- param_grid$par2value[i]
  spreadweight <- param_grid$spreadweight[i]
  
  # Run strategy and evaluate performance on `final_test`
  performance <- optimize_strategy_test(final_clean, 
                                        par1value = par1, 
                                        par2value = par2, 
                                        spreadweight = spreadweight)
  
  # Return results as a data frame
  data.frame(par1value = par1, par2value = par2, spreadweight = spreadweight, performance = performance)
}

# Stop parallel cluster
stopCluster(cl)
  • Overall Percentage Win with set model constraints (during training phase): 56.25% — The strategy achieved positive returns in 56.25% of trades during the training phase. It’s a moderate success rate, indicating the strategy’s consistency during backtesting.

  • Overall Percentage Win with set model constraints (during test phase): 47.62% — This shows a lower success rate in the testing phase, meaning the strategy was less consistent in generating positive returns on unseen data.

Code
cat("Overall Percentage Win (Train):", overall_win_train, "%\n")
Overall Percentage Win (Train): 56.25 %
Code
cat("Overall Percentage Win (Test):", overall_win_test, "%\n")
Overall Percentage Win (Test): 48.83721 %
Code
library(PerformanceAnalytics)
library(xts)


train_returns <- xts(train_strategy$ret, order.by = train_strategy$Date)
test_returns <- xts(test_strategy$ret, order.by = test_strategy$Date)
Code
PerformanceAnalytics::chart.Drawdown(test_returns, main = "Drawdown Chart (Test Strategy)")

Code
PerformanceAnalytics::chart.Drawdown(train_returns, main = "Drawdown Chart (Train Strategy)")

The absence of consistent drawdowns in both senario can largely be attributed to the embedded stop-loss mechanism, which effectively mitigates the occurrence of significant losses. The relatively low frequency of large drawdowns in both scenarios indicates that the model carries a lower overall risk profile. In the training model, there is only one pronounced drawdown, but the subsequent recovery is swift, demonstrating the model’s resilience in the face of market shocks. From a personal risk tolerance perspective, such sharp fluctuations should not provoke significant emotional distress, as an investor, due to their relatively brief duration and the model’s ability to recover quickly.

Potential Weaknesses and Limitations:

  1. Sensitivity to Model Parameters:
    The effectiveness of the composite indicator depends on the weighting of the spread and Baltic index components. Inappropriate weighting may overemphasize one factor, leading to suboptimal signals. Linear trend-line for spread and baltic changes attached in appendix for reference.

  2. Impact of Roll Adjustments:
    Using front-month continuous contracts adjusted for rollovers introduces a risk of data distortions, particularly during periods of sharp contango or backwardation in the futures curve. These distortions can affect the stationarity assumptions.

  3. Assumption of Constant Market Dynamics:
    The model assumes that the fundamental relationship between Brent and WTI prices and their drivers remains consistent over time. Structural changes in market conditions, such as shifts in global oil production or changes in transportation infrastructure, may weaken the model’s predictive power. In other words the decay rate of models of such type is very high, compared to multidimensional pure factor models.

    Opportunities for Future Improvement:

    1. Incorporate Volatility Adjustments:
      Adding a volatility-adjusted measure to the composite indicator could help account for periods of market stress, providing additional safeguards against unexpected deviations.

    2. Expand Fundamental Drivers:
      Including other fundamental factors such as refinery demand, storage capacity, or geopolitical risk indices could enhance the explanatory power of the model.

    3. Backtesting Across Multiple Regimes:
      Conducting extensive backtesting across various market regimes (e.g., high volatility, geopolitical tensions) would provide deeper insights into the model’s resilience and adaptability.

    4. Dynamic Weighting Mechanism:
      A dynamic adjustment of the weights for the spread and Baltic index components could allow the model to better adapt to changing market conditions, improving performance over time.

Conclusion:

The developed pairs trading strategy for the Brent-WTI spread demonstrates a robust and disciplined framework for statistical arbitrage. Its strengths lie in combining stationarity-based statistical insights with fundamental market driver, providing a reliable foundation for mean-reversion trades. The built-in risk management framework—comprising stop-loss mechanisms, and continuous monitoring—further enhances its practical applicability by safeguarding against excessive losses.

However, the model has limitations, including sensitivity to parameter choices, exclusion of exogenous risks, and potential vulnerability to structural market changes. Future iterations could address these challenges by incorporating dynamic weighting, volatility adjustments, and expanded fundamental drivers.

Ultimately, the model is a powerful tool for market-neutral trading in the crude oil market, capable of capturing short-term mispricings while maintaining a strong focus on capital preservation and disciplined risk management.

Learning Outcomes

1. Understanding Risk Appetite

I developed a nuanced understanding of risk appetite, recognizing its critical role in shaping trading strategies and decision-making. This involved identifying tolerance levels and aligning them with practical, executable logic.

2. Codifying Logic and Testing with Historical Data

The process of transforming my views into a structured logic code and testing it with historical data was an enriching yet challenging experience. Algorithmic trading fundamentally revolves around codifying logic into a specific set of processes while adhering to well-defined constraints. Through this, I realized that designing a robust solution is as intellectually demanding as coding it. The iterative process was often frustrating but ultimately rewarding, as it deepened my understanding of systematic problem-solving.

3. Iterative Development Approach

Rather than attempting to build the entire model at once, I adopted an iterative approach akin to agile methodology. I started with a simple foundation and progressively added features as needed, such as incorporating transaction costs, and using both var with omega rather than only omega. This approach allowed for continuous refinement, where elements that made sense were retained, while others were removed or adjusted based on outcomes.

4. Organizing Code for Efficiency

One of the key lessons I learned was the importance of organizing code effectively. Much like maintaining an orderly house, having well-structured and logically arranged code significantly reduces time spent searching for functions or debugging issues. This practice enhanced my productivity and allowed me to focus more on improving the model’s performance.

5. Understanding Risk Appetite

I developed a nuanced understanding of risk appetite, recognizing its critical role in shaping trading strategies and decision-making. This involved identifying tolerance levels and aligning them with practical, executable logic.

6. Understanding Key Metrics: Omega and VaR

This project introduced me to financial risk metrics such as Omega and Value at Risk (VaR), both of which I had not utilized before. Exploring these metrics enhanced my understanding of their applications in evaluating the performance and risk of trading strategies.

Appendix

Code
spread_changeplot

Footnotes

  1. linear regression graph of the two indicators attached in appendix↩︎

  2. Spread weight + baltic weight = 1↩︎