Commodity carry as a trading signal #

This notebook serves as an illustration of the points discussed in the post “Commodity carry as a trading signal” available on the Macrosynergy website.

Commodity futures carry contains information on implicit subsidies, such as convenience yields and hedging premia. It becomes a valid trading signal when incorporating some adjustments for inflation, seasonal effects, and volatility. There has been strong evidence for predictive power of carry with respect to subsequent futures returns for a broad panel of 23 commodities from 2000 to 2023. Furthermore, stylized naïve PnLs based on carry signals point to material economic value, either on its own or whenever managing long commodity exposure. The predictive power and value generation of carry signals seems to be even stronger for relative cross-commodity trades.

This notebook provides the essential code required to replicate the analysis discussed in the post.

The notebook covers the three main parts:

  • Get Packages and JPMaQS Data: This section is responsible for installing and importing the necessary Python packages that are used throughout the analysis.

  • Transformations and Checks: In this part, the notebook performs calculations and transformations on the data to derive the relevant signals and targets used for the analysis, including normalization of feature variables using z-score or building a simple linear composite indicator for commodities futures returns.

  • Value Checks: This is the most critical section, where the notebook calculates and implements the trading strategies based on the hypotheses tested in the post. This section involves backtesting a few simple, but powerful trading strategies targeting. The primary focus is on three key propositions:

    • The viability of real commodity carry as a predictor for subsequent outright commodity futures returns.

    • The effectiveness of relative commodity carry in predicting subsequent global relative commodity futures returns.

    • The reliability of relative commodity carry as a predictor for subsequent intra-group relative commodity futures returns.

It’s important to note that while the notebook covers a selection of indicators and strategies used for the post’s main findings, there are countless other possible indicators and approaches that can be explored by users. Users can modify the code to test different hypotheses and strategies based on their own research and ideas. Best of luck with your research!

Get packages and JPMaQS data #

This notebook primarily relies on the standard packages available in the Python data science stack. However, there is an additional package macrosynergy that is required for two purposes:

  • Downloading JPMaQS data: The macrosynergy package facilitates the retrieval of JPMaQS data, which is used in the notebook.

  • For the analysis of quantamental data and value propositions: The macrosynergy package provides functionality for performing quick analyses of quantamental data and exploring value propositions.

For detailed information and a comprehensive understanding of the macrosynergy package and its functionalities, please refer to the “Introduction to Macrosynergy package” notebook on the Macrosynergy Quantamental Academy or visit the following link on Kaggle .

# Uncomment below for new downloads or package updates
"""
%%capture
! pip install macrosynergy --upgrade"""
'\n%%capture\n! pip install macrosynergy --upgrade'
import numpy as np
import pandas as pd
from pandas import Timestamp
import matplotlib.pyplot as plt
import seaborn as sns

import warnings
import os

import macrosynergy.management as msm
import macrosynergy.panel as msp
import macrosynergy.signal as mss
import macrosynergy.pnl as msn
from macrosynergy.download import JPMaQSDownload

warnings.simplefilter("ignore")
# Commodity cross-section lists

cids_nfm = ["GLD", "SIV", "PAL", "PLT"]
cids_fme = ["ALM", "CPR", "LED", "NIC", "TIN", "ZNC"]
cids_ene = ["BRT", "WTI", "NGS", "GSO", "HOL"]
cids_sta = ["COR", "WHT", "SOY", "CTN"]
cids_mis = ["CFE", "SGR", "NJO", "CLB"]

cids = cids_nfm + cids_fme + cids_ene + cids_sta + cids_mis

comm_groups = {
    "PRM": ["GLD", "SIV", "PAL", "PLT"],  # Precious metals
    "INM": ["ALM", "CPR", "LED", "NIC", "TIN", "ZNC"], # Industrial metals
    "ENE": ["BRT", "WTI", "NGS", "GSO", "HOL"],  # Energy
    "GRA": ["COR", "WHT", "SOY", "CTN"],  # Grains
    "SOF": ["CFE", "SGR", "NJO", "CLB"]
}
# Category tickers
main = [
    # nominal carry
    "COCRY_NSA", "COCRY_VT10", "COCRY_SA", "COCRY_SAVT10", 
    # real carry
    "COCRR_NSA", "COCRR_VT10", "COCRR_SA", "COCRR_SAVT10"
]
econ = []
mark = ["COXR_NSA", "COXR_VT10"]

xcats = main + econ + mark

# Resultant tickers

xtix = ["USD_EQXR_NSA", "GLB_DRBXR_NSA"]

tickers = [cid + "_" + xcat for cid in cids for xcat in xcats] + xtix
print(f"Maximum number of tickers is {len(tickers)}")
Maximum number of tickers is 232

The description of each JPMaQS category is available under Macro quantamental academy , or JPMorgan Markets (password protected). For tickers used in this notebook see Commodity future carry , Commodity future returns , Equity index future returns (USD) , and The global directional risk basket (GLB) .

# Download series from J.P. Morgan DataQuery by tickers

start_date = "1995-01-01"

# Retrieve credentials

client_id: str = os.getenv("DQ_CLIENT_ID")
client_secret: str = os.getenv("DQ_CLIENT_SECRET")

# Download from DataQuery

with JPMaQSDownload(client_id=client_id, client_secret=client_secret) as downloader:
    dfd = downloader.download(
        tickers=tickers,
        start_date=start_date,
        metrics=["value",],
        suppress_warning=True,
        show_progress=True,
    )
Downloading data from JPMaQS.
Timestamp UTC:  2024-03-21 12:58:08
Connection successful!
Requesting data: 100%|██████████| 12/12 [00:02<00:00,  4.93it/s]
Downloading data: 100%|██████████| 12/12 [00:13<00:00,  1.08s/it]
Some dates are missing from the downloaded data. 
3 out of 7626 dates are missing.
dfx = dfd.copy().sort_values(["cid", "xcat", "real_date"])
dfx.info()
<class 'pandas.core.frame.DataFrame'>
Index: 1574676 entries, 158531 to 1574675
Data columns (total 4 columns):
 #   Column     Non-Null Count    Dtype         
---  ------     --------------    -----         
 0   real_date  1574676 non-null  datetime64[ns]
 1   cid        1574676 non-null  object        
 2   xcat       1574676 non-null  object        
 3   value      1574676 non-null  float64       
dtypes: datetime64[ns](1), float64(1), object(2)
memory usage: 60.1+ MB

Availability #

It is important to assess data availability before conducting any analysis. It allows to identify any potential gaps or limitations in the dataset, which can impact the validity and reliability of analysis and ensure that a sufficient number of observations for each selected category and cross-section is available as well as determining the appropriate time periods for analysis.

msm.check_availability(dfx, xcats=xcats, cids=cids, missing_recent=False)
https://macrosynergy.com/notebooks.build/trading-factors/commodity-carry-as-a-trading-signal/_images/815051dbe75c1f5c4d2ec5a8646205e1e0a5b0d5b2ae33a7e4ccfeb29ad2adc6.png

Transformation and checks #

Features #

Nominal carry #

Futures carry displays extreme differences in variation across commodities. Natural gas, gasoline, and soy display large volatility, whereas easily storable precious metals, particularly palladium, gold, and silver, show very low variation. Here, we are displaying COCRY_NSA , Nominal commodity future carry. This indicator comes straight from JPMaQS and does not require any transformations. Convenient function .view_ranges() displays a barplot with historical means and standard deviations of the indicator from the chosen date (2000)

xcatx = ["COCRY_NSA"]
sdate = "2000-01-01"

msp.view_ranges(
    dfx,
    cids=cids,
    xcats=xcatx,
    kind="bar",
    sort_cids_by="std",
    title=f"Means and standard deviations of commodity futures carry since {sdate}",
    start=sdate,
    legend_bbox_to_anchor=(1, 1)
)
https://macrosynergy.com/notebooks.build/trading-factors/commodity-carry-as-a-trading-signal/_images/a60d7136c1dc1898b8136f3843e0394ed5c406572fb7cfc56b13d1750cdbe565.png

Below are the actual means of futures carry across the panel of 25 commodities and the mean across all values:

filt =( dfx["xcat"] == "COCRY_NSA") & (dfx['real_date'] > "2000-01-01")
dfw = (
    dfx[filt]
    .pivot_table(index="real_date", columns="cid", values="value")
    .replace(0, np.nan)
)
display(dfw.mean().sort_values(ascending=False))
display(dfw.mean().mean())
cid
GSO    11.076610
SOY     7.517636
SGR     3.568312
TIN     1.603071
NIC     0.833597
HOL     0.665130
PLT     0.177149
CPR    -0.318347
WTI    -0.393550
PAL    -0.935151
LED    -0.987825
BRT    -1.372177
GLD    -2.320359
SIV    -2.482233
ZNC    -2.587650
NJO    -2.648748
CTN    -4.113172
CLB    -4.122368
ALM    -4.154135
NGS    -4.954269
COR    -6.373901
CFE   -10.225343
WHT   -11.093605
dtype: float64
-1.4626664769309723

Real carry #

The line plot below shows the development of nominal and real carry across all 25 commodities with the help of customized function view_timelines() from the macrosynergy package. The differences between real and nominal measures are mostly minor but notable for commodities with low carry variance. Conveniently, the Real commodity future carry indicator "COCRR_NSA" is also directly obtainable from JPMaQS alongside nominal version “COCRY_NSA”

xcatx = ["COCRY_NSA", "COCRR_NSA"]

msp.view_timelines(
    dfx,
    xcats=xcatx,
    cids=cids,
    ncol=4,
    cumsum=False,
    start=sdate,
    same_y=False,
    all_xticks=True,
    title="Nominal and real commodity futures carry",
    title_fontsize=30,
    xcat_labels=["Nominal", "Real"],
    legend_fontsize=18,
    )
https://macrosynergy.com/notebooks.build/trading-factors/commodity-carry-as-a-trading-signal/_images/e6d375cd64b8ee7c5a7deb6fb46b3f29c961e4a567ee5dfa5742258ac36147ba.png

Inflation adjustment pushes the average panel carry since 2000 from negative to positive territory:

filt =( dfx["xcat"] == "COCRR_NSA") & (dfx['real_date'] > "2000-01-01")
dfw = (
    dfx[filt]
    .pivot_table(index="real_date", columns="cid", values="value")
    .replace(0, np.nan)
)
dfw.mean().mean()
0.8176231139133816

Commodity future carry is not highly correlated across markets, and hence, carry-based positions should tend to be more diversified. Below is a correlation matrix showing a rather weak correlation across most commodities with very few exceptions (such as between WTI and BRT). The correl_matrix() function from macrosynergy package visualizes Pearson correlation between commodity futures carry across different commodities:

msp.correl_matrix(
    dfx,
    xcats="COCRR_NSA",
    cids=cids,
    title="Cross-commodity correlation of real futures carry, since 2000",
    size=(12, 8),
    start=sdate,
    cluster=False
)
https://macrosynergy.com/notebooks.build/trading-factors/commodity-carry-as-a-trading-signal/_images/30efb963dd38d1c77378c3a28169ff72fa5258ab334a84343d6a561b8c9cc8a8.png

Volatility-adjusted carry #

The average daily return standard deviations of natural gas futures are roughly three times as large as those of gold. To mitigate this heteroscedasticity, one can calculate “volatility-targeted carry.” JPMaQS calculates such carry based on positions that are scaled to a 10% vol target based on a historical standard deviation of the commodity future returns for an exponential moving average with a half-life of 11 days. Positions are rebalanced at the end of each month. Conveniently the volatility adjusted carry is also available from JPMaQS COCRR_VT10 . As before, .view_ranges() displays a barplot with historical means and standard deviations of the indicator from the chosen date (2000)

xcatx = ["COCRR_VT10"]

msp.view_ranges(
    dfx,
    cids=cids,
    xcats=xcatx,
    kind="bar",
    sort_cids_by="std",
    title=f"Median, normal ranges and outliers of vol-targeted futures carry since {sdate}",
    start=sdate,
    legend_bbox_to_anchor=(1, 1)
)
https://macrosynergy.com/notebooks.build/trading-factors/commodity-carry-as-a-trading-signal/_images/530963e813658bb8ccf6c4b31fc00aa6f96a8641e1e82fe251d61250423cfa97.png

Volatility targeted real futures carry are displayed for comparison with the help of customized function view_timelines() from the macrosynergy package:

xcatx = ["COCRR_VT10",]

msp.view_timelines(
    dfx,
    xcats=xcatx,
    cids=cids,
    ncol=4,
    cumsum=False,
    start=sdate,
    same_y=False,
    all_xticks=True,
    title="Vol-targeted real commodity futures carry",
    title_fontsize=30,
    legend_fontsize=18,
    )
https://macrosynergy.com/notebooks.build/trading-factors/commodity-carry-as-a-trading-signal/_images/478b5fe9e8d8a07ae69c3f801c63f39867206a30bb0a87940404402e2c74535c.png

Seasonally adjusted carry #

Here, we display side-by-side non-seasonally adjusted real carry COCRR_NSA with seasonally adjusted version COCRR_SA , which also comes straight from JPMaQS and does not require any transformation. As before, the customized function view_timelines() from the macrosynergy package proves handy for side by side comparison of both indicators:

xcatx = ["COCRR_NSA", "COCRR_SA"]

msp.view_timelines(
    dfx,
    xcats=xcatx,
    cids=cids,
    ncol=4,
    cumsum=False,
    start=sdate,
    same_y=False,
    all_xticks=True,
    title="Real commodity futures carry (outright and seasonally adjusted)",
    title_fontsize=30,
    xcat_labels=["Outright", "Seasonally adjusted"],
    legend_fontsize=18,
    )
https://macrosynergy.com/notebooks.build/trading-factors/commodity-carry-as-a-trading-signal/_images/1f306d40fee5f9f1bcc7de015a26bdf010fad3a7982a8610cadc4b89565d9cb7.png

To display real-time estimated seasonal factors, we display the difference between seasonally adjusted and non-adjusted carry.

calcs = ["COCRY_ASF = COCRY_SA - COCRY_NSA",]
dfa = msp.panel_calculator(dfx, calcs=calcs, cids=cids)
dfx = msm.update_df(dfx, dfa)

msp.view_timelines(
    dfx,
    xcats="COCRY_ASF",
    cids=cids,
    ncol=4,
    cumsum=False,
    start=sdate,
    same_y=True,
    all_xticks=True,
    title="Additive seasonal factors of commodity futures carry (in % ar)",
    title_fontsize=30,
    legend_fontsize=18,
    )
https://macrosynergy.com/notebooks.build/trading-factors/commodity-carry-as-a-trading-signal/_images/4019c940edd25ccfd698e2382e40c91b5b27eaee27a08bc772af159045ede7f2.png

Normalized carry #

Normalizing values across different categories is a common practice in macroeconomics. This is particularly important when summing or averaging categories with different units and time series properties. Using macrosynergy's custom function make_zn_scores() we normalize the selected scores around neutral value (zero), using only past information. Re-estimation is done on monthly basis, and we use a minimum of 3 years data. We protect against outliers using 3 standard deviations as the threshold. The normalized indicators receive postfix _ZNI . Using pan_weight=0 , we use a particular cross-section, not the whole panel, for scaling.

crrs = ["COCRR_NSA", "COCRR_VT10", "COCRR_SA", "COCRR_SAVT10"]
xcatx = crrs

dfa = pd.DataFrame(columns=list(dfx.columns))
for xc in xcatx:
    dfaa = msp.make_zn_scores(
        dfx,
        xcat=xc,
        cids=cids,
        sequential=True,
        min_obs=261 * 3,  # minimum requirement is 3 years of daily data
        neutral="zero", 
        pan_weight=0,
        thresh=3,
        postfix="_ZNI",
        est_freq="m",
    )
    dfa = msm.update_df(dfa, dfaa)

dfx = msm.update_df(dfx, dfa)
xcatx = ["COCRR_SA_ZNI", "COCRR_SAVT10_ZNI"]

msp.view_timelines(
    dfx,
    xcats=xcatx,
    cids=cids,
    ncol=4,
    cumsum=False,
    start=sdate,
    same_y=True,
    all_xticks=True,
    title="Normalized real seasonally-adjusted futures carry (outright and vol-targeted)",
    title_fontsize=30,
    xcat_labels=["Outright", "Vol-targeted"],
    legend_fontsize=18,
    )
https://macrosynergy.com/notebooks.build/trading-factors/commodity-carry-as-a-trading-signal/_images/3f687d1734151ed04ae69793fe33140a37b661132a90ba68802fbfbcc31d548d.png

Relative carry metrics #

For the second hypothesis tested in this notebook (The effectiveness of relative commodity carry in predicting subsequent global relative commodity futures returns), we build global relative values of vol-targeted carry. The make_relative_value() function generates a data frame of relative values for a given list of categories. In this case, “relative” means that the original value is compared to a basket average. By default, the basket consists of all available cross-sections, and the relative value is calculated by subtracting the basket average from individual cross-section values. Here, we calculate relative vol-targeted adjusted and non-adjusted futures commodity real carry. The new time series receives postfix vGCO (versus Global Commodities).

xcatx= ['COCRR_VT10_ZNI', 'COCRR_SAVT10_ZNI', 'COCRR_VT10', 'COCRR_SAVT10']

dfa = msp.make_relative_value(
    dfx,
    xcats=xcatx,
    cids=cids,
    blacklist=None,
    rel_meth="subtract",
    rel_xcats=None,
    postfix="vGCO",
)

dfx = msm.update_df(dfx, dfa)

For the third hypothesis tested in this notebook (the reliability of relative commodity carry as a predictor for subsequent intra-group relative commodity futures returns), we build relative values of vol-targeted carry against their relative groups. We distinguish:

  • precious metals (gold, silver, palladium, and platinum),

  • base metals (aluminum, copper, lead, nickel, tin, and zinc),

  • fuels (Brent, WTI, natural gas, gasoline and heating oil),

  • U.S. corn belt crops (cotton, corn, soy and wheat), and

  • other agricultural commodities (coffee, sugar, orange juice, and lumber)

The new category receives postfix vRCO (vs Relative Commodities)

xcatx= ['COCRR_VT10_ZNI', 'COCRR_SAVT10_ZNI', 'COCRR_VT10', 'COCRR_SAVT10']

dfa = pd.DataFrame(columns=list(dfx.columns))
for new_cid, cid_group in comm_groups.items():
    dfaa = msp.make_relative_value(
        dfx,
        xcats=xcatx,
        cids=cid_group,
        blacklist=None,
        rel_meth="subtract",
        rel_xcats=None,
        postfix="vRCO",
    )
    dfa = msm.update_df(dfa, dfaa)

dfx = msm.update_df(dfx, dfa)

Targets #

Directional returns #

Commodity future returns "COXR_NSA" is available directly from JPMaQS. The means across commodities and standard deviations since 2000 are displayed below with .view_ranges() function from the macrosynergy package

xcatx = ["COXR_NSA"]

msp.view_ranges(
    dfx,
    cids=cids,
    xcats=xcatx,
    kind="bar",
    sort_cids_by="std",
    title=f"Means and standard deviations of commodity futures returns since {sdate}",
    start=sdate,
    legend_bbox_to_anchor=(1, 1)
)
https://macrosynergy.com/notebooks.build/trading-factors/commodity-carry-as-a-trading-signal/_images/cd2ec3cf430c7f1945314538fd8903b7552072fd5a4152fcc1939d0bcf15060d.png

The correlation matrix of commodity futures returns displays a positive correlation since 2000:

msp.correl_matrix(
    dfx,
    xcats="COXR_NSA",
    cids=cids,
    title=None,
    size=(12, 8),
    start=sdate,
    cluster=True
)
https://macrosynergy.com/notebooks.build/trading-factors/commodity-carry-as-a-trading-signal/_images/050b64ee6ef8f1f40c987dab83214f00d51dacbadde7afb4c9f51e78fefd29f5.png

Cumulative volatility-targeted commodity futures returns for all commodities can be quickly displayed with the help of the customized function view_timelines() from the macrosynergy package:

xcatx = ["COXR_VT10"]

msp.view_timelines(
    dfx,
    xcats=xcatx,
    cids=cids,
    ncol=4,
    cumsum=True,
    start=sdate,
    same_y=True,
    all_xticks=True,
    title="Vol-targeted commodity futures return, % cumulative",
    title_fontsize=30,
    legend_fontsize=18,
    )
https://macrosynergy.com/notebooks.build/trading-factors/commodity-carry-as-a-trading-signal/_images/dd4b3942c9324d01d57ed5bdd237facf35f869283b1e88d06d19a10eeceba8dd.png

Relative returns to entire commodity basket #

The make_relative_value() function generates a data frame of relative values for a given list of categories. In this case, “relative” means that the original value is compared to a basket average. By default, the basket consists of all available cross-sections, and the relative value is calculated by subtracting the basket average from individual cross-section values. Here, we calculate relative vol-targeted commodity future returns. The new time series receives postfix vGCO (versus Global commodities) - the same postfix as for relative futures commodity real carry calculated above.

xcatx = ["COXR_VT10"]

dfa = msp.make_relative_value(
    dfx,
    xcats=xcatx,
    cids=cids,
    blacklist=None,
    rel_meth="subtract",
    rel_xcats=None,
    postfix="vGCO",
)

dfx = msm.update_df(dfx, dfa)

Relative returns to specific commodity basket #

Another way to look at relative return is to look at it relative not to all commodities but to respective commodity groups. As distinct groups, we choose the following:

  • "PRM" - precious metals (gold, silver, palladium, and platinum),

  • "INM" - base metals (aluminum, copper, lead, nickel, tin, and zinc),

  • "ENE" - fuels (Brent, WTI, natural gas, gasoline and heating oil),

  • "GRA" - U.S. corn belt crops (cotton, corn, soy and wheat), and

  • "SOF" - other agricultural commodities (coffee, sugar, orange juice, and lumber).

Here, we calculate the relative return to the respective group, so if a commodity belongs to precious metals, we calculate the relative return to the average of the precious metals group, not to the average return of all commodities. The new category receives postfix vRCO (the same postfix as for relative intra-group commodities futures carry calculated above in this notebook)

xcatx = ["COXR_VT10"]
comm_groups = {
    "PRM": ["GLD", "SIV", "PAL", "PLT"],  # Precious metals
    "INM": ["ALM", "CPR", "LED", "NIC", "TIN", "ZNC"], # Industrial metals
    "ENE": ["BRT", "WTI", "NGS", "GSO", "HOL"],  # Energy
    "GRA": ["COR", "WHT", "SOY", "CTN"],  # Grains
    "SOF": ["CFE", "SGR", "NJO", "CLB"]
}

dfa = pd.DataFrame(columns=list(dfx.columns))
for new_cid, cid_group in comm_groups.items():
    dfaa = msp.make_relative_value(
        dfx,
        xcats=xcatx,
        cids=cid_group,
        blacklist=None,
        rel_meth="subtract",
        rel_xcats=None,
        postfix="vRCO",
    )
    dfa = msm.update_df(dfa, dfaa)
    
dfx = msm.update_df(dfx, dfa)

All three types of volatility targeted cumulative commodity returns:

  • “outright”,

  • “relative to global commodity average”, and

  • “relative to group commodity average”

are displayed for comparison with the help of customized function view_timelines() from the macrosynergy package:

xcatx = ["COXR_VT10", "COXR_VT10vGCO", "COXR_VT10vRCO"]

msp.view_timelines(
    dfx,
    xcats=xcatx,
    cids=cids,
    ncol=4,
    cumsum=True,
    start=sdate,
    same_y=True,
    all_xticks=True,
    title="Vol-targeted commodity futures return, % cumulative",
    title_fontsize=30,
    xcat_labels=["outright", "relative to global commodity average", "relative to group commodity average"],
    legend_fontsize=18,
    )
https://macrosynergy.com/notebooks.build/trading-factors/commodity-carry-as-a-trading-signal/_images/df93ab97301c0ed41abec74dbb6236b25c9d559897671d4ebc7c8bb8b9b7870f.png

Value checks #

In this part of the analysis, the notebook calculates the naive PnLs (Profit and Loss) for commodity future returns using commodity carry as signals. The PnLs are calculated based on simple trading strategies that utilize the carry as signals (no regression is involved). The strategies involve going long (buying) or short (selling) on commodity positions based purely on the direction of the carry.

To evaluate the performance of these strategies, the notebook computes various metrics and ratios, including:

  • Correlation: Measures the relationship between the changes in carry and consequent commodity returns. Positive correlations indicate that the strategy moves in the same direction as the market, while negative correlations indicate an opposite movement.

  • Accuracy Metrics: These metrics assess the accuracy of the confidence carry based strategies in predicting market movements. Common accuracy metrics include accuracy rate, balanced accuracy, precision etc.

  • Performance Ratios: Various performance ratios, such as Sharpe ratio, Sortino ratio, Max draws etc.

The notebook compares the performance of three simple carry-based strategies with the long-only performance of commodity futures.

The three strategies investigated in this notebook are:

  • Directional strategy: real commodity carry as a predictor for subsequent outright commodity futures returns.

  • Relative to global commodity basket: relative commodity carry as a predictor of subsequent global relative commodity futures returns.

  • Relative to specific commodity group baskets: relative commodity carry as a predictor for subsequent intra-group relative commodity futures returns.

It’s important to note that the analysis deliberately disregards transaction costs and risk management considerations. This is done to provide a more straightforward comparison of the strategies’ raw performance without the additional complexity introduced by transaction costs and risk management, which can vary based on trading size, institutional rules, and regulations.

Directional #

This part of the notebook aims to investigate the initial hypothesis regarding the predictive capability of commodity carry in anticipating future outright commodity returns.

Specs and panel test #

We establish a dictionary encompassing a list of potential signals for initial data analysis and ease of use. The primary signal under consideration is COCRR_SAVT10_ZNI , which represents normalized real seasonally-adjusted futures carry with volatility targeting. Other signals to be explored include non-seasonally adjusted carry and non-volatility targeted carry versions, as well as non-normalized signals. The targeted return here is COXR_VT10 , representing commodity future returns with volatility targeting.

sigs = [
    "COCRR_SAVT10_ZNI",
    "COCRR_SAVT10", 
    "COCRR_SA",
    "COCRR_SA_ZNI"
    "COCRR_NSA",
    "COCRR_VT10",
    "COCRR_VT10_ZNI",
    "COCRR_NSA_ZNI",
]

targ = "COXR_VT10"
start = "2000-01-01"


dict_dir = {
    "sigs": sigs,
    "targ": targ,
    "cidx": cids,
    "start": start,
    "black": None,
    "srr": None,
    "pnls": None,
}

Using function CategoryRelations() from macrosynergy package we visualize the relationship between the main signal and the target. The function allows aggregation (last value for signal and sum for target), monthly reestimation frequency, and lag of 1 month (i.e., we estimate the relationship between the signal and subsequent target and thus test the signal’s predictive power). As the signal, we use seasonally adjusted vol-targeted carry, normalized and windorized COCRR_SAVT10_ZNI and the target is commodity basket return COXR_VT10

cr = msp.CategoryRelations(
    dfx,
    xcats=[sigs[0], targ],
    cids=cids,
    freq="M",
    lag=1,
    xcat_aggs=["last", "sum"],
    fwin=1,
    start=sdate,
)
cr.reg_scatter(
    labels=False,
    coef_box="lower right",
    title = "Seasonally adjusted vol-targeted commodity carry and subsequent futures returns",
    xlab="Seasonally-adjusted vol-targeted carry, normalized and winsorized, %ar",
    ylab="Next month's vol-targeted futures return, %ar",
    prob_est="map",
)
https://macrosynergy.com/notebooks.build/trading-factors/commodity-carry-as-a-trading-signal/_images/0f901675e7bb35241c2e6532b815e7ac9d54c090c014afb7f3731a1b24117164.png

Accuracy and correlation check #

The SignalReturnRelations class from the macrosynergy.signal module is specifically designed to analyze, visualize, and compare the relationships between panels of trading signals and panels of subsequent returns.

sigs = [
    "COCRR_SAVT10_ZNI",
    "COCRR_SAVT10",
    "COCRR_SA",
    "COCRR_SA_ZNI",
    "COCRR_NSA",
    "COCRR_VT10",
    "COCRR_VT10_ZNI",
    "COCRR_NSA_ZNI"
]

srr = mss.SignalReturnRelations(
    dfx,
    cids=cids,
    sigs=sigs,
    rets=targ,
    freqs="M",
    start="2000-01-01",
)

The .summary_table() of the SignalReturnRelations class gives a short high-level snapshot of the strength and stability of the main signal relation (seasonally adjusted vol-targeted carry, normalized and windorized COCRR_SAVT10_ZNI ) and the target is commodity basket return COXR_VT10 .

display(srr.summary_table().astype("float").round(3))
accuracy bal_accuracy pos_sigr pos_retr pos_prec neg_prec pearson pearson_pval kendall kendall_pval auc
Panel 0.526 0.528 0.469 0.522 0.551 0.505 0.045 0.000 0.041 0.000 0.528
Mean years 0.529 0.535 0.466 0.521 0.564 0.506 0.049 0.332 0.044 0.329 0.533
Positive ratio 0.880 0.840 0.360 0.680 0.720 0.440 0.720 0.600 0.880 0.720 0.840
Mean cids 0.527 0.516 0.474 0.523 0.534 0.498 0.031 0.462 0.026 0.422 0.515
Positive ratio 0.870 0.783 0.522 0.696 0.696 0.435 0.696 0.391 0.652 0.522 0.783

multiple_relations_table() is a method that compares multiple signal-return relations in one table. It is useful to compare the performance of different signals against the same return series (more than one possible financial return) and multiple possible frequencies. The method returns a table with standard columns used for summary tables , but the rows display different signals from the list of signals specified under SignalReturnsRelations() sigs list. The row names indicate the frequency (‘D,’ ‘W,’ ‘M,’ ‘Q,’ ‘A’) followed by the signal’s and return’s names.

srr.multiple_relations_table().astype("float").round(3)
accuracy bal_accuracy pos_sigr pos_retr pos_prec neg_prec pearson pearson_pval kendall kendall_pval auc
M: COCRR_NSA/last => COXR_VT10 0.516 0.518 0.453 0.521 0.541 0.496 0.001 0.929 0.035 0.0 0.518
M: COCRR_NSA_ZNI/last => COXR_VT10 0.515 0.518 0.453 0.522 0.541 0.494 0.034 0.007 0.031 0.0 0.518
M: COCRR_SA/last => COXR_VT10 0.527 0.528 0.468 0.521 0.551 0.506 0.007 0.591 0.044 0.0 0.528
M: COCRR_SAVT10/last => COXR_VT10 0.527 0.528 0.468 0.521 0.551 0.506 0.019 0.130 0.046 0.0 0.528
M: COCRR_SAVT10_ZNI/last => COXR_VT10 0.526 0.528 0.469 0.522 0.551 0.505 0.045 0.000 0.041 0.0 0.528
M: COCRR_SA_ZNI/last => COXR_VT10 0.526 0.528 0.469 0.522 0.551 0.505 0.045 0.000 0.039 0.0 0.528
M: COCRR_VT10/last => COXR_VT10 0.516 0.518 0.453 0.521 0.541 0.496 0.011 0.385 0.037 0.0 0.518
M: COCRR_VT10_ZNI/last => COXR_VT10 0.515 0.518 0.453 0.522 0.541 0.494 0.035 0.005 0.033 0.0 0.518

The accuracy_bars method shows the accuracy and balanced accuracy of the predicted relationship for the main signal ( "COCRR_SAVT10_ZNI" ). This can be displayed either by cross-section or by year

srr.accuracy_bars(
    type="cross_section",
    title="Accuracy of monthly return prediction of the fully adjusted commodity carry, by market",
    size=(16, 6),
)
https://macrosynergy.com/notebooks.build/trading-factors/commodity-carry-as-a-trading-signal/_images/4b564612bc24241a4daf764633f2e77aecab29ac37fac0ede841927ca9f05043.png
srr.accuracy_bars(
    type="years",
    title="Accuracy of monthly return prediction of fully adjusted commodity carry, by year",
    size=(16, 6),
)
https://macrosynergy.com/notebooks.build/trading-factors/commodity-carry-as-a-trading-signal/_images/989805e2d008db62850a4290d75c3c5cb72853f4d535336ea28fb31006337713.png

Naive PnL #

We calculate stylized PnLs, i.e., dollar-based profit and loss developments over and above funding costs, according to standard rules applied in previous posts. This is done with macrosynergy ’s custom class NaivePnL . Upon instantiation, we define the list of signals, target variable ( COXR_VT10 ), list of cross-sections (all available in our case) and (optionally) benchmark (we choose here USD_EQXR_NSA , USD equity index futures returns and GLB_DRBXR_NSA - Directional risk basket returns)

Positions are re-calculated monthly at the end of the week and re-balanced in the following with a one-day slippage for trading time. The long-term volatility of the PnL for positions across all currency areas has been set to 10% annualized. This is no proper vol-targeting but mainly a scaling that makes it easy to compare different types of PnLs in graphs.

dix = dict_dir

targ = dix["targ"]

pnl = msn.NaivePnL(
    dfx,
    ret=targ,
    sigs=sigs,
    cids=cids,
    start="2000-01-01",
    bms=["USD_EQXR_NSA", "GLB_DRBXR_NSA"],
)

for sig in sigs:
    pnl.make_pnl(
        sig,
        sig_neg=False,
        sig_op="zn_score_pan",
        thresh=2,
        rebal_freq="monthly",
        vol_scale=10,
        rebal_slip=1,
        pnl_name=sig + "_PZN",
    )
    
pnl.make_long_pnl(vol_scale=10, label="Long only")
dix["pnls"] = pnl

Here we simply plot directional naive PnL alongside long only PnL:

dix = dict_dir

start = dix["start"]
cidx = dix["cidx"]
sig = [dix["sigs"]]

pnls = [sigs[0] + "_PZN"] + ["Long only"]
dict_labels = {"COCRR_SAVT10_ZNI_PZN": "Fully adjusted carry signal", 
               "Long only": "Long only, risk parity portfolio"}



pnl.plot_pnls(
    pnl_cats=pnls,
    pnl_cids=["ALL"],
    start=start,
    title="Naive commodity futures PnL across 23 markets",
    xcat_labels=dict_labels,
    figsize=(16, 8),
)
https://macrosynergy.com/notebooks.build/trading-factors/commodity-carry-as-a-trading-signal/_images/a65f431ccc8b4cc81e5af42ce4cb85213d38b3827922507882586fafa91ce878.png

To compare PnLs generated by all signals we define a simple dictionary assigning more readable names to the technical tickers:

dict_pnl = {
    "COCRR_SAVT10_ZNI_PZN": "Fully adjusted carry",
    "COCRR_SAVT10_PZN": "Seasonally-adjusted and vol-targeted real carry",
    
    "COCRR_VT10_ZNI_PZN": "Vol-targeted, normalized and winsorized real carry",
    "COCRR_VT10_PZN": "Vol-targeted real carry",
    
    "COCRR_SA_ZNI_PZN": "Seasonally-adjusted, normalized and winsorized real carry",
    "COCRR_SA_PZN": "Seasonally-adjusted real carry",
    
    "COCRR_NSA_ZNI_PZN": "Normalized and winsorized real carry",
    "COCRR_NSA_PZN": "Real carry",
    
    "Long only": "Long only, risk parity portfolio",
}
dix = dict_dir

start = dix["start"]
cidx = dix["cidx"]

naive_pnl = dix["pnls"]

pnls = [s + "_PZN" for s in sigs] 

naive_pnl.plot_pnls(
    pnl_cats=pnls,
    pnl_cids=["ALL"],
    start=start,
    title="Commodity futures naive PnLs for different versions of directional carry",
    figsize=(16, 8),
    xcat_labels=[dict_pnl[k] for k in pnls],
)
https://macrosynergy.com/notebooks.build/trading-factors/commodity-carry-as-a-trading-signal/_images/c52944d3c2ebf29429a1a5c85eb20cfedcb6da6a67a6fd36ed560138ec1c434f.png

The summary of key performance metrics for signals alongside “long only” PnL can be displayed with the method evaluate_pnls() , which returns a small dataframe of key PnL statistics.

dix = dict_dir

pnl = dix["pnls"]
pnls = [sig + type for sig in sigs for type in ["_PZN"]] + ["Long only"]

df_eval = pnl.evaluate_pnls(
    pnl_cats=pnls,
    pnl_cids=["ALL"],
    start="2000-01-01",
)
display(df_eval.transpose().astype("float").round(3))
Return (pct ar) St. Dev. (pct ar) Sharpe Ratio Sortino Ratio Max 21-day draw Max 6-month draw USD_EQXR_NSA correl GLB_DRBXR_NSA correl Traded Months
xcat
COCRR_NSA_PZN 3.372 10.0 0.337 0.471 -15.558 -17.855 -0.078 -0.030 291.0
COCRR_NSA_ZNI_PZN 2.637 10.0 0.264 0.367 -20.096 -26.103 -0.050 -0.001 291.0
COCRR_SAVT10_PZN 5.127 10.0 0.513 0.723 -14.850 -23.579 -0.035 0.008 291.0
COCRR_SAVT10_ZNI_PZN 4.172 10.0 0.417 0.586 -16.579 -24.183 -0.017 0.030 291.0
COCRR_SA_PZN 4.991 10.0 0.499 0.704 -14.578 -20.596 -0.052 -0.003 291.0
COCRR_SA_ZNI_PZN 3.840 10.0 0.384 0.539 -18.484 -25.676 -0.027 0.021 291.0
COCRR_VT10_PZN 3.585 10.0 0.359 0.501 -14.248 -22.505 -0.063 -0.026 291.0
COCRR_VT10_ZNI_PZN 2.856 10.0 0.286 0.399 -18.248 -24.575 -0.041 0.004 291.0
Long only 3.548 10.0 0.355 0.490 -20.316 -31.813 0.290 0.439 291.0

Naive long-biased PnL #

In this example, we add one standard deviation to the normalized carry signals of the naïve PnL generator, creating a long-biased strategy (this is done with the optional parameter sig_add , which adds a constant to the signal after initial transformation). This allows to give PnLs a long (if a constant is positive) or short bias (negative constant) relative to the signal score. We add here sig_add=1 to indicate one standard deviation.

dix = dict_dir

targ = dix["targ"]
cidx = dix["cidx"]

pnl = msn.NaivePnL(
    dfx,
    ret=targ,
    sigs=sigs,
    cids=cidx,
    start="2000-01-01",
    bms=["USD_EQXR_NSA", "GLB_DRBXR_NSA"],
)

for sig in sigs:
    pnl.make_pnl(
        sig,
        sig_neg=False,
        sig_op="zn_score_pan",
        sig_add=1,  # add a constant to the signal after the initial transformation. This allows to give PnLs a long or short bias relative to the signal score
        thresh=2,
        rebal_freq="monthly",
        vol_scale=10,
        rebal_slip=1,
        pnl_name=sig + "_PZN",
    )
    
pnl.make_long_pnl(vol_scale=10, label="Long only")
dix["pnls_long"] = pnl
dix = dict_dir

start = dix["start"]
cidx = dix["cidx"]

pnl = dix["pnls_long"] 

pnls = [s + "_PZN" for s in sigs] + ["Long only"]
pnl.plot_pnls(
    pnl_cats=pnls,
    pnl_cids=["ALL"],
    start=start,
    title="Commodity futures long-biased naive PnLs for different versions of directional carry",
    figsize=(16, 8),
    xcat_labels=[dict_pnl[k] for k in pnls],
)
https://macrosynergy.com/notebooks.build/trading-factors/commodity-carry-as-a-trading-signal/_images/2836562b49b441858d0dbc503489a4389cf19557e138a972b7b813541d56a019.png

The summary of key performance metrics for signals alongside “long only” PnL can be displayed with the method evaluate_pnls() , which returns a small dataframe of key PnL statistics.

dix = dict_dir

pnl = dix["pnls_long"]
pnls = [sig + type for sig in sigs for type in ["_PZN"]] + ["Long only"]

df_eval = pnl.evaluate_pnls(
    pnl_cats=pnls,
    pnl_cids=["ALL"],
    start="2000-01-01",
)
display(df_eval.transpose().astype("float").round(3))
Return (pct ar) St. Dev. (pct ar) Sharpe Ratio Sortino Ratio Max 21-day draw Max 6-month draw USD_EQXR_NSA correl GLB_DRBXR_NSA correl Traded Months
xcat
COCRR_NSA_PZN 4.577 10.0 0.458 0.630 -20.991 -26.165 0.224 0.384 291.0
COCRR_NSA_ZNI_PZN 4.187 10.0 0.419 0.572 -21.802 -27.316 0.211 0.362 291.0
COCRR_SAVT10_PZN 5.533 10.0 0.553 0.765 -24.379 -27.912 0.232 0.384 291.0
COCRR_SAVT10_ZNI_PZN 4.998 10.0 0.500 0.686 -24.728 -28.743 0.222 0.367 291.0
COCRR_SA_PZN 5.252 10.0 0.525 0.726 -24.464 -27.093 0.228 0.387 291.0
COCRR_SA_ZNI_PZN 4.808 10.0 0.481 0.659 -24.759 -27.597 0.216 0.364 291.0
COCRR_VT10_PZN 4.831 10.0 0.483 0.666 -20.753 -26.592 0.228 0.381 291.0
COCRR_VT10_ZNI_PZN 4.380 10.0 0.438 0.599 -21.174 -26.819 0.217 0.363 291.0
Long only 3.548 10.0 0.355 0.490 -20.316 -31.813 0.290 0.439 291.0

Relative to global commodity basket #

This part investigates the second type of trading strategy based on carry: relative carry values to the global commodity basket. For relative signals, we can reasonably only consider vol-targeted carry signals for the sake of making cross-commodity signal positions comparable. As a target we look at relative, vol-adjusted commodity future return COXR_VT10vGCO

Specs and panel test #

sigs = [cr + "vGCO" for cr in sigs if "VT10" in cr]

targ = "COXR_VT10vGCO"
cidx = cids
start = "2000-01-01"

dict_rel = {
    "sig": sigs,
    "targ": targ,
    "cidx": cidx,
    "start": start,
    "black": None,
    "srr": None,
    "pnls": None,
}
dix = dict_rel

sig = dix["sig"]
targ = dix["targ"]
cidx = dix["cidx"]

cr = msp.CategoryRelations(
    dfx,
    xcats=[sigs[0], targ],
    cids=cidx,
    freq="M",
    lag=1,
    xcat_aggs=["last", "sum"],
    fwin=1,
    start=dix["start"],
)
cr.reg_scatter(
    labels=False,
    coef_box="lower right",
    title = "Seasonally-adjusted vol-targeted carry and subsequent futures returns, relative to the entire commodity basket",
    xlab="Seasonally-adjusted vol-targeted carry, normalized and winsorized, relative to entire commodity set",
    ylab="Next month's vol-targeted futures return against global commodity basket",
    prob_est="map",
)
https://macrosynergy.com/notebooks.build/trading-factors/commodity-carry-as-a-trading-signal/_images/1a4ee137f16e6aaa26c6207abf632a22c341f710cde17afbd47979d99d057eb5.png

Accuracy and correlation check #

The SignalReturnRelations class from the macrosynergy.signal module is specifically designed to analyze, visualize, and compare the relationships between panels of trading signals and panels of subsequent returns.

dix = dict_rel

sig = dix["sig"]
targ = dix["targ"]
cidx = dix["cidx"]

srr = mss.SignalReturnRelations(
    dfx,
    cids=cidx,
    sigs=sigs,
    rets=targ,
    freqs="M",
    start="2000-01-01",
)

dix["srr"] = srr

As for the outright strategy, we use tables to compare basic statistics among different signals. The .summary_table() of the SignalReturnRelations class gives a high-level snapshot of the strength and stability of the main signal relation (seasonally adjusted relative vol-targeted carry, normalized and windorized 'COCRR_SAVT10_ZNIvGCO' ) and the target is relative commodity basket return COXR_VT10vGCO .

display(srr.summary_table().astype("float").round(3))
accuracy bal_accuracy pos_sigr pos_retr pos_prec neg_prec pearson pearson_pval kendall kendall_pval auc
Panel 0.535 0.535 0.460 0.490 0.527 0.542 0.050 0.000 0.045 0.000 0.535
Mean years 0.538 0.536 0.460 0.488 0.527 0.546 0.055 0.360 0.047 0.320 0.536
Positive ratio 0.840 0.840 0.120 0.360 0.760 0.880 0.840 0.560 0.840 0.680 0.840
Mean cids 0.536 0.523 0.465 0.491 0.509 0.537 0.034 0.439 0.029 0.408 0.523
Positive ratio 0.870 0.696 0.522 0.391 0.609 0.783 0.609 0.435 0.696 0.478 0.696

To compare different signals in one table we use multiple_relations_table() . The method returns a table with standard columns used for summary tables , but the rows display different signals from the list of signals specified under SignalReturnsRelations() sigs list. The row names indicate the frequency (‘D,’ ‘W,’ ‘M,’ ‘Q,’ ‘A’) followed by the signal’s and return’s names.

srr.multiple_relations_table().astype("float").round(3)
accuracy bal_accuracy pos_sigr pos_retr pos_prec neg_prec pearson pearson_pval kendall kendall_pval auc
M: COCRR_SAVT10_ZNIvGCO/last => COXR_VT10vGCO 0.535 0.535 0.460 0.490 0.527 0.542 0.050 0.000 0.045 0.0 0.535
M: COCRR_SAVT10vGCO/last => COXR_VT10vGCO 0.536 0.535 0.456 0.489 0.528 0.543 0.024 0.052 0.054 0.0 0.535
M: COCRR_VT10_ZNIvGCO/last => COXR_VT10vGCO 0.521 0.520 0.452 0.490 0.512 0.528 0.039 0.002 0.033 0.0 0.520
M: COCRR_VT10vGCO/last => COXR_VT10vGCO 0.529 0.528 0.448 0.489 0.520 0.536 0.016 0.188 0.039 0.0 0.527

The accuracy_bars method shows accuracy and balanced accuracy of the predicted relationship for the main signal ( "COCRR_SAVT10_ZNIvGCO" ). This can be displayed either by cross-section or by year.

srr.accuracy_bars(
    type="cross_section",
    title="Accuracy of monthly return prediction of fully adjusted commodity carry, by commodity",
    size=(16, 6),
)
https://macrosynergy.com/notebooks.build/trading-factors/commodity-carry-as-a-trading-signal/_images/c02d469e43756406793b57ba78e8ecf9d87ff7af97396cfc1b66fd5f2b93ebcf.png
srr.accuracy_bars(
    type="years",
    title="Accuracy of relative monthly return prediction of the fully adjusted commodity carry, by year",
    size=(16, 6),
)
https://macrosynergy.com/notebooks.build/trading-factors/commodity-carry-as-a-trading-signal/_images/7dea744ef965eb289b74c363a5a1d29e205ea5999bfc0d79d3c3a1d36039f909.png

The method .correlation_bars() visualizes positive correlation probabilities based on parametric (Pearson) and non-parametric (Kendall) correlation statistics and compares signals between each other, across countries, or years.

srr.correlation_bars(
    type="years",
    title="Significance of positive correlation of relative fully adjusted commodity carry and subsequent monthly returns, 23 markets",
    size=(16, 6),
)
https://macrosynergy.com/notebooks.build/trading-factors/commodity-carry-as-a-trading-signal/_images/a3183f2f43d1863a91612ee80ef03b4418d1fc3fc45ab8c4a6aa9e0fab3843ce.png

Naive PnL #

We calculate stylized PnLs, i.e., dollar-based profit and loss developments over and above funding costs, according to standard rules applied in previous posts. This is done with macrosynergy ’s custom class NaivePnL . Upon instantiation, we define the list of signals, target variable ( COXR_VT10vGCO ), list of cross-sections (all available in our case) and (optionally) benchmark (we choose here USD_EQXR_NSA , USD equity index futures returns and GLB_DRBXR_NSA - Directional risk basket returns)

Positions are re-calculated monthly at the end of the week and re-balanced in the following with a one-day slippage for trading time. The long-term volatility of the PnL for positions across all currency areas has been set to 10% annualized. This is no proper vol-targeting but mainly a scaling that makes it easy to compare different types of PnLs in graphs.

dix = dict_rel

targ = dix["targ"]
cidx = dix["cidx"]

pnl = msn.NaivePnL(
    dfx,
    ret=targ,
    sigs=sigs,
    cids=cidx,
    start="2000-01-01",
    bms=["USD_EQXR_NSA", "GLB_DRBXR_NSA"],
)

for sig in sigs:
    pnl.make_pnl(
        sig,
        sig_neg=False,
        sig_op="zn_score_pan",
        thresh=2,
        rebal_freq="monthly",
        vol_scale=10,
        rebal_slip=1,
        pnl_name=sig + "_PZN",
    )
    
dix["pnls"] = pnl
dict_pnr = {
    "COCRR_SAVT10_ZNIvGCO_PZN": "Fully adjusted carry",
    "COCRR_SAVT10vGCO_PZN": "Seasonally-adjusted and vol-targeted real carry",
    
    "COCRR_VT10_ZNIvGCO_PZN": "Vol-targeted, normalized and winsorized real carry",
    "COCRR_VT10vGCO_PZN": "Vol-targeted, norma;lized and winsorized real carry",
}

We utilize the .plot_pnls() method to straightforwardly visualize the naive Profit and Loss (PnLs) for all chosen signals.

dix = dict_rel

start = dix["start"]
cidx = dix["cidx"]

naive_pnl = dix["pnls"]

pnls = [s + "_PZN" for s in sigs] 
naive_pnl.plot_pnls(
    pnl_cats=pnls,
    pnl_cids=["ALL"],
    title="Commodity futures naive relative value PnLs for versions of relative carry",
    figsize=(16, 8),
    xcat_labels=[dict_pnr[k] for k in pnls],
)
https://macrosynergy.com/notebooks.build/trading-factors/commodity-carry-as-a-trading-signal/_images/8c09868b53125f48e32112338abc76edfa675662542dd28d88953ee64f19f499.png

The summary of key performance metrics for signals alongside “long only” PnL can be displayed with the method evaluate_pnls() , which returns a small dataframe of key PnL statistics.

dix = dict_rel

pnl = dix["pnls"]
pnls = [sig + type for sig in sigs for type in ["_PZN"]]

df_eval = pnl.evaluate_pnls(
    pnl_cats=pnls,
    pnl_cids=["ALL"],
    start="2000-01-01",
)
display(df_eval.transpose())
Return (pct ar) St. Dev. (pct ar) Sharpe Ratio Sortino Ratio Max 21-day draw Max 6-month draw USD_EQXR_NSA correl GLB_DRBXR_NSA correl Traded Months
xcat
COCRR_SAVT10_ZNIvGCO_PZN 6.034896 10.0 0.60349 0.863208 -13.089743 -22.276771 -0.017338 0.033533 291
COCRR_SAVT10vGCO_PZN 7.30108 10.0 0.730108 1.035271 -13.61194 -24.635466 0.009876 0.066785 291
COCRR_VT10_ZNIvGCO_PZN 4.29287 10.0 0.429287 0.610756 -13.534062 -22.508028 -0.028148 0.025306 291
COCRR_VT10vGCO_PZN 5.003936 10.0 0.500394 0.703539 -14.056721 -25.846821 0.003532 0.058945 291

Relative to specific commodity group baskets #

An alternative to global relative positioning is intra-group relative positioning. As distinct groups, we choose here

  • precious metals (gold, silver, palladium, and platinum),

  • base metals (aluminum, copper, lead, nickel, tin, and zinc),

  • fuels (Brent, WTI, natural gas, gasoline, and heating oil),

  • U.S. corn belt crops (cotton, corn, soy, and wheat), and

  • other agricultural commodities (coffee, sugar, orange juice, and lumber).

The tested proposition is the predictive power of intra-group relative carry for intra-group relative future return.

Specs and panel test #

We establish a dictionary encompassing a list of potential signals for initial data analysis and ease of use. The primary signal under consideration is 'COCRR_SAVT10_ZNIvRCO' , which represents normalized relative real seasonally-adjusted futures carry with volatility targeting. The targeted return here is COXR_VT10vRCO , representing intra-group relative commodity future returns with volatility targeting.

sigs = [cr + "vRCO" for cr in sigs if "VT10" in cr]

targ = "COXR_VT10vRCO"
cidx = cids
start = "2000-01-01"

dict_reg = {
    "sigs": sigs,
    "targ": targ,
    "cidx": cidx,
    "start": start,
    "black": None,
    "srr": None,
    "pnls": None,
}

Accuracy and correlation check #

As with the previous strategies we use SignalReturnRelations class from the macrosynergy.signal module together with .summary_table() , .accuracy_bars() , and .correlation_bars() to assess and compare strength and consistency of selected relative signals.

sigs = [
    "COCRR_SAVT10_ZNIvRCO",
    "COCRR_SAVT10vRCO",
    "COCRR_VT10vRCO",
    "COCRR_VT10_ZNIvRCO",
    
]
dix = dict_reg

targ = dix["targ"]
cidx = dix["cidx"]

srr = mss.SignalReturnRelations(
    dfx,
    cids=cidx,
    sigs=sigs,
    rets=targ,
    freqs="M",
    start="2000-01-01",
)

dix["srr"] = srr
display(srr.summary_table().astype("float").round(3))
accuracy bal_accuracy pos_sigr pos_retr pos_prec neg_prec pearson pearson_pval kendall kendall_pval auc
Panel 0.525 0.524 0.471 0.495 0.521 0.528 0.063 0.000 0.046 0.000 0.524
Mean years 0.522 0.522 0.470 0.495 0.519 0.524 0.059 0.267 0.041 0.282 0.521
Positive ratio 0.720 0.720 0.200 0.400 0.680 0.760 0.800 0.720 0.760 0.680 0.720
Mean cids 0.524 0.509 0.476 0.497 0.506 0.512 0.038 0.348 0.022 0.391 0.510
Positive ratio 0.783 0.609 0.391 0.435 0.522 0.652 0.652 0.435 0.652 0.435 0.609
srr.accuracy_bars(
    type="cross_section",
    title="Accuracy of monthly return prediction of fully adjusted commodity carry, by commodity",
    size=(16, 6),
)
https://macrosynergy.com/notebooks.build/trading-factors/commodity-carry-as-a-trading-signal/_images/57033fa7a1a7f71411da7ec94169f0ed2ccf6342e604980b2595a4f14d2449d3.png
srr.accuracy_bars(
    type="years",
    title="Accuracy of relative monthly return prediction of the fully adjusted commodity carry, by year",
    size=(16, 6),
)
https://macrosynergy.com/notebooks.build/trading-factors/commodity-carry-as-a-trading-signal/_images/a5135772248f728d12b3fc7254cffca03650c5603f8aa626b8b3449a1ce78059.png
srr.correlation_bars(
    type="years",
    title="Significance of positive correlation of fully adjusted carry and subsequent relative sector monthly returns, 23 markets",
    size=(16, 6),
)
https://macrosynergy.com/notebooks.build/trading-factors/commodity-carry-as-a-trading-signal/_images/14f6e13659fb12ba6a0fb3d660521ec1883a3ddfb08ac7ddc632f8bed35fbc4c.png

Naive PnL #

As with directional strategy the custom class NaivePnL is used to construct naive PnL. Upon instantiation, we define the list of relative carry as potential signals, target variable ( COXR_VT10vRCO ), list of cross-sections (all available in our case), and (optionally) benchmark (we choose here USD_EQXR_NSA , USD equity index futures returns and GLB_DRBXR_NSA - Directional risk basket returns)

dix = dict_reg

targ = dix["targ"]
cidx = dix["cidx"]

pnl_relgroup = msn.NaivePnL(
    dfx,
    ret=targ,
    sigs=sigs,
    cids=cidx,
    start="2000-01-01",
    bms=["USD_EQXR_NSA", "GLB_DRBXR_NSA"],
)

for sig in sigs:
    pnl_relgroup.make_pnl(
        sig,
        sig_neg=False,
        sig_op="zn_score_pan",
        thresh=2,
        rebal_freq="monthly",
        vol_scale=10,
        rebal_slip=1,
        pnl_name=sig + "_PZN",
    )
    
dix["pnls"] = pnl_relgroup
dict_pns = {
    "COCRR_SAVT10_ZNIvRCO_PZN": "Fully adjusted carry",
    "COCRR_SAVT10vRCO_PZN": "Seasonally-adjusted and vol-targeted real carry",
    
    "COCRR_VT10_ZNIvRCO_PZN": "Vol-targeted, normalized and winsorized real carry",
    "COCRR_VT10vRCO_PZN": "Vol-targeted real carry",
}
dix = dict_reg

start = dix["start"]
cidx = dix["cidx"]
pnl = dix["pnls"]

naive_pnl_relgroup = dix["pnls"]

pnls = [s + "_PZN" for s in sigs] 
pnl.plot_pnls(
    pnl_cats=pnls,
    pnl_cids=["ALL"],
    title="Commodity futures naive relative value PnLs for relative intra-group carry",
    figsize=(16, 8),
    xcat_labels=[dict_pns[k] for k in pnls],
)
https://macrosynergy.com/notebooks.build/trading-factors/commodity-carry-as-a-trading-signal/_images/3858d4b27c53e16801a747341e2cb0385ee0bd12e98d9b1d1a5da4acc7f32245.png

The evaluate_pnls() method facilitates the presentation of key performance metrics for signals and, if required, the long-only Profit and Loss (PnL). When invoked, this method generates a concise dataframe containing essential PnL statistics. For further details, you can refer to the documentation for evaluate_pnls()

dix = dict_reg

pnl = dix["pnls"]
pnls = [sig + type for sig in sigs for type in ["_PZN"]]

df_eval = pnl.evaluate_pnls(
    pnl_cats=pnls,
    pnl_cids=["ALL"],
    start="2000-01-01",
)
display(df_eval.transpose().astype("float").round(3))
Return (pct ar) St. Dev. (pct ar) Sharpe Ratio Sortino Ratio Max 21-day draw Max 6-month draw USD_EQXR_NSA correl GLB_DRBXR_NSA correl Traded Months
xcat
COCRR_SAVT10_ZNIvRCO_PZN 6.300 10.0 0.630 0.932 -14.145 -15.604 0.014 0.048 291.0
COCRR_SAVT10vRCO_PZN 7.759 10.0 0.776 1.129 -13.519 -16.895 0.047 0.094 291.0
COCRR_VT10_ZNIvRCO_PZN 4.422 10.0 0.442 0.649 -11.403 -14.722 0.008 0.040 291.0
COCRR_VT10vRCO_PZN 4.424 10.0 0.442 0.632 -10.420 -16.573 0.045 0.083 291.0