| Overall Statistics |
|
Total Orders 495 Average Win 0.29% Average Loss -0.40% Compounding Annual Return -3.415% Drawdown 24.400% Expectancy -0.193 Start Equity 500000 End Equity 411600.89 Net Profit -17.680% Sharpe Ratio -1.191 Sortino Ratio -0.861 Probabilistic Sharpe Ratio 0.002% Loss Rate 53% Win Rate 47% Profit-Loss Ratio 0.72 Alpha -0.054 Beta 0.015 Annual Standard Deviation 0.044 Annual Variance 0.002 Information Ratio -0.767 Tracking Error 0.179 Treynor Ratio -3.508 Total Fees $1202.89 Estimated Strategy Capacity $370000000.00 Lowest Capacity Asset HG YW4KTTPXXAJH Portfolio Turnover 5.28% Drawdown Recovery 337 |
"""
HG Copper Futures Mean Reversion Strategy - CORRECT VERSION
Matching algo_beta's DAILY data with TIME COLUMNS structure
Data Structure:
- algo_beta: Daily rows with '16:00' column (price at 4:00 PM each day)
- lag=3: Compare today's 16:00 vs 3 TRADING DAYS ago 16:00
- Expected: 384 signals from 2020-2025
Pattern:
- At 16:00 each day: Check if price declined -5000 to -100 bps vs 3 days ago
- Entry: 16:00 close
- Exit: Next day 16:00 (1 trading day forward)
"""
from AlgorithmImports import *
import numpy as np
from datetime import timedelta
class HGDailyTimeColumnAlgorithm(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2020, 1, 1)
self.SetEndDate(2025, 11, 1)
self.SetCash(500000) # Increased from 100k to avoid margin calls
# CRITICAL: Set timezone to Central Time to match algo_beta data
self.SetTimeZone("America/Chicago") # Central Time (CT)
# Add HG futures with continuous contract
# CRITICAL: extendedMarketHours=True to get evening Globex session (17:00-23:59 CT)
self.hg = self.AddFuture(
Futures.Metals.Copper,
resolution=Resolution.Minute, # Need minute to capture 16:00
extendedMarketHours=True, # Enable 23/5 trading hours (includes evening session)
dataNormalizationMode=DataNormalizationMode.BackwardsPanamaCanal
)
# Filter for front month contract (0-90 days to expiry)
self.hg.SetFilter(0, 90)
# Pattern parameters matching algo_beta
self.entry_hour = 16
self.entry_minute = 0
self.exit_hour = 21 # Exit at 21:00 next day (matching algo_beta)
self.exit_minute = 0
# Note: With extendedMarketHours=True, we now have access to:
# - Evening Globex session: 17:00-23:59 CT
# - Can exit at 21:00 CT to match algo_beta's optimal horizon
# - Gives ~29 hours for mean reversion (vs 17 hours at 09:00)
self.lookback_days = 3 # 3 TRADING DAYS (not bars)
self.min_threshold_bps = -5000
self.max_threshold_bps = -100
self.holding_days = 1 # Exit next trading day
# POSITION SIZING - CHANGE THIS
self.contracts_per_trade = 1 # Number of contracts to trade
# For HG (Copper): 1 contract = 25,000 lbs
# Margin required: ~$3,500-$7,500 per contract
# Examples:
# 1 contract = ~$3,500 margin
# 2 contracts = ~$7,000 margin
# 5 contracts = ~$17,500 margin
# 10 contracts = ~$35,000 margin
# Store daily 16:00 prices (one per trading day)
# This mimics algo_beta's '16:00' column
self.daily_16_prices = {} # {date: price}
# Track positions
self.current_contract = None
self.entry_time = None
self.entry_price = None
self.exit_attempted = False # Track if we've tried to exit today
# Statistics
self.trades = []
self.pattern_checks = 0
self.pattern_matches = 0
# Diagnostics: Track what hours we see in the data
self.hours_seen = set()
self.first_day_hours_logged = False
self.Debug("=" * 60)
self.Debug("HG Mean Reversion - Daily Data (×1000 scaled)")
self.Debug(f"Timezone: {self.TimeZone} (matching algo_beta CT)")
self.Debug(f"Entry: {self.entry_hour:02d}:{self.entry_minute:02d} CT | Exit: {self.exit_hour:02d}:{self.exit_minute:02d} CT next day")
self.Debug(f"Lookback: {self.lookback_days} days | Threshold: [{self.min_threshold_bps}, {self.max_threshold_bps}] bps")
self.Debug(f"Expected: ~244 signals (QC data) | Position: {self.contracts_per_trade} contract(s)")
self.Debug("=" * 60)
def OnData(self, data):
"""Capture 16:00 price each trading day"""
# DIAGNOSTIC: Log hours seen in first trading day (only once)
if not self.first_day_hours_logged:
self.hours_seen.add(self.Time.hour)
# Log after seeing enough data (after 16:00 on day 2)
if self.Time.date().day >= 3 and self.Time.hour >= 16:
self.Debug("\n" + "=" * 60)
self.Debug("DIAGNOSTIC: Hours available in QC data:")
sorted_hours = sorted(list(self.hours_seen))
self.Debug(f"Hours seen: {sorted_hours}")
if 21 in self.hours_seen:
self.Debug("✅ 21:00 (9 PM) IS available in data")
else:
self.Debug("❌ 21:00 (9 PM) NOT available in data")
if 9 in self.hours_seen:
self.Debug("✅ 09:00 IS available")
else:
self.Debug("❌ 09:00 NOT available (trading starts 09:30)")
self.Debug("=" * 60 + "\n")
self.first_day_hours_logged = True
# Update current contract - select contract with most volume/liquidity
if len(data.FutureChains) > 0:
chain = list(data.FutureChains.Values)[0]
if len(chain) > 0:
# Select contract with highest open interest (most liquid)
# This automatically rolls as contracts approach expiry
contracts = sorted(chain, key=lambda x: x.OpenInterest, reverse=True)
if len(contracts) > 0:
new_contract = contracts[0].Symbol
if self.current_contract != new_contract:
# Log contract switches
if self.current_contract is None or self.pattern_checks < 5:
self.Debug(f"[{self.Time.date()}] Contract: {new_contract} (Expiry: {contracts[0].Expiry.date()})")
self.current_contract = new_contract
# Capture 16:00 price (mimics algo_beta's '16:00' column)
if (self.Time.hour == self.entry_hour and
self.Time.minute == self.entry_minute and
self.current_contract is not None):
if data.Bars.ContainsKey(self.current_contract):
raw_price = data.Bars[self.current_contract].Close
# CRITICAL: Scale price by 1000 to match algo_beta data format
# algo_beta stores HG with multiplier=1000 (e.g., 2.8250 → 2825)
scaled_price = raw_price * 1000
date = self.Time.date()
# Store scaled price (matches algo_beta's '16:00' column)
self.daily_16_prices[date] = scaled_price
if len(self.daily_16_prices) <= 3:
self.Debug(f"[{date}] 16:00: ${raw_price:.4f} → {scaled_price:.0f} (scaled)")
# Check pattern immediately after capturing today's price
self.CheckPatternAndTrade()
# Check exit conditions
self.CheckExitConditions()
def CheckPatternAndTrade(self):
"""
Check pattern at 16:00 daily (after price captured)
This mimics: df['HG', '16:00'] vs df['HG', '16:00'].shift(3)
"""
self.pattern_checks += 1
# Already in position
if self.Portfolio.Invested:
return
# Need at least 4 days of 16:00 prices (today + 3 days back)
if len(self.daily_16_prices) < self.lookback_days + 1:
if self.pattern_checks <= 10:
self.Debug(f"Check #{self.pattern_checks}: Need {self.lookback_days + 1} days, have {len(self.daily_16_prices)}")
return
# Get sorted dates
sorted_dates = sorted(self.daily_16_prices.keys())
# Get today's date
today = self.Time.date()
# Find today in the list
if today not in self.daily_16_prices:
return
today_idx = sorted_dates.index(today)
# Need at least 3 days before today
if today_idx < self.lookback_days:
return
# Get prices: today vs 3 trading days ago
current_price = self.daily_16_prices[today]
lookback_date = sorted_dates[today_idx - self.lookback_days]
lookback_price = self.daily_16_prices[lookback_date]
# Calculate log return (matches algo_beta: log(current/old) * 10000)
log_return_bps = np.log(current_price / lookback_price) * 10000
# Log only first 10 checks and every 500th check to avoid rate limiting
if self.pattern_checks <= 10 or self.pattern_checks % 500 == 0:
in_range = "✓ MATCH" if self.min_threshold_bps <= log_return_bps <= self.max_threshold_bps else "✗ no match"
self.Debug(f"[{today}] Check #{self.pattern_checks}: "
f"{lookback_date} {lookback_price:.0f} -> "
f"{today} {current_price:.0f} | "
f"Return: {log_return_bps:.2f} bps | {in_range}")
# Check if decline within threshold (pattern match)
if self.min_threshold_bps <= log_return_bps <= self.max_threshold_bps:
self.pattern_matches += 1
# Only log first 10 and every 50th match
if self.pattern_matches <= 10 or self.pattern_matches % 50 == 0:
self.Debug(f"✓ MATCH #{self.pattern_matches}: {today} | {log_return_bps:.2f} bps")
self.EnterPosition(log_return_bps, lookback_date, lookback_price, current_price)
def EnterPosition(self, log_return_bps, lookback_date, lookback_price, current_price):
"""Enter position at 16:00"""
if self.current_contract is None:
return
# Use configurable position size
quantity = self.contracts_per_trade
ticket = self.MarketOrder(self.current_contract, quantity)
self.entry_time = self.Time
self.entry_price = current_price # Stored as scaled price
# Only log first 10 entries and every 50th
if len(self.trades) + 1 <= 10 or (len(self.trades) + 1) % 50 == 0:
self.Debug(f"ENTRY #{len(self.trades) + 1}: {self.Time.date()} | "
f"{lookback_price:.0f} → {current_price:.0f} | "
f"Trigger: {log_return_bps:.2f} bps")
def CheckExitConditions(self):
"""Exit next trading day at 16:00 - with failsafe for stuck positions"""
if not self.Portfolio.Invested or self.entry_time is None:
return
# Calculate days held
days_held = (self.Time.date() - self.entry_time.date()).days
# EMERGENCY FAILSAFE: Exit if held more than 5 days (something went wrong)
if days_held > 5:
self.Debug(f"⚠️ EMERGENCY EXIT: Held {days_held} days! Liquidating all.")
self.Liquidate()
self.entry_time = None
self.entry_price = None
return
# Normal exit: First bar on or after 09:30 after holding period
# Check if we're past exit time and haven't exited yet today
is_exit_time = (days_held >= self.holding_days and
self.Time.hour >= self.exit_hour and
not self.exit_attempted)
# If it's a new day, reset exit_attempted flag
if self.entry_time and self.Time.date() != self.entry_time.date():
if self.Time.hour < self.exit_hour:
self.exit_attempted = False
# Debug: Log first few exit attempts
if is_exit_time and len(self.trades) < 3:
self.Debug(f"[{self.Time}] ✅ EXIT TIME REACHED: Closing position #{len(self.trades)+1}")
if not is_exit_time:
return
# Mark that we've attempted exit today
self.exit_attempted = True
if days_held >= self.holding_days:
# Get current contract value for exit
if not self.Securities.ContainsKey(self.current_contract):
self.Debug(f"⚠️ Contract missing! Liquidating all.")
self.Liquidate()
self.entry_time = None
self.entry_price = None
return
# Scale exit price to match scaled entry price
raw_exit_price = self.Securities[self.current_contract].Price
exit_price = raw_exit_price * 1000
if self.entry_price > 0:
trade_return_bps = np.log(exit_price / self.entry_price) * 10000
self.trades.append({
'entry_time': self.entry_time,
'exit_time': self.Time,
'entry_price': self.entry_price,
'exit_price': exit_price,
'return_bps': trade_return_bps,
'days_held': days_held
})
# Log first 10 exits, then every 50th, then last 10
should_log = (len(self.trades) <= 10 or
len(self.trades) % 50 == 0)
if should_log:
hours_held = (self.Time - self.entry_time).total_seconds() / 3600
self.Debug(f"EXIT #{len(self.trades)}: {self.entry_time.strftime('%Y-%m-%d %H:%M')} → "
f"{self.Time.strftime('%Y-%m-%d %H:%M')} ({hours_held:.1f}h) | "
f"{self.entry_price:.0f} → {exit_price:.0f} | "
f"Return: {trade_return_bps:.2f} bps")
# Close position
self.Liquidate(self.current_contract)
self.entry_time = None
self.entry_price = None
self.exit_attempted = False # Reset for next trade
def OnEndOfAlgorithm(self):
"""Summary comparing with algo_beta"""
self.Debug("\n" + "=" * 60)
self.Debug("BACKTEST COMPLETE")
self.Debug("=" * 60)
self.Debug(f"Trading days: {len(self.daily_16_prices)}")
self.Debug(f"Pattern checks: {self.pattern_checks}")
self.Debug(f"Pattern matches: {self.pattern_matches} (expected: ~384)")
self.Debug(f"Trades executed: {len(self.trades)}")
if len(self.trades) == 0:
self.Debug("⚠️ NO TRADES EXECUTED")
return
# Calculate statistics
returns = [t['return_bps'] for t in self.trades]
wins = len([r for r in returns if r > 0])
hit_rate = (wins / len(returns) * 100) if len(returns) > 0 else 0
avg_return = np.mean(returns)
self.Debug(f"\nPerformance:")
self.Debug(f" Hit Rate: {hit_rate:.1f}% (expected: ~58%)")
self.Debug(f" Avg Return: {avg_return:.2f} bps (expected: ~10 bps)")
self.Debug(f" Winners: {wins} | Losers: {len(returns) - wins}")
# Check for stuck positions
if self.Portfolio.Invested:
self.Debug(f"\n⚠️ WARNING: Still holding position!")
self.Debug(f" Entry time: {self.entry_time}")
self.Debug(f" Days held: {(self.Time.date() - self.entry_time.date()).days}")
self.Debug("=" * 60)
# Calculate statistics
returns = [t['return_bps'] for t in self.trades]
wins = len([r for r in returns if r > 0])
losses = len([r for r in returns if r <= 0])
hit_rate = (wins / len(returns) * 100) if len(returns) > 0 else 0
avg_return = np.mean(returns)
std_return = np.std(returns)
self.Debug("\n" + "=" * 60)
self.Debug("COMPARISON WITH ALGO_BETA")
self.Debug("=" * 60)
self.Debug("Expected (algo_beta):")
self.Debug(" Observations: 384")
self.Debug(" Hit Rate: 58% (55% per output)")
self.Debug(" MU (avg return): 10 bps at 21:00 (best horizon)")
self.Debug(" TDR: 322")
self.Debug(" Cum PL: 251 bps")
self.Debug(f"\nActual (QuantConnect):")
self.Debug(f" Pattern Matches: {self.pattern_matches}")
self.Debug(f" Trades Executed: {len(self.trades)}")
self.Debug(f" Hit Rate: {hit_rate:.2f}%")
self.Debug(f" Avg Return: {avg_return:.2f} bps")
self.Debug(f" Std Dev: {std_return:.2f} bps")
# Status checks
match_pct = abs(self.pattern_matches - 384) / 384 * 100
hr_diff = abs(hit_rate - 55)
self.Debug("\n" + "=" * 60)
self.Debug("VALIDATION STATUS")
self.Debug("=" * 60)
if match_pct < 10:
self.Debug(f"✅ Pattern count: {self.pattern_matches} vs 384 (±{match_pct:.1f}%)")
else:
self.Debug(f"❌ Pattern count: {self.pattern_matches} vs 384 (±{match_pct:.1f}%)")
if hr_diff < 5:
self.Debug(f"✅ Hit rate: {hit_rate:.1f}% vs 55% (±{hr_diff:.1f}%)")
else:
self.Debug(f"❌ Hit rate: {hit_rate:.1f}% vs 55% (±{hr_diff:.1f}%)")
if self.pattern_matches < 300:
self.Debug("\n*** WARNING: Pattern count too low! ***")
self.Debug("Check:")
self.Debug("1. Are 16:00 prices being captured daily?")
self.Debug("2. Is lookback calculation correct?")
self.Debug("3. Are there data gaps?")
# Last 10 trades
self.Debug("\n" + "=" * 60)
self.Debug("LAST 10 TRADES (compare with algo_beta output)")
self.Debug("=" * 60)
for i, trade in enumerate(self.trades[-10:], 1):
self.Debug(f"{i}. {trade['entry_time'].strftime('%Y-%m-%d')} | "
f"Return: {trade['return_bps']:>7.2f} bps | "
f"Entry: {trade['entry_price']:.4f} | "
f"Exit: {trade['exit_price']:.4f}")
# Compare with algo_beta's last 10
self.Debug("\nalgo_beta's Last 10 Observations:")
self.Debug("2024-11-22: 71 bps")
self.Debug("2024-12-13: -2 bps")
self.Debug("2024-12-16: -108 bps")
self.Debug("2024-12-17: -137 bps")
self.Debug("2024-12-18: -14 bps")
self.Debug("2024-12-19: 37 bps")
self.Debug("2024-12-20: -9 bps")
self.Debug("2024-12-31: 0 bps")
self.Debug("2025-01-01: 28 bps")
self.Debug("2025-01-02: 96 bps")
self.Debug("\n" + "=" * 60)
self.Debug("COMPARISON WITH ALGO_BETA")
self.Debug("=" * 60)
self.Debug("Expected (algo_beta):")
self.Debug(" Observations: 384")
self.Debug(" Hit Rate: 58% (55% per output)")
self.Debug(" MU (avg return): 10 bps at 21:00 (best horizon)")
self.Debug(" TDR: 322")
self.Debug(" Cum PL: 251 bps")
self.Debug(f"\nActual (QuantConnect):")
self.Debug(f" Pattern Matches: {self.pattern_matches}")
self.Debug(f" Trades Executed: {len(self.trades)}")
self.Debug(f" Hit Rate: {hit_rate:.2f}%")
self.Debug(f" Avg Return: {avg_return:.2f} bps")
self.Debug(f" Std Dev: {std_return:.2f} bps")
# Status checks
match_pct = abs(self.pattern_matches - 384) / 384 * 100
hr_diff = abs(hit_rate - 55)
self.Debug("\n" + "=" * 60)
self.Debug("VALIDATION STATUS")
self.Debug("=" * 60)
if match_pct < 10:
self.Debug(f"✅ Pattern count: {self.pattern_matches} vs 384 (±{match_pct:.1f}%)")
else:
self.Debug(f"❌ Pattern count: {self.pattern_matches} vs 384 (±{match_pct:.1f}%)")
if hr_diff < 5:
self.Debug(f"✅ Hit rate: {hit_rate:.1f}% vs 55% (±{hr_diff:.1f}%)")
else:
self.Debug(f"❌ Hit rate: {hit_rate:.1f}% vs 55% (±{hr_diff:.1f}%)")
if self.pattern_matches < 300:
self.Debug("\n*** WARNING: Pattern count too low! ***")
self.Debug("Check:")
self.Debug("1. Are 16:00 prices being captured daily?")
self.Debug("2. Is lookback calculation correct?")
self.Debug("3. Are there data gaps?")
# Last 10 trades
self.Debug("\n" + "=" * 60)
self.Debug("LAST 10 TRADES (compare with algo_beta output)")
self.Debug("=" * 60)
for i, trade in enumerate(self.trades[-10:], 1):
self.Debug(f"{i}. {trade['entry_time'].strftime('%Y-%m-%d')} | "
f"Return: {trade['return_bps']:>7.2f} bps | "
f"Entry: {trade['entry_price']:.4f} | "
f"Exit: {trade['exit_price']:.4f}")
# Compare with algo_beta's last 10
self.Debug("\nalgo_beta's Last 10 Observations:")
self.Debug("2024-11-22: 71 bps")
self.Debug("2024-12-13: -2 bps")
self.Debug("2024-12-16: -108 bps")
self.Debug("2024-12-17: -137 bps")
self.Debug("2024-12-18: -14 bps")
self.Debug("2024-12-19: 37 bps")
self.Debug("2024-12-20: -9 bps")
self.Debug("2024-12-31: 0 bps")
self.Debug("2025-01-01: 28 bps")
self.Debug("2025-01-02: 96 bps")