Created with Highcharts 12.1.2EquityJan 2019Jan…Jul 2019Jan 2020Jul 2020Jan 2021Jul 2021Jan 2022Jul 2022Jan 2023Jul 2023Jan 2024Jul 2024Jan 202505M10M-50-25000.10.2-2024010M20M30M050100
Overall Statistics
Total Orders
5798
Average Win
0.43%
Average Loss
-0.67%
Compounding Annual Return
40.508%
Drawdown
43.300%
Expectancy
0.301
Start Equity
1000000
End Equity
7663994.39
Net Profit
666.399%
Sharpe Ratio
1.025
Sortino Ratio
1.134
Probabilistic Sharpe Ratio
47.001%
Loss Rate
21%
Win Rate
79%
Profit-Loss Ratio
0.64
Alpha
0.122
Beta
1.535
Annual Standard Deviation
0.282
Annual Variance
0.08
Information Ratio
1.179
Tracking Error
0.153
Treynor Ratio
0.188
Total Fees
$13482.43
Estimated Strategy Capacity
$0
Lowest Capacity Asset
NB R735QTJ8XC9X
Portfolio Turnover
2.76%
# region imports
from AlgorithmImports import *
# endregion
# QuantConnect Research Notebook Environment
from QuantConnect import *
from QuantConnect.Data.Market import *
import pandas as pd
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from sklearn.preprocessing import RobustScaler
from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings('ignore')
import math
import gc
import os

# Add memory management
def free_memory():
    gc.collect()
    torch.cuda.empty_cache() if torch.cuda.is_available() else None
    
# Set seeds for reproducibility
torch.manual_seed(42)
np.random.seed(42)


class DirectionalAccuracyLoss(nn.Module):
    def __init__(self, alpha=0.5):
        super().__init__()
        self.alpha = alpha
        self.mse = nn.MSELoss()
    
    def forward(self, pred, target):
        mse_loss = self.mse(pred, target)
        direction_loss = torch.mean((torch.sign(pred) != torch.sign(target)).float())
        return self.alpha * mse_loss + (1 - self.alpha) * direction_loss


class QuaternionLinear(nn.Module):
    def __init__(self, in_features, out_features, bias=True):
        super().__init__()
        assert in_features % 4 == 0, f"in_features must be divisible by 4, got {in_features}"
        real_in_features = in_features // 4
        real_out_features = out_features // 4 if out_features % 4 == 0 else out_features
        
        self.r_weight = nn.Parameter(torch.Tensor(real_out_features, real_in_features))
        self.i_weight = nn.Parameter(torch.Tensor(real_out_features, real_in_features))
        self.j_weight = nn.Parameter(torch.Tensor(real_out_features, real_in_features))
        self.k_weight = nn.Parameter(torch.Tensor(real_out_features, real_in_features))
        
        if bias:
            self.r_bias = nn.Parameter(torch.Tensor(real_out_features))
            self.i_bias = nn.Parameter(torch.Tensor(real_out_features))
            self.j_bias = nn.Parameter(torch.Tensor(real_out_features))
            self.k_bias = nn.Parameter(torch.Tensor(real_out_features))
        else:
            self.register_parameter('r_bias', None)
            self.register_parameter('i_bias', None)
            self.register_parameter('j_bias', None)
            self.register_parameter('k_bias', None)
        
        self.bias = bias
        self.real_out_features = real_out_features
        self.out_features = real_out_features * 4 if out_features % 4 == 0 else out_features
        self.reset_parameters()
    
    def reset_parameters(self):
        nn.init.kaiming_uniform_(self.r_weight, a=math.sqrt(5))
        nn.init.kaiming_uniform_(self.i_weight, a=math.sqrt(5))
        nn.init.kaiming_uniform_(self.j_weight, a=math.sqrt(5))
        nn.init.kaiming_uniform_(self.k_weight, a=math.sqrt(5))
        
        if self.bias:
            fan_in = self.r_weight.size(1)
            bound = 1 / math.sqrt(fan_in)
            nn.init.uniform_(self.r_bias, -bound, bound)
            nn.init.uniform_(self.i_bias, -bound, bound)
            nn.init.uniform_(self.j_bias, -bound, bound)
            nn.init.uniform_(self.k_bias, -bound, bound)
    
    def forward(self, input):
        batch_dims = input.shape[:-1]
        input_reshaped = input.reshape(-1, input.shape[-1])
        r, i, j, k = torch.chunk(input_reshaped, 4, dim=-1)
        
        out_r = F.linear(r, self.r_weight) - F.linear(i, self.i_weight) - \
                F.linear(j, self.j_weight) - F.linear(k, self.k_weight)
        out_i = F.linear(r, self.i_weight) + F.linear(i, self.r_weight) + \
                F.linear(j, self.k_weight) - F.linear(k, self.j_weight)
        out_j = F.linear(r, self.j_weight) - F.linear(i, self.k_weight) + \
                F.linear(j, self.r_weight) + F.linear(k, self.i_weight)
        out_k = F.linear(r, self.k_weight) + F.linear(i, self.j_weight) - \
                F.linear(j, self.i_weight) + F.linear(k, self.r_weight)
        
        if self.bias:
            out_r += self.r_bias
            out_i += self.i_bias
            out_j += self.j_bias
            out_k += self.k_bias
        
        out = torch.cat([out_r, out_i, out_j, out_k], dim=-1)
        if self.out_features != self.real_out_features * 4:
            out = out[:, :self.out_features]
        return out.reshape(*batch_dims, -1)

# KoopmanOperator (copied from your code)
class KoopmanOperator(nn.Module):
    def __init__(self, state_dim, koopman_dim):
        super().__init__()
        self.state_dim = state_dim
        self.padded_state_dim = ((state_dim + 3) // 4) * 4
        self.koopman_dim = koopman_dim
        self.padded_koopman_dim = ((koopman_dim + 3) // 4) * 4
        
        self.encoder = nn.Sequential(
            QuaternionLinear(self.padded_state_dim, self.padded_koopman_dim * 2),
            nn.LeakyReLU(0.2),
            QuaternionLinear(self.padded_koopman_dim * 2, self.padded_koopman_dim)
        )
        self.koopman_matrix = nn.Parameter(torch.Tensor(self.padded_koopman_dim, self.padded_koopman_dim))
        self.decoder = nn.Sequential(
            QuaternionLinear(self.padded_koopman_dim, self.padded_koopman_dim * 2),
            nn.LeakyReLU(0.2),
            QuaternionLinear(self.padded_koopman_dim * 2, self.padded_state_dim)
        )
        nn.init.eye_(self.koopman_matrix)
        self.koopman_matrix.data += 0.01 * torch.randn_like(self.koopman_matrix)
    
    def forward(self, x, steps=1):
        orig_shape = x.shape
        batch_dims = orig_shape[:-1]
        if self.state_dim != self.padded_state_dim:
            padding = torch.zeros(*batch_dims, self.padded_state_dim - self.state_dim, 
                                 device=x.device, dtype=x.dtype)
            x_padded = torch.cat([x, padding], dim=-1)
        else:
            x_padded = x
        if len(batch_dims) > 1:
            x_padded = x_padded.reshape(-1, self.padded_state_dim)
        g = self.encoder(x_padded)
        if steps == 1:
            g_next = torch.matmul(g, self.koopman_matrix)
        else:
            koopman_power = torch.matrix_power(self.koopman_matrix, steps)
            g_next = torch.matmul(g, koopman_power)
        x_pred = self.decoder(g_next)
        if self.state_dim != self.padded_state_dim:
            x_pred = x_pred[..., :self.state_dim]
        if len(batch_dims) > 1:
            x_pred = x_pred.reshape(*batch_dims, -1)
            g = g.reshape(*batch_dims, -1)
            g_next = g_next.reshape(*batch_dims, -1)
        return x_pred, g, g_next

# Modify the KQT class to implement cross-sequence attention

class CrossStockAttention(nn.Module):
    def __init__(self, hidden_size, dropout=0.1):
        super().__init__()
        self.hidden_size = hidden_size
        self.query = nn.Linear(hidden_size, hidden_size)
        self.key = nn.Linear(hidden_size, hidden_size)
        self.value = nn.Linear(hidden_size, hidden_size)
        self.output = nn.Linear(hidden_size, hidden_size)
        self.dropout = nn.Dropout(dropout)
        self.norm = nn.LayerNorm(hidden_size)
        self.scaling = hidden_size ** -0.5
        
    def forward(self, x, stock_ids):
        batch_size, seq_len, _ = x.shape
        last_states = x[:, -1, :]  # [batch_size, hidden_size]
        
        q = self.query(last_states)
        k = self.key(last_states)
        v = self.value(last_states)
        

        mask = stock_ids.unsqueeze(1) == stock_ids.unsqueeze(0)
        mask = mask.float().to(x.device)
        
        # Compute attention scores with masking
        scores = torch.matmul(q, k.transpose(0, 1)) * self.scaling
        scores = scores * mask + -1e9 * (1 - mask)  # Apply mask
        
        attn_weights = F.softmax(scores, dim=-1)
        attn_weights = self.dropout(attn_weights)
        
        context = torch.matmul(attn_weights, v)
        output = self.output(context)
        output = self.norm(output + last_states)
        
        return output

# Update your EnhancedKQT class definition by adding this line in the __init__ method
class EnhancedKQT(nn.Module):
    def __init__(self, input_size, hidden_size, num_layers, dropout=0.1, nhead=4):
        super().__init__()
        self.input_size = input_size
        # Use smaller hidden dimensions
        self.hidden_size = hidden_size * 4  # Changed from 6
        # CHANGE THIS LINE to match your trained model
        self.koopman_dim = max(1024, self.hidden_size // 4)  # Match trained model
        
        # Simpler embedding
        self.embedding = nn.Sequential(
            nn.Linear(input_size, self.hidden_size),
            nn.GELU(),
            nn.Dropout(dropout)
        )
        # ADD THIS LINE - Create positional encoding parameter
        self.pos_encoding = nn.Parameter(torch.randn(1, 32, self.hidden_size) * 0.02)
        
        # Smaller transformer
        self.transformer_layers = nn.ModuleList([
            TransformerEncoderLayer(self.hidden_size, nhead, self.hidden_size, dropout)
            for _ in range(1)  # Only use 1 layer
        ])
        
        self.cross_attn = CrossStockAttention(self.hidden_size, dropout)
        self.koopman = KoopmanOperator(self.hidden_size, self.koopman_dim)
        self.output_proj = nn.Linear(self.hidden_size, 1)
    
    def forward(self, x, stock_ids=None):
        batch_size, seq_len, _ = x.shape
        
        # Simpler embedding
        x = self.embedding(x)
        
        # Add positional encoding
        pos_enc = self.pos_encoding
        if seq_len > pos_enc.size(1):
            repeat_factor = (seq_len // pos_enc.size(1)) + 1
            pos_enc = pos_enc.repeat(1, repeat_factor, 1)[:, :seq_len, :]
        else:
            pos_enc = pos_enc[:, :seq_len, :]
        x = x + pos_enc
        
        # Apply transformer layers
        for layer in self.transformer_layers:
            x = layer(x)
        
        # Get final sequence representation
        last_hidden = x[:, -1, :]
        
        # Apply cross-stock attention if stock_ids are provided
        if stock_ids is not None:
            cross_attended = self.cross_attn(x, stock_ids)
            # Weighted combination instead of simple addition
            alpha = 0.7  # Weight for original representation
            last_hidden = alpha * last_hidden + (1-alpha) * cross_attended
        
        # Apply Koopman operator (standard, not multi-scale)
        pred_state, _, _ = self.koopman(last_hidden)
        
        # Project to output
        return self.output_proj(pred_state)
class QuaternionAttention(nn.Module):
    def __init__(self, embed_dim, num_heads, dropout=0.1):
        super().__init__()
        self.embed_dim = embed_dim
        self.padded_embed_dim = ((embed_dim + 3) // 4) * 4
        self.num_heads = num_heads
        self.head_dim = (self.padded_embed_dim // (num_heads * 4)) * 4 or 4
        self.qk_dim = self.head_dim * num_heads
        self.v_dim = self.padded_embed_dim
        
        self.q_proj = QuaternionLinear(self.padded_embed_dim, self.qk_dim)
        self.k_proj = QuaternionLinear(self.padded_embed_dim, self.qk_dim)
        self.v_proj = QuaternionLinear(self.padded_embed_dim, self.v_dim)
        self.out_proj = nn.Linear(self.v_dim, embed_dim)
        self.dropout = nn.Dropout(dropout)
        self.scaling = self.head_dim ** -0.5
    
    def forward(self, query, key=None, value=None, attn_mask=None):
        if key is None:
            key = query
        if value is None:
            value = query
        batch_size, seq_len, _ = query.shape
        if query.size(-1) != self.padded_embed_dim:
            padding = torch.zeros(batch_size, seq_len, 
                                  self.padded_embed_dim - query.size(-1),
                                  device=query.device, dtype=query.dtype)
            query_padded = torch.cat([query, padding], dim=-1)
            key_padded = torch.cat([key, padding], dim=-1)
            value_padded = torch.cat([value, padding], dim=-1)
        else:
            query_padded, key_padded, value_padded = query, key, value
        
        q = self.q_proj(query_padded).view(batch_size, seq_len, self.num_heads, -1).transpose(1, 2)
        k = self.k_proj(key_padded).view(batch_size, seq_len, self.num_heads, -1).transpose(1, 2)
        v = self.v_proj(value_padded).view(batch_size, seq_len, self.num_heads, -1).transpose(1, 2)
        
        scores = torch.matmul(q, k.transpose(-2, -1)) * self.scaling
        if attn_mask is not None:
            scores = scores.masked_fill(attn_mask, float('-inf'))
        attn_weights = F.softmax(scores, dim=-1)
        attn_weights = self.dropout(attn_weights)
        output = torch.matmul(attn_weights, v).transpose(1, 2).contiguous().view(batch_size, seq_len, -1)
        return self.out_proj(output)

class TransformerEncoderLayer(nn.Module):
    def __init__(self, d_model, nhead, dim_feedforward, dropout=0.1):
        super().__init__()
        self.self_attn = QuaternionAttention(d_model, nhead, dropout)
        self.linear1 = nn.Linear(d_model, dim_feedforward)
        self.linear2 = nn.Linear(dim_feedforward, d_model)
        self.norm1 = nn.LayerNorm(d_model)
        self.norm2 = nn.LayerNorm(d_model)
        self.dropout = nn.Dropout(dropout)
        self.dropout1 = nn.Dropout(dropout)
        self.dropout2 = nn.Dropout(dropout)
        self.activation = nn.GELU()
    
    def forward(self, src, src_mask=None):
        src2 = self.self_attn(src)
        src = src + self.dropout1(src2)
        src = self.norm1(src)
        src2 = self.linear2(self.dropout(self.activation(self.linear1(src))))
        src = src + self.dropout2(src2)
        return self.norm2(src)

class SectorAwareCrossAttention(nn.Module):
    def __init__(self, hidden_size, dropout=0.1):
        super().__init__()
        self.hidden_size = hidden_size
        self.query = nn.Linear(hidden_size, hidden_size)
        self.key = nn.Linear(hidden_size, hidden_size)
        self.value = nn.Linear(hidden_size, hidden_size)
        self.output = nn.Linear(hidden_size, hidden_size)
        self.dropout = nn.Dropout(dropout)
        self.norm = nn.LayerNorm(hidden_size)
        self.scaling = hidden_size ** -0.5
        
    def forward(self, x, stock_ids):
        batch_size, seq_len, _ = x.shape
        last_states = x[:, -1, :]  # [batch_size, hidden_size]
        
        q = self.query(last_states)
        k = self.key(last_states)
        v = self.value(last_states)
        

        scores = torch.matmul(q, k.transpose(0, 1)) * self.scaling
        
        attn_weights = F.softmax(scores, dim=-1)
        attn_weights = self.dropout(attn_weights)
        
        context = torch.matmul(attn_weights, v)
        output = self.output(context)
        output = self.norm(output + last_states)
        
        return output

class MultiScaleKoopmanOperator(nn.Module):
    def __init__(self, state_dim, koopman_dim):
        super().__init__()
        self.state_dim = state_dim
        self.padded_state_dim = ((state_dim + 3) // 4) * 4
        self.koopman_dim = koopman_dim
        self.padded_koopman_dim = ((koopman_dim + 3) // 4) * 4
        
        self.encoder = nn.Sequential(
            QuaternionLinear(self.padded_state_dim, self.padded_koopman_dim * 2),
            nn.LeakyReLU(0.2),
            nn.LayerNorm(self.padded_koopman_dim * 2),
            QuaternionLinear(self.padded_koopman_dim * 2, self.padded_koopman_dim)
        )
        
        self.koopman_matrix_fast = nn.Parameter(torch.Tensor(self.padded_koopman_dim, self.padded_koopman_dim))
        self.koopman_matrix_medium = nn.Parameter(torch.Tensor(self.padded_koopman_dim, self.padded_koopman_dim))
        self.koopman_matrix_slow = nn.Parameter(torch.Tensor(self.padded_koopman_dim, self.padded_koopman_dim))
        
        self.decoder = nn.Sequential(
            QuaternionLinear(self.padded_koopman_dim, self.padded_koopman_dim * 2),
            nn.LeakyReLU(0.2),
            nn.LayerNorm(self.padded_koopman_dim * 2),
            QuaternionLinear(self.padded_koopman_dim * 2, self.padded_state_dim)
        )
        
        nn.init.eye_(self.koopman_matrix_fast)
        nn.init.eye_(self.koopman_matrix_medium)
        nn.init.eye_(self.koopman_matrix_slow)
        
        self.koopman_matrix_fast.data += 0.015 * torch.randn_like(self.koopman_matrix_fast)
        self.koopman_matrix_medium.data += 0.01 * torch.randn_like(self.koopman_matrix_medium)
        self.koopman_matrix_slow.data += 0.005 * torch.randn_like(self.koopman_matrix_slow)
        
        self.timescale_blend = nn.Parameter(torch.ones(3) / 3)
    
    def forward(self, x, steps=1):
        orig_shape = x.shape
        batch_dims = orig_shape[:-1]
        
        # Pad input if needed
        if self.state_dim != self.padded_state_dim:
            padding = torch.zeros(*batch_dims, self.padded_state_dim - self.state_dim, 
                                 device=x.device, dtype=x.dtype)
            x_padded = torch.cat([x, padding], dim=-1)
        else:
            x_padded = x
            
        if len(batch_dims) > 1:
            x_padded = x_padded.reshape(-1, self.padded_state_dim)
            
        g = self.encoder(x_padded)
        
        if steps == 1:
            g_next_fast = torch.matmul(g, self.koopman_matrix_fast)
            
            g_next_medium = torch.matmul(g, self.koopman_matrix_medium)
            
            g_next_slow = torch.matmul(g, self.koopman_matrix_slow)
            
        else:
            koopman_power_fast = torch.matrix_power(self.koopman_matrix_fast, steps)
            koopman_power_medium = torch.matrix_power(self.koopman_matrix_medium, max(1, steps // 2))
            koopman_power_slow = torch.matrix_power(self.koopman_matrix_slow, max(1, steps // 4))
            
            g_next_fast = torch.matmul(g, koopman_power_fast)
            g_next_medium = torch.matmul(g, koopman_power_medium)
            g_next_slow = torch.matmul(g, koopman_power_slow)
        
        blend_weights = F.softmax(self.timescale_blend, dim=0)
        g_next = blend_weights[0] * g_next_fast + blend_weights[1] * g_next_medium + blend_weights[2] * g_next_slow
        
        x_pred = self.decoder(g_next)
        
        if self.state_dim != self.padded_state_dim:
            x_pred = x_pred[..., :self.state_dim]
            
        if len(batch_dims) > 1:
            x_pred = x_pred.reshape(*batch_dims, -1)
            g = g.reshape(*batch_dims, -1)
            g_next = g_next.reshape(*batch_dims, -1)
            
        return x_pred, g, g_next
# region imports
from AlgorithmImports import *
# endregion
from QuantConnect import *
from QuantConnect.Algorithm import *
from QuantConnect.Data import *
from QuantConnect.Indicators import *
from datetime import timedelta
import numpy as np
import pandas as pd
import torch
import os
import torch.nn as nn
from sklearn.preprocessing import RobustScaler

from classes import DirectionalAccuracyLoss, QuaternionLinear, KoopmanOperator, EnhancedKQT, TransformerEncoderLayer, SectorAwareCrossAttention, CrossStockAttention, QuaternionAttention, MultiScaleKoopmanOperator

class KQTStrategy:
    def __init__(self):
        self.model = None
        self.lookback = 30
        self.scalers = {}
        self.feature_cols = []
        self.stock_to_id = {}
        self.sector_mappings = {
            "AAPL": "Technology", "MSFT": "Technology", "NVDA": "Technology", 
            "GOOGL": "Technology", "GOOG": "Technology", "META": "Technology",
            "CSCO": "Technology", "ADBE": "Technology", "INTC": "Technology",
            "AMD": "Technology", "ORCL": "Technology", "IBM": "Technology",
            "QCOM": "Technology", "AMAT": "Technology", "CRM": "Technology",
            
            "AMZN": "Consumer", "TSLA": "Consumer", "HD": "Consumer", 
            "MCD": "Consumer", "SBUX": "Consumer", "NKE": "Consumer",
            "WMT": "Consumer", "COST": "Consumer", "TJX": "Consumer",
            "LOW": "Consumer", "DIS": "Consumer", "NFLX": "Consumer",
            
            # Financial
            "JPM": "Financial", "BRK-B": "Financial", "V": "Financial", 
            "MA": "Financial", "BAC": "Financial", "GS": "Financial",
            "WFC": "Financial", "AXP": "Financial", "MS": "Financial",
            "BLK": "Financial", "C": "Financial",
            
            # Healthcare
            "UNH": "Healthcare", "JNJ": "Healthcare", "LLY": "Healthcare", 
            "PFE": "Healthcare", "MRK": "Healthcare", "ABBV": "Healthcare",
            "ABT": "Healthcare", "TMO": "Healthcare", "MDT": "Healthcare",
            "BMY": "Healthcare", "ISRG": "Healthcare", "REGN": "Healthcare",
            
            # Energy & Industrial
            "XOM": "Energy", "CVX": "Energy", "COP": "Energy",
            "AVGO": "Industrial", "HON": "Industrial", "UPS": "Industrial", 
            "CAT": "Industrial", "DE": "Industrial", "BA": "Industrial",
            "UNP": "Industrial", "RTX": "Industrial", "GE": "Industrial",
            
            # Other sectors
            "PG": "Consumer Staples", "KO": "Consumer Staples", "PEP": "Consumer Staples",
        }
        self.adaptive_threshold = 0.2
        self.pred_std = 1.0
        self.current_regime = "neutral"
        self.portfolio_returns = []
        self.defensive_mode = False
        self.previous_day_hit_stops = []
        
    def initialize_model(self, input_size, hidden_size=3, num_layers=2, dropout=0.25, n_heads=6):
        self.model = EnhancedKQT(input_size, hidden_size, num_layers, dropout, n_heads)
        
        def init_weights(m):
            if isinstance(m, nn.Linear):
                nn.init.xavier_uniform_(m.weight)
                if m.bias is not None:
                    nn.init.zeros_(m.bias)
        
        self.model.apply(init_weights)
        self.model.eval()  # Set to evaluation mode for inference
        
        return self.model
    
        
    def load_model_weights(self, weights_path):
        if self.model is None:
            raise ValueError("Model must be initialized before loading weights")
        
        try:
            self.model.load_state_dict(torch.load(weights_path))
            self.model.eval()
            return True
        except Exception as e:
            print(f"Error loading model weights: {e}")
            return False
    
    def save_model_weights(self, weights_path):
        if self.model is None:
            raise ValueError("Model not initialized, cannot save weights")
        
        try:
            torch.save(self.model.state_dict(), weights_path)
            return True
        except Exception as e:
            print(f"Error saving model weights: {e}")
            return False
    
    def create_sliding_sequences(self, df, feature_cols, lookback, stride=1):
        X = []
        for i in range(0, len(df) - lookback + 1, stride):
            X.append(df.iloc[i:i+lookback][feature_cols].values.astype(np.float32))
        return np.array(X)
    
    def clip_outliers(self, df, cols, lower=0.01, upper=0.99):
        df_copy = df.copy()
        for col in cols:
            if col in df_copy.columns:
                q_low = df_copy[col].quantile(lower)
                q_high = df_copy[col].quantile(upper)
                df_copy.loc[df_copy[col] < q_low, col] = q_low
                df_copy.loc[df_copy[col] > q_high, col] = q_high
        return df_copy
    
    def filter_features_to_match_model(self, df, feature_cols, required_count=5):
        """Ensure we have exactly the required number of features"""
        if len(feature_cols) == required_count:
            return feature_cols
            
        # First, prioritize the lag returns (most important)
        lag_features = [col for col in feature_cols if 'return_lag' in col]
        
        # Next, add in the most predictive technical features in a fixed order
        tech_priority = ['roc_5', 'volatility_10', 'ma_cross', 'dist_ma20', 'momentum_1m',
                        'oversold', 'overbought', 'roc_diff', 'volatility_regime']
                        
        prioritized_features = lag_features.copy()
        for feat in tech_priority:
            if feat in feature_cols and len(prioritized_features) < required_count:
                prioritized_features.append(feat)
        
        # If still not enough, add remaining features
        remaining = [col for col in feature_cols if col not in prioritized_features]
        while len(prioritized_features) < required_count and remaining:
            prioritized_features.append(remaining.pop(0))
        
        # If too many, truncate
        return prioritized_features[:required_count]

    def add_technical_features(self, df):
        if 'Close' not in df.columns:
            return df
            
        df['ma5'] = df['Close'].rolling(5).mean() / df['Close'] - 1  # Relative to price
        df['ma20'] = df['Close'].rolling(20).mean() / df['Close'] - 1
        df['ma_cross'] = df['ma5'] - df['ma20']  # Moving average crossover signal
        
        df['volatility_10'] = df['Close'].pct_change().rolling(10).std()
        df['volatility_ratio'] = df['Close'].pct_change().rolling(5).std() / df['Close'].pct_change().rolling(20).std()
        
        df['roc_5'] = df['Close'].pct_change(5)
        df['roc_10'] = df['Close'].pct_change(10)
        df['roc_diff'] = df['roc_5'] - df['roc_10']
        
        df['dist_ma20'] = (df['Close'] / df['Close'].rolling(20).mean() - 1)
        
        return df.fillna(0)
    
    def add_enhanced_features(self, df):
        """Add enhanced technical features"""
        df['volatility_trend'] = df['volatility_10'].pct_change(5)
        df['volatility_regime'] = (df['volatility_10'] > df['volatility_10'].rolling(20).mean()).astype(int)
        
        if 'volume' in df.columns:
            df['vol_ma_ratio'] = df['volume'] / df['volume'].rolling(20).mean()
            df['vol_price_trend'] = df['vol_ma_ratio'] * df['roc_5']
        
        df['momentum_1m'] = df['Close'].pct_change(20)
        df['momentum_3m'] = df['Close'].pct_change(60)
        df['momentum_breadth'] = (
            (df['roc_5'] > 0).astype(int) + 
            (df['momentum_1m'] > 0).astype(int) + 
            (df['momentum_3m'] > 0).astype(int)
        ) / 3
        
        df['mean_rev_signal'] = -1 * df['dist_ma20'] * df['volatility_10']
        df['oversold'] = (df['dist_ma20'] < -2 * df['volatility_10']).astype(int)
        df['overbought'] = (df['dist_ma20'] > 2 * df['volatility_10']).astype(int)
        
        df['regime_change'] = (np.sign(df['ma_cross']) != np.sign(df['ma_cross'].shift(1))).astype(int)
        
        df['risk_adj_momentum'] = df['roc_5'] / (df['volatility_10'] + 0.001)
        
        return df

    def prepare_stock_data(self, stock_data, ticker, is_training=False):
        """Prepare data for a single stock"""
        if len(stock_data) < self.lookback + 5:  # Need enough data
            return None, None
        
        stock_df = pd.DataFrame({
            'Close': stock_data['close'].values,
            'time': stock_data['time'].values
        })
        
        if 'volume' in stock_data.columns:
            stock_df['volume'] = stock_data['volume'].values
            
        stock_df = stock_df.sort_values('time').reset_index(drop=True)
        
        stock_df['pct_return'] = stock_df['Close'].pct_change().shift(-1) * 100
        
        # In prepare_stock_data, replace the feature cols section with:
        feature_cols = []

        # Add basic lag features
        for i in range(1, 6):
            col_name = f'return_lag{i}'
            stock_df[col_name] = stock_df['pct_return'].shift(i)
            feature_cols.append(col_name)

        # Add technical features
        stock_df = self.add_technical_features(stock_df)
        stock_df = self.add_enhanced_features(stock_df)

        # Add all potential features
        additional_features = ['ma_cross', 'volatility_10', 'roc_5', 'roc_diff', 'dist_ma20']
        enhanced_features = ['volatility_trend', 'volatility_regime', 'momentum_1m', 
                            'momentum_breadth', 'mean_rev_signal', 'oversold', 
                            'overbought', 'regime_change', 'risk_adj_momentum']

        for col in additional_features + enhanced_features:
            if col in stock_df.columns:
                feature_cols.append(col)

        # Filter to the exact number of features expected by the model
        model_feature_count = 5  # Use the exact count from your model
        feature_cols = self.filter_features_to_match_model(stock_df, feature_cols, model_feature_count)

        if not self.feature_cols:
            self.feature_cols = feature_cols.copy()
        
        stock_df = stock_df.dropna().reset_index(drop=True)
        
        # Handle outliers
        stock_df = self.clip_outliers(stock_df, feature_cols)
        
        # Replace the scaling code in prepare_stock_data with this:
        # Scale features
        if ticker not in self.scalers or is_training:
            # Check if we have data
            if len(stock_df) == 0 or len(feature_cols) == 0:
                return None, stock_df  # Return early if no data
                
            # Check if any features are empty/nan
            if stock_df[feature_cols].isna().any().any() or stock_df[feature_cols].empty:
                # Fill NaNs with zeros
                stock_df[feature_cols] = stock_df[feature_cols].fillna(0)
                
            # Ensure we have data
            if len(stock_df[feature_cols]) > 0:
                try:
                    scaler = RobustScaler()
                    stock_df[feature_cols] = scaler.fit_transform(stock_df[feature_cols])
                    self.scalers[ticker] = scaler
                except Exception as e:
                    print(f"Scaling error for {ticker}: {str(e)}")
                    # Use a simple standardization as fallback
                    for col in feature_cols:
                        mean = stock_df[col].mean()
                        std = stock_df[col].std()
                        if std > 0:
                            stock_df[col] = (stock_df[col] - mean) / std
                        else:
                            stock_df[col] = 0
            else:
                return None, stock_df  # Return early if empty after processing
        else:
            # Use existing scaler
            scaler = self.scalers[ticker]
            try:
                stock_df[feature_cols] = scaler.transform(stock_df[feature_cols])
            except Exception as e:
                print(f"Transform error for {ticker}: {str(e)}")
                # Simple standardization fallback
                for col in feature_cols:
                    if col in stock_df.columns and len(stock_df[col]) > 0:
                        mean = stock_df[col].mean()
                        std = stock_df[col].std()
                        if std > 0:
                            stock_df[col] = (stock_df[col] - mean) / std
                        else:
                            stock_df[col] = 0
        
        # Create sequences for prediction
        X = self.create_sliding_sequences(stock_df, feature_cols, self.lookback, stride=1)
        
        if len(X) == 0:
            return None, stock_df
            
        return X, stock_df
        
    def predict_returns(self, X, ticker):
        """Make predictions for a single stock"""
        if self.model is None:
            return 0
            
        if ticker not in self.stock_to_id:
            self.stock_to_id[ticker] = len(self.stock_to_id)
            
        stock_id = self.stock_to_id[ticker]
        
        try:
            X_tensor = torch.tensor(X, dtype=torch.float32)
            stock_ids = torch.tensor([stock_id] * len(X), dtype=torch.long)
            
            with torch.no_grad():
                predictions = self.model(X_tensor, stock_ids)
                
            # Convert to standard Python float for safety
            return float(predictions.detach().numpy().flatten()[-1])
        except Exception as e:
            print(f"Prediction error for {ticker}: {e}")
            return 0  # Return neutral prediction on error
        
    def detect_market_regime(self, daily_returns, lookback=10):
        """Detect current market regime based on portfolio returns"""
        if len(daily_returns) >= 1:
            market_return = np.mean(daily_returns)
            market_vol = np.std(daily_returns)
            
            if len(self.portfolio_returns) >= 3:
                recent_returns = self.portfolio_returns[-min(lookback, len(self.portfolio_returns)):]
                avg_recent_return = np.mean(recent_returns)
                
                if len(self.portfolio_returns) >= 5:
                    very_recent = np.mean(self.portfolio_returns[-3:])
                    less_recent = np.mean(self.portfolio_returns[-min(8, len(self.portfolio_returns)):-3])
                    trend_change = very_recent - less_recent
                    
                    if trend_change > 0.5 and avg_recent_return > 0.2:
                        return "breakout_bullish"
                    elif trend_change < -0.5 and avg_recent_return < -0.2:
                        return "breakdown_bearish"
                
                if avg_recent_return > 0.15:
                    if market_return > 0:
                        return "bullish_strong"
                    else:
                        return "bullish_pullback"
                elif avg_recent_return < -0.3:
                    if market_return < -0.2:
                        return "bearish_high_vol"
                    else:
                        return "bearish_low_vol"
                elif avg_recent_return > 0 and market_return > 0:
                    return "bullish"
                elif avg_recent_return < 0 and market_return < 0:
                    return "bearish"
            
            if market_return > -0.05:
                return "neutral"
            else:
                return "bearish"
        
        return "neutral"
        
    def detect_bearish_signals(self, recent_returns):
        """Detect early warning signs of bearish conditions"""
        bearish_signals = 0
        signal_strength = 0
        
        if len(self.portfolio_returns) >= 5:
            recent_portfolio_returns = self.portfolio_returns[-5:]
            pos_days = sum(1 for r in recent_portfolio_returns if r > 0)
            neg_days = sum(1 for r in recent_portfolio_returns if r < 0)
            
            if neg_days > pos_days:
                bearish_signals += 1
                signal_strength += 0.2 * (neg_days - pos_days)
        
        if len(self.portfolio_returns) >= 10:
            recent_vol = np.std(self.portfolio_returns[-5:])
            older_vol = np.std(self.portfolio_returns[-10:-5])
            if recent_vol > older_vol * 1.3:  # 30% volatility increase
                bearish_signals += 1
                signal_strength += 0.3 * (recent_vol/older_vol - 1)
        
        
        if len(self.portfolio_returns) >= 5:
            if self.portfolio_returns[-1] < 0 and self.portfolio_returns[-2] > 0.3:
                bearish_signals += 1
                signal_strength += 0.3
        
        return bearish_signals, signal_strength
        
    def generate_positions(self, prediction_data, current_returns=None):
        """Generate position sizing based on predictions"""
        if not prediction_data:
            return {}
            
        # Update market regime
        if current_returns is not None:
            self.current_regime = self.detect_market_regime(current_returns)
            bearish_count, bearish_strength = self.detect_bearish_signals(current_returns)
            self.defensive_mode = bearish_count >= 2 or bearish_strength > 0.5
        
        base_threshold = 0.25 * self.pred_std
        
        if self.current_regime in ["bullish_strong", "breakout_bullish"]:
            self.adaptive_threshold = base_threshold * 0.4
        elif self.current_regime in ["bearish_high_vol", "breakdown_bearish"]:
            self.adaptive_threshold = base_threshold * 2.5
        elif self.current_regime in ["bearish", "bearish_low_vol"]:
            self.adaptive_threshold = base_threshold * 1.6
        elif self.current_regime in ["bullish_pullback"]:
            self.adaptive_threshold = base_threshold * 0.9
        else:  # neutral or other regimes
            self.adaptive_threshold = base_threshold * 0.75
        
        positions = {}
        
        sector_data = {}
        for ticker, data in prediction_data.items():
            pred_return = data["pred_return"]
            sector = self.sector_mappings.get(ticker, "Unknown")
            
            if sector not in sector_data:
                sector_data[sector] = []
                
            sector_data[sector].append({
                "ticker": ticker,
                "pred_return": pred_return,
                "composite_score": pred_return / self.adaptive_threshold
            })
        
        # Rank sectors by predicted return
        sector_avg_scores = {}
        for sector, stocks in sector_data.items():
            sector_avg_scores[sector] = np.mean([s["pred_return"] for s in stocks])
        
        ranked_sectors = sorted(sector_avg_scores.keys(), key=lambda x: sector_avg_scores[x], reverse=True)
        
        # Focus on top 2 sectors
        top_sectors = ranked_sectors[:min(2, len(ranked_sectors))]
        
        # Allocate within top sectors - focus on stocks with strongest signals
        for sector in top_sectors:
            sector_stocks = sorted(sector_data[sector], key=lambda x: x["pred_return"], reverse=True)
            
            # Take top 2 stocks in each selected sector
            top_stocks = sector_stocks[:min(2, len(sector_stocks))]
                        
            # In the generate_positions method, modify these lines
            for stock in top_stocks:
                ticker = stock["ticker"]
                signal_strength = stock["pred_return"] / (0.2 * self.pred_std)
                # Reduce max size from 1.5 to 0.4 to prevent overallocation
                size = min(0.4, max(0.1, 0.2 * signal_strength))
                positions[ticker] = size
        
        # Defensive adjustments
        if self.defensive_mode or self.current_regime in ["bearish_high_vol", "bearish_low_vol", "breakdown_bearish"]:
            # 1. Reduce overall position sizes
            scaling_factor = 0.6 if self.defensive_mode else 0.8
            for ticker in positions:
                positions[ticker] *= scaling_factor
            
            # 2. Add inverse positions (shorts) as hedges if we have bearish predictions
            if len(positions) > 0:
                negative_preds = {t: data["pred_return"] for t, data in prediction_data.items() 
                                  if data["pred_return"] < 0 and t not in positions}
                
                if negative_preds:
                    worst_stocks = sorted(negative_preds.items(), key=lambda x: x[1])[:2]
                    for ticker, pred in worst_stocks:
                        hedge_size = -0.2 if self.defensive_mode else -0.15
                        positions[ticker] = hedge_size
        
        # Position count limits
        max_positions = 5
        if self.current_regime in ["bullish_strong", "breakout_bullish"]:
            max_positions = 6  # More positions in strong bull markets
        elif self.current_regime in ["bearish_high_vol", "breakdown_bearish"]:
            max_positions = 3  # Fewer positions in bear markets
        
        # Trim to max positions if needed
        if len(positions) > max_positions:
            sorted_positions = sorted(positions.items(), key=lambda x: abs(prediction_data[x[0]]["composite_score"]), reverse=True)
            positions = {ticker: pos for ticker, pos in sorted_positions[:max_positions]}
        
        # Handle extreme market stress (defensive shutdown)
        extreme_market_stress = False
        if len(self.portfolio_returns) >= 3:
            recent_returns = self.portfolio_returns[-3:]
            if sum(r < -1.0 for r in recent_returns) >= 2:  # 2+ days of >1% losses
                extreme_market_stress = True
        
        if extreme_market_stress:
            # Convert to all cash except small hedges
            new_positions = {}
            for ticker, position in positions.items():
                if position < 0:
                    # Keep small hedge positions only
                    new_positions[ticker] = max(-0.2, position)
            positions = new_positions
        
        return positions

    def get_stop_loss_level(self):
        """Get appropriate stop-loss level based on market regime"""
        if self.current_regime in ["bullish_strong", "breakout_bullish"]:
            if self.defensive_mode:
                return -2.0  # Tighter in defensive mode
            else:
                return -3.5  # More room for positions to breathe
        elif self.current_regime in ["bearish_high_vol", "breakdown_bearish"]:
            return -1.5  # Tighter stop-loss in bearish regimes
        else:
            if self.defensive_mode:
                return -1.8
            else:
                return -2.5
    
    def update_portfolio_returns(self, daily_return):
        """Update portfolio return history"""
        self.portfolio_returns.append(daily_return)
        if len(self.portfolio_returns) > 60:  # Keep a rolling window
            self.portfolio_returns = self.portfolio_returns[-60:]
    
    def update_model_calibration(self, all_predictions):
        """Update prediction standard deviation for threshold calibration"""
        all_pred_values = [p for p in all_predictions.values()]
        if len(all_pred_values) > 5:
            self.pred_std = np.std(all_pred_values)
# region imports
from AlgorithmImports import *
# endregion
from QuantConnect import *
from QuantConnect.Algorithm import *
from QuantConnect.Data import *
from QuantConnect.Indicators import *
from datetime import timedelta
import numpy as np
import pandas as pd
import torch
import os
import torch.nn as nn

from classes import DirectionalAccuracyLoss, QuaternionLinear, KoopmanOperator, EnhancedKQT, TransformerEncoderLayer, SectorAwareCrossAttention, CrossStockAttention, QuaternionAttention, MultiScaleKoopmanOperator

from kqtstrategy import KQTStrategy
class KQTAlgorithm(QCAlgorithm):
    def Initialize(self):
        """Initialize the algorithm"""
        # Set start date, end date, and cash
        self.SetStartDate(2019, 1, 3)
        self.SetEndDate(2025, 3, 26)
        self.SetCash(1000000)
        self.previous_portfolio_value = 0

        
        # Set benchmark to SPY
        self.SetBenchmark("SPY")
        
        # Initialize the KQT strategy
        self.strategy = KQTStrategy()
        self.lookback = 60  # Need enough data for technical indicators
        
        # Universe of stocks to trade
        self.tickers = [
            "AAPL", "MSFT", "AMZN", "NVDA", "GOOGL", "META", "TSLA", "JPM",
            "XOM", "V", "JNJ", "PG", "HD", "CVX", "MRK", "COST", "KO", "PEP", 
            "ADBE", "WMT", "MCD", "BAC", "CSCO", "ACN", "NFLX", "CMCSA"
        ]
        
        # Add each equity to our universe AND store the Symbol objects
        self.symbols = {}  # Add this line
        for ticker in self.tickers:
            self.symbols[ticker] = self.AddEquity(ticker, Resolution.Daily).Symbol  # Store the Symbol
        
        # Add SPY for market data
        self.spy = self.AddEquity("SPY", Resolution.Daily).Symbol
        
        # Storage for historical data and predictions
        self.stock_data = {}
        self.current_predictions = {}
        self.previous_positions = {}
        
        # Track stopped out positions
        self.stopped_out = set()
        
        # Schedule the trading function to run before market close
        self.Schedule.On(self.DateRules.EveryDay(), 
                self.TimeRules.At(10, 0),  # 10:00 AM Eastern
                self.TradeExecute)
        
        # Initialize model with feature count
        feature_count = 18  # Adjust based on your actual feature count
        self.strategy.initialize_model(input_size=feature_count)
        
        # Load pre-trained model weights if available
        self.TryLoadModelWeights()
            
    def TryLoadModelWeights(self):
        """Try to load model weights from ObjectStore with proper dimensions"""
        try:
            if self.ObjectStore.ContainsKey("kqt_model_weights2"):  # Use the new weights name
                self.Debug("Found model weights in ObjectStore, loading...")
                # Get base64 encoded string
                encoded_bytes = self.ObjectStore.Read("kqt_model_weights2")
                
                # Decode back to binary
                import base64
                model_bytes = base64.b64decode(encoded_bytes)
                
                # Save temporarily to file
                import tempfile
                with tempfile.NamedTemporaryFile(delete=False, suffix='.pth') as temp:
                    temp_path = temp.name
                    temp.write(model_bytes)
                
                # CRITICAL: Load weights first to check dimensions
                try:
                    state_dict = torch.load(temp_path)
                    
                    # Extract input and hidden dimensions from weights
                    # Look at the embedding layer weight to get input size
                    input_shape = state_dict['embedding.0.weight'].shape
                    actual_input_size = input_shape[1]
                    
                    # Look at Koopman matrix to determine hidden size
                    koopman_shape = state_dict['koopman.koopman_matrix'].shape
                    koopman_dim = koopman_shape[0]
                    
                    # Calculate the appropriate hidden_size (reverse of the calculation in EnhancedKQT)
                    # If koopman_dim is 256, and hidden_size*4 -> koopman_dim/4
                    hidden_size = 1  # Start with default
                    if koopman_dim == 256:
                        hidden_size = 1  # This will make hidden_size*4 = 4, and koopman_dim = 256
                    elif koopman_dim == 1024:
                        hidden_size = 4
                    else:
                        hidden_size = koopman_dim // 256  # General formula
                    
                    self.Debug(f"Detected dimensions - input_size: {actual_input_size}, hidden_size: {hidden_size}, koopman_dim: {koopman_dim}")
                    
                    # Reinitialize model with EXACT dimensions from the saved weights
                    self.strategy.initialize_model(
                        input_size=actual_input_size,
                        hidden_size=hidden_size,  # This is critical - must match saved weights
                        num_layers=2,
                        dropout=0.25,
                        n_heads=8
                    )
                    
                    # Now load the weights with matching dimensions
                    self.strategy.load_model_weights(temp_path)
                    self.Debug("Successfully loaded model weights")
                except Exception as inner_e:
                    self.Debug(f"Error loading weights: {str(inner_e)}")
                
                # Clean up
                import os
                os.unlink(temp_path)
            else:
                self.Debug("No model weights found in ObjectStore")
        except Exception as e:
            self.Debug(f"Error loading model weights: {str(e)}")
    
    def OnData(self, data):
        """OnData event is the primary entry point for your algorithm."""
        # We're using scheduled events instead of processing each data point
        pass
    
    def TradeExecute(self):
        """Execute trading logic daily"""
        # Skip if not trading day
        if not self.IsMarketOpen(self.spy):
            return
        
        self.Debug(f"TradeExecute running on {self.Time}")

        # 1. Update historical data for all stocks
        self.UpdateHistoricalData()
        
        # 2. Generate predictions for each stock
        self.current_predictions = self.GeneratePredictions()
        self.Debug(f"Current predictions: {self.current_predictions}")

        # 3. Check for stop losses
        self.ProcessStopLosses()
        
        # 4. Generate new position sizes
        market_returns = self.GetMarketReturns()
        target_positions = self.strategy.generate_positions(self.current_predictions, market_returns)
        self.Debug(f"Target positions before execution: {target_positions}")
        self.Debug(f"Market returns: {market_returns}")

        # 5. Execute trades to reach target positions
        self.ExecuteTrades(target_positions)
        
        # 6. Update portfolio return for regime detection
        daily_return = self.CalculatePortfolioReturn()
        self.strategy.update_portfolio_returns(daily_return)
        
        # 7. Add this line to store today's value for tomorrow's calculation
        self.previous_portfolio_value = self.Portfolio.TotalPortfolioValue
        self.Debug(f"Generated {len(target_positions)} positions: {target_positions}")
    
    def CalculatePortfolioReturn(self):
        """Calculate today's portfolio return"""
        # Get the portfolio value change
        current_value = self.Portfolio.TotalPortfolioValue
        
        # Use our stored previous value instead of the non-existent property
        if self.previous_portfolio_value > 0:
            return (current_value / self.previous_portfolio_value - 1) * 100  # as percentage
        
        # On first day, just store the value and return 0
        self.previous_portfolio_value = current_value
        return 0
    
    def UpdateHistoricalData(self):
        """Fetch and update historical data for all symbols"""
        for ticker in self.tickers:
            symbol = self.Securities[ticker].Symbol
            
            # Request sufficient history for features
            history = self.History(symbol, self.lookback, Resolution.Daily)
            
            if history.empty or len(history) < self.lookback:
                self.Debug(f"Not enough historical data for {ticker}, skipping")
                continue
                
            # Store historical data
            if isinstance(history.index, pd.MultiIndex):
                history_reset = history.reset_index()
                symbol_data = history_reset[history_reset['symbol'] == symbol]
                self.stock_data[ticker] = symbol_data
            else:
                self.stock_data[ticker] = history
    
    def GetMarketReturns(self):
        """Get recent market returns for regime detection"""
        spy_history = self.History(self.spy, 10, Resolution.Daily)
        
        if spy_history.empty:
            return []
            
        # Handle both MultiIndex and regular index formats
        if isinstance(spy_history.index, pd.MultiIndex):
            spy_history_reset = spy_history.reset_index()
            spy_history_filtered = spy_history_reset[spy_history_reset['symbol'] == self.spy]
            spy_prices = spy_history_filtered['close'].values
        else:
            spy_prices = spy_history['close'].values
            
        # Calculate returns
        spy_returns = []
        for i in range(1, len(spy_prices)):
            daily_return = (spy_prices[i] / spy_prices[i-1] - 1) * 100
            spy_returns.append(daily_return)
            
        return spy_returns
    
    def GeneratePredictions(self):
        """Generate predictions for all stocks"""
        predictions = {}
        
        self.Debug(f"Generating predictions with {len(self.stock_data)} stocks in data")
        
        for ticker, history in self.stock_data.items():
            try:
                if len(history) < self.lookback:
                    continue
                    
                # Prepare data for this stock
                X, stock_df = self.strategy.prepare_stock_data(history, ticker)
                
                if X is None or len(X) == 0:
                    # FALLBACK: Simple prediction if ML fails
                    if isinstance(history.index, pd.MultiIndex):
                        history_reset = history.reset_index()
                        closes = history_reset[history_reset['symbol'] == self.symbols[ticker]]['close'].values
                    else:
                        closes = history['close'].values
                    
                    if len(closes) > 20:
                        # Simple momentum strategy for fallback
                        short_ma = np.mean(closes[-5:])
                        long_ma = np.mean(closes[-20:])
                        momentum = closes[-1] / closes[-10] - 1 if len(closes) > 10 else 0
                        
                        pred_score = momentum + 0.5 * (short_ma/long_ma - 1)
                        predictions[ticker] = {
                            "pred_return": pred_score * 2,
                            "composite_score": pred_score * 5
                        }
                        self.Debug(f"Used fallback prediction for {ticker}: {pred_score}")
                    continue
                    
                # Generate prediction with ML model
                pred_return = self.strategy.predict_returns(X, ticker)
                
                # Store prediction
                predictions[ticker] = {
                    "pred_return": pred_return, 
                    "composite_score": pred_return / self.strategy.adaptive_threshold
                }
                self.Debug(f"ML prediction for {ticker}: {pred_return}")
            except Exception as e:
                self.Debug(f"Error processing {ticker}: {str(e)}")
                continue
        
        self.Debug(f"Generated {len(predictions)} predictions")
        return predictions
    
    def ProcessStopLosses(self):
        """Check and process stop loss orders"""
        stop_loss_level = self.strategy.get_stop_loss_level()
        
        for ticker in self.tickers:
            if not self.Portfolio[ticker].Invested:
                continue
                
            symbol = self.Securities[ticker].Symbol
            position = self.Portfolio[ticker]
            
            # Get today's return
            history = self.History(symbol, 2, Resolution.Daily)
            if history.empty or len(history) < 2:
                continue
                
            # Handle both MultiIndex and regular index formats
            if isinstance(history.index, pd.MultiIndex):
                history_reset = history.reset_index()
                symbol_data = history_reset[history_reset['symbol'] == symbol]
                if len(symbol_data) < 2:
                    continue
                close_prices = symbol_data['close'].values
            else:
                close_prices = history['close'].values
                
            daily_return = (close_prices[-1] / close_prices[-2] - 1) * 100
            
            position_type = "long" if position.Quantity > 0 else "short"
            hit_stop = False
            
            if position_type == "long" and daily_return < stop_loss_level:
                hit_stop = True
                self.Debug(f"Stop loss triggered for {ticker} (long): {daily_return:.2f}%")
            elif position_type == "short" and daily_return > -stop_loss_level:
                hit_stop = True
                self.Debug(f"Stop loss triggered for {ticker} (short): {daily_return:.2f}%")
                
            if hit_stop:
                self.stopped_out.add(ticker)
    
    def ExecuteTrades(self, target_positions):
        """Execute trades to reach target positions"""
        if not target_positions:
            self.Debug("No target positions received")
            return
        
        self.Debug(f"Executing trades for {len(target_positions)} positions")
        
        # Calculate total allocation first to check for overallocation
        portfolio_value = self.Portfolio.TotalPortfolioValue
        total_allocation = sum(abs(weight) for weight in target_positions.values())
        
        # Scale down if total allocation exceeds 100%
        if total_allocation > 1.0:
            scaling_factor = 0.95 / total_allocation  # Leave a small buffer
            self.Debug(f"Scaling positions by {scaling_factor:.2f} to prevent overallocation")
            for ticker in target_positions:
                target_positions[ticker] *= scaling_factor
        
        # Execute trades to reach target positions
        for ticker, target_weight in target_positions.items():
            symbol = self.symbols[ticker]
            current_security = self.Securities[symbol]
            
            # Calculate target share amount
            price = current_security.Price
            target_value = portfolio_value * target_weight
            target_shares = int(target_value / price) if price > 0 else 0
            
            # Get current holdings
            holding = self.Portfolio[symbol]
            current_shares = holding.Quantity
            
            # Calculate shares to trade
            shares_to_trade = target_shares - current_shares
            
            # Skip tiny orders
            if abs(shares_to_trade) > 0:
                try:
                    # Place the order
                    if shares_to_trade > 0:
                        self.MarketOrder(symbol, shares_to_trade)
                        self.Debug(f"BUY {shares_to_trade} shares of {ticker}")
                    else:
                        self.MarketOrder(symbol, shares_to_trade)  # Negative for sell
                        self.Debug(f"SELL {abs(shares_to_trade)} shares of {ticker}")
                except Exception as e:
                    self.Debug(f"Order error for {ticker}: {str(e)}")
                    # If buying fails, try with a smaller order
                    if shares_to_trade > 0:
                        try:
                            reduced_shares = max(1, int(shares_to_trade * 0.5))
                            self.MarketOrder(symbol, reduced_shares)
                            self.Debug(f"Reduced BUY to {reduced_shares} shares of {ticker}")
                        except:
                            self.Debug(f"Skipping order for {ticker} due to insufficient buying power")
        
        # Store current positions for next day
        self.previous_positions = target_positions.copy()