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_XGB1(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 = 40 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.info("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() logger.info("Completed feature_engineering_expand_all") return dataframe.fillna(0) def feature_engineering_expand_basic(self, dataframe: DataFrame, metadata: Dict, **kwargs) -> DataFrame: """Basic price and volume features.""" logger.info("Starting feature_engineering_expand_basic") dataframe["%-pct-change"] = dataframe["close"].pct_change() dataframe["%-raw_volume"] = dataframe["volume"] dataframe["%-raw_price"] = dataframe["close"] logger.info("Completed feature_engineering_expand_basic") 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: return dataframe try: df = dataframe.copy() # 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.info("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.info("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.info("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.info("Stroke and pivot features completed") # 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 except Exception as e: logger.error(f"Error in feature_engineering_standard: {e}") # 发生错误时返回原始数据框 return dataframe def set_freqai_targets(self, dataframe: DataFrame, metadata: Dict, **kwargs) -> DataFrame: """Set prediction targets for FreqAI.""" logger.info("Setting FreqAI targets") 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 ) # Discretize into categorical labels: 1 (buy), 2 (sell) dataframe["&-s_close"] = pd.Series(0, index=dataframe.index) # Default: no trade dataframe.loc[future_return > 0.01, "&-s_close"] = 1 # Buy if return > 1% dataframe.loc[future_return < -0.01, "&-s_close"] = 0 # Sell if return < -1% 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('%-')] temp_df = dataframe[custom_features + ['date', 'close', 'open', 'high', 'low', 'volume']] # Run FreqAI freqai_df = self.freqai.start(dataframe, metadata, self) # Merge back custom features freqai_df = freqai_df.combine_first(temp_df) dataframe = freqai_df logger.info(f"Completed populate_indicators. Columns: {list(dataframe.columns)}") return dataframe def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: """Define entry signals.""" logger.info(f"Starting populate_entry_trend for pair {metadata.get('pair', 'unknown')}") df = self.ensure_columns(dataframe, ['%-buy_signal', '%-bottom_strength', '%-sell_signal', '%-top_strength', '%-macd_top_div', '%-rsi-14']) enter_long_conditions = [ df["do_predict"] == 1, df["&-s_close"] == 1, # 上涨预测 df["%-buy_signal"] > 0, df["%-bottom_strength"] > 2.0, df["%-rsi-14"] < 40, ] if enter_long_conditions: df.loc[reduce(lambda x, y: x & y, enter_long_conditions), ["enter_long", "enter_tag"]] = (1, "long") enter_short_conditions = [ df["do_predict"] == 1, df["&-s_close"] == 0, # 下跌预测 df["%-sell_signal"] > 0, df["%-top_strength"] > 2.5, df["%-rsi-14"] > 60, df["%-macd_top_div"] > 0, ] if enter_short_conditions: df.loc[reduce(lambda x, y: x & y, enter_short_conditions), ["enter_short", "enter_tag"]] = (1, "short") logger.info("Completed populate_entry_trend") return df def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: """Define exit signals.""" logger.info(f"Starting populate_exit_trend for pair {metadata.get('pair', 'unknown')}") df = self.ensure_columns(dataframe, ['%-top_combo', '%-top_candle_pattern', '%-bottom_combo', '%-bottom_strength', '%-rsi-14']) exit_long_conditions = [ df["do_predict"] == 1, df["&-s_close"] == 0, # 下跌预测时退出多头 (df["%-top_combo"] > 0) | (df["%-top_candle_pattern"] > 0) ] if exit_long_conditions: df.loc[reduce(lambda x, y: x & y, exit_long_conditions), "exit_long"] = 1 exit_short_conditions = [ df["do_predict"] == 1, df["&-s_close"] == 1, # 上涨预测时退出空头 (df["%-bottom_combo"] > 0) | (df["%-bottom_strength"] > 3.0) ] if exit_short_conditions: df.loc[reduce(lambda x, y: x & y, exit_short_conditions), "exit_short"] = 1 logger.info("Completed populate_exit_trend") return df 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.info(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 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) 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, df: DataFrame, columns: list) -> DataFrame: """Ensure required columns exist with default values.""" logger.debug(f"Ensuring columns: {columns}") for col in columns: if col not in df.columns: logger.warning(f"Column {col} missing, using default value") df[col] = 50 if col == '%-rsi-14' else 0 return df 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