Duration volatility risk premia#

This notebook serves as an illustration of the points discussed in the post “Duration volatility risk premia” available on the Macrosynergy website.

The post argues, that simple duration VRP (volatility risk premia) can help predict idiosyncratic IRS returns in non-USD markets. The post also argues that two derived concepts of volatility risk premia hold can help create better signals for fixed-income positions. The first one is term spreads, which is the differences between volatility risk premia for longer-maturity and shorter-maturity IRS contracts. The second one is maturity spreads, which are the differences between volatility risk premia of longer- and shorter-maturity options, as indicative of a fear of risk escalation, which affects mainly fixed receivers. Indeed, maturity spreads have been positively and significantly related to subsequent fixed-rate receiver returns. These premia are best combined with fundamental indicators of the related risks to give valid signals for fixed-income positions.

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

Imports#

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
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

from timeit import default_timer as timer
from datetime import timedelta, date, datetime

import warnings
warnings.simplefilter("ignore")

%load_ext nb_black

The JPMaQS indicators we consider are downloaded using the J.P. Morgan Dataquery API interface within the macrosynergy package. This is done by specifying ticker strings, formed by appending an indicator category code to a currency area code <cross_section>. These constitute the main part of a full quantamental indicator ticker, taking the form DB(JPMAQS,<cross_section>_<category>,<info>), where denotes the time series of information for the given cross-section and category. The following types of information are available:

value giving the latest available values for the indicator eop_lag referring to days elapsed since the end of the observation period mop_lag referring to the number of days elapsed since the mean observation period grade denoting a grade of the observation, giving a metric of real-time information quality.

After instantiating the JPMaQSDownload class within the macrosynergy.download module, one can use the download(tickers,start_date,metrics) method to easily download the necessary data, where tickers is an array of ticker strings, start_date is the first collection date to be considered and metrics is an array comprising the times series information to be downloaded. For more information see here.

Largely, only the standard packages in the Python data science stack are required to run this notebook. The specialized macrosynergy package is also needed to download JPMaQS data and for quick analysis of quantamental data and value propositions.

The description of each JPMaQS category is available under Macro quantamental academy. For tickers used in this notebook see Duration volatility risk premia, Duration returns, and Equity index future returns.

cids_90 = ["EUR", "GBP", "USD", "SEK"]
cids_00 = ["HKD", "HUF", "ILS", "NOK", "PLN", "ZAR"]
cids_10 = ["CHF", "JPY", "KRW"]
cids_vrp = cids_90 + cids_00 + cids_10
cids_vrxu = list(set(cids_vrp) - set(["USD"]))
main = [
    "IRVRP03M02Y_NSA",
    "IRVRP06M02Y_NSA",
    "IRVRP01Y02Y_NSA",
    "IRVRP03M03Y_NSA",
    "IRVRP06M03Y_NSA",
    "IRVRP01Y03Y_NSA",
    "IRVRP03M05Y_NSA",
    "IRVRP06M05Y_NSA",
    "IRVRP01Y05Y_NSA",
]
xtra = ["DU02YXRxEASD_NSA", "DU05YXRxEASD_NSA", "EQXR_NSA"]
rets = ["DU02YXR_NSA", "DU05YXR_NSA", "DU02YXR_VT10", "DU05YXR_VT10"]

xcats = main + xtra + rets

# Resultant tickers

tickers = [cid + "_" + xcat for cid in cids_vrp for xcat in xcats]
print(f"Maximum number of tickers is {len(tickers)}")
Maximum number of tickers is 208
# Download series from J.P. Morgan DataQuery by tickers

start_date = "1990-01-01"
end_date = "2023-05-01"

# Retrieve credentials

client_id: str = os.getenv("DQ_OAUTH_CLIENT_ID")
client_secret: str = os.getenv("DQ_OAUTH_SECRET")

with JPMaQSDownload(client_id=client_id, client_secret=client_secret) as dq:
    df = dq.download(
        tickers=tickers,
        start_date=start_date,
        end_date=end_date,
        suppress_warning=True,
        metrics=["all"],
        report_time_taken=True,
        show_progress=True,
        report_egress=True,
    )
Downloading data from JPMaQS.
Timestamp UTC:  2023-06-20 15:56:41
Connection successful!
Number of expressions requested: 832
Requesting data: 100%|██████████| 42/42 [00:12<00:00,  3.29it/s]
Downloading data: 100%|██████████| 42/42 [00:24<00:00,  1.72it/s]
Time taken to download data: 	38.45 seconds.
Time taken to convert to dataframe: 	16.48 seconds.
Average upload size: 	0.20 KB
Average download size: 	176950.83 KB
Average time taken: 	14.38 seconds
Longest time taken: 	21.32 seconds
Average transfer rate : 	98472.71 Kbps
scols = ["cid", "xcat", "real_date", "value"]  # required columns
dfx = df[scols].copy()
dfx.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 1160894 entries, 0 to 1160893
Data columns (total 4 columns):
 #   Column     Non-Null Count    Dtype         
---  ------     --------------    -----         
 0   cid        1160894 non-null  object        
 1   xcat       1160894 non-null  object        
 2   real_date  1160894 non-null  datetime64[ns]
 3   value      1160894 non-null  float64       
dtypes: datetime64[ns](1), float64(1), object(2)
memory usage: 35.4+ MB

Availability#

It is important to assess data availability before conducting any analysis. It allows identifying 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.missing_in_df(df, xcats=xcats, cids=cids_vrp)
Missing xcats across df:  set()
Missing cids for DU02YXR_NSA:  set()
Missing cids for DU02YXR_VT10:  set()
Missing cids for DU02YXRxEASD_NSA:  set()
Missing cids for DU05YXR_NSA:  set()
Missing cids for DU05YXR_VT10:  set()
Missing cids for DU05YXRxEASD_NSA:  set()
Missing cids for EQXR_NSA:  {'ILS', 'NOK', 'HUF'}
Missing cids for IRVRP01Y02Y_NSA:  set()
Missing cids for IRVRP01Y03Y_NSA:  set()
Missing cids for IRVRP01Y05Y_NSA:  set()
Missing cids for IRVRP03M02Y_NSA:  set()
Missing cids for IRVRP03M03Y_NSA:  set()
Missing cids for IRVRP03M05Y_NSA:  set()
Missing cids for IRVRP06M02Y_NSA:  set()
Missing cids for IRVRP06M03Y_NSA:  set()
Missing cids for IRVRP06M05Y_NSA:  set()
msm.check_availability(df, xcats=xcats, cids=cids_vrp)
_images/Duration VRP and duration returns_13_0.png _images/Duration VRP and duration returns_13_1.png

Blacklist dictionary#

ZAR data was not updated March 2019 to July 2020, due to the lack of trader at J.P. Morgan, hence we create a blacklist dictionary and pass it to several package functions in this notebook that exclude the blacklisted period from related analyses.

zar_black = ["2019-03-15", "2020-07-31"]
dv_black = {"ZAR": [pd.to_datetime(s) for s in zar_black]}
dv_black
{'ZAR': [Timestamp('2019-03-15 00:00:00'), Timestamp('2020-07-31 00:00:00')]}

Transformations and checkups#

Features#

Volatility return premia#

As the first step, part of preliminary analysis, we display volatility risk premia for 2-year and 5-year IRS receivers with swaption maturity of 3 months on the timeline as well as separately their means and standard deviations. Please see here for the definition of the indicators and Introduction to Macrosynergy package for the standard functions used throughout this notebook.

Shorter-duration VRP have been highest in economies with low short-term interest rates (which have little realized rates volatility).

xcats_sel = ["IRVRP03M02Y_NSA", "IRVRP03M05Y_NSA"] 
msp.view_ranges(
    dfx,
    cids=cids_vrp,
    xcats=xcats_sel,
    kind="bar",
    sort_cids_by="mean",
    title=None,
    ylab=None,
    start="2000-01-01",
)
msp.view_timelines(
    dfx,
    xcats=xcats_sel,
    cids=cids_vrp,
    ncol=3,
    cumsum=False,
    start=None,
    same_y=False,
    aspect=1.75,
    size=(12, 12),
    all_xticks=True,
    title_adj=0.95,
    label_adj=0.05,
    title=None,
    xcat_labels=None,
)
_images/Duration VRP and duration returns_21_0.png _images/Duration VRP and duration returns_21_1.png

For most countries for 2-year duration, the VRP based on shorter-maturity options is larger than the VRP based on longer-maturity options (5 years). High current volatility is often expected to revert to its long-term average or decline over time, which can impact the relationship between long-term implied volatility and recently realized volatility. This expectation of mean reversion in volatility can influence the estimation of volatility risk premia based on longer-dated maturities. Given this understanding, incorporating both short and long-term lookbacks in estimating the expected realized measure can help provide a more accurate estimation of volatility risk premia for longer-dated maturities.

xcats_sel = ["IRVRP03M02Y_NSA", "IRVRP01Y02Y_NSA"]
msp.view_ranges(
    dfx,
    cids=cids_vrp,
    xcats=xcats_sel,
    kind="bar",
    sort_cids_by="mean",
    title="Volatility risk premia for 2-year IRS receivers with swaption maturity of 3 months and 1 year",
    ylab=None,
    start="2000-01-01",
)
_images/Duration VRP and duration returns_23_0.png

For longer-duration IRS (5 years) the gap between short-term and longer-term maturity-based VRPs has been smaller.

xcats_sel = ["IRVRP03M05Y_NSA", "IRVRP01Y05Y_NSA"]
msp.view_ranges(
    dfx,
    cids=cids_vrp,
    xcats=xcats_sel,
    kind="bar",
    sort_cids_by="mean",
    title="Volatility risk premia for 5-year IRS receivers with swaption maturity of 3 months and 1 year",
    ylab=None,
    start="2000-01-01",
)
_images/Duration VRP and duration returns_25_0.png

Rolling means and medians#

Adding a rolling average to the estimation of volatility premia can indeed introduce stability to the estimates, but it’s important to note that it also introduces a time lag in capturing shifts in the premium. The code below creates a rolling 5-day average for all volatility premia categories, appending a postfix _5DMA to indicate the modified category.

calcs = []
for vrp in main:
    calc = [f"{vrp}_5DMA = {vrp}.rolling(5).mean() "]
    calcs += calc

dfa = msp.panel_calculator(dfx, calcs=calcs, cids=cids_vrp, blacklist=dv_black)
dfx = msm.update_df(dfx, dfa)

For comparison, the new rolling 5-day moving average is plotted against the original series.

xcats_sel = ["IRVRP03M02Y_NSA", "IRVRP03M02Y_NSA_5DMA"]
msp.view_timelines(
    dfx,
    xcats=xcats_sel,
    cids=["USD"],
    start="2020-01-01",
    title=None,
)
_images/Duration VRP and duration returns_30_0.png

VRP averages#

Simple averages of duration VRPs may be most representative of the general concept and reduce the effects of individual pricing errors. The average volatility risk premium here is based on the arithmetic average of the premium for three underlying IRS tenors (2, 3, and 5 years) and three option maturities (3, 6, and 12 months).

The cell below creates 2 types of averages for each cross-section:

IRVRP_AVG - represents the average of original volatility premia IRVRP_5DMA_AVG represents the average of rolling 5 days averages

sum_str = " + ".join((vrp for vrp in main))  # join list to string of sum
sum_str_5dma = " + ".join((vrp + "_5DMA" for vrp in main))

calc1 = f"IRVRP_AVG = ( {sum_str} ) / {len(main)}"
calc2 = f"IRVRP_5DMA_AVG = ( {sum_str_5dma} ) / {len(main)}"
calcs = [calc1] + [calc2] 

dfa = msp.panel_calculator(dfx, calcs=calcs, cids=cids_vrp, blacklist=dv_black)
dfx = msm.update_df(dfx, dfa)

Volatility risk premia across the available sample periods have on average been positive for all countries. The highest premia were charged in Switzerland and Israel, the lowest in the U.S. and South Korea. In all countries, the premia have at least temporarily been negative.

xcats_sel = ["IRVRP_AVG"]
msp.view_ranges(
    dfx,
    cids=cids_vrp,
    xcats=xcats_sel,
    kind="bar",
    sort_cids_by="mean",
    title="Volatility risk premia across available sample periods: averages across maturities and tenors",
    ylab=None,
    start="1992-01-01",
)
_images/Duration VRP and duration returns_35_0.png

The premia have been stationarity with sustained periods of months or years above average and short periods below average or in negative territory. Beyond, there has been ample short-term volatility, even after taking 5-day moving averages.

xcats_sel = ["IRVRP_5DMA_AVG"]
msp.view_timelines(
    dfx,
    xcats=xcats_sel,
    cids=cids_vrxu,
    ncol=3,
    cumsum=False,
    start="2000-01-01",
    same_y=False,
    aspect=1.75,
    size=(12, 12),
    all_xticks=True,
    title_adj=1.01,
    label_adj=0.1,
    title="Non-USD: Average volatility risk premium across durations and maturities, 5-day rolling",
    xcat_labels=None,
)
_images/Duration VRP and duration returns_37_0.png

U.S. data are now available for three decades and reveal pronounced and sustained phases of negative and positive premia.

xcats_sel = ["IRVRP_5DMA_AVG"]
msp.view_timelines(
    dfx,
    xcats=xcats_sel,
    cids=["USD"],
    start="1992-01-01",
    title="USD: Average volatility risk premium across durations and maturities, 5-day rolling",
    xcat_labels=["based on USD swaption and IRS markets"],
)
_images/Duration VRP and duration returns_39_0.png

Volatility risk premia have been positively correlated across all markets, based on the longest common samples. This suggests that they reflect common global factors.

msp.correl_matrix(
    dfx,
    xcats="IRVRP_5DMA_AVG",
    cids=cids_vrp,
    start="2000-01-01",
    cluster=True,
    title="Cross-sectional correlation coefficients of volatility risk premia",
)
_images/Duration VRP and duration returns_41_0.png

VRP term spreads#

Here term spread refers to the difference between 5-year and 2-year IRS duration VRPs for the same option maturity. Conceptually, this refers to the difference in premia for bearing long-term uncertainty versus short-term uncertainty.

The effective spread serves as an indicator of the dominance of either structural uncertainty (resulting in a positive spread) or cyclical uncertainty (resulting in a negative spread). Structural uncertainty refers to factors related to the overall economic or financial structure, such as long-term economic trends, systemic risks, or policy uncertainties. Cyclical uncertainty, on the other hand, relates to short-term economic fluctuations, business cycles, or market sentiment.

The next cell calculates term spreads and creates a new category by appending a postfix _TS to the original category.

omats = ["03M", "06M", "01Y"]

calcs = []
for omat in omats:  # term spreads across option maturities
    calc1 = [f"IRVRP_TS{omat} = IRVRP{omat}05Y_NSA - IRVRP{omat}02Y_NSA"]
    calc2 = [f"IRVRP_TS{omat}_5DMA = IRVRP{omat}05Y_NSA_5DMA - IRVRP{omat}02Y_NSA_5DMA"]
    calcs += calc1 + calc2


dfa = msp.panel_calculator(dfx, calcs=calcs, cids=cids_vrp, blacklist=dv_black)
dfx = msm.update_df(dfx, dfa)

It is useful to calculate an average over the three option maturities. The new indicator gets the postfix _AVG in addition to _TS

sum_str = " + ".join((f"IRVRP_TS{omat}" for omat in omats))
sum_str_5dma = " + ".join((f"IRVRP_TS{omat}_5DMA" for omat in omats))

calc1 = f"IRVRP_TS_AVG = ( {sum_str} ) / {len(omats)}"
calc2 = f"IRVRP_TS_5DMA_AVG = ( {sum_str_5dma} ) / {len(omats)}"
calcs = [calc1] + [calc2]

dfa = msp.panel_calculator(dfx, calcs=calcs, cids=cids_vrp, blacklist=dv_black)
dfx = msm.update_df(dfx, dfa)

Historically, the term spread has been mostly negative, i.e. premia for taking short-duration volatility risk have been higher than for taking long-duration volatility risk. This may be an indication that interest rates in most countries are seen as more anchored in the long run than in the short run.

xcats_sel = ["IRVRP_TS_5DMA_AVG"]
msp.view_ranges(
    dfx,
    cids=cids_vrp,
    xcats=xcats_sel,
    kind="bar",
    sort_cids_by="mean",
    title="Means and standard deviations of term spreads of volatility risk premia",
    ylab="difference of premium for 5-year and 2-year interest rate swaps",
    start="2000-01-01",
)
_images/Duration VRP and duration returns_48_0.png

Over time, the spreads have displayed trends, cycles, and ample short-term volatility. Here we display the timeline of US term spreads of volatility risk premia, 5 day rolling mean (US has the longest history of data, available singe 1992)

xcats_sel = ["IRVRP_TS_5DMA_AVG"]
msp.view_timelines(
    dfx,
    xcats=xcats_sel,
    cids=["USD"],
    start="1992-01-01",
    title="USD: Term spreads of volatility risk premia, 5-day rolling mean",
    xcat_labels=["based on USD swaption and IRS markets"],
)
_images/Duration VRP and duration returns_50_0.png

Correlations of term spreads across economies have been mixed. In particular, USD term spread correlation with other countries has been mostly negative. However, the correlation of term spreads for the European countries has been mostly positive.

msp.correl_matrix(
    dfx,
    xcats="IRVRP_TS_5DMA_AVG",
    cids=cids_vrp,
    start="2000-01-01",
    cluster=True,
    title="Cross-sectional correlation coefficients of term spreads of volatility risk premia since 2000",
)
_images/Duration VRP and duration returns_52_0.png

VRP maturity spreads#

Directional#

Here maturity spread means the difference between the VRP based on a longer-maturity option compared to a shorter-maturity option, for the same underlying duration. Since realized volatility for both is estimated in the same day this is equivalent to the scaled difference between implied vols of the 1-year and 3-month options for the same maturity. This may give an indication if volatility is expected to be persistent (positive spread) or short-lived (negative spread).

durs = ["02Y", "03Y", "05Y"]

calcs = []
for dur in durs:  # term spreads across option maturities
    calc1 = [f"IRVRP_MS{dur} = IRVRP01Y{dur}_NSA - IRVRP03M{dur}_NSA"]
    calc2 = [f"IRVRP_MS{dur}_5DMA = IRVRP01Y{dur}_NSA_5DMA - IRVRP03M{dur}_NSA_5DMA"]
    calcs += calc1 + calc2


dfa = msp.panel_calculator(dfx, calcs=calcs, cids=cids_vrp, blacklist=dv_black)
dfx = msm.update_df(dfx, dfa)

An average maturity spread is simply the mean overall underlying durations.

sum_str = " + ".join((f"IRVRP_MS{dur}" for dur in durs))
sum_str_5dma = " + ".join((f"IRVRP_MS{dur}_5DMA" for dur in durs))

calc1 = f"IRVRP_MS_AVG = ( {sum_str} ) / {len(durs)}"
calc2 = f"IRVRP_MS_5DMA_AVG = ( {sum_str_5dma} ) / {len(durs)}"
calcs = [calc1] + [calc2]

dfa = msp.panel_calculator(dfx, calcs=calcs, cids=cids_vrp, blacklist=dv_black)
dfx = msm.update_df(dfx, dfa)

Maturity spreads have been mostly negative and displayed very different standard deviations across currency areas with larger countries posting smaller fluctuations.

xcats_sel = ["IRVRP_MS_5DMA_AVG"]
msp.view_ranges(
    dfx,
    cids=cids_vrp,
    xcats=xcats_sel,
    kind="bar",
    sort_cids_by="mean",
    title="Means and standard deviations of maturity spreads of volatility risk premia",
    ylab="difference of premium for 1-year and 3-month swaption maturity",
    start="2000-01-01",
)
_images/Duration VRP and duration returns_60_0.png

The time series of maturity spreads have been stationary with pronounced cycles around a negative mean, as exemplified by the U.S. history in the graph below:

xcats_sel = ["IRVRP_MS_5DMA_AVG"]
msp.view_timelines(
    dfx,
    xcats=xcats_sel,
    cids=["USD"],
    start="1992-01-01",
    title="USD: Maturity spreads of volatility risk premia, 5-day rolling mean",
    xcat_labels=["based on USD swaption and IRS markets"],
)
_images/Duration VRP and duration returns_62_0.png

The short-duration maturity spreads turned out to be the most negative. JPY, HKD and CHF have been the countries with the deepest negative maturity spreads for 2-year durations.

xcats_sel = ["IRVRP_MS02Y", "IRVRP_MS05Y"]
msp.view_ranges(
    dfx,
    cids=cids_vrp,
    xcats=xcats_sel,
    kind="bar",
    sort_cids_by="mean",
    title=None,
    ylab=None,
    start="2000-01-01",
)
_images/Duration VRP and duration returns_64_0.png

Correlations across currency areas are mixed. Most post positive correlation with either the U.S. or the Euro area.

msp.correl_matrix(
    dfx,
    xcats="IRVRP_MS_5DMA_AVG",
    cids=cids_vrp,
    start="2000-01-01",
    cluster=True,
    title="Cross-sectional correlation coefficients of maturity spreads since 2000",
)
_images/Duration VRP and duration returns_66_0.png

Targets#

Outright returns#

Outright returns and vol-targeted returns across currency areas have shown similar cyclical patterns.

xcats_sel = ["DU02YXR_NSA", "DU05YXR_NSA", "DU02YXR_VT10", "DU05YXR_VT10"]
msp.view_timelines(
    dfx,
    xcats=xcats_sel,
    cids=cids_vrp,
    ncol=4,
    cumsum=True,
    start="2000-01-01",
    same_y=True,
    aspect=1.75,
    size=(12, 12),
    all_xticks=True,
    title_adj=0.95,
    label_adj=0.05,
    title=None,
    xcat_labels=None,
)
_images/Duration VRP and duration returns_70_0.png

Relative vol-parity returns (across durations and versus USD)#

This relative return is the 5-year IRS receiver return minus the 2-year IRS receiver return, both based on vol-targeted positions.

calc1 = "DU5v2YXR = DU05YXR_VT10 - DU02YXR_VT10"
calc2 = "DU02YvUSDXR = DU02YXR_VT10 - iUSD_DU02YXR_VT10"
calc3 = "DU05YvUSDXR = DU05YXR_VT10 - iUSD_DU05YXR_VT10"
calcs = [calc1, calc2, calc3]

dfa = msp.panel_calculator(dfx, calcs=calcs, cids=cids_vrp, blacklist=dv_black)
dfx = msm.update_df(dfx, dfa)

While cumulative returns of curve trades have posted local trends and cycles, only CHF, JPY and NOK posted longer-term drifts

xcats_sel = ["DU5v2YXR"]
msp.view_timelines(
    dfx,
    xcats=xcats_sel,
    cids=cids_vrp,
    ncol=4,
    cumsum=True,
    start="2000-01-01",
    same_y=True,
    aspect=1.75,
    size=(12, 12),
    all_xticks=True,
    title_adj=0.95,
    label_adj=0.05,
    title=None,
    xcat_labels=None,
)
_images/Duration VRP and duration returns_75_0.png

Curve trade returns have generally been positively correlated across currency areas.

msp.correl_matrix(
    dfx, xcats="DU5v2YXR", cids=cids_vrp, start="2000-01-01", cluster=True
)
_images/Duration VRP and duration returns_77_0.png

Most currency areas’ receiver positions have outperformed the USD IRS receivers over the long term.

xcats_sel = ["DU02YvUSDXR", "DU05YvUSDXR"]
msp.view_timelines(
    dfx,
    xcats=xcats_sel,
    cids=cids_vrxu,
    ncol=4,
    cumsum=True,
    start="2000-01-01",
    same_y=True,
    aspect=1.75,
    size=(12, 12),
    all_xticks=True,
    title_adj=0.95,
    label_adj=0.1,
    title=None,
    xcat_labels=None,
)
_images/Duration VRP and duration returns_79_0.png

Value checks#

VRP and directional returns#

There has been no clear relation between duration VRP and subsequent IRS returns. Higher premia on volatility risk have not translated into higher returns on outright duration exposure. The correlation coefficient is around 0. Using “map” as prob_est instead of the default “pool” diminishes the significance probability further. For details on the test please see Testing macro trading factors. Since we see almost no correlation between VRP and directional returns, we will not construct a trading strategy based on simple VRP, instead, we proceed with the investigation of the two derived concepts, term and maturity spreads, on subsequent returns.

xcats_vpa_2vt = ["IRVRP_5DMA_AVG", "DU02YXR_VT10"]
xcats_sel = xcats_vpa_2vt
cids_sel = cids_vrp
cr = msp.CategoryRelations(
    dfx,
    xcats=xcats_sel,
    cids=cids_sel,
    freq="M",
    lag=1,
    xcat_aggs=["last", "sum"],
    start="2000-01-01",
    xcat_trims=[None, None],
)
cr.reg_scatter(
    labels=False,
    prob_est="map",
    coef_box="lower right",
    title="Volatility risk premium and subsequent weekly IRS returns across all markets since 2000",
    xlab="Average volatility risk premium across durations and option maturities",
    ylab="Next month's 2-year IRS receiver return",
)
_images/Duration VRP and duration returns_83_0.png

VRP and relative returns#

The correlation of volatility risk premia in non-U.S. markets with subsequent IRS receiver returns relative to the U.S. has been positive with modest significance. This suggests that premia are charged for idiosyncratic volatility risk and predictive for idiosyncratic returns in non-USD currency areas. However, if the test is run using time-specific random effects estimation method, the significance goes down further.

xcats_vpa_5vu = ["IRVRP_5DMA_AVG", "DU05YvUSDXR"]
xcats_sel = xcats_vpa_5vu
cids_sel = cids_vrxu
cr = msp.CategoryRelations(
    dfx,
    xcats=xcats_sel,
    cids=cids_sel,
    freq="M",
    lag=1,
    xcat_aggs=["last", "sum"],
    start="2000-01-01",
    xcat_trims=[None, None],
    blacklist=dv_black
)
cr.reg_scatter(
    labels=False,
    coef_box="lower left",
    
    title="Volatility risk premia and subsequent relative IRS returns across all markets since 2000",
    xlab="Average volatility risk premium across durations and option maturities",
    ylab="Next month's 5-year IRS receiver return relative to USD receiver return (vol-parity)",
)
_images/Duration VRP and duration returns_86_0.png
cr.reg_scatter(
    labels=False,
    coef_box="lower left",
    title=None,
    xlab=None,
    ylab=None,
    title_adj=1.02,
    separator="cids",
)
_images/Duration VRP and duration returns_87_0.png

We use SignalReturnRelations() function from the Macrosynergy package, which analyses and compares the relationships between the chosen signals and the panel of subsequent returns. There is no regression analysis involved, rather the sign of the signal is used for predicting the sign of the target.

xcats_sel = xcats_vpa_5vu
cids_sel = cids_vrxu
srr = mss.SignalReturnRelations(
    dfx,
    cids=cids_sel,
    sig=xcats_sel[0],
    ret=xcats_sel[1],
    sig_neg=False,
    freq="W",
    start="2000-01-01",
    blacklist=dv_black
)
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
Panel 0.515 0.512 0.696 0.510 0.518 0.507 0.014 0.149 0.013 0.048
Mean years 0.518 0.514 0.697 0.512 0.520 0.509 0.017 0.350 0.016 0.470
Positive ratio 0.708 0.625 0.875 0.542 0.667 0.500 0.500 0.417 0.583 0.375
Mean cids 0.513 0.509 0.689 0.507 0.514 0.503 0.014 0.531 0.013 0.514
Positive ratio 0.917 0.667 0.917 0.750 0.917 0.417 0.750 0.500 0.750 0.500
xcats_sel = xcats_vpa_5vu
cids_sel = cids_vrxu
start_date = "2000-01-01"
sigs = [xcats_sel[0]]

naive_pnl = msn.NaivePnL(
    dfx,
    ret=xcats_sel[1],
    sigs=sigs,
    cids=cids_sel,
    start=start_date,
    blacklist=dv_black
)

for sig in sigs:
    naive_pnl.make_pnl(
        sig,
        sig_op="zn_score_pan",
        neutral="zero",
        thresh=2,
        rebal_freq="weekly",
        vol_scale=10,
        rebal_slip=1,
        pnl_name=sig + "PZ0",
    )
    naive_pnl.make_pnl(
        sig,
        sig_op="zn_score_pan",
        neutral="mean",
        thresh=2,
        rebal_freq="weekly",
        vol_scale=10,
        rebal_slip=1,
        pnl_name=sig + "PZM",
        
    )

naive_pnl.make_long_pnl(vol_scale=10, label="Long")
pnls = [xcats_sel[0] + "PZ0", "Long"]

naive_pnl.plot_pnls(
    pnl_cats=pnls,
    pnl_cids=["ALL"],
    start=start_date,
    
    title="PnLs of global relative IRS positions (local currency versus USD)",
    xcat_labels=[
        "Relative 5-year IRS receiver positions based on local volatility risk premia ",
        "Continuously long relative IRS receiver positions (vol-parity)",
    ],
)
_images/Duration VRP and duration returns_91_0.png

Term spreads and relative returns#

There has been a positive correlation but due largely to size co-movement rather than high accuracy of directional predictions. Value generation would have been impressive, though, based on a standard generic strategy.

xcats_tsa_52 = ["IRVRP_TS_5DMA_AVG", "DU5v2YXR"]
xcats_sel = xcats_tsa_52
cids_sel = cids_vrp
cr = msp.CategoryRelations(
    dfx,
    xcats=xcats_sel,
    cids=cids_sel,
    freq="M",
    lag=1,
    xcat_aggs=["last", "sum"],
    start="2000-01-01",
    xcat_trims=[None, None],
)
cr.reg_scatter(
    labels=False,
    coef_box="lower left",
    prob_est="map",
    title="Term spreads of volatility risk premia and subsequent 5-year versus 2-year IRS returns",
    xlab="Term spread",
    ylab="Next month's 5-year versus 2-year IRS returns (vol-parity)",
)
_images/Duration VRP and duration returns_94_0.png
xcats_sel = xcats_tsa_52
cids_sel = cids_vrp
srr = mss.SignalReturnRelations(
    dfx,
    cids=cids_sel,
    sig=xcats_sel[0],
    ret=xcats_sel[1],
    sig_neg=False,
    freq="W",
    start="2000-01-01",
)
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
Panel 0.509 0.507 0.547 0.523 0.530 0.485 0.053 0.000 0.009 0.147
Mean years 0.510 0.509 0.569 0.521 0.527 0.490 0.035 0.340 0.012 0.352
Positive ratio 0.583 0.583 0.625 0.625 0.708 0.333 0.625 0.500 0.542 0.417
Mean cids 0.506 0.511 0.513 0.525 0.533 0.489 0.044 0.335 0.014 0.468
Positive ratio 0.538 0.538 0.615 0.923 0.923 0.462 0.769 0.692 0.769 0.385
xcats_sel = xcats_tsa_52
cids_sel = cids_vrp
start_date = "2000-01-01"
sigs = [xcats_sel[0]]

naive_pnl = msn.NaivePnL(
    dfx,
    ret=xcats_sel[1],
    sigs=sigs,
    cids=cids_sel,
    start=start_date,
    blacklist=dv_black
)

for sig in sigs:
    naive_pnl.make_pnl(
        sig,
        sig_op="zn_score_pan",
        neutral="zero",
        thresh=2,
        rebal_freq="weekly",
        vol_scale=10,
        rebal_slip=1,
        pnl_name=sig + "PZ0",
    )
    naive_pnl.make_pnl(
        sig,
        sig_op="zn_score_pan",
        neutral="mean",
        thresh=2,
        rebal_freq="weekly",
        vol_scale=10,
        rebal_slip=1,
        pnl_name=sig + "PZM",
    )

naive_pnl.make_long_pnl(vol_scale=10, label="Long")
pnls = [xcats_sel[0] + "PZ0", "Long"]
start_date = "2000-01-01" 

naive_pnl.plot_pnls(
    pnl_cats=pnls,
    pnl_cids=["ALL"],
    start=start_date,
    title="PnLs of global IRS positions (5-years versus 2-years, risk parity)",
    xcat_labels=[
        "Relative IRS receiver positions based on term spreads volatility risk premia ",
        "Continuously long 5-years versus 2-years receiver positions",
    ],
)

df_eval = naive_pnl.evaluate_pnls(
    pnl_cats=pnls,
    pnl_cids=["ALL"],
    start=start_date,
)
display(df_eval)
_images/Duration VRP and duration returns_97_0.png
xcat IRVRP_TS_5DMA_AVGPZ0 Long
Return (pct ar) 6.115988 2.827264
St. Dev. (pct ar) 10.0 10.0
Sharpe Ratio 0.611599 0.282726
Sortino Ratio 0.951695 0.386833
Max 21-day draw -9.655 -23.458588
Max 6-month draw -22.937998 -34.37233
Traded Months 281 281

Maturity spreads and directional returns#

There has been a mild positive correlation between maturity spreads and subsequent duration returns. The drawback as a trading signal is mainly the almost 70% short bias. Part of the short bias relates to the unexploited mean reversion of realized duration volatility.

xcats_msa_2vt = ["IRVRP_MS_5DMA_AVG", "DU05YXR_VT10"]
xcats_sel = xcats_msa_2vt
cids_sel = cids_vrp
cr = msp.CategoryRelations(
    dfx,
    xcats=xcats_sel,
    cids=cids_sel,
    freq="M",
    lag=1,
    
    xcat_aggs=["last", "sum"],
    start="2000-01-01",
    xcat_trims=[None, None],
)
cr.reg_scatter(
    labels=False,
    coef_box="lower left",
    title="Maturity spreads and subsequent IRS returns across all markets since 2000",
    xlab="Average maturity spreads across underlying IRS tenors",
    ylab="Next month's 5-year IRS receiver return (vol-targeted at 10%)",
)
_images/Duration VRP and duration returns_100_0.png
xcats_sel = xcats_msa_2vt
cids_sel = cids_vrp
srr = mss.SignalReturnRelations(
    dfx,
    cids=cids_sel,
    sig=xcats_sel[0],
    ret=xcats_sel[1],
    sig_neg=False,
    freq="W",
    start="2000-01-01",
)
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
Panel 0.489 0.504 0.310 0.540 0.546 0.463 0.033 0.000 0.010 0.087
Mean years 0.493 0.502 0.321 0.545 0.546 0.458 0.020 0.393 0.005 0.432
Positive ratio 0.333 0.417 0.125 0.833 0.708 0.167 0.542 0.375 0.542 0.292
Mean cids 0.486 0.477 0.288 0.537 0.489 0.464 0.023 0.390 0.010 0.409
Positive ratio 0.308 0.538 0.077 0.923 0.769 0.077 0.769 0.538 0.615 0.462

A simple strategy that uses only the maturity spread (z-score around zero) as a signal for receiver versus payer position would not have created much positive PnL over the past 22 years. This is due mainly to its strong short-duration bias. Since, in stable monetary regimes, there is not much “escalation risk premium” on offer, the signal would have been negative in almost 70% of all weeks across all countries. The signal would have implied a massive short duration risk bias.

xcats_sel = xcats_msa_2vt
cids_sel = cids_vrp
start_date = "2000-01-01"
sigs = [xcats_sel[0]]

naive_pnl = msn.NaivePnL(
    dfx,
    ret=xcats_sel[1],
    sigs=sigs,
    cids=cids_sel,
    start=start_date,
)

for sig in sigs:
    naive_pnl.make_pnl(
        sig,
        sig_op="zn_score_pan",
        neutral="zero",
        thresh=2,
        rebal_freq="weekly",
        vol_scale=10,
        rebal_slip=1,
        pnl_name=sig + "PZ0",
    )
    naive_pnl.make_pnl(
        sig,
        sig_op="zn_score_pan",
        neutral="mean",
        thresh=2,
        rebal_freq="weekly",
        vol_scale=10,
        rebal_slip=1,
        pnl_name=sig + "PZM",
    )

naive_pnl.make_long_pnl(vol_scale=10, label="Long")

The chart illustrates that positive correlation and predictive power alone are not enough to make a good directional positioning signal. The signal must also set the right long-term bias and gather a critical mass of explanatory power for all the premia charged on a contract. The maturity spread, which reflects a single type of risk premium, cannot provide that. As with many short risk-bias strategies, its own overall performance as a trading signal is not impressive. However, it is a valid contributor to signal for directional exposure to duration risk.

pnls = [xcats_sel[0] + "PZ0", "Long"]

naive_pnl.plot_pnls(
    pnl_cats=pnls,
    pnl_cids=["ALL"],
    start=start_date,
    title="PnLs of global IRS positions (5-years tenor, targeted at 10% vol)",
    xcat_labels=[
        "based on maturity spreads only ",
        "constant long receivers only",
    ],
)
df_eval = naive_pnl.evaluate_pnls(
    pnl_cats=pnls,
    pnl_cids=["ALL"],
    start=start_date,
)
display(df_eval)
_images/Duration VRP and duration returns_105_0.png
xcat IRVRP_MS_5DMA_AVGPZ0 Long
Return (pct ar) 1.560677 4.284572
St. Dev. (pct ar) 10.0 10.0
Sharpe Ratio 0.156068 0.428457
Sortino Ratio 0.231886 0.594486
Max 21-day draw -20.397879 -19.202942
Max 6-month draw -35.210957 -40.49919
Traded Months 281 281
xcats_sel = xcats_tsa_52
cids_sel = ["USD"]
srr = mss.SignalReturnRelations(
    dfx,
    cids=cids_sel,
    sig=xcats_sel[0],
    ret=xcats_sel[1],
    sig_neg=False,
    freq="W",
    start="1992-01-01",
)
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
Panel 0.529 0.526 0.713 0.518 0.533 0.519 0.070 0.005 0.028 0.095
Mean years 0.527 0.538 0.719 0.520 0.528 0.514 0.022 0.499 0.013 0.565
Positive ratio 0.562 0.594 0.719 0.625 0.656 0.469 0.500 0.312 0.656 0.188
Mean cids 0.529 0.526 0.713 0.518 0.533 0.519 0.070 0.005 0.028 0.095
Positive ratio 1.000 1.000 1.000 1.000 1.000 1.000 1.000 1.000 1.000 1.000