FX Carry Strategy Backtester
Welcome to the documentation for the FX Carry Strategy Backtester — a Python engine for constructing, weighting, and evaluating currency carry-trade strategies using spot FX rates, forward rates, and purchasing-power-parity (PPP) "fair value" estimates.
What is FX Carry?
The FX carry trade is one of the oldest and most studied strategies in macro/quant investing. In its simplest form, it exploits the fact that interest rates differ across countries: an investor borrows in a low-yield currency and invests in a high-yield currency, pocketing the interest rate differential — the carry — as long as the exchange rate doesn't move against them by more than that differential.
Because of Covered Interest Rate Parity, this interest-rate differential is embedded directly in the forward premium (the gap between the spot and forward exchange rate), so the code in this project never needs interest rate data directly — it derives carry entirely from spot and forward prices.
What This Codebase Does
flowchart LR
A[Excel Data: Spot, Forward, PPP Value] --> B[get_returns]
B --> C[Carry Returns]
B --> D[Carry Signal]
D --> E[set_weights]
C --> F[get_results]
E --> F
F --> G[Performance Stats:<br/>Return, Vol, Sharpe, MDD]
F --> H[print_results:<br/>Charts]
G --> I[combine_strategies /<br/>composite_signal]
H --> I
The pipeline has four conceptual stages:
- Data ingestion — read spot, forward, and PPP value time series from Excel.
- Signal & return generation — compute realized carry returns and a
trading signal, with the signal definition depending on the chosen
strategy (
Simple_Carry,VolAdj_Carry,Value,Max_Sharpe_Port). - Portfolio construction — turn signals into portfolio weights, either
directly (rule-based strategies) or via mean-variance optimization
(
Max_Sharpe_Port). - Evaluation & reporting — compute annualized return, volatility, Sharpe ratio, maximum drawdown, and rolling Sharpe, then plot results and optionally blend multiple strategies together.
Supported Strategies
| Strategy | Signal Definition | Weighting |
|---|---|---|
Simple_Carry |
Forward premium | Rule-based buy/sell |
VolAdj_Carry |
Forward premium ÷ realized volatility | Rule-based buy/sell |
Value |
PPP fair value deviation | Rule-based buy/sell |
Max_Sharpe_Port |
Forward premium | Mean-variance (max Sharpe) optimization |
Navigating These Docs
- Getting Started — install dependencies and run a backtest.
- Theory — the finance and math behind each strategy.
- Architecture — how the modules fit together.
- Modules — function-by-function reference with code snippets.
- Known Issues — bugs and inconsistencies identified during review.
- Full Annotated Source — the complete script with inline commentary.
This is a single page
Everything — theory, architecture, module reference, known issues, and
the full annotated source — lives on this one page. Use the
table-of-contents sidebar (or Ctrl+F / Cmd+F) to jump to a section.
Getting Started
Requirements
The script depends on the following Python packages:
pip install numpy pandas matplotlib scipy openpyxl
openpyxl is required by pandas.read_excel for reading .xlsx files even
though it isn't imported explicitly.
Expected Input Data
get_data() expects an Excel workbook with (at least) three sheets, each
sharing the same date index and the same set of currency-pair columns:
| Sheet | Contents |
|---|---|
| Spot | Spot FX rates per currency pair |
| Forward | Outright forward FX rates (matching tenor to the rebalance frequency) |
| Value | PPP-implied "fair value" FX rate estimate |
df_spot = get_data('fx_data.xlsx', 'Spot')
df_fwd = get_data('fx_data.xlsx', 'Forward')
df_val = get_data('fx_data.xlsx', 'Value')
Each sheet must use a column called Date as the index — this is
enforced by index_col='Date' inside get_data.
Minimal End-to-End Example
periods_per_year = 12 # e.g. monthly rebalancing
look_back_carry_ret = 36 # 3-year lookback for Max_Sharpe_Port
look_back_rolling_sharpe = 36 # 3-year rolling Sharpe window
strategy = 'Simple_Carry'
# 1. Load data
df_spot = get_data('fx_data.xlsx', 'Spot')
df_fwd = get_data('fx_data.xlsx', 'Forward')
df_val = get_data('fx_data.xlsx', 'Value')
vol = df_spot.pct_change().rolling(12).std() * (12 ** 0.5) # example realized vol
# 2. Build returns & signal
df_ret, df_signal = get_returns(df_spot, df_fwd, df_val, vol, strategy)
# 3. Convert signal to buy/sell weights (e.g. sign of the signal, equal-weighted)
df_buy_sell = np.sign(df_signal) / np.sign(df_signal).abs().sum(axis=1).values.reshape(-1, 1)
# 4. Determine portfolio weights
weights = set_weights(df_ret, df_buy_sell, strategy, periods_per_year, look_back_carry_ret)
# 5. Backtest
weightd_ret, mu, std, sharpe, sharpe_roll_wind, mdd = get_results(
df_ret, weights, strategy, periods_per_year,
look_back_carry_ret, look_back_rolling_sharpe, num_obs=len(df_ret)
)
# 6. Report
print_results(df_ret, weightd_ret, mu, std, sharpe, sharpe_roll_wind, mdd,
spot_codes=df_spot.columns.tolist(),
look_back_rolling_sharpe=look_back_rolling_sharpe)
The df_buy_sell step isn't in the original script
The provided code does not include the logic that converts a raw signal
(df_signal) into a df_buy_sell weight matrix — it's assumed to be
produced upstream. The snippet above shows one plausible implementation
(equal-weighted, sign-based) purely to illustrate the pipeline end to end.
Running the Docs Site Locally
pip install mkdocs-material
mkdocs serve
Then open http://127.0.0.1:8000 in your browser.
Theory: Carry Trade Basics
Covered Interest Rate Parity
The link between spot rates, forward rates, and interest rate differentials is given by Covered Interest Rate Parity (CIP):
where:
- \(S_t\) is the spot exchange rate at time \(t\) (domestic per unit foreign),
- \(F_t\) is the forward exchange rate,
- \(i_d\) is the domestic (quote-currency) interest rate,
- \(i_f\) is the foreign (base-currency) interest rate.
Rearranging shows that the forward premium/discount, \((S_t - F_t)/F_t\), is (approximately) equal to the interest rate differential:
This is exactly why the codebase never needs raw interest rate data — the forward-spot spread already encodes the interest differential that carry traders seek to harvest.
Realized Carry Return
Once a forward contract initiated at time \(t-1\) matures at time \(t\), the realized percentage return of that position is the difference between the prevailing spot rate and the rate locked in by the forward:
This is implemented directly in get_returns():
df_ret = (df_spot / df_fwd.shift(1) - 1) * 100
The .shift(1) aligns the forward rate that was contracted in the
previous period with the spot rate realized in the current period —
i.e. it captures the P&L of holding the forward-implied position for one
period.
Uncovered Interest Rate Parity — and Why Carry Works
Textbook Uncovered Interest Rate Parity (UIP) predicts that the high-yield currency should depreciate by exactly the amount of the interest rate differential, making expected carry-trade profit zero:
Empirically, UIP fails persistently — high-yield currencies tend to depreciate less than the differential implies (or even appreciate), producing what is often called the "forward premium puzzle." This empirical failure of UIP is the economic rationale for why systematic carry strategies have historically earned a positive risk premium, at the cost of occasional sharp drawdowns during risk-off / de-leveraging episodes (e.g. 2008, 2015 CHF unpeg) — which is precisely why this codebase tracks maximum drawdown and rolling Sharpe ratio alongside average returns.
Crash risk
Carry returns are famously negatively skewed: steady small gains punctuated by sharp losses when funding currencies rally in risk-off periods. Point-in-time Sharpe ratios can look attractive while masking tail risk — this is why the rolling Sharpe and max drawdown diagnostics matter as much as the headline Sharpe ratio.
Theory: Signal Construction
get_returns() produces both the realized return series (df_ret,
covered in Carry Trade Basics) and a signal series
(df_signal) used to rank or weight currencies at each rebalance date. The
signal definition depends on the strategy argument.
def get_returns(df_spot, df_fwd, df_val, vol, strategy):
df_ret = (df_spot / df_fwd.shift(1) - 1) * 100
if strategy in ('Simple_Carry', 'Max_Sharpe_Port'):
df_signal = ((df_spot / df_fwd) - 1) * 100
elif strategy == 'VolAdj_Carry':
df_signal = ((df_spot / df_fwd) - 1) * 100 / vol
elif strategy == 'Value':
df_signal = (df_val.values - df_spot) / df_spot
else:
raise ValueError('Error in Strategy Name')
return df_ret, df_signal
1. Simple Carry / Max Sharpe Signal
This is the current forward premium (no lag), used to rank currencies by
carry attractiveness today, as opposed to df_ret which measures the
realized P&L from a forward contracted in the previous period. Both
Simple_Carry and Max_Sharpe_Port use this same raw signal — they differ
only in how the signal is turned into portfolio weights (see
Portfolio Optimization).
2. Volatility-Adjusted Carry
Dividing the raw carry signal by realized volatility \(\sigma_t\) is a simple risk-parity-style adjustment: it tilts the strategy toward currency pairs offering more carry per unit of risk, rather than the highest carry in absolute terms. This tends to reduce allocation to notoriously volatile high-yielders (e.g. emerging-market currencies during stress periods) relative to the raw carry signal.
3. Value (PPP Deviation)
where \(V_t\) is the PPP "fair value" estimate. This signal is not a carry signal at all — it is a mean-reversion / valuation signal: currencies trading materially below their PPP fair value (\(V_t > S_t\)) generate a positive signal, i.e. a long recommendation, on the thesis that the currency is undervalued and should appreciate toward fair value over time.
Why combine Value with Carry?
Carry and value signals are known to be close to orthogonal empirically
— carry tends to do well when valuations are stretched and can reverse
sharply, while value is slow-moving and mean-reverting. This is the
motivation behind composite_signal(), which only takes a position when
both signals agree on direction (see
Strategy Combination).
Signal → Weight Pipeline
flowchart TD
A["df_signal<br/>(strategy-specific)"] --> B{Strategy type?}
B -->|Rule-based:<br/>Simple_Carry, VolAdj_Carry, Value| C["df_buy_sell<br/>(external construction,<br/>e.g. rank/sign-based weights)"]
B -->|Max_Sharpe_Port| D["set_weights_day()<br/>mean-variance optimization<br/>on rolling window of df_ret"]
C --> E[set_weights]
D --> E
E --> F["weights (pd.DataFrame)"]
Theory: Portfolio Optimization
For the Max_Sharpe_Port strategy, portfolio weights are not derived from a
simple rule but from mean-variance optimization, re-solved on every
rolling window of history.
Annualized Portfolio Performance
Given a weight vector \(\mathbf{w}\), a vector of mean periodic returns \(\boldsymbol{\mu}\), and a covariance matrix \(\Sigma\):
where \(p\) is periods_per_year. This is implemented as:
def portfolio_annualised_performance(weights, mean_returns, carry_cov, periods_per_year):
returns = np.sum(mean_returns * weights) * periods_per_year
std = np.sqrt(np.dot(weights.T, np.dot(carry_cov, weights))) * np.sqrt(periods_per_year)
return returns, std
The annualization scales the mean by \(p\) (linear scaling of expectation) and the standard deviation by \(\sqrt{p}\) (square-root-of-time scaling under an i.i.d. returns assumption) — the standard convention for converting periodic statistics to annualized ones.
Objective: Maximize the Sharpe Ratio
The (annualized) Sharpe ratio of the portfolio is:
scipy.optimize.minimize only minimizes, so the code minimizes the
negative Sharpe ratio — written here as \(r_f - R_p\) over \(\sigma_p\),
which is algebraically equivalent up to sign to \(-(R_p - r_f)/\sigma_p\):
def neg_sharpe_ratio(weights, mean_returns, carry_cov, risk_free_rate, periods_per_year):
p_ret, p_var = portfolio_annualised_performance(weights, mean_returns, carry_cov, periods_per_year)
return (risk_free_rate - p_ret) / p_var
The Optimization Problem
def max_sharpe_ratio(mean_returns, carry_cov, risk_free_rate, periods_per_year):
args = locals().values()
no = len(mean_returns)
constraints = {'type': 'eq', 'fun': lambda x: np.sum(x) - 0}
bounds = [(-1.0, 1.0) for _ in range(no)]
return sco.minimize(neg_sharpe_ratio, no * [1 / no], args=args, method='SLSQP',
bounds=bounds, constraints=constraints)
Notable design choices:
- Dollar-neutral constraint: weights must sum to zero, not one. This makes the portfolio a long/short, market-neutral book rather than a fully-invested long-only portfolio — consistent with how carry baskets are typically traded (long high-yielders, short low-yielders, funded self-financing via the FX forward).
- Bounded weights: each currency's weight is capped at \(\pm 100\%\), preventing unbounded leverage on any single pair.
- SLSQP (Sequential Least Squares Programming) is used because the problem has nonlinear objective (Sharpe ratio) with linear equality and box constraints — a natural fit for SLSQP's local-optimization approach.
- Initial guess: equal weights \(1/n\) for all \(n\) currencies.
Non-convexity & local optima
The Sharpe ratio objective is not convex in \(\mathbf{w}\) in general, so SLSQP (a local optimizer) is not guaranteed to find the global maximum. In practice, for well-behaved covariance matrices and a modest number of currencies, this is a minor concern, but it's worth being aware of.
Rolling Re-Optimization
The optimization is re-run on every rolling window of length
look_back_carry_ret, one window per rebalance date:
def set_weights_day(i, df_ret, periods_per_year, look_back_carry_ret):
ret = df_ret.iloc[i: i + look_back_carry_ret] / 100
max_sharpe = max_sharpe_ratio(ret.mean(), ret.cov(), 0, periods_per_year)
return pd.Series(max_sharpe.x, ret.columns, name=ret.index[-1]).round(7).sort_index()
flowchart LR
subgraph "Rolling window i"
W1["df_ret.iloc[i : i+L]"] --> M1[mean & cov]
M1 --> O1[SLSQP optimizer]
O1 --> WT1["weights at date<br/>ret.index[-1]"]
end
subgraph "Rolling window i+1"
W2["df_ret.iloc[i+1 : i+1+L]"] --> M2[mean & cov]
M2 --> O2[SLSQP optimizer]
O2 --> WT2["weights at next date"]
end
WT1 --> C[pd.concat: full weight history]
WT2 --> C
Each call re-estimates \(\boldsymbol{\mu}\) and \(\Sigma\) purely from the
trailing window df_ret.iloc[i : i + look_back_carry_ret], so the resulting
weight series adapts to changing return/co-movement patterns over time
rather than using a single static covariance estimate for the whole
backtest.
Argument order bug
set_weights() calls
set_weights_day(i, df_ret, look_back_carry_ret, periods_per_year),
but set_weights_day's signature is
(i, df_ret, periods_per_year, look_back_carry_ret). The third and
fourth positional arguments are swapped at the call site, meaning
periods_per_year receives the lookback window length and vice versa.
See Known Issues for details.
Theory: Performance Metrics
Annualized Return, Volatility, and Sharpe Ratio
def get_sharpe(ret_series, periods_per_year):
mu = ret_series.mean() * periods_per_year
std = ret_series.std(ddof=1) * (12 ** 0.5)
sharpe = mu / std
return mu, std, sharpe
Given a periodic return series \(r_1, \dots, r_n\) (in percent):
Here \(s_r\) is the sample standard deviation (using Bessel's correction,
ddof=1).
Hardcoded annualization factor
The volatility annualization always multiplies by \(\sqrt{12}\),
regardless of the periods_per_year argument that's passed in. If the
data is monthly (\(p=12\)) this is correct, but if periods_per_year is
ever anything other than 12 (e.g. weekly or daily rebalancing), mu and
std will be annualized on inconsistent bases, and the resulting Sharpe
ratio will be wrong. See Known Issues.
The mathematically consistent version would be: [ \sigma_{ann} = s_r \times \sqrt{p} ]
Maximum Drawdown
def maximum_drawdown(ret_series):
cum_ret = np.cumprod(1 + ret_series / 100)
mdd = 0
peak = cum_ret[0]
for x in cum_ret:
if x > peak:
peak = x
dd = (peak - x) / peak
if dd > mdd:
mdd = dd
return mdd
First, the cumulative wealth index is built from periodic returns:
Then the maximum drawdown is the largest peak-to-trough percentage decline observed at any point in the series:
This is a classic running-maximum drawdown calculation: at each time step, track the highest cumulative wealth seen so far (the "peak"), and measure how far the current value has fallen from that peak. The maximum of these drawdowns across the whole series is the reported MDD.
sequenceDiagram
participant R as Return series
participant C as Cumulative wealth
participant P as Running peak
participant D as Drawdown
R->>C: cumprod(1 + r/100)
loop each time step t
C->>P: update peak = max(peak, C_t)
P->>D: drawdown_t = (peak - C_t) / peak
end
D->>D: MDD = max(drawdown_t)
Duration vs. magnitude
The docstrings in the original code describe mdd as a "maximum
drawdown duration," but the calculation actually returns the maximum
drawdown magnitude (a percentage loss from peak), not a count of
periods. This is a naming/documentation inconsistency — see
Known Issues.
Rolling Sharpe Ratio
get_results() also computes a rolling-window Sharpe ratio to visualize how
risk-adjusted performance evolves through time (e.g. a rolling 3-year window
if look_back_rolling_sharpe = 36 with monthly data):
segment_sharpes = [get_sharpe(weightd_ret[i - look_back_rolling_sharpe], periods_per_year)
for i in range(look_back_rolling_sharpe, num_obs)]
sharpe_roll_wind = np.array([sharpe if std else 0 for mu, std, sharpe in segment_sharpes])
Not actually a rolling window
weightd_ret[i - look_back_rolling_sharpe] indexes a single scalar
value, not a slice — so get_sharpe receives one number instead of a
trailing window of returns. This almost certainly should be
weightd_ret[i - look_back_rolling_sharpe : i] to compute the mean/std
over the trailing look_back_rolling_sharpe periods. See
Known Issues for a suggested fix.
Architecture
Module Map
The script is organized as a linear pipeline of pure(ish) functions rather than classes — each stage consumes the output of the previous stage.
flowchart TB
subgraph Data["Data Layer"]
A1[get_data]
end
subgraph Signal["Signal & Return Layer"]
B1[get_returns]
end
subgraph Weights["Portfolio Construction Layer"]
C1[set_weights]
C2[set_weights_day]
C3[max_sharpe_ratio]
C4[neg_sharpe_ratio]
C5[portfolio_annualised_performance]
C1 --> C2
C2 --> C3
C3 --> C4
C4 --> C5
end
subgraph Eval["Evaluation Layer"]
D1[get_results]
D2[get_sharpe]
D3[maximum_drawdown]
D1 --> D2
D1 --> D3
end
subgraph Report["Reporting Layer"]
E1[print_results]
end
subgraph Combine["Multi-Strategy Layer"]
F1[combine_strategies]
F2[composite_signal]
end
A1 --> B1
B1 --> C1
B1 --> D1
C1 --> D1
D1 --> E1
D1 --> F1
B1 --> F2
C1 --> F2
D1 --> F2
F2 --> E1
F1 --> E1
Data Flow: Single Strategy Backtest
sequenceDiagram
autonumber
participant U as User script
participant GD as get_data
participant GR as get_returns
participant SW as set_weights
participant GRes as get_results
participant PR as print_results
U->>GD: load Spot / Forward / Value sheets
GD-->>U: df_spot, df_fwd, df_val
U->>GR: get_returns(df_spot, df_fwd, df_val, vol, strategy)
GR-->>U: df_ret, df_signal
U->>SW: set_weights(df_ret, df_buy_sell, strategy, ...)
Note over SW: Max_Sharpe_Port -> rolling optimization<br/>otherwise -> passthrough of df_buy_sell
SW-->>U: weights
U->>GRes: get_results(df_ret, weights, strategy, ...)
Note over GRes: weighted return = sum(df_ret * weights.shift(1))
GRes-->>U: weightd_ret, mu, std, sharpe, sharpe_roll_wind, mdd
U->>PR: print_results(...)
PR-->>U: printed stats + 3 matplotlib charts
Why weights.shift(1)?
A critical, easy-to-miss detail in get_results():
weightd_ret = (df_ret * weights.shift(1)).sum(axis=1)
Weights are lagged by one period before being multiplied by realized returns. This enforces a no-lookahead-bias convention: the portfolio weight decided at the end of period \(t-1\) (based on information known up to \(t-1\)) is the one applied to the return realized during period \(t\). Without this shift, the backtest would implicitly assume perfect foreknowledge of each period's return when setting that period's weight — a common and serious backtesting error.
gantt
dateFormat X
axisFormat %s
section Weight decision
Weight set using info up to t-1 :done, w1, 0, 1
section Return realized
Return earned over period t :active, r1, 1, 2
Data Flow: Combined-Strategy Backtest
flowchart LR
S1["Strategy 1 signal<br/>(list_buy_sell[strat1])"] --> AGREE{"Signals<br/>agree in sign?"}
S2["Strategy 2 signal<br/>(list_buy_sell[strat2])"] --> AGREE
AGREE -->|Yes| KEEP["df_carry_value =<br/>strat1 signal"]
AGREE -->|No| ZERO["Position = 0"]
KEEP --> SW[set_weights]
ZERO --> SW
SW --> GR[get_results]
GR --> PR[print_results]
GR --> STORE["port_weightd_ret[strat1_strat2]"]
composite_signal() implements a signal-agreement filter: a currency is
only held when both strategies' signals point the same direction
(list_buy_sell[strat1] == list_buy_sell[strat2]); otherwise its weight is
zeroed out for that period. This is a conservative way of combining signals
— it trades less often but (in principle) with higher conviction.
combine_strategies(), in contrast, is a much simpler capital-weighted
blend: it just averages the realized return series of two already-run
strategies 50/50, rather than combining signals before trading.
Class-Free Design
The whole module is written as free functions operating on pandas
DataFrame/Series objects, with a strategy: str flag threaded through
many functions to select behavior (if strategy == '...'). This keeps the
code simple and easy to script interactively (e.g. in a notebook), at the
cost of some duplication and stringly-typed branching that could be
refactored into a strategy-pattern class hierarchy if the codebase grows.
Module: Data Loading
get_data(fname, sheet)
def get_data(fname, sheet):
"""
Returns a sheet read in from an excel file.
"""
return pd.read_excel(fname, sheet_name=sheet, index_col='Date')
A thin wrapper around pandas.read_excel that standardizes how every sheet
is loaded: always index by the Date column, so that all downstream
DataFrames (df_spot, df_fwd, df_val) are automatically date-aligned
and ready for elementwise arithmetic.
| Parameter | Type | Description |
|---|---|---|
fname |
str |
Path to the Excel workbook |
sheet |
str |
Name of the sheet to load |
Returns: pd.DataFrame indexed by Date.
Usage
df_spot = get_data('fx_data.xlsx', 'Spot')
df_fwd = get_data('fx_data.xlsx', 'Forward')
df_val = get_data('fx_data.xlsx', 'Value')
Column alignment matters
Every downstream calculation (df_spot / df_fwd, etc.) relies on
implicit pandas alignment by column label and index. Make sure the
Spot, Forward, and Value sheets use identical currency-pair
column headers and date ranges — mismatches will silently introduce
NaNs rather than raising an error.
Data Requirements Diagram
erDiagram
SPOT_SHEET {
date Date PK
float EURUSD
float GBPUSD
float USDJPY
}
FORWARD_SHEET {
date Date PK
float EURUSD
float GBPUSD
float USDJPY
}
VALUE_SHEET {
date Date PK
float EURUSD
float GBPUSD
float USDJPY
}
SPOT_SHEET ||--|| FORWARD_SHEET : "same dates & columns"
SPOT_SHEET ||--|| VALUE_SHEET : "same dates & columns"
Module: Returns & Signals
get_returns(df_spot, df_fwd, df_val, vol, strategy)
def get_returns(df_spot, df_fwd, df_val, vol, strategy):
# Realized return: spot today vs. forward locked in last period
df_ret = (df_spot / df_fwd.shift(1) - 1) * 100
if strategy in ('Simple_Carry', 'Max_Sharpe_Port'):
# Current forward premium -- ranks currencies by carry attractiveness
df_signal = ((df_spot / df_fwd) - 1) * 100
elif strategy == 'VolAdj_Carry':
# Forward premium scaled by realized volatility (risk-adjusted carry)
df_signal = ((df_spot / df_fwd) - 1) * 100 / vol
elif strategy == 'Value':
# PPP deviation: positive => currency undervalued vs. fair value
df_signal = (df_val.values - df_spot) / df_spot
else:
raise ValueError('Error in Strategy Name')
return df_ret, df_signal
| Parameter | Type | Description |
|---|---|---|
df_spot |
pd.DataFrame |
Spot FX rates |
df_fwd |
pd.DataFrame |
Forward FX rates |
df_val |
pd.DataFrame |
PPP fair-value estimates |
vol |
pd.DataFrame |
Realized volatility (used only for VolAdj_Carry) |
strategy |
str |
One of 'Simple_Carry', 'VolAdj_Carry', 'Value', 'Max_Sharpe_Port' |
Returns: a tuple (df_ret, df_signal).
See Signal Construction for the full mathematical derivation of each branch.
df_val.values vs df_spot
The Value branch uses df_val.values (a raw NumPy array) rather than
df_val (a DataFrame) when subtracting df_spot. This works because
pandas will broadcast a NumPy array against a DataFrame positionally
rather than aligning on the index/columns — it's functionally fine as
long as df_val and df_spot already share the same shape and row/column
order, but it silently skips pandas' usual label-based alignment safety
net. Using df_val directly (letting pandas align on Date/columns)
would be more robust to accidental misordering.
Strategy Dispatch
flowchart TD
S["strategy string"] --> Q{value}
Q -->|Simple_Carry or Max_Sharpe_Port| A["(S/F - 1) * 100"]
Q -->|VolAdj_Carry| B["(S/F - 1) * 100 / vol"]
Q -->|Value| C["(V - S) / S"]
Q -->|other| E["raise ValueError"]
Module: Portfolio Weights
This module covers portfolio_annualised_performance, neg_sharpe_ratio,
max_sharpe_ratio, set_weights_day, and set_weights — the mean-variance
optimization machinery used by the Max_Sharpe_Port strategy, plus the
dispatcher that decides whether to optimize at all.
For the full mathematical derivation, see Portfolio Optimization.
set_weights(df_ret, df_buy_sell, strategy, periods_per_year, look_back_carry_ret)
def set_weights(df_ret, df_buy_sell, strategy, periods_per_year, look_back_carry_ret):
if strategy == 'Max_Sharpe_Port':
# Re-optimize on every rolling window, then stitch results together
return pd.concat(
[set_weights_day(i, df_ret, look_back_carry_ret, periods_per_year)
for i in range(len(df_ret) - look_back_carry_ret)],
axis=1
)
else:
# Rule-based strategies: weights come straight from the buy/sell signal
return df_buy_sell
This is the single entry point used by callers — it hides the branching between "optimize" and "just use the rule-based signal" behind one function.
| Parameter | Type | Description |
|---|---|---|
df_ret |
pd.DataFrame |
Carry returns (used as optimization input for Max_Sharpe_Port) |
df_buy_sell |
pd.DataFrame |
Pre-computed buy/sell weights for rule-based strategies |
strategy |
str |
Strategy name |
periods_per_year |
int |
Annualization factor |
look_back_carry_ret |
int |
Rolling window length (in periods) for optimization |
Returns: pd.DataFrame of weights, one row per currency, one column per
rebalance date (for Max_Sharpe_Port), or the passthrough df_buy_sell
otherwise.
Argument order bug at the call site
set_weights_day(i, df_ret, look_back_carry_ret, periods_per_year)
set_weights_day's definition is:
def set_weights_day(i, df_ret, periods_per_year, look_back_carry_ret):
look_back_carry_ret and periods_per_year are swapped. Concretely, if
periods_per_year = 12 and look_back_carry_ret = 36, the optimizer
ends up slicing df_ret.iloc[i : i+12] (using the wrong, smaller window)
and annualizing with a factor of 36 (also wrong). See
Known Issues for the fix.
set_weights_day(i, df_ret, periods_per_year, look_back_carry_ret)
def set_weights_day(i, df_ret, periods_per_year, look_back_carry_ret):
ret = df_ret.iloc[i: i + look_back_carry_ret] / 100
max_sharpe = max_sharpe_ratio(ret.mean(), ret.cov(), 0, periods_per_year)
return pd.Series(max_sharpe.x, ret.columns, name=ret.index[-1]).round(7).sort_index()
Computes the max-Sharpe weight vector for a single rolling window starting
at index i, then packages it as a labeled pd.Series named after the
last date in the window (so the weight is dated as of the day it was
decided).
| Parameter | Type | Description |
|---|---|---|
i |
int |
Start index of the rolling window |
df_ret |
pd.DataFrame |
Carry returns |
periods_per_year |
int |
Annualization factor |
look_back_carry_ret |
int |
Window length in periods |
Returns: pd.Series of weights indexed by currency, .name set to the
window's final date.
max_sharpe_ratio(mean_returns, carry_cov, risk_free_rate, periods_per_year)
def max_sharpe_ratio(mean_returns, carry_cov, risk_free_rate, periods_per_year):
args = locals().values()
no = len(mean_returns)
constraints = {'type': 'eq', 'fun': lambda x: np.sum(x) - 0}
bounds = [(-1.0, 1.0) for _ in range(no)]
return sco.minimize(neg_sharpe_ratio, no * [1 / no], args=args, method='SLSQP',
bounds=bounds, constraints=constraints)
Solves the dollar-neutral, box-constrained Sharpe-maximization problem via
scipy.optimize.minimize (SLSQP). Returns the full scipy.optimize.OptimizeResult
object — the optimal weights are at .x.
args = locals().values()
This is a compact but slightly fragile trick: it forwards all of this
function's local variables (in definition order:
mean_returns, carry_cov, risk_free_rate, periods_per_year) as the
positional args tuple passed through to neg_sharpe_ratio. It works
correctly here because neg_sharpe_ratio's parameter order
(after weights) exactly matches, but it's a pattern that breaks
silently if either function's signature is reordered.
neg_sharpe_ratio(weights, mean_returns, carry_cov, risk_free_rate, periods_per_year)
def neg_sharpe_ratio(weights, mean_returns, carry_cov, risk_free_rate, periods_per_year):
p_ret, p_var = portfolio_annualised_performance(weights, mean_returns, carry_cov, periods_per_year)
return (risk_free_rate - p_ret) / p_var
The objective function minimized by SLSQP. Despite the local variable being
named p_var, portfolio_annualised_performance actually returns a
standard deviation, not a variance — see the note below.
Naming: p_var is really p_std
portfolio_annualised_performance returns (returns, std), where std
is already a standard deviation (it includes the sqrt(...)). The
variable here is named p_var, which is misleading — it is not being
squared, and dividing by variance instead of by standard deviation would
change the scale of the Sharpe ratio. The code is numerically correct;
only the variable name is inaccurate.
portfolio_annualised_performance(weights, mean_returns, carry_cov, periods_per_year)
def portfolio_annualised_performance(weights, mean_returns, carry_cov, periods_per_year):
returns = np.sum(mean_returns * weights) * periods_per_year
std = np.sqrt(np.dot(weights.T, np.dot(carry_cov, weights))) * np.sqrt(periods_per_year)
return returns, std
Computes annualized portfolio return \(R_p = (\mathbf w \cdot \boldsymbol\mu)\, p\) and annualized volatility \(\sigma_p = \sqrt{\mathbf w^\top \Sigma \mathbf w}\,\sqrt p\). See Portfolio Optimization for the full derivation.
Module: Performance & Reporting
get_results(df_ret, weights, strategy, periods_per_year, look_back_carry_ret, look_back_rolling_sharpe, num_obs)
def get_results(df_ret, weights, strategy, periods_per_year, look_back_carry_ret,
look_back_rolling_sharpe, num_obs):
# Weight decided at t-1 applied to return realized at t (no look-ahead)
weightd_ret = (df_ret * weights.shift(1)).sum(axis=1)
# Max_Sharpe_Port has no weights for the first look_back_carry_ret periods
ret = weightd_ret[look_back_carry_ret + 1:] if strategy == 'Max_Sharpe_Port' else weightd_ret[1:]
weightd_ret_mu, weightd_ret_std, sharpe = get_sharpe(ret, periods_per_year)
mdd = maximum_drawdown(weightd_ret)
segment_sharpes = [get_sharpe(weightd_ret[i - look_back_rolling_sharpe], periods_per_year)
for i in range(look_back_rolling_sharpe, num_obs)]
sharpe_roll_wind = np.array([sharpe if std else 0 for mu, std, sharpe in segment_sharpes])
return weightd_ret, weightd_ret_mu, weightd_ret_std, sharpe, sharpe_roll_wind, mdd
The central backtesting function: it turns a weight history into a realized strategy return series and a bundle of summary statistics.
| Parameter | Type | Description |
|---|---|---|
df_ret |
pd.DataFrame |
Carry returns per currency |
weights |
pd.DataFrame |
Portfolio weights per currency, per date |
strategy |
str |
Strategy name (affects burn-in trimming) |
periods_per_year |
int |
Annualization factor |
look_back_carry_ret |
int |
Optimization lookback (used to trim burn-in for Max_Sharpe_Port) |
look_back_rolling_sharpe |
int |
Window length for the rolling Sharpe chart |
num_obs |
int |
Number of observations to compute rolling Sharpe over |
Returns: (weightd_ret, weightd_ret_mu, weightd_ret_std, sharpe, sharpe_roll_wind, mdd)
Why the burn-in trim?
flowchart LR
A["Full weightd_ret series"] --> B{strategy == Max_Sharpe_Port?}
B -->|Yes| C["Drop first look_back_carry_ret + 1 periods<br/>(no weights until enough history<br/>exists to run the optimizer)"]
B -->|No| D["Drop first period only<br/>(weights.shift(1) creates one NaN row)"]
C --> E[Sharpe / return / vol computed<br/>on trimmed series]
D --> E
For Max_Sharpe_Port, the first look_back_carry_ret periods have no
optimized weight (there isn't enough history yet to fill the rolling
window), so those periods are excluded before computing summary statistics
— otherwise the average return/vol would be diluted by a stretch of
zero/NaN returns that isn't representative of the live strategy.
Rolling Sharpe indexing bug
weightd_ret[i - look_back_rolling_sharpe]
get_sharpe (which expects a
Series to call .mean() / .std() on) receives one scalar per
iteration. The corrected line should almost certainly be:
weightd_ret[i - look_back_rolling_sharpe : i]
print_results(...)
def print_results(df_ret, weightd_ret, weightd_ret_mu, weightd_ret_std, sharpe, sharpe_roll_wind, mdd,
spot_codes, look_back_rolling_sharpe):
print('Return =', round(weightd_ret_mu, 5), '%')
print('Volatility =', round(weightd_ret_std, 5), '%')
print('Sharpe Ratio =', round(sharpe, 3))
print('Max Drawdown =', round(mdd * 100, 3), '%')
# Chart 1: strategy return time series
fig, ax = plt.subplots()
plt.plot(df_ret.index, weightd_ret, color='red')
plt.xticks(df_ret.index[1::12], rotation=90)
plt.title('Strategy Returns')
plt.ylabel('%')
plt.grid(True)
plt.show()
plt.close('all')
# Chart 2: underlying FX returns, one line per currency pair
fig, ax = plt.subplots()
for spot_code in spot_codes:
plt.plot(df_ret.index, df_ret[spot_code], label=spot_code)
plt.xticks(df_ret.index[1::12], rotation=90)
plt.title('Underlying FX Returns ')
plt.ylabel('%')
plt.legend(loc=2, ncol=2, prop={'size': 8}).get_frame().set_alpha(0.1)
plt.show()
# Chart 3: rolling Sharpe ratio
plt.plot(df_ret.index[look_back_rolling_sharpe:], sharpe_roll_wind)
plt.xticks(df_ret.index[look_back_rolling_sharpe::12], rotation=90)
plt.grid(True)
plt.title('Rolling-3 year Sharpe Ratio')
plt.show()
Prints the four headline statistics, then produces three matplotlib
charts:
- Strategy Returns — the realized weighted return series over time.
- Underlying FX Returns — each currency pair's raw carry return, overlaid for comparison against the blended strategy.
- Rolling Sharpe Ratio — risk-adjusted performance stability through
time (title says "3 year" — accurate only when
look_back_rolling_sharpeis set to 36 periods of monthly data).
Title is hardcoded
The chart title 'Rolling-3 year Sharpe Ratio' is a hardcoded string —
if look_back_rolling_sharpe is changed to something other than 36
(with monthly data), the title will no longer accurately describe the
window length shown. Consider making this dynamic, e.g.:
years = look_back_rolling_sharpe / periods_per_year
plt.title(f'Rolling {years:.0f}-Year Sharpe Ratio')
Chart Layout
flowchart TB
A[print headline stats:<br/>Return, Vol, Sharpe, MDD] --> B[Chart 1:<br/>Strategy Returns line]
B --> C[Chart 2:<br/>Per-currency FX Returns]
C --> D[Chart 3:<br/>Rolling Sharpe Ratio]
Module: Strategy Combination
Two different ways of blending strategies are provided: a simple
return-blend (combine_strategies) and a signal-agreement filter
(composite_signal). See Architecture for a
side-by-side data-flow diagram.
combine_strategies(strat1, strat2, spot_codes, port_weightd_ret, periods_per_year, df_ret, look_back_rolling_sharpe, sharpe_roll_wind)
def combine_strategies(strat1, strat2, spot_codes, port_weightd_ret, periods_per_year, df_ret,
look_back_rolling_sharpe, sharpe_roll_wind):
strategy = '50/50 {} & {}'.format(strat1, strat2)
print('Running for Strategy :', strategy)
# Equal-weight blend of two already-computed return series
weightd_ret = (port_weightd_ret.loc[:, [strat1, strat2]] / 2).sum(axis=1)
weightd_ret_mu, weightd_ret_std, sharpe = get_sharpe(weightd_ret, periods_per_year)
mdd = maximum_drawdown(weightd_ret)
port_weightd_ret[strategy] = weightd_ret
print_results(df_ret, weightd_ret, weightd_ret_mu, weightd_ret_std, sharpe, sharpe_roll_wind, mdd,
spot_codes, look_back_rolling_sharpe)
Combines two already-backtested return series (assumed to live as
columns in port_weightd_ret, e.g. 'Simple_Carry' and 'Value') into a
50/50 blend:
This is a capital allocation view of diversification — each strategy is run independently and the portfolio-level P&L is averaged. It's simple and robust but does not account for any interaction between the two strategies' underlying positions (e.g. it could hold offsetting long/short positions in the same currency across the two sleeves without netting them).
sharpe_roll_wind is passed through unchanged
Note that combine_strategies receives sharpe_roll_wind as a
parameter and forwards it directly to print_results without
recomputing it for the blended series. This means the "Rolling Sharpe"
chart shown for the 50/50 blend will actually display the rolling
Sharpe of whichever series it was originally computed from — likely a
bug, since a genuinely blended rolling Sharpe should be recalculated
from weightd_ret.
composite_signal(strat1, strat2, spot_codes, port_weightd_ret, list_buy_sell, df_ret, periods_per_year, look_back_carry_ret, look_back_rolling_sharpe, num_obs)
def composite_signal(strat1, strat2, spot_codes, port_weightd_ret, list_buy_sell, df_ret, periods_per_year,
look_back_carry_ret, look_back_rolling_sharpe, num_obs):
strategy = strat1 + '_' + strat2
print('Running for Strategy:', strategy)
# Only keep a position where both strategies' signals point the same way
boolean_signal = list_buy_sell[strat1] == list_buy_sell[strat2]
df_carry_value = list_buy_sell[strat1] * boolean_signal
weights = set_weights(df_ret, df_carry_value, strategy, periods_per_year, look_back_carry_ret)
weightd_ret, *results = get_results(df_ret, weights, strategy, periods_per_year,
look_back_carry_ret, look_back_rolling_sharpe, num_obs)
port_weightd_ret[strategy] = weightd_ret
print_results(df_ret, weightd_ret, *results, spot_codes, look_back_rolling_sharpe)
Instead of blending returns after the fact, this combines signals before trading:
for each currency \(i\). boolean_signal is True wherever the two
strategies' buy/sell signals match exactly; multiplying list_buy_sell[strat1]
by this boolean mask zeroes out any currency where the strategies disagree,
while passing through strat1's (matching) value where they agree.
Uses strategy name that won't match set_weights' branch
set_weights only special-cases the literal string 'Max_Sharpe_Port';
any other string (including the composite name strat1 + '_' + strat2)
falls through to return df_buy_sell — which is the correct behavior
here, since composite_signal is combining rule-based signals, not
re-running the optimizer. This works as intended, but is a slightly
fragile string-matching design worth being aware of if strategy names
ever change.
Results storage: both functions write their blended return series back
into port_weightd_ret[strategy], building up a single DataFrame that
accumulates every individual and combined strategy's return series — handy
for a final side-by-side comparison table or correlation matrix across all
strategies tested in a session.
Known Issues & Recommendations
These were identified during a code review of the original script. None are fatal, but each can materially affect backtest results if left unaddressed.
1. Swapped arguments in set_weights() → set_weights_day()
Severity: High — silently changes the optimization window and annualization factor.
# set_weights_day signature:
def set_weights_day(i, df_ret, periods_per_year, look_back_carry_ret):
...
# but called as:
set_weights_day(i, df_ret, look_back_carry_ret, periods_per_year)
Fix:
set_weights_day(i, df_ret, periods_per_year, look_back_carry_ret)
2. Rolling Sharpe uses a single value instead of a window
Severity: High — the "rolling Sharpe ratio" chart is not actually rolling; each point is derived from a single scalar return rather than a trailing window of returns.
segment_sharpes = [get_sharpe(weightd_ret[i - look_back_rolling_sharpe], periods_per_year)
for i in range(look_back_rolling_sharpe, num_obs)]
Fix:
segment_sharpes = [get_sharpe(weightd_ret[i - look_back_rolling_sharpe : i], periods_per_year)
for i in range(look_back_rolling_sharpe, num_obs)]
3. Hardcoded 12 ** 0.5 annualization factor in get_sharpe()
Severity: Medium — correct only when periods_per_year == 12
(monthly data); silently wrong for any other frequency.
std = ret_series.std(ddof=1) * (12 ** 0.5)
Fix:
std = ret_series.std(ddof=1) * (periods_per_year ** 0.5)
4. combine_strategies reuses a stale sharpe_roll_wind
Severity: Medium — the rolling Sharpe chart shown for a 50/50 blend is not recomputed from the blended series; it's whatever was passed in.
Fix: recompute a rolling Sharpe for weightd_ret inside
combine_strategies (mirroring the logic in get_results), rather than
accepting it as a parameter.
5. df_val.values breaks index/column alignment safety
Severity: Low–Medium — works today if df_val and df_spot share
identical shape/ordering, but is fragile.
df_signal = (df_val.values - df_spot) / df_spot
Fix:
df_signal = (df_val - df_spot) / df_spot
6. max_sharpe_ratio constrains weights to sum to zero, not documented
Severity: Low (documentation) — this is very likely intentional
(dollar-neutral long/short book), but the docstring for max_sharpe_ratio
is missing entirely, so this constraint isn't discoverable without reading
the source.
Fix: Add a docstring explaining the dollar-neutral constraint and weight bounds explicitly.
7. maximum_drawdown docstring says "duration", code computes "magnitude"
Severity: Low (documentation) — no code change needed, just a
docstring correction: mdd is a decimal fraction representing the largest
percentage decline from a peak, not a count of periods.
8. Misleading variable name p_var in neg_sharpe_ratio
Severity: Low (readability) — p_var actually holds a standard
deviation (as returned by portfolio_annualised_performance), not a
variance. Renaming to p_std would avoid confusion, especially since
Sharpe ratio conventionally divides by standard deviation, not variance.
9. Hardcoded chart title 'Rolling-3 year Sharpe Ratio'
Severity: Low (cosmetic) — only accurate when
look_back_rolling_sharpe = 36 and data is monthly. Consider deriving the
label from look_back_rolling_sharpe / periods_per_year.
Summary Table
| # | Issue | Severity | File location |
|---|---|---|---|
| 1 | Swapped args in set_weights → set_weights_day call |
High | set_weights() |
| 2 | Non-rolling "rolling" Sharpe | High | get_results() |
| 3 | Hardcoded sqrt(12) annualization |
Medium | get_sharpe() |
| 4 | Stale sharpe_roll_wind in blend |
Medium | combine_strategies() |
| 5 | .values breaks alignment |
Low–Medium | get_returns() |
| 6 | Undocumented dollar-neutral constraint | Low | max_sharpe_ratio() |
| 7 | "Duration" vs "magnitude" docstring | Low | maximum_drawdown() |
| 8 | Misleading p_var name |
Low | neg_sharpe_ratio() |
| 9 | Hardcoded chart title | Low | print_results() |
Full Annotated Source
The complete script, reproduced with added inline comments explaining each
block. Cross-references to the theory pages and known-issue callouts are
inlined as # NOTE: / # BUG: comments where relevant.
| fxcarry.py | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 | |