import logging from functools import reduce from typing import Dict import numpy as np import talib.abstract as ta from pandas import DataFrame from technical import qtpylib from talib import MACD, RSI from datetime import datetime import pandas as pd import uuid from freqtrade.strategy import IStrategy logger = logging.getLogger(__name__) # freqtrade backtesting --config user_data/ChanLun_XGB.json --strategy ChanLun_XGB --freqaimodel XGBoostClassifier --timerange=20250401-20250421 class ChanLun_XGB(IStrategy): minimal_roi = {"0": 0.1, "240": -1} plot_config = { "main_plot": {}, "subplots": { "&-s_close": {"&-s_close": {"color": "blue"}}, "do_predict": {"do_predict": {"color": "brown"}}, }, } process_only_new_candles = True stoploss = -0.05 use_exit_signal = True startup_candle_count: int = 100 # Ensure sufficient data for pivots can_short = True freqai_info = { "feature_parameters": { "label_period_candles": 24 } } def feature_engineering_expand_all(self, dataframe: DataFrame, period: int, metadata: Dict, **kwargs) -> DataFrame: """Basic technical indicators for various periods.""" logger.debug("Starting feature_engineering_expand_all") dataframe["%-rsi-period"] = ta.RSI(dataframe, timeperiod=period) dataframe["%-mfi-period"] = ta.MFI(dataframe, timeperiod=period) dataframe["%-adx-period"] = ta.ADX(dataframe, timeperiod=period) dataframe["%-sma-period"] = ta.SMA(dataframe, timeperiod=period) dataframe["%-ema-period"] = ta.EMA(dataframe, timeperiod=period) bollinger = qtpylib.bollinger_bands(qtpylib.typical_price(dataframe), window=period, stds=2.2) dataframe["bb_lowerband-period"] = bollinger["lower"] dataframe["bb_middleband-period"] = bollinger["mid"] dataframe["bb_upperband-period"] = bollinger["upper"] dataframe["%-bb_width-period"] = ( (dataframe["bb_upperband-period"] - dataframe["bb_lowerband-period"]) / dataframe["bb_middleband-period"] ) dataframe["%-close-bb_lower-period"] = dataframe["close"] / dataframe["bb_lowerband-period"] dataframe["%-roc-period"] = ta.ROC(dataframe, timeperiod=period) dataframe["%-relative_volume-period"] = dataframe["volume"] / dataframe["volume"].rolling(period).mean() return dataframe.fillna(0) def feature_engineering_expand_basic(self, dataframe: DataFrame, metadata: Dict, **kwargs) -> DataFrame: """Basic price and volume features.""" logger.debug("Starting feature_engineering_expand_basic") dataframe["%-pct-change"] = dataframe["close"].pct_change() dataframe["%-raw_volume"] = dataframe["volume"] dataframe["%-raw_price"] = dataframe["close"] return dataframe.fillna(0) def feature_engineering_standard(self, dataframe: DataFrame, metadata: Dict, **kwargs) -> DataFrame: """Advanced feature engineering with Chan Lun and technical indicators.""" logger.info(f"Starting feature_engineering_standard for pair {metadata.get('pair', 'unknown')}") if dataframe.empty or len(dataframe) < self.startup_candle_count: logger.error(f"Input DataFrame is empty or too small: {len(dataframe)} candles") return self.ensure_columns(dataframe, [ '%-buy_signal', '%-bottom_strength', '%-sell_signal', '%-top_strength', '%-macd_top_div', '%-rsi-14', '%-top_combo', '%-top_candle_pattern', '%-bottom_combo' ]) df = dataframe.copy() if df['close'].isna().any(): logger.warning(f"Missing data: {df[['open', 'high', 'low', 'close', 'volume']].isna().sum()}") # Time-based features df["%-day_of_week"] = df["date"].dt.dayofweek df["%-hour_of_day"] = df["date"].dt.hour # Fractal detection df = self.detect_fractals(df) logger.debug("Fractal detection completed") # MACD and RSI macd, signal, hist = MACD(df['close'], fastperiod=12, slowperiod=26, signalperiod=9) df['macd'] = macd df['macd_signal'] = signal df['macd_hist'] = hist df['macd_hist_sum'] = df['macd_hist'].rolling(5).sum() df["%-rsi-14"] = RSI(df['close'], timeperiod=14) logger.debug("MACD and RSI calculated") # Candlestick features df["%-close_open_diff"] = (df["close"] - df["open"]) / df["open"].replace(0, np.nan) df["%-body_length"] = abs(df["close"] - df["open"]) / df["close"].replace(0, np.nan) df["%-upper_shadow"] = (df["high"] - df[["open", "close"]].max(axis=1)) / df["close"].replace(0, np.nan) df["%-lower_shadow"] = (df[["open", "close"]].min(axis=1) - df["low"]) / df["close"].replace(0, np.nan) # Candle color and trend df["%-candle_color"] = (df["close"] > df["open"]).astype(int) * 2 - 1 df["%-consec_same_color"] = df["%-candle_color"].groupby((df["%-candle_color"] != df["%-candle_color"].shift()).cumsum()).cumcount() + 1 # Fractal strength df["%-bottom_strength"], df["%-top_strength"] = self.calculate_fractal_strength(df) logger.debug("Fractal strength calculated") # Price relationships for i in [1, 2, 3]: high_shift = df['high'].shift(i).replace(0, df['high'].mean()) low_shift = df['low'].shift(i).replace(0, df['low'].mean()) df[f"%-high_ratio_{i}"] = df['high'] / high_shift df[f"%-low_ratio_{i}"] = df['low'] / low_shift # MACD divergence df["%-macd_bottom_div"] = ((df['low'] < df['low'].rolling(5).min().shift(1)) & (df['macd'] > df['macd'].rolling(5).min().shift(1))).astype(int) df["%-macd_top_div"] = ((df['high'] > df['high'].rolling(5).max().shift(1)) & (df['macd'] < df['macd'].rolling(5).max().shift(1))).astype(int) # Additional features df["%-volume_change"] = df['volume'].pct_change() df["%-volatility"] = df['close'].rolling(5).std() df["%-price_range_20"] = (df['high'].rolling(5).max() - df['low'].rolling(5).min()) / df['close'].replace(0, np.nan) df["%-potential_top"] = (df['is_top'] & (df["%-rsi-14"] > 70) & (df["%-macd_top_div"] == 1) & (df['volume'] > df['volume'].rolling(20).mean()) & (df['close'] < df['open'])).astype(int) df["%-potential_bottom"] = (df['is_bottom'] & (df["%-rsi-14"] < 30) & (df["%-macd_bottom_div"] == 1)).astype(int) # Fractal distance df["%-last_fractal_distance"] = self.calculate_fractal_distance(df) # Advanced features df["%-macd_hist_change"] = df['macd_hist_sum'].pct_change().replace([np.inf, -np.inf], 0) df["%-volume_divergence"] = (df['close'].pct_change() - df['volume'].pct_change()).abs() df["%-breakout_high"] = (df['high'] > df['high'].shift(1).rolling(20).max()).astype(int) df["%-breakout_low"] = (df['low'] < df['low'].shift(1).rolling(20).min()).astype(int) df["%-top_prominence"] = (df['high'] - df['high'].shift(1).rolling(5).mean()) / (df['high'].shift(1).rolling(5).std() + 1e-6) df["%-top_prominence"] = df["%-top_prominence"].clip(-100, 100) df["%-macd_hist_decline"] = df['macd_hist'].rolling(3).apply( lambda x: 1 if all(x[i] > x[i+1] for i in range(len(x)-1)) else 0, raw=True) df["%-top_candle_pattern"] = ((df['close'].shift(1) > df['open'].shift(1)) & (df['close'] < df['open']) & (df['close'] < df['open'].shift(1))).astype(int) df["%-bottom_combo"] = (df['is_bottom'] & (df["%-rsi-14"] < 40) & (df['macd_hist'] > 0) & (df['volume'] > df['volume'].rolling(20).mean())).astype(int) df["%-top_combo"] = (df['is_top'] & (df["%-rsi-14"] > 60) & (df['macd_hist'] < 0) & (df['volume'] > df['volume'].rolling(20).mean())).astype(int) df["%-post_top_decline"] = self.calculate_post_top_decline(df) df["%-resistance_distance"] = self.calculate_resistance_distance(df) # Stroke and pivot features strokes = self.detect_strokes(df) df["%-macd_hist_dynamic"], df["%-pivot_distance"], df["%-buy_signal"], df["%-sell_signal"] = self.process_strokes_and_pivots(df, strokes) logger.debug("Stroke and pivot features completed") # Validate required columns required_columns = [ '%-buy_signal', '%-bottom_strength', '%-sell_signal', '%-top_strength', '%-macd_top_div', '%-rsi-14', '%-top_combo', '%-top_candle_pattern', '%-bottom_combo' ] missing_columns = [col for col in required_columns if col not in df.columns] if missing_columns: logger.error(f"Missing required columns: {missing_columns}") df = self.ensure_columns(df, missing_columns) # Clean up num_columns = df.select_dtypes(include=[np.number]).columns df[num_columns] = df[num_columns].replace([np.inf, -np.inf], 0).fillna(0) logger.info(f"Completed feature_engineering_standard. Columns: {list(df.columns)}") return df def set_freqai_targets(self, dataframe: DataFrame, metadata: Dict, **kwargs) -> DataFrame: """Set prediction targets for FreqAI.""" logger.debug(f"Setting FreqAI targets for pair {metadata.get('pair', 'unknown')}") label_period = self.freqai_info["feature_parameters"]["label_period_candles"] # Calculate future return future_return = ( dataframe["close"].shift(-label_period).rolling(label_period).mean() / dataframe["close"] - 1 ) # 二分类标签:1(买入/上涨),0(卖出/下跌) - 更简单且不易出错 dataframe["&-s_close"] = np.where(future_return > 0.01, 1, 0).astype(int) logger.debug(f"Label distribution: {dataframe['&-s_close'].value_counts().to_dict()}") return dataframe.fillna(0) def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: """Populate indicators, leveraging FreqAI if available.""" logger.info(f"Starting populate_indicators for pair {metadata.get('pair', 'unknown')}") # Always run feature_engineering_standard to ensure custom features dataframe = self.feature_engineering_standard(dataframe, metadata) if hasattr(self, "freqai"): logger.info("Running FreqAI pipeline") # Preserve custom features custom_features = [col for col in dataframe.columns if col.startswith('%-')] base_columns = ['date', 'close', 'open', 'high', 'low', 'volume'] # 仅保存确定存在的列 preserve_columns = custom_features + [col for col in base_columns if col in dataframe.columns] temp_df = dataframe[preserve_columns] # 设置FreqAI目标 dataframe = self.set_freqai_targets(dataframe, metadata) # Run FreqAI try: freqai_df = self.freqai.start(dataframe, metadata, self) # Merge back custom features freqai_df = freqai_df.combine_first(temp_df) dataframe = freqai_df except Exception as e: logger.error(f"FreqAI pipeline failed: {e}") # Fallback to custom features logger.info(f"Completed populate_indicators. Columns: {list(dataframe.columns)}") return dataframe def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: """Generate entry signals for the strategy.""" dataframe.loc[:, "enter_long"] = 0 dataframe.loc[:, "enter_short"] = 0 # 确保数据框中的列存在 dataframe = self.ensure_columns(dataframe) # 只有当do_predict列存在时才使用它 if "do_predict" in dataframe.columns: # 根据AI预测值设置交易信号 - 使用二分类结果 mask = (dataframe["do_predict"] == 1) dataframe.loc[mask, "enter_long"] = 1 # 设置空头信号 (如果策略支持空头) if self.can_short: mask = (dataframe["do_predict"] == 0) dataframe.loc[mask, "enter_short"] = 1 # 将NaN值替换为0 dataframe = dataframe.fillna(0) return dataframe def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: """Generate exit signals for the strategy.""" dataframe.loc[:, "exit_long"] = 0 dataframe.loc[:, "exit_short"] = 0 # 确保数据框中的列存在 dataframe = self.ensure_columns(dataframe) # 只有当do_predict列存在时才使用它 if "do_predict" in dataframe.columns: # 多头平仓信号 mask = (dataframe["do_predict"] == 0) dataframe.loc[mask, "exit_long"] = 1 # 空头平仓信号 if self.can_short: mask = (dataframe["do_predict"] == 1) dataframe.loc[mask, "exit_short"] = 1 # 将NaN值替换为0 dataframe = dataframe.fillna(0) return dataframe def confirm_trade_entry(self, pair: str, order_type: str, amount: float, rate: float, time_in_force: str, current_time, entry_tag, side: str, **kwargs) -> bool: """Confirm trade entry with additional checks.""" logger.debug(f"Confirming trade entry for {pair}, side: {side}") df, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) last_candle = df.iloc[-1].squeeze() df = self.ensure_columns(df, ['%-bottom_strength', '%-buy_signal', '%-top_strength', '%-sell_signal', '%-rsi-14']) if side == "long": if rate > (last_candle["close"] * 1.0025): logger.debug(f"Long entry rejected: rate {rate} exceeds threshold") return False return last_candle["%-bottom_strength"] > 1.5 or last_candle["%-buy_signal"] > 0 else: if rate < (last_candle["close"] * 0.9975): logger.debug(f"Short entry rejected: rate {rate} below threshold") return False return last_candle["%-top_strength"] > 2.0 or last_candle["%-sell_signal"] > 0 def detect_fractals(self, df: DataFrame) -> DataFrame: """Detect top and bottom fractals.""" logger.debug("Detecting fractals") df['is_top'] = ( (df['high'] > df['high'].shift(1)) & (df['high'] > df['high'].shift(2)) & (df['high'] > df['high'].shift(-1)) & (df['high'] > df['high'].shift(-2)) ) df['is_bottom'] = ( (df['low'] < df['low'].shift(1)) & (df['low'] < df['low'].shift(2)) & (df['low'] < df['low'].shift(-1)) & (df['low'] < df['low'].shift(-2)) ) return df.fillna({'is_top': False, 'is_bottom': False}) def calculate_fractal_strength(self, df: DataFrame) -> tuple: """Calculate strength of fractals.""" logger.debug("Calculating fractal strength") bottom_strength = np.zeros(len(df)) top_strength = np.zeros(len(df)) for i in range(2, len(df) - 2): if df['is_bottom'].iloc[i]: strength = 0.0 pre_decline = (df['low'].iloc[i-2:i].min() - df['low'].iloc[i]) / df['low'].iloc[i] post_rise = (df['high'].iloc[i+1:i+3].max() - df['high'].iloc[i]) / df['high'].iloc[i] strength += min(pre_decline * 10, 1.0) + min(post_rise * 10, 1.0) vol_surge = df['volume'].iloc[i] / df['volume'].iloc[i-3:i].mean() if df['volume'].iloc[i-3:i].mean() > 0 else 1 strength += min(vol_surge / 3, 1.0) if any(abs(df['low'].iloc[j] - df['low'].iloc[i]) / df['low'].iloc[i] < 0.01 for j in range(max(0, i-20), i)): strength += 1.0 bottom_strength[i] = min(strength, 5.0) if df['is_top'].iloc[i]: strength = 0.0 pre_rise = (df['high'].iloc[i] - df['high'].iloc[i-2:i].max()) / df['high'].iloc[i] post_decline = (df['low'].iloc[i] - df['low'].iloc[i+1:i+3].min()) / df['low'].iloc[i] strength += min(pre_rise * 10, 1.0) + min(post_decline * 10, 1.0) vol_surge = df['volume'].iloc[i] / df['volume'].iloc[i-3:i].mean() if df['volume'].iloc[i-3:i].mean() > 0 else 1 strength += min(vol_surge / 3, 1.0) if any(abs(df['high'].iloc[j] - df['high'].iloc[i]) / df['high'].iloc[i] < 0.01 for j in range(max(0, i-20), i)): strength += 1.0 bearish_count = sum(df['close'].iloc[i:i+3] < df['open'].iloc[i:i+3]) strength += min(bearish_count * 0.5, 1.5) top_strength[i] = min(strength, 6.0) return bottom_strength, top_strength def calculate_fractal_distance(self, df: DataFrame) -> pd.Series: """Calculate distance to last fractal.""" logger.debug("Calculating fractal distance") fractal_indices = df[df['is_top'] | df['is_bottom']].index distances = pd.Series(0, index=df.index) for i in range(1, len(df)): if fractal_indices[fractal_indices < df.index[i]].size > 0: last_fractal_idx = fractal_indices[fractal_indices < df.index[i]][-1] if isinstance(df.index[i], pd.Timestamp) and isinstance(last_fractal_idx, pd.Timestamp): time_diff = (df.index[i] - last_fractal_idx).total_seconds() / 60 else: time_diff = i - df.index.get_loc(last_fractal_idx) distances.iloc[i] = time_diff return distances def calculate_post_top_decline(self, df: DataFrame) -> pd.Series: """Calculate post-top decline.""" logger.debug("Calculating post-top decline") post_top_decline = pd.Series(0.0, index=df.index) for i in range(2, len(df)-3): if df['is_top'].iloc[i]: decline = (df['high'].iloc[i] - df['low'].iloc[i+1:i+4].min()) / df['high'].iloc[i] post_top_decline.iloc[i] = min(decline * 10, 5.0) return post_top_decline def calculate_resistance_distance(self, df: DataFrame) -> pd.Series: """Calculate resistance distance.""" logger.debug("Calculating resistance distance") resistance_distance = pd.Series(0.0, index=df.index) for i in range(20, len(df)): if df['is_top'].iloc[i]: price_high = df['high'].iloc[i] resistance_levels = df['high'].iloc[i-20:i].rolling(5).max() distance = (price_high - resistance_levels.min()) / price_high if resistance_levels.min() > 0 else 0 resistance_distance.iloc[i] = min(distance * 10, 5.0) return resistance_distance def detect_strokes(self, df: DataFrame) -> list: """Detect strokes based on fractals.""" logger.debug("Detecting strokes") strokes = [] last_fractal, last_price, last_index = None, None, None for i in range(len(df)): if df['is_top'].iloc[i] or df['is_bottom'].iloc[i]: current_fractal = 'top' if df['is_top'].iloc[i] else 'bottom' current_price = df['high'].iloc[i] if current_fractal == 'top' else df['low'].iloc[i] if last_fractal is None: last_fractal, last_price, last_index = current_fractal, current_price, df.index[i] continue if not isinstance(current_price, (int, float)) or not isinstance(last_price, (int, float)): continue price_change = abs(current_price - last_price) / last_price if price_change < 0.005: continue if (last_fractal == 'top' and current_fractal == 'bottom' and current_price < last_price) or \ (last_fractal == 'bottom' and current_fractal == 'top' and current_price > last_price): strokes.append({ 'start_time': last_index, 'end_time': df.index[i], 'start_price': last_price, 'end_price': current_price, 'type': 'down' if current_fractal == 'bottom' else 'up' }) last_fractal, last_price, last_index = current_fractal, current_price, df.index[i] logger.debug(f"Detected {len(strokes)} strokes") return strokes def detect_pivots(self, strokes: list) -> list: """Detect pivots based on strokes.""" logger.debug("Detecting pivots") pivots = [] if len(strokes) < 3: logger.debug("Insufficient strokes for pivot detection") return pivots for i in range(2, len(strokes)): high1, low1 = max(strokes[i-2]['start_price'], strokes[i-2]['end_price']), min(strokes[i-2]['start_price'], strokes[i-2]['end_price']) high2, low2 = max(strokes[i-1]['start_price'], strokes[i-1]['end_price']), min(strokes[i-1]['start_price'], strokes[i-1]['end_price']) high3, low3 = max(strokes[i]['start_price'], strokes[i]['end_price']), min(strokes[i]['start_price'], strokes[i]['end_price']) if max(low1, low2, low3) < min(high1, high2, high3): pivots.append({ 'start_time': strokes[i-2]['start_time'], 'end_time': strokes[i]['end_time'], 'high': min(high1, high2, high3), 'low': max(low1, low2, low3) }) logger.debug(f"Detected {len(pivots)} pivots") return pivots def add_pivot_distance(self, df: DataFrame, pivots: list) -> DataFrame: """Add pivot distance feature.""" logger.debug("Adding pivot distance") df = df.copy() df['pivot_distance'] = 0.0 if not pivots: logger.debug("No pivots detected, returning default pivot_distance") return df for pivot in pivots: try: start_time = pivot['start_time'] end_time = pivot['end_time'] if start_time not in df.index or end_time not in df.index: logger.debug(f"Invalid pivot times: {start_time} to {end_time}") continue mask = (df.index >= start_time) & (df.index <= end_time) denominator = pivot['high'] - pivot['low'] if denominator > 0: df.loc[mask, 'pivot_distance'] = (df['close'] - pivot['low']) / denominator else: logger.debug(f"Zero denominator for pivot {pivot}") except Exception as e: logger.error(f"Error in pivot distance calculation: {e}") continue df['pivot_distance'] = df['pivot_distance'].clip(-10, 10).fillna(0.0) logger.debug("Completed pivot distance calculation") return df def detect_back_divergence(self, df: DataFrame, strokes: list) -> DataFrame: """Detect back divergence for buy/sell signals.""" logger.debug("Detecting back divergence") df = df.copy() df['buy_signal'] = False df['sell_signal'] = False if len(strokes) < 2: logger.debug("Insufficient strokes for divergence detection") return df for i in range(1, len(strokes)): current_hist = self.safe_get_value(df, strokes[i]['end_time'], 'macd_hist_sum') previous_hist = self.safe_get_value(df, strokes[i-1]['end_time'], 'macd_hist_sum') if current_hist is None or previous_hist is None: continue if strokes[i]['type'] == strokes[i-1]['type'] == 'up': if strokes[i]['end_price'] > strokes[i-1]['end_price'] and current_hist < previous_hist: closest_time = df.index[df.index <= strokes[i]['end_time']] if len(closest_time) > 0: df.loc[closest_time[-1], 'sell_signal'] = True elif strokes[i]['type'] == strokes[i-1]['type'] == 'down': if strokes[i]['end_price'] < strokes[i-1]['end_price'] and current_hist < previous_hist: closest_time = df.index[df.index <= strokes[i]['end_time']] if len(closest_time) > 0: df.loc[closest_time[-1], 'buy_signal'] = True logger.debug("Completed back divergence detection") return df def process_strokes_and_pivots(self, df: DataFrame, strokes: list) -> tuple: """Process strokes and pivots for advanced features.""" logger.debug("Processing strokes and pivots") macd_hist_dynamic = pd.Series(0.0, index=df.index) pivot_distance = pd.Series(0.0, index=df.index) buy_signal = pd.Series(0, index=df.index) sell_signal = pd.Series(0, index=df.index) if strokes: try: for stroke in strokes[1:]: start_time, end_time = stroke['start_time'], stroke['end_time'] if start_time not in df.index or end_time not in df.index: logger.debug(f"Invalid stroke times: {start_time} to {end_time}") continue window = self.calculate_window(df, start_time, end_time) start_idx, end_idx = df.index.get_loc(start_time), df.index.get_loc(end_time) + 1 hist_values = df['macd_hist'].iloc[start_idx:end_idx].rolling(window, min_periods=1).sum().fillna(0) macd_hist_dynamic.iloc[start_idx:end_idx] = hist_values logger.debug("Dynamic MACD calculated") except Exception as e: logger.warning(f"Dynamic MACD calculation error: {e}") try: pivots = self.detect_pivots(strokes) if pivots: df_with_pivot = self.add_pivot_distance(df, pivots) pivot_distance = df_with_pivot['pivot_distance'] else: logger.debug("No pivots detected") except Exception as e: logger.warning(f"Pivot detection error: {e}") try: df_with_divergence = self.detect_back_divergence(df, strokes) buy_signal = df_with_divergence['buy_signal'].astype(int) sell_signal = df_with_divergence['sell_signal'].astype(int) logger.debug("Back divergence signals calculated") except Exception as e: logger.warning(f"Back divergence detection error: {e}") logger.debug("Completed stroke and pivot processing") return macd_hist_dynamic, pivot_distance, buy_signal, sell_signal def ensure_columns(self, dataframe: DataFrame) -> DataFrame: """确保数据框中包含必要的列""" # 不要尝试创建do_predict和&-s_close列,它们由FreqAI生成 for col in ["enter_long", "enter_short", "exit_long", "exit_short"]: if col not in dataframe.columns: dataframe[col] = 0 return dataframe def safe_get_value(self, df: DataFrame, timestamp, column: str): """Safely get value from DataFrame.""" try: if timestamp in df.index: return df.loc[timestamp, column] closest_idx = df.index[df.index <= timestamp] return df.loc[closest_idx[-1], column] if len(closest_idx) > 0 else None except Exception as e: logger.debug(f"Error getting value for {column} at {timestamp}: {e}") return None def calculate_window(self, df: DataFrame, start_time, end_time) -> int: """Calculate window size for dynamic features.""" try: if isinstance(start_time, pd.Timestamp) and isinstance(end_time, pd.Timestamp): window = int((end_time - start_time).total_seconds() / 60) else: start_idx = df.index.get_loc(start_time) end_idx = df.index.get_loc(end_time) window = end_idx - start_idx return max(window, 1) except (TypeError, AttributeError, KeyError) as e: logger.debug(f"Window calculation error: {e}") return 1