CoolFace
Apppublic

Daveabc12/Backtesting-App

sourceHugging Faceupdated 12h agoView on Hugging Face
1likes
app.py5006 linesDownload Raw Back to root
1# New version ---
2import streamlit as st
3import pandas as pd
4import os
5import numpy as np
6from datetime import date, datetime
7import plotly.graph_objects as go
8import itertools
9import json
10# --- CORRECTED IMPORTS: Moved MACD ---
11from ta.volatility import BollingerBands
12from ta.momentum import RSIIndicator # Removed MACD from here
13from ta.trend import ADXIndicator, MACD 
14from ta.volume import MFIIndicator # Added MFI here
15# --- [NEW] ADDED ATR FOR INTELLIGENT EXIT ---
16from ta.volatility import AverageTrueRange
17# --- [END NEW] ---
18from multiprocessing import Pool, cpu_count
19from functools import partial
20from dateutil.relativedelta import relativedelta
21from datetime import timedelta
22
23# --- 0. Settings Management Functions ---
24CONFIG_FILE = "config.json"
25VETO_CONFIG_FILE = "veto_config.json"
26TOP_SETUPS_FILE = "top_setups.json"
27USER_SETUPS_FILE = "user_advisor_setups.json"
28MARKOV_SETUP_FILE = "best_markov.json"
29
30def save_settings(params_to_save):
31    with open(CONFIG_FILE, 'w', encoding='utf-8') as f:
32        json.dump(params_to_save, f, indent=4)
33    st.sidebar.success("Settings saved as default!")
34
35# --- Replacement for load_settings function ---
36def load_settings():
37    default_structure = {
38        "large_ma_period": 70, 
39        "bband_period": 32, 
40        "bband_std_dev": 1.4,
41        "confidence_threshold": 95,
42        
43        "long_entry_threshold_pct": 0.01, 
44        "long_exit_ma_threshold_pct": 0.0, 
45        "long_trailing_stop_loss_pct": 0.2, 
46        "long_delay_days": 0,
47        
48        "short_entry_threshold_pct": 0.0, 
49        "short_exit_ma_threshold_pct": 0.0, 
50        "short_trailing_stop_loss_pct": 0.2, 
51        "short_delay_days": 0,
52        
53        "use_rsi": True, "rsi_w": 1.5,
54        "rsi_logic": "Level",
55        "primary_driver": "Bollinger Bands",
56        
57        "exit_logic_type": "Intelligent (ADX/MACD/ATR)", 
58        "exit_confidence_threshold": 40, 
59        "smart_exit_atr_period": 14,
60        "smart_exit_atr_multiplier": 3.0,
61        "intelligent_tsl_pct": 0.2,
62        "norm_lookback_years": 1,
63        'use_rolling_benchmark': False,
64        "benchmark_rank": 99,
65        "use_ma_floor_filter": True,
66        "catcher_stop_pct": 0.03,
67        "use_vol": False, "vol_w": 0.5,
68        "use_trend": False, "trend_w": 2.0,
69        "use_volume": False, "volume_w": 0.5,
70        
71        "use_adx_filter": False, "adx_threshold": 10.0,
72        "adx_period": 14,
73        
74        "use_macd": False, "macd_w": 2.0,
75        "use_ma_slope": False, "ma_slope_w": 0.5,
76        "use_markov": False, "markov_w": 1.0, 
77        
78        "max_trading_days": 60,
79        "max_long_duration": 60,
80        "max_short_duration": 3
81    }
82
83    if os.path.exists(CONFIG_FILE):
84        try:
85            with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
86                loaded = json.load(f)
87                for key in default_structure:
88                    if key in loaded:
89                        default_structure[key] = loaded[key]
90        except (json.JSONDecodeError, Exception) as e:
91            print(f"Error loading config.json: {e}. Using default settings.")
92    return default_structure
93
94# --- UPDATED: Save a list of veto setups ---
95def save_veto_setup(veto_setups_list): # Takes a list now
96    with open(VETO_CONFIG_FILE, 'w', encoding='utf-8') as f:
97        # Save the list directly
98        json.dump(veto_setups_list, f, indent=4)
99    st.sidebar.success(f"Saved {len(veto_setups_list)} Veto filter(s) as default!")
100
101# --- UPDATED: Load a list of veto setups, robustly ---
102def load_veto_setup():
103    veto_list = [] # Default to empty list
104    if os.path.exists(VETO_CONFIG_FILE):
105        try:
106            with open(VETO_CONFIG_FILE, 'r', encoding='utf-8') as f:
107                loaded_data = json.load(f)
108                # Ensure it's loaded as a list
109                if isinstance(loaded_data, list):
110                    veto_list = loaded_data
111                elif isinstance(loaded_data, dict): # Handle old single dict format
112                    veto_list = [loaded_data]
113        except (json.JSONDecodeError, Exception) as e:
114            print(f"Error loading veto config: {e}. Using empty list.")
115            # Keep veto_list as empty
116            pass
117    return veto_list
118
119# --- UPDATED: Includes new factors, num_setups=8, removed infinity filter ---
120def save_top_setups(results_df, side, num_setups=8): # Default is now 8
121    df = results_df.copy()
122
123    if df.empty:
124        st.sidebar.warning(f"No valid setups found for {side.title()} to save.")
125        return
126
127    deduplication_cols = [
128        'Conf. Threshold', 'Avg Profit/Trade', 'Ticker G/B Ratio', 'Trade G/B Ratio',
129        'Winning Tickers', 'Losing Tickers', 'Avg Entry Conf.',
130        'Good Score', 'Bad Score', 'Norm. Score %', 'Total Trades'
131    ]
132
133    factor_cols = ['RSI', 'Volatility', 'TREND', 'Volume', 'MACD', 'MA Slope']
134    existing_factor_cols = [col for col in factor_cols if col in df.columns]
135    if existing_factor_cols:
136        df['FactorsOn'] = df[existing_factor_cols].apply(lambda row: (row == 'On').sum(), axis=1)
137    else:
138        df['FactorsOn'] = 0
139
140    # --- CONSISTENCY FIX: Sort by the Weighted Score ("Norm. Score %") ---
141    # If Norm. Score % exists, use it. Otherwise fall back to Strategy Score or Trade G/B Ratio.
142    sort_col = 'Trade G/B Ratio' # Default fallback
143    if 'Norm. Score %' in df.columns:
144        sort_col = 'Norm. Score %'
145    elif 'Strategy Score' in df.columns:
146        sort_col = 'Strategy Score'
147
148    if sort_col in df.columns:
149        df[sort_col] = df[sort_col].fillna(-np.inf)
150        # Sort by Score (Descending), then by Complexity (Ascending - simpler is better)
151        df = df.sort_values(
152            by=[sort_col, 'FactorsOn'],
153            ascending=[False, True]
154        )
155    else:
156        st.sidebar.error(f"Could not sort top setups: '{sort_col}' column missing.")
157        return
158
159    existing_cols_for_dedup = [col for col in deduplication_cols if col in df.columns]
160    deduplicated_df = df.drop_duplicates(subset=existing_cols_for_dedup, keep='first')
161
162    top_setups = deduplicated_df.head(num_setups).to_dict('records')
163
164    if os.path.exists(TOP_SETUPS_FILE):
165        try:
166            with open(TOP_SETUPS_FILE, 'r', encoding='utf-8') as f:
167                all_top_setups = json.load(f)
168        except json.JSONDecodeError:
169            all_top_setups = {}
170    else:
171        all_top_setups = {}
172
173    all_top_setups[side] = top_setups
174
175    with open(TOP_SETUPS_FILE, 'w', encoding='utf-8') as f:
176        json.dump(all_top_setups, f, indent=4)
177
178    st.sidebar.success(f"Top {len(top_setups)} unique {side.title()} setups saved! (Sorted by {sort_col})")
179
180def load_top_setups():
181    if os.path.exists(TOP_SETUPS_FILE):
182        try:
183            with open(TOP_SETUPS_FILE, 'r', encoding='utf-8') as f:
184                return json.load(f)
185        except json.JSONDecodeError:
186             return None # Return None if file is corrupt
187    return None
188
189@st.cache_data # Use caching for efficiency
190def load_proverbs():
191    # Default to an empty list
192    proverbs_list = []
193    if os.path.exists("proverbs.json"):
194        try:
195            with open("proverbs.json", 'r', encoding='utf-8') as f:
196                loaded_data = json.load(f)
197                # Ensure it's a list
198                if isinstance(loaded_data, list):
199                    proverbs_list = loaded_data
200        except (json.JSONDecodeError, Exception) as e:
201            print(f"Error loading proverbs.json: {e}. Using fallback.")
202            # Keep proverbs_list empty or provide a default
203            # proverbs_list = ["Error loading proverbs."]
204    # Add a fallback if the list is empty after trying to load
205    if not proverbs_list:
206        proverbs_list = ["Have a great trading day!"] # Fallback message
207    return proverbs_list
208
209def load_user_setups():
210    """Loads user-defined advisor setups from a JSON file."""
211    
212    # 1. Update Template to include Notes
213    default_row_template = {
214        "Run": False,
215        "Notes": "", # <--- NEW FIELD FOR ADVICE
216        "RSI": "Off", "Volatility": "Off", "TREND": "Off", "Volume": "Off", 
217        "MACD": "Off", "MA Slope": "Off", "Markov": "Off", 
218        "ADX Filter": "Off",
219        "Conf. Threshold": 50,
220        "Large MA Period": 50, "Bollinger Band Period": 20, "Bollinger Band Std Dev": 2.0,
221        "Catcher Offset (%)": 3.0,
222        "Long Entry Threshold (%)": 0.0, "Long Exit Threshold (%)": 0.0, "Long Stop Loss (%)": 8.0, "Long Delay (Days)": 0,
223        "Short Entry Threshold (%)": 0.0, "Short Exit Threshold (%)": 0.0, "Short Stop Loss (%)": 8.0, "Short Delay (Days)": 0,
224        "Z_Avg_Profit": 0.0, "Z_Num_Trades": 0, "Z_WL_Ratio": 0.0
225    }
226
227    # Default Examples
228    default_setup = [default_row_template.copy()]
229
230    # Pad with defaults
231    while len(default_setup) < 20:
232        default_setup.append(default_row_template.copy())
233
234    if os.path.exists(USER_SETUPS_FILE):
235        try:
236            with open(USER_SETUPS_FILE, 'r', encoding='utf-8') as f:
237                user_setups = json.load(f)
238                if isinstance(user_setups, list):
239                    processed_setups = []
240                    for setup in user_setups:
241                        full_setup = default_row_template.copy()
242                        full_setup.update(setup) # Merge loaded data
243                        processed_setups.append(full_setup)
244
245                    while len(processed_setups) < 20:
246                        processed_setups.append(default_row_template.copy())
247                    return processed_setups[:20]
248                else:
249                    return default_setup
250        except Exception as e:
251            print(f"Error loading {USER_SETUPS_FILE}: {e}. Using defaults.")
252            return default_setup
253    return default_setup
254
255# --- UPDATED: Callback to Save Setup (Added Notes) ---
256def add_setup_to_user_list():
257    try:
258        stats = st.session_state.get('last_run_stats', {})
259        if not stats:
260            st.toast("⚠️ No stats found. Run analysis first.", icon="⚠️")
261            return
262
263        def get_weight_or_off(toggle_key, weight_key):
264            if st.session_state.get(toggle_key, False):
265                return round(st.session_state.get(weight_key, 1.0), 2)
266            return "Off"
267
268        adx_val = "Off"
269        if st.session_state.get("use_adx_filter", False):
270            val = st.session_state.get("adx_threshold", 25.0)
271            adx_val = max(20.0, min(30.0, val))
272
273        new_setup = {
274            "Run": True,
275            "Notes": "Auto-Saved Setup", # <--- Default note for auto-saves
276            "RSI": get_weight_or_off('use_rsi', 'rsi_w'),
277            "Volatility": get_weight_or_off('use_vol', 'vol_w'),
278            "TREND": get_weight_or_off('use_trend', 'trend_w'),
279            "Volume": get_weight_or_off('use_volume', 'volume_w'),
280            "MACD": get_weight_or_off('use_macd', 'macd_w'),
281            "MA Slope": get_weight_or_off('use_ma_slope', 'ma_slope_w'),
282            "Markov": get_weight_or_off('use_markov', 'markov_w'),
283            "ADX Filter": adx_val,
284
285            "Conf. Threshold": st.session_state.confidence_slider,
286            "Large MA Period": st.session_state.ma_period,
287            "Bollinger Band Period": st.session_state.bb_period,
288            "Bollinger Band Std Dev": st.session_state.bb_std,
289            
290            "Long Entry Threshold (%)": st.session_state.long_entry,
291            "Long Exit Threshold (%)": st.session_state.long_exit,
292            "Long Stop Loss (%)": st.session_state.long_sl,
293            "Long Delay (Days)": st.session_state.long_delay,
294            
295            "Short Entry Threshold (%)": st.session_state.short_entry,
296            "Short Exit Threshold (%)": st.session_state.short_exit,
297            "Short Stop Loss (%)": st.session_state.short_sl,
298            "Short Delay (Days)": st.session_state.short_delay,
299            
300            "Z_Avg_Profit": stats.get("Z_Avg_Profit", 0.0) * 100.0,
301            "Z_Num_Trades": stats.get("Z_Num_Trades", 0),
302            "Z_WL_Ratio": stats.get("Z_WL_Ratio", 0.0)
303        }
304        
305        current_setups = st.session_state.get("user_setups_data", [])
306        non_empty_setups = [s for s in current_setups if not is_row_blank(s)]
307        default_row_template = {k:v for k,v in load_user_setups()[0].items()}
308        non_empty_setups.append(new_setup)
309        while len(non_empty_setups) < 20: non_empty_setups.append(default_row_template.copy())
310            
311        final_setups = non_empty_setups[:20]
312        save_user_setups(final_setups)
313        
314        # Refresh State
315        processed_setups = []
316        for s in final_setups:
317            processed_setups.append(s.copy()) # Simple copy is enough now
318        st.session_state["user_setups_data"] = processed_setups
319        
320        st.toast("✅ Setup saved!", icon="✅")
321        st.session_state.run_user_advisor_setup = True
322        st.rerun()
323
324    except Exception as e:
325        st.error(f"Could not save setup: {e}")
326
327def save_user_setups(setups_list):
328    """Saves user-defined advisor setups to a JSON file."""
329    try:
330        # Ensure we only save 20
331        with open(USER_SETUPS_FILE, 'w', encoding='utf-8') as f:
332            json.dump(setups_list[:20], f, indent=4) # <-- CHANGED TO 20
333        st.success("User-defined setups saved!")
334    except Exception as e:
335        st.error(f"Error saving user setups: {e}")
336
337def save_markov_setup(setup_dict):
338    """Saves the best Markov setup to a JSON file."""
339    try:
340        with open(MARKOV_SETUP_FILE, 'w', encoding='utf-8') as f:
341            json.dump(setup_dict, f, indent=4)
342        st.sidebar.success("Best Markov setup saved as default!")
343    except Exception as e:
344        st.sidebar.error(f"Error saving Markov setup: {e}")
345
346def load_markov_setup():
347    """Loads the best Markov setup from a JSON file."""
348    if os.path.exists(MARKOV_SETUP_FILE):
349        try:
350            with open(MARKOV_SETUP_FILE, 'r', encoding='utf-8') as f:
351                return json.load(f)
352        except Exception as e:
353            print(f"Error loading {MARKOV_SETUP_FILE}: {e}")
354            return None
355    return None
356
357# --- UPDATED: Helper to accept filename (Replaces the old load_completed_setups) ---
358def load_completed_setups(filename):
359    """Reads the existing CSV and returns a set of completed configurations."""
360    completed_configs = set()
361    if os.path.exists(filename):
362        try:
363            # We only need to read the config columns: Factors and Weights
364            df_completed = pd.read_csv(filename, usecols=['RSI', 'Volatility', 'TREND', 'Volume', 'MACD', 'MA Slope', 'Markov',
365                                                          'RSI W', 'Volatility W', 'TREND W', 'Volume W', 'MACD W', 'MA Slope W', 'Markov W'], 
366                                       dtype={'RSI': str, 'Volatility': str, 'TREND': str, 'Volume': str, 'MACD': str, 'MA Slope': str, 'Markov': str})
367            
368            for index, row in df_completed.iterrows():
369                toggles = tuple(row[['RSI', 'Volatility', 'TREND', 'Volume', 'MACD', 'MA Slope', 'Markov']].values)
370                weights = tuple(row[['RSI W', 'Volatility W', 'TREND W', 'Volume W', 'MACD W', 'MA Slope W', 'Markov W']].values)
371                completed_configs.add((toggles, weights))
372        except Exception as e:
373            print(f"Warning: Error loading CSV for checkpointing: {e}. Starting from scratch.")
374    return completed_configs
375
376# --- 1. Data Loading and Cleaning Functions ---
377@st.cache_data(ttl=300) # Added TTL to auto-refresh cache every 5 mins
378def load_all_data(folder_path):
379    if not os.path.exists(folder_path):
380        st.error(f"Folder '{folder_path}' not found.")
381        return None, None
382
383    all_files = [f for f in os.listdir(folder_path) if f.endswith('.csv')]
384    all_files.sort() # Ensure we load in chronological order (e.g. 2023, then 2024)
385    
386    if not all_files:
387        st.error("No CSV files found in the 'csv_data' folder.")
388        return None, None
389
390    df_list = []
391    error_messages = []
392
393    for file_name in all_files:
394        file_path = os.path.join(folder_path, file_name)
395        try:
396            # 1. Read CSV without parsing dates yet (load as strings to be safe)
397            df = pd.read_csv(file_path, header=0, index_col=0, encoding='utf-8')
398            
399            # 2. FORCE ISO PARSING (YYYY-MM-DD)
400            # We disable 'dayfirst' because ISO puts Year first.
401            df.index = pd.to_datetime(df.index, format='%Y-%m-%d', errors='coerce')
402            
403            # 3. Drop rows with invalid dates
404            df = df[df.index.notna()]
405
406            if df.empty:
407                error_messages.append(f"Warning: Skipped {file_name}, no valid dates found.")
408                continue
409            
410            df_list.append(df)
411        except Exception as e:
412            error_messages.append(f"Could not read {file_name}. Error: {e}")
413
414    if not df_list:
415        return None, "No data could be loaded successfully."
416
417    # 4. CONSOLIDATE
418    try:
419        master_df = pd.concat(df_list)
420        
421        # 5. DEDUPLICATE (Keep the newest version of any overlapping date)
422        # This prevents "Double Dots" if 2024-2025.csv and 2025-NOW.csv overlap
423        if master_df.index.has_duplicates:
424            master_df = master_df[~master_df.index.duplicated(keep='last')]
425
426        # 6. FORCE SORT (The Final "Zig-Zag" Killer)
427        # Ensures Jan 1st always comes before Jan 2nd
428        master_df.sort_index(inplace=True)
429
430        # 7. NUMERIC CONVERSION (From your original code)
431        # Ensures all price columns are numbers, not strings
432        for col in master_df.columns:
433            master_df[col] = pd.to_numeric(master_df[col], errors='coerce')
434
435        for msg in error_messages: st.warning(msg)
436        
437        return master_df, f"Successfully combined data from {len(df_list)} files."
438
439    except Exception as e:
440        return None, f"Critical Error merging data: {e}"
441
442def clean_data_and_report_outliers(df):
443    """
444    Cleans data using a 'Rolling Median' filter (User Defined: 1 Month Window).
445    Adapts to long-term trends while catching sudden 'Pence vs Pound' glitches.
446    """
447    outlier_report = []
448    # Identify price columns (exclude _Volume, _High, _Low)
449    price_columns = [col for col in df.columns if not any(x in str(col) for x in ['_Volume', '_High', '_Low'])]
450
451    for ticker in price_columns:
452        if ticker in df.columns:
453            # Ensure numeric
454            series = pd.to_numeric(df[ticker], errors='coerce')
455            
456            # --- ROLLING MEDIAN FILTER (1 Month / 20 Days) ---
457            # center=True looks at 10 days before and 10 days after (if available) to find the 'true' level.
458            # min_periods=1 ensures it works even at the start of the file.
459            rolling_median = series.rolling(window=20, center=True, min_periods=1).median()
460            
461            # 1. Catch the 'Pence vs Pounds' Crash (e.g., 154 -> 1.56)
462            # Logic: If price is < 20% of the recent median (an 80% drop).
463            low_threshold = rolling_median * 0.20
464            
465            # 2. Catch massive data spikes (e.g., 1.56 -> 154 if logic reversed)
466            # Logic: If price is > 5x the recent median (500% spike).
467            high_threshold = rolling_median * 5.0
468            
469            bad_data_mask = (series < low_threshold) | (series > high_threshold)
470            bad_days = series[bad_data_mask].index
471            
472            if not bad_days.empty:
473                df.loc[bad_days, ticker] = np.nan
474                outlier_report.append({'Ticker': ticker, 'Type': 'Rolling Filter', 'Count': len(bad_days)})
475    
476    return df, outlier_report
477
478# --- 2. Custom Backtesting Engine ---
479
480# --- [UPDATED] 9-Factor Version (with MFI & SuperTrend) ---
481def calculate_confidence_score(df, primary_driver,
482                                use_rsi, use_volatility, use_trend, use_volume, use_macd, use_ma_slope, use_markov, 
483                                use_mfi, use_supertrend, 
484                                rsi_w, vol_w, trend_w, vol_w_val, macd_w, ma_slope_w, markov_w, 
485                                mfi_w, supertrend_w, 
486                                bband_params,
487                                best_markov_setup=None):
488
489    long_score = pd.Series(0.0, index=df.index)
490    short_score = pd.Series(0.0, index=df.index)
491
492    # --- Pre-calculate Markov State if needed ---
493    if use_markov and best_markov_setup and 'RunUp_State' not in df.columns:
494        run_up_period = best_markov_setup.get('Run-Up Period', 10)
495        df['RunUp_Return'] = df['Close'].pct_change(periods=run_up_period)
496        df['RunUp_State'] = df['RunUp_Return'].apply(lambda x: 'Up' if x > 0 else 'Down')
497
498    # --- BBand Factor ---
499    if primary_driver != 'Bollinger Bands' and 'bband_lower' in df.columns:
500        bb_weight = 1.0 
501        long_entry_pct = bband_params.get('long_entry_threshold_pct', 0.0)
502        short_entry_pct = bband_params.get('short_entry_threshold_pct', 0.0)
503        long_bb_trigger_price = df['bband_lower'] * (1 - long_entry_pct)
504        short_bb_trigger_price = df['bband_upper'] * (1 + short_entry_pct)
505        long_score_range = (df['large_ma'] - long_bb_trigger_price).replace(0, np.nan)
506        short_score_range = (short_bb_trigger_price - df['large_ma']).replace(0, np.nan)
507        long_score += ((df['large_ma'] - df['Close']) / long_score_range).clip(0, 1).fillna(0) * bb_weight
508        short_score += ((df['Close'] - df['large_ma']) / short_score_range).clip(0, 1).fillna(0) * bb_weight
509
510    # Factor 1: RSI
511    if primary_driver != 'RSI Crossover' and use_rsi and 'RSI' in df.columns:
512        long_score += ((30 - df['RSI']) / 30).clip(0, 1).fillna(0) * rsi_w
513        short_score += ((df['RSI'] - 70) / 30).clip(0, 1).fillna(0) * rsi_w
514
515    # Factor 2: Volatility
516    if use_volatility and 'Volatility_p' in df.columns:
517        vol_signal = (df['Volatility_p'] > 0.025).astype(float) * vol_w
518        long_score += vol_signal; short_score += vol_signal
519
520    # Factor 3: Trend
521    if use_trend and 'SMA_200' in df.columns and 'Close' in df.columns:
522        valid_sma = (df['SMA_200'] != 0) & df['SMA_200'].notna()
523        pct_dist = pd.Series(0.0, index=df.index)
524        pct_dist.loc[valid_sma] = df.loc[valid_sma].apply(lambda row: (row['Close'] - row['SMA_200']) / row['SMA_200'] if row['SMA_200'] != 0 else 0, axis=1)
525        long_score += (pct_dist / 0.10).clip(0, 1).fillna(0) * trend_w
526        short_score += (-pct_dist / 0.10).clip(0, 1).fillna(0) * trend_w
527
528    # Factor 4: Volume
529    if use_volume and 'Volume_Ratio' in df.columns:
530        vol_spike_signal = ((df['Volume_Ratio'] - 1.75) / 2.25).clip(0, 1).fillna(0) * vol_w_val
531        long_score += vol_spike_signal; short_score += vol_spike_signal
532
533    # Factor 5: MACD
534    if primary_driver != 'MACD Crossover' and use_macd and 'MACD_line' in df.columns:
535        macd_cross_long = (df['MACD_line'].shift(1) < df['MACD_signal'].shift(1)) & (df['MACD_line'] >= df['MACD_signal'])
536        macd_cross_short = (df['MACD_line'].shift(1) > df['MACD_signal'].shift(1)) & (df['MACD_line'] <= df['MACD_signal'])
537        long_score += macd_cross_long.astype(float) * macd_w * 0.6
538        short_score += macd_cross_short.astype(float) * macd_w * 0.6
539        long_score += (df['MACD_hist'] > 0).astype(float) * macd_w * 0.4
540        short_score += (df['MACD_hist'] < 0).astype(float) * macd_w * 0.4
541
542    # Factor 6: MA Slope
543    if primary_driver != 'MA Slope' and use_ma_slope and 'ma_slope' in df.columns:
544        long_score += (df['ma_slope'] > 0).astype(float) * ma_slope_w
545        short_score += (df['ma_slope'] < 0).astype(float) * ma_slope_w
546
547    # Factor 7: Markov State
548    if primary_driver != 'Markov State' and use_markov and best_markov_setup and 'RunUp_State' in df.columns:
549        strategy = best_markov_setup.get('Strategy')
550        if strategy == 'Down -> Up': long_score += (df['RunUp_State'] == 'Down').astype(float) * markov_w
551        elif strategy == 'Up -> Up': long_score += (df['RunUp_State'] == 'Up').astype(float) * markov_w
552        elif strategy == 'Up -> Down': short_score += (df['RunUp_State'] == 'Up').astype(float) * markov_w
553        elif strategy == 'Down -> Down': short_score += (df['RunUp_State'] == 'Down').astype(float) * markov_w
554
555    # Factor 8: MFI (Money Flow Index) [IMPROVED]
556    if use_mfi and 'MFI' in df.columns:
557        # Binary Logic: If MFI < 20 (Oversold), give full weight. 
558        # This fixes the "tiny score" issue.
559        long_score += (df['MFI'] < 25).astype(float) * mfi_w
560        short_score += (df['MFI'] > 75).astype(float) * mfi_w
561
562    # Factor 9: SuperTrend [IMPROVED]
563    if use_supertrend and 'SuperTrend' in df.columns:
564        # Long Score: Price > SuperTrend (Trend is Bullish)
565        long_score += (df['Close'] > df['SuperTrend']).astype(float) * supertrend_w
566        # Short Score: Price < SuperTrend (Trend is Bearish)
567        short_score += (df['Close'] < df['SuperTrend']).astype(float) * supertrend_w
568
569    return long_score.fillna(0), short_score.fillna(0)
570
571# --- FULL REPLACEMENT FOR run_backtest FUNCTION ---
572def run_backtest(data, params,
573                 use_rsi, use_volatility, use_trend, use_volume, use_macd, use_ma_slope, use_markov, use_mfi, use_supertrend, # Added MFI/ST
574                 rsi_w, vol_w, trend_w, vol_w_val, macd_w, ma_slope_w, markov_w, mfi_w, supertrend_w, # Added MFI/ST weights
575                 use_adx_filter, adx_threshold, rsi_logic, 
576                 adx_period=14, 
577                 veto_setups_list=None,
578                 primary_driver='Bollinger Bands', 
579                 markov_setup=None, 
580                 exit_logic_type='Standard (Price-Based)', 
581                 exit_confidence_threshold=50,
582                 smart_trailing_stop_pct=0.05,
583                 long_score_95_percentile=None,
584                 short_score_95_percentile=None,
585                 benchmark_rank=0.95,
586                 smart_exit_atr_period=14,
587                 smart_exit_atr_multiplier=3.0,
588                 intelligent_tsl_pct=1.0,
589                 analysis_start_date=None,
590                 analysis_end_date=None,
591                 benchmark_lookback_years=None,
592                 use_rolling_benchmark=False):
593
594    df = data.copy()
595    required_cols = ['Close']
596    if 'High' not in df.columns: df['High'] = df['Close']
597    if 'Low' not in df.columns: df['Low'] = df['Close']
598    required_cols.extend(['High', 'Low'])
599    if 'Volume' in df.columns: required_cols.append('Volume')
600
601    df['Close'] = pd.to_numeric(df['Close'], errors='coerce').replace(0, np.nan)
602    df.dropna(subset=['Close'], inplace=True)
603
604    min_ma_period = params.get('large_ma_period', 50)
605    
606    min_lookback_needed = min_ma_period
607    if (primary_driver == 'Markov State' or use_markov) and markov_setup is not None:
608        min_lookback_needed = max(min_ma_period, markov_setup.get('Run-Up Period', 10))
609        
610    if len(df) < min_lookback_needed or len(df) < params.get('bband_period', 20) or len(df) < 30:
611        return 0, 0, 0.0, 0.0, None, ([], [], [], []), [], (0, 0, 0, 0), ([], []), (None, None, None, None), (0, 0, 0, 0, 0, 0)
612
613    # --- Indicator Calculations ---
614    df['large_ma'] = df['Close'].rolling(window=min_ma_period).mean().ffill()
615    df['ma_slope'] = df['large_ma'].diff(periods=3).ffill()
616
617    bband_period = params.get('bband_period', 20)
618    if len(df) >= bband_period:
619        try:
620            indicator_bb = BollingerBands(close=df['Close'], window=bband_period, window_dev=params['bband_std_dev'])
621            df['bband_lower'] = indicator_bb.bollinger_lband()
622            df['bband_upper'] = indicator_bb.bollinger_hband()
623        except Exception: df['bband_lower'], df['bband_upper'] = np.nan, np.nan
624    else: df['bband_lower'], df['bband_upper'] = np.nan, np.nan
625
626    if len(df) >= 14:
627        try: 
628            indicator_rsi = RSIIndicator(close=df['Close'], window=14)
629            df['RSI'] = indicator_rsi.rsi()
630        except Exception: df['RSI'] = np.nan
631            
632        df['Volatility_p'] = df['Close'].pct_change().rolling(window=14).std()
633        
634        try:
635            indicator_adx = ADXIndicator(high=df['High'], low=df['Low'], close=df['Close'], window=adx_period, fillna=True)
636            df['ADX'] = indicator_adx.adx().ffill()
637        except Exception: df['ADX'] = np.nan
638            
639        try:
640            atr_window = int(smart_exit_atr_period)
641            indicator_atr = AverageTrueRange(high=df['High'], low=df['Low'], close=df['Close'], window=atr_window, fillna=True)
642            df['ATR'] = indicator_atr.average_true_range()
643        except Exception: df['ATR'] = np.nan
644    else: 
645        df['RSI'], df['Volatility_p'], df['ADX'], df['ATR'] = np.nan, np.nan, np.nan, np.nan
646
647# --- [NEW] MFI & SUPERTREND CALCULATIONS (Fixed) ---
648    if len(df) >= 14:
649        # 1. MFI Calculation
650        try:
651            mfi_ind = MFIIndicator(high=df['High'], low=df['Low'], close=df['Close'], volume=df['Volume'], window=14, fillna=True)
652            df['MFI'] = mfi_ind.money_flow_index()
653        except Exception: df['MFI'] = 50.0 
654
655        # 2. Proper Recursive SuperTrend Calculation
656        try:
657            st_period = 10
658            st_multiplier = 3.0
659            
660            # Calculate ATR
661            high_low = df['High'] - df['Low']
662            high_close = np.abs(df['High'] - df['Close'].shift())
663            low_close = np.abs(df['Low'] - df['Close'].shift())
664            ranges = pd.concat([high_low, high_close, low_close], axis=1)
665            true_range = np.max(ranges, axis=1)
666            atr = true_range.rolling(st_period).mean().fillna(0)
667
668            # Basic Bands
669            hl2 = (df['High'] + df['Low']) / 2
670            basic_upper = hl2 + (st_multiplier * atr)
671            basic_lower = hl2 - (st_multiplier * atr)
672
673            # Initialize Final Bands
674            final_upper = basic_upper.copy()
675            final_lower = basic_lower.copy()
676            trend = np.zeros(len(df), dtype=int)
677            supertrend = np.zeros(len(df))
678            
679            # Recursive Loop (Accurate Logic)
680            close = df['Close'].values
681            bu = basic_upper.values
682            bl = basic_lower.values
683            fu = final_upper.values
684            fl = final_lower.values
685            
686            # 1 = Uptrend, -1 = Downtrend
687            curr_trend = 1 
688            
689            for i in range(1, len(df)):
690                # Calculate Final Upper Band
691                if bu[i] < fu[i-1] or close[i-1] > fu[i-1]:
692                    fu[i] = bu[i]
693                else:
694                    fu[i] = fu[i-1]
695                
696                # Calculate Final Lower Band
697                if bl[i] > fl[i-1] or close[i-1] < fl[i-1]:
698                    fl[i] = bl[i]
699                else:
700                    fl[i] = fl[i-1]
701                
702                # Determine Trend
703                if curr_trend == 1 and close[i] < fl[i]:
704                    curr_trend = -1
705                elif curr_trend == -1 and close[i] > fu[i]:
706                    curr_trend = 1
707                
708                trend[i] = curr_trend
709                supertrend[i] = fl[i] if curr_trend == 1 else fu[i]
710
711            df['SuperTrend'] = supertrend
712            
713        except Exception as e: 
714            # print(f"ST Error: {e}") # Debug if needed
715            df['SuperTrend'] = np.nan
716    else:
717        df['MFI'], df['SuperTrend'] = 50.0, np.nan
718
719    df['SMA_200'] = df['Close'].rolling(window=200, min_periods=1).mean()
720
721    if 'Volume' in df.columns:
722        df['Volume'] = pd.to_numeric(df['Volume'], errors='coerce').fillna(0)
723        df['Volume_MA50'] = df['Volume'].rolling(window=50, min_periods=1).mean()
724        df['Volume_Ratio'] = df.apply(lambda row: row['Volume'] / row['Volume_MA50'] if row['Volume_MA50'] > 0 else 0, axis=1)
725        df['Volume_Ratio'] = df['Volume_Ratio'].replace([np.inf, -np.inf], 0).fillna(0)
726    else: df['Volume_Ratio'] = 0.0
727
728    if len(df) >= 26:
729        indicator_macd = MACD(close=df['Close'], window_slow=26, window_fast=12, window_sign=9, fillna=True)
730        df['MACD_line'] = indicator_macd.macd().ffill()
731        df['MACD_signal'] = indicator_macd.macd_signal().ffill()
732        df['MACD_hist'] = indicator_macd.macd_diff().ffill()
733    else: df['MACD_line'], df['MACD_signal'], df['MACD_hist'] = np.nan, np.nan, np.nan
734    
735    if (primary_driver == 'Markov State' or use_markov) and markov_setup is not None:
736        run_up_period = markov_setup.get('Run-Up Period', 10)
737        df['RunUp_Return'] = df['Close'].pct_change(periods=run_up_period)
738        df['RunUp_State'] = df['RunUp_Return'].apply(lambda x: 'Up' if x > 0 else 'Down')
739    
740    bband_params_for_score = {
741        'long_entry_threshold_pct': params.get('long_entry_threshold_pct', 0.0),
742        'short_entry_threshold_pct': params.get('short_entry_threshold_pct', 0.0)
743    }
744
745    raw_long_score, raw_short_score = calculate_confidence_score(df,
746        primary_driver,
747        use_rsi, use_volatility, use_trend, use_volume, use_macd, use_ma_slope, use_markov, 
748        use_mfi, use_supertrend, # <--- NEW
749        rsi_w, vol_w, trend_w, vol_w_val, macd_w, ma_slope_w, markov_w, 
750        mfi_w, supertrend_w, # <--- NEW
751        bband_params_for_score,
752        best_markov_setup=markov_setup 
753    )
754
755    # 1. GLOBAL (STATIC) MODE - "The Crystal Ball"
756    # Used for "Elite Filtering" of open trades. 
757    if not use_rolling_benchmark:
758        if long_score_95_percentile is None:
759            long_scores_gt_zero = raw_long_score[raw_long_score > 0]
760            val = long_scores_gt_zero.quantile(benchmark_rank) if not long_scores_gt_zero.empty else 1.0
761            long_95 = pd.Series(val, index=df.index)
762        else:
763            long_95 = pd.Series(long_score_95_percentile, index=df.index)
764
765        if short_score_95_percentile is None:
766            short_scores_gt_zero = raw_short_score[raw_short_score > 0]
767            val = short_scores_gt_zero.quantile(benchmark_rank) if not short_scores_gt_zero.empty else 1.0
768            short_95 = pd.Series(val, index=df.index) 
769        else:
770            short_95 = pd.Series(short_score_95_percentile, index=df.index)
771
772    # 2. ADAPTIVE (ROLLING) MODE - "Real World"
773    # Used for realistic historical simulation.
774    else:
775        if long_score_95_percentile is None:
776            # Default to 1 year if lookback is missing
777            years = benchmark_lookback_years if (benchmark_lookback_years is not None and benchmark_lookback_years > 0) else 1
778            window_days = int(years * 252)
779            # Calculate Rolling Percentile
780            long_95 = raw_long_score.rolling(window=window_days, min_periods=50).quantile(benchmark_rank).fillna(method='bfill')
781        else:
782            long_95 = pd.Series(long_score_95_percentile, index=df.index)
783
784        if short_score_95_percentile is None:
785            years = benchmark_lookback_years if (benchmark_lookback_years is not None and benchmark_lookback_years > 0) else 1
786            window_days = int(years * 252)
787            short_95 = raw_short_score.rolling(window=window_days, min_periods=50).quantile(benchmark_rank).fillna(method='bfill')
788        else:
789            short_95 = pd.Series(short_score_95_percentile, index=df.index)
790
791    # Safety: Ensure we don't divide by zero
792    long_95 = long_95.replace(0, 1.0)
793    short_95 = short_95.replace(0, 1.0)
794    
795    # Final Calculation (Removing 'if' check to avoid Series Ambiguity Error)
796    df['long_confidence_score'] = (raw_long_score / long_95 * 100).clip(0, 100).fillna(0.0)
797    df['short_confidence_score'] = (raw_short_score / short_95 * 100).clip(0, 100).fillna(0.0)
798
799    apply_veto = bool(veto_setups_list)
800    if apply_veto:
801        df['any_long_veto_trigger'] = False; df['any_short_veto_trigger'] = False
802        for veto_setup in veto_setups_list:
803            veto_threshold = veto_setup.get('Conf. Threshold', 0)
804            veto_rsi_on = veto_setup.get('RSI') == 'On'; veto_vol_on = veto_setup.get('Volatility') == 'On'
805            veto_trend_on = veto_setup.get('TREND') == 'On'; veto_volume_on = veto_setup.get('Volume') == 'On'
806            veto_macd_on = veto_setup.get('MACD', 'Off') == 'On'; veto_ma_slope_on = veto_setup.get('MA Slope', 'Off') == 'On'
807            veto_markov_on = False 
808            
809            veto_long_raw, veto_short_raw = calculate_confidence_score(df, 
810                'Bollinger Bands', 
811                veto_rsi_on, veto_vol_on, veto_trend_on, veto_volume_on, veto_macd_on, veto_ma_slope_on, veto_markov_on,
812                1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 
813                bband_params_for_score,
814                best_markov_setup=markov_setup
815            )
816            veto_long_norm = (veto_long_raw / 3.0) * 100 
817            
818            current_long_veto_trigger = (veto_long_norm >= veto_threshold); current_short_veto_trigger = (veto_long_norm >= veto_threshold)
819            df['any_long_veto_trigger'] |= current_long_veto_trigger; df['any_short_veto_trigger'] |= current_long_veto_trigger
820
821    # --- Entry Trigger Logic ---
822    bb_long_signal = df['Close'] < (df['bband_lower'] * (1 - params['long_entry_threshold_pct']))
823    bb_short_signal = df['Close'] > (df['bband_upper'] * (1 + params['short_entry_threshold_pct']))
824
825    if rsi_logic == "Crossover":
826        rsi_long_signal = (df['RSI'].shift(1) < 30) & (df['RSI'] >= 30) & df['RSI'].notna()
827        rsi_short_signal = (df['RSI'].shift(1) > 70) & (df['RSI'] <= 70) & df['RSI'].notna()
828    else: # Level
829        rsi_long_signal = (df['RSI'] <= 30) & df['RSI'].notna()
830        rsi_short_signal = (df['RSI'] >= 70) & df['RSI'].notna()
831
832    macd_long_signal = (df['MACD_line'].shift(1) < df['MACD_signal'].shift(1)) & (df['MACD_line'] >= df['MACD_signal'])
833    macd_short_signal = (df['MACD_line'].shift(1) > df['MACD_signal'].shift(1)) & (df['MACD_line'] <= df['MACD_signal'])
834    ma_slope_long_signal = (df['ma_slope'].shift(1) <= 0) & (df['ma_slope'] > 0)
835    ma_slope_short_signal = (df['ma_slope'].shift(1) >= 0) & (df['ma_slope'] < 0)
836
837    if primary_driver == 'RSI Crossover':
838        base_long_trigger = rsi_long_signal; base_short_trigger = rsi_short_signal
839    elif primary_driver == 'MACD Crossover':
840        base_long_trigger = macd_long_signal; base_short_trigger = macd_short_signal
841    elif primary_driver == 'MA Slope':
842        base_long_trigger = ma_slope_long_signal; base_short_trigger = ma_slope_short_signal
843    elif primary_driver == 'Markov State' and markov_setup is not None:
844        strategy = markov_setup.get('Strategy')
845        if strategy == 'Down -> Up':
846            base_long_trigger = (df['RunUp_State'] == 'Down'); base_short_trigger = pd.Series(False, index=df.index) 
847        elif strategy == 'Up -> Up':
848            base_long_trigger = (df['RunUp_State'] == 'Up'); base_short_trigger = pd.Series(False, index=df.index) 
849        elif strategy == 'Up -> Down':
850            base_long_trigger = pd.Series(False, index=df.index); base_short_trigger = (df['RunUp_State'] == 'Up')
851        elif strategy == 'Down -> Down':
852            base_long_trigger = pd.Series(False, index=df.index); base_short_trigger = (df['RunUp_State'] == 'Down')
853        else: 
854            base_long_trigger = pd.Series(False, index=df.index); base_short_trigger = pd.Series(False, index=df.index)
855    else: 
856        base_long_trigger = bb_long_signal; base_short_trigger = bb_short_signal
857
858    if use_adx_filter and 'ADX' in df.columns and df['ADX'].notna().any():
859        adx_allows_entry = (df['ADX'] < adx_threshold).fillna(False)
860    else: adx_allows_entry = True
861
862    ma_is_valid = pd.notna(df['large_ma']) 
863    
864    # [SURGICAL PATCH START] --------------------------------------------------
865    # Check if ALL confidence indicators are disabled
866    # CORRECTED: Added use_mfi and use_supertrend to this list so the score isn't ignored when they are On.
867    all_indicators_off = not any([
868        use_rsi, use_volatility, use_trend, use_volume, use_macd, use_ma_slope, use_markov,
869        use_mfi, use_supertrend 
870    ])
871
872    # If indicators are OFF, we bypass the confidence check (allow raw signal)
873    # Otherwise, we enforce the confidence threshold as usual
874    long_entry_trigger = base_long_trigger & adx_allows_entry & ((df['long_confidence_score'] >= params['confidence_threshold']) | all_indicators_off) & ma_is_valid
875    short_entry_trigger = base_short_trigger & adx_allows_entry & ((df['short_confidence_score'] >= params['confidence_threshold']) | all_indicators_off) & ma_is_valid
876    # [SURGICAL PATCH END] ----------------------------------------------------
877
878    # [FIX] Filter entries by BOTH Start and End date
879    if analysis_start_date is not None:
880        start_mask = df.index >= pd.Timestamp(analysis_start_date)
881        long_entry_trigger &= start_mask
882        short_entry_trigger &= start_mask
883
884    if analysis_end_date is not None:
885        end_mask = df.index <= pd.Timestamp(analysis_end_date)
886        long_entry_trigger &= end_mask
887        short_entry_trigger &= end_mask
888
889    if apply_veto:
890        long_entry_trigger &= ~df['any_long_veto_trigger']
891        short_entry_trigger &= ~df['any_short_veto_trigger']
892
893    potential_long_price_exit = (df['Close'] >= (df['large_ma'] * (1 + params['long_exit_ma_threshold_pct']))) | (df['Close'] >= df['bband_upper'])
894    potential_short_price_exit = (df['Close'] <= (df['large_ma'] * (1 - params['short_exit_ma_threshold_pct']))) | (df['Close'] <= df['bband_lower'])
895
896    long_entry_prices = df['Close'].where(long_entry_trigger).ffill()
897    short_entry_prices = df['Close'].where(short_entry_trigger).ffill()
898    
899    if exit_logic_type == 'Intelligent (ADX/MACD/ATR)':
900        adx_rising = (df['ADX'] > df['ADX'].shift(1)).fillna(False)
901        adx_strong = (df['ADX'] > adx_threshold)
902        macd_bullish = (df['MACD_line'] > df['MACD_signal'])
903        stay_in_long_trade = (adx_rising | adx_strong) & macd_bullish
904        base_long_exit_trigger = potential_long_price_exit & (~stay_in_long_trade)
905
906        macd_bearish = (df['MACD_line'] < df['MACD_signal'])
907        stay_in_short_trade = (adx_rising | adx_strong) & macd_bearish
908        base_short_exit_trigger = potential_short_price_exit & (~stay_in_short_trade)
909    else: 
910        long_is_in_profit = (df['Close'] > long_entry_prices).fillna(False) 
911        short_is_in_profit = (df['Close'] < short_entry_prices).fillna(False)
912        base_long_exit_trigger = potential_long_price_exit & long_is_in_profit
913        base_short_exit_trigger = potential_short_price_exit & short_is_in_profit
914    
915    df['long_signal'] = np.nan; df.loc[long_entry_trigger, 'long_signal'] = 1; df.loc[base_long_exit_trigger, 'long_signal'] = 0 
916    df['short_signal'] = np.nan; df.loc[short_entry_trigger, 'short_signal'] = -1; df.loc[base_short_exit_trigger, 'short_signal'] = 0 
917
918    if primary_driver == 'Markov State' and markov_setup is not None:
919        limit_long = markov_setup.get('Future Period', 5); limit_short = markov_setup.get('Future Period', 5)
920    else:
921        limit_long = params.get('max_long_duration', 120); limit_short = params.get('max_short_duration', 120)
922        
923    temp_long_pos = df['long_signal'].ffill().fillna(0)
924    temp_short_pos = df['short_signal'].ffill().fillna(0)
925    
926    long_groups = (~(temp_long_pos == 1)).cumsum(); df['days_in_long_trade'] = df.groupby(long_groups).cumcount(); df.loc[temp_long_pos == 0, 'days_in_long_trade'] = 0
927    short_groups = (~(temp_short_pos == -1)).cumsum(); df['days_in_short_trade'] = df.groupby(short_groups).cumcount(); df.loc[temp_short_pos == 0, 'days_in_short_trade'] = 0
928    
929    long_time_exit_trigger = (df['days_in_long_trade'] > limit_long) & (temp_long_pos == 1)
930    short_time_exit_trigger = (df['days_in_short_trade'] > limit_short) & (temp_short_pos == -1)
931    
932    df.loc[long_time_exit_trigger, 'long_signal'] = 0; df.loc[short_time_exit_trigger, 'short_signal'] = 0
933
934    df['long_entry_price_static'] = df['Close'].where(df['long_signal'].shift(1) == 0).ffill().bfill()
935    df['short_entry_price_static'] = df['Close'].where(df['short_signal'].shift(1) == 0).ffill().bfill()
936
937    # [SURGICAL UPDATE START] --- Catcher Offset Logic ---
938    # We retrieve the offset percentage from params (default to 0.0 if missing)
939    catcher_offset = params.get('catcher_stop_pct', 0.0)
940    
941    # Calculate the Adjusted Floor/Ceiling
942    # Positive Offset = Higher Floor (Profit for Long, Profit for Short)
943    # Negative Offset = Lower Floor (Loss allowance for Long, Loss allowance for Short)
944    long_breakeven_floor = df['long_entry_price_static'] * (1 + catcher_offset)
945    short_breakeven_floor = df['short_entry_price_static'] * (1 - catcher_offset) 
946    # [SURGICAL UPDATE END] -----------------------------
947    
948    use_ma_floor_filter = params.get('use_ma_floor_filter', False) 
949    
950    standard_tsl_pct_long = params.get('long_trailing_stop_loss_pct', 0)
951    standard_tsl_pct_short = params.get('short_trailing_stop_loss_pct', 0)
952    
953    intelligent_tsl_pct_long = intelligent_tsl_pct if exit_logic_type == 'Intelligent (ADX/MACD/ATR)' else 0.0
954    intelligent_tsl_pct_short = intelligent_tsl_pct if exit_logic_type == 'Intelligent (ADX/MACD/ATR)' else 0.0
955    
956    long_tsl_exit = pd.Series(False, index=df.index); short_tsl_exit = pd.Series(False, index=df.index)
957
958    if primary_driver != 'Markov State':
959        # --- LONG TSL ---
960        has_hit_target = potential_long_price_exit
961        # Determine which TSL percentage to use (Standard vs Intelligent)
962        tsl_to_use_long = np.where(has_hit_target, intelligent_tsl_pct_long, standard_tsl_pct_long) if use_ma_floor_filter else (intelligent_tsl_pct_long if exit_logic_type == 'Intelligent (ADX/MACD/ATR)' else standard_tsl_pct_long)
963        if not isinstance(tsl_to_use_long, pd.Series): tsl_to_use_long = pd.Series(tsl_to_use_long, index=df.index)
964
965        # Only run calculation if TSL is active (> 0)
966        if (tsl_to_use_long > 0).any():
967            in_long_trade = (df['long_signal'].ffill().fillna(0) == 1)
968            long_high_water_mark = df['High'].where(in_long_trade).groupby((~in_long_trade).cumsum()).cummax()
969            tsl_from_hwm = long_high_water_mark * (1 - tsl_to_use_long)
970            
971            # [FIX] Apply Catcher floor ONLY if target has been hit (Persistent during trade)
972            if use_ma_floor_filter:
973                trade_groups = (~in_long_trade).cumsum()
974                # [FIX] Cast to float before cummax to avoid Object dtype error
975                target_hit_persistent = has_hit_target.where(in_long_trade).astype(float).groupby(trade_groups).cummax().fillna(0).astype(bool)
976                long_tsl_price = np.where(target_hit_persistent, np.maximum(tsl_from_hwm, long_breakeven_floor), tsl_from_hwm)
977            else:
978                long_tsl_price = tsl_from_hwm
979                
980            long_tsl_exit = in_long_trade & (df['Close'] < long_tsl_price)
981            df.loc[long_tsl_exit, 'long_signal'] = 0
982
983        # --- SHORT TSL ---
984        has_hit_target = potential_short_price_exit
985        # Determine which TSL percentage to use (Standard vs Intelligent)
986        tsl_to_use_short = np.where(has_hit_target, intelligent_tsl_pct_short, standard_tsl_pct_short) if use_ma_floor_filter else (intelligent_tsl_pct_short if exit_logic_type == 'Intelligent (ADX/MACD/ATR)' else standard_tsl_pct_short)
987        if not isinstance(tsl_to_use_short, pd.Series): tsl_to_use_short = pd.Series(tsl_to_use_short, index=df.index)
988
989        # Only run calculation if TSL is active (> 0)
990        if (tsl_to_use_short > 0).any():
991            in_short_trade = (df['short_signal'].ffill().fillna(0) == -1)
992            short_low_water_mark = df['Low'].where(in_short_trade).groupby((~in_short_trade).cumsum()).cummin()
993            tsl_from_lwm = short_low_water_mark * (1 + tsl_to_use_short)
994            
995            # [FIX] Apply Catcher floor ONLY if target has been hit (Persistent during trade)
996            if use_ma_floor_filter:
997                trade_groups = (~in_short_trade).cumsum()
998                # [FIX] Cast to float before cummax to avoid Object dtype error
999                target_hit_persistent = has_hit_target.where(in_short_trade).astype(float).groupby(trade_groups).cummax().fillna(0).astype(bool)
1000                short_tsl_price = np.where(target_hit_persistent, np.minimum(tsl_from_lwm, short_breakeven_floor), tsl_from_lwm)
1001            else:
1002                short_tsl_price = tsl_from_lwm
1003
1004            short_tsl_exit = in_short_trade & (df['Close'] > short_tsl_price)
1005            df.loc[short_tsl_exit, 'short_signal'] = 0
1006   
1007    df['long_position'] = df['long_signal'].ffill().fillna(0)
1008    df['short_position'] = df['short_signal'].ffill().fillna(0)
1009    
1010    if params['long_delay_days'] > 0: df['long_position'] = df['long_position'].shift(params['long_delay_days']).fillna(0)
1011    if params['short_delay_days'] > 0: df['short_position'] = df['short_position'].shift(params['short_delay_days']).fillna(0)
1012    
1013    df['daily_return'] = df['Close'].pct_change()
1014    df['long_strategy_return'] = df['long_position'].shift(1) * df['daily_return']
1015    df['short_strategy_return'] = df['short_position'].shift(1) * df['daily_return']
1016    final_long_pnl = (1 + df['long_strategy_return'].fillna(0)).prod(skipna=True) - 1
1017    final_short_pnl = (1 + df['short_strategy_return'].fillna(0)).prod(skipna=True) - 1
1018
1019    long_entries = df[(df['long_position'] == 1) & (df['long_position'].shift(1) == 0)]
1020    long_exits = df[(df['long_position'] == 0) & (df['long_position'].shift(1) == 1)]
1021    short_entries = df[(df['short_position'] == -1) & (df['short_position'].shift(1) == 0)]
1022    short_exits = df[(df['short_position'] == 0) & (df['short_position'].shift(1) == -1)]
1023    
1024    if not df.empty:
1025        end_date = df.index.max(); 
1026    else: end_date = pd.NaT; 
1027    
1028    # --- CHANGED: COLLECT ALL HISTORY (Removed "30 day" filter) ---
1029    all_historical_trades = []
1030
1031    long_trade_profits, long_durations, first_long_entry_date, last_long_exit_date = [], [], None, None
1032    short_trade_profits, short_durations, first_short_entry_date, last_short_exit_date = [], [], None, None
1033    long_profit_take_count, long_tsl_count, long_time_exit_count = 0, 0, 0 
1034    short_profit_take_count, short_tsl_count, short_time_exit_count = 0, 0, 0 
1035    df_indices = pd.Series(range(len(df)), index=df.index)
1036    
1037    for idx, row in long_entries.iterrows():
1038        if first_long_entry_date is None: first_long_entry_date = idx
1039        future_exits = long_exits[long_exits.index > idx]
1040        if not future_exits.empty:
1041            exit_row = future_exits.iloc[0]; last_long_exit_date = exit_row.name
1042            exit_date = exit_row.name
1043            is_tsl = long_tsl_exit.loc[exit_row.name]; is_time = long_time_exit_trigger.loc[exit_row.name]
1044            
1045            if is_tsl: long_tsl_count += 1
1046            elif is_time: long_time_exit_count += 1
1047            else: long_profit_take_count += 1 
1048
1049            profit = (exit_row['Close'] / row['Close']) - 1 if pd.notna(exit_row['Close']) and pd.notna(row['Close']) and row['Close'] != 0 else np.nan
1050            long_trade_profits.append(profit)
1051
1052            # APPEND ALL TRADES (No Date Filter)
1053            all_historical_trades.append({'Side': 'Long', 'Date Open': idx, 'Date Closed': exit_date, 'Start Confidence': row.get('long_confidence_score', np.nan), 'Final % P/L': profit, 'Status': 'Closed', 'Exit Reason': 'TSL' if is_tsl else ('Time' if is_time else 'Profit')})
1054            
1055            try: long_durations.append(df_indices.loc[exit_row.name] - df_indices.loc[idx])
1056            except KeyError: long_durations.append(np.nan)
1057    avg_long_profit_per_trade = np.nanmean(long_trade_profits) if long_trade_profits else 0.0
1058
1059    for idx, row in short_entries.iterrows():
1060        if first_short_entry_date is None: first_short_entry_date = idx
1061        future_exits = short_exits[short_exits.index > idx]
1062        if not future_exits.empty:
1063            exit_row = future_exits.iloc[0]; last_short_exit_date = exit_row.name
1064            exit_date = exit_row.name
1065            is_tsl = short_tsl_exit.loc[exit_row.name]; is_time = short_time_exit_trigger.loc[exit_row.name]
1066            
1067            if is_tsl: short_tsl_count += 1
1068            elif is_time: short_time_exit_count += 1
1069            else: short_profit_take_count += 1 
1070            
1071            profit = ((exit_row['Close'] / row['Close']) - 1) * -1 if pd.notna(exit_row['Close']) and pd.notna(row['Close']) and row['Close'] != 0 else np.nan
1072            short_trade_profits.append(profit)
1073
1074            # APPEND ALL TRADES (No Date Filter)
1075            all_historical_trades.append({'Side': 'Short', 'Date Open': idx, 'Date Closed': exit_date, 'Start Confidence': row.get('short_confidence_score', np.nan), 'Final % P/L': profit, 'Status': 'Closed', 'Exit Reason': 'TSL' if is_tsl else ('Time' if is_time else 'Profit')})
1076            
1077            try: short_durations.append(df_indices.loc[exit_row.name] - df_indices.loc[idx])
1078            except KeyError: short_durations.append(np.nan)
1079    avg_short_profit_per_trade = np.nanmean(short_trade_profits) if short_trade_profits else 0.0
1080
1081    long_wins = sum(1 for p in long_trade_profits if pd.notna(p) and p > 0); long_losses = sum(1 for p in long_trade_profits if pd.notna(p) and p < 0)
1082    short_wins = sum(1 for p in short_trade_profits if pd.notna(p) and p > 0); short_losses = sum(1 for p in short_trade_profits if pd.notna(p) and p < 0)
1083
1084    long_trades_log = [{'date': idx, 'price': row['Close'], 'confidence': row.get('long_confidence_score', np.nan)} for idx, row in long_entries.iterrows()]
1085    short_trades_log = [{'date': idx, 'price': row['Close'], 'confidence': row.get('short_confidence_score', np.nan)} for idx, row in short_entries.iterrows()]
1086    
1087    # --- ROBUST OPEN TRADE CAPTURE ---
1088    open_trades = []
1089    if not df.empty and 'Close' in df.columns:
1090        last_close = df['Close'].iloc[-1] 
1091        
1092        # 1. Check Long
1093        if df['long_position'].iloc[-1] == 1:
1094            if not long_entries.empty:
1095                last_entry_time = long_entries.index[-1]
1096                last_entry = long_entries.loc[last_entry_time]
1097                entry_price = last_entry['Close']
1098                entry_conf = last_entry.get('long_confidence_score', np.nan)
1099            else:
1100                last_entry_time = pd.NaT
1101                entry_price = df['long_entry_price_static'].iloc[-1]
1102                entry_conf = np.nan
1103            
1104            if pd.notna(last_close) and pd.notna(entry_price) and entry_price != 0:
1105                pnl = (last_close / entry_price) - 1
1106                open_trades.append({
1107                    'Side': 'Long', 
1108                    'Date Open': last_entry_time, 
1109                    'Date Closed': pd.NaT, 
1110                    'Start Confidence': entry_conf, 
1111                    'Final % P/L': pnl, 
1112                    'Status': 'Open', 
1113                    'Exit Reason': 'N/A (Open)'
1114                })
1115
1116        # 2. Check Short
1117        if df['short_position'].iloc[-1] == -1:
1118            if not short_entries.empty:
1119                last_entry_time = short_entries.index[-1]
1120                last_entry = short_entries.loc[last_entry_time]
1121                entry_price = last_entry['Close']
1122                entry_conf = last_entry.get('short_confidence_score', np.nan)
1123            else:
1124                last_entry_time = pd.NaT
1125                entry_price = df['short_entry_price_static'].iloc[-1]
1126                entry_conf = np.nan
1127            
1128            if pd.notna(last_close) and pd.notna(entry_price) and entry_price != 0:
1129                pnl = ((last_close / entry_price) - 1) * -1
1130                open_trades.append({
1131                    'Side': 'Short', 
1132                    'Date Open': last_entry_time, 
1133                    'Date Closed': pd.NaT, 
1134                    'Start Confidence': entry_conf, 
1135                    'Final % P/L': pnl, 
1136                    'Status': 'Open', 
1137                    'Exit Reason': 'N/A (Open)'
1138                })
1139    # ---------------------------------
1140
1141    # Combine: [All Historical Closed] + [Current Open]
1142    open_trades.extend(all_historical_trades)
1143    df.sort_index(inplace=True)
1144
1145    trade_dates = (first_long_entry_date, last_long_exit_date, first_short_entry_date, last_short_exit_date)
1146    long_durations = [d for d in long_durations if pd.notna(d)]; short_durations = [d for d in short_durations if pd.notna(d)]
1147    avg_long_profit = float(avg_long_profit_per_trade) if pd.notna(avg_long_profit_per_trade) else 0.0
1148    avg_short_profit = float(avg_short_profit_per_trade) if pd.notna(avg_short_profit_per_trade) else 0.0
1149    final_long_pnl_float = float(final_long_pnl) if pd.notna(final_long_pnl) else 0.0
1150    final_short_pnl_float = float(final_short_pnl) if pd.notna(final_short_pnl) else 0.0
1151    final_trade_logs = (long_trades_log, long_exits.index, short_trades_log, short_exits.index)
1152    exit_breakdown = (long_profit_take_count, long_tsl_count, long_time_exit_count, short_profit_take_count, short_tsl_count, short_time_exit_count)
1153
1154    return final_long_pnl_float, final_short_pnl_float, avg_long_profit, avg_short_profit, df, final_trade_logs, open_trades, (long_wins, long_losses, short_wins, short_losses), (long_durations, short_durations), trade_dates, exit_breakdown
1155
1156# --- 3. Charting and Display Functions ---
1157def generate_long_plot(df, trades, ticker):
1158    fig = go.Figure()
1159    # Add Price and MA Lines
1160    fig.add_trace(go.Scatter(x=df.index, y=df['Close'], mode='lines', name='Close Price', line=dict(color='blue')))
1161    if 'large_ma' in df.columns:
1162        fig.add_trace(go.Scatter(x=df.index, y=df['large_ma'], mode='lines', name='Large MA', line=dict(color='orange', dash='dash')))
1163    # Add Bollinger Bands
1164    if 'bband_upper' in df.columns and 'bband_lower' in df.columns:
1165        fig.add_trace(go.Scatter(x=df.index, y=df['bband_upper'], mode='lines', name='Upper Band', line=dict(color='gray', width=0.5)))
1166        fig.add_trace(go.Scatter(x=df.index, y=df['bband_lower'], mode='lines', name='Lower Band', line=dict(color='gray', width=0.5), fill='tonexty', fillcolor='rgba(211,211,211,0.2)'))
1167
1168    # Unpack Trades Tuple: (Long Entries Log, Long Exits Index, Short Entries Log, Short Exits Index)
1169    long_entries_log, long_exits, _, _ = trades
1170    
1171    # 1. Plot Long Entries
1172    if long_entries_log:
1173        dates = [t['date'] for t in long_entries_log]
1174        prices = [t['price'] for t in long_entries_log]
1175        scores = [f"Confidence: {t['confidence']:.0f}%" for t in long_entries_log]
1176        
1177        # Filter out entries that fall outside the dataframe's date range (just in case)
1178        valid_points = [(d, p, s) for d, p, s in zip(dates, prices, scores) if d in df.index]
1179        if valid_points:
1180            v_dates, v_prices, v_scores = zip(*valid_points)
1181            fig.add_trace(go.Scatter(x=v_dates, y=v_prices, mode='markers', name='Long Entry', 
1182                                     marker=dict(color='green', symbol='triangle-up', size=12), 
1183                                     text=v_scores, hoverinfo='text'))
1184
1185    # 2. Plot Long Exits
1186    # Ensure long_exits is not empty and contains valid dates found in df
1187    if not long_exits.empty:
1188        valid_exits = [date for date in long_exits if date in df.index]
1189        if valid_exits:
1190            exit_prices = df.loc[valid_exits, 'Close']
1191            fig.add_trace(go.Scatter(x=exit_prices.index, y=exit_prices, mode='markers', name='Long Exit', 
1192                                     marker=dict(color='darkgreen', symbol='x', size=8)))
1193
1194    fig.update_layout(title=f'Long Trades for {ticker}', xaxis_title='Date', yaxis_title='Price', legend_title="Indicator")
1195    return fig
1196
1197def generate_short_plot(df, trades, ticker):
1198    fig = go.Figure()
1199    # Add Price and MA Lines
1200    fig.add_trace(go.Scatter(x=df.index, y=df['Close'], mode='lines', name='Close Price', line=dict(color='blue')))

Showing the first 1,200 of 5006 lines. Download the file for the rest.