commit 6b677b8259af7d47d26f01558b86edd193cb9642 Author: unknown Date: Sun Apr 12 18:25:46 2026 +1000 Initial commit - MT5 Tools dashboard diff --git a/README.md b/README.md new file mode 100644 index 0000000..fbef15f --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# MT5 Tools + +Standalone Streamlit dashboard for MT5 trade analysis and comparison. + +## Setup + +```powershell +cd C:\Users\pc\MT5Tools +pip install -r requirements.txt +``` + +## Launch + +```powershell +streamlit run app.py +``` + +## Files + +``` +MT5Tools\ +├── app.py ← main Streamlit app +├── mt5_parser.py ← parsers for all 3 formats + stats +├── mt5_batch_backtest.py ← batch backtest runner (copy from ea\) +├── set_comparator.py ← EA set file comparator (copy from ea\) +├── requirements.txt +├── mt5_batch_config.json ← gitignored, created on first run +└── pages\ + ├── trade_analysis.py ← single report analysis + ├── trade_compare.py ← side-by-side comparison + └── settings.py + +``` + +## Supported Formats + +| Format | Extension | Notes | +|---|---|---| +| MT5 Account History | `.htm` / `.html` | Export from MT5 → Account History → Save as Report | +| MT5 Backtest Report | `.htm` / `.html` | Generated by batch backtest runner or manual tester | +| Quant Analyzer CSV | `.csv` | Export listOfTrades from Quant Analyzer | + +## .gitignore + +Add the following to `.gitignore`: +``` +mt5_batch_config.json +``` diff --git a/__pycache__/mt5_parser.cpython-314.pyc b/__pycache__/mt5_parser.cpython-314.pyc new file mode 100644 index 0000000..e488a62 Binary files /dev/null and b/__pycache__/mt5_parser.cpython-314.pyc differ diff --git a/__pycache__/set_comparator.cpython-314.pyc b/__pycache__/set_comparator.cpython-314.pyc new file mode 100644 index 0000000..095c6fa Binary files /dev/null and b/__pycache__/set_comparator.cpython-314.pyc differ diff --git a/__pycache__/view_set_comparator.cpython-314.pyc b/__pycache__/view_set_comparator.cpython-314.pyc new file mode 100644 index 0000000..89df84d Binary files /dev/null and b/__pycache__/view_set_comparator.cpython-314.pyc differ diff --git a/__pycache__/view_settings.cpython-314.pyc b/__pycache__/view_settings.cpython-314.pyc new file mode 100644 index 0000000..8037d13 Binary files /dev/null and b/__pycache__/view_settings.cpython-314.pyc differ diff --git a/__pycache__/view_trade_analysis.cpython-314.pyc b/__pycache__/view_trade_analysis.cpython-314.pyc new file mode 100644 index 0000000..1bd2e85 Binary files /dev/null and b/__pycache__/view_trade_analysis.cpython-314.pyc differ diff --git a/__pycache__/view_trade_compare.cpython-314.pyc b/__pycache__/view_trade_compare.cpython-314.pyc new file mode 100644 index 0000000..d6eb6be Binary files /dev/null and b/__pycache__/view_trade_compare.cpython-314.pyc differ diff --git a/app.py b/app.py new file mode 100644 index 0000000..533adf4 --- /dev/null +++ b/app.py @@ -0,0 +1,186 @@ +""" +MT5 Tools Dashboard +=================== +Streamlit app for MT5 trade analysis and comparison. + +Launch: streamlit run app.py +""" + +import streamlit as st +from streamlit_option_menu import option_menu +import importlib, sys, os + +# ── Page config ─────────────────────────────────────────────────────────────── +st.set_page_config( + page_title = "MT5 Tools", + page_icon = "📈", + layout = "wide", + initial_sidebar_state = "expanded" +) + +# ── Theme ───────────────────────────────────────────────────────────────────── +st.markdown(""" + +""", unsafe_allow_html=True) + +# ── Sidebar nav ─────────────────────────────────────────────────────────────── +with st.sidebar: + st.markdown("### 📈 MT5 Tools") + st.markdown("---") + page = option_menu( + menu_title = None, + options = ["Trade Analysis", "Trade Compare", "EA Comparator", "Settings"], + icons = ["bar-chart-line", "arrow-left-right", "sliders", "gear"], + default_index = 0, + styles = { + "container" : {"background-color": "transparent", "padding": "0"}, + "icon" : {"color": "#7c6af7", "font-size": "14px"}, + "nav-link" : { + "font-family" : "Syne, sans-serif", + "font-size" : "13px", + "font-weight" : "600", + "color" : "#aaa", + "border-radius": "6px", + "margin" : "2px 0", + }, + "nav-link-selected": { + "background-color": "rgba(124,106,247,0.15)", + "color" : "#c5beff", + "border" : "1px solid rgba(124,106,247,0.25)", + }, + } + ) + +# ── Route pages ─────────────────────────────────────────────────────────────── +if page == "Trade Analysis": + import view_trade_analysis as p + importlib.reload(p) + p.render() + +elif page == "Trade Compare": + import view_trade_compare as p + importlib.reload(p) + p.render() + +elif page == "EA Comparator": + import view_set_comparator as p + importlib.reload(p) + p.render() + +elif page == "Settings": + import view_settings as p + importlib.reload(p) + p.render() \ No newline at end of file diff --git a/mt5_batch_backtest.py b/mt5_batch_backtest.py new file mode 100644 index 0000000..b027b14 --- /dev/null +++ b/mt5_batch_backtest.py @@ -0,0 +1,669 @@ +""" +MT5 Batch Backtest Runner +========================= +Generates a MT5 tester .ini file for each .set file in a folder, +updates EA_Comment in each .set file, then launches MT5 terminal +for each backtest sequentially. + +On first run: detects MT5 installations and saves config. +Subsequent runs: loads saved config, prompts to use same or modify. + +Usage: python mt5_batch_backtest.py +""" + +import os +import sys +import glob +import subprocess +import time +import shutil +import json + +# ── MT5 Period constants ─────────────────────────────────────────────────────── +PERIOD_MAP = { + 'M1' : 'M1', + 'M5' : 'M5', + 'M15' : 'M15', + 'M30' : 'M30', + 'H1' : 'H1', + 'H4' : 'H4', + 'D' : 'Daily', + 'D1' : 'Daily', + 'DAILY': 'Daily', + 'W1' : 'Weekly', + 'MN' : 'Monthly', +} + +# ── Model labels ───────────────────────────────────────────────────────────── +MODEL_LABELS = { + '1' : 'OHLC', + '2' : 'CTRLPTS', + '4' : 'EVERYTICK', + '5' : 'EVERYTICKREAL', +} + +# ── Defaults (used if no config found) ──────────────────────────────────────── +DEFAULTS = { + 'terminal_path' : r"C:\Program Files\MetaTrader 5\terminal64.exe", + 'tester_folder' : '', + 'ea_name' : r"Market\Ultimate Breakout System.ex5", + 'from_date' : '2018.01.01', + 'to_date' : '2026.04.01', + 'model' : '1', + 'deposit' : '10000', + 'currency' : 'USD', + 'leverage' : '100', + 'optimization' : '0', + 'suffix' : '.a', +} + +CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'mt5_batch_config.json') + + +# ── Helpers ──────────────────────────────────────────────────────────────────── + +def prompt(text, default=None): + if default is not None and default != '': + val = input(f" {text} [{default}]: ").strip() + return val if val else default + else: + while True: + val = input(f" {text}: ").strip() + if val: + return val + print(" (required)") + + +def load_config(): + if os.path.isfile(CONFIG_FILE): + try: + with open(CONFIG_FILE, 'r') as f: + return json.load(f) + except: + pass + return None + + +def save_config(cfg): + try: + with open(CONFIG_FILE, 'w') as f: + json.dump(cfg, f, indent=2) + except Exception as e: + print(f" WARNING: Could not save config: {e}") + + +def find_mt5_terminals(): + """Scan MetaQuotes Terminal folder for all MT5 installations.""" + appdata = os.environ.get('APPDATA', '') + base = os.path.join(appdata, 'MetaQuotes', 'Terminal') + results = [] + if not os.path.isdir(base): + return results + for entry in os.listdir(base): + entry_path = os.path.join(base, entry) + if not os.path.isdir(entry_path): + continue + # Check for origin.txt which contains the terminal exe path + origin = os.path.join(entry_path, 'origin.txt') + tester = os.path.join(entry_path, 'Tester') + label = entry + if os.path.isfile(origin): + try: + with open(origin, 'r', encoding='utf-8', errors='replace') as f: + label = f.read().strip() or entry + except: + pass + # Skip folders that don't look like real MT5 terminals + if not os.path.isdir(os.path.join(entry_path, 'MQL5')): + continue + results.append({ + 'id' : entry, + 'label' : label, + 'tester_folder' : tester, + 'data_folder' : entry_path, + }) + return results + + +def pick_terminal(): + """Let user pick from detected MT5 terminals.""" + terminals = find_mt5_terminals() + if not terminals: + print(" No MT5 terminals found in AppData\\MetaQuotes\\Terminal\\") + print(" You will need to enter the tester folder path manually.") + return None, None + + print() + print(" Detected MT5 terminal(s):") + for i, t in enumerate(terminals, 1): + print(f" {i}) {t['label']}") + print(f" Tester: {t['tester_folder']}") + + if len(terminals) == 1: + choice = input(f"\n Select terminal [1]: ").strip() + idx = 0 + else: + while True: + choice = input(f"\n Select terminal [1-{len(terminals)}]: ").strip() + try: + idx = int(choice) - 1 + if 0 <= idx < len(terminals): + break + except: + pass + print(" Invalid selection.") + + selected = terminals[idx] + return selected['tester_folder'], selected['label'] + + +def find_ea_files(tester_folder): + """ + Scan MQL5/Experts folder for .ex5 files. + Returns list of dicts with label (display) and value (ini path). + """ + experts_dir = os.path.join(os.path.dirname(tester_folder), 'MQL5', 'Experts') + results = [] + if not os.path.isdir(experts_dir): + return results + for root, dirs, files in os.walk(experts_dir): + dirs[:] = [d for d in dirs if not d.startswith('.')] + for fn in sorted(files): + if fn.lower().endswith('.ex5'): + full_path = os.path.join(root, fn) + rel = os.path.relpath(full_path, experts_dir) + # Only include EAs in the Market subfolder + if rel.startswith('Market' + os.sep) or rel.startswith('Market/'): + results.append({'label': rel, 'value': rel}) + return results + + +def pick_ea_name(tester_folder, current=None): + """List available EAs and let user pick, or enter manually.""" + eas = find_ea_files(tester_folder) + + if not eas: + print(" No .ex5 files found in MQL5/Experts -- enter EA name manually.") + return prompt("EA Name", current or DEFAULTS['ea_name']) + + print() + print(" Available EAs:") + for i, ea in enumerate(eas, 1): + marker = ' <' if current and ea['value'] == current else '' + print(f" {i:>3}) {ea['label']}{marker}") + print(f" {len(eas)+1:>3}) Enter manually") + + while True: + default_idx = None + if current: + for i, ea in enumerate(eas, 1): + if ea['value'] == current: + default_idx = i + break + hint = f"1-{len(eas)+1}" + (f", Enter={default_idx}" if default_idx else "") + choice = input(f" Select EA [{hint}]: ").strip() + + if choice == '' and default_idx: + return eas[default_idx - 1]['value'] + try: + idx = int(choice) - 1 + if idx == len(eas): + return prompt("EA Name", current or DEFAULTS['ea_name']) + if 0 <= idx < len(eas): + return eas[idx]['value'] + except: + pass + print(" Invalid selection.") + + +def setup_config(): + """First-run setup — detect terminals and build config.""" + print() + print(" ── First Run Setup ──────────────────────────────────────") + + tester_folder, terminal_label = pick_terminal() + + if not tester_folder: + tester_folder = prompt("Tester folder path") + + terminal_path = prompt("Path to terminal64.exe", DEFAULTS['terminal_path']) + + print() + print(" ── Backtest Defaults ────────────────────────────────────") + cfg = { + 'terminal_path' : terminal_path, + 'tester_folder' : tester_folder, + 'terminal_label': terminal_label or '', + 'ea_name' : pick_ea_name(tester_folder, DEFAULTS['ea_name']), + 'from_date' : prompt("From Date (YYYY.MM.DD)", DEFAULTS['from_date']), + 'to_date' : prompt("To Date (YYYY.MM.DD)", DEFAULTS['to_date']), + 'model' : prompt("Model (1=OHLC M1, 2=Control points, 4=Every tick)", DEFAULTS['model']), + 'deposit' : prompt("Deposit", DEFAULTS['deposit']), + 'currency' : prompt("Currency", DEFAULTS['currency']), + 'leverage' : prompt("Leverage", DEFAULTS['leverage']), + 'suffix' : prompt("Instrument suffix (e.g. .a)", DEFAULTS['suffix']), + } + save_config(cfg) + print() + print(f" Config saved to: {CONFIG_FILE}") + return cfg + + +def review_config(cfg): + """Show saved config and ask to use same or modify.""" + print() + print(" ── Saved Settings ───────────────────────────────────────") + print(f" Terminal : {cfg.get('terminal_label', cfg['tester_folder'])}") + print(f" Tester : {cfg['tester_folder']}") + print(f" EA : {cfg['ea_name']}") + print(f" Dates : {cfg['from_date']} → {cfg['to_date']}") + print(f" Model : {cfg['model']} Deposit: {cfg['deposit']} {cfg['currency']} Leverage: {cfg['leverage']}") + print(f" Suffix : {cfg['suffix']}") + print() + choice = input(" Use these settings? [Y/n/reset]: ").strip().lower() + + if choice == 'reset': + os.remove(CONFIG_FILE) + print(" Config reset — re-running setup.") + return setup_config() + + if choice == 'n': + print() + print(" ── Modify Settings ──────────────────────────────────────") + redetect = input(" Re-detect MT5 terminals? [y/N]: ").strip().lower() + if redetect == 'y': + tester_folder, terminal_label = pick_terminal() + if tester_folder: + cfg['tester_folder'] = tester_folder + cfg['terminal_label'] = terminal_label or '' + + cfg['terminal_path'] = prompt("terminal64.exe path", cfg['terminal_path']) + cfg['ea_name'] = pick_ea_name(cfg['tester_folder'], cfg['ea_name']) + cfg['from_date'] = prompt("From Date", cfg['from_date']) + cfg['to_date'] = prompt("To Date", cfg['to_date']) + cfg['model'] = prompt("Model (1=OHLC M1, 2=Control points, 4=Every tick)", cfg['model']) + cfg['deposit'] = prompt("Deposit", cfg['deposit']) + cfg['currency'] = prompt("Currency", cfg['currency']) + cfg['leverage'] = prompt("Leverage", cfg['leverage']) + cfg['suffix'] = prompt("Suffix", cfg['suffix']) + save_config(cfg) + print(" Config updated.") + + return cfg + + +def detect_timeframe(filename): + name = os.path.splitext(filename)[0].upper() + for token in PERIOD_MAP: + if name.endswith('_' + token) or name.endswith('-' + token) or \ + ('_' + token + '_') in name or ('-' + token + '-') in name: + return PERIOD_MAP[token] + return None + + +def detect_instrument(filename, n_chars): + return os.path.splitext(filename)[0][:n_chars].upper() + + +def read_utf16(path): + with open(path, 'rb') as f: + raw = f.read() + if raw[:2] == b'\xff\xfe': + text = raw[2:].decode('utf-16-le') + elif raw[:2] == b'\xfe\xff': + text = raw[2:].decode('utf-16-be') + else: + text = raw.decode('utf-8', errors='replace') + return text.splitlines() + + +def write_utf16(path, lines): + text = '\r\n'.join(lines) + '\r\n' + with open(path, 'wb') as f: + f.write(b'\xff\xfe') + f.write(text.encode('utf-16-le')) + + +def update_set_file(set_path, ea_comment, lot_mode, lot_value): + lines = read_utf16(set_path) + + def update_param(lines, key, new_val): + for i, line in enumerate(lines): + if line.strip().startswith(key + '='): + parts = line.strip().split('||') + parts[0] = f'{key}={new_val}' + lines[i] = '||'.join(parts) + return True + return False + + # Always update EA_Comment + updated = False + for i, line in enumerate(lines): + if line.strip().startswith('EA_Comment='): + lines[i] = f'EA_Comment={ea_comment}' + updated = True + break + if not updated: + lines.append(f'EA_Comment={ea_comment}') + + if lot_mode == 'manual': + update_param(lines, 'Risk', '0') + update_param(lines, 'StartLots', str(lot_value)) + elif lot_mode == 'balance': + update_param(lines, 'Risk', '9999') + update_param(lines, 'LotPerBalance_step', str(lot_value)) + # lot_mode None/'asis': only EA_Comment updated + + write_utf16(set_path, lines) + + +def build_ini(symbol, period, set_file_path, ini_out_path, report_folder, cfg): + name_stem = os.path.splitext(os.path.basename(set_file_path))[0] + model_label = MODEL_LABELS.get(cfg['model'], f"M{cfg['model']}") + report_name = f"{name_stem}_{model_label}" + content = ( + '[Tester]\r\n' + f'Expert={cfg["ea_name"]}\r\n' + f'Symbol={symbol}\r\n' + f'Period={period}\r\n' + f'Optimization={cfg.get("optimization","0")}\r\n' + f'Model={cfg["model"]}\r\n' + f'FromDate={cfg["from_date"]}\r\n' + f'ToDate={cfg["to_date"]}\r\n' + 'ForwardMode=0\r\n' + f'Deposit={cfg["deposit"]}\r\n' + f'Currency={cfg["currency"]}\r\n' + 'ProfitInPips=0\r\n' + f'Leverage={cfg["leverage"]}\r\n' + 'ExecutionMode=0\r\n' + 'OptimizationCriterion=0\r\n' + 'Visual=0\r\n' + f'Report={report_name}\r\n' + 'ReplaceReport=1\r\n' + f'Inputs={set_file_path}\r\n' + 'ShutdownTerminal=1\r\n' + ) + with open(ini_out_path, 'wb') as f: + f.write(content.encode('utf-8')) + + +# ── Main ─────────────────────────────────────────────────────────────────────── + +def main(): + print("=" * 60) + print(" MT5 Batch Backtest Runner") + print("=" * 60) + + # ── Load or create config ────────────────────────────────────── + cfg = load_config() + if cfg is None: + cfg = setup_config() + else: + cfg = review_config(cfg) + + terminal_path = cfg['terminal_path'] + tester_folder = cfg['tester_folder'] + os.makedirs(tester_folder, exist_ok=True) + + if not os.path.isfile(terminal_path): + print(f"\n WARNING: terminal64.exe not found at: {terminal_path}") + + # ── Folder of set files ──────────────────────────────────────── + print() + set_folder = prompt("Path to folder containing .set files") + set_folder = os.path.expandvars(set_folder.strip('"').strip("'")) + if not os.path.isdir(set_folder): + print(f" ERROR: Folder not found: {set_folder}") + sys.exit(1) + + all_set_files = sorted(glob.glob(os.path.join(set_folder, '**', '*.set'), recursive=True)) + set_files = [f for f in all_set_files + if not os.path.basename(f).lower().startswith('optimization') + and '_batch_modified' not in f.replace('\\', '/')] + skipped = len(all_set_files) - len(set_files) + if not set_files: + print(" ERROR: No .set files found (after exclusions).") + sys.exit(1) + print(f" Found {len(set_files)} .set file(s).", end='') + if skipped: + print(f" ({skipped} Optimization file(s) excluded)", end='') + print() + + # ── Report output folder ─────────────────────────────────────── + default_reports = os.path.join(set_folder, 'reports') + report_folder = prompt("Path to save reports", default_reports) + report_folder = report_folder.strip('"').strip("'") + os.makedirs(report_folder, exist_ok=True) + + # ── Lot size mode ────────────────────────────────────────────── + print() + print(" Lot size mode:") + print(" 0 = Use set file as-is (no changes to Risk/Lots)") + print(" 1 = Manual lot size (same for all) — sets Risk=0, StartLots=X") + print(" 2 = Lots per balance (from each .set file) — sets Risk=9999") + print(" 3 = Lots per balance (enter per file) — sets Risk=9999") + lot_mode_choice = prompt("Choose [0/1/2/3]", "2") + + lot_mode = {'0': 'asis', '1': 'manual'}.get(lot_mode_choice, 'balance') + balance_ask = lot_mode_choice == '3' + manual_lots = None + + if lot_mode == 'asis': + print(" Set files used as-is — no Risk/Lots changes.") + elif lot_mode == 'manual': + manual_lots = prompt("StartLots for all files", "0.01") + elif balance_ask: + print(" Will prompt LotPerBalance_step per file. Risk=9999.") + else: + print(" Using LotPerBalance_step from each file. Risk=9999.") + + # ── Instrument mode ──────────────────────────────────────────── + print() + print(" Instrument detection:") + print(" 1 = Enter one instrument for all set files") + print(" 2 = Extract from filename (specify number of characters)") + print(" 3 = Ask per file") + instr_mode = prompt("Choose [1/2/3]", "1") + instr_global = None + instr_n_chars = None + + if instr_mode == '1': + instr_global = prompt("Instrument (without suffix, e.g. GBPJPY)").upper() + elif instr_mode == '2': + instr_n_chars = int(prompt("Characters from start of filename", "6")) + + # ── Timeframe mode ───────────────────────────────────────────── + print() + print(" Timeframe:") + print(" 1 = One timeframe for all set files") + print(" 2 = Detect from filename (D/Daily/H1/H4 etc.)") + print(" 3 = Ask per file") + tf_mode = prompt("Choose [1/2/3]", "2") + tf_global = None + + if tf_mode == '1': + tf_raw = prompt("Timeframe (e.g. Daily, H1, H4, M15)").upper() + tf_global = PERIOD_MAP.get(tf_raw, tf_raw) + + # ── Output folder for modified .set copies ───────────────────── + # (subfolders mirrored inside _batch_modified and reports) + out_set_base = os.path.join(set_folder, '_batch_modified') + os.makedirs(out_set_base, exist_ok=True) + + # ── Process each set file ────────────────────────────────────── + print() + print("=" * 60) + print(f" Processing {len(set_files)} file(s)...") + print("=" * 60) + + results = [] + + for set_path in set_files: + filename = os.path.basename(set_path) + name_stem = os.path.splitext(filename)[0] + + # Mirror subfolder structure from set_folder root + rel_path = os.path.relpath(set_path, set_folder) + rel_subdir = os.path.dirname(rel_path) + out_set_folder = os.path.join(out_set_base, rel_subdir) if rel_subdir else out_set_base + file_report_dir = os.path.join(report_folder, rel_subdir) if rel_subdir else report_folder + os.makedirs(out_set_folder, exist_ok=True) + os.makedirs(file_report_dir, exist_ok=True) + + subfolder_label = f" ({rel_subdir})" if rel_subdir else "" + print(f"\n [{filename}]{subfolder_label}") + + # Instrument + if instr_mode == '1': + instrument = instr_global + elif instr_mode == '2': + instrument = detect_instrument(filename, instr_n_chars) + print(f" Instrument: {instrument}") + else: + instrument = prompt(f"Instrument for {filename} (without suffix)").upper() + + symbol = instrument + cfg['suffix'] + + # Timeframe + if tf_mode == '1': + period = tf_global + elif tf_mode == '2': + period = detect_timeframe(filename) + if period: + print(f" Timeframe : {period}") + else: + tf_raw = prompt(f"Timeframe for {filename} (e.g. Daily, H1, H4)").upper() + period = PERIOD_MAP.get(tf_raw, tf_raw) + else: + tf_raw = prompt(f"Timeframe for {filename}").upper() + period = PERIOD_MAP.get(tf_raw, tf_raw) + + # Lot value + if lot_mode == 'asis': + lot_value = None + print(f" Lots : as-is") + elif lot_mode == 'manual': + lot_value = manual_lots + print(f" Lots : Manual StartLots={lot_value}") + else: + if balance_ask: + file_lines = read_utf16(set_path) + file_lot = None + for line in file_lines: + if line.strip().startswith('LotPerBalance_step='): + parts = line.strip().split('||') + file_lot = parts[0].replace('LotPerBalance_step=', '').strip() + break + lot_value = prompt(f"LotPerBalance_step for {filename}", file_lot or "100") + else: + file_lines = read_utf16(set_path) + lot_value = None + for line in file_lines: + if line.strip().startswith('LotPerBalance_step='): + parts = line.strip().split('||') + lot_value = parts[0].replace('LotPerBalance_step=', '').strip() + break + if lot_value is None: + lot_value = prompt(f"LotPerBalance_step not found, enter value", "100") + print(f" Lots : Balance LotPerBalance_step={lot_value} Risk=9999") + + # EA_Comment + model_label = MODEL_LABELS.get(cfg['model'], f"M{cfg['model']}") + ea_comment = f"{name_stem} {symbol} {period} {model_label}" + print(f" EA_Comment: {ea_comment}") + + # Copy and modify set file + modified_set = os.path.join(out_set_folder, filename) + shutil.copy2(set_path, modified_set) + if lot_mode == 'asis': + update_set_file(modified_set, ea_comment, None, None) + else: + update_set_file(modified_set, ea_comment, lot_mode, lot_value) + + # Write ini + model_label = MODEL_LABELS.get(cfg['model'], f"M{cfg['model']}") + report_name = f"{name_stem}_{model_label}" + ini_path = os.path.join(tester_folder, f"{name_stem}.ini") + build_ini(symbol, period, modified_set, ini_path, report_folder, cfg) + print(f" INI : {ini_path}") + print(f" Report as : {report_name}.htm") + + # Launch MT5 minimised + cmd = [terminal_path, f'/config:{ini_path}'] + print(f" Launching MT5", end='', flush=True) + si = subprocess.STARTUPINFO() + si.dwFlags |= subprocess.STARTF_USESHOWWINDOW + si.wShowWindow = 6 # SW_MINIMIZE + proc = subprocess.Popen(cmd, startupinfo=si) + while proc.poll() is None: + time.sleep(10) + print('.', end='', flush=True) + print(f" done (exit {proc.returncode})") + + # Copy report files from MT5 terminal folder + search_dirs = [ + os.path.dirname(tester_folder), + os.path.join(os.path.dirname(tester_folder), 'MQL5', 'Profiles', 'Tester'), + tester_folder, + ] + + success = False + for search_dir in search_dirs: + htm_src = os.path.join(search_dir, report_name + '.htm') + if os.path.isfile(htm_src): + htm_dest = os.path.join(file_report_dir, report_name + '.htm') + shutil.copy2(htm_src, htm_dest) + copied = [report_name + '.htm'] + for fn in os.listdir(search_dir): + if fn.startswith(report_name) and not fn.endswith('.htm'): + shutil.copy2(os.path.join(search_dir, fn), + os.path.join(file_report_dir, fn)) + copied.append(fn) + # Remove from MT5 folder + os.remove(htm_src) + for fn in os.listdir(search_dir): + if fn.startswith(report_name) and not fn.endswith('.htm'): + try: + os.remove(os.path.join(search_dir, fn)) + except: + pass + print(f" Report : {len(copied)} file(s) → {file_report_dir}") + success = True + break + + if not success: + print(f" FAIL : Report not found. Checked:") + for d in search_dirs: + print(f" {d}") + + results.append({ + 'file' : filename, + 'subfolder': rel_subdir, + 'symbol' : symbol, + 'period' : period, + 'success' : success, + }) + + # ── Summary ──────────────────────────────────────────────────── + print() + print("=" * 60) + print(" SUMMARY") + print("=" * 60) + passed = [r for r in results if r['success']] + failed = [r for r in results if not r['success']] + print(f" Completed: {len(passed)}/{len(results)}") + if failed: + print(f"\n Failed (no report generated):") + for r in failed: + subfolder = f" [{r['subfolder']}]" if r.get('subfolder') else "" + print(f" - {r['file']}{subfolder} ({r['symbol']} {r['period']})") + failed_log = os.path.join(report_folder, 'failed_backtests.txt') + with open(failed_log, 'w') as f: + for r in failed: + f.write(f"{r['file']}\t{r['symbol']}\t{r['period']}\n") + print(f"\n Failed list saved to: {failed_log}") + print() + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/mt5_batch_config.json b/mt5_batch_config.json new file mode 100644 index 0000000..5575889 --- /dev/null +++ b/mt5_batch_config.json @@ -0,0 +1,13 @@ +{ + "terminal_path": "C:\\Program Files\\MetaTrader 5\\terminal64.exe", + "tester_folder": "C:\\Users\\pc\\AppData\\Roaming\\MetaQuotes\\Terminal\\D0E8209F77C8CF37AD8BF550E51FF075\\Tester", + "terminal_label": "\ufffd\ufffdC\u0000:\u0000\\\u0000P\u0000r\u0000o\u0000g\u0000r\u0000a\u0000m\u0000 \u0000F\u0000i\u0000l\u0000e\u0000s\u0000\\\u0000M\u0000e\u0000t\u0000a\u0000T\u0000r\u0000a\u0000d\u0000e\u0000r\u0000 \u00005\u0000", + "ea_name": "Market\\Ultimate Breakout System.ex5", + "from_date": "2018.01.01", + "to_date": "2026.04.01", + "model": "1", + "deposit": "10000", + "currency": "USD", + "leverage": "100", + "suffix": ".a" +} \ No newline at end of file diff --git a/mt5_parser.py b/mt5_parser.py new file mode 100644 index 0000000..32956fc --- /dev/null +++ b/mt5_parser.py @@ -0,0 +1,389 @@ +""" +mt5_parser.py +============= +Parsers for three MT5 trade report formats: + 1. MT5 Real Account HTM export + 2. MT5 Backtest HTM report + 3. Quant Analyzer CSV export + +All normalise to a common DataFrame schema. +""" + +import pandas as pd +import re + + +# ── Common schema ───────────────────────────────────────────────────────────── +# open_time, close_time, symbol, type, volume, open_price, close_price, +# sl, tp, commission, swap, profit, net_profit, comment, strategy, +# duration_min, win, day_of_week, hour, source + +def _decode(file_bytes): + for enc in ['utf-16', 'utf-8', 'latin-1', 'cp1252']: + try: + return file_bytes.decode(enc) + except: + continue + return '' + + +def _strip(s): + return re.sub(r'<[^>]+>', '', s).strip().replace('\xa0', '').replace('\u00a0', '') + + +def _to_float(s): + try: + return float(str(s).replace(' ', '').replace(',', '')) + except: + return None + + +def _to_dt(s, fmt='%Y.%m.%d %H:%M:%S'): + return pd.to_datetime(s, format=fmt, errors='coerce') + + +def _enrich(df): + """Add derived columns common to all formats.""" + df['open_time'] = pd.to_datetime(df['open_time'], errors='coerce') + df['close_time'] = pd.to_datetime(df['close_time'], errors='coerce') + df['open_date'] = df['open_time'].dt.date + df['close_date'] = df['close_time'].dt.date + df['day_of_week'] = df['open_time'].dt.day_name() + df['hour'] = df['open_time'].dt.hour + df['duration_min'] = ((df['close_time'] - df['open_time']) + .dt.total_seconds() / 60).round(1) + for col in ['volume', 'open_price', 'close_price', 'sl', 'tp', + 'commission', 'swap', 'profit']: + if col in df.columns: + df[col] = pd.to_numeric( + df[col].astype(str).str.replace(' ', '').str.replace(',', ''), + errors='coerce' + ) + if 'net_profit' not in df.columns: + df['net_profit'] = ( + df.get('profit', 0).fillna(0) + + df.get('commission', 0).fillna(0) + + df.get('swap', 0).fillna(0) + ) + df['win'] = df['net_profit'] > 0 + df['type'] = df['type'].str.lower().str.strip() + df['strategy'] = df['comment'].apply(extract_strategy) + # Normalise symbol — strip .a suffix for display matching + df['symbol_base'] = df['symbol'].str.replace(r'\.[a-z]+$', '', regex=True).str.upper() + return df + + +# ── Format 1: Real Account HTM ──────────────────────────────────────────────── + +def parse_mt5_report(file_bytes): + """Parse MT5 real account HTML trade history report.""" + text = _decode(file_bytes) + rows = re.findall(r']*>(.*?)', text, re.DOTALL) + trades = [] + in_trades = False + + COLS = ['open_time', 'position', 'symbol', 'type', 'comment', 'volume', + 'open_price', 'sl', 'tp', 'close_time', 'close_price', + 'commission', 'swap', 'profit'] + + for row in rows: + cells = re.findall(r']*>(.*?)', row, re.DOTALL) + cells = [re.sub(r'\s+', ' ', _strip(c)).strip() for c in cells] + + if cells and cells[0] == 'Time' and len(cells) >= 13: + in_trades = True + continue + if not in_trades: + continue + if cells and any(kw in cells[0] for kw in + ['Total Net Profit', 'Results', 'Balance', 'Equity']): + break + + if len(cells) >= 14 and re.match(r'\d{4}\.\d{2}\.\d{2}', cells[0]): + last = [c.lower() for c in cells if c] + if any(s in last for s in ['placed', 'cancelled', 'expired', 'partial']): + continue + if len(cells) < 10 or not re.match(r'\d{4}\.\d{2}\.\d{2}', cells[9]): + continue + if '/' in str(cells[5]): + continue + try: + trade = dict(zip(COLS, cells[:14])) + trades.append(trade) + except: + continue + + if not trades: + return None + + df = pd.DataFrame(trades) + df['source'] = 'real' + return _enrich(df) + + +# ── Format 2: Backtest HTM ──────────────────────────────────────────────────── + +def parse_backtest_report(file_bytes): + """ + Parse MT5 Strategy Tester HTML report. + Pairs in/out deals into complete trades. + """ + text = _decode(file_bytes) + tables = re.findall(r']*>(.*?)', text, re.DOTALL) + if len(tables) < 2: + return None + + rows = re.findall(r']*>(.*?)', tables[1], re.DOTALL) + + # Find deals section + in_deals = False + deal_rows = [] + for row in rows: + cells = [re.sub(r'\s+', ' ', _strip(c)).strip() + for c in re.findall(r']*>(.*?)', row, re.DOTALL)] + cells = [c for c in cells if c] + if not cells: + continue + if 'Deals' in cells: + in_deals = True + continue + if in_deals and cells[0] == 'Time' and 'Deal' in cells: + continue # header row + if in_deals and len(cells) >= 7 and re.match(r'\d{4}\.\d{2}\.\d{2}', cells[0]): + deal_rows.append(cells) + + if not deal_rows: + return None + + # Columns: Time, Deal, Symbol, Type, Direction, Volume, Price, Order, + # Commission, Swap, Profit, Balance, Comment + DEAL_COLS = ['time', 'deal', 'symbol', 'type', 'direction', 'volume', + 'price', 'order', 'commission', 'swap', 'profit', 'balance', 'comment'] + + deals = [] + for row in deal_rows: + d = dict(zip(DEAL_COLS, row[:len(DEAL_COLS)])) + deals.append(d) + + df_deals = pd.DataFrame(deals) + df_deals = df_deals[df_deals['direction'].isin(['in', 'out'])] + + # Match in/out pairs — pair consecutive in→out by symbol+type + open_stack = {} # key: (symbol, type_) -> list of open deals + trades = [] + + for _, deal in df_deals.iterrows(): + sym = deal.get('symbol', '') + typ = deal.get('type', '') + dirn = deal.get('direction', '') + key = (sym, typ) + + if dirn == 'in': + open_stack.setdefault(key, []).append(deal) + elif dirn == 'out': + stack = open_stack.get(key, []) + if stack: + entry = stack.pop(0) + trades.append({ + 'open_time' : entry['time'], + 'close_time' : deal['time'], + 'symbol' : sym, + 'type' : typ, + 'volume' : entry['volume'], + 'open_price' : entry['price'], + 'close_price': deal['price'], + 'sl' : None, + 'tp' : None, + 'commission' : _to_float(entry.get('commission', 0)), + 'swap' : _to_float(deal.get('swap', 0)), + 'profit' : _to_float(deal.get('profit', 0)), + 'comment' : deal.get('comment', ''), + 'position' : entry.get('deal', ''), + }) + + if not trades: + return None + + df = pd.DataFrame(trades) + df['source'] = 'backtest' + return _enrich(df) + + +# ── Format 3: Quant Analyzer CSV ───────────────────────────────────────────── + +def parse_quant_csv(file_bytes): + """Parse Quant Analyzer listOfTrades CSV export.""" + try: + text = file_bytes.decode('utf-8-sig') + except: + text = file_bytes.decode('latin-1') + + from io import StringIO + df_raw = pd.read_csv(StringIO(text)) + + # Normalise column names + df_raw.columns = [c.strip().lower().replace(' ', '_').replace('/', '_').replace('(', '').replace(')', '') for c in df_raw.columns] + + col_map = { + 'open_time' : ['open_time_$', 'open_time_$_', 'open_time', 'opentime'], + 'close_time' : ['close_time_$', 'close_time_$_', 'close_time', 'closetime'], + 'symbol' : ['symbol_$', 'symbol_$_', 'symbol'], + 'type' : ['type_$', 'type_$_', 'type', 'direction'], + 'volume' : ['size_$', 'size_$_', 'size', 'volume', 'lots'], + 'open_price' : ['open_price_$', 'open_price_$_', 'open_price', 'openprice'], + 'close_price' : ['close_price_$', 'close_price_$_', 'close_price', 'closeprice'], + 'profit' : ['profit_loss_$', 'profit_loss_$_', 'profit_loss', 'profit', 'net_profit'], + 'commission' : ['comm_swap_$', 'comm_swap_$_', 'commission', 'comm'], + 'swap' : ['swap_$', 'swap'], + 'sl' : ['stop_loss_$', 'stop_loss_$_', 'stop_loss', 'sl'], + 'comment' : ['comment_$', 'comment_$_', 'comment'], + 'strategy' : ['strategy_name_$', 'strategy_name_$_', 'strategy_name', 'strategy'], + 'mae' : ['mae_$', 'mae_$_', 'mae'], + 'mfe' : ['mfe_$', 'mfe_$_', 'mfe'], + 'drawdown' : ['drawdown_$', 'drawdown_$_', 'drawdown'], + } + + result = {} + for target, candidates in col_map.items(): + for cand in candidates: + if cand in df_raw.columns: + result[target] = df_raw[cand] + break + + df = pd.DataFrame(result) + + # Parse datetimes — QA uses DD.MM.YYYY HH:MM:SS + for col in ['open_time', 'close_time']: + if col in df.columns: + df[col] = pd.to_datetime(df[col], format='%d.%m.%Y %H:%M:%S', errors='coerce') + if df[col].isna().all(): + df[col] = pd.to_datetime(df[col], infer_datetime_format=True, errors='coerce') + + # QA comm_swap is combined — split evenly as approximation if no separate swap + if 'commission' in df.columns and 'swap' not in df.columns: + df['swap'] = 0.0 + + if 'sl' not in df.columns: + df['sl'] = None + if 'tp' not in df.columns: + df['tp'] = None + if 'position' not in df.columns: + df['position'] = df.get('ticket', range(len(df))) + + df['source'] = 'quant_csv' + + # Add extra QA-specific columns if present + for extra in ['mae', 'mfe', 'drawdown']: + if extra in df_raw.columns: + df[extra] = pd.to_numeric(df_raw[extra], errors='coerce') + + return _enrich(df) + + +# ── Auto-detect format ──────────────────────────────────────────────────────── + +def detect_and_parse(file_bytes, filename=''): + """ + Auto-detect file format and parse. + Returns (df, format_name) or (None, None). + """ + fname = filename.lower() + + if fname.endswith('.csv'): + df = parse_quant_csv(file_bytes) + return df, 'Quant Analyzer CSV' + + # HTML/HTM — detect backtest vs real account + try: + text = _decode(file_bytes) + except: + return None, None + + if 'Strategy Tester Report' in text or 'strategy tester' in text.lower(): + df = parse_backtest_report(file_bytes) + return df, 'MT5 Backtest Report' + + df = parse_mt5_report(file_bytes) + return df, 'MT5 Account History' + + +# ── Stats ───────────────────────────────────────────────────────────────────── + +def calc_stats(df): + if df is None or len(df) == 0: + return {} + + total = len(df) + wins = df[df['win'] == True] + losses = df[df['win'] == False] + win_rate = round(len(wins) / total * 100, 1) if total > 0 else 0 + gross_profit = round(wins['net_profit'].sum(), 2) + gross_loss = round(losses['net_profit'].sum(), 2) + net_profit = round(df['net_profit'].sum(), 2) + pf = round(abs(gross_profit / gross_loss), 2) if gross_loss != 0 else float('inf') + avg_win = round(wins['net_profit'].mean(), 2) if len(wins) > 0 else 0 + avg_loss = round(losses['net_profit'].mean(), 2) if len(losses) > 0 else 0 + rr = round(abs(avg_win / avg_loss), 2) if avg_loss != 0 else float('inf') + expectancy = round((win_rate/100 * avg_win) + ((1 - win_rate/100) * avg_loss), 2) + + results = df.sort_values('close_time')['win'].tolist() + max_cw = _max_consec(results, True) + max_cl = _max_consec(results, False) + + cumulative = df.sort_values('close_time')['net_profit'].cumsum() + rolling_max = cumulative.cummax() + max_dd = round((cumulative - rolling_max).min(), 2) + + avg_dur = round(df['duration_min'].mean(), 1) if 'duration_min' in df.columns else 0 + avg_win_dur = round(wins['duration_min'].mean(), 1) if len(wins) > 0 else 0 + avg_los_dur = round(losses['duration_min'].mean(), 1) if len(losses) > 0 else 0 + + longs = df[df['type'] == 'buy'] + shorts = df[df['type'] == 'sell'] + + return { + 'total_trades' : total, + 'win_rate' : win_rate, + 'net_profit' : net_profit, + 'gross_profit' : gross_profit, + 'gross_loss' : gross_loss, + 'profit_factor' : pf, + 'avg_win' : avg_win, + 'avg_loss' : avg_loss, + 'rr_ratio' : rr, + 'expectancy' : expectancy, + 'max_consec_wins' : max_cw, + 'max_consec_losses' : max_cl, + 'max_drawdown' : max_dd, + 'best_trade' : round(df['net_profit'].max(), 2), + 'worst_trade' : round(df['net_profit'].min(), 2), + 'avg_duration_min' : avg_dur, + 'avg_win_duration' : avg_win_dur, + 'avg_loss_duration' : avg_los_dur, + 'long_trades' : len(longs), + 'short_trades' : len(shorts), + 'long_win_rate' : round(len(longs[longs['win']]) / len(longs) * 100, 1) if len(longs) > 0 else 0, + 'short_win_rate' : round(len(shorts[shorts['win']]) / len(shorts) * 100, 1) if len(shorts) > 0 else 0, + } + + +def extract_strategy(comment): + if not comment or str(comment).strip() == '': + return 'Manual' + parts = str(comment).split('_') + while parts and re.match(r'^\d+$', parts[-1]): + parts.pop() + if parts and re.match(r'^[A-Z]{3,8}(\.a)?$', parts[-1]): + parts.pop() + return '_'.join(parts) if parts else str(comment) + + +def _max_consec(results, target): + max_c = cur_c = 0 + for r in results: + if r == target: + cur_c += 1 + max_c = max(max_c, cur_c) + else: + cur_c = 0 + return max_c \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..898da09 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +streamlit +streamlit-option-menu +pandas +plotly diff --git a/set_comparator.py b/set_comparator.py new file mode 100644 index 0000000..d993df0 --- /dev/null +++ b/set_comparator.py @@ -0,0 +1,94 @@ +import io +import zipfile +from pathlib import Path + +def parse_set_file(file_bytes, filename): + """Parse a .set file and return dict of {param: value} and ordered param list""" + text = file_bytes.decode('utf-16') + params = {} + raw_lines = {} # preserve full line for export + order = [] + + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith(';'): + continue + if '=' not in line: + continue + key, _, rest = line.partition('=') + key = key.strip() + # Value is first field before || + parts = rest.split('||') + value = parts[0].strip() + params[key] = value + raw_lines[key] = rest # preserve everything after = + order.append(key) + + return params, raw_lines, order + +def build_comparison_df(files_data): + """ + files_data: list of (filename, params, raw_lines, order) + Returns DataFrame with param names as index, filenames as columns + """ + import pandas as pd + + # Build union of all param keys preserving order from first file + all_keys = [] + seen = set() + for _, _, _, order in files_data: + for k in order: + if k not in seen: + all_keys.append(k) + seen.add(k) + + # Build dataframe + rows = [] + for key in all_keys: + row = {'Parameter': key} + for filename, params, _, _ in files_data: + row[filename] = params.get(key, '') + rows.append(row) + + return pd.DataFrame(rows) + +def export_set_file(filename, params_edited, raw_lines, order, original_bytes): + """ + Rebuild .set file with edited values, preserving || fields + Returns bytes (utf-16 encoded) + """ + # Get original header comments + text = original_bytes.decode('utf-16') + lines = text.splitlines() + header_lines = [] + for line in lines: + if line.startswith(';'): + header_lines.append(line) + else: + break + + output_lines = header_lines.copy() + + for key in order: + if key not in params_edited: + continue + new_value = params_edited[key] + rest = raw_lines.get(key, new_value) + parts = rest.split('||') + parts[0] = str(new_value) + output_lines.append(f"{key}={'||'.join(parts)}") + + content = '\r\n'.join(output_lines) + '\r\n' + return content.encode('utf-16') + +def create_zip(files_export): + """ + files_export: list of (filename, bytes) + Returns zip bytes + """ + buf = io.BytesIO() + with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf: + for fname, fbytes in files_export: + zf.writestr(fname, fbytes) + buf.seek(0) + return buf.read() \ No newline at end of file diff --git a/settings.py b/settings.py new file mode 100644 index 0000000..39d7a04 --- /dev/null +++ b/settings.py @@ -0,0 +1,37 @@ +""" +pages/settings.py +================= +Settings page for MT5 Tools dashboard. +""" + +import streamlit as st + + +def render(): + st.title("⚙️ Settings") + + st.markdown(""" +
+ MT5 Tools — settings and information. +
+ """, unsafe_allow_html=True) + + st.subheader("About") + st.markdown(""" + **MT5 Tools** is a standalone trade analysis and comparison dashboard. + + **Supported file formats:** + - MT5 Account History HTML export (`.htm` / `.html`) + - MT5 Strategy Tester Backtest Report (`.htm` / `.html`) + - Quant Analyzer CSV export (`listOfTrades_*.csv`) + + **Pages:** + - **Trade Analysis** — statistics, equity curves, day/hour breakdown for a single report + - **Trade Compare** — match and compare two reports to measure slippage and variance + """) + + st.subheader("Requirements") + st.code("pip install streamlit streamlit-option-menu pandas plotly", language="bash") + + st.subheader("Launch") + st.code("streamlit run app.py", language="bash") diff --git a/trade_analysis.py b/trade_analysis.py new file mode 100644 index 0000000..95bf2ff --- /dev/null +++ b/trade_analysis.py @@ -0,0 +1,333 @@ +""" +pages/trade_analysis.py +======================= +MT5 Trade Analysis page — migrated from main dashboard. +""" + +import streamlit as st +import plotly.graph_objects as go +import sys, os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from mt5_parser import detect_and_parse, calc_stats + + +def render(): + st.title("📊 Trade Analysis") + + # ── Session state ───────────────────────────────────────────────────────── + if 'ta_df' not in st.session_state: + st.session_state['ta_df'] = None + st.session_state['ta_format'] = None + + # ── File upload ─────────────────────────────────────────────────────────── + col1, col2 = st.columns([4, 1]) + with col1: + uploaded = st.file_uploader( + "Upload MT5 Report (HTM/HTML) or Quant Analyzer CSV", + type=['html', 'htm', 'csv'], + key='ta_upload' + ) + with col2: + st.markdown("
", unsafe_allow_html=True) + if st.button("🗑 Clear", key='ta_clear'): + st.session_state['ta_df'] = None + st.session_state['ta_format'] = None + st.rerun() + + if uploaded: + df, fmt = detect_and_parse(uploaded.read(), uploaded.name) + if df is not None: + st.session_state['ta_df'] = df + st.session_state['ta_format'] = fmt + st.success(f"✓ Loaded {len(df)} trades — {fmt}") + else: + st.error("Could not parse report — check file format") + + df_all = st.session_state['ta_df'] + fmt = st.session_state['ta_format'] + + if df_all is None or len(df_all) == 0: + st.markdown(""" +
+ Upload an MT5 account history report (.htm/.html), MT5 backtest report, + or a Quant Analyzer CSV export to begin analysis. +
+ """, unsafe_allow_html=True) + return + + if fmt: + st.caption(f"Format detected: **{fmt}** · {len(df_all)} total trades") + + # ── Filters ─────────────────────────────────────────────────────────────── + st.divider() + fc1, fc2, fc3, fc4 = st.columns(4) + + with fc1: + date_min = df_all['open_time'].min().date() + date_max = df_all['open_time'].max().date() + date_from = st.date_input("From", value=date_min, min_value=date_min, + max_value=date_max, key='ta_from') + date_to = st.date_input("To", value=date_max, min_value=date_min, + max_value=date_max, key='ta_to') + + with fc2: + symbols = sorted(df_all['symbol'].dropna().unique().tolist()) + sel_symbol = st.multiselect("Symbol", symbols, key='ta_sym') + + with fc3: + strategies = sorted(df_all['strategy'].dropna().unique().tolist()) + sel_strategy = st.multiselect("Strategy / EA", strategies, key='ta_strat') + + with fc4: + days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'] + sel_days = st.multiselect("Day of week", days, key='ta_days') + sel_type = st.multiselect("Type", ['buy', 'sell'], key='ta_type') + + # Apply filters + df = df_all.copy() + df = df[(df['open_time'].dt.date >= date_from) & + (df['open_time'].dt.date <= date_to)] + if sel_symbol: + df = df[df['symbol'].isin(sel_symbol)] + if sel_strategy: + df = df[df['strategy'].isin(sel_strategy)] + if sel_days: + df = df[df['day_of_week'].isin(sel_days)] + if sel_type: + df = df[df['type'].isin(sel_type)] + + st.caption(f"Showing **{len(df)}** trades after filters") + + # ── Analysis mode ───────────────────────────────────────────────────────── + mode = st.radio( + "Analysis mode", + ["Overall", "By Strategy", "By Symbol", "By Day of Week"], + horizontal=True, key='ta_mode' + ) + st.divider() + + # ── Helpers ─────────────────────────────────────────────────────────────── + def render_stats(stats, label=""): + if label: + st.markdown(f"**{label}**") + + c1, c2, c3, c4, c5 = st.columns(5) + c1.metric("Net Profit", f"${stats['net_profit']:,.2f}") + c2.metric("Win Rate", f"{stats['win_rate']}%") + c3.metric("Profit Factor", f"{stats['profit_factor']}") + c4.metric("R:R Ratio", f"{stats['rr_ratio']}") + c5.metric("Expectancy", f"${stats['expectancy']:,.2f}") + + c1, c2, c3, c4, c5 = st.columns(5) + c1.metric("Total Trades", stats['total_trades']) + c2.metric("Avg Win", f"${stats['avg_win']:,.2f}") + c3.metric("Avg Loss", f"${stats['avg_loss']:,.2f}") + c4.metric("Max DD", f"${stats['max_drawdown']:,.2f}") + c5.metric("Best Trade", f"${stats['best_trade']:,.2f}") + + c1, c2, c3, c4, c5 = st.columns(5) + c1.metric("Max Consec Wins", stats['max_consec_wins']) + c2.metric("Max Consec Losses", stats['max_consec_losses']) + c3.metric("Avg Win Dur", f"{stats['avg_win_duration']}m") + c4.metric("Avg Loss Dur", f"{stats['avg_loss_duration']}m") + c5.metric("Worst Trade", f"${stats['worst_trade']:,.2f}") + + c1, c2, c3, c4 = st.columns(4) + c1.metric("Long Trades", stats['long_trades']) + c2.metric("Long Win Rate", f"{stats['long_win_rate']}%") + c3.metric("Short Trades", stats['short_trades']) + c4.metric("Short Win Rate",f"{stats['short_win_rate']}%") + + def render_equity_curve(df_plot, label="Equity Curve"): + df_s = df_plot.sort_values('close_time').copy() + df_s['cumulative'] = df_s['net_profit'].cumsum() + fig = go.Figure() + fig.add_trace(go.Scatter( + x=df_s['close_time'], y=df_s['cumulative'], + mode='lines', + line=dict(color='#7c6af7', width=2), + fill='tozeroy', + fillcolor='rgba(124,106,247,0.08)', + name='Equity' + )) + fig.update_layout( + title=label, height=300, + plot_bgcolor='rgba(10,10,15,1)', + paper_bgcolor='rgba(10,10,15,1)', + font=dict(color='#aaa', family='JetBrains Mono'), + xaxis=dict(gridcolor='rgba(255,255,255,0.04)'), + yaxis=dict(gridcolor='rgba(255,255,255,0.04)', tickprefix='$'), + margin=dict(l=60, r=20, t=40, b=40) + ) + st.plotly_chart(fig, use_container_width=True) + + def render_dow_chart(df_plot): + dow_order = ['Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday'] + dow = df_plot.groupby('day_of_week').agg( + trades = ('net_profit', 'count'), + net_profit = ('net_profit', 'sum'), + win_rate = ('win', lambda x: round(x.mean()*100, 1)) + ).reindex([d for d in dow_order if d in df_plot['day_of_week'].unique()]) + + wins_dow = df_plot[df_plot['win']].groupby('day_of_week')['net_profit'].sum().reindex(dow.index, fill_value=0) + losses_dow = df_plot[~df_plot['win']].groupby('day_of_week')['net_profit'].sum().reindex(dow.index, fill_value=0) + + fig = go.Figure() + fig.add_trace(go.Bar(x=dow.index, y=wins_dow, name='Profit', marker_color='rgba(45,198,83,0.8)')) + fig.add_trace(go.Bar(x=dow.index, y=losses_dow, name='Loss', marker_color='rgba(230,57,70,0.8)')) + fig.update_layout( + title='P&L by Day of Week', height=280, barmode='relative', + plot_bgcolor='rgba(10,10,15,1)', paper_bgcolor='rgba(10,10,15,1)', + font=dict(color='#aaa'), margin=dict(l=60, r=20, t=40, b=40), + xaxis=dict(gridcolor='rgba(255,255,255,0.04)'), + yaxis=dict(gridcolor='rgba(255,255,255,0.04)', tickprefix='$'), + legend=dict(bgcolor='rgba(0,0,0,0.3)') + ) + st.plotly_chart(fig, use_container_width=True) + + dt = dow.reset_index() + dt.columns = ['Day', 'Trades', 'Net Profit', 'Win Rate %'] + dt['Net Profit'] = dt['Net Profit'].round(2) + st.dataframe(dt, use_container_width=True, hide_index=True) + + def render_hour_chart(df_plot): + hourly = df_plot.groupby('hour').agg( + trades = ('net_profit', 'count'), + net_profit = ('net_profit', 'sum'), + ) + wins_h = df_plot[df_plot['win']].groupby('hour')['net_profit'].sum().reindex(hourly.index, fill_value=0) + losses_h = df_plot[~df_plot['win']].groupby('hour')['net_profit'].sum().reindex(hourly.index, fill_value=0) + + fig = go.Figure() + fig.add_trace(go.Bar(x=wins_h.index, y=wins_h, name='Profit', marker_color='rgba(45,198,83,0.8)')) + fig.add_trace(go.Bar(x=losses_h.index, y=losses_h, name='Loss', marker_color='rgba(230,57,70,0.8)')) + fig.update_layout( + title='P&L by Hour of Day', height=280, barmode='relative', + plot_bgcolor='rgba(10,10,15,1)', paper_bgcolor='rgba(10,10,15,1)', + font=dict(color='#aaa'), margin=dict(l=60, r=20, t=40, b=40), + xaxis=dict(gridcolor='rgba(255,255,255,0.04)', title='Hour (UTC)'), + yaxis=dict(gridcolor='rgba(255,255,255,0.04)', tickprefix='$'), + legend=dict(bgcolor='rgba(0,0,0,0.3)') + ) + st.plotly_chart(fig, use_container_width=True) + + def colour_profit(val): + try: + v = float(str(val).replace(',', '')) + if v > 0: return 'background-color: rgba(0,180,0,0.12)' + if v < 0: return 'background-color: rgba(180,0,0,0.12)' + except: + pass + return '' + + # ── Render mode ─────────────────────────────────────────────────────────── + if mode == "Overall": + stats = calc_stats(df) + render_stats(stats, "Overall Statistics") + render_equity_curve(df) + col1, col2 = st.columns(2) + with col1: + render_dow_chart(df) + with col2: + render_hour_chart(df) + + elif mode == "By Strategy": + strats = sorted(df['strategy'].dropna().unique().tolist()) + if not strats: + st.info("No strategies found") + else: + st.subheader("Strategy Comparison") + rows = [] + for s in strats: + sdf = df[df['strategy'] == s] + stat = calc_stats(sdf) + rows.append({ + 'Strategy' : s, + 'Trades' : stat['total_trades'], + 'Net Profit' : stat['net_profit'], + 'Win Rate %' : stat['win_rate'], + 'Profit Factor' : stat['profit_factor'], + 'R:R' : stat['rr_ratio'], + 'Expectancy' : stat['expectancy'], + 'Max DD' : stat['max_drawdown'], + 'Max Consec W' : stat['max_consec_wins'], + 'Max Consec L' : stat['max_consec_losses'], + }) + sdf_sum = __import__('pandas').DataFrame(rows).sort_values('Net Profit', ascending=False) + st.dataframe( + sdf_sum.style.map(colour_profit, subset=['Net Profit', 'Expectancy', 'Max DD']), + use_container_width=True, hide_index=True + ) + st.divider() + sel = st.selectbox("Select strategy for detail", strats) + if sel: + sdf = df[df['strategy'] == sel] + stat = calc_stats(sdf) + render_stats(stat, sel) + render_equity_curve(sdf, f"{sel} — Equity Curve") + col1, col2 = st.columns(2) + with col1: render_dow_chart(sdf) + with col2: render_hour_chart(sdf) + + elif mode == "By Symbol": + syms = sorted(df['symbol'].dropna().unique().tolist()) + rows = [] + for s in syms: + sdf = df[df['symbol'] == s] + stat = calc_stats(sdf) + rows.append({ + 'Symbol' : s, + 'Trades' : stat['total_trades'], + 'Net Profit' : stat['net_profit'], + 'Win Rate %' : stat['win_rate'], + 'Profit Factor' : stat['profit_factor'], + 'R:R' : stat['rr_ratio'], + 'Expectancy' : stat['expectancy'], + 'Max DD' : stat['max_drawdown'], + }) + sdf_sum = __import__('pandas').DataFrame(rows).sort_values('Net Profit', ascending=False) + st.dataframe( + sdf_sum.style.map(colour_profit, subset=['Net Profit', 'Expectancy', 'Max DD']), + use_container_width=True, hide_index=True + ) + sel = st.selectbox("Select symbol for detail", syms) + if sel: + sdf = df[df['symbol'] == sel] + stat = calc_stats(sdf) + render_stats(stat, sel) + render_equity_curve(sdf, f"{sel} — Equity Curve") + col1, col2 = st.columns(2) + with col1: render_dow_chart(sdf) + with col2: render_hour_chart(sdf) + + elif mode == "By Day of Week": + render_dow_chart(df) + render_hour_chart(df) + + # ── Raw trade log ───────────────────────────────────────────────────────── + st.divider() + with st.expander("Raw Trade Log"): + show_cols = ['open_time', 'close_time', 'symbol', 'type', 'strategy', + 'volume', 'open_price', 'close_price', 'sl', 'tp', + 'commission', 'swap', 'profit', 'net_profit', 'duration_min'] + show_cols = [c for c in show_cols if c in df.columns] + + def colour_net(val): + try: + v = float(val) + if v > 0: return 'background-color: rgba(0,180,0,0.12)' + if v < 0: return 'background-color: rgba(180,0,0,0.12)' + except: + pass + return '' + + st.dataframe( + df[show_cols].style.map(colour_net, subset=['net_profit', 'profit']), + use_container_width=True, hide_index=True, height=400 + ) + st.download_button( + "⬇ Download filtered trades CSV", + data = df[show_cols].to_csv(index=False), + file_name = f"mt5_trades_{date_from}_{date_to}.csv", + mime = 'text/csv' + ) diff --git a/trade_compare.py b/trade_compare.py new file mode 100644 index 0000000..23b8e66 --- /dev/null +++ b/trade_compare.py @@ -0,0 +1,422 @@ +""" +pages/trade_compare.py +====================== +Side-by-side comparison of two trade history files. +Matches trades by symbol + type + open time within a tolerance window. +Highlights slippage, profit variance, and timing differences. +""" + +import streamlit as st +import pandas as pd +import plotly.graph_objects as go +import sys, os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from mt5_parser import detect_and_parse, calc_stats + + +# ── Match trades ────────────────────────────────────────────────────────────── + +def match_trades(df_a, df_b, tolerance_hours): + """ + Match trades between two DataFrames. + Match criteria: same symbol_base + same type + open_time within tolerance. + Returns DataFrame of matched pairs with diff columns. + """ + tol = pd.Timedelta(hours=tolerance_hours) + matched = [] + used_b = set() + + for i, a in df_a.iterrows(): + best_match = None + best_delta = tol + pd.Timedelta(seconds=1) + + for j, b in df_b.iterrows(): + if j in used_b: + continue + if a['symbol_base'] != b['symbol_base']: + continue + if a['type'] != b['type']: + continue + delta = abs(a['open_time'] - b['open_time']) + if delta <= tol and delta < best_delta: + best_delta = delta + best_match = (j, b) + + if best_match: + j, b = best_match + used_b.add(j) + + open_slip = round(float(b['open_price']) - float(a['open_price']), 5) if pd.notna(a['open_price']) and pd.notna(b['open_price']) else None + close_slip = round(float(b['close_price']) - float(a['close_price']), 5) if pd.notna(a['close_price']) and pd.notna(b['close_price']) else None + profit_var = round(float(b['net_profit']) - float(a['net_profit']), 2) if pd.notna(a['net_profit']) and pd.notna(b['net_profit']) else None + time_diff = round((b['open_time'] - a['open_time']).total_seconds() / 60, 1) + dur_diff = round(float(b.get('duration_min', 0) or 0) - float(a.get('duration_min', 0) or 0), 1) + + matched.append({ + # File A + 'A_open_time' : a['open_time'], + 'A_close_time' : a['close_time'], + 'A_symbol' : a['symbol'], + 'A_type' : a['type'], + 'A_volume' : a.get('volume'), + 'A_open_price' : a.get('open_price'), + 'A_close_price': a.get('close_price'), + 'A_profit' : a.get('net_profit'), + 'A_duration' : a.get('duration_min'), + # File B + 'B_open_time' : b['open_time'], + 'B_close_time' : b['close_time'], + 'B_symbol' : b['symbol'], + 'B_type' : b['type'], + 'B_volume' : b.get('volume'), + 'B_open_price' : b.get('open_price'), + 'B_close_price': b.get('close_price'), + 'B_profit' : b.get('net_profit'), + 'B_duration' : b.get('duration_min'), + # Differences + 'open_slippage' : open_slip, + 'close_slippage': close_slip, + 'profit_var' : profit_var, + 'time_diff_min' : time_diff, + 'duration_diff' : dur_diff, + }) + + return pd.DataFrame(matched) + + +# ── Render ──────────────────────────────────────────────────────────────────── + +def render(): + st.title("🔄 Trade Compare") + + st.markdown(""" +
+ Compare two trade history files — backtest vs real account, or any two exports. + Trades are matched by symbol, direction, and open time within a configurable + tolerance window to account for gaps, slippage, and market open variations. +
+ """, unsafe_allow_html=True) + + # ── Session state ───────────────────────────────────────────────────────── + for k in ['tc_df_a', 'tc_df_b', 'tc_fmt_a', 'tc_fmt_b']: + if k not in st.session_state: + st.session_state[k] = None + + # ── File upload ─────────────────────────────────────────────────────────── + st.subheader("Load Files") + col_a, col_b = st.columns(2) + + with col_a: + st.markdown("**File A** — Reference (e.g. Backtest)") + up_a = st.file_uploader("Upload File A", type=['html','htm','csv'], key='tc_up_a') + if up_a: + df_a, fmt_a = detect_and_parse(up_a.read(), up_a.name) + if df_a is not None: + st.session_state['tc_df_a'] = df_a + st.session_state['tc_fmt_a'] = fmt_a + st.success(f"✓ {len(df_a)} trades — {fmt_a}") + else: + st.error("Could not parse File A") + if st.session_state['tc_df_a'] is not None: + st.caption(f"Loaded: **{st.session_state['tc_fmt_a']}** · {len(st.session_state['tc_df_a'])} trades") + + with col_b: + st.markdown("**File B** — Comparison (e.g. Real Account)") + up_b = st.file_uploader("Upload File B", type=['html','htm','csv'], key='tc_up_b') + if up_b: + df_b, fmt_b = detect_and_parse(up_b.read(), up_b.name) + if df_b is not None: + st.session_state['tc_df_b'] = df_b + st.session_state['tc_fmt_b'] = fmt_b + st.success(f"✓ {len(df_b)} trades — {fmt_b}") + else: + st.error("Could not parse File B") + if st.session_state['tc_df_b'] is not None: + st.caption(f"Loaded: **{st.session_state['tc_fmt_b']}** · {len(st.session_state['tc_df_b'])} trades") + + df_a = st.session_state['tc_df_a'] + df_b = st.session_state['tc_df_b'] + + if df_a is None or df_b is None: + return + + # ── Filters ─────────────────────────────────────────────────────────────── + st.divider() + st.subheader("Filters") + + fa1, fa2, fa3 = st.columns(3) + fb1, fb2, fb3 = st.columns(3) + + with fa1: + st.markdown("**File A filters**") + with fb1: + st.markdown("**File B filters**") + + col1, col2, col3, col4, col5, col6 = st.columns(6) + + with col1: + a_date_min = df_a['open_time'].min().date() + a_date_max = df_a['open_time'].max().date() + a_from = st.date_input("A — From", value=a_date_min, min_value=a_date_min, + max_value=a_date_max, key='tc_a_from') + a_to = st.date_input("A — To", value=a_date_max, min_value=a_date_min, + max_value=a_date_max, key='tc_a_to') + + with col2: + a_syms = sorted(df_a['symbol'].dropna().unique().tolist()) + a_sel_sym = st.multiselect("A — Symbol", a_syms, key='tc_a_sym') + + with col3: + a_strats = sorted(df_a['strategy'].dropna().unique().tolist()) + a_sel_strat = st.multiselect("A — Strategy", a_strats, key='tc_a_strat') + a_sel_type = st.multiselect("A — Type", ['buy', 'sell'], key='tc_a_type') + + with col4: + b_date_min = df_b['open_time'].min().date() + b_date_max = df_b['open_time'].max().date() + b_from = st.date_input("B — From", value=b_date_min, min_value=b_date_min, + max_value=b_date_max, key='tc_b_from') + b_to = st.date_input("B — To", value=b_date_max, min_value=b_date_min, + max_value=b_date_max, key='tc_b_to') + + with col5: + b_syms = sorted(df_b['symbol'].dropna().unique().tolist()) + b_sel_sym = st.multiselect("B — Symbol", b_syms, key='tc_b_sym') + + with col6: + b_strats = sorted(df_b['strategy'].dropna().unique().tolist()) + b_sel_strat = st.multiselect("B — Strategy", b_strats, key='tc_b_strat') + b_sel_type = st.multiselect("B — Type", ['buy', 'sell'], key='tc_b_type') + + # ── Matching tolerance ──────────────────────────────────────────────────── + st.divider() + col_tol, col_run = st.columns([3, 1]) + with col_tol: + tolerance = st.slider( + "Match tolerance (hours) — max time difference between A and B open times", + min_value=1, max_value=24, value=4, step=1, + help="Trades within this window are considered the same setup. " + "Increase for daily charts, decrease for intraday." + ) + with col_run: + st.markdown("
", unsafe_allow_html=True) + run = st.button("🔍 Match Trades", type="primary", use_container_width=True) + + if not run and 'tc_matched' not in st.session_state: + return + + # Apply filters + fa = df_a.copy() + fa = fa[(fa['open_time'].dt.date >= a_from) & (fa['open_time'].dt.date <= a_to)] + if a_sel_sym: fa = fa[fa['symbol'].isin(a_sel_sym)] + if a_sel_strat: fa = fa[fa['strategy'].isin(a_sel_strat)] + if a_sel_type: fa = fa[fa['type'].isin(a_sel_type)] + + fb = df_b.copy() + fb = fb[(fb['open_time'].dt.date >= b_from) & (fb['open_time'].dt.date <= b_to)] + if b_sel_sym: fb = fb[fb['symbol'].isin(b_sel_sym)] + if b_sel_strat: fb = fb[fb['strategy'].isin(b_sel_strat)] + if b_sel_type: fb = fb[fb['type'].isin(b_sel_type)] + + if run: + with st.spinner("Matching trades..."): + matched = match_trades(fa, fb, tolerance) + st.session_state['tc_matched'] = matched + st.session_state['tc_fa_len'] = len(fa) + st.session_state['tc_fb_len'] = len(fb) + + matched = st.session_state.get('tc_matched', pd.DataFrame()) + fa_len = st.session_state.get('tc_fa_len', len(fa)) + fb_len = st.session_state.get('tc_fb_len', len(fb)) + + if matched is None or len(matched) == 0: + st.warning("No matching trades found — try increasing the tolerance window or adjusting filters.") + return + + # ── Summary stats ───────────────────────────────────────────────────────── + st.divider() + st.subheader("Match Summary") + + m1, m2, m3, m4, m5 = st.columns(5) + m1.metric("File A Trades", fa_len) + m2.metric("File B Trades", fb_len) + m3.metric("Matched Pairs", len(matched)) + m4.metric("Unmatched A", fa_len - len(matched)) + m5.metric("Unmatched B", fb_len - len(matched)) + + st.divider() + + # ── Aggregate comparison ─────────────────────────────────────────────────── + st.subheader("Aggregate Comparison") + + ac1, ac2 = st.columns(2) + + with ac1: + st.markdown("**File A (Reference)**") + a_net = matched['A_profit'].sum() + a_wr = (matched['A_profit'] > 0).mean() * 100 + a_avg = matched['A_profit'].mean() + a_dur = matched['A_duration'].mean() if 'A_duration' in matched else None + st.metric("Net Profit", f"${a_net:,.2f}") + st.metric("Win Rate", f"{a_wr:.1f}%") + st.metric("Avg Profit", f"${a_avg:,.2f}") + if a_dur: + st.metric("Avg Duration", f"{a_dur:.0f}m") + + with ac2: + st.markdown("**File B (Comparison)**") + b_net = matched['B_profit'].sum() + b_wr = (matched['B_profit'] > 0).mean() * 100 + b_avg = matched['B_profit'].mean() + b_dur = matched['B_duration'].mean() if 'B_duration' in matched else None + delta_net = b_net - a_net + st.metric("Net Profit", f"${b_net:,.2f}", + delta=f"{delta_net:+.2f}", delta_color="normal") + st.metric("Win Rate", f"{b_wr:.1f}%", + delta=f"{b_wr - a_wr:+.1f}%", delta_color="normal") + st.metric("Avg Profit", f"${b_avg:,.2f}", + delta=f"{b_avg - a_avg:+.2f}", delta_color="normal") + if b_dur and a_dur: + st.metric("Avg Duration", f"{b_dur:.0f}m", + delta=f"{b_dur - a_dur:+.0f}m", delta_color="off") + + # ── Slippage summary ─────────────────────────────────────────────────────── + st.divider() + st.subheader("Slippage & Variance Summary") + + sc1, sc2, sc3, sc4 = st.columns(4) + avg_open_slip = matched['open_slippage'].mean() + avg_close_slip = matched['close_slip'].mean() if 'close_slip' in matched else matched['close_slippage'].mean() + avg_profit_var = matched['profit_var'].mean() + avg_time_diff = matched['time_diff_min'].mean() + + sc1.metric("Avg Entry Slippage", f"{avg_open_slip:+.5f}" if pd.notna(avg_open_slip) else "N/A", + help="B open price minus A open price. Positive = B filled higher.") + sc2.metric("Avg Exit Slippage", f"{avg_close_slip:+.5f}" if pd.notna(avg_close_slip) else "N/A", + help="B close price minus A close price.") + sc3.metric("Avg Profit Variance", f"${avg_profit_var:+.2f}" if pd.notna(avg_profit_var) else "N/A", + help="B net profit minus A net profit per trade.") + sc4.metric("Avg Time Difference", f"{avg_time_diff:+.0f}m" if pd.notna(avg_time_diff) else "N/A", + help="B open time minus A open time in minutes.") + + # ── Equity curve overlay ─────────────────────────────────────────────────── + st.divider() + st.subheader("Equity Curve Overlay") + + m_sorted = matched.sort_values('A_open_time') + fig = go.Figure() + fig.add_trace(go.Scatter( + x=m_sorted['A_open_time'], + y=m_sorted['A_profit'].cumsum(), + mode='lines', name='File A', + line=dict(color='#7c6af7', width=2), + fill='tozeroy', fillcolor='rgba(124,106,247,0.05)' + )) + fig.add_trace(go.Scatter( + x=m_sorted['B_open_time'], + y=m_sorted['B_profit'].cumsum(), + mode='lines', name='File B', + line=dict(color='#2dc653', width=2), + fill='tozeroy', fillcolor='rgba(45,198,83,0.05)' + )) + fig.update_layout( + height=320, + plot_bgcolor='rgba(10,10,15,1)', + paper_bgcolor='rgba(10,10,15,1)', + font=dict(color='#aaa', family='JetBrains Mono'), + xaxis=dict(gridcolor='rgba(255,255,255,0.04)'), + yaxis=dict(gridcolor='rgba(255,255,255,0.04)', tickprefix='$'), + legend=dict(bgcolor='rgba(0,0,0,0.3)'), + margin=dict(l=60, r=20, t=20, b=40) + ) + st.plotly_chart(fig, use_container_width=True) + + # ── Profit variance scatter ──────────────────────────────────────────────── + st.subheader("Profit Variance per Trade") + fig2 = go.Figure() + colours = matched['profit_var'].apply( + lambda v: 'rgba(45,198,83,0.7)' if v >= 0 else 'rgba(230,57,70,0.7)' + ) + fig2.add_trace(go.Bar( + x=list(range(len(matched))), + y=matched['profit_var'], + marker_color=colours, + name='Profit Variance (B - A)' + )) + fig2.update_layout( + height=250, + plot_bgcolor='rgba(10,10,15,1)', + paper_bgcolor='rgba(10,10,15,1)', + font=dict(color='#aaa'), + xaxis=dict(gridcolor='rgba(255,255,255,0.04)', title='Trade #'), + yaxis=dict(gridcolor='rgba(255,255,255,0.04)', tickprefix='$'), + margin=dict(l=60, r=20, t=20, b=40) + ) + st.plotly_chart(fig2, use_container_width=True) + + # ── Matched trade table ──────────────────────────────────────────────────── + st.divider() + st.subheader("Matched Trade Detail") + + def colour_diff(val): + try: + v = float(str(val).replace('+', '')) + if v > 0: return 'color: #2dc653; font-weight: 600' + if v < 0: return 'color: #e63946; font-weight: 600' + except: + pass + return 'color: #666' + + def colour_profit_cell(val): + try: + v = float(str(val).replace(',', '')) + if v > 0: return 'background-color: rgba(0,180,0,0.10)' + if v < 0: return 'background-color: rgba(180,0,0,0.10)' + except: + pass + return '' + + display = matched[[ + 'A_open_time', 'A_symbol', 'A_type', + 'A_open_price', 'A_close_price', 'A_profit', 'A_duration', + 'B_open_time', + 'B_open_price', 'B_close_price', 'B_profit', 'B_duration', + 'open_slippage', 'close_slippage', 'profit_var', 'time_diff_min' + ]].copy() + + display.columns = [ + 'A Open Time', 'Symbol', 'Type', + 'A Entry', 'A Exit', 'A Profit', 'A Dur(m)', + 'B Open Time', + 'B Entry', 'B Exit', 'B Profit', 'B Dur(m)', + 'Entry Slip', 'Exit Slip', 'Profit Var', 'Time Diff(m)' + ] + + # Format numeric columns + for col in ['A Entry', 'A Exit', 'B Entry', 'B Exit']: + if col in display.columns: + display[col] = display[col].apply( + lambda x: f"{x:.5f}" if pd.notna(x) else '') + + for col in ['A Profit', 'B Profit', 'Profit Var']: + display[col] = display[col].apply( + lambda x: f"{x:+.2f}" if pd.notna(x) else '') + + for col in ['Entry Slip', 'Exit Slip']: + display[col] = display[col].apply( + lambda x: f"{x:+.5f}" if pd.notna(x) else '') + + st.dataframe( + display.style + .map(colour_diff, subset=['Entry Slip', 'Exit Slip', 'Profit Var', 'Time Diff(m)']) + .map(colour_profit_cell, subset=['A Profit', 'B Profit']), + use_container_width=True, hide_index=True, height=500 + ) + + # ── Export ──────────────────────────────────────────────────────────────── + st.download_button( + "⬇ Download matched trades CSV", + data = display.to_csv(index=False), + file_name = "trade_comparison.csv", + mime = 'text/csv' + ) diff --git a/view_set_comparator.py b/view_set_comparator.py new file mode 100644 index 0000000..5e78b10 --- /dev/null +++ b/view_set_comparator.py @@ -0,0 +1,215 @@ +""" +view_set_comparator.py +====================== +EA Settings Comparator page — migrated from main dashboard. +""" + +import streamlit as st +import pandas as pd +from datetime import datetime +import sys, os +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from set_comparator import parse_set_file, build_comparison_df, export_set_file, create_zip + + +def render(): + st.markdown(""" + + """, unsafe_allow_html=True) + + st.title("⚙ EA Settings Comparator") + + # ── Session state init ──────────────────────────────────────────────────── + if 'ea_files' not in st.session_state: st.session_state['ea_files'] = {} + if 'ea_raw' not in st.session_state: st.session_state['ea_raw'] = {} + if 'ea_order' not in st.session_state: st.session_state['ea_order'] = {} + if 'ea_bytes' not in st.session_state: st.session_state['ea_bytes'] = {} + if 'ea_edited' not in st.session_state: st.session_state['ea_edited'] = {} + + # ── Controls ────────────────────────────────────────────────────────────── + col1, col2, col3 = st.columns([1, 1, 4]) + with col1: + n_files = st.selectbox("Number of files", list(range(2, 11)), index=0) + with col2: + st.markdown("
", unsafe_allow_html=True) + if st.button("🗑 Clear All", type="secondary"): + for key in ['ea_files', 'ea_raw', 'ea_order', 'ea_bytes', 'ea_edited']: + st.session_state[key] = {} + st.rerun() + + # ── File upload slots ───────────────────────────────────────────────────── + st.divider() + upload_cols = st.columns(min(n_files, 5)) + for i in range(n_files): + with upload_cols[i % 5]: + uploaded = st.file_uploader( + f"File {i+1}", type=['set'], key=f"ea_upload_{i}" + ) + if uploaded is not None: + file_bytes = uploaded.read() + fname = uploaded.name + params, raw_lines, order = parse_set_file(file_bytes, fname) + st.session_state['ea_files'][fname] = params + st.session_state['ea_raw'][fname] = raw_lines + st.session_state['ea_order'][fname] = order + st.session_state['ea_bytes'][fname] = file_bytes + if fname not in st.session_state['ea_edited']: + st.session_state['ea_edited'][fname] = params.copy() + st.success(f"✓ {fname} — {len(params)} params") + + # ── Comparison table ────────────────────────────────────────────────────── + files_data = st.session_state['ea_files'] + + if len(files_data) >= 2: + st.divider() + + filenames = list(files_data.keys()) + + col1, col2, col3 = st.columns(3) + with col1: + source_file = st.selectbox("Source file for comparison", filenames) + with col2: + pct_threshold = st.slider("Highlight % variation from source", 0, 100, 10) + with col3: + show_diff_only = st.toggle("Show different rows only", value=False) + + files_list = [ + (fn, st.session_state['ea_files'][fn], + st.session_state['ea_raw'][fn], + st.session_state['ea_order'][fn]) + for fn in filenames + ] + df = build_comparison_df(files_list) + + value_cols = [c for c in df.columns if c != 'Parameter'] + df['_diff'] = df[value_cols].nunique(axis=1) > 1 + + df_display = df[df['_diff']].copy() if show_diff_only else df.copy() + df_display = df_display.drop(columns=['_diff']) + + source_vals = files_data.get(source_file, {}) + + def style_cells(row): + styles = [''] * len(row) + param = row['Parameter'] + src_v = source_vals.get(param, '') + for j, col in enumerate(row.index): + if col == 'Parameter': + continue + cell_v = row[col] + if col == source_file: + styles[j] = 'background-color: rgba(100,100,255,0.15)' + continue + if cell_v == '' or src_v == '': + if cell_v != src_v: + styles[j] = 'background-color: rgba(255,180,0,0.2)' + continue + try: + sv = float(src_v) + cv = float(cell_v) + if sv == 0: + if cv != 0: + styles[j] = 'background-color: rgba(255,100,100,0.2)' + else: + pct_diff = abs((cv - sv) / sv) * 100 + if pct_diff > pct_threshold: + styles[j] = 'background-color: rgba(255,100,100,0.2)' + elif pct_diff > 0: + styles[j] = 'background-color: rgba(255,180,0,0.15)' + except: + if cell_v != src_v: + styles[j] = 'background-color: rgba(255,180,0,0.2)' + return styles + + st.markdown( + f"**{len(df_display)} parameters** — " + f"{int(df[df['_diff']].shape[0])} rows differ across files" + ) + + styled = df_display.style.apply(style_cells, axis=1) + row_height = 35 + table_h = min(len(df_display) * row_height + 40, 2000) + + col_config = {'Parameter': st.column_config.TextColumn('Parameter', width='medium')} + for fn in filenames: + col_config[fn] = st.column_config.TextColumn(fn, width='small') + + st.dataframe( + styled, width='content', hide_index=True, + height=table_h, column_config=col_config + ) + + # ── Edit & Export ───────────────────────────────────────────────────── + st.divider() + st.subheader("Edit & Export") + + edit_file = st.selectbox("Select file to edit", filenames, key='ea_edit_sel') + + if edit_file: + edited_params = st.session_state['ea_edited'].get(edit_file, {}) + order = st.session_state['ea_order'].get(edit_file, []) + raw_lines = st.session_state['ea_raw'].get(edit_file, {}) + + edit_df = pd.DataFrame([ + {'Parameter': k, 'Value': edited_params.get(k, '')} + for k in order + ]) + + edited = st.data_editor( + edit_df, width='stretch', hide_index=True, height=400, + column_config={ + 'Parameter': st.column_config.TextColumn('Parameter', disabled=True), + 'Value' : st.column_config.TextColumn('Value'), + }, + key=f"ea_editor_{edit_file}" + ) + + st.session_state['ea_edited'][edit_file] = dict( + zip(edited['Parameter'], edited['Value'].astype(str)) + ) + + col1, col2 = st.columns(2) + with col1: + export_bytes = export_set_file( + edit_file, + st.session_state['ea_edited'][edit_file], + raw_lines, order, + st.session_state['ea_bytes'][edit_file] + ) + st.download_button( + label = f"⬇ Export {edit_file}", + data = export_bytes, + file_name = edit_file, + mime = 'application/octet-stream', + key = 'ea_export_single' + ) + + with col2: + all_exports = [] + for fn in filenames: + fb = export_set_file( + fn, + st.session_state['ea_edited'].get(fn, files_data[fn]), + st.session_state['ea_raw'][fn], + st.session_state['ea_order'][fn], + st.session_state['ea_bytes'][fn] + ) + all_exports.append((fn, fb)) + zip_bytes = create_zip(all_exports) + st.download_button( + label = "⬇ Export All as ZIP", + data = zip_bytes, + file_name = f"ea_settings_{datetime.today().strftime('%Y%m%d')}.zip", + mime = 'application/zip', + key = 'ea_export_all' + ) + + elif len(files_data) == 1: + st.info("Upload at least 2 files to compare") + else: + st.info("Upload .set files above to begin comparison") \ No newline at end of file diff --git a/view_settings.py b/view_settings.py new file mode 100644 index 0000000..39d7a04 --- /dev/null +++ b/view_settings.py @@ -0,0 +1,37 @@ +""" +pages/settings.py +================= +Settings page for MT5 Tools dashboard. +""" + +import streamlit as st + + +def render(): + st.title("⚙️ Settings") + + st.markdown(""" +
+ MT5 Tools — settings and information. +
+ """, unsafe_allow_html=True) + + st.subheader("About") + st.markdown(""" + **MT5 Tools** is a standalone trade analysis and comparison dashboard. + + **Supported file formats:** + - MT5 Account History HTML export (`.htm` / `.html`) + - MT5 Strategy Tester Backtest Report (`.htm` / `.html`) + - Quant Analyzer CSV export (`listOfTrades_*.csv`) + + **Pages:** + - **Trade Analysis** — statistics, equity curves, day/hour breakdown for a single report + - **Trade Compare** — match and compare two reports to measure slippage and variance + """) + + st.subheader("Requirements") + st.code("pip install streamlit streamlit-option-menu pandas plotly", language="bash") + + st.subheader("Launch") + st.code("streamlit run app.py", language="bash") diff --git a/view_trade_analysis.py b/view_trade_analysis.py new file mode 100644 index 0000000..19eb112 --- /dev/null +++ b/view_trade_analysis.py @@ -0,0 +1,333 @@ +""" +pages/trade_analysis.py +======================= +MT5 Trade Analysis page — migrated from main dashboard. +""" + +import streamlit as st +import plotly.graph_objects as go +import sys, os +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from mt5_parser import detect_and_parse, calc_stats + + +def render(): + st.title("📊 Trade Analysis") + + # ── Session state ───────────────────────────────────────────────────────── + if 'ta_df' not in st.session_state: + st.session_state['ta_df'] = None + st.session_state['ta_format'] = None + + # ── File upload ─────────────────────────────────────────────────────────── + col1, col2 = st.columns([4, 1]) + with col1: + uploaded = st.file_uploader( + "Upload MT5 Report (HTM/HTML) or Quant Analyzer CSV", + type=['html', 'htm', 'csv'], + key='ta_upload' + ) + with col2: + st.markdown("
", unsafe_allow_html=True) + if st.button("🗑 Clear", key='ta_clear'): + st.session_state['ta_df'] = None + st.session_state['ta_format'] = None + st.rerun() + + if uploaded: + df, fmt = detect_and_parse(uploaded.read(), uploaded.name) + if df is not None: + st.session_state['ta_df'] = df + st.session_state['ta_format'] = fmt + st.success(f"✓ Loaded {len(df)} trades — {fmt}") + else: + st.error("Could not parse report — check file format") + + df_all = st.session_state['ta_df'] + fmt = st.session_state['ta_format'] + + if df_all is None or len(df_all) == 0: + st.markdown(""" +
+ Upload an MT5 account history report (.htm/.html), MT5 backtest report, + or a Quant Analyzer CSV export to begin analysis. +
+ """, unsafe_allow_html=True) + return + + if fmt: + st.caption(f"Format detected: **{fmt}** · {len(df_all)} total trades") + + # ── Filters ─────────────────────────────────────────────────────────────── + st.divider() + fc1, fc2, fc3, fc4 = st.columns(4) + + with fc1: + date_min = df_all['open_time'].min().date() + date_max = df_all['open_time'].max().date() + date_from = st.date_input("From", value=date_min, min_value=date_min, + max_value=date_max, key='ta_from') + date_to = st.date_input("To", value=date_max, min_value=date_min, + max_value=date_max, key='ta_to') + + with fc2: + symbols = sorted(df_all['symbol'].dropna().unique().tolist()) + sel_symbol = st.multiselect("Symbol", symbols, key='ta_sym') + + with fc3: + strategies = sorted(df_all['strategy'].dropna().unique().tolist()) + sel_strategy = st.multiselect("Strategy / EA", strategies, key='ta_strat') + + with fc4: + days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'] + sel_days = st.multiselect("Day of week", days, key='ta_days') + sel_type = st.multiselect("Type", ['buy', 'sell'], key='ta_type') + + # Apply filters + df = df_all.copy() + df = df[(df['open_time'].dt.date >= date_from) & + (df['open_time'].dt.date <= date_to)] + if sel_symbol: + df = df[df['symbol'].isin(sel_symbol)] + if sel_strategy: + df = df[df['strategy'].isin(sel_strategy)] + if sel_days: + df = df[df['day_of_week'].isin(sel_days)] + if sel_type: + df = df[df['type'].isin(sel_type)] + + st.caption(f"Showing **{len(df)}** trades after filters") + + # ── Analysis mode ───────────────────────────────────────────────────────── + mode = st.radio( + "Analysis mode", + ["Overall", "By Strategy", "By Symbol", "By Day of Week"], + horizontal=True, key='ta_mode' + ) + st.divider() + + # ── Helpers ─────────────────────────────────────────────────────────────── + def render_stats(stats, label=""): + if label: + st.markdown(f"**{label}**") + + c1, c2, c3, c4, c5 = st.columns(5) + c1.metric("Net Profit", f"${stats['net_profit']:,.2f}") + c2.metric("Win Rate", f"{stats['win_rate']}%") + c3.metric("Profit Factor", f"{stats['profit_factor']}") + c4.metric("R:R Ratio", f"{stats['rr_ratio']}") + c5.metric("Expectancy", f"${stats['expectancy']:,.2f}") + + c1, c2, c3, c4, c5 = st.columns(5) + c1.metric("Total Trades", stats['total_trades']) + c2.metric("Avg Win", f"${stats['avg_win']:,.2f}") + c3.metric("Avg Loss", f"${stats['avg_loss']:,.2f}") + c4.metric("Max DD", f"${stats['max_drawdown']:,.2f}") + c5.metric("Best Trade", f"${stats['best_trade']:,.2f}") + + c1, c2, c3, c4, c5 = st.columns(5) + c1.metric("Max Consec Wins", stats['max_consec_wins']) + c2.metric("Max Consec Losses", stats['max_consec_losses']) + c3.metric("Avg Win Dur", f"{stats['avg_win_duration']}m") + c4.metric("Avg Loss Dur", f"{stats['avg_loss_duration']}m") + c5.metric("Worst Trade", f"${stats['worst_trade']:,.2f}") + + c1, c2, c3, c4 = st.columns(4) + c1.metric("Long Trades", stats['long_trades']) + c2.metric("Long Win Rate", f"{stats['long_win_rate']}%") + c3.metric("Short Trades", stats['short_trades']) + c4.metric("Short Win Rate",f"{stats['short_win_rate']}%") + + def render_equity_curve(df_plot, label="Equity Curve"): + df_s = df_plot.sort_values('close_time').copy() + df_s['cumulative'] = df_s['net_profit'].cumsum() + fig = go.Figure() + fig.add_trace(go.Scatter( + x=df_s['close_time'], y=df_s['cumulative'], + mode='lines', + line=dict(color='#7c6af7', width=2), + fill='tozeroy', + fillcolor='rgba(124,106,247,0.08)', + name='Equity' + )) + fig.update_layout( + title=label, height=300, + plot_bgcolor='rgba(10,10,15,1)', + paper_bgcolor='rgba(10,10,15,1)', + font=dict(color='#aaa', family='JetBrains Mono'), + xaxis=dict(gridcolor='rgba(255,255,255,0.04)'), + yaxis=dict(gridcolor='rgba(255,255,255,0.04)', tickprefix='$'), + margin=dict(l=60, r=20, t=40, b=40) + ) + st.plotly_chart(fig, use_container_width=True) + + def render_dow_chart(df_plot): + dow_order = ['Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday'] + dow = df_plot.groupby('day_of_week').agg( + trades = ('net_profit', 'count'), + net_profit = ('net_profit', 'sum'), + win_rate = ('win', lambda x: round(x.mean()*100, 1)) + ).reindex([d for d in dow_order if d in df_plot['day_of_week'].unique()]) + + wins_dow = df_plot[df_plot['win']].groupby('day_of_week')['net_profit'].sum().reindex(dow.index, fill_value=0) + losses_dow = df_plot[~df_plot['win']].groupby('day_of_week')['net_profit'].sum().reindex(dow.index, fill_value=0) + + fig = go.Figure() + fig.add_trace(go.Bar(x=dow.index, y=wins_dow, name='Profit', marker_color='rgba(45,198,83,0.8)')) + fig.add_trace(go.Bar(x=dow.index, y=losses_dow, name='Loss', marker_color='rgba(230,57,70,0.8)')) + fig.update_layout( + title='P&L by Day of Week', height=280, barmode='relative', + plot_bgcolor='rgba(10,10,15,1)', paper_bgcolor='rgba(10,10,15,1)', + font=dict(color='#aaa'), margin=dict(l=60, r=20, t=40, b=40), + xaxis=dict(gridcolor='rgba(255,255,255,0.04)'), + yaxis=dict(gridcolor='rgba(255,255,255,0.04)', tickprefix='$'), + legend=dict(bgcolor='rgba(0,0,0,0.3)') + ) + st.plotly_chart(fig, use_container_width=True) + + dt = dow.reset_index() + dt.columns = ['Day', 'Trades', 'Net Profit', 'Win Rate %'] + dt['Net Profit'] = dt['Net Profit'].round(2) + st.dataframe(dt, use_container_width=True, hide_index=True) + + def render_hour_chart(df_plot): + hourly = df_plot.groupby('hour').agg( + trades = ('net_profit', 'count'), + net_profit = ('net_profit', 'sum'), + ) + wins_h = df_plot[df_plot['win']].groupby('hour')['net_profit'].sum().reindex(hourly.index, fill_value=0) + losses_h = df_plot[~df_plot['win']].groupby('hour')['net_profit'].sum().reindex(hourly.index, fill_value=0) + + fig = go.Figure() + fig.add_trace(go.Bar(x=wins_h.index, y=wins_h, name='Profit', marker_color='rgba(45,198,83,0.8)')) + fig.add_trace(go.Bar(x=losses_h.index, y=losses_h, name='Loss', marker_color='rgba(230,57,70,0.8)')) + fig.update_layout( + title='P&L by Hour of Day', height=280, barmode='relative', + plot_bgcolor='rgba(10,10,15,1)', paper_bgcolor='rgba(10,10,15,1)', + font=dict(color='#aaa'), margin=dict(l=60, r=20, t=40, b=40), + xaxis=dict(gridcolor='rgba(255,255,255,0.04)', title='Hour (UTC)'), + yaxis=dict(gridcolor='rgba(255,255,255,0.04)', tickprefix='$'), + legend=dict(bgcolor='rgba(0,0,0,0.3)') + ) + st.plotly_chart(fig, use_container_width=True) + + def colour_profit(val): + try: + v = float(str(val).replace(',', '')) + if v > 0: return 'background-color: rgba(0,180,0,0.12)' + if v < 0: return 'background-color: rgba(180,0,0,0.12)' + except: + pass + return '' + + # ── Render mode ─────────────────────────────────────────────────────────── + if mode == "Overall": + stats = calc_stats(df) + render_stats(stats, "Overall Statistics") + render_equity_curve(df) + col1, col2 = st.columns(2) + with col1: + render_dow_chart(df) + with col2: + render_hour_chart(df) + + elif mode == "By Strategy": + strats = sorted(df['strategy'].dropna().unique().tolist()) + if not strats: + st.info("No strategies found") + else: + st.subheader("Strategy Comparison") + rows = [] + for s in strats: + sdf = df[df['strategy'] == s] + stat = calc_stats(sdf) + rows.append({ + 'Strategy' : s, + 'Trades' : stat['total_trades'], + 'Net Profit' : stat['net_profit'], + 'Win Rate %' : stat['win_rate'], + 'Profit Factor' : stat['profit_factor'], + 'R:R' : stat['rr_ratio'], + 'Expectancy' : stat['expectancy'], + 'Max DD' : stat['max_drawdown'], + 'Max Consec W' : stat['max_consec_wins'], + 'Max Consec L' : stat['max_consec_losses'], + }) + sdf_sum = __import__('pandas').DataFrame(rows).sort_values('Net Profit', ascending=False) + st.dataframe( + sdf_sum.style.map(colour_profit, subset=['Net Profit', 'Expectancy', 'Max DD']), + use_container_width=True, hide_index=True + ) + st.divider() + sel = st.selectbox("Select strategy for detail", strats) + if sel: + sdf = df[df['strategy'] == sel] + stat = calc_stats(sdf) + render_stats(stat, sel) + render_equity_curve(sdf, f"{sel} — Equity Curve") + col1, col2 = st.columns(2) + with col1: render_dow_chart(sdf) + with col2: render_hour_chart(sdf) + + elif mode == "By Symbol": + syms = sorted(df['symbol'].dropna().unique().tolist()) + rows = [] + for s in syms: + sdf = df[df['symbol'] == s] + stat = calc_stats(sdf) + rows.append({ + 'Symbol' : s, + 'Trades' : stat['total_trades'], + 'Net Profit' : stat['net_profit'], + 'Win Rate %' : stat['win_rate'], + 'Profit Factor' : stat['profit_factor'], + 'R:R' : stat['rr_ratio'], + 'Expectancy' : stat['expectancy'], + 'Max DD' : stat['max_drawdown'], + }) + sdf_sum = __import__('pandas').DataFrame(rows).sort_values('Net Profit', ascending=False) + st.dataframe( + sdf_sum.style.map(colour_profit, subset=['Net Profit', 'Expectancy', 'Max DD']), + use_container_width=True, hide_index=True + ) + sel = st.selectbox("Select symbol for detail", syms) + if sel: + sdf = df[df['symbol'] == sel] + stat = calc_stats(sdf) + render_stats(stat, sel) + render_equity_curve(sdf, f"{sel} — Equity Curve") + col1, col2 = st.columns(2) + with col1: render_dow_chart(sdf) + with col2: render_hour_chart(sdf) + + elif mode == "By Day of Week": + render_dow_chart(df) + render_hour_chart(df) + + # ── Raw trade log ───────────────────────────────────────────────────────── + st.divider() + with st.expander("Raw Trade Log"): + show_cols = ['open_time', 'close_time', 'symbol', 'type', 'strategy', + 'volume', 'open_price', 'close_price', 'sl', 'tp', + 'commission', 'swap', 'profit', 'net_profit', 'duration_min'] + show_cols = [c for c in show_cols if c in df.columns] + + def colour_net(val): + try: + v = float(val) + if v > 0: return 'background-color: rgba(0,180,0,0.12)' + if v < 0: return 'background-color: rgba(180,0,0,0.12)' + except: + pass + return '' + + st.dataframe( + df[show_cols].style.map(colour_net, subset=['net_profit', 'profit']), + use_container_width=True, hide_index=True, height=400 + ) + st.download_button( + "⬇ Download filtered trades CSV", + data = df[show_cols].to_csv(index=False), + file_name = f"mt5_trades_{date_from}_{date_to}.csv", + mime = 'text/csv' + ) diff --git a/view_trade_compare.py b/view_trade_compare.py new file mode 100644 index 0000000..935f4ae --- /dev/null +++ b/view_trade_compare.py @@ -0,0 +1,422 @@ +""" +pages/trade_compare.py +====================== +Side-by-side comparison of two trade history files. +Matches trades by symbol + type + open time within a tolerance window. +Highlights slippage, profit variance, and timing differences. +""" + +import streamlit as st +import pandas as pd +import plotly.graph_objects as go +import sys, os +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from mt5_parser import detect_and_parse, calc_stats + + +# ── Match trades ────────────────────────────────────────────────────────────── + +def match_trades(df_a, df_b, tolerance_hours): + """ + Match trades between two DataFrames. + Match criteria: same symbol_base + same type + open_time within tolerance. + Returns DataFrame of matched pairs with diff columns. + """ + tol = pd.Timedelta(hours=tolerance_hours) + matched = [] + used_b = set() + + for i, a in df_a.iterrows(): + best_match = None + best_delta = tol + pd.Timedelta(seconds=1) + + for j, b in df_b.iterrows(): + if j in used_b: + continue + if a['symbol_base'] != b['symbol_base']: + continue + if a['type'] != b['type']: + continue + delta = abs(a['open_time'] - b['open_time']) + if delta <= tol and delta < best_delta: + best_delta = delta + best_match = (j, b) + + if best_match: + j, b = best_match + used_b.add(j) + + open_slip = round(float(b['open_price']) - float(a['open_price']), 5) if pd.notna(a['open_price']) and pd.notna(b['open_price']) else None + close_slip = round(float(b['close_price']) - float(a['close_price']), 5) if pd.notna(a['close_price']) and pd.notna(b['close_price']) else None + profit_var = round(float(b['net_profit']) - float(a['net_profit']), 2) if pd.notna(a['net_profit']) and pd.notna(b['net_profit']) else None + time_diff = round((b['open_time'] - a['open_time']).total_seconds() / 60, 1) + dur_diff = round(float(b.get('duration_min', 0) or 0) - float(a.get('duration_min', 0) or 0), 1) + + matched.append({ + # File A + 'A_open_time' : a['open_time'], + 'A_close_time' : a['close_time'], + 'A_symbol' : a['symbol'], + 'A_type' : a['type'], + 'A_volume' : a.get('volume'), + 'A_open_price' : a.get('open_price'), + 'A_close_price': a.get('close_price'), + 'A_profit' : a.get('net_profit'), + 'A_duration' : a.get('duration_min'), + # File B + 'B_open_time' : b['open_time'], + 'B_close_time' : b['close_time'], + 'B_symbol' : b['symbol'], + 'B_type' : b['type'], + 'B_volume' : b.get('volume'), + 'B_open_price' : b.get('open_price'), + 'B_close_price': b.get('close_price'), + 'B_profit' : b.get('net_profit'), + 'B_duration' : b.get('duration_min'), + # Differences + 'open_slippage' : open_slip, + 'close_slippage': close_slip, + 'profit_var' : profit_var, + 'time_diff_min' : time_diff, + 'duration_diff' : dur_diff, + }) + + return pd.DataFrame(matched) + + +# ── Render ──────────────────────────────────────────────────────────────────── + +def render(): + st.title("🔄 Trade Compare") + + st.markdown(""" +
+ Compare two trade history files — backtest vs real account, or any two exports. + Trades are matched by symbol, direction, and open time within a configurable + tolerance window to account for gaps, slippage, and market open variations. +
+ """, unsafe_allow_html=True) + + # ── Session state ───────────────────────────────────────────────────────── + for k in ['tc_df_a', 'tc_df_b', 'tc_fmt_a', 'tc_fmt_b']: + if k not in st.session_state: + st.session_state[k] = None + + # ── File upload ─────────────────────────────────────────────────────────── + st.subheader("Load Files") + col_a, col_b = st.columns(2) + + with col_a: + st.markdown("**File A** — Reference (e.g. Backtest)") + up_a = st.file_uploader("Upload File A", type=['html','htm','csv'], key='tc_up_a') + if up_a: + df_a, fmt_a = detect_and_parse(up_a.read(), up_a.name) + if df_a is not None: + st.session_state['tc_df_a'] = df_a + st.session_state['tc_fmt_a'] = fmt_a + st.success(f"✓ {len(df_a)} trades — {fmt_a}") + else: + st.error("Could not parse File A") + if st.session_state['tc_df_a'] is not None: + st.caption(f"Loaded: **{st.session_state['tc_fmt_a']}** · {len(st.session_state['tc_df_a'])} trades") + + with col_b: + st.markdown("**File B** — Comparison (e.g. Real Account)") + up_b = st.file_uploader("Upload File B", type=['html','htm','csv'], key='tc_up_b') + if up_b: + df_b, fmt_b = detect_and_parse(up_b.read(), up_b.name) + if df_b is not None: + st.session_state['tc_df_b'] = df_b + st.session_state['tc_fmt_b'] = fmt_b + st.success(f"✓ {len(df_b)} trades — {fmt_b}") + else: + st.error("Could not parse File B") + if st.session_state['tc_df_b'] is not None: + st.caption(f"Loaded: **{st.session_state['tc_fmt_b']}** · {len(st.session_state['tc_df_b'])} trades") + + df_a = st.session_state['tc_df_a'] + df_b = st.session_state['tc_df_b'] + + if df_a is None or df_b is None: + return + + # ── Filters ─────────────────────────────────────────────────────────────── + st.divider() + st.subheader("Filters") + + fa1, fa2, fa3 = st.columns(3) + fb1, fb2, fb3 = st.columns(3) + + with fa1: + st.markdown("**File A filters**") + with fb1: + st.markdown("**File B filters**") + + col1, col2, col3, col4, col5, col6 = st.columns(6) + + with col1: + a_date_min = df_a['open_time'].min().date() + a_date_max = df_a['open_time'].max().date() + a_from = st.date_input("A — From", value=a_date_min, min_value=a_date_min, + max_value=a_date_max, key='tc_a_from') + a_to = st.date_input("A — To", value=a_date_max, min_value=a_date_min, + max_value=a_date_max, key='tc_a_to') + + with col2: + a_syms = sorted(df_a['symbol'].dropna().unique().tolist()) + a_sel_sym = st.multiselect("A — Symbol", a_syms, key='tc_a_sym') + + with col3: + a_strats = sorted(df_a['strategy'].dropna().unique().tolist()) + a_sel_strat = st.multiselect("A — Strategy", a_strats, key='tc_a_strat') + a_sel_type = st.multiselect("A — Type", ['buy', 'sell'], key='tc_a_type') + + with col4: + b_date_min = df_b['open_time'].min().date() + b_date_max = df_b['open_time'].max().date() + b_from = st.date_input("B — From", value=b_date_min, min_value=b_date_min, + max_value=b_date_max, key='tc_b_from') + b_to = st.date_input("B — To", value=b_date_max, min_value=b_date_min, + max_value=b_date_max, key='tc_b_to') + + with col5: + b_syms = sorted(df_b['symbol'].dropna().unique().tolist()) + b_sel_sym = st.multiselect("B — Symbol", b_syms, key='tc_b_sym') + + with col6: + b_strats = sorted(df_b['strategy'].dropna().unique().tolist()) + b_sel_strat = st.multiselect("B — Strategy", b_strats, key='tc_b_strat') + b_sel_type = st.multiselect("B — Type", ['buy', 'sell'], key='tc_b_type') + + # ── Matching tolerance ──────────────────────────────────────────────────── + st.divider() + col_tol, col_run = st.columns([3, 1]) + with col_tol: + tolerance = st.slider( + "Match tolerance (hours) — max time difference between A and B open times", + min_value=1, max_value=24, value=4, step=1, + help="Trades within this window are considered the same setup. " + "Increase for daily charts, decrease for intraday." + ) + with col_run: + st.markdown("
", unsafe_allow_html=True) + run = st.button("🔍 Match Trades", type="primary", use_container_width=True) + + if not run and 'tc_matched' not in st.session_state: + return + + # Apply filters + fa = df_a.copy() + fa = fa[(fa['open_time'].dt.date >= a_from) & (fa['open_time'].dt.date <= a_to)] + if a_sel_sym: fa = fa[fa['symbol'].isin(a_sel_sym)] + if a_sel_strat: fa = fa[fa['strategy'].isin(a_sel_strat)] + if a_sel_type: fa = fa[fa['type'].isin(a_sel_type)] + + fb = df_b.copy() + fb = fb[(fb['open_time'].dt.date >= b_from) & (fb['open_time'].dt.date <= b_to)] + if b_sel_sym: fb = fb[fb['symbol'].isin(b_sel_sym)] + if b_sel_strat: fb = fb[fb['strategy'].isin(b_sel_strat)] + if b_sel_type: fb = fb[fb['type'].isin(b_sel_type)] + + if run: + with st.spinner("Matching trades..."): + matched = match_trades(fa, fb, tolerance) + st.session_state['tc_matched'] = matched + st.session_state['tc_fa_len'] = len(fa) + st.session_state['tc_fb_len'] = len(fb) + + matched = st.session_state.get('tc_matched', pd.DataFrame()) + fa_len = st.session_state.get('tc_fa_len', len(fa)) + fb_len = st.session_state.get('tc_fb_len', len(fb)) + + if matched is None or len(matched) == 0: + st.warning("No matching trades found — try increasing the tolerance window or adjusting filters.") + return + + # ── Summary stats ───────────────────────────────────────────────────────── + st.divider() + st.subheader("Match Summary") + + m1, m2, m3, m4, m5 = st.columns(5) + m1.metric("File A Trades", fa_len) + m2.metric("File B Trades", fb_len) + m3.metric("Matched Pairs", len(matched)) + m4.metric("Unmatched A", fa_len - len(matched)) + m5.metric("Unmatched B", fb_len - len(matched)) + + st.divider() + + # ── Aggregate comparison ─────────────────────────────────────────────────── + st.subheader("Aggregate Comparison") + + ac1, ac2 = st.columns(2) + + with ac1: + st.markdown("**File A (Reference)**") + a_net = matched['A_profit'].sum() + a_wr = (matched['A_profit'] > 0).mean() * 100 + a_avg = matched['A_profit'].mean() + a_dur = matched['A_duration'].mean() if 'A_duration' in matched else None + st.metric("Net Profit", f"${a_net:,.2f}") + st.metric("Win Rate", f"{a_wr:.1f}%") + st.metric("Avg Profit", f"${a_avg:,.2f}") + if a_dur: + st.metric("Avg Duration", f"{a_dur:.0f}m") + + with ac2: + st.markdown("**File B (Comparison)**") + b_net = matched['B_profit'].sum() + b_wr = (matched['B_profit'] > 0).mean() * 100 + b_avg = matched['B_profit'].mean() + b_dur = matched['B_duration'].mean() if 'B_duration' in matched else None + delta_net = b_net - a_net + st.metric("Net Profit", f"${b_net:,.2f}", + delta=f"{delta_net:+.2f}", delta_color="normal") + st.metric("Win Rate", f"{b_wr:.1f}%", + delta=f"{b_wr - a_wr:+.1f}%", delta_color="normal") + st.metric("Avg Profit", f"${b_avg:,.2f}", + delta=f"{b_avg - a_avg:+.2f}", delta_color="normal") + if b_dur and a_dur: + st.metric("Avg Duration", f"{b_dur:.0f}m", + delta=f"{b_dur - a_dur:+.0f}m", delta_color="off") + + # ── Slippage summary ─────────────────────────────────────────────────────── + st.divider() + st.subheader("Slippage & Variance Summary") + + sc1, sc2, sc3, sc4 = st.columns(4) + avg_open_slip = matched['open_slippage'].mean() + avg_close_slip = matched['close_slip'].mean() if 'close_slip' in matched else matched['close_slippage'].mean() + avg_profit_var = matched['profit_var'].mean() + avg_time_diff = matched['time_diff_min'].mean() + + sc1.metric("Avg Entry Slippage", f"{avg_open_slip:+.5f}" if pd.notna(avg_open_slip) else "N/A", + help="B open price minus A open price. Positive = B filled higher.") + sc2.metric("Avg Exit Slippage", f"{avg_close_slip:+.5f}" if pd.notna(avg_close_slip) else "N/A", + help="B close price minus A close price.") + sc3.metric("Avg Profit Variance", f"${avg_profit_var:+.2f}" if pd.notna(avg_profit_var) else "N/A", + help="B net profit minus A net profit per trade.") + sc4.metric("Avg Time Difference", f"{avg_time_diff:+.0f}m" if pd.notna(avg_time_diff) else "N/A", + help="B open time minus A open time in minutes.") + + # ── Equity curve overlay ─────────────────────────────────────────────────── + st.divider() + st.subheader("Equity Curve Overlay") + + m_sorted = matched.sort_values('A_open_time') + fig = go.Figure() + fig.add_trace(go.Scatter( + x=m_sorted['A_open_time'], + y=m_sorted['A_profit'].cumsum(), + mode='lines', name='File A', + line=dict(color='#7c6af7', width=2), + fill='tozeroy', fillcolor='rgba(124,106,247,0.05)' + )) + fig.add_trace(go.Scatter( + x=m_sorted['B_open_time'], + y=m_sorted['B_profit'].cumsum(), + mode='lines', name='File B', + line=dict(color='#2dc653', width=2), + fill='tozeroy', fillcolor='rgba(45,198,83,0.05)' + )) + fig.update_layout( + height=320, + plot_bgcolor='rgba(10,10,15,1)', + paper_bgcolor='rgba(10,10,15,1)', + font=dict(color='#aaa', family='JetBrains Mono'), + xaxis=dict(gridcolor='rgba(255,255,255,0.04)'), + yaxis=dict(gridcolor='rgba(255,255,255,0.04)', tickprefix='$'), + legend=dict(bgcolor='rgba(0,0,0,0.3)'), + margin=dict(l=60, r=20, t=20, b=40) + ) + st.plotly_chart(fig, use_container_width=True) + + # ── Profit variance scatter ──────────────────────────────────────────────── + st.subheader("Profit Variance per Trade") + fig2 = go.Figure() + colours = matched['profit_var'].apply( + lambda v: 'rgba(45,198,83,0.7)' if v >= 0 else 'rgba(230,57,70,0.7)' + ) + fig2.add_trace(go.Bar( + x=list(range(len(matched))), + y=matched['profit_var'], + marker_color=colours, + name='Profit Variance (B - A)' + )) + fig2.update_layout( + height=250, + plot_bgcolor='rgba(10,10,15,1)', + paper_bgcolor='rgba(10,10,15,1)', + font=dict(color='#aaa'), + xaxis=dict(gridcolor='rgba(255,255,255,0.04)', title='Trade #'), + yaxis=dict(gridcolor='rgba(255,255,255,0.04)', tickprefix='$'), + margin=dict(l=60, r=20, t=20, b=40) + ) + st.plotly_chart(fig2, use_container_width=True) + + # ── Matched trade table ──────────────────────────────────────────────────── + st.divider() + st.subheader("Matched Trade Detail") + + def colour_diff(val): + try: + v = float(str(val).replace('+', '')) + if v > 0: return 'color: #2dc653; font-weight: 600' + if v < 0: return 'color: #e63946; font-weight: 600' + except: + pass + return 'color: #666' + + def colour_profit_cell(val): + try: + v = float(str(val).replace(',', '')) + if v > 0: return 'background-color: rgba(0,180,0,0.10)' + if v < 0: return 'background-color: rgba(180,0,0,0.10)' + except: + pass + return '' + + display = matched[[ + 'A_open_time', 'A_symbol', 'A_type', + 'A_open_price', 'A_close_price', 'A_profit', 'A_duration', + 'B_open_time', + 'B_open_price', 'B_close_price', 'B_profit', 'B_duration', + 'open_slippage', 'close_slippage', 'profit_var', 'time_diff_min' + ]].copy() + + display.columns = [ + 'A Open Time', 'Symbol', 'Type', + 'A Entry', 'A Exit', 'A Profit', 'A Dur(m)', + 'B Open Time', + 'B Entry', 'B Exit', 'B Profit', 'B Dur(m)', + 'Entry Slip', 'Exit Slip', 'Profit Var', 'Time Diff(m)' + ] + + # Format numeric columns + for col in ['A Entry', 'A Exit', 'B Entry', 'B Exit']: + if col in display.columns: + display[col] = display[col].apply( + lambda x: f"{x:.5f}" if pd.notna(x) else '') + + for col in ['A Profit', 'B Profit', 'Profit Var']: + display[col] = display[col].apply( + lambda x: f"{x:+.2f}" if pd.notna(x) else '') + + for col in ['Entry Slip', 'Exit Slip']: + display[col] = display[col].apply( + lambda x: f"{x:+.5f}" if pd.notna(x) else '') + + st.dataframe( + display.style + .map(colour_diff, subset=['Entry Slip', 'Exit Slip', 'Profit Var', 'Time Diff(m)']) + .map(colour_profit_cell, subset=['A Profit', 'B Profit']), + use_container_width=True, hide_index=True, height=500 + ) + + # ── Export ──────────────────────────────────────────────────────────────── + st.download_button( + "⬇ Download matched trades CSV", + data = display.to_csv(index=False), + file_name = "trade_comparison.csv", + mime = 'text/csv' + )