Skip to content

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:

  1. Data ingestion — read spot, forward, and PPP value time series from Excel.
  2. 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).
  3. Portfolio construction — turn signals into portfolio weights, either directly (rule-based strategies) or via mean-variance optimization (Max_Sharpe_Port).
  4. 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

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):

\[ F_t = S_t \cdot \frac{1 + i_{d}}{1 + i_{f}} \]

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:

\[ \frac{F_t - S_t}{S_t} \approx i_d - i_f \]

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:

\[ r_t = \left(\frac{S_t}{F_{t-1}} - 1\right) \times 100 \]

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:

\[ E[S_{t+1}] = F_t \]

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

\[ \text{signal}_t = \left(\frac{S_t}{F_t} - 1\right) \times 100 \]

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

\[ \text{signal}_t = \frac{1}{\sigma_t}\left(\frac{S_t}{F_t} - 1\right) \times 100 \]

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)

\[ \text{signal}_t = \frac{V_t - S_t}{S_t} \]

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\):

\[ R_p = \left(\sum_i w_i \mu_i\right) \times p \]
\[ \sigma_p = \sqrt{\mathbf{w}^\top \Sigma\, \mathbf{w}} \times \sqrt{p} \]

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:

\[ \text{Sharpe}(\mathbf{w}) = \frac{R_p(\mathbf{w}) - r_f}{\sigma_p(\mathbf{w})} \]

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

\[ \begin{aligned} \min_{\mathbf{w}} \quad & \frac{r_f - R_p(\mathbf{w})}{\sigma_p(\mathbf{w})} \\ \text{s.t.} \quad & \sum_i w_i = 0 \\ & -1 \le w_i \le 1 \quad \forall i \end{aligned} \]
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):

\[ \mu_{ann} = \bar{r} \times p \]
\[ \sigma_{ann} = s_r \times \sqrt{12}, \qquad s_r = \sqrt{\frac{1}{n-1}\sum_{t=1}^n (r_t - \bar r)^2} \]
\[ \text{Sharpe} = \frac{\mu_{ann}}{\sigma_{ann}} \]

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:

\[ C_t = \prod_{k=1}^{t}\left(1 + \frac{r_k}{100}\right) \]

Then the maximum drawdown is the largest peak-to-trough percentage decline observed at any point in the series:

\[ \text{MDD} = \max_{t}\left(\frac{\displaystyle\max_{s \le t} C_s \; - \; C_t}{\displaystyle\max_{s \le t} C_s}\right) \]

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):

\[ \text{Sharpe}_t^{roll} = \frac{\mu(r_{t-L:t}) \times p}{\sigma(r_{t-L:t}) \times \sqrt{12}} \]
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)
But 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]
indexes a single value, not a slice, so 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]
to pass a genuine trailing window. See Known Issues.

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:

  1. Strategy Returns — the realized weighted return series over time.
  2. Underlying FX Returns — each currency pair's raw carry return, overlaid for comparison against the blended strategy.
  3. Rolling Sharpe Ratio — risk-adjusted performance stability through time (title says "3 year" — accurate only when look_back_rolling_sharpe is 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:

\[ r_t^{blend} = \frac{r_t^{strat1} + r_t^{strat2}}{2} \]

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:

\[ w_t^{i} = \begin{cases} w_t^{i,\,strat1} & \text{if } \operatorname{sign}(w_t^{i,\,strat1}) = \operatorname{sign}(w_t^{i,\,strat2}) \\ 0 & \text{otherwise} \end{cases} \]

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
letting pandas align by label instead of relying on positional order.

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_weightsset_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
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import scipy.optimize as sco


# ---------------------------------------------------------------------------
# Data loading
# ---------------------------------------------------------------------------
def get_data(fname, sheet):
    """
    Returns a sheet read in from an excel file.

    Paramaters:
        fname (str): file name
        sheet (str): sheet name

    Returns:
        (pd.DataFrame): dataframe of excel sheet with 'Date' as index col
    """
    # Standardizes every sheet read to be indexed by Date so that Spot,
    # Forward and Value dataframes are automatically aligned downstream.
    return pd.read_excel(fname, sheet_name=sheet, index_col='Date')


# ---------------------------------------------------------------------------
# Returns & signal construction  (see docs: theory/signals.md)
# ---------------------------------------------------------------------------
def get_returns(df_spot, df_fwd, df_val, vol, strategy):
    """
    Returns carry return and signal datafranes calculated from spot, forward
    and value PPP dataframes.

    Parameters:
        df_spot (pd.DataFrame): spot
        df_fwd (pd.DataFrame): forward
        df_val (pd.DataFrame): value
        vol (pd.DataFrame): realized vol
        strategy (str): strategy

    Returns:
        df_ret (pd.DataFrame): carry returns
        df_signal (pd.DataFrame): carry signal
    """
    # Realized P&L: spot today vs. the forward rate locked in one period ago.
    # r_t = (S_t / F_{t-1} - 1) * 100
    df_ret = (df_spot / df_fwd.shift(1) - 1) * 100

    if strategy in ('Simple_Carry', 'Max_Sharpe_Port'):
        # Current forward premium -- used to rank currencies by carry
        # attractiveness *today* (no lag, unlike df_ret above).
        df_signal = ((df_spot / df_fwd) - 1) * 100
    elif strategy == 'VolAdj_Carry':
        # Risk-adjusted carry: raw carry signal divided by realized vol,
        # favoring high carry-per-unit-of-risk over raw carry magnitude.
        df_signal = ((df_spot / df_fwd) - 1) * 100 / vol
    elif strategy == 'Value':
        # PPP deviation signal (mean-reversion, not carry):
        # positive => spot is below fair value => long recommendation.
        # NOTE: uses df_val.values (positional) rather than df_val
        # (label-aligned) -- fine only if df_val and df_spot share identical
        # shape/order. See docs: known-issues.md #5.
        df_signal = (df_val.values - df_spot) / df_spot
    else:
        raise ValueError('Error in Strategy Name')

    return df_ret, df_signal


# ---------------------------------------------------------------------------
# Mean-variance optimization  (see docs: theory/portfolio-optimization.md)
# ---------------------------------------------------------------------------
def portfolio_annualised_performance(weights, mean_returns, carry_cov, periods_per_year):
    # Annualized return: R_p = (w . mu) * periods_per_year
    returns = np.sum(mean_returns * weights) * periods_per_year
    # Annualized vol:   sigma_p = sqrt(w' Sigma w) * sqrt(periods_per_year)
    std = np.sqrt(np.dot(weights.T, np.dot(carry_cov, weights))) * np.sqrt(periods_per_year)
    return returns, std


def neg_sharpe_ratio(weights, mean_returns, carry_cov, risk_free_rate, periods_per_year):
    # NOTE: variable is named p_var but is actually a standard deviation
    # (portfolio_annualised_performance already applies sqrt(...)).
    # See docs: known-issues.md #8.
    p_ret, p_var  = portfolio_annualised_performance(weights, mean_returns, carry_cov, periods_per_year)
    # Minimizing this is equivalent to maximizing (p_ret - risk_free_rate) / p_var,
    # since scipy.optimize.minimize only minimizes.
    return (risk_free_rate - p_ret) / p_var


def max_sharpe_ratio(mean_returns, carry_cov, risk_free_rate, periods_per_year):
    # Forwards all local vars, in definition order, as *args to
    # neg_sharpe_ratio (after the `weights` arg supplied by the optimizer).
    args = locals().values()
    no = len(mean_returns)
    # Dollar-neutral constraint: weights must sum to zero (long/short book),
    # NOT to one (this is not a fully-invested long-only portfolio).
    constraints = {'type': 'eq', 'fun': lambda x: np.sum(x) - 0}
    # Each currency's weight is bounded in [-100%, 100%].
    bounds = [(-1.0, 1.0) for _ in range(no)]
    # SLSQP: handles nonlinear objective with linear equality + box constraints.
    # Initial guess: equal weight 1/n for every currency.
    return sco.minimize(neg_sharpe_ratio, no * [1 / no], args=args, method='SLSQP', bounds=bounds,
                        constraints=constraints)


def set_weights_day(i, df_ret, periods_per_year, look_back_carry_ret):
    """
    Returns weights for a slice of the time series.

    Parameters:
        i (int): start index of slice
        df_ret (pd.DataFrame): carry returns
        periods_per_year (int):
        look_back_carry_ret (int):

    Returns:
        (pd.Series):

    """
    # Trailing rolling window of raw (not-yet-annualized) returns.
    ret = df_ret.iloc[i: i + look_back_carry_ret] / 100
    # Re-estimate mean & covariance purely from this window, then optimize.
    max_sharpe = max_sharpe_ratio(ret.mean(), ret.cov(), 0, periods_per_year)
    # Label the resulting weight vector with the *last* date in the window --
    # i.e. the date on which this weight decision would actually be made.
    return pd.Series(max_sharpe.x, ret.columns, name=ret.index[-1]).round(7).sort_index()


def set_weights(df_ret, df_buy_sell, strategy, periods_per_year, look_back_carry_ret):
    """
    Returns weights for the trading stratgey as an equal weighted portfolio.

    Parameters:
        df_ret (pd.DataFrame): carry returns
        df_buy_sell (pd.DataFrame): dataframe holding the buy sell signal at
                                    each rebalance date for all the currencies
                                    in the basket
        strategy (str): name of the weighing scheme
        periods_per_year (int):
        look_back_carry_ret (int):

    Returns:
        (pd.DataFrame): weights assigned for each currency pair based on the
                        weighing scheme chosen
    """
    if strategy == 'Max_Sharpe_Port':
        # Re-optimize on every rolling window and stitch the resulting
        # per-date weight Series together into one wide DataFrame.
        #
        # BUG: arguments are passed in the wrong order here relative to
        # set_weights_day's signature (periods_per_year, look_back_carry_ret).
        # See docs: known-issues.md #1.
        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: pass through the externally-computed
        # buy/sell weight matrix unchanged.
        return df_buy_sell


# ---------------------------------------------------------------------------
# Performance metrics  (see docs: theory/performance-metrics.md)
# ---------------------------------------------------------------------------
def maximum_drawdown(ret_series):
    """
    Returns maximum drawdown duration of a return series.

    Parameters:
        ret_series (np.array): return series

    Returns:
        mdd (int): maximum drawdown duration
    """
    # NOTE: despite the docstring/variable naming, this computes maximum
    # drawdown *magnitude* (a fraction, e.g. 0.23 == 23% peak-to-trough
    # decline), not a *duration* in periods. See docs: known-issues.md #7.
    cum_ret = np.cumprod(1 + ret_series / 100)
    mdd = 0
    peak = cum_ret[0]

    for x in cum_ret:
        if x > peak:
            peak = x  # update running peak
        dd = (peak - x) / peak
        if dd > mdd:
            mdd = dd  # track worst drawdown seen so far

    return mdd


def get_sharpe(ret_series, periods_per_year):
    """
    Returns mu, std and sharpe of a return series.

    Parameters:
        ret (pd.Series): return series
        periods_per_year (int):

    Returns:
        mu (float): average
        std (float): standard deviation
        sharpe (float): sharpe ratio
    """
    mu = ret_series.mean() * periods_per_year
    # BUG: hardcoded sqrt(12) regardless of periods_per_year -- only correct
    # for monthly data (periods_per_year == 12). See docs: known-issues.md #3.
    std = ret_series.std(ddof=1) * (12 ** 0.5)
    sharpe = mu / std
    return mu, std, sharpe


def get_results(df_ret, weights, strategy, periods_per_year, look_back_carry_ret,
                look_back_rolling_sharpe, num_obs):
    """
    Calculates the strategy realized returns and the perormance statisctics.

    Parameters:
        df_ret (pd.DataFrame): carry returns
        weights (pd.DataFrame): weights for trading strategy
        strategy (str): name of weighting scheme
        periods_per_year (int):
        look_back_carry_ret (int):
        look_back_rolling_sharpe (int): rolling sharpe historical period
        num_obs (int):

    Returns:
        weightd_ret (pd.DataFrame): returns of the strategy
        weightd_ret_mu (float): annualised returns
        weightd_ret_std (float): annualized standard deviation
        sharpe (float): sharpe ratio
        sharpe_roll_wind (np.array):
        mdd (int): maximum drawdown duration
    """
    # Lag weights by one period: the weight decided using info known at
    # t-1 is applied to the return realized during period t. This is what
    # prevents look-ahead bias in the backtest.
    weightd_ret = (df_ret * weights.shift(1)).sum(axis=1)

    # Trim the burn-in period where no (or NaN) weights exist yet.
    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)

    # BUG: weightd_ret[i - look_back_rolling_sharpe] indexes a single
    # scalar, not a slice -- so get_sharpe receives one number rather than
    # a trailing window of returns. Should be
    # weightd_ret[i - look_back_rolling_sharpe : i]. See known-issues.md #2.
    segment_sharpes = [get_sharpe(weightd_ret[i - look_back_rolling_sharpe], periods_per_year)
                       for i in range(look_back_rolling_sharpe, num_obs)]
    # Guard against division-by-zero Sharpe when std == 0.
    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


# ---------------------------------------------------------------------------
# Reporting
# ---------------------------------------------------------------------------
def print_results(df_ret, weightd_ret, weightd_ret_mu, weightd_ret_std, sharpe, sharpe_roll_wind, mdd,
                  spot_codes, look_back_rolling_sharpe):
    """
    Prints results.

    Parameters:
        df_ret (pd.DataFrame): carry returns
        weightd_ret (pd.DataFrame): returns of the strategy
        weightd_ret_mu (float): annualised returns
        weightd_ret_std (float): annualized standard deviation
        sharpe (float): sharpe ratio
        mdd (int): maximum drawdown duration
        sharpe_roll_wind (np.array):
        spot_codes (list):
        look_back_rolling_sharpe (int):
    """
    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 per-currency carry returns for comparison.
    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.
    # NOTE: title is hardcoded to "3 year"; only accurate when
    # look_back_rolling_sharpe == 36 with monthly data.
    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()


# ---------------------------------------------------------------------------
# Multi-strategy combination  (see docs: modules/strategy-combination.md)
# ---------------------------------------------------------------------------
def combine_strategies(strat1, strat2, spot_codes, port_weightd_ret, periods_per_year, df_ret,
                       look_back_rolling_sharpe, sharpe_roll_wind):
    """
    Allocates an equal weight to both strategies and combines them in a 50/50
    portfolio.

    Parameters:
        strat1 (str): first strategy
        strat2 (str): second strategy
        spot_codes (list):
        port_weightd_ret (pd.DataFrame):
        periods_per_year (int):
    """
    strategy = '50/50 {} & {}'.format(strat1, strat2)
    print('Running for Strategy :', strategy) 

    # Simple return-blend: average the two strategies' already-realized
    # weighted return series 50/50 (capital allocation, not signal blending).
    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
    # NOTE: sharpe_roll_wind is passed straight through, not recomputed for
    # the blended series -- the rolling Sharpe chart may not reflect the
    # blend. See docs: known-issues.md #4.
    print_results(df_ret, weightd_ret, weightd_ret_mu, weightd_ret_std, sharpe, sharpe_roll_wind, mdd,
                  spot_codes, look_back_rolling_sharpe)


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):
    """
    Creates a composite signal of the 2 strategies to backtest the strategy and
    calculate performance statistics.

    Parameters:
        strat1 (str): first strategy
        strat2 (str): second strategy
        spot_codes (list):
        port_weightd_ret (pd.DataFrame):
        list_buy_sell (dict): carry signals for all strategies
        df_ret (pd.DataFrame): carry returns
        periods_per_year (int):
        look_back_carry_ret (int):
        look_back_rolling_sharpe (int):
        num_obs (int):
    """
    strategy = strat1 + '_' + strat2
    print('Running for Strategy:', strategy)

    # Signal-agreement filter: keep a position only where both strategies'
    # buy/sell signals agree in direction; zero out everywhere else.
    boolean_signal = list_buy_sell[strat1] == list_buy_sell[strat2]
    df_carry_value = list_buy_sell[strat1] * boolean_signal

    # `strategy` (e.g. "Simple_Carry_Value") won't match 'Max_Sharpe_Port',
    # so set_weights correctly falls through to the rule-based passthrough.
    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)