FX trading signals and regressionbased learning #
This notebook illustrates the points discussed in the post “FX trading signals and regressionbased learning” on the Macrosynergy website. It demonstrates how regressionbased statistical learning helps build trading signals from multiple candidate constituents. The method optimizes models and hyperparameters sequentially and produces pointintime signals for backtesting and live trading. This post applies regressionbased learning to a selection of macro trading factors for developed market FX trading, using a novel crossvalidation method for expanding panel data. Sequentially optimized models consider nine theoretically valid macro trend indicators to predict FX forward returns. The learning process has delivered significant predictors of returns and consistent positive PnL generation for over 20 years. The most important macroFX signals, in the long run, have been relative labor market trends, manufacturing business sentiment changes, inflation expectations, and terms of trade dynamics.
The notebook is organized into three main sections:

Get Packages and JPMaQS Data: This section is dedicated to installing and importing the necessary Python packages for the analysis. It includes standard Python libraries like pandas and seaborn, as well as the
scikitlearn
package and the specializedmacrosynergy
package. 
Transformations and Checks: In this part, the notebook conducts data calculations and transformations to derive relevant signals and targets for the analysis. This involves constructing simple linear composite indicators, means, relative values, etc.

Actual regressionbased learning: , employing the learning module of
macrosynergy
package for LinearRegression() fromscikitlearn
package defining hyperparameter grid, employed models, crossvalidation splits, optimization criteria, etc. 
Value checks and comparison of conceptual parity signal with OLS based learning signals. The comparison is performed using standard performance metrics, as well as calculating naive PnLs (Profit and Loss) for simple relative trading strategies based on both risk parity and OLS based learning signals.

Alternative models of regressionbased learning explores additional models: Regularized linear model (elastic net), Signweighted linear regression, Timeweighted linear regression  using the standard performance metrics with naive PnL calculations.
Get packages and JPMaQS data #
import os
import numpy as np
import pandas as pd
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LinearRegression, ElasticNet
from sklearn.metrics import (
make_scorer,
r2_score,
)
import macrosynergy.management as msm
import macrosynergy.panel as msp
import macrosynergy.pnl as msn
import macrosynergy.signal as mss
import macrosynergy.learning as msl
import macrosynergy.visuals as msv
from macrosynergy.download import JPMaQSDownload
import warnings
warnings.simplefilter("ignore")
# Crosssections of interest  FX
cids_g3 = ["EUR", "JPY", "USD"] # DM large currency areas
cids_dmfx = ["AUD", "CAD", "CHF", "GBP", "NOK", "NZD", "SEK"] # DM small currency areas
cids_dm = cids_g3 + cids_dmfx
cids = cids_dm
cids_eur = ["CHF", "NOK", "SEK"] # trading against EUR
cids_eud = ["GBP"] # trading against EUR and USD
cids_usd = list(set(cids_dmfx)  set(cids_eur + cids_eud)) # trading against USD
# Quantamental features of interest
ir = [
"RIR_NSA",
]
ctots = [
"CTOT_NSA_P1M1ML12",
"CTOT_NSA_P1M12ML1",
"CTOT_NSA_P1W4WL1",
]
surv = [
"MBCSCORE_SA_D3M3ML3",
"MBCSCORE_SA_D1Q1QL1",
"MBCSCORE_SA_D6M6ML6",
"MBCSCORE_SA_D2Q2QL2",
]
ppi = [
"PGDPTECH_SA_P1M1ML12_3MMA",
]
gdps = [
"INTRGDP_NSA_P1M1ML12_3MMA",
]
emp = [
"UNEMPLRATE_SA_D3M3ML3",
"UNEMPLRATE_SA_D6M6ML6",
"UNEMPLRATE_SA_D1Q1QL1",
"UNEMPLRATE_SA_D2Q2QL2",
"UNEMPLRATE_NSA_3MMA_D1M1ML12",
"UNEMPLRATE_NSA_D1Q1QL4",
]
iip = [
"IIPLIABGDP_NSA",
]
cpi = [
"CPIXFE_SA_P1M1ML12",
"CPIXFE_SJA_P3M3ML3AR",
"CPIXFE_SJA_P6M6ML6AR",
"INFE1Y_JA",
"INFE2Y_JA",
"INFE5Y_JA",
]
tots = ctots
main = tots + gdps + surv + ir + emp + iip + ppi + cpi
adds = [
"INFTEFF_NSA",
]
mkts = [
"FXTARGETED_NSA",
"FXUNTRADABLE_NSA",
]
rets = [
"FXXR_VT10",
]
xcats = main + mkts + rets + adds
# Resultant tickers for download
single_tix = ["USD_GB10YXR_NSA", "EUR_FXXR_NSA", "USD_EQXR_NSA"]
tickers = [cid + "_" + xcat for cid in cids for xcat in xcats] + single_tix
JPMaQS indicators are conveniently grouped into six main categories: Economic Trends, Macroeconomic balance sheets, Financial conditions, Shocks and risk measures, Stylized trading factors, and Generic returns. Each indicator has a separate page with notes, description, availability, statistical measures, and timelines for main currencies. The description of each JPMaQS category is available under the Macro quantamental academy . For tickers used in this notebook see Real shortterm interest rates , Termsoftrade , Manufacturing confidence scores , Producer price inflation , Intuitive GDP growth estimates , Labor market dynamics , International investment position , Consistent core CPI trends , FX forward returns , and FX tradeability and flexibility . JPMaQS indicators are conveniently grouped into six main categories: Economic Trends, Macroeconomic balance sheets, Financial conditions, Shocks and risk measures, Stylized trading factors, and Generic returns. Each indicator has a separate page with notes, description, availability, statistical measures, and timelines for main currencies. The description of each JPMaQS category is available under Macro quantamental academy . For tickers used in this notebook see Real shortterm interest rates , Termsoftrade , Manufacturing confidence scores , Producer price inflation , Intuitive GDP growth estimates , Labor market dynamics , International investment position , Consistent core CPI trends , Inflation expectations , Inflation targets , FX forward returns , and FX tradeability and flexibility .
In this notebook, we introduce several lists of currencies for the sake of convenience in our subsequent analysis:

cids_g3
represents the 3 largest developed market currencies. 
cids_dmfx
represents DM small currency areas. 
cids_eur
trading against EUR. 
cids_eud
trading against EUR and USD 
cids_usd
trading against USD
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
DB(JPMAQS,<cross_section>_<category>,<info>)
, where
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 realtime 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
# Download series from J.P. Morgan DataQuery by tickers
start_date = "19900101"
end_date = None
# Retrieve credentials
oauth_id = os.getenv("DQ_CLIENT_ID") # Replace with own client ID
oauth_secret = os.getenv("DQ_CLIENT_SECRET") # Replace with own secret
# Download from DataQuery
downloader = JPMaQSDownload(client_id=oauth_id, client_secret=oauth_secret)
df = downloader.download(
tickers=tickers,
start_date=start_date,
end_date=end_date,
metrics=["value"],
suppress_warning=True,
show_progress=True,
)
dfx = df.copy()
dfx.info()
Downloading data from JPMaQS.
Timestamp UTC: 20240718 18:55:51
Connection successful!
Requesting data: 100%█████████████████████████████████████████████████████████████████ 14/14 [00:02<00:00, 4.71it/s]
Downloading data: 100%████████████████████████████████████████████████████████████████ 14/14 [00:10<00:00, 1.28it/s]
Some expressions are missing from the downloaded data. Check logger output for complete list.
53 out of 273 expressions are missing. To download the catalogue of all available expressions and filter the unavailable expressions, set `get_catalogue=True` in the call to `JPMaQSDownload.download()`.
Some dates are missing from the downloaded data.
2 out of 9016 dates are missing.
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 1627316 entries, 0 to 1627315
Data columns (total 4 columns):
# Column NonNull Count Dtype
   
0 real_date 1627316 nonnull datetime64[ns]
1 cid 1627316 nonnull object
2 xcat 1627316 nonnull object
3 value 1627316 nonnull float64
dtypes: datetime64[ns](1), float64(1), object(2)
memory usage: 49.7+ MB
Availability and blacklisting #
Renaming #
Rename quarterly tickers to roughly equivalent monthly tickers to simplify subsequent operations.
dict_repl = {
"UNEMPLRATE_NSA_D1Q1QL4": "UNEMPLRATE_NSA_3MMA_D1M1ML12",
"UNEMPLRATE_SA_D1Q1QL1": "UNEMPLRATE_SA_D3M3ML3",
"UNEMPLRATE_SA_D2Q2QL2": "UNEMPLRATE_SA_D6M6ML6",
"MBCSCORE_SA_D1Q1QL1": "MBCSCORE_SA_D3M3ML3",
"MBCSCORE_SA_D2Q2QL2": "MBCSCORE_SA_D6M6ML6",
}
for key, value in dict_repl.items():
dfx["xcat"] = dfx["xcat"].str.replace(key, value)
Check availability #
Prior to commencing any analysis, it is crucial to evaluate the accessibility of data. This evaluation serves several purposes, including identifying potential data gaps or constraints within the dataset. Such gaps can significantly influence the trustworthiness and accuracy of the analysis. Moreover, it aids in verifying that ample observations are accessible for each chosen category and crosssection. Additionally, it assists in establishing suitable timeframes for conducting the analysis.
xcatx = cpi + ppi + ir
msm.check_availability(df=dfx, xcats=xcatx, cids=cids, missing_recent=False)
xcatx = surv + emp
msm.check_availability(df=dfx, xcats=xcatx, cids=cids, missing_recent=False)
xcatx = gdps
msm.check_availability(df=dfx, xcats=xcatx, cids=cids, missing_recent=False)
xcatx = ctots
msm.check_availability(df=dfx, xcats=xcatx, cids=cids, missing_recent=False)
xcatx = iip
msm.check_availability(df=dfx, xcats=xcatx, cids=cids, missing_recent=False)
Blacklisting #
Identifying and isolating periods of official exchange rate targets, illiquidity, or convertibilityrelated distortions in FX markets is the first step in creating an FX trading strategy. These periods can significantly impact the behavior and dynamics of currency markets, and failing to account for them can lead to inaccurate or misleading findings. A standard blacklist dictionary (
fxblack
in the cell below) can be passed to several
macrosynergy
package functions that exclude the blacklisted periods from related analyses.
# Create blacklisting dictionary
dfb = df[df["xcat"].isin(["FXTARGETED_NSA", "FXUNTRADABLE_NSA"])].loc[
:, ["cid", "xcat", "real_date", "value"]
]
dfba = (
dfb.groupby(["cid", "real_date"])
.aggregate(value=pd.NamedAgg(column="value", aggfunc="max"))
.reset_index()
)
dfba["xcat"] = "FXBLACK"
fxblack = msp.make_blacklist(dfba, "FXBLACK")
fxblack
{'CHF': (Timestamp('20111003 00:00:00'), Timestamp('20150130 00:00:00'))}
Transformations and checks #
Singleconcept calculations #
Unemployment rate changes #
The
linear_composite
method from the
macrosynergy
package is used to combine individual category scores into a single composite indicator. This technique allows for the assignment of specific weights to each category, which can be adjusted over time according to analysis needs or data insights. In this example, the weights [4, 2, 1] are allocated to three different measures of unemployment trends: “UNEMPLRATE_SA_D3M3ML3”, “UNEMPLRATE_SA_D6M6ML6”, and “UNEMPLRATE_NSA_3MMA_D1M1ML12”. By applying these weights, a unified composite indicator, named
UNEMPLRATE_SA_D
, is created, offering a weighted perspective on unemployment trends.
# Combine to annualized change average
xcatx = [
"UNEMPLRATE_SA_D3M3ML3",
"UNEMPLRATE_SA_D6M6ML6",
"UNEMPLRATE_NSA_3MMA_D1M1ML12",
]
cidx = cids_dm
dfa = msp.linear_composite(
dfx,
xcats=xcatx,
cids=cidx,
weights=[4, 2, 1],
normalize_weights=True,
complete_xcats=False,
new_xcat="UNEMPLRATE_SA_D",
)
dfx = msm.update_df(dfx, dfa)
International liabilities trends #
To analyze trends in the information states of liabilities compared to the past 2 and 5 years, the process begins by computing 2Year and 5Year rolling averages. These averages are then subtracted from the current trend to highlight deviations or changes over the specified periods. Utilizing the
linear_composite
method from the
macrosynergy
package, these calculated deviations for each time frame are combined into a single composite indicator named
IIPLIABGDP_NSA_D
. In this aggregation, greater importance is assigned to the 2Year trend compared to the 5Year trend, as reflected by the allocation of higher weights to the former in the weighting scheme used within the function.
# Two plausible information state trends
cidx = cids_dm
calcs = [
" IIPLIABGDP_NSA_2YAVG = IIPLIABGDP_NSA.rolling(21*24).mean() ",
" IIPLIABGDP_NSA_5YAVG = IIPLIABGDP_NSA.rolling(21*60).mean() ",
" IIPLIABGDP_NSAv2YAVG = IIPLIABGDP_NSA  IIPLIABGDP_NSA_2YAVG ",
" IIPLIABGDP_NSAv5YAVG = IIPLIABGDP_NSA  IIPLIABGDP_NSA_5YAVG ",
]
dfa = msp.panel_calculator(dfx, calcs=calcs, cids=cidx)
dfx = msm.update_df(dfx, dfa)
# Combine to single trend measure
cidx = cids_dm
xcatx = [
"IIPLIABGDP_NSAv2YAVG",
"IIPLIABGDP_NSAv5YAVG",
]
dfa = msp.linear_composite(
dfx,
xcats=xcatx,
cids=cidx,
weights=[1/2, 1/5],
normalize_weights=True,
complete_xcats=False,
new_xcat="IIPLIABGDP_NSA_D",
)
dfx = msm.update_df(dfx, dfa)
Excess producer and core consumer price inflation #
linear_composite
method from the
macrosynergy
package is utilized to synthesize individual inflation trends scores into a consolidated composite indicator, applying equal weights to each score. This approach ensures that all identified inflation trends contribute equally to the formation of the composite indicator, facilitating a balanced and comprehensive overview of inflationary movements.
cidx = cids_dm
xcatx = ['CPIXFE_SA_P1M1ML12', 'CPIXFE_SJA_P3M3ML3AR', 'CPIXFE_SJA_P6M6ML6AR']
dfa = msp.linear_composite(
dfx,
xcats=xcatx,
cids=cidx,
complete_xcats=False,
new_xcat="CPIXFE_SA_PAR",
)
dfx = msm.update_df(dfx, dfa)
In this analysis, the difference is calculated between the estimated inflation expectations for the local economy, represented by the average labeled
CPIXFE_SA_PAR
, and the inflation expectations for the benchmark currency area, denoted by
INFTEFF_NSA
. This calculation produces a new indicator named
XCPIXFE_SA_PAR
, which reflects the deviation of local inflation expectations from those of the benchmark currency area.
Similarly, for the Producer Price Inflation trend, the same method is applied. The difference between the local Producer Price Inflation trend and the benchmark currency area’s trend is calculated. This results in the creation of a new indicator, which is labeled
XPGDPTECH_SA_P1M1ML12_3MMA
. This new indicator captures the disparity in the Producer Price Inflation trend between the local economy and the benchmark currency area, providing insights into relative inflationary pressures from the perspective of producers.
cidx = cids_dm
xcatx = ["CPIXFE_SA_PAR", "PGDPTECH_SA_P1M1ML12_3MMA"]
calcs = []
for xc in xcatx:
calcs.append(f" X{xc} = {xc}  INFTEFF_NSA ")
dfa = msp.panel_calculator(dfx, calcs=calcs, cids=cidx)
dfx = msm.update_df(dfx, dfa)
Inflation expectations #
The
linear_composite
method from the
macrosynergy
package is used to compute the average of 1year, 2year, and 5year inflation expectations, culminating in the creation of a composite indicator named
INFE_JA
. This method effectively consolidates the various inflation expectation horizons into a single, comprehensive measure, providing a more holistic view of expected inflation trends over multiple time frames.
cidx = cids_dm
xcatx = ['INFE1Y_JA', 'INFE2Y_JA', 'INFE5Y_JA']
dfa = msp.linear_composite(
dfx,
xcats=xcatx,
cids=cidx,
complete_xcats=False,
new_xcat="INFE_JA",
)
dfx = msm.update_df(dfx, dfa)
Commoditybased terms of trade improvement #
The
linear_composite
method from the
macrosynergy
package is utilized to develop a composite indicator of commoditybased terms of trade trends, named
CTOT_NSA_PAR
. In this process, weights are assigned to reflect the relative importance of shortterm changes, specifically comparing the most recent week against the preceding four weeks. This weighting strategy emphasizes the significance of the latest movements in commodity terms of trade, allowing the composite indicator to provide insights into shortterm trends and their potential impact on trade conditions.
cidx = cids_dm
xcatx = ["CTOT_NSA_P1M1ML12", "CTOT_NSA_P1M12ML1", "CTOT_NSA_P1W4WL1"]
dfa = msp.linear_composite(
dfx,
xcats=xcatx,
cids=cidx,
weights=[1/12, 1/6, 2],
normalize_weights=True,
complete_xcats=False,
new_xcat="CTOT_NSA_PAR",
)
dfx = msm.update_df(dfx, dfa)
Manufacturing confidence improvement #
Using the same weighting approach and function, the
linear_composite
method from the
macrosynergy
package is employed to aggregate individual category scores of manufacturing confidence into a consolidated composite indicator named
MBCSCORE_SA_D
. This process involves combining various measures of manufacturing confidence into a single indicator, with weights applied to emphasize specific aspects of the data according to predefined criteria. The creation of the
MBCSCORE_SA_D
indicator enables a comprehensive view of manufacturing confidence, streamlining the analysis of this sector’s sentiment.
cidx = cids_dm
xcatx = ["MBCSCORE_SA_D3M3ML3", "MBCSCORE_SA_D6M6ML6"]
dfa = msp.linear_composite(
dfx,
xcats=xcatx,
cids=cidx,
weights=[2, 1],
normalize_weights=True,
complete_xcats=False,
new_xcat="MBCSCORE_SA_D",
)
dfx = msm.update_df(dfx, dfa)
Relative values, sign adjustments and normalization #
The
make_relative_value()
function is designed to create a dataframe that showcases relative values for a specified list of categories. In this context, “relative” refers to comparing an original value against an average from a basket of choices. By default, this basket is composed of all available crosssections, with the relative value being derived by subtracting the average of the basket from each individual crosssection value.
For this specific application, relative values are computed against either the USD or EUR as benchmarks, contingent upon the currency in question. Currencies that are typically traded against the EUR, namely CHF, NOK, and SEK (grouped in the list
cids_eur
), are calculated in relation to the EUR. The GBP is unique in that it is considered in trading against both USD and EUR, while all other currencies for this analysis are benchmarked against the USD.
As a result of this process, the generated relative time series are appended with the postfix
_vBM
, indicating their comparison against a benchmark currency.
# Calculate relative values to benchmark currency areas
xcatx = [
"UNEMPLRATE_SA_D",
"RIR_NSA",
"XCPIXFE_SA_PAR",
"XPGDPTECH_SA_P1M1ML12_3MMA",
"INTRGDP_NSA_P1M1ML12_3MMA",
"INFE_JA",
]
dfa_usd = msp.make_relative_value(dfx, xcatx, cids_usd, basket=["USD"], postfix="vBM")
dfa_eur = msp.make_relative_value(dfx, xcatx, cids_eur, basket=["EUR"], postfix="vBM")
dfa_eud = msp.make_relative_value(
dfx, xcatx, cids_eud, basket=["EUR", "USD"], postfix="vBM"
)
dfa = pd.concat([dfa_eur, dfa_usd, dfa_eud])
dfx = msm.update_df(dfx, dfa)
To display and compare the values for one of the resulting
INFE_JAvBM
indicator we use
view_timelines()
function from the
macrosynergy
package:
# Check time series
xc = "INFE_JA"
cidx = cids_dmfx
xcatx = [xc, xc + "vBM"]
msp.view_timelines(
df=dfx,
xcats=xcatx,
cids=cidx,
start="20000101",
aspect=1.7,
ncol=3,
)
The negatives of relative unemployment rates and international liabilities to GDP series are defined. This is done for convenience, all constituent candidates are given the “right sign” that makes their theoretically expected predictive direction positive.
# Negative values
xcatx = ["UNEMPLRATE_SA_DvBM", "IIPLIABGDP_NSA_D"]
calcs = []
for xc in xcatx:
calcs += [f"{xc}_NEG =  {xc}"]
dfa = msp.panel_calculator(dfx, calcs=calcs, cids=cids_dmfx)
dfx = msm.update_df(dfx, dfa)
# Conceptual features with theoretical positive FX impact
cpos = [
"INFE_JAvBM",
"CTOT_NSA_PAR",
"UNEMPLRATE_SA_DvBM_NEG",
"IIPLIABGDP_NSA_D_NEG",
"RIR_NSAvBM",
"XCPIXFE_SA_PARvBM",
"MBCSCORE_SA_D",
"XPGDPTECH_SA_P1M1ML12_3MMAvBM",
"INTRGDP_NSA_P1M1ML12_3MMAvBM",
]
cpos.sort()
We normalize the plausible indicators collected in the list
cpos
above using
make_zn_scores()
function. This is a standard procedure aimed at making various categories comparable, in particular, summing or averaging categories with different units and time series properties.
# Znscores
xcatx = cpos
cidx = cids_dmfx
dfa = pd.DataFrame(columns=list(dfx.columns))
for xc in xcatx:
dfaa = msp.make_zn_scores(
dfx,
xcat=xc,
cids=cidx,
sequential=True,
min_obs=261 * 3,
neutral="zero",
pan_weight=1,
thresh=4,
postfix="_ZN",
est_freq="m",
)
dfa = msm.update_df(dfa, dfaa)
dfx = msm.update_df(dfx, dfa)
cpoz = [x + "_ZN" for x in cpos]
Renaming indicators to more intuitive names rather than technical tickers is a beneficial practice for enhancing the readability and interpretability of data analyses.
The
correl_matrix()
function visualizes correlation coefficient between signal constituent candidates for a panel of seven DM countries since
cidx = cids_dmfx
xcatx = cpoz
sdate = "20000101"
renaming_dict = {
"CTOT_NSA_PAR_ZN": "Termsoftrade dynamics",
"IIPLIABGDP_NSA_D_NEG_ZN": "International liability trends (negative)",
"INFE_JAvBM_ZN": "Relative inflation expectations",
"INTRGDP_NSA_P1M1ML12_3MMAvBM_ZN": "Relative excess GDP growth trends",
"MBCSCORE_SA_D_ZN": "Manufacturing confidence changes",
"RIR_NSAvBM_ZN": "Relative real interest rates",
"UNEMPLRATE_SA_DvBM_NEG_ZN": "Relative unemployment trends (negative)",
"XCPIXFE_SA_PARvBM_ZN": "Relative excess core CPI inflation",
"XPGDPTECH_SA_P1M1ML12_3MMAvBM_ZN": "Relative excess GDP deflators",
}
dfx_corr = dfx.copy()
for key, value in renaming_dict.items():
dfx_corr["xcat"] = dfx_corr["xcat"].str.replace(key, value)
msp.correl_matrix(
dfx_corr,
xcats=list(renaming_dict.values()),
cids=cidx,
start=sdate,
freq="M",
cluster=False,
title="Monthly cross correlation of signal constituent candidates for a panel of seven DM countries since 2000",
size=(14, 8),
)
Signal preparations #
Conceptual parity as benchmark #
linear_composite
method from the
macrosynergy
package aggregates the individual category scores into a unified (equally weighted) composite indicator
ALL_AVGZ
.
ALL_AVGZ
composite indicator serves as a benchmark for subsequent analyses, particularly in comparing its performance or relevance against signals optimized through machine learning techniques. By doing so, this composite indicator becomes a standard reference point, enabling a systematic evaluation of the effectiveness of machine learningderived signals in capturing the underlying dynamics represented by the individual category scores.
# Linear equallyweighted combination of all available candidates
xcatx = cpoz
cidx = cids_dmfx
dfa = msp.linear_composite(
df=dfx,
xcats=cpoz,
cids=cidx,
new_xcat="ALL_AVGZ",
)
dfx = msm.update_df(dfx, dfa)
Convert data to scikitlearn format #
Downsampling daily information states to a monthly frequency is a common preparation step for machine learning models, especially when dealing with financial and economic time series data. The
categories_df()
function applies the leg of 1 month and uses the last value in the month for explanatory variables and the sum for the aggregated target (return). As explanatory variables, we use plausible zscores of economic variables derived earlier and collected in the list
cpos
. As a target, we use
FXXR_VT10
, FX forward return for 10% vol target: dominant cross.
cidx = cids_dmfx
xcatx = cpoz + ["FXXR_VT10"]
# Downsample from daily to monthly frequency (features as last and target as sum)
dfw = msm.categories_df(
df=dfx,
xcats=xcatx,
cids=cidx,
freq="M",
lag=1,
blacklist=fxblack,
xcat_aggs=["last", "sum"],
)
# Drop rows with missing values and assign features and target
dfw.dropna(inplace=True)
X_fx = dfw.iloc[:, :1]
y_fx = dfw.iloc[:, 1]
Define splitter and scorer #
The
RollingKFoldPanelSplit
class instantiates splitters where temporally adjacent panel training sets of fixed joint maximum time spans can border the test set from both the past and future. Thus, most folds do not respect the chronological order but allow training with past and future information. While this does not simulate the evolution of information, it makes better use of the available data and is often acceptable for macro data as economic regimes come in cycles. It is equivalent to
scikitlearn
’s
Kfold class
but adapted for panels.
The standard
make_scorer()
function from the
scikitlearn
library is used to create a scorer object that is used to evaluate the performance on the test set. The standard
r2_score
function is used as a scorer.
splitter = msl.RollingKFoldPanelSplit(n_splits=5)
scorer = make_scorer(r2_score, greater_is_better=True)
The method
visualise_splits()
is a convenient method for visualizing the splits produced by each child splitter, giving the user confidence in the splits produced for their use case.
splitter.visualise_splits(X_fx, y_fx)
OLS/NNLS regressionbased learning #
Sequential optimization and analysis #
For a straightforward Ordinary Least Squares (OLS) learning process, employing the
LinearRegression()
from
scikitlearn
offers a streamlined and efficient approach to regression analysis. When setting up this model for machine learning, two hyperparameter decisions can significantly influence the model’s behavior and its interpretation of the data:

Nonnegativity constraint: This offers the option of nonnegative least squares (NNLS), rather than simple OLS. NNLS imposes the constraint that the coefficients must be nonnegative. The benefit of this restriction is that it allows consideration of theoretical priors on the direction of impact, reducing dependence on scarce data.

Inclusion of a regression intercept: Conceptually the neutral level of all (mostly relative) signal constituent candidates is zero. Hence, the regression intercept is presumed to be zero, albeit that may not always be exact, and some theoretical assumptions may have been wrong.
# Specify model options and grids
mods_ols = {
"ls": LinearRegression(),
}
grid_ols = {
"ls": {"positive": [True, False], "fit_intercept": [True, False]},
}
SignalOptimizer
class from the
macrosynergy
package is a sophisticated tool designed to facilitate the process of optimizing and evaluating different signals (predictors) for forecasting models in a time series context. Leveraging the earlier defined crossvalidation split (
RollingKFoldPanelSplit
), the blacklist period, defined data frames of dependent and independent variables, and a few other optional parameters, we instantiate the
SignalOptimizer
class.
calculate_predictions()
method is used to calculate, store and return sequentially optimized signals for a given process. This method implements the nested crossvalidation and subsequent signal generation. The name of the process, together with models to fit, hyperparameters to search over and a metric to optimize, are provided as compulsory arguments.
# Initiate signal optimizer and derive optimal FX signals
so_ls = msl.SignalOptimizer(
inner_splitter=splitter,
X=X_fx,
y=y_fx,
blacklist=fxblack,
initial_nsplits=5,
threshold_ndates=36,
)
so_ls.calculate_predictions(
name="LS",
models=mods_ols,
hparam_grid=grid_ols,
metric=scorer,
min_cids=2,
min_periods=36,
)
dfa = so_ls.get_optimized_signals()
dfx = msm.update_df(dfx, dfa)
The “heatmap” serves as a powerful tool to illustrate the evolution and selection of predictive models over time, especially in a context. When the visualization indicates that over time the preferred model becomes the most restrictive one, it suggests a shift towards models with tighter constraints or fewer degrees of freedom.
# Illustrate model choice
so_ls.models_heatmap(
"LS",
title="Selected leastsquares models over time, based on crossvalidation and R2",
figsize=(12, 4),
)
# Number of splits used for crossvalidation over time
so_ls.nsplits_timeplot("LS", title="Number of splits used for rolling kfold crossvalidation")
coefs_timeplot()
custom function is designed for plotting the time series of feature coefficients obtained from regression models over time. This kind of visualization can be particularly valuable to understand the dynamics and stability of model coefficients, which is crucial for interpretation and forecasting.
ftrs_dict = {
"CTOT_NSA_PAR_ZN": "Termsoftrade trend",
"IIPLIABGDP_NSA_D_NEG_ZN": "International liability trends (negative)",
"INFE_JAvBM_ZN": "Relative inflation expectations",
"INTRGDP_NSA_P1M1ML12_3MMAvBM_ZN": "Relative GDP growth trends",
"MBCSCORE_SA_D_ZN": "Manufacturing confidence changes",
"RIR_NSAvBM_ZN": "Relative real interest rates",
"UNEMPLRATE_SA_DvBM_NEG_ZN": "Relative unemployment trends (negative)",
"XCPIXFE_SA_PARvBM_ZN": "Relative excess core inflation",
"XPGDPTECH_SA_P1M1ML12_3MMAvBM_ZN": "Relative excess GDP deflator growth",
}
so_ls.coefs_timeplot("LS", title="Model coefficients of normalized features",ftrs_renamed=ftrs_dict)
coefs_stackedbarplot()
is another convenient function, generating stacked bar plots to visualize the coefficients of features from regression models. This visualization method effectively displays the magnitude and direction of each feature’s influence on the outcome, allowing for easy comparison and interpretation of how different predictors contribute to the model.
so_ls.coefs_stackedbarplot("LS",
title="Optimal models' average regression coefficients for normalized features",
ftrs_renamed=ftrs_dict)
The
intercepts_timeplot()
function is designed to plot the time series of intercepts from regression models, enabling an analysis of how the model intercepts change over time.
so_ls.intercepts_timeplot("LS",
title="Intercepts of optimal models over time")
To display and compare the values for conceptual parity
ALL_AVGZ
versus OLSbased learning indicator
LS
we use
view_timelines()
function from the
macrosynergy
package
sigs_ls = [
"ALL_AVGZ",
"LS",
]
xcatx = sigs_ls
cidx = cids_dmfx
msp.view_timelines(
dfx,
xcats=xcatx,
cids=cidx,
ncol=4,
start="20030829",
title="Developed market FX forward trading signals: conceptual parity versus OLSbased learning",
title_fontsize=30,
same_y=False,
cs_mean=False,
xcat_labels=["Conceptual parity signal", "OLSbased learning signal"],
legend_fontsize=16,
)
Signal value checks #
Specs and panel test #
sigs = ["ALL_AVGZ", "LS"]
targs = ["FXXR_VT10"]
cidx = cids_dmfx
sdate = "20030829"
dict_ls = {
"sigs": sigs,
"targs": targs,
"cidx": cidx,
"start": sdate,
"black": fxblack,
"srr": None,
"pnls": None,
}
CategoryRelations
facilitates the visualization and analysis of relationships between two specific categories, namely, two panels of time series data. This tool is used to examine the interaction between the risk parity “ALL_AVGZ”, and optimized “LS”  on one side, and the FXXR_VT10, which represents the FX forward return for a 10% volatility target, focusing on the dominant cross, on the other. The
multiple_reg_scatter
method of the class displays correlation scatters for the two pairs.
The plot below shows the significant positive predictive power of the OLS learningbased signals with respect to subsequent weekly, monthly, and quarterly FX returns. The correlation coefficient for the OLS learningbased signal is considerably higher than for the risk parity version.
dix = dict_ls
sigx = dix["sigs"]
tarx = dix["targs"]
cidx = dix["cidx"]
blax = dix["black"]
start = dix["start"]
def crmaker(sig, targ):
crx = msp.CategoryRelations(
dfx,
xcats=[sig, targ],
cids=cidx,
freq="Q",
lag=1,
xcat_aggs=["last", "sum"],
start=start,
blacklist=blax,
)
return crx
lcrs = [crmaker(sig, targ) for sig in sigx for targ in tarx]
msv.multiple_reg_scatter(
lcrs,
ncol=2,
nrow=1,
figsize=(20, 10),
title="Macro signals and subsequent quarterly cumulative FX returns, 2003  2024",
xlab="Monthend macro signal value",
ylab="Cumulative FX forward return, 10% voltargeted position, next quarter",
coef_box="lower right",
prob_est="map",
subplot_titles=["Conceptual parity signals", "OLSbased learning signals"],
)
Accuracy and correlation check #
The
SignalReturnRelations
class of the
macrosynergy
package is specifically designed to assess the predictive power of signal categories in determining the direction of subsequent returns, particularly for data structured in the JPMaQS format. This class provides a streamlined approach for evaluating how well a given signal can forecast future market movements, offering insights for investment strategies, risk management, and economic analysis. It helps to identify which indicators have more predictive power.
## Compare optimized signals with simple average zscores
dix = dict_ls
sigx = dix["sigs"]
tarx = dix["targs"]
cidx = dix["cidx"]
blax = dix["black"]
startx = dix["start"]
srr = mss.SignalReturnRelations(
df=dfx,
rets=tarx,
sigs=sigx,
cids=cidx,
cosp=True,
freqs=["M"],
agg_sigs=["last"],
start=startx,
blacklist=blax,
slip=1,
ms_panel_test=True,
)
dix["srr"] = srr
srr = dict_ls["srr"]
selcols = [
"accuracy",
"bal_accuracy",
"pos_sigr",
"pos_retr",
"pearson",
"map_pval",
"kendall",
"kendall_pval",
]
srr.multiple_relations_table().round(3)[selcols]
accuracy  bal_accuracy  pos_sigr  pos_retr  pearson  map_pval  kendall  kendall_pval  

Return  Signal  Frequency  Aggregation  
FXXR_VT10  ALL_AVGZ  M  last  0.523  0.523  0.506  0.507  0.057  0.033  0.043  0.007 
LS  M  last  0.535  0.535  0.541  0.507  0.087  0.044  0.065  0.000 
Naive PnL #
In this analysis, the notebook calculates the naive Profit and Loss (PnL) for foreign exchange (FX) strategies utilizing both risk parity and OLSbased learning indicators. These PnL calculations are derived from simple trading strategies that interpret the indicators as signals to either go long (buy) or short (sell) FX positions. Specifically, the direction of the indicators guides the trading decisions, where a positive signal might suggest buying (going long) the FX asset, and a negative signal might suggest selling (going short). This approach allows for a direct evaluation of how effectively each indicator—risk parity and OLSbased—can predict profitable trading opportunities in the FX market.
Please refer to
NaivePnl()
class
for details.
dix = dict_ls
sigx = dix["sigs"]
tarx = dix["targs"]
cidx = dix["cidx"]
blax = dix["black"]
startx = dix["start"]
pnls = msn.NaivePnL(
df=dfx,
ret=tarx[0],
sigs=sigx,
cids=cidx,
start=startx,
blacklist=blax,
bms=["USD_GB10YXR_NSA", "EUR_FXXR_NSA", "USD_EQXR_NSA"],
)
for sig in sigx:
pnls.make_pnl(
sig=sig,
sig_op="zn_score_pan",
rebal_freq="monthly",
neutral="zero",
rebal_slip=1,
vol_scale=10,
thresh=4,
)
pnls.make_long_pnl(vol_scale=10, label="Long only")
dix["pnls"] = pnls
The PnLs are plotted using
.plot_pnls()
method of the
NaivePnl()
class
. The performance characteristics of the learningbased signal are encouraging. One should consider that the strategy only has seven markets to trade in, some of which are highly correlated. Also, the strategy only macro signals, without any other consideration of market prices and volumes. Position changes are infrequent and gentle.
pnls = dix["pnls"]
sigx = dix["sigs"]
pnls.plot_pnls(
title="Developed market FX forward trading signals: conceptual parity versus OLSbased learning",
title_fontsize=14,
xcat_labels=["Conceptual parity", "OLSbased learning", "Long only (small currencies)"],
)
pnls.evaluate_pnls(pnl_cats=["PNL_" + sig for sig in sigx] + ["Long only"])
xcat  Long only  PNL_ALL_AVGZ  PNL_LS 

Return %  0.311261  5.052502  5.461789 
St. Dev. %  10.0  10.0  10.0 
Sharpe Ratio  0.031126  0.50525  0.546179 
Sortino Ratio  0.042749  0.712779  0.770022 
Max 21Day Draw %  23.193956  18.616331  16.137935 
Max 6Month Draw %  22.927876  17.18243  16.206219 
Peak to Trough Draw %  72.453669  27.936565  20.354461 
Top 5% Monthly PnL Share  11.169925  0.762875  0.609932 
USD_GB10YXR_NSA correl  0.013502  0.093614  0.081175 
EUR_FXXR_NSA correl  0.411255  0.143329  0.156879 
USD_EQXR_NSA correl  0.246571  0.131913  0.04883 
Traded Months  252  252  252 
Alternative models of regressionbased learning #
Regularized linear model (elastic net) #
Beyond simple OLS regressionbased learning the post considers three other types of regressionbased learning processes.
The first alternative model is Regularized regressionbased learning ElasticNet
# Specify model options and grids
mods_en = {
"en": ElasticNet(),
"ls": LinearRegression(),
}
grid_en = {
"en": {
"positive": [True, False],
"fit_intercept": [True, False],
"alpha": [1e4, 1e3, 1e2, 1e1, 1],
"l1_ratio": [0.1, 0.25, 0.5, 0.75, 0.9]
},
"ls": {
"positive": [True, False],
"fit_intercept": [True, False],
}
}
For the ElasticNet model, similar to the OLS regression approach,
SignalOptimizer
class from the
macrosynergy
package is instantiated using earlier defined crossvalidation split (
RollingKFoldPanelSplit
), blacklist period, dataframes of dependent and independent variables and a few other optional parameters.
calculate_predictions()
method is used to calculate, store and return sequentially optimized signals for a given process. This method implements the nested crossvalidation and subsequent signal generation. The name of the process, together with models to fit, hyperparameters to search over and a metric to optimize, are provided as compulsory arguments.
# Initiate signal optimizer and derive optimal FX signals
so_en = msl.SignalOptimizer(
inner_splitter=splitter,
X=X_fx,
y=y_fx,
blacklist=fxblack,
initial_nsplits=5,
threshold_ndates=36,
)
so_en.calculate_predictions(
name="EN",
models=mods_en,
hparam_grid=grid_en,
metric=scorer,
min_cids=2,
min_periods=36,
)
dfa = so_en.get_optimized_signals()
dfx = msm.update_df(dfx, dfa)
The “heatmap” serves as a powerful tool to illustrate the evolution and selection of predictive models over time.
# Illustrate model choice
so_en.models_heatmap(
"EN",
title="Selected elastic net models over time, based on crossvalidation and R2",
figsize=(12, 3),
)
The
coefs_timeplot()
function is used to plot the time series of feature coefficients, providing a visual representation of how the importance or influence of each feature in the model changes over time.
so_en.coefs_timeplot("EN", title="Model coefficients of normalized features", ftrs_renamed=ftrs_dict)
coefs_stackedbarplot()
is another convenient function, allowing to create a stacked bar plot of feature coefficients.
so_en.coefs_stackedbarplot("EN", title="Model coefficients of normalized features", ftrs_renamed=ftrs_dict)
intercepts_timeplot()
plots the time series of intercepts.
so_en.intercepts_timeplot("EN")
Signweighted linear regression #
The second alternative machine learning model is signweighted WLS linear regression. Signweighted least squares equalize the contribution of positive and negative target samples to the model fit. The learning process can choose between signweighted and ordinary least squares, both with intercept exclusion and nonnegativity constraints.
SignWeightedLinearRegression
is a custom class to create a WLS linear regression model, with the sample weights chosen by inverse frequency of the label’s sign in the training set.
As for simple OLS regression, two hyperparameter decisions need to be made:

Nonnegativity constraint: This offers the option of nonnegative least squares (NNLS), rather than simple OLS. NNLS imposes the constraint that the coefficients must be nonnegative. The benefit of this restriction is that it allows for the consideration of theoretical priors on the direction of impact, reducing dependence on scarce data.

Inclusion of a regression intercept: Conceptually the neutral level of all (mostly relative) signal constituent candidates is zero. Hence, the regression intercept is presumed to be zero, albeit that may not always be exact, and some theoretical assumptions may have been wrong.
# Specify model options and grids
mods_sw = {
"swls": msl.SignWeightedLinearRegression(),
"ls": LinearRegression(),
}
grid_sw = {
"swls": {"positive": [True, False], "fit_intercept": [True, False]},
"ls": {"positive": [True, False], "fit_intercept": [True, False]},
}
SignalOptimizer
class from the
macrosynergy
package is instantiated using earlier defined crossvalidation split (RollingKFoldPanelSplit), blacklist period, dataframes of dependent and independent variables and few other optional parameters.
calculate_predictions()
method is used to calculate, store and return sequentially optimized signals for a given process. This method implements the nested crossvalidation and subsequent signal generation. The name of the process, together with models to fit, hyperparameters to search over and a metric to optimize, are provided as compulsory arguments.
# Initiate signal optimizer and derive optimal FX signals
so_sw = msl.SignalOptimizer(
inner_splitter=splitter,
X=X_fx,
y=y_fx,
blacklist=fxblack,
initial_nsplits=5,
threshold_ndates=36,
)
so_sw.calculate_predictions(
name="SW",
models=mods_sw,
hparam_grid=grid_sw,
metric=scorer,
min_cids=2,
min_periods=36,
)
dfa = so_sw.get_optimized_signals()
dfx = msm.update_df(dfx, dfa)
The “heatmap” serves as a powerful tool to illustrate the evolution and selection of predictive models over time:
# Illustrate model choice
so_sw.models_heatmap(
"SW",
title="Selected signweighted or unweighted least square models, based on crossvalidation and R2",
figsize=(12, 3),
)
Timeweighted regression #
The last alternative machine learning model is timeweighted regressionbased learning. TimeWeighted Least Squares allow prioritizing more recent information in the model fit by defining a halflife of exponential decay in units of the native dataset frequency. The learning process can choose between timeweighted and ordinary least squares.
TimeWeightedLinearRegression
is a custom class to create a WLS linear regression model, where the training sample weights exponentially decay by sample recency, given a prescribed half_life.
Key hyperparameters of this model are:

Nonnegativity constraint: option of nonnegative least squares (NNLS), rather than simple OLS

Inclusion of a regression intercept: decides whether to include an intercept, acknowledging cases where the assumption of a zero neutral level might not hold.

Halflife specification: defines the decay rate for weighting recent observations more heavily, with a selectable range between 12 to 240 months, adapting the model to the specific temporal sensitivity of the dataset.
# Specify model options and grids
mods_tw = {
"twls": msl.TimeWeightedLinearRegression(),
"ls": LinearRegression(),
}
grid_tw = {
"twls": {"positive": [True, False], "fit_intercept": [True, False], "half_life": [12, 24, 36, 60, 120, 240]},
"ls": {"positive": [True, False], "fit_intercept": [True, False]},
}
SignalOptimizer
class from the
macrosynergy
package is instantiated using a timerespecting crossvalidator split (
ExpandingKFoldPanelSplit
), blacklist period, dataframes of dependent and independent variables and few other optional parameters. This expanding splitter is required because the model takes into account the sequential component of the panel.
calculate_predictions()
method is used to calculate, store and return sequentially optimized signals for a given process. This method implements the nested crossvalidation and subsequent signal generation. The name of the process, together with models to fit, hyperparameters to search over and a metric to optimize, are provided as compulsory arguments.
# Initiate signal optimizer and derive optimal FX signals
so_tw = msl.SignalOptimizer(
inner_splitter=msl.ExpandingKFoldPanelSplit(n_splits=5),
X=X_fx,
y=y_fx,
blacklist=fxblack,
initial_nsplits=5,
threshold_ndates=36,
)
so_tw.calculate_predictions(
name="TW",
models=mods_tw,
hparam_grid=grid_tw,
metric=scorer,
min_cids=2,
min_periods=36,
)
dfa = so_tw.get_optimized_signals()
dfx = msm.update_df(dfx, dfa)
The “heatmap” serves as a powerful tool to illustrate the evolution and selection of predictive models over time
# Illustrate model choice
so_tw.models_heatmap(
"TW",
title="Selected timeweighted or unweighted least square models, based on crossvalidation and R2",
figsize=(12, 3),
)
so_tw.coefs_timeplot("TW", title="Model coefficients of normalized features", ftrs_renamed=ftrs_dict)
so_tw.coefs_stackedbarplot("TW", title="Model coefficients of normalized features", ftrs_renamed=ftrs_dict)
so_tw.intercepts_timeplot("TW")
Signal value checks #
Specs and panel test #
This part of the notebook checks and compares the predictive powers of the three alternative regressionbased learning processes:
• Regularized regressionbased learning
• Signweighted regressionbased learning
• Timeweighted regressionbased learning
with standard regressionbased learning approach OLS.
This part follows the same structure as above, where we compared conceptual parity
ALL_AVGZ
versus OLSbased learning indicator
LS
. In this part, we compare the four optimized indicators generated by the above models:

CategoryRelations
andmultiple_reg_scatter
analyze and visualize the relationships between the four optimized signals  “LS”, “EN”, “SW”, “TW”  on one side, and the FXXR_VT10, which represents the FX forward return for a 10% volatility target, focusing on the dominant cross, on the other. 
The
SignalReturnRelations
class of themacrosynergy
package assess the predictive power of the signals 
NaivePnl() class
provides a quick and simple overview of a stylized PnL profile of a set of trading signals and plots line charts of cumulative PnLs:
sigs = ["ALL_AVGZ", "LS", "EN", "SW", "TW"]
targs = ["FXXR_VT10"]
cidx = cids_dmfx
dict_ams = {
"sigs": sigs,
"targs": targs,
"cidx": cidx,
"start": dix["start"],
"black": fxblack,
"srr": None,
"pnls": None,
}
dix = dict_ams
sigx = dix["sigs"]
tarx = dix["targs"]
cidx = dix["cidx"]
blax = dix["black"]
start = dix["start"]
def crmaker(sig, targ):
crx = msp.CategoryRelations(
dfx,
xcats=[sig, targ],
cids=cidx,
freq="Q",
lag=1,
xcat_aggs=["last", "sum"],
start=start,
blacklist=blax,
)
return crx
lcrs = [crmaker(sig, targ) for sig in sigx[1:] for targ in tarx]
msv.multiple_reg_scatter(
lcrs,
ncol=2,
nrow=2,
title="Macro signals and subsequent quarterly cumulative FX returns, 2003  2024",
xlab="Conceptual scores / regression predictors ",
ylab="Cumulative FX forward return, 10% voltarget, next quarter",
coef_box="lower right",
prob_est="map",
subplot_titles=["OLSbased learning", "Elastic net", "Signweighted", "Timeweighted"],
)
Accuracy and correlation check #
The
SignalReturnRelations
class of the
macrosynergy
package facilitates a quick assessment of the power of a signal category in predicting the direction of subsequent returns for data in JPMaQS format.
## Compare optimized signals
dix = dict_ams
sigx = dix["sigs"]
tarx = dix["targs"]
cidx = dix["cidx"]
blax = dix["black"]
start = dix["start"]
srr = mss.SignalReturnRelations(
df=dfx,
rets=tarx,
sigs=sigx,
cids=cids_dmfx,
cosp=True,
freqs=["M"],
agg_sigs=["last"],
start=start,
blacklist=fxblack,
slip=1,
ms_panel_test=True,
)
dix["srr"] = srr
srr = dict_ams["srr"]
selcols = [
"accuracy",
"bal_accuracy",
"pos_sigr",
"pos_retr",
"pearson",
"map_pval",
"kendall",
"kendall_pval",
]
srr.multiple_relations_table().round(3)[selcols]
accuracy  bal_accuracy  pos_sigr  pos_retr  pearson  map_pval  kendall  kendall_pval  

Return  Signal  Frequency  Aggregation  
FXXR_VT10  ALL_AVGZ  M  last  0.523  0.523  0.506  0.507  0.057  0.033  0.043  0.007 
EN  M  last  0.544  0.545  0.560  0.503  0.072  0.217  0.058  0.000  
LS  M  last  0.535  0.535  0.541  0.507  0.087  0.044  0.065  0.000  
SW  M  last  0.537  0.536  0.534  0.507  0.087  0.046  0.065  0.000  
TW  M  last  0.526  0.526  0.486  0.507  0.084  0.040  0.053  0.001 
Naive PnL #
NaivePnl()
class
from
macrosynergy
package is designed to provide a quick and simple overview of a stylized PnL profile of a set of trading signals.
The related
make_pnl()
method calculates and stores generic PnLs based on a range of signals and their transformations into positions. The positioning options include choice of trading frequency, zscoring, simple equalsize longshort positions (1/1) thresholds to prevent outsized positions, and rebalancing slippage.
The generated PnLs are, however, naive insofar as they do not consider trading costs and plausible risk management restrictions. Also, if a volatility scale is set this is done so expost, mainly for the benefit of plotting different signals’ PnLs in a single chart.
A complementary method is
make_long_pnl()
, which calculates a “longonly” PnL based on a uniform long position across all markets at all times. This often serves as a benchmark for gauging the benefits of active trading.
dix = dict_ams
sigx = dix["sigs"]
tarx = dix["targs"]
cidx = dix["cidx"]
blax = dix["black"]
start = dix["start"]
pnls = msn.NaivePnL(
df=dfx,
ret=tarx[0],
sigs=sigx,
cids=cidx,
start=start,
blacklist=fxblack,
bms=["USD_GB10YXR_NSA", "EUR_FXXR_NSA", "USD_EQXR_NSA"],
)
for sig in sigx:
pnls.make_pnl(
sig=sig,
sig_op="zn_score_pan",
rebal_freq="monthly",
neutral="zero",
rebal_slip=1,
vol_scale=10,
thresh=3,
)
pnls.make_long_pnl(vol_scale=10, label="Long only")
dix["pnls"] = pnls
The PnLs are plotted using
.plot_pnls()
method of the
NaivePnl()
class
. These plots mainly inform on seasonality and stability of value generation under the assumption of negligible transaction costs. The post argues, that the underperformance of elastic net methods may not be accidental. Shrinkagebased regularization involves reducing model coefficients so that small changes in the input(s) lead to small changes in the predictions made. The idea is that introducing some bias into the regression can help reduce the variance in predictions made. Whilst theoretically sound, this in practice leads to more frequent model changes, as the appropriate type and size of penalties must be estimated based on the scarce data. This accentuates a source of signal variation that is unrelated to macro trends.
dix = dict_ams
pnls = dix["pnls"]
sigx = dix["sigs"]
pnls.plot_pnls(
pnl_cats=["PNL_" + sig for sig in sigx[1:]],
title="Developed market FX forward trading signals: various regressionbased learning methods",
title_fontsize=14,
xcat_labels=["OLS", "Elastic net", "Signweighted", "Timeweighted"],
)
pnls.evaluate_pnls(pnl_cats=["PNL_" + sig for sig in sigx] + ["Long only"])
xcat  Long only  PNL_ALL_AVGZ  PNL_EN  PNL_LS  PNL_SW  PNL_TW 

Return %  0.311261  5.181379  3.630834  5.437446  5.564073  4.815589 
St. Dev. %  10.0  10.0  10.0  10.0  10.0  10.0 
Sharpe Ratio  0.031126  0.518138  0.363083  0.543745  0.556407  0.481559 
Sortino Ratio  0.042749  0.73207  0.508149  0.765107  0.785278  0.697791 
Max 21Day Draw %  23.193956  15.879929  13.123343  16.277462  16.536405  11.769125 
Max 6Month Draw %  22.927876  14.733593  17.047512  16.346337  16.823309  14.671148 
Peak to Trough Draw %  72.453669  28.692588  23.73028  20.751495  21.322069  27.778388 
Top 5% Monthly PnL Share  11.169925  0.752091  0.973851  0.612461  0.612486  0.970613 
USD_GB10YXR_NSA correl  0.013502  0.092114  0.096101  0.080681  0.082405  0.026329 
EUR_FXXR_NSA correl  0.411255  0.136912  0.204481  0.156307  0.145728  0.028311 
USD_EQXR_NSA correl  0.246571  0.13044  0.06025  0.047537  0.043694  0.046068 
Traded Months  252  252  252  252  252  252 
Correcting linear models for statistical precision #
A notable issue with using regressionbased learning as the sole component of a macro trading signal is that macroeconomic trends take time to form, meaning that model coefficients (and predictions) exhibit greater variability in early years, before the correlations have stabilised. As a consequence, absolute model coefficients have a tendency to decrease over time, producing smaller signals. This is exaggerated by the
NaivePnL
calculation because the signal considered by the PnL is a windorized zscore with standard deviation updated over time.
One way of mitigating this is by adjusting linear model coefficients by their estimated standard errors. The resulting factor model has coefficients that take into account statistical precision of the parameter estimates based on sample size.
OLS/NNLS: analytical standard error adjustment #
# Specify model options and grids
mods_mls = {
"mls": msl.ModifiedLinearRegression(method="analytic", error_offset=1e5),
}
grid_mls = {
"mls": {"positive": [True, False], "fit_intercept": [True, False]},
}
# Initiate signal optimizer and derive optimal FX signals
so_mls = msl.SignalOptimizer(
inner_splitter=splitter,
X=X_fx,
y=y_fx,
blacklist=fxblack,
initial_nsplits=5,
threshold_ndates=36,
)
so_mls.calculate_predictions(
name="MLS_analytic",
models=mods_mls,
hparam_grid=grid_mls,
metric=scorer,
min_cids=2,
min_periods=36,
)
dfa = so_mls.get_optimized_signals()
dfx = msm.update_df(dfx, dfa)
# Illustrate model choice
so_mls.models_heatmap(
"MLS_analytic",
title="Modified least squares, analytical standard error adjustment, based on crossvalidation and R2",
figsize=(12, 3),
)
so_mls.coefs_stackedbarplot("MLS_analytic", title="Model coefficients of modified least squares features", ftrs_renamed=ftrs_dict)
OLS/NNLS: White’s estimator for standard errors #
The usual standard error expressions are subject to assumptions that are violated  particularly the assumption of constant error variance. White’s estimator is a standard error estimator that takes into account heteroskedasticity. We implement the HC3 version of this estimator.
# Specify model options and grids
mods_mls = {
"mls": msl.ModifiedLinearRegression(method="analytic", analytic_method="White", error_offset=1e5),
}
grid_mls = {
"mls": {"positive": [True, False], "fit_intercept": [True, False]},
}
so_mls.calculate_predictions(
name="MLS_white",
models=mods_mls,
hparam_grid=grid_mls,
metric=scorer,
min_cids=2,
min_periods=36,
)
dfa = so_mls.get_optimized_signals(name="MLS_white")
dfx = msm.update_df(dfx, dfa)
OLS/NNLS: panel bootstrap standard error adjustment #
# Specify model options and grids
mods_mls = {
"mls": msl.predictors.ModifiedLinearRegression(method="bootstrap",bootstrap_iters=100, error_offset=1e5),
}
grid_mls = {
"mls": {"positive": [True, False], "fit_intercept": [True, False]},
}
so_mls.calculate_predictions(
name="MLS_bootstrap",
models=mods_mls,
hparam_grid=grid_mls,
metric=scorer,
min_cids=2,
min_periods=36,
)
dfa = so_mls.get_optimized_signals(name="MLS_bootstrap")
dfx = msm.update_df(dfx, dfa)
# Illustrate model choice
so_mls.models_heatmap(
"MLS_bootstrap",
title="Modified least squares, bootstrap standard error adjustment, based on crossvalidation and R2",
figsize=(12, 3),
)
so_mls.coefs_stackedbarplot("MLS_bootstrap", title="Model coefficients of modified least squares features, panel bootstrap", ftrs_renamed=ftrs_dict)
Comparison #
xcatx = [
"MLS_analytic",
"MLS_white",
"MLS_bootstrap",
"LS"
]
cidx = cids_dmfx
msp.view_timelines(
df = dfx,
xcats = xcatx,
cids = cidx,
title="Signals based on adjusted factor coefficients",
xcat_labels=["Defacto", "White", "Panel bootstrap", "Unadjusted"]
)
By adjusting the coefficients, the value generated in the early history is mitigated  as hoped  resulting in more consistent PnL generation over the 20 years. A consequence of adjusting by statistical precision is that one is able to take greater advantage of more recent periods where there is a clear long/short signal, instead of the signal petering out over time.
sigx = [
"ALL_AVGZ",
"MLS_analytic",
"MLS_white",
"MLS_bootstrap",
"LS",
]
pnl = msn.NaivePnL(
df = dfx,
ret = "FXXR_VT10",
cids=cids_dmfx,
sigs = sigx,
blacklist=fxblack,
start="20040101",
bms=["USD_GB10YXR_NSA", "EUR_FXXR_NSA", "USD_EQXR_NSA"],
)
pnl.make_long_pnl(vol_scale=10,label="Long")
for sig in sigx:
pnl.make_pnl(
sig = sig,
sig_op="raw",
rebal_freq="monthly",
rebal_slip=1,
vol_scale=10,
thresh=5
)
pnames = ["PNL_" + sig for sig in sigx] + ["Long"]
pnl.plot_pnls(
pnl_cats=pnames,
title="Developed market FX forward PnLs: simple signals with volatility scaling",
)
pnl.evaluate_pnls(pnl_cats=pnames)
xcat  Long  PNL_ALL_AVGZ  PNL_LS  PNL_MLS_analytic  PNL_MLS_bootstrap  PNL_MLS_white 

Return %  0.824251  3.792166  4.357335  5.459982  4.869211  5.354708 
St. Dev. %  10.0  10.0  10.0  10.0  10.0  10.0 
Sharpe Ratio  0.082425  0.379217  0.435734  0.545998  0.486921  0.535471 
Sortino Ratio  0.113014  0.530848  0.604194  0.768149  0.681141  0.752987 
Max 21Day Draw %  23.127879  20.732365  17.865446  14.53903  14.150388  14.586697 
Max 6Month Draw %  22.862557  17.25004  17.620398  20.419964  21.829145  20.673058 
Peak to Trough Draw %  72.247255  28.068502  21.488758  22.79378  25.426238  23.173005 
Top 5% Monthly PnL Share  4.275906  1.014362  0.771744  0.714336  0.787799  0.712154 
USD_GB10YXR_NSA correl  0.013873  0.09727  0.074183  0.080222  0.088156  0.084099 
EUR_FXXR_NSA correl  0.410613  0.144694  0.164734  0.069744  0.084898  0.074021 
USD_EQXR_NSA correl  0.249914  0.136671  0.069171  0.006267  0.024986  0.012537 
Traded Months  247  247  247  247  247  247 
sigx = [
"ALL_AVGZ",
"MLS_analytic",
"MLS_white",
"MLS_bootstrap",
"LS",
]
pnl = msn.NaivePnL(
df = dfx,
ret = "FXXR_VT10",
cids=cids_dmfx,
sigs = sigx,
blacklist=fxblack,
start="20040101",
bms=["USD_GB10YXR_NSA", "EUR_FXXR_NSA", "USD_EQXR_NSA"],
)
pnl.make_long_pnl(vol_scale=10,label="Long")
for sig in sigx:
pnl.make_pnl(
sig = sig,
sig_op="zn_score_pan",
min_obs=22 * 6, # minimum required data for normalization
iis=False, # allow no insample bias
rebal_freq="monthly",
rebal_slip=1,
vol_scale=10,
thresh=4
)
pnames = ["PNL_" + sig for sig in sigx] + ["Long"]
pnl.plot_pnls(
pnl_cats=pnames,
title="Developed market FX forward PnLs: normalized signals with volatility scaling",
)
pnl.evaluate_pnls(pnl_cats=pnames)
xcat  Long  PNL_ALL_AVGZ  PNL_LS  PNL_MLS_analytic  PNL_MLS_bootstrap  PNL_MLS_white 

Return %  0.824251  4.419383  5.152835  5.063572  4.705479  4.943281 
St. Dev. %  10.0  10.0  10.0  10.0  10.0  10.0 
Sharpe Ratio  0.082425  0.441938  0.515283  0.506357  0.470548  0.494328 
Sortino Ratio  0.113014  0.629117  0.734286  0.723793  0.669715  0.705261 
Max 21Day Draw %  23.127879  18.578167  16.56765  15.419496  17.059201  15.992291 
Max 6Month Draw %  22.862557  17.167092  17.163477  18.22733  16.358422  18.303031 
Peak to Trough Draw %  72.247255  27.894157  21.476942  23.175404  22.413366  23.117494 
Top 5% Monthly PnL Share  4.275906  0.880259  0.679146  0.717976  0.758508  0.711933 
USD_GB10YXR_NSA correl  0.013873  0.102282  0.098734  0.086293  0.106812  0.092935 
EUR_FXXR_NSA correl  0.410613  0.136064  0.12174  0.076822  0.100646  0.083316 
USD_EQXR_NSA correl  0.249914  0.134274  0.049693  0.004584  0.033262  0.013325 
Traded Months  247  247  247  247  247  247 
sigx = [
"ALL_AVGZ",
"MLS_analytic",
"MLS_white",
"MLS_bootstrap",
"LS",
]
pnl = msn.NaivePnL(
df = dfx,
ret = "FXXR_VT10",
cids=cids_dmfx,
sigs = sigx,
blacklist=fxblack,
start="20040101",
bms=["USD_GB10YXR_NSA", "EUR_FXXR_NSA", "USD_EQXR_NSA"],
)
pnl.make_long_pnl(label="Long")
for sig in sigx:
pnl.make_pnl(
sig = sig,
sig_op="zn_score_pan",
min_obs=22 * 6, # minimum required data for normalization
iis=False, # allow no insample bias
rebal_freq="monthly",
rebal_slip=1,
thresh=4
)
pnames = ["PNL_" + sig for sig in sigx] + ["Long"]
pnl.plot_pnls(
pnl_cats=pnames,
title="Developed market FX forward PnLs: normalized signals without volatility scaling",
)
pnl.evaluate_pnls(pnl_cats=pnames)
xcat  Long  PNL_ALL_AVGZ  PNL_LS  PNL_MLS_analytic  PNL_MLS_bootstrap  PNL_MLS_white 

Return %  3.91568  19.277523  19.091406  26.536706  23.496726  25.964837 
St. Dev. %  47.505922  43.620394  37.050298  52.407087  49.934828  52.525509 
Sharpe Ratio  0.082425  0.441938  0.515283  0.506357  0.470548  0.494328 
Sortino Ratio  0.113014  0.629117  0.734286  0.723793  0.669715  0.705261 
Max 21Day Draw %  109.871121  81.038698  61.383639  80.809087  85.184824  84.000323 
Max 6Month Draw %  108.610685  74.88353  63.591194  95.524125  81.685496  96.137604 
Peak to Trough Draw %  343.21725  121.675413  79.57271  121.455543  111.920755  121.425817 
Top 5% Monthly PnL Share  4.275906  0.880259  0.679146  0.717976  0.758508  0.711933 
USD_GB10YXR_NSA correl  0.013873  0.102282  0.098734  0.086293  0.106812  0.092935 
EUR_FXXR_NSA correl  0.410613  0.136064  0.12174  0.076822  0.100646  0.083316 
USD_EQXR_NSA correl  0.249914  0.134274  0.049693  0.004584  0.033262  0.013325 
Traded Months  247  247  247  247  247  247 
The coefficients for the unadjusted OLSbased signal noticeably diminished post2010. A comparison between the adjusted and unadjusted OLS signals in this period revealed substantial outperformance of the adjusted signal in this period.
sigx = [
"ALL_AVGZ",
"MLS_analytic",
"MLS_white",
"MLS_bootstrap",
"LS",
]
pnl = msn.NaivePnL(
df = dfx,
ret = "FXXR_VT10",
cids=cids_dmfx,
sigs = sigx,
blacklist=fxblack,
start="20100101",
bms=["USD_GB10YXR_NSA", "EUR_FXXR_NSA", "USD_EQXR_NSA"],
)
pnl.make_long_pnl(vol_scale=10,label="Long")
for sig in sigx:
pnl.make_pnl(
sig = sig,
sig_op="raw",
rebal_freq="monthly",
rebal_slip=1,
vol_scale=10,
thresh=5
)
pnames = ["PNL_" + sig for sig in sigx] + ["Long"]
pnl.plot_pnls(
pnl_cats=pnames,
title="Developed market FX forward PnLs: simple signals with volatility scaling, post2010",
)
pnl.evaluate_pnls(pnl_cats=pnames)
xcat  Long  PNL_ALL_AVGZ  PNL_LS  PNL_MLS_analytic  PNL_MLS_bootstrap  PNL_MLS_white 

Return %  2.064412  3.446788  4.226579  5.485918  4.783915  5.300654 
St. Dev. %  10.0  10.0  10.0  10.0  10.0  10.0 
Sharpe Ratio  0.206441  0.344679  0.422658  0.548592  0.478391  0.530065 
Sortino Ratio  0.280133  0.47865  0.581343  0.778697  0.67392  0.751235 
Max 21Day Draw %  22.693253  21.327225  23.115259  14.340199  13.846241  14.341977 
Max 6Month Draw %  19.780244  14.17058  18.790492  20.140708  21.359951  20.326228 
Peak to Trough Draw %  70.889566  28.873853  23.115259  22.482059  24.879729  22.784233 
Top 5% Monthly PnL Share  1.692304  1.179839  0.906085  0.741687  0.835962  0.745129 
USD_GB10YXR_NSA correl  0.016629  0.07318  0.116106  0.118214  0.123351  0.122262 
EUR_FXXR_NSA correl  0.326628  0.128599  0.114323  0.028785  0.03811  0.035521 
USD_EQXR_NSA correl  0.299601  0.127305  0.075152  0.006086  0.021066  0.014553 
Traded Months  175  175  175  175  175  175 
sigx = [
"ALL_AVGZ",
"MLS_analytic",
"MLS_white",
"MLS_bootstrap",
"LS",
]
pnl = msn.NaivePnL(
df = dfx,
ret = "FXXR_VT10",
cids=cids_dmfx,
sigs = sigx,
blacklist=fxblack,
start="20100101",
bms=["USD_GB10YXR_NSA", "EUR_FXXR_NSA", "USD_EQXR_NSA"],
)
pnl.make_long_pnl(vol_scale=10,label="Long")
for sig in sigx:
pnl.make_pnl(
sig = sig,
sig_op="zn_score_pan",
rebal_freq="monthly",
rebal_slip=1,
vol_scale=10,
thresh=4
)
pnames = ["PNL_" + sig for sig in sigx] + ["Long"]
pnl.plot_pnls(
pnl_cats=pnames,
title="Developed market FX forward PnLs: normalized signals with volatility scaling, post2010",
)
pnl.evaluate_pnls(pnl_cats=pnames)
xcat  Long  PNL_ALL_AVGZ  PNL_LS  PNL_MLS_analytic  PNL_MLS_bootstrap  PNL_MLS_white 

Return %  2.064412  3.395608  4.313448  4.846606  4.209228  4.673829 
St. Dev. %  10.0  10.0  10.0  10.0  10.0  10.0 
Sharpe Ratio  0.206441  0.339561  0.431345  0.484661  0.420923  0.467383 
Sortino Ratio  0.280133  0.474952  0.607951  0.69613  0.597279  0.668763 
Max 21Day Draw %  22.693253  18.612568  15.813157  13.188376  14.194553  13.382712 
Max 6Month Draw %  19.780244  14.752481  18.187731  14.775183  15.219825  14.764592 
Peak to Trough Draw %  70.889566  30.781382  18.745652  17.051453  17.176611  17.085856 
Top 5% Monthly PnL Share  1.692304  1.225089  0.868901  0.841376  0.919357  0.835121 
USD_GB10YXR_NSA correl  0.016629  0.070128  0.114568  0.104693  0.118017  0.11164 
EUR_FXXR_NSA correl  0.326628  0.117746  0.069787  0.017851  0.02541  0.019763 
USD_EQXR_NSA correl  0.299601  0.11862  0.016267  0.033981  0.013478  0.026795 
Traded Months  175  175  175  175  175  175 
We see below that the signals from the unadjusted models vanished over time, whilst the adjusted versions are able to produce strong signals in the more recent past.
pnl.signal_heatmap(
pnl_name="PNL_LS",
title="Average signal values, OLS factor model signal"
)
pnl.signal_heatmap(
pnl_name="PNL_MLS_analytic",
title="Average signal values, modified OLS factor model signal"
)