Borrow Low, Lend High - EP8/365
A Backtested FX Carry Trade Strategy Across Emerging Market Currencies
Use the button at the end to download the source code
In an FX carry trade, investors secure funding in a currency from a developed economy featuring low interest rates and deploy those funds into a higher-yielding currency, typically from an emerging market. The primary goal is to capture profits from the difference in interest rates between the two. A key vulnerability here is the fluctuation in exchange rates, which can erode gains. According to principles like covered interest rate parity and inflation parity, currencies in emerging markets ought to weaken against those in developed ones, potentially wiping out these advantages. Yet, historical evidence indicates that carry trades have delivered positive results over extended horizons in many cases.
This analysis simulates an FX carry trade executed by a U.S.-based participant. The investor funds the position by borrowing in British Pound Sterling (GBP) for a brief one-week term, based on overnight index swap (OIS) rates. These proceeds, combined with additional personal funds, are then invested in debt instruments from emerging markets. To amplify potential yields, the focus is on longer-duration assets — specifically five-year bonds — which are liquidated after just one week to repay the GBP loan.
The timeframe examined encompasses significant global developments, such as the June 2016 Brexit vote, which led to a sharp decline in the value of the British Pound. Additionally, in early 2020, the COVID-19 pandemic prompted widespread interest rate cuts across nations to support expansionary monetary policies. Examining how the strategy fared amid these disruptions offers valuable insights.
Performance is assessed through two lenses:
1. Independent Strategy Review via Backtesting: This involves scrutinizing interest rate gaps and exchange rate movements across four emerging markets: (a) South Africa, (b) Thailand, (c)the Philippines, and (d) Pakistan. From there, we simulate the strategy’s returns, with positions in borrowing and lending adjusted weekly to reflect ongoing opportunities.
2. Integration into Broader Portfolios — Examining Correlations: FX carry trades in various currencies often move in tandem, driven by the interconnected nature of emerging market bonds and exchange rates. To gauge their fit within diversified holdings, we explore both aggregate correlations and how these links evolve over time among the strategies.
Key Assumptions:
- Borrowing in GBP for one week occurs at the prevailing OIS rate plus a 50 basis point premium, with interest calculated on an actual/360 day count convention each week.
- Investments adopt a one-week horizon using the zero-coupon yields from the selected emerging market debt securities.
- Currency exchanges happen at spot market rates.
System-Wide Parameters (Adopting CamelCase Standards) Applied Throughout the Evaluation
config_data = {
‘START_DT’: ‘2009-01-01’,
‘END_DT’: ‘2021-12-08’,
‘GB’: dict(Country=’United Kingdom’, Currency=’British Pound Sterling’, Yield=’UK Yield’),
‘SA’: dict(Country=’South Africa’, Currency=’South African Rand’, Yield=’South African Yield’),
‘TH’: dict(Country=’Thailand’, Currency=’Thai Baht’, Yield=’Thai Yield’),
‘PAK’: dict(Country=’Pakistan’, Currency=’Pakistani Rupee’, Yield=’Pakistani Yield’),
‘PHL’: dict(Country=’Philippines’, Currency=’Philippine Peso’, Yield=’Philippine Yield’)
}
for key_name, value_assign in list(config_data.items()):
globals()[key_name] = value_assignIt begins by defining a dictionary named config_data that encapsulates key elements needed for the strategy: the start and end dates of the historical period under evaluation — from January 1, 2009, to December 8, 2021 — to ensure the backtest covers a comprehensive timeframe capturing various market cycles, including the global financial crisis recovery and subsequent volatility. Additionally, the dictionary includes entries for specific country-currency pairs central to the carry trade logic, such as the British Pound (GB) against emerging market currencies like the South African Rand (SA), Thai Baht (TH), Pakistani Rupee (PAK), and Philippine Peso (PHL). Each entry is structured as a nested dictionary providing essential metadata: the country name for geographic context, the currency name for identification in FX pairs, and the yield source (e.g., ‘UK Yield’) to facilitate retrieval of interest rate data, which is crucial for calculating carry profits from holding higher-yielding currencies funded by lower-yielding ones.
The subsequent loop processes this configuration dictionary to make its contents directly accessible as global variables throughout the strategy’s codebase, promoting a clean and modular flow where these parameters can be referenced without repeated dictionary lookups. By iterating over a list of the dictionary’s items — using list(config_data.items()) to create a snapshot that avoids runtime modification issues during iteration — the code extracts each key (like ‘START_DT’ or ‘GB’) and its corresponding value (the date string or nested dictionary). It then dynamically assigns the value to the global namespace via globals()[key_name] = value_assign, effectively elevating these elements to top-level variables such as START_DT, GB, SA, and so on. This approach is chosen to streamline data flow in the strategy: the dates define the temporal boundaries for fetching historical FX rates and yields, while the country configurations enable targeted data pulls for yield differentials, ensuring that subsequent steps in the carry strategy — such as computing forward points or position sizing — can seamlessly integrate this metadata without cumbersome dictionary navigation, ultimately supporting the precise simulation of carry trade performance over the specified period.
%matplotlib inline
import warnings
warnings.filterwarnings(’ignore’)
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
import nasdaqdatalink
import scipy.stats as stats
#import statsmodels.api as sm
_ = sns.color_palette(”mako”, as_cmap=True)
plt.rcParams.update({”figure.figsize”: (15, 6)})The process begins with enabling inline matplotlib rendering via %matplotlib inline, ensuring that plots generated during the strategy’s exploratory data analysis and performance visualization display directly within the notebook cells for seamless review without disrupting the sequential flow of development.
Next, to maintain a clean execution environment, warnings are suppressed using warnings.filterwarnings(‘ignore’), which prevents extraneous output from libraries like pandas or numpy that might clutter the notebook during intensive computations, such as calculating carry returns or simulating strategy performance over historical FX data. This keeps the focus on the core logic of the strategy, where data integrity and output clarity are paramount.
The essential libraries are then imported to handle the numerical and tabular data central to FX carry analysis: numpy as np provides efficient array operations for vectorized computations, like normalizing interest rate spreads or computing rolling volatilities; pandas as pd equips us with DataFrames for structuring time-series FX rates and economic indicators, enabling easy merging, resampling, and alignment of multi-currency datasets that form the backbone of the strategy’s signal generation. For visualization, matplotlib.pyplot as plt and seaborn as sns are brought in to create insightful plots — such as equity curves, correlation heatmaps, or distribution histograms — that help validate the strategy’s risk-adjusted returns and exposure to market regimes.
The nasdaqdatalink library is imported to fetch high-quality historical financial data, including FX spot rates, forward curves, and interest rates from reliable sources, which is crucial for accurately reconstructing the carry trade universe and ensuring the strategy’s backtest reflects real-world market conditions. Similarly, scipy.stats as stats is loaded for advanced statistical tools, like performing normality tests on strategy returns or calculating Sharpe ratios, to rigorously assess the probabilistic underpinnings of the FX carry approach. The commented-out import for statsmodels.api as sm indicates a potential extension for econometric modeling, such as regression analysis on carry factors, but it’s held in reserve to avoid unnecessary dependencies at this stage.
Finally, the environment is visually tuned by assigning seaborn’s “mako” color palette to a variable for consistent, professional styling across plots, and updating matplotlib’s rcParams to set a default figure size of 15x6 inches. This configuration enhances readability when graphing strategy metrics, like cumulative P&L or drawdown profiles, allowing the team to quickly interpret how the FX carry positions evolve over time and across different currency pairs, thereby supporting informed decision-making in the strategy’s deployment.
Primary Operations Utilized in the Examination
def summaryStats(frame):
‘’‘
Computes summary statistics for the input dataframe
‘’‘
annual_returns = frame.mean() * 52
vol_annual = frame.std() * np.sqrt(52)
sharpe_values = annual_returns / vol_annual
skew_values = frame.skew()
kurt_excess = frame.kurt()
min_values = frame.min()
max_values = frame.max()
positive_ratio = round(100 * (frame > 0).mean().iloc[0], 2)
positive_series = pd.Series(positive_ratio, index=frame.columns)
stats_data = {
‘Mean Annualized Return (NonCompunded)’: annual_returns,
‘Annualized Volatility’: vol_annual,
‘Sharpe Ratio’: sharpe_values,
‘Skewness’: skew_values,
‘Excess Kurtosis’: kurt_excess,
‘Biggest Loss’: min_values,
‘Maximum Gain’: max_values,
‘% time positive’: positive_series
}
result = pd.DataFrame(stats_data)
return resultThis allows us to quickly assess the strategy’s risk-adjusted returns, distributional characteristics, and overall behavior, helping traders decide on position sizing or portfolio adjustments. The function takes a pandas DataFrame frame — typically containing weekly returns as columns for each currency pair — and processes it sequentially to compute annualized and descriptive statistics, ensuring all metrics are aligned for easy comparison.
The process begins by annualizing the mean returns and volatility to provide a yearly perspective, which is essential for comparing the strategy against benchmarks or other assets on a consistent time scale. It calculates the mean weekly return for each column using frame.mean(), then multiplies by 52 to get the annualized return (annual_returns), assuming 52 weeks in a year for non-compounded growth. Similarly, for volatility, it computes the standard deviation of weekly returns with frame.std() and scales it by the square root of 52 (vol_annual) to annualize it, reflecting the strategy’s risk level over a full year. From these, the Sharpe ratio is derived by dividing the annualized returns by the annualized volatility (sharpe_values), offering a measure of excess return per unit of risk, which is crucial for evaluating the efficiency of the carry strategy in volatile FX markets.
Next, the function delves into the distributional properties of the returns to uncover non-normal behaviors common in FX carry trades, such as asymmetry from interest rate differentials or sudden reversals. It computes skewness (skew_values) to quantify the asymmetry of return distributions — positive skew might indicate occasional large gains from carry unwind, while negative skew signals downside risks. Excess kurtosis (kurt_excess) follows, measuring the “tailedness” beyond a normal distribution, helping identify the strategy’s exposure to extreme events like geopolitical shocks. Additionally, the minimum and maximum values (min_values and max_values) capture the worst drawdown and best single-period gain, providing bounds on potential losses and upsides that inform risk management in the strategy.
To wrap up the core metrics, the function assesses the strategy’s consistency by calculating the percentage of positive return periods. It first determines the proportion of positive values in the DataFrame using (frame > 0).mean(), multiplies by 100 for a percentage, rounds to two decimals, and extracts the scalar value with .iloc[0] since it’s uniform across columns. This is then broadcast into a pandas Series (positive_series) with the same index as the input frame to ensure compatibility. These steps highlight the strategy’s win rate, which is particularly relevant for carry trades where steady positive drift from interest differentials is expected, though interrupted by volatility.
Finally, all computed statistics are organized into a dictionary (stats_data) where each key labels a metric and the value is the corresponding Series, maintaining the column structure for each currency pair. This dictionary is then converted into a pandas DataFrame (result), transposing the metrics into rows for a clean, tabular summary.
def compute_zcb_curve(spot_rates_curve, tenors):
‘’‘
Function to compute Zero Coupon Curve from a given Spot Rate Curve
Tenors: list of maturity dates to be considered while building the Zero Coupon Curve
‘’‘
adjusted_spot_data = spot_rates_curve.iloc[:, :len(tenors)].copy().fillna(method=’ffill’)
adjusted_spot_data.columns = tenors
transposed_data = adjusted_spot_data.T
column_names = list(transposed_data.columns)
for idx in range(len(column_names)):
current_column = column_names[idx]
current_series = transposed_data[current_column].copy()
eligible_maturities = [mat for mat in current_series.index if mat > 0.001]
maturity_idx = 0
while maturity_idx < len(eligible_maturities):
current_maturity = eligible_maturities[maturity_idx]
current_rate = current_series[current_maturity]
semi_annual_coupon = 0.5 * current_rate
interpolation_points = np.arange(0.5, current_maturity, 0.5)
index_array = transposed_data[current_column].index.values
value_array = transposed_data[current_column].values
interpolated_values = np.interp(interpolation_points, index_array, value_array)
discounted_values = semi_annual_coupon * np.exp(-interpolated_values * interpolation_points)
accumulated_coupons = np.sum(discounted_values)
log_argument = (1 - accumulated_coupons) / (1 + semi_annual_coupon)
updated_rate = -np.log(log_argument) / current_maturity
current_series[current_maturity] = updated_rate
maturity_idx += 1
transposed_data[current_column] = current_series
final_transposed = transposed_data.T
end_date = final_transposed.last_valid_index()
extended_index = pd.date_range(START_DT, end_date)
result_frame = final_transposed.reindex(extended_index).fillna(method=’ffill’)
result_frame = result_frame.dropna()
result_frame = result_frame.resample(’W-WED’).ffill()
return result_frameThe ZCB curve provides discount factors essential for pricing FX forwards, swaps, and carry positions accurately, as it represents continuously compounded zero rates that eliminate coupon reinvestment assumptions inherent in spot rates (often interpreted here as par yields for semi-annual coupon bonds). The function takes a spot_rates_curve DataFrame — typically with dates as the index and columns representing rate series for various tenors — and a list of tenors, which are numeric maturity points in years (e.g., [0.5, 1.0, 1.5, …]) used to structure the output curve.
The process begins by preparing the input data for bootstrapping. It selects the first len(tenors) columns from the spot_rates_curve, creates a copy to avoid modifying the original, and forward-fills any missing values to ensure continuity across the time series. The columns are then relabeled to match the tenors, aligning the data with specific maturities. Transposing the DataFrame shifts the perspective: tenors now form the row index (as numeric maturities), while the original columns (representing time series or scenarios) become the new columns. This setup facilitates sequential processing along the maturity axis for each independent rate series.
Next, the core bootstrapping logic iterates over each column in the transposed DataFrame, treating it as a series of rates to convert from spot (par) yields to zero rates. It first identifies eligible maturities greater than 0.001 years to skip negligible short ends. Processing occurs sequentially in order of increasing maturity, which is vital because each zero rate depends on previously computed values for discounting intermediate cash flows. For a given maturity T and its initial spot rate r (annualized), the function assumes a par bond paying semi-annual coupons at rate c = r, so each coupon is c/2. It generates interpolation points at semi-annual intervals from 0.5 up to but not including T. Using linear interpolation on the current series’ index (maturities) and values (rates, with prior ones already updated to zero rates), it estimates zero rates at these points. These interpolated zero rates are then used to compute discount factors for the coupons: each coupon amount c/2 is discounted back via exp(-interpolated_rate * time). The sum of these discounted coupons up to the penultimate period represents the bond’s intermediate cash flows.
To solve for the zero rate at T, the function rearranges the par bond pricing equation, where the bond’s face value of 1 equals the present value of all coupons plus principal. Specifically, it isolates the discount factor for the final payment (coupon plus principal) as DF(T) = [1 — sum of prior discounted coupons] / (1 + c/2), ensuring the bond prices at par. The zero rate z(T) is then derived as z(T) = -ln(DF(T)) / T, providing a continuously compounded rate. This updated zero rate replaces the original spot rate in the series at position T. By updating in place and proceeding sequentially, the function builds the zero curve iteratively, with each step leveraging the freshly computed zeros for accurate interpolation in subsequent maturities. This bootstrapping approach is how the spot curve — reflecting yields to maturity on coupon-bearing instruments — is transformed into a pure discount curve, free of embedded coupon effects, which is critical for FX carry calculations involving clean discounting across currencies.
After processing all columns, the DataFrame is transposed back, restoring the original structure with tenors as columns and time as the index. To create a complete, forward-looking curve suitable for strategy simulations, it identifies the last valid index date and extends the index to a daily range starting from a predefined START_DT up to that end date. The DataFrame is reindexed to this daily grid and forward-filled to propagate the most recent zero rates forward in time. Any remaining rows with all NaNs are dropped for cleanliness. Finally, to align with weekly risk and valuation cycles in the FX carry strategy, the result is resampled to Wednesdays (business convention for rate fixes) with forward-filling, yielding a weekly ZCB curve that can be directly used for computing carry-adjusted FX forwards or interest rate differentials over the tenors.
def buildStrategy(country,debtRatio = 0.8,FCYlendingTenor = 5.00, FCYexitTenor = 4+51/52,GBPfundingSpread = 0.005):
‘’‘
Constructs a Carry Trade approach for the specified nation by funding in GBP and investing in local bonds.
The approach targets long-term bonds (default 5 years) for one week, then closes positions weekly.
‘’‘
zero_coupon_bonds = country[’zcb’]
foreign_exchange = country[’exchange’]
trading_periods = zero_coupon_bonds.index
strat_df = strategyBuilder.copy()
notional_amount = 10000000
cash_reserve = (1 - debtRatio) * notional_amount
position_index = 0
total_trades = len(trading_periods) - 1
while position_index < total_trades:
start_period = trading_periods[position_index]
end_period = trading_periods[position_index + 1]
gbp_bond_rate = GB[’zcb’].loc[start_period][FCYlendingTenor]
local_bond_rate = zero_coupon_bonds.loc[start_period][FCYlendingTenor]
if gbp_bond_rate + 0.005 > local_bond_rate:
column_size = len(strat_df.columns)
null_row = [start_period, end_period] + [0] * (column_size - 2)
strat_df.loc[position_index] = null_row
position_index += 1
continue
entry_fx_gbp = GB[’exchange’].loc[start_period][’RATE’]
gbp_notional = notional_amount * entry_fx_gbp
debt_gbp = debtRatio * gbp_notional
ois_gbp = GB[’OIS’].loc[start_period]
cost_gbp = ois_gbp + GBPfundingSpread
cost_gbp_interest = (end_period - start_period).days / 360 * cost_gbp * debt_gbp
exit_fx_gbp = GB[’exchange’].loc[end_period][’RATE’]
repayment_notional = (cost_gbp_interest + debt_gbp) / exit_fx_gbp
entry_fx_local = foreign_exchange.loc[start_period][’RATE’]
investment_local = notional_amount * entry_fx_local
start_bond_rate = zero_coupon_bonds.loc[start_period][FCYlendingTenor]
start_bond_value = np.exp(-start_bond_rate * FCYlendingTenor)
end_bond_rate = np.interp(FCYexitTenor, zero_coupon_bonds.columns, zero_coupon_bonds.loc[end_period])
end_bond_value = np.exp(-end_bond_rate * FCYexitTenor)
outcome_local = investment_local * end_bond_value / start_bond_value
exit_fx_local = foreign_exchange.loc[end_period][’RATE’]
outcome_notional = outcome_local / exit_fx_local
profit_loss = outcome_notional - repayment_notional - cash_reserve
full_row = [start_period, end_period, notional_amount,
entry_fx_gbp, gbp_notional, debt_gbp, ois_gbp, cost_gbp, cost_gbp_interest,
exit_fx_gbp, repayment_notional,
entry_fx_local, investment_local, start_bond_rate, start_bond_value,
end_bond_rate, end_bond_value, outcome_local,
exit_fx_local, outcome_notional,
profit_loss, 0]
strat_df.loc[position_index] = full_row
position_index += 1
strat_df[’CumPnLUSD’] = strat_df[’PnL’].cumsum()
strat_df[’returns’] = strat_df[’PnL’] / cash_reserve
return strat_dfThis approach aims to profit from the interest rate differential, or “carry,” while limiting exposure through weekly position closures, thereby balancing potential returns against currency and yield curve risks. The strategy operates on a notional amount of $10 million USD, leveraging up to a specified debt ratio (default 80%), with the remainder held as a cash reserve to cover unleveraged portions and compute returns.
To begin, the function extracts key data from the input country’s dictionary: zero-coupon bond yields (zcb) and foreign exchange rates (exchange), both indexed by trading periods, which are assumed to be weekly. It initializes a strategy DataFrame (strat_df) as a copy of a predefined strategyBuilder template, ensuring a consistent structure for recording trade details. The trading periods define sequential weekly intervals, and the function iterates over these from the first to the second-to-last period, treating each pair (start_period to end_period) as a potential one-week trade window.
For each interval, the function first evaluates the opportunity by comparing the GBP zero-coupon bond rate at the lending tenor (default 5 years) against the local bond rate at the same tenor, adjusted by a 0.5% threshold (GBP rate + 0.005). This check ensures trades only occur when the local yield sufficiently exceeds the GBP funding cost, capturing the carry only in favorable differential scenarios; if not met, a null row is inserted into strat_df with zeros for metrics, skipping the trade to avoid unprofitable positions.
When the condition holds, the funding leg is calculated in GBP terms to simulate borrowing at low cost. The entry GBP/USD exchange rate converts the USD notional to GBP notional, from which the debt amount is derived using the debt ratio. The funding cost combines the GBP OIS rate (a risk-free benchmark) with a 0.5% spread to reflect realistic borrowing expenses, and the interest accrual is computed pro-rata over the week’s days (using a 360-day convention) on the debt principal. At the period’s end, the total repayment in GBP (principal plus interest) is converted back to USD using the exit GBP/USD rate, yielding the repayment_notional in USD — this step accounts for FX movements impacting the repayment burden when settling in USD terms.
Simultaneously, the investment leg converts the USD notional to local currency using the entry local/USD exchange rate, funding the purchase of local zero-coupon bonds at the 5-year tenor. The initial bond value is derived from the discount factor exp(-rate * tenor), representing the bond’s price per unit face value. After one week, the bond’s remaining tenor is slightly reduced (to approximately 4.98 years via FCYexitTenor), and the exit yield is interpolated from the updated local yield curve to compute the new discount factor. The local currency outcome reflects the bond’s value change over the week — primarily the carry from the yield but also any price appreciation or depreciation due to curve shifts — scaled by the initial investment and the ratio of end-to-start bond values, effectively simulating the short hold’s total return.
The FX conversion at exit brings the local outcome back to USD using the exit local/USD rate, producing outcome_notional. The period’s profit and loss (PnL) is then the difference between this outcome and the USD-equivalent repayment, minus the cash reserve, isolating the net gain attributable to the leveraged carry trade after accounting for funding costs, FX effects, and the unleveraged buffer. All intermediate values — FX rates, notionals, rates, values, and outcomes — are populated into a full row in strat_df for transparency and analysis.
Finally, post-loop, cumulative PnL in USD is computed as the running sum of individual PnLs, tracking the strategy’s compounded performance over time, while weekly returns are derived by dividing each PnL by the cash reserve, providing a measure of return on the equity portion. The resulting strat_df encapsulates the entire strategy’s weekly trades, enabling evaluation of the FX carry’s effectiveness in exploiting cross-currency yield differentials within the constrained, weekly-rolling framework of Strategy#4.
import matplotlib.pyplot as plt
from IPython.display import display
def assess_carry_opportunity(nation_data, foreign_currency_period=5.00, sterling_borrow_cost=0.005):
zero_coupon_rates, foreign_currency_fx = nation_data[’zcb’], nation_data[’exchange’]
plot_figure, (primary_axis, secondary_axis) = plt.subplots(2, 1, figsize=(15, 10))
interest_yield = nation_data[’Yield’]
plot_color = ‘tab:red’
primary_axis.set_ylabel(’Carry Interest/FX Rate Change’, fontsize=14)
carry_series = zero_coupon_rates[foreign_currency_period] - GB[’OIS’].loc[zero_coupon_rates[foreign_currency_period].index] - sterling_borrow_cost
primary_axis.plot(carry_series, color=plot_color, label=f’Carry Interest: {foreign_currency_period}yr {interest_yield} - GBP Funding’)
sterling_foreign_pair = foreign_currency_fx.loc[zero_coupon_rates[foreign_currency_period].index] / GB[’exchange’].loc[zero_coupon_rates[foreign_currency_period].index]
sterling_foreign_pair[’change’] = (sterling_foreign_pair[’RATE’] / sterling_foreign_pair[’RATE’].shift() - 1).dropna()
plot_color = ‘tab:blue’
primary_axis.plot(sterling_foreign_pair[’change’], color=plot_color, label=’Change in GBP/’ + nation_data[’Currency’] + ‘ exchange rate’)
mean_variation = round(100 * sterling_foreign_pair[’change’].mean(), 4)
primary_axis.axhline(y=sterling_foreign_pair[’change’].mean(), c=’m’,
label=f’Mean weekly change in FX Rate = {mean_variation}%, (Approx {round(52 * mean_variation, 2)}% per year)’)
primary_axis.set_title(’Potential: Carry Trade spreads’, fontsize=14)
primary_axis.legend()
total_return = nation_data[’Strategy’].CumPnLUSD.iloc[-1]
initial_investment = 2e6
duration_years = (nation_data[’Strategy’].ExitDate.iloc[-1] - nation_data[’Strategy’].EnterDate.iloc[0]).days / 365.25
annual_return_rate = round((total_return / initial_investment / duration_years) * 100, 0)
secondary_axis.set_ylabel(’USD Million’, fontsize=14)
secondary_axis.plot(nation_data[’Strategy’][’EnterDate’], nation_data[’Strategy’][’CumPnLUSD’], label=’Cummulative P&L’, color=’c’)
start_year = nation_data[’Strategy’].set_index(’ExitDate’).iloc[0].name.year
end_year = nation_data[’Strategy’].set_index(’ExitDate’).iloc[-1].name.year
line_color = lambda value: ‘g’ if value > 0 else ‘r’
annotation_text = lambda value: ‘Total Profit for year’ if value > 0 else ‘Total Loss for year’
yearly_data_points = []
for current_year in range(start_year, end_year + 1):
annual_slice = nation_data[’Strategy’].set_index(’ExitDate’).loc[str(current_year)]
annual_gain = annual_slice.PnL.sum()
yearly_data_points.append((annual_slice.index, [annual_gain] * len(annual_slice), line_color(annual_gain), annotation_text(annual_gain)))
for idx, dates, values, clr, lbl in enumerate(yearly_data_points):
secondary_axis.plot(dates, values, color=clr, label=lbl)
legend_elements, legend_names = secondary_axis.get_legend_handles_labels()
unique_names = set()
filtered_elements = []
filtered_names = []
for element, name in zip(legend_elements, legend_names):
if name not in unique_names:
unique_names.add(name)
filtered_elements.append(element)
filtered_names.append(name)
secondary_axis.legend(filtered_elements, filtered_names)
secondary_axis.set_xlabel(’Year’, fontsize=14)
secondary_axis.set_title(f’Backtested: Year-wise and Cummulative Profit and Loss \n Leveraged Annualized ROC = {annual_return_rate}%’, fontsize=14)
plt.suptitle(nation_data[’Country’] + ‘-UK Carry Trade: Potential and Backtested Profitability’, fontsize=18)
plot_figure.tight_layout()
plt.show()
weekly_returns_frame = (nation_data[’Strategy’][’PnL’] / 2e6).to_frame(nation_data[’Country’] + ‘-UK Weekly Carry Trade Returns’)
display(summaryStats(weekly_returns_frame))It takes as input a dictionary nation_data containing key elements like zero-coupon bond rates, exchange rates, yields, and strategy backtest results, along with parameters for the foreign currency tenor (default 5 years) and sterling borrowing cost (default 0.5% annually), allowing for flexible analysis of trade viability between the specified nation and the UK.
The function begins by extracting the zero-coupon rates and foreign exchange rates from nation_data, then initializes a two-panel subplot figure for dual-axis plotting: the primary axis focuses on the core carry mechanics and FX dynamics, while the secondary axis handles cumulative and yearly profit/loss (P&L) outcomes. This setup enables a layered view of the strategy’s theoretical potential and historical realization. On the primary axis, the carry interest series is computed as the difference between the foreign zero-coupon rate at the specified tenor and the corresponding UK Overnight Index Swap (OIS) rate, adjusted downward by the sterling borrowing cost; this quantifies the net yield pickup — the “why” behind the carry trade’s appeal, as it isolates the profit from interest arbitrage after funding expenses. This series is plotted in red, labeled to reflect the yield and GBP funding context, providing a time-series view of how persistent or volatile the opportunity remains over the data period.
Next, the primary axis incorporates the FX risk component by constructing the sterling-foreign currency exchange pair as the ratio of the foreign FX rate to the GBP exchange rate, aligned to the same index as the zero-coupon rates; this normalization creates a GBP-denominated perspective on the foreign currency’s value. Weekly changes in this pair are then calculated as the percentage return from one period to the next, plotted in blue to overlay the FX volatility against the carry interest — highlighting how adverse currency movements could erode gains, a critical “how” in assessing trade sustainability. A horizontal magenta line marks the mean weekly change, annotated with its value and an annualized approximation (scaled by 52 weeks), to contextualize the typical FX drift and its compounded impact over a year, helping evaluate if the carry spread compensates for this exposure.
Shifting to the secondary axis, the function derives overall strategy metrics from nation_data[‘Strategy’]: the total return as the final cumulative P&L in USD, divided by a fixed initial investment of $2 million and normalized by the trade duration (in years, from the first entry date to the last exit date) to yield the leveraged annualized rate of return. This cumulative P&L is plotted in cyan against entry dates, illustrating the strategy’s growth trajectory in USD millions and tying directly to the backtested execution of the carry trade. To break down performance annually, the code identifies the start and end years from exit dates, then iterates through each year: for each, it slices the strategy data by exit date, sums the yearly P&L to get the annual gain or loss, and prepares horizontal line segments spanning the year’s exit dates at that gain level, colored green for profits and red for losses with corresponding labels. These lines are plotted on the secondary axis, effectively annotating the cumulative curve with yearly summaries to reveal patterns in profitability, such as consistent gains or intermittent drawdowns, without cluttering the main trend.
To manage legend duplication from the multiple yearly lines (which share label text for profits or losses), the function retrieves all legend handles and labels, then filters to unique names, ensuring a clean display that consolidates repeated entries. Axis labels, titles, and a suptitle frame the visualization: the primary emphasizes “Potential: Carry Trade spreads,” the secondary highlights “Backtested: Year-wise and Cumulative Profit and Loss” with the annualized return, and the overall title names the nation-UK carry trade, reinforcing the strategy’s focus on opportunity assessment and historical validation. The layout is tightened for clarity before display.
Finally, to quantify risk and return at a granular level, weekly returns are computed by dividing the strategy’s weekly P&L by the $2 million initial investment, framed as a single-column DataFrame named for the carry trade pair; this is passed to a summaryStats function (presumed external) for display, providing statistical insights like mean, volatility, and Sharpe ratio on the normalized returns, which encapsulate the strategy’s weekly performance dynamics and support deeper evaluation of the FX carry opportunity’s consistency.
def distribution_analysis(data_input):
plot_frame, (density_plot, quantile_plot) = plt.subplots(1, 2, figsize=(15, 8))
raw_pnl = data_input[’Strategy’][’PnL’]
weekly_returns = raw_pnl / 2e6
avg_return = weekly_returns.mean()
std_dev = weekly_returns.std()
standardized_returns = (weekly_returns - avg_return) / std_dev
density_result = sns.distplot(standardized_returns, bins=40, ax=density_plot)
density_result.set_xlabel(’Normalized Weekly Return’, fontsize=12)
density_result.set_ylabel(’Density’, fontsize=12)
density_result.set_title(’Kernal Density of Weekly Returns’, fontsize=14)
stats.probplot(standardized_returns, dist=”norm”, plot=quantile_plot)
quantile_plot.set_title(’Quantile-Quantile Plot of Weekly Returns’, fontsize=14)
quantile_plot.set_xlabel(’Theoretical Quantiles’, fontsize=12)
quantile_plot.set_ylabel(’Observed Quantiles’, fontsize=12)
plt.suptitle(data_input[’Country’] + ‘-UK Carry Trade: Distribution of Weekly Returns’, fontsize=18)
plt.show()This helps in understanding the risk profile and potential deviations from expected behaviors in carry trade PnL.
The function begins by setting up a figure with two subplots side by side using Matplotlib, allocating a 15x8 inch space to accommodate a density plot on the left and a quantile-quantile (Q-Q) plot on the right. This dual-panel layout allows for a comprehensive view of the distribution in one glance, comparing empirical data against theoretical norms.
Next, it extracts the raw profit and loss (PnL) data for the strategy from the input dictionary and normalizes it to weekly returns by dividing by 2 million, representing the notional capital base. This step converts absolute PnL into percentage-like returns for comparability. It then computes the mean and standard deviation of these weekly returns, which are used to standardize the series by subtracting the mean and dividing by the standard deviation. Standardization centers the data at zero with unit variance, enabling a fair comparison to a standard normal distribution and highlighting any skewness or kurtosis in the strategy’s returns.
The left subplot receives a kernel density estimate (KDE) of the standardized returns using Seaborn’s distplot with 40 bins for smooth visualization of the probability density. Labels and title are applied to clarify that this plot shows the density of normalized weekly returns, emphasizing the shape — whether it’s bell-shaped or exhibits tails indicative of carry trade volatility from currency movements.
On the right, SciPy’s probplot generates a Q-Q plot against a normal distribution, plotting observed quantiles against theoretical ones to visually test for normality. If points align closely with the diagonal line, it suggests the returns follow a normal distribution, which is crucial for assuming standard risk metrics in the FX carry strategy; deviations reveal fat tails or asymmetry from events like sudden rate changes or interventions.
Finally, a overarching title incorporates the country name and strategy descriptor, tying the visualization back to the specific carry trade pair. The plot is then displayed, completing the analysis workflow by rendering these insights for quick interpretation of the strategy’s return distribution.
Sourcing Data
Utilizing Nasdaq Data Link (previously known as Quandl), our process draws on the YC collection to retrieve interest rate yield curves across a range of global currencies, encompassing Overnight Indexed Swap (OIS) figures specific to the United Kingdom. In parallel, the CUR collection from this source supplies exchange rates for foreign currencies benchmarked against the US dollar.
def retrieve_yield_info(nation_id):
raw_data = nasdaqdatalink.get(f’YC/{nation_id}’, start_date=START_DT, end_date=END_DT)
return raw_data / 100
def obtain_exchange_value(nation_id):
return nasdaqdatalink.get(f’CUR/{nation_id}’, start_date=START_DT, end_date=END_DT)
nasdaqdatalink.ApiConfig.api_key = ‘YourAPIkey’
getYieldCurve = retrieve_yield_info
getCurrencyRate = obtain_exchange_valueThe process begins with configuring access to the Nasdaq Data Link API by setting the API key, ensuring that all subsequent data fetches are authenticated and can pull historical financial datasets reliably for the strategy’s analysis period defined by START_DT and END_DT.
The core logic then flows through two key functions designed to gather the essential inputs for carry trade calculations. First, retrieve_yield_info takes a nation_id as input, representing a specific country’s currency or economic zone, and queries the Nasdaq Data Link for yield curve data using the endpoint ‘YC/{nation_id}’. This fetches raw yield percentages over the specified date range, which are then normalized by dividing by 100 to convert them into decimal form — a standard practice for financial computations where rates need to align with other quantitative models, such as those evaluating borrowing and lending costs in carry strategies. The function returns this processed dataset, providing the interest rate baselines that highlight yield advantages or disadvantages across nations.
Next, obtain_exchange_value follows a similar pattern but targets currency exchange rate data via the ‘CUR/{nation_id}’ endpoint, retrieving spot rates against a base currency for the same date range without further transformation. This raw data is crucial for the strategy as it enables the adjustment of yield differentials into a common currency perspective, accounting for forex movements that could erode or amplify carry profits. By encapsulating these fetches in dedicated functions, the code promotes modularity, allowing the strategy’s higher-level logic to invoke them as needed for pairs of nations.
This abstraction hides the underlying retrieval details, making it easier for the strategy’s core algorithms to reference these data sources directly when computing carry trade opportunities, such as identifying high-yield currencies funded by low-yield ones while hedging exchange rate risks.
country_mappings = {
GB: ‘GBP’,
SA: ‘ZAR’,
TH: ‘THB’,
PAK: ‘PKR’,
PHL: ‘PHP’
}
for target, code in country_mappings.items():
raw_rate = getCurrencyRate(code)
weekly_resampled = raw_rate.resample(’W-WED’)
target[’exchange’] = weekly_resampled.ffill()It begins by defining a dictionary called country_mappings that associates country identifiers — such as GB for Great Britain, SA for South Africa, and others — with their corresponding ISO currency codes like ‘GBP’ for the British Pound and ‘ZAR’ for the South African Rand. This mapping serves as a concise lookup to ensure we’re targeting the right currencies relevant to the strategy’s focus on high-yield carry pairs.
The code then iterates over each entry in this mapping using a for loop, where target represents the country identifier (e.g., GB) and code is the associated currency code (e.g., ‘GBP’). For each iteration, it retrieves the raw historical exchange rate data for that currency by calling getCurrencyRate(code), which presumably fetches time-series data, likely in a pandas Series or DataFrame format, from an external source or database. This step ensures we have the foundational daily or intraday rate data needed to compute carry returns, as exchange rate fluctuations directly impact the profitability of holding positions in these currencies.
Next, to align the data with the strategy’s weekly rebalancing cadence — common in carry strategies to reduce noise from daily volatility — the raw rate series is resampled to a weekly frequency anchored on Wednesdays via raw_rate.resample(‘W-WED’). Wednesdays are chosen as the anchor day to standardize the end-of-week snapshot, avoiding distortions from weekend gaps or varying market closures. This resampling aggregates the rates (typically taking the last value of the week) into a coarser timeline that matches the strategy’s holding period.
Finally, to handle any potential gaps in the weekly data — such as missing weeks due to holidays or data unavailability — the resampled series is forward-filled using ffill(), which propagates the most recent available rate forward until a new value appears. This maintains continuity in the time series without introducing artificial interpolations that could skew carry calculations. The resulting forward-filled weekly rates are then assigned to a column named ‘exchange’ within the target object, which is likely a DataFrame or dictionary keyed by the country identifier, thereby populating the exchange rate component for each currency in the strategy’s dataset. This prepared data flows into subsequent steps of the strategy, enabling the computation of carry yields by combining these rates with interest rate differentials.
Obtaining UK Overnight Index Swap Data and Sovereign Yield Curves for Every Nation
temp_issc = getYieldCurve(’GBR_ISSC’)
short_term_series = temp_issc[’0.08Y’]
resampled_series = short_term_series.resample(’W-WED’).ffill()
GB[’OIS’] = resampled_series
source_gbr = getYieldCurve(’GBR’)
GBR_yield = source_gbr.fillna(method=’ffill’)
zaf_source = getYieldCurve(’ZAF’)
filled_zaf = zaf_source.fillna(method=’ffill’)
intermediate_rate = (filled_zaf[’12-Month’] + filled_zaf[’5to10-Year’]) / 2
filled_zaf[’5-Year’] = intermediate_rate
columns_to_exclude = {’3-Month’, ‘6-Month’, ‘9-Month’, ‘3to5-Year’, ‘5to10-Year’, ‘10-Year’}
SA_yield = filled_zaf.loc[:, ~filled_zaf.columns.isin(columns_to_exclude)].dropna()
tha_data = getYieldCurve(’THA’)
filled_tha = tha_data.fillna(method=’ffill’)
short_terms_th = {’1-Month’, ‘3-Month’, ‘6-Month’}
TH_yield = filled_tha.drop(columns=short_terms_th)
pak_source = getYieldCurve(’PAK’)
filled_pak = pak_source.fillna(method=’ffill’)
pak_short = (’3-Month’, ‘6-Month’)
PAK_yield = filled_pak.drop(columns=pak_short)
phl_data = getYieldCurve(’PHL’)
filled_phl = phl_data.fillna(method=’ffill’)
phl_shorts = [’1-Month’, ‘3-Month’, ‘6-Month’]
PHL_yield = filled_phl.drop(columns=phl_shorts)The process begins with the United Kingdom’s Overnight Index Swap (OIS) rate, essential for benchmarking short-term risk-free rates in FX carry calculations. It retrieves the GBR_ISSC yield curve, extracts the ultra-short 0.08-year tenor to represent near-term OIS dynamics, and resamples this series to a weekly frequency aligned to Wednesdays — a common convention for financial data to ensure consistency in weekly carry rollovers — using forward fill to propagate the last available value, thereby minimizing gaps in the time series. This cleaned series is then assigned to the ‘OIS’ column in the GB DataFrame, providing a stable short-term reference for GBP-based carry trades.
Next, the code handles the broader GBR yield curve to capture the full spectrum of UK government bond yields, which are crucial for assessing longer-term interest rate environments in carry strategies involving GBP pairs. It fetches the source data via getYieldCurve(‘GBR’) and applies forward fill to handle any missing observations, ensuring a continuous series that reflects the forward evolution of yields without introducing artificial breaks that could distort carry computations.
Shifting to South Africa (ZAF), a high-yield emerging market often targeted in carry trades for its interest rate premium, the code retrieves the yield curve and forward-fills missing values to maintain data continuity. To derive a representative 5-year yield — key for medium-term carry exposure where direct data might be sparse — it calculates an intermediate rate as the average of the 12-month and 5-to-10-year tenors, blending short- and medium-term information to approximate the missing benchmark without relying on external interpolation. It then excludes irrelevant columns such as very short-term (3-, 6-, 9-month) and extended long-term (3-to-5-year, 5-to-10-year, 10-year) maturities, focusing the dataset on core tenors like the newly computed 5-year, before dropping any remaining rows with NaN values. This results in a streamlined SA_yield DataFrame tailored for ZAF’s role in carry strategies, emphasizing actionable maturities for interest differential analysis.
For Thailand (THA), another emerging market with attractive carry potential due to its policy rates, the process mirrors the cleaning approach: it fetches the yield curve, forward-fills gaps to ensure seamless time series coverage, and drops the shortest tenors (1-, 3-, and 6-month) to prioritize medium- and longer-term yields that better represent sustainable carry yields, avoiding the volatility of ultra-short rates that might not align with strategy horizons. This yields a focused TH_yield series for THB-related pairs.
Similarly, for Pakistan (PAK), which offers elevated yields in carry contexts despite higher risk, the code retrieves and forward-fills the yield curve data, then excludes the 3- and 6-month short-term columns to concentrate on longer maturities suitable for carry trade duration, producing a refined PAK_yield DataFrame that supports reliable interest rate comparisons.
Finally, the Philippines (PHL) follows the same pattern, fetching and forward-filling the yield curve before removing the 1-, 3-, and 6-month short tenors, resulting in a PHL_yield series that highlights medium-term yields ideal for PHP carry opportunities. Throughout, this sequential data preparation ensures that each currency’s yield series is gap-free, maturity-focused, and aligned with the FX carry strategy’s need for consistent, relevant interest rate inputs to evaluate and execute trades based on cross-currency differentials.
Deriving Zero-Coupon Yields Based on Spot Rates
country_mappings = {
‘SA’: SA,
‘TH’: TH,
‘PAK’: PAK,
‘PHL’: PHL,
‘GB’: GB
}
yield_sources = {
‘SA’: SA_yield,
‘TH’: TH_yield,
‘PAK’: PAK_yield,
‘PHL’: PHL_yield,
‘GB’: GBR_yield
}
maturity_periods = {
‘SA’: (1, 5),
‘TH’: (1, 2, 3, 4, 5),
‘PAK’: (1, 3, 5),
‘PHL’: (1, 2, 3, 4, 5),
‘GB’: (5,)
}
for identifier in country_mappings:
country_mappings[identifier][’zcb’] = compute_zcb_curve(
yield_sources[identifier],
list(maturity_periods[identifier])
)It begins by defining three dictionaries to organize the necessary data: country_mappings associates country codes (such as ‘SA’ for Saudi Arabia) with predefined country data objects (like SA), providing a central structure to hold computed results; yield_sources links the same country codes to their respective yield data objects (e.g., SA_yield), which contain the interest rate information derived from market sources; and maturity_periods specifies the time horizons in years for each country as tuples (e.g., (1, 5) for Saudi Arabia, indicating 1-year and 5-year maturities), tailored to the relevant tenors where yield data is reliable and impactful for carry analysis.
The logic then proceeds sequentially through a loop that iterates over each country identifier from country_mappings. For every identifier, it invokes the compute_zcb_curve function, passing the corresponding yield source from yield_sources and the list-converted maturity periods from maturity_periods — this conversion ensures the function receives an iterable list of integers for processing. The function computes the ZCB curve by discounting the yields to derive spot rates for zero-coupon instruments at those maturities, effectively transforming raw yield data into a smooth curve that represents the time value of money for that country’s currency. This computed curve is then assigned to the country data object under the key ‘zcb’ within country_mappings, enriching the structure with this essential pricing tool. By doing so, the code ensures that each country’s profile now includes a ready-to-use ZCB curve, enabling downstream calculations in the strategy — such as forward rate agreements or swap valuations — to assess carry profitability by comparing borrowing and lending rates across these currencies.
Section 4: Implementing the Carry Trade Tactic
In this section, we delve into the prospects of applying a Carry Trade approach across the four targeted emerging economies. Following that, we scrutinize the profiles of returns and risks to gauge the strategy’s feasibility for real-world implementation.
portfolio_frame = pd.DataFrame({
‘EnterDate’: [],
‘ExitDate’: [],
‘USDNotional’: [],
‘USD-GBP_Ent’: [],
‘EqvGBP’: [],
‘BorrowGBP’: [],
‘GBP_OIS’: [],
‘GBPFundingRate’: [],
‘InterstGBP’: [],
‘USD-GBP_ext’: [],
‘ReturnAmountUSD’: [],
‘FCY_USD_Ent’: [],
‘LendFCY’: [],
‘EnterZCRate’: [],
‘5YBondP’: [],
‘ExitZCRate’: [],
‘BondPriceExt’: [],
‘RealizedFCY’: [],
‘FCY_USD_ext’: [],
‘RealizedUSD’: [],
‘PnL’: [],
‘CumPnLUSD’: []
})
portfolio_frame.index.name = ‘Week’This setup ensures all key metrics for each weekly period are captured in a consistent format, allowing for straightforward computation of positions, cash flows, and performance as the strategy simulates entering and exiting trades.
The core logic starts with creating an empty pandas DataFrame named portfolio_frame, which serves as the central repository for strategy data. We populate it with a predefined set of columns, each designed to hold specific values that reflect the mechanics of the FX carry trade. For instance, columns like ‘EnterDate’ and ‘ExitDate’ record the timestamps for initiating and closing positions, enabling precise tracking of trade durations. Similarly, ‘USDNotional’ captures the initial USD investment size, while ‘USD-GBP_Ent’ and ‘USD-GBP_ext’ store the entry and exit exchange rates, which are crucial for calculating currency conversions and exposures as the strategy exploits rate movements.
As data flows through the strategy, subsequent columns handle the GBP-side elements of the trade. ‘EqvGBP’ computes the equivalent GBP amount from the USD notional at entry, reflecting the spot conversion needed to fund the position. ‘BorrowGBP’ then tracks the borrowed GBP amount, as the carry strategy typically involves borrowing in the lower-yield currency (here, GBP) to invest elsewhere. Funding dynamics are addressed via ‘GBP_OIS’ for the Overnight Index Swap rate and ‘GBPFundingRate’ for the overall cost of borrowing, which together inform ‘InterstGBP’ — the interest accrued or paid on the GBP leg, directly tying into the carry profit mechanism by offsetting borrowing costs against yield differentials.
The DataFrame further accommodates fixed income components integral to the strategy’s risk hedging or yield enhancement. ‘FCY_USD_Ent’ and ‘FCY_USD_ext’ mirror the USD-side equivalents for foreign currency (FCY) lending, with ‘LendFCY’ quantifying the lent amount to generate carry income. Zero-coupon rates enter via ‘EnterZCRate’ and ‘ExitZCRate’, used to value ‘5YBondP’ (the 5-year bond price at entry) and ‘BondPriceExt’ at exit, as the strategy may incorporate bond positions to lock in yields. This flows into realized values: ‘RealizedFCY’ and ‘RealizedUSD’ compute the final FCY and USD amounts post-trade, culminating in ‘ReturnAmountUSD’ for the USD return and ‘PnL’ for the period’s profit and loss.
Finally, to support cumulative performance analysis, ‘CumPnLUSD’ accumulates the USD PnL across weeks, providing a running total of strategy returns. By naming the DataFrame’s index ‘Week’, we establish a time-series structure that aligns all entries chronologically, facilitating sequential population as the strategy iterates through historical or simulated weeks — ensuring every data point contributes to a cohesive narrative of position management, cash flow reconciliation, and overall carry realization.
entities = (PAK, PHL, SA, TH)
for entity in entities:
entity[’Strategy’] = buildStrategy(entity)In the context of implementing Strategy #4, the FX Carry Strategy, this code segment prepares a set of predefined market entities — specifically PAK for Pakistan, PHL for the Philippines, SA for South Africa, and TH for Thailand — by equipping each with a customized strategy object. These entities are first defined as a tuple, ensuring a fixed, iterable collection of the target markets relevant to the carry trade analysis, where interest rate differentials drive profitability across emerging market currencies. The subsequent for loop sequentially processes each entity in this tuple, allowing the data flow to move from the collective list to individual customization. For every entity encountered, the buildStrategy function is invoked with the entity itself as the sole argument; this function analyzes the entity’s inherent attributes — such as currency pairs, interest rates, and risk parameters — and constructs a tailored strategy instance optimized for that market’s carry dynamics. The resulting strategy object is then assigned directly to a ‘Strategy’ key within the entity, effectively embedding the computed logic into the entity’s structure. This approach ensures that downstream processes in the FX Carry Strategy can access and execute market-specific tactics uniformly, as each entity now carries its pre-built strategy, facilitating efficient parallel evaluation of carry opportunities across the selected economies.
Section One: Spotlight on South Africa
Prior to 2019, the South African Reserve Bank pursued an accommodative monetary approach, maintaining steady interest rates over that period. The following year, in 2020, authorities implemented a short-lived increase in rates, only to pivot toward reductions as the COVID-19 pandemic emerged.
Featuring an interest rate differential of approximately 6% for carry trades, South Africa stands out as a compelling option for investors pursuing such strategies.
(lambda func, arg: func(arg))(carry_trade_potential, SA)The expression begins with an immediately invoked lambda function that acts as a simple applicator: it takes two arguments — a function and an argument — and applies the function to that argument. Here, the lambda is defined as taking func and arg, then returning func(arg). This lambda is then called right away with carry_trade_potential as the func and SA as the arg. As a result, the data flow is straightforward: the value or data represented by SA — likely a currency pair or market identifier such as South African Rand-related assets — is passed directly into the carry_trade_potential function, which processes it to generate a metric assessing the potential return from carrying positions in that pair, factoring in elements like yield spreads and risk adjustments central to the strategy’s goal of optimizing carry trade selections. This approach ensures that the computation happens inline, integrating seamlessly into the broader strategy logic without unnecessary intermediate variables, allowing the output to feed into subsequent decision-making steps like position sizing or risk filtering.
Key Insights from Analysis
The analysis shows that a strong and persistent interest rate spread exists, typically between 6 percent and 8 percent, when comparing the five year South African zero coupon yield with the UK lending rate priced at OIS plus 50 basis points. This spread remains a core driver of the strategy and underpins its overall economic rationale.
Movements in the GBP/ZAR exchange rate are highly volatile, with weekly fluctuations often falling in the range of 4 percent to 6 percent. Even with this level of instability, the data reveals repeated episodes of mean reversion. Over the entire period studied, the South African Rand weakened by an average of about 3.83 percent per year, which is materially lower than the carry earned from the relevant fixed income instruments. This gap helps explain why the strategy remains viable despite currency risk.
From a performance perspective, the strategy struggled up to 2016. Losses during this phase were largely caused by a combination of widening interest rate differentials and sharper depreciation of the Rand, which together weighed heavily on returns. Conditions improved between 2016 and 2018 as carry yields began to compress after the early months of 2016, leading to a noticeable turnaround in results.
In 2020, returns dropped sharply as the COVID-19 pandemic emerged. This period was marked by a sudden expansion in interest rate spreads, which negatively affected the weekly execution of the carry strategy and forced positions to be closed at unfavorable levels. At the same time, the Rand experienced a steep devaluation. However, this weakness reversed relatively quickly, allowing the strategy to recover much of the earlier losses.
Overall, when leverage is applied, the approach produces an annualized return on capital of roughly 31 percent. The average yearly return shown in the table, around 0.30, reflects an implied weekly compounding assumption of 52 periods and broadly aligns with the reported return on capital. Returns vary significantly from week to week and year to year, which is typical for fixed income carry strategies exposed to foreign exchange volatility. Profits were recorded in about 55 percent of the observed periods, a hit rate that is sufficient to generate attractive long term performance.
Interestingly, unlike many carry trade strategies that tend to exhibit negatively skewed returns with pronounced tail risk, this implementation displays little skewness and no significant excess kurtosis. The distribution of returns appears relatively balanced, and a more detailed analysis of these distributions follows.
Analyzing Return Patterns
At this stage, we delve into the spread of our returns data, enabling a sharper assessment of both the odds and the scale of the anticipated downturns.
analysis_func = distribution_analysis
input_param = SA
analysis_func(input_param)The process begins by assigning the function distribution_analysis to the variable analysis_func. This assignment creates a flexible reference to the analysis routine, allowing the code to invoke it dynamically without hardcoding the function name directly; this approach promotes modularity, as it enables the strategy to swap or extend the analysis logic in larger workflows without altering subsequent calls.
Next, the variable input_param is set to SA, which represents the sensitivity analysis data structure — typically a dataset or model output capturing how the FX carry strategy’s performance varies under different market scenarios, such as shifts in carry yields or volatility regimes. By isolating this as input_param, the code prepares the data for processing in a clean, reusable way, ensuring that the analysis receives precisely the sensitivity metrics needed to assess the strategy’s robustness.
Finally, the code executes analysis_func(input_param), passing the sensitivity analysis data into the distribution analysis function. This invocation computes statistical properties of the input distribution, such as mean returns, variance, or tail risks, tailored to the FX carry context where understanding carry trade profitability under varying currency pair dynamics is crucial. The result informs the strategy’s decision-making, like identifying optimal entry points or hedging thresholds, by quantifying how sensitive the carry positions are to distributional assumptions in FX markets. This sequential flow ensures the analysis integrates seamlessly into the broader strategy evaluation pipeline.
Key Insights
Kernel Density Estimation
The generated kernel density plot reveals a subtle leftward tilt in its distribution. The bulk of the probability mass clusters near the standardized average, while the extremities taper off rapidly.
Q-Q Plot Analysis
In the quantile-quantile graph, the majority of data points align closely with the straight line representing a standard normal distribution. That said, the outer regions show pronounced departures from this line. Negative outliers outnumber their positive counterparts, indicating a slight asymmetry toward the downside. Nevertheless, nearly every point falls inside the bounds of three standard deviations from the center, suggesting that no significant extreme events have surfaced in this dataset.
Insights on the UK-South Africa Carry Trade Strategy
In our analysis, the carry trade from the UK to South Africa emerged as a backtested approach delivering impressive yields alongside reduced exposure to volatility.
This robust performance can likely be linked to the persistently undervalued British pound and consistent interest rate environments across much of the examined timeframe.
Given the absence of prolonged major disruptions in exchange rates or borrowing costs within the evaluation window, the simulated results might overlook rare, extreme market shocks — often termed Black Swan occurrences. That said, the method is poised to produce solid gains over prolonged durations, potentially sufficient to offset any infrequent but severe downturns.
Thailand
Among developing economies, Thailand stands out for its notably subdued interest rates. The nation’s currency, the Thai Baht, has demonstrated remarkable resilience over many years. That changed in 2019 when the central bank implemented fresh cuts to these rates, which in turn curtailed prospects for carry trade operations. This situation provides a fascinating lens through which to explore the dynamics of carry trade viability.
def execute_strategy(input_value):
carry_trade_potential(input_value)
execute_strategy(TH)This approach ensures a streamlined process where the strategy’s potential is calculated based on an input parameter, allowing for modular and focused analysis of carry trade viability — such as identifying profitable spreads between low-yield funding currencies and higher-yield investment currencies.
The function execute_strategy is defined to accept a single input_value parameter, which represents a key input for the strategy, such as a specific currency pair, market threshold, or economic indicator relevant to carry trade dynamics. Upon invocation, the function immediately delegates to carry_trade_potential(input_value), passing this value directly. This design choice encapsulates the strategy’s logic within a dedicated subroutine, promoting separation of concerns: the outer function acts as a high-level orchestrator, while carry_trade_potential handles the intricate computations, like simulating interest accruals, risk adjustments for exchange rate volatility, and potential returns over a holding period. By structuring it this way, the code maintains clarity and reusability, ensuring that the strategy’s evaluation is triggered precisely when needed without unnecessary overhead.
Following the function definition, the code concludes with a direct call to execute_strategy(TH), where TH serves as the concrete input value — likely denoting a predefined constant or variable tied to a specific market entity, such as the Thai Baht in an FX context, which could be evaluated for its carry trade attractiveness against a funding currency like the Japanese Yen. This invocation sets the strategy in motion, flowing the input through the function to ultimately assess and potentially realize the carry trade’s profitability, aligning with the broader goal of identifying and quantifying high-yield opportunities in global forex markets.
Key Insights:
1. Rate Spreads: In the context of the Thailand carry trade, the gap between Thailand’s yield curve rates and the UK’s OIS + 50 basis points lending benchmark appears significantly reduced. This contraction in the differential has created brief windows for short-term gains, though it lacks sustainability for longer durations. At times, the spread has dipped into negative territory. Importantly, we avoid entering positions unless the UK-Thailand yield differential meets our minimum threshold.
2. Currency Fluctuations: Over the analysis timeframe, the Thai Baht strengthened relative to the British Pound, opening doors for earnings through forex shifts in carry operations. This outcome runs counter to typical economic forecasts and is unlikely to persist across extended periods.
3. Annual Results: Across most years examined, the strategy delivered outcomes ranging from a $1 million deficit to $2 million surplus. These gains stemmed predominantly from favorable Thai Baht movements rather than the interest rate carry itself. In the past two years, however, it posted losses, driven by razor-thin carry margins and the Baht’s overall decline versus the Pound. Although the COVID-19 outbreak triggered elevated swings in exchange rates, these stabilized rapidly.
4. Overall Strategy Performance: Employing leverage, the approach yielded an annual return of 19.27%, characterized by low volatility and minimal asymmetry. That said, it succeeded in just 51.3% of trading intervals, raising questions about its viability as a consistent strategy. Unlike the anticipated left-tail risk in carry trades, this setup displays limited skewness and kurtosis in its return profile. We explore return distributions in the following section.



