Testing FX Carry vs UIP#
Predictability#
Uncovered Interest Parity (UIP) is a popular model for forecasting exchange rates.
Uncovered FX Trading#
Consider two ways of moving USD from t to t + 1.
At time t, one could simply invest in the USD risk-free rate.
Invest in the Euro risk-free rate:
At time t, one could buy Euros to invest in the Euro risk-free rate.
Then at time t + 1 convert the payoff back to dollars.
The first investment is riskless while the second involves uncertainty about the future exchange rate.
UIP claims the expected depreciation of the USD will exactly offset any interest rate premium over the Euro.
Market Predictions#
Exchange Rates#
UIP relates expected FX growth to interest rate differential.
UIP (combined with CIP) says that forwards are the market expectation of future spot rates:
\(\begin{align} \mathbb{E}_t\left[\frac{\FX_{t+1}}{\FX_t}\right] = & \frac{\Fcrncy_{t}}{\FX_{t}} \hspace{.5cm} \Longrightarrow \hspace{.5cm} \mathbb{E}_t\left[\FX_{t+1}\right] = \Fcrncy_{t} \end{align}\)
Excess Returns#
The UIP model implies that the expected excess return of currency is zero.
UIP allows that there may be a return in any given trade or sample.
But it states that on average, across a wide sample, it will be near zero.
Evidence from Passive Returns#
Above we looked at return data on various currencies.
The mean excess returns were not significantly (in a statistical sense,) different than zero.
This seemingly supports the UIP.
However, further analysis would show that
currency growth is not predictable with interest-rate differentials
the future spot is not well predicted by today’s forward.
And thus,
mean excess returns to currency are positive if a trader reacts to predictable signals, such as forward premium or interest-rate spread.
Traders can take advantage of the fact that interest-rate differentials (nor current forward rates) are good predictors of FX movements.
This is is known as the carry trade.
import pandas as pd
import numpy as np
import datetime
import matplotlib.pyplot as plt
%matplotlib inline
plt.rcParams['figure.figsize'] = (12,6)
plt.rcParams['font.size'] = 15
plt.rcParams['legend.fontsize'] = 13
from cmds.portfolio import performanceMetrics, tailMetrics, get_ols_metrics
Data#
DATAPATH_FX = '../data/fx_data.xlsx'
SHEET = 'exchange rates'
fx = pd.read_excel(DATAPATH_FX, sheet_name=SHEET).set_index('date')
SHEET = 'risk-free rates'
DAYS = 360
rfraw = pd.read_excel(DATAPATH_FX,sheet_name=SHEET).set_index('date')
rfraw /= DAYS
rfraw.columns = [c + ' RF' for c in rfraw.columns]
rfusd = rfraw[['USD RF']]
rfusd.columns = ['USD']
rf = rfraw.drop(columns=['USD RF'])
fxgrowth = (fx / fx.shift())
rets = fxgrowth.mul(1+rf.values,axis=1) - 1
rx = rets.sub(rfusd.values,axis=1)
tickUSD = 'USD RF'
logFX = np.log(fx)
logRFraw = np.log(rfraw+1)
logRFusd = logRFraw[tickUSD]
logRF = logRFraw.drop(columns=[tickUSD])
logRFspread = -logRF.subtract(logRFusd.values,axis=0)
logRFspread = logRFspread.shift(1)
logFXgrowth = logFX.diff(axis=0)
logRX = logFXgrowth - logRFspread.values
The Carry Trade#
The carry trade logic is simple:
go long the currency with the higher interest rate.
hope that future currency depreciation does not offset the gain from the interest-rate differential.
Empirically, what happens?
Historically#
Historically, the excess return on the carry trade has been significant.
A widely-used trading strategy.
At times presented like an arbitrage, it is not.
If there is systematic risk in FX volatility, then it is a premium for this exposure.
Even so, potentially attractive in that the risk premium is not explained by obvious factors like market beta.
Peso problems#
As is seen in the picture, the carry trade is subject to large crashes. Referred to as a “peso problem”.
In the 1970’s, Mexico had pegged their FX rate to the USD for over a decade.
Yet, a significant interest rate differential persisted.
Seemingly a lucrative trade: higher interest rate, no FX volatility.
But what about risk of infrequent, sudden, and large depreciation?
In fact, there eventually was a large depreciation of the peso.
Carry Trade and Options?#
Given that exchange rates are subject to large sudden movements,
Carry trade premium is similar to writing far out of the money puts.
Make a consistent, small premium, but subject to big losses in a catastrophe.
But some research shows that even after hedging extreme movements with options, the carry trade has excess returns.
What economic factors explain this premium?
Testing the Carry Trade#
Run a forecasting regression for the log FX Growth, as implied by UIP.
UIP implies that the interest rate spread is the optimal predictor of FX growth.
Thus, it implies the forecasting beta should be 1.
Linearization#
This relationship is easier to analyze in a linear form. Taking logs,
Regression Tests#
Consider the regression tests for these two UIP statements.
Using the interest rate differential,
(Noting yet again that \(\rflog_{t+1}\) is known at time \(t\).)
Alternatively, using the forward premium, $\(\fxlog_{t+1} - \fxlog_t = \alpha + \beta\left(\fcrncylog_{t} - \fxlog_{t}\right) + \epsilon_{t+1}\)$
In either test, UIP implies that \(\beta=1, \alpha=0\).
olstab = pd.DataFrame(index=logFXgrowth.columns, columns=['alpha','Beta','r-squared'])
for i, curncy in enumerate(logFXgrowth.columns):
temp = get_ols_metrics(logRFspread.iloc[:,i],logFXgrowth.iloc[:,i],annualization=1).drop(columns=['Treynor Ratio','Info Ratio'])
temp.rename(columns={logRFspread.columns[i]:'Beta'}, inplace=True)
olstab.loc[curncy,:] = temp.values
olstab.T.style.format('{:.4%}').set_caption('OLS Metrics')
| JPY | EUR | GBP | MXN | CHF | |
|---|---|---|---|---|---|
| alpha | -0.0091% | -0.0047% | -0.0199% | -0.0200% | -0.0169% |
| Beta | 50.5123% | -4.0507% | 543.0619% | -72.6696% | 347.9885% |
| r-squared | 0.0009% | 0.0000% | 0.0765% | 0.0019% | 0.0128% |
Predicting Appreciation or Depreciation?#
Look at whether the betas are positive or negative.
If the regressor (USD minus foreign risk-free rate) goes DOWN, then we expect
decreased UK and JP relative to US.
increased EU and SZ relative to US. (That is, the expected FX growth is higher.)
Thus, if UK risk-free rate increases relative to US risk-free rate, we expect USD will appreciate.
This means FX growth is partially offsetting the interest-rate differential. But not fully. Beta is positive, but less than one.
For EU and SZ,
this means that FX growth is exacerbating the returns from the interest rate differentials. Beta is negative, so in a sense, investors expect to gain twice: immediately from the risk-free rate differential, and again from the FX movement at the close of the position.
In fact, the statements above are just about the point estimates, and may not be statistically significant.
Strength of Predictability?#
These effects are extremely small! The R-squared stats for all four currencies are nearly 0.
The Dynamic Carry Trade#
forecast = pd.DataFrame(columns=logFXgrowth.columns, index=logFXgrowth.index)
for i, curncy in enumerate(logFXgrowth.columns):
forecast[curncy] = olstab.loc[curncy,'alpha'] + (olstab.loc[curncy,'Beta']-1) * logRFspread.iloc[:,i]
Forecast timing#
We do not need to lag the forecasts with .shift() because we already use the lagged calculation of the interest rate spread.
Thus, the regressor is lagged, and thus is generating forecasts stamped with the date of their targeted value, as desired.
forecast_positive = ((forecast.dropna() > 0).sum() / forecast.dropna().shape[0]).to_frame().T
forecast_positive.index = ['fraction with positive forecast']
forecast_positive.T.style.format('{:.2%}').set_caption('Frequency of forecasting positive carry return')
| fraction with positive forecast | |
|---|---|
| JPY | 0.00% |
| EUR | 0.00% |
| GBP | 3.74% |
| MXN | 51.83% |
| CHF | 84.25% |
forecast_positive.T.plot.bar(figsize=(7,4))
<Axes: >
(forecast*DAYS).plot(title='Forecasted Excess Log Returns', figsize=(9,6));
Positive or Negative Forecasts?#
Though the static carry-trade above found that UK and EU have negative mean excess (log) returns, the dynamic carry trade finds that the time-varying forecast is often positive.
For JP, the forecast is negative throughout the sample due to the much higher interest rate in USD not being offset by appreciation of the Yen to the USD.
Improving the Trade?#
We can use forecasts of excess returns to construct trading positions, (weights,) which vary with the forecasts. The resulting trading strategy should be better than the static carry trade.
That is, we could time the magnitude and direction of the currency trades instead of being passively 100% invested in a currency according to the evidence above.
It is analagous to timing the trading of SPY according to the Earnings-Price Ratio instead of being passively 100% invested in SPY.
Of course, it is an open question as to whether this will work given the weak R-squared in-sample, let alone out-of-sample.
Building the Dynamic Trade#
Try implementing the forecasts with a simple proportional weighting of each forecast.
passive = logRX
forecast_baseline = logRX.expanding().mean().shift(1)
def find_scaling_factor(vector, target_magnitude=1, norm_type=1):
"""
Find a power-of-10 scaling factor to bring the norm close to target_magnitude.
Args:
vector: numpy array or list
target_magnitude: desired magnitude (default 1)
norm_type: 1 for L1 norm, 2 for L2 norm
Returns:
scaling_factor: power of 10 to multiply the vector by
"""
vector = np.array(vector)
# Calculate current norm
if norm_type == 1:
current_norm = np.sum(np.abs(vector)) # L1 norm
elif norm_type == 2:
current_norm = np.linalg.norm(vector) # L2 norm
else:
raise ValueError("norm_type must be 1 or 2")
if current_norm == 0:
return 1.0
# Find the power of 10 needed
ratio = target_magnitude / current_norm
log_ratio = np.log10(ratio)
power = round(log_ratio) # Round to nearest integer
scaling_factor = 10 ** power
return scaling_factor
scaling_factor = find_scaling_factor(forecast.iloc[1,:], norm_type=1)
wts = 1000 * forecast
forecast_returns = wts * passive.values
Check the performance#
Also compare to the equally-weighted combination of the currency trades#
All stats are much better than the static carry trade.
But this is an in-sample performance.
Open question whether the OOS version can beat the static table.
Note that currencies have low means and low vols, so in no case are we expecting to get equity-like return statistics.
forecast_returns['Equally-Weighted'] = forecast_returns.mean(axis=1) * 10
performanceMetrics(forecast_returns,annualization=DAYS).style.format('{:.2%}').set_caption('Performance Metrics')
| Mean | Vol | Sharpe | Min | Max | |
|---|---|---|---|---|---|
| JPY | 0.52% | 1.45% | 36.13% | -0.58% | 0.60% |
| EUR | 0.42% | 1.19% | 35.49% | -0.40% | 0.28% |
| GBP | 1.07% | 2.18% | 48.97% | -1.51% | 1.28% |
| MXN | 0.26% | 1.30% | 19.64% | -0.76% | 0.73% |
| CHF | 0.11% | 0.50% | 21.28% | -0.19% | 0.46% |
| Equally-Weighted | 4.76% | 6.94% | 68.57% | -3.42% | 2.94% |