| Overall Statistics |
|
Total Orders 398 Average Win 1.07% Average Loss -0.53% Compounding Annual Return 0.175% Drawdown 13.300% Expectancy 0.077 Start Equity 25000 End Equity 25814.19 Net Profit 3.257% Sharpe Ratio -0.44 Sortino Ratio -0.525 Probabilistic Sharpe Ratio 0.000% Loss Rate 64% Win Rate 36% Profit-Loss Ratio 2.03 Alpha -0.013 Beta -0.059 Annual Standard Deviation 0.04 Annual Variance 0.002 Information Ratio -0.497 Tracking Error 0.177 Treynor Ratio 0.297 Total Fees $0.00 Estimated Strategy Capacity $1800000.00 Lowest Capacity Asset USDZAR 8G Portfolio Turnover 1.30% Drawdown Recovery 1367 |
# region imports
from AlgorithmImports import *
# endregion
import numpy as np
"""
FX CARRY + MOMENTUM + RATE-CHANGE STRATEGY
This algorithm implements a monthly foreign-exchange ranking model.
The strategy trades a universe of USD-based FX pairs:
USDEUR, USDZAR, USDAUD, USDJPY, USDTRY,
USDINR, USDCNY, USDMXN, USDCAD
Each FX pair is linked to an external interest-rate proxy from Nasdaq Data Link.
The model ranks the FX pairs using a composite score built from three components:
1. Carry level:
The current value of the linked interest-rate proxy.
Higher rates receive a better rank.
2. FX momentum:
The recent price momentum of the FX pair.
Stronger positive momentum receives a better rank.
3. Rate change:
The recent change in the linked interest-rate proxy.
Rising rates receive a better rank.
The composite score is:
composite score =
carry_weight * carry_rank
+ momentum_weight * momentum_rank
+ rate_change_weight * rate_change_rank
The strategy rebalances once per month.
Portfolio construction:
- Go long the top-ranked FX pairs.
- Go short the bottom-ranked FX pairs.
- Hold no position in the middle-ranked pairs.
- Use volatility-adjusted weights inside the long and short baskets.
- Cap the maximum weight of any single FX pair.
- Avoid trading if the score gap is too small.
- Avoid sending orders if the target weight change is too small.
Risk and quality controls:
- The model uses monthly-only logic; there is no OnData loop.
- It logs the full monthly ranking for auditability.
- It tracks monthly turnover.
- It plots portfolio value, cash benchmark, score gap, turnover, rate spread,
active positions, and target gross exposure.
- It includes basic rate-data diagnostics so unusual rate values can be seen.
- It includes a monthly drawdown check. If the portfolio drawdown is too large,
the strategy moves to cash for the month.
Important data note:
The Nasdaq Data Link / BCB rate series are used as macro rate proxies. They may
not perfectly represent the true tradable carry in the FX market. This is why the
model logs the full monthly ranking and plots the selected rate spread.
"""
class ForexCarryTradeAlgorithm(QCAlgorithm):
def Initialize(self):
# ------------------------------------------------------------
# 1. BACKTEST SETTINGS
# ------------------------------------------------------------
self.SetStartDate(2008, 1, 1)
self.SetEndDate(2026, 5, 5)
self.initial_cash = 25000
self.SetCash(self.initial_cash)
# ------------------------------------------------------------
# 2. FX PAIRS AND RATE DATA
# ------------------------------------------------------------
self.rate_symbol_by_ticker = {
"USDEUR": "BCB/17900", # Euro Area
"USDZAR": "BCB/17906", # South Africa
"USDAUD": "BCB/17880", # Australia
"USDJPY": "BCB/17903", # Japan
"USDTRY": "BCB/17907", # Turkey
"USDINR": "BCB/17901", # India
"USDCNY": "BCB/17899", # China
"USDMXN": "BCB/17904", # Mexico
"USDCAD": "BCB/17881" # Canada
}
# Original working structure:
# string FX symbol -> rate data symbol
self.symbols = {}
# Additional map:
# string FX symbol -> QuantConnect Symbol object
self.fx_symbol_objects = {}
for ticker, rate_symbol in self.rate_symbol_by_ticker.items():
forex_symbol = self.AddForex(
ticker,
Resolution.Daily,
Market.Oanda
).Symbol
data_symbol = self.AddData(
NasdaqDataLink,
rate_symbol,
Resolution.Daily,
TimeZones.Utc,
True
).Symbol
self.symbols[str(forex_symbol)] = data_symbol
self.fx_symbol_objects[str(forex_symbol)] = forex_symbol
# ------------------------------------------------------------
# 3. STRATEGY PARAMETERS
# ------------------------------------------------------------
self.top_count = 2
self.bottom_count = 2
self.target_gross_exposure = 1.00
self.max_single_pair_weight = 0.35
self.carry_weight = 0.50
self.momentum_weight = 0.35
self.rate_change_weight = 0.15
self.momentum_lookback_days = 126
self.volatility_lookback_days = 63
self.rate_change_lookback_days = 126
self.minimum_score_gap = 1.00
self.minimum_weight_change = 0.05
# Monthly drawdown guard.
self.max_drawdown_from_peak = 0.25
self.portfolio_peak = self.initial_cash
# ------------------------------------------------------------
# 4. DIAGNOSTIC STATE
# ------------------------------------------------------------
self.previous_targets = {}
self.rebalance_count = 0
for symbol in self.symbols.keys():
self.previous_targets[symbol] = 0.0
# ------------------------------------------------------------
# 5. MONTHLY REBALANCE ONLY
# ------------------------------------------------------------
self.Schedule.On(
self.DateRules.MonthStart("USDEUR"),
self.TimeRules.BeforeMarketClose("USDEUR"),
self.Rebalance
)
def Rebalance(self):
# ------------------------------------------------------------
# 1. PORTFOLIO DRAWDOWN CHECK
# ------------------------------------------------------------
self.portfolio_peak = max(
self.portfolio_peak,
self.Portfolio.TotalPortfolioValue
)
portfolio_drawdown = 0.0
if self.portfolio_peak > 0:
portfolio_drawdown = (
self.Portfolio.TotalPortfolioValue
/ self.portfolio_peak
- 1.0
)
if portfolio_drawdown <= -self.max_drawdown_from_peak:
self.Debug(
"Monthly drawdown guard triggered on "
+ str(self.Time.date())
+ ". Moving to cash. Drawdown="
+ str(round(portfolio_drawdown, 4))
)
for symbol in self.symbols.keys():
self.SetHoldings(symbol, 0.0)
self.previous_targets[symbol] = 0.0
self.portfolio_peak = self.Portfolio.TotalPortfolioValue
self.Plot("Strategy Equity", "Portfolio Value", self.Portfolio.TotalPortfolioValue)
self.Plot("Strategy Equity", "Cash Benchmark", self.initial_cash)
self.Plot("Risk Diagnostics", "Portfolio Drawdown", portfolio_drawdown)
self.Plot("Risk Diagnostics", "Drawdown Limit", -self.max_drawdown_from_peak)
return
# ------------------------------------------------------------
# 2. BUILD DATA TABLE
# ------------------------------------------------------------
data_table = []
for fx_symbol_string, rate_symbol in self.symbols.items():
rate_value = self.Securities[rate_symbol].Price
fx_momentum = self.GetMomentum(
fx_symbol_string,
self.momentum_lookback_days
)
fx_volatility = self.GetVolatility(
fx_symbol_string,
self.volatility_lookback_days
)
rate_change = self.GetRateChange(
rate_symbol,
self.rate_change_lookback_days
)
data_table.append(
{
"fx_symbol": fx_symbol_string,
"rate_symbol": rate_symbol,
"rate": rate_value,
"momentum": fx_momentum,
"volatility": fx_volatility,
"rate_change": rate_change
}
)
minimum_required = self.top_count + self.bottom_count
if len(data_table) < minimum_required:
return
# ------------------------------------------------------------
# 3. CREATE RANKS
# ------------------------------------------------------------
# Higher rate is better.
carry_sorted = sorted(
data_table,
key=lambda x: x["rate"]
)
for rank, item in enumerate(carry_sorted):
item["carry_rank"] = rank
# Higher FX momentum is better.
momentum_sorted = sorted(
data_table,
key=lambda x: x["momentum"]
)
for rank, item in enumerate(momentum_sorted):
item["momentum_rank"] = rank
# More positive rate change is better.
rate_change_sorted = sorted(
data_table,
key=lambda x: x["rate_change"]
)
for rank, item in enumerate(rate_change_sorted):
item["rate_change_rank"] = rank
# ------------------------------------------------------------
# 4. COMPOSITE SCORE
# ------------------------------------------------------------
for item in data_table:
item["score"] = (
self.carry_weight * item["carry_rank"]
+ self.momentum_weight * item["momentum_rank"]
+ self.rate_change_weight * item["rate_change_rank"]
)
ranked = sorted(
data_table,
key=lambda x: x["score"]
)
bottom_group = ranked[:self.bottom_count]
top_group = ranked[-self.top_count:]
lowest_score = bottom_group[0]["score"]
highest_score = top_group[-1]["score"]
score_gap = highest_score - lowest_score
# ------------------------------------------------------------
# 5. BUILD TARGET WEIGHTS
# ------------------------------------------------------------
target_weights = {}
for item in data_table:
target_weights[item["fx_symbol"]] = 0.0
if score_gap >= self.minimum_score_gap:
long_budget = self.target_gross_exposure / 2.0
short_budget = self.target_gross_exposure / 2.0
long_weights = self.GetInverseVolatilityWeights(
top_group,
long_budget
)
short_weights = self.GetInverseVolatilityWeights(
bottom_group,
short_budget
)
for symbol, weight in long_weights.items():
target_weights[symbol] = weight
for symbol, weight in short_weights.items():
target_weights[symbol] = -weight
else:
self.Debug(
"No trade on "
+ str(self.Time.date())
+ ". Score gap too small: "
+ str(round(score_gap, 4))
)
# ------------------------------------------------------------
# 6. CAP SINGLE-PAIR WEIGHTS AND RESCALE GROSS EXPOSURE
# ------------------------------------------------------------
for symbol in target_weights:
if target_weights[symbol] > self.max_single_pair_weight:
target_weights[symbol] = self.max_single_pair_weight
if target_weights[symbol] < -self.max_single_pair_weight:
target_weights[symbol] = -self.max_single_pair_weight
gross_target = sum(
abs(weight)
for weight in target_weights.values()
)
if gross_target > self.target_gross_exposure and gross_target > 0:
scale = self.target_gross_exposure / gross_target
for symbol in target_weights:
target_weights[symbol] = target_weights[symbol] * scale
# ------------------------------------------------------------
# 7. TURNOVER CALCULATION BEFORE UPDATING PREVIOUS TARGETS
# ------------------------------------------------------------
monthly_turnover = 0.0
for symbol, target_weight in target_weights.items():
previous_weight = self.previous_targets.get(symbol, 0.0)
monthly_turnover += abs(target_weight - previous_weight)
monthly_turnover = monthly_turnover / 2.0
# ------------------------------------------------------------
# 8. APPLY TARGETS ONLY WHEN CHANGE IS MEANINGFUL
# ------------------------------------------------------------
for symbol, target_weight in target_weights.items():
previous_weight = self.previous_targets.get(symbol, 0.0)
weight_change = abs(target_weight - previous_weight)
if weight_change >= self.minimum_weight_change:
self.SetHoldings(
symbol,
target_weight
)
self.previous_targets[symbol] = target_weight
self.rebalance_count += 1
# ------------------------------------------------------------
# 9. LOG FULL MONTHLY RANKING
# ------------------------------------------------------------
self.Debug("========== MONTHLY FX COMPOSITE RANKING ==========")
self.Debug("Date: " + str(self.Time.date()))
for item in ranked:
self.Debug(
str(item["fx_symbol"])
+ " | Rate="
+ str(round(item["rate"], 6))
+ " | Momentum="
+ str(round(item["momentum"], 6))
+ " | Rate Change="
+ str(round(item["rate_change"], 6))
+ " | Volatility="
+ str(round(item["volatility"], 6))
+ " | Score="
+ str(round(item["score"], 4))
)
self.Debug(
"Long group: "
+ str([x["fx_symbol"] for x in top_group])
+ " | Short group: "
+ str([x["fx_symbol"] for x in bottom_group])
+ " | Score gap="
+ str(round(score_gap, 4))
+ " | Turnover="
+ str(round(monthly_turnover, 4))
)
# ------------------------------------------------------------
# 10. MONTHLY PLOTS ONLY
# ------------------------------------------------------------
self.Plot(
"Strategy Equity",
"Portfolio Value",
self.Portfolio.TotalPortfolioValue
)
self.Plot(
"Strategy Equity",
"Cash Benchmark",
self.initial_cash
)
self.Plot(
"Carry Diagnostics",
"Rebalance Count",
self.rebalance_count
)
self.Plot(
"Carry Diagnostics",
"Monthly Turnover",
monthly_turnover
)
self.Plot(
"Carry Diagnostics",
"Score Gap",
score_gap
)
self.Plot(
"Carry Diagnostics",
"Highest Composite Score",
highest_score
)
self.Plot(
"Carry Diagnostics",
"Lowest Composite Score",
lowest_score
)
highest_rate = max(x["rate"] for x in data_table)
lowest_rate = min(x["rate"] for x in data_table)
self.Plot(
"Carry Selection",
"Highest Rate",
highest_rate
)
self.Plot(
"Carry Selection",
"Lowest Rate",
lowest_rate
)
self.Plot(
"Carry Selection",
"Rate Spread",
highest_rate - lowest_rate
)
self.Plot(
"Signal Diagnostics",
"Highest Momentum",
max(x["momentum"] for x in data_table)
)
self.Plot(
"Signal Diagnostics",
"Lowest Momentum",
min(x["momentum"] for x in data_table)
)
self.Plot(
"Signal Diagnostics",
"Highest Rate Change",
max(x["rate_change"] for x in data_table)
)
self.Plot(
"Signal Diagnostics",
"Lowest Rate Change",
min(x["rate_change"] for x in data_table)
)
self.Plot(
"Risk Diagnostics",
"Portfolio Drawdown",
portfolio_drawdown
)
self.Plot(
"Risk Diagnostics",
"Drawdown Limit",
-self.max_drawdown_from_peak
)
gross_exposure = sum(
abs(weight)
for weight in target_weights.values()
)
active_positions = sum(
1
for weight in target_weights.values()
if abs(weight) > 0
)
self.Plot(
"Portfolio State",
"Target Gross Exposure",
gross_exposure
)
self.Plot(
"Portfolio State",
"Target Active Positions",
active_positions
)
def GetInverseVolatilityWeights(self, group, budget):
raw_weights = {}
total_inverse_vol = 0.0
for item in group:
volatility = item["volatility"]
if volatility <= 0:
volatility = 0.0001
inverse_vol = 1.0 / volatility
raw_weights[item["fx_symbol"]] = inverse_vol
total_inverse_vol += inverse_vol
final_weights = {}
if total_inverse_vol <= 0:
equal_weight = budget / len(group)
for item in group:
final_weights[item["fx_symbol"]] = equal_weight
return final_weights
for symbol, inverse_vol in raw_weights.items():
final_weights[symbol] = (
budget
* inverse_vol
/ total_inverse_vol
)
return final_weights
def GetMomentum(self, fx_symbol_string, lookback_days):
fx_symbol = self.fx_symbol_objects[fx_symbol_string]
values = self.GetHistoryCloseValues(
fx_symbol,
lookback_days
)
if len(values) < 2:
return 0.0
start_price = values[0]
end_price = values[-1]
if start_price <= 0:
return 0.0
return end_price / start_price - 1.0
def GetVolatility(self, fx_symbol_string, lookback_days):
fx_symbol = self.fx_symbol_objects[fx_symbol_string]
values = self.GetHistoryCloseValues(
fx_symbol,
lookback_days
)
if len(values) < 3:
return 0.01
returns = []
for i in range(1, len(values)):
if values[i - 1] <= 0:
continue
returns.append(
values[i] / values[i - 1] - 1.0
)
if len(returns) < 2:
return 0.01
return float(np.std(returns))
def GetRateChange(self, rate_symbol, lookback_days):
values = self.GetHistoryCloseValues(
rate_symbol,
lookback_days
)
if len(values) < 2:
return 0.0
start_value = values[0]
end_value = values[-1]
return end_value - start_value
def GetHistoryCloseValues(self, symbol, lookback_days):
history = self.History(
symbol,
lookback_days,
Resolution.Daily
)
values = []
# Case 1: pandas dataframe
if hasattr(history, "empty"):
if history.empty:
return values
if "close" in history.columns:
for value in history["close"]:
values.append(float(value))
elif "value" in history.columns:
for value in history["value"]:
values.append(float(value))
return values
# Case 2: QC enumerable
for bar in history:
if hasattr(bar, "Close"):
values.append(float(bar.Close))
elif hasattr(bar, "Value"):
values.append(float(bar.Value))
return values