diff --git a/.gitignore b/.gitignore index f9a8a2b..10866a1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,8 @@ .venv/ mt5_batch_config.json __pycache__/ -*.pyc \ No newline at end of file +*.pyc +cache/ +mt5_accounts.json +ftp_config.json +ftp_accounts.json \ No newline at end of file diff --git a/.streamlit/config.toml b/.streamlit/config.toml index 7d1bf4b..e9e9e5e 100644 --- a/.streamlit/config.toml +++ b/.streamlit/config.toml @@ -1,7 +1,7 @@ [theme] -base = "dark" -primaryColor = "#7c6af7" -backgroundColor = "#0e1117" -secondaryBackgroundColor = "#1a1f2e" -textColor = "#fafafa" +base = "light" +primaryColor = "#2E75B6" +backgroundColor = "#ffffff" +secondaryBackgroundColor = "#f0f2f6" +textColor = "#1a1a1a" font = "sans serif" diff --git a/MT5Tools_FTP_Setup_Guide.docx b/MT5Tools_FTP_Setup_Guide.docx new file mode 100644 index 0000000..e512a88 Binary files /dev/null and b/MT5Tools_FTP_Setup_Guide.docx differ diff --git a/__pycache__/mt5_parser.cpython-314.pyc b/__pycache__/mt5_parser.cpython-314.pyc index 3f8f750..cf9eb19 100644 Binary files a/__pycache__/mt5_parser.cpython-314.pyc and b/__pycache__/mt5_parser.cpython-314.pyc differ diff --git a/__pycache__/view_settings.cpython-314.pyc b/__pycache__/view_settings.cpython-314.pyc index f8ad517..340374f 100644 Binary files a/__pycache__/view_settings.cpython-314.pyc 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 index 6ccd958..4e0c3e4 100644 Binary files a/__pycache__/view_trade_analysis.cpython-314.pyc and b/__pycache__/view_trade_analysis.cpython-314.pyc differ diff --git a/app.py b/app.py index 8e737d4..1d1b305 100644 --- a/app.py +++ b/app.py @@ -9,8 +9,6 @@ Launch: streamlit run app.py import streamlit as st from streamlit_option_menu import option_menu import importlib, sys, os -import view_settings -view_settings.inject_theme_css() # ── Page config ─────────────────────────────────────────────────────────────── st.set_page_config( @@ -144,8 +142,8 @@ with st.sidebar: st.markdown("---") page = option_menu( menu_title = None, - options = ["Trade Analysis", "Trade Compare", "Portfolio Builder", "Portfolio Master", "EA Comparator", "Batch Backtest", "Settings"], - icons = ["bar-chart-line", "arrow-left-right", "briefcase", "trophy", "sliders", "cpu", "gear"], + options = ["Trade Analysis", "Trade Compare", "Portfolio Builder", "Portfolio Master", "Live MT5 EAs", "EA Comparator", "Batch Backtest", "Settings"], + icons = ["bar-chart-line", "arrow-left-right", "briefcase", "trophy", "wifi", "sliders", "cpu", "gear"], default_index = 0, styles = { "container" : {"background-color": "transparent", "padding": "0"}, @@ -174,6 +172,7 @@ if page == "Trade Analysis": elif page == "Portfolio Builder": import view_portfolio_builder as p + importlib.reload(p) p.render() elif page == "Portfolio Master": @@ -195,6 +194,11 @@ elif page == "Batch Backtest": importlib.reload(p) p.render() +elif page == "Live MT5 EAs": + import view_live_mt5_eas as p + importlib.reload(p) + p.render() + elif page == "Settings": import view_settings as p importlib.reload(p) diff --git a/ftp_sync_cli.py b/ftp_sync_cli.py new file mode 100644 index 0000000..0e0d942 --- /dev/null +++ b/ftp_sync_cli.py @@ -0,0 +1,297 @@ +""" +ftp_sync_cli.py +=============== +CLI tool to pull MT5 published account history from FTP and display stats. +Run from MT5Tools folder with venv activated. + +Usage: + python ftp_sync_cli.py --host 192.168.1.x --user ftpuser --pass ftppass + python ftp_sync_cli.py --host 192.168.1.x --user ftpuser --pass ftppass --list + python ftp_sync_cli.py --host 192.168.1.x --user ftpuser --pass ftppass --account 12345 + python ftp_sync_cli.py --config (use saved config in ftp_config.json) + +Config is saved to ftp_config.json after first run (gitignored). +""" + +import argparse +import ftplib +import json +import os +import sys +from pathlib import Path +from datetime import datetime + +CONFIG_FILE = Path(__file__).parent / "ftp_config.json" +CACHE_DIR = Path(__file__).parent / "cache" + + +# ── Config ──────────────────────────────────────────────────────────────────── + +def load_config() -> dict: + if CONFIG_FILE.exists(): + return json.loads(CONFIG_FILE.read_text()) + return {} + + +def save_config(cfg: dict): + CONFIG_FILE.write_text(json.dumps(cfg, indent=2)) + print(f"Config saved to {CONFIG_FILE}") + + +# ── FTP helpers ─────────────────────────────────────────────────────────────── + +def connect_ftp(host: str, user: str, password: str, port: int = 21) -> ftplib.FTP: + ftp = ftplib.FTP() + ftp.connect(host, port, timeout=10) + ftp.login(user, password) + ftp.set_pasv(True) + return ftp + + +def list_accounts(ftp: ftplib.FTP) -> list: + """List top-level directories on FTP — each should be an account folder.""" + items = [] + ftp.retrlines("LIST", items.append) + folders = [] + for item in items: + parts = item.split() + if item.startswith("d") and parts: + folders.append(parts[-1]) + return folders + + +def find_report_file(ftp: ftplib.FTP, account_folder: str) -> str | None: + """ + Find the HTML report file inside an account folder. + MT5 typically publishes as: account_folder/report.htm or account_folder/Report.htm + """ + try: + ftp.cwd(f"/{account_folder}") + except ftplib.error_perm: + try: + ftp.cwd(account_folder) + except ftplib.error_perm: + return None + + files = [] + ftp.retrlines("NLST", files.append) + for f in files: + if f.lower().endswith(('.htm', '.html')): + return f + return None + + +def download_report(ftp: ftplib.FTP, account_folder: str, + filename: str) -> bytes: + """Download report file and return raw bytes.""" + buf = [] + ftp.retrbinary(f"RETR {filename}", buf.append) + return b"".join(buf) + + +# ── Parse + display ─────────────────────────────────────────────────────────── + +def display_stats(stats: dict, fmt: str, account_folder: str): + """Print stats to console in a readable format.""" + sep = "─" * 60 + print(f"\n{sep}") + print(f" Account: {account_folder} | Format: {fmt}") + print(sep) + + rows = [ + ("Net Profit", f"${stats.get('net_profit', 0):,.2f}"), + ("Total Trades", stats.get('total_trades', 0)), + ("Win Rate", f"{stats.get('win_rate', 0)}%"), + ("Profit Factor", stats.get('profit_factor', 0)), + ("R:R Ratio", stats.get('rr_ratio', 0)), + ("Expectancy", f"${stats.get('expectancy', 0):,.2f}"), + ("Max Drawdown", f"${stats.get('max_drawdown', 0):,.2f}"), + ("Avg Win", f"${stats.get('avg_win', 0):,.2f}"), + ("Avg Loss", f"${stats.get('avg_loss', 0):,.2f}"), + ("Best Trade", f"${stats.get('best_trade', 0):,.2f}"), + ("Worst Trade", f"${stats.get('worst_trade', 0):,.2f}"), + ("Max Consec Wins", stats.get('max_consec_wins', 0)), + ("Max Consec Loss", stats.get('max_consec_losses', 0)), + ("Trading Days", stats.get('trading_days', 0)), + ("Trades/Day", stats.get('trades_per_day', 0)), + ("Long Trades", f"{stats.get('long_trades', 0)} ({stats.get('long_win_rate', 0)}% WR)"), + ("Short Trades", f"{stats.get('short_trades', 0)} ({stats.get('short_win_rate', 0)}% WR)"), + ] + + for label, value in rows: + print(f" {label:<22} {value}") + print(sep) + + +def display_monthly(df): + """Print monthly P&L breakdown.""" + import pandas as pd + if df is None or df.empty: + return + tmp = df[['close_time', 'net_profit']].dropna().copy() + tmp['close_time'] = pd.to_datetime(tmp['close_time'], errors='coerce') + tmp['ym'] = tmp['close_time'].dt.strftime('%Y-%m') + monthly = tmp.groupby('ym')['net_profit'].sum().sort_index() + + print("\n Monthly P&L:") + print(" " + "─" * 30) + for ym, pnl in monthly.items(): + bar = "█" * min(int(abs(pnl) / 10), 30) + sign = "+" if pnl >= 0 else "" + color = "\033[92m" if pnl >= 0 else "\033[91m" + reset = "\033[0m" + print(f" {ym} {color}{sign}${pnl:>8.2f} {bar}{reset}") + print() + + +def display_recent_trades(df, n=10): + """Print the most recent N trades.""" + if df is None or df.empty: + return + import pandas as pd + df = df.sort_values('close_time', ascending=False).head(n) + print(f"\n Last {n} trades:") + print(" " + "─" * 70) + print(f" {'Date':<22} {'Symbol':<12} {'Type':<6} {'Profit':>10}") + print(" " + "─" * 70) + for _, row in df.iterrows(): + pnl = row.get('net_profit', 0) + color = "\033[92m" if pnl >= 0 else "\033[91m" + reset = "\033[0m" + print(f" {str(row.get('close_time','')):<22} " + f"{str(row.get('symbol','')):<12} " + f"{str(row.get('type','')):<6} " + f"{color}${pnl:>9.2f}{reset}") + print() + + +# ── Main ────────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser( + description="Pull MT5 FTP published reports and display stats", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__ + ) + parser.add_argument("--host", help="FTP host/IP") + parser.add_argument("--user", help="FTP username") + parser.add_argument("--password","--pass", dest="password", help="FTP password") + parser.add_argument("--port", type=int, default=21, help="FTP port (default: 21)") + parser.add_argument("--account", help="Account folder name (default: first found)") + parser.add_argument("--list", action="store_true", help="List account folders and exit") + parser.add_argument("--config", action="store_true", help="Use saved ftp_config.json") + parser.add_argument("--save", action="store_true", help="Save connection details to ftp_config.json") + parser.add_argument("--trades", type=int, default=10, metavar="N", help="Show last N trades (default: 10)") + parser.add_argument("--no-monthly", action="store_true", help="Skip monthly breakdown") + parser.add_argument("--save-cache", action="store_true", help="Save parsed data to cache/ folder") + args = parser.parse_args() + + # Load from config if requested + cfg = {} + if args.config or (not args.host): + cfg = load_config() + if not cfg: + print("No ftp_config.json found. Run with --host, --user, --password first.") + sys.exit(1) + + host = args.host or cfg.get("host") + user = args.user or cfg.get("user") + password = args.password or cfg.get("password") + port = args.port or cfg.get("port", 21) + + if not all([host, user, password]): + parser.print_help() + sys.exit(1) + + if args.save: + save_config({"host": host, "user": user, "password": password, "port": port}) + + # Connect + print(f"\nConnecting to {host}:{port}...") + try: + ftp = connect_ftp(host, user, password, port) + print(f"✓ Connected as {user}") + except Exception as e: + print(f"✗ Connection failed: {e}") + sys.exit(1) + + # List accounts + accounts = list_accounts(ftp) + if not accounts: + print("No account folders found on FTP root.") + ftp.quit() + sys.exit(1) + + print(f"Found {len(accounts)} folder(s): {', '.join(accounts)}") + + if args.list: + ftp.quit() + return + + # Pick account + target = args.account or accounts[0] + if target not in accounts: + print(f"Account folder '{target}' not found. Available: {', '.join(accounts)}") + ftp.quit() + sys.exit(1) + + # Find and download report + print(f"\nLooking for report in '{target}'...") + report_file = find_report_file(ftp, target) + if not report_file: + print(f"No .htm/.html file found in '{target}'") + ftp.quit() + sys.exit(1) + + print(f"Downloading {report_file}...") + try: + raw = download_report(ftp, target, report_file) + ftp.quit() + print(f"✓ Downloaded {len(raw):,} bytes") + except Exception as e: + print(f"✗ Download failed: {e}") + ftp.quit() + sys.exit(1) + + # Parse + print("Parsing report...") + try: + sys.path.insert(0, str(Path(__file__).parent)) + from mt5_parser import detect_and_parse, calc_stats + df, fmt = detect_and_parse(raw, f"{target}.htm") + if df is None or df.empty: + print("✗ Could not parse report — check file format") + sys.exit(1) + print(f"✓ Parsed {len(df)} trades — format: {fmt}") + except ImportError: + print("✗ mt5_parser.py not found — run from MT5Tools folder") + sys.exit(1) + + # Stats + stats = calc_stats(df) + display_stats(stats, fmt, target) + + if not args.no_monthly: + display_monthly(df) + + if args.trades > 0: + display_recent_trades(df, args.trades) + + # Save cache + if args.save_cache: + import pickle + CACHE_DIR.mkdir(exist_ok=True) + cache_data = { + "account_folder": target, + "df" : df, + "stats" : stats, + "fmt" : fmt, + "fetched_at" : datetime.now().isoformat(), + } + cache_file = CACHE_DIR / f"ftp_{target}.pkl" + cache_file.write_bytes(pickle.dumps(cache_data)) + print(f"Cache saved to {cache_file}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/mt5_parser.py b/mt5_parser.py index a8de624..56601d1 100644 --- a/mt5_parser.py +++ b/mt5_parser.py @@ -42,7 +42,36 @@ def _to_dt(s, fmt='%Y.%m.%d %H:%M:%S'): return pd.to_datetime(s, format=fmt, errors='coerce') -def _enrich(df): +def _strategy_from_filename(filename): + """ + Derive a clean strategy name from a filename. + Strips leading date prefix (DD_MM_YYYY_ or YYYY_MM_DD_) and file extension. + E.g. '22_03_2026GoldPhantomModerate.csv' -> 'GoldPhantomModerate' + 'GoldPhantom_XAUUSD_Daily_OHLC_A.htm' -> 'GoldPhantom' + """ + import os + stem = os.path.splitext(os.path.basename(filename))[0] + # Strip leading date prefix like 22_03_2026 or 2026_03_22 (with optional separator) + stem = re.sub(r'^\d{2}_\d{2}_\d{4}', '', stem) + stem = re.sub(r'^\d{4}_\d{2}_\d{2}', '', stem) + # Strip leading underscores/hyphens left after date removal + stem = stem.lstrip('_-') + # If underscore-delimited, take only parts that look like a name (not symbol/period/model) + parts = stem.split('_') + clean = [] + for p in parts: + # Stop at parts that look like: instrument suffix (.a), timeframe (H1/M15/Daily), + # model label (OHLC/EVERYTICK), or single uppercase letter (instance A/B/C) + if re.match(r'^(H\d+|M\d+|Daily|Weekly|Monthly|OHLC|EVERYTICK|CTRLPTS|[A-Z])$', p): + break + if re.match(r'^[A-Z]{3,8}(\.a)?$', p): + break + clean.append(p) + result = '_'.join(clean) if clean else stem + return result if result else stem + + +def _enrich(df, fallback_strategy=None): """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') @@ -70,6 +99,10 @@ def _enrich(df): if 'comment' not in df.columns: df['comment'] = '' df['strategy'] = df['comment'].apply(extract_strategy) + # If every trade resolved to 'Manual' and a fallback name was supplied + # (e.g. derived from the filename), use it instead. + if fallback_strategy and (df['strategy'] == 'Manual').all(): + df['strategy'] = fallback_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 @@ -77,7 +110,7 @@ def _enrich(df): # ── Format 1: Real Account HTM ──────────────────────────────────────────────── -def parse_mt5_report(file_bytes): +def parse_mt5_report(file_bytes, fallback_strategy=None): """Parse MT5 real account HTML trade history report.""" text = _decode(file_bytes) rows = re.findall(r']*>(.*?)', text, re.DOTALL) @@ -120,12 +153,12 @@ def parse_mt5_report(file_bytes): df = pd.DataFrame(trades) df['source'] = 'real' - return _enrich(df) + return _enrich(df, fallback_strategy=fallback_strategy) # ── Format 2: Backtest HTM ──────────────────────────────────────────────────── -def parse_backtest_report(file_bytes): +def parse_backtest_report(file_bytes, fallback_strategy=None): """ Parse MT5 Strategy Tester HTML report. Pairs in/out deals into complete trades. @@ -203,7 +236,7 @@ def parse_backtest_report(file_bytes): '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', ''), + 'comment' : entry.get('comment', '') or deal.get('comment', ''), 'position' : entry.get('deal', ''), }) @@ -212,12 +245,12 @@ def parse_backtest_report(file_bytes): df = pd.DataFrame(trades) df['source'] = 'backtest' - return _enrich(df) + return _enrich(df, fallback_strategy=fallback_strategy) # ── Format 3: Quant Analyzer CSV ───────────────────────────────────────────── -def parse_quant_csv(file_bytes): +def parse_quant_csv(file_bytes, fallback_strategy=None): """Parse Quant Analyzer listOfTrades CSV export.""" try: text = file_bytes.decode('utf-8-sig') @@ -286,7 +319,71 @@ def parse_quant_csv(file_bytes): if extra in df_raw.columns: df[extra] = pd.to_numeric(df_raw[extra], errors='coerce') - return _enrich(df) + return _enrich(df, fallback_strategy=fallback_strategy) + + + + +def parse_open_positions(file_bytes) -> 'pd.DataFrame | None': + """Parse the Open Positions section from MT5 account history HTML.""" + import pandas as pd + text = _decode(file_bytes) + rows = re.findall(r']*>(.*?)', text, re.DOTALL) + + in_open = False + in_orders = False + positions = [] + + for row in rows: + cells = re.findall(r']*>(.*?)', row, re.DOTALL) + cells = [re.sub(r'\s+', ' ', _strip(c)).strip() for c in cells] + cells = [c for c in cells if c] + + if not cells: + continue + + flat = ' '.join(cells) + if 'Open Positions' in flat: + in_open = True + in_orders = False + continue + if 'Working Orders' in flat or 'Pending Orders' in flat: + in_orders = True + in_open = False + continue + if 'Results' in flat or 'Closed Positions' in flat or 'Balance:' in flat: + if in_open or in_orders: + break + + # Header row + if cells[0] in ('Time', 'Open Time'): + continue + + # Open position row: Time, Position, Symbol, Type, Volume, Price, SL, TP + # followed sometimes by a profit row (fewer cols) + if in_open and len(cells) >= 6 and re.match(r'\d{4}\.', cells[0]): + try: + vol_str = cells[4].split('/')[0].strip() + positions.append({ + 'open_time' : _to_dt(cells[0]), + 'position' : cells[1], + 'symbol' : cells[2], + 'type' : cells[3].lower(), + 'volume' : _to_float(vol_str), + 'open_price' : _to_float(cells[5]), + 'sl' : _to_float(cells[6]) if len(cells) > 6 else None, + 'tp' : _to_float(cells[7]) if len(cells) > 7 else None, + 'status' : 'open', + }) + except Exception: + pass + + if not positions: + return None + + df = pd.DataFrame(positions) + df['symbol_base'] = df['symbol'].str.replace(r'\.[a-z]+$', '', regex=True).str.upper() + return df # ── Auto-detect format ──────────────────────────────────────────────────────── @@ -297,9 +394,13 @@ def detect_and_parse(file_bytes, filename=''): Returns (df, format_name) or (None, None). """ fname = filename.lower() + # Derive a fallback strategy name from the filename for files where + # all comments are MT5 close-reason tags (sl/tp/so) and no strategy + # name is embedded in the comment field. + fallback = _strategy_from_filename(filename) if filename else None if fname.endswith('.csv'): - df = parse_quant_csv(file_bytes) + df = parse_quant_csv(file_bytes, fallback_strategy=fallback) return df, 'Quant Analyzer CSV' # HTML/HTM — detect backtest vs real account @@ -309,16 +410,16 @@ def detect_and_parse(file_bytes, filename=''): return None, None if 'Strategy Tester Report' in text or 'strategy tester' in text.lower(): - df = parse_backtest_report(file_bytes) + df = parse_backtest_report(file_bytes, fallback_strategy=fallback) return df, 'MT5 Backtest Report' - df = parse_mt5_report(file_bytes) + df = parse_mt5_report(file_bytes, fallback_strategy=fallback) return df, 'MT5 Account History' # ── Stats ───────────────────────────────────────────────────────────────────── -def calc_stats(df): +def calc_stats(df, deposit=0.0): if df is None or len(df) == 0: return {} @@ -340,8 +441,15 @@ def calc_stats(df): 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) + # Balance series: deposit + cumulative P&L (matches MT5 Balance Drawdown Maximal) + balance = deposit + cumulative + rolling_max = balance.cummax() + drawdown_ser = balance - rolling_max + max_dd = round(drawdown_ser.min(), 2) + peak_equity = round(rolling_max.max(), 2) + # % = max_dd / local peak at the point of max drawdown + peak_at_dd = rolling_max.loc[drawdown_ser.idxmin()] if not drawdown_ser.empty else peak_equity + max_dd_pct = round(max_dd / peak_at_dd * 100, 2) if peak_at_dd != 0 else 0 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 @@ -369,6 +477,8 @@ def calc_stats(df): 'max_consec_wins' : max_cw, 'max_consec_losses' : max_cl, 'max_drawdown' : max_dd, + 'max_drawdown_pct' : max_dd_pct, + 'peak_equity' : peak_equity, 'best_trade' : round(df['net_profit'].max(), 2), 'worst_trade' : round(df['net_profit'].min(), 2), 'avg_duration_min' : avg_dur, @@ -382,14 +492,13 @@ def calc_stats(df): def extract_strategy(comment): - if not comment or str(comment).strip() == '': + if not comment or str(comment).strip() in ('', 'nan'): 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) + s = str(comment).strip() + # Filter out MT5 close-reason comments: "sl 1234.56", "tp 1234.56", "so 50%" + if re.match(r'^(sl|tp|so)\s+[\d\.]+%?$', s, re.IGNORECASE): + return 'Manual' + return s def _max_consec(results, target): diff --git a/view_live_mt5_eas.py b/view_live_mt5_eas.py new file mode 100644 index 0000000..7b5a515 --- /dev/null +++ b/view_live_mt5_eas.py @@ -0,0 +1,1200 @@ +""" +view_ftp_tracker.py +=================== +FTP-based multi-account MT5 tracker. +Pulls HTML reports from FTP, parses via mt5_parser, shows calendar + analysis. + +Config: ftp_accounts.json (labels, balances per account) +Cache: cache/ftp_*.pkl (parsed DataFrames, refreshed on demand) +FTP: ftp_config.json (host/user/pass) +""" + +import streamlit as st +import pandas as pd +import plotly.graph_objects as go +from datetime import date, datetime, timedelta +import calendar +import pickle +import json +import ftplib +from pathlib import Path + +CONFIG_FILE = Path("ftp_config.json") +ACCOUNTS_FILE = Path("ftp_accounts.json") +CACHE_DIR = Path("cache") +CACHE_MAX_AGE = 5 # minutes before auto-refresh on load + + +# ── Config helpers ───────────────────────────────────────────────────────────── + +def load_ftp_config() -> dict: + if CONFIG_FILE.exists(): + return json.loads(CONFIG_FILE.read_text()) + return {} + + +def load_account_configs() -> list: + if ACCOUNTS_FILE.exists(): + return json.loads(ACCOUNTS_FILE.read_text()) + return [] + + +def save_account_configs(accounts: list): + ACCOUNTS_FILE.write_text(json.dumps(accounts, indent=2)) + + +# ── FTP + parse ──────────────────────────────────────────────────────────────── + +def ftp_list_accounts(cfg: dict) -> list: + ftp = ftplib.FTP() + ftp.connect(cfg["host"], cfg.get("port", 21), timeout=10) + ftp.login(cfg["user"], cfg["password"]) + ftp.set_pasv(True) + items = [] + ftp.retrlines("LIST", items.append) + folders = [i.split()[-1] for i in items if i.startswith("d")] + ftp.quit() + return folders + + +def ftp_download_report(cfg: dict, account_folder: str) -> bytes | None: + ftp = ftplib.FTP() + ftp.connect(cfg["host"], cfg.get("port", 21), timeout=15) + ftp.login(cfg["user"], cfg["password"]) + ftp.set_pasv(True) + try: + ftp.cwd(f"/{account_folder}") + except ftplib.error_perm: + ftp.quit() + return None + files = [] + ftp.retrlines("NLST", files.append) + htm = next((f for f in files if f.lower().endswith(('.htm','.html'))), None) + if not htm: + ftp.quit() + return None + buf = [] + ftp.retrbinary(f"RETR {htm}", buf.append) + ftp.quit() + return b"".join(buf) + + +def _extract_report_date(raw: bytes) -> str | None: + """Extract the report generation date from MT5 HTML report header.""" + import re + for enc in ['utf-16', 'utf-8', 'latin-1']: + try: + text = raw.decode(enc) + break + except Exception: + text = None + if not text: + return None + # Look for Date: 2026.04.17 20:06 pattern in table cells + # Strip tags first so whitespace/newlines between 'Date:' and value don't block match + text_clean = re.sub(r'<[^>]+>', ' ', text) + match = re.search(r'Date:\s*(\d{4}\.\d{2}\.\d{2}\s+\d{2}:\d{2})', text_clean) + if match: + try: + from datetime import datetime as _dt + return _dt.strptime(match.group(1).strip(), '%Y.%m.%d %H:%M').isoformat() + except Exception: + return None + return None + + +def refresh_account(cfg: dict, account_folder: str, label: str = "") -> dict: + """Download, parse, cache one account. Returns {df, stats, error}.""" + from mt5_parser import detect_and_parse, calc_stats + raw = ftp_download_report(cfg, account_folder) + if raw is None: + return {"error": f"No report found for {account_folder}"} + df, fmt = detect_and_parse(raw, f"{account_folder}.htm") + if df is None or df.empty: + return {"error": f"Could not parse report for {account_folder}"} + stats = calc_stats(df) + # Also parse open positions if present + from mt5_parser import parse_open_positions + df_open = parse_open_positions(raw) + data = { + "account_folder": account_folder, + "label" : label or account_folder, + "df" : df, + "stats" : stats, + "fmt" : fmt, + "df_open" : df_open, + "fetched_at" : datetime.now().isoformat(), + "report_date" : _extract_report_date(raw), + "error" : None, + } + CACHE_DIR.mkdir(exist_ok=True) + (CACHE_DIR / f"ftp_{account_folder}.pkl").write_bytes(pickle.dumps(data)) + return data + + +def load_cache(account_folder: str) -> dict | None: + p = CACHE_DIR / f"ftp_{account_folder}.pkl" + if not p.exists(): + return None + try: + return pickle.loads(p.read_bytes()) + except Exception: + return None + + +def cache_age_minutes(account_folder: str) -> float: + p = CACHE_DIR / f"ftp_{account_folder}.pkl" + if not p.exists(): + return float("inf") + return (datetime.now().timestamp() - p.stat().st_mtime) / 60 + + +def get_all_cached() -> list[dict]: + if not CACHE_DIR.exists(): + return [] + out = [] + for p in sorted(CACHE_DIR.glob("ftp_*.pkl")): + try: + out.append(pickle.loads(p.read_bytes())) + except Exception: + pass + return out + + +# ── Render ───────────────────────────────────────────────────────────────────── + +def render(): + st.title("📡 Live MT5 EA's") + + ftp_cfg = load_ftp_config() + if not ftp_cfg: + st.error("No FTP config found. Run `python ftp_sync_cli.py --host ... --save` first.") + return + + acc_cfgs = load_account_configs() + + # ── Account config expander ────────────────────────────────────────────── + with st.expander("⚙️ Account Configuration", expanded=not acc_cfgs): + st.caption("Add accounts by folder name (must match FTP folder). Set label and starting balance.") + + # ── Add account form ────────────────────────────────────────────────── + st.markdown("**Add Account**") + add_c1, add_c2, add_c3, add_c4, add_c5 = st.columns([2, 2, 2, 2, 1]) + new_folder = add_c1.text_input("FTP Folder", placeholder="144032", + key="cfg_new_folder") + new_label = add_c2.text_input("Label", placeholder="Gold EA", + key="cfg_new_label") + new_balance = add_c3.number_input("Starting Balance ($)", value=10000.0, + min_value=0.0, step=1000.0, format="%.0f", + key="cfg_new_balance") + new_type = add_c4.selectbox("Type", ["Demo","Personal","Prop"], + key="cfg_new_type") + add_c5.markdown("
", unsafe_allow_html=True) + if add_c5.button("➕ Add", key="cfg_add"): + if not new_folder.strip(): + st.error("FTP folder name is required.") + else: + # Verify folder exists on FTP + try: + ftp_folders = ftp_list_accounts(ftp_cfg) + if new_folder.strip() not in ftp_folders: + st.error(f"Folder `{new_folder}` not found on FTP. " + f"Available: {', '.join(ftp_folders)}") + else: + existing_accs = load_account_configs() + if any(a["account"] == new_folder.strip() for a in existing_accs): + st.warning(f"Account `{new_folder}` already configured.") + else: + existing_accs.append({ + "account": new_folder.strip(), + "label" : new_label.strip() or new_folder.strip(), + "balance": float(new_balance), + "type" : new_type, + }) + save_account_configs(existing_accs) + acc_cfgs = existing_accs + st.success(f"✓ Added `{new_folder}`") + st.rerun() + except Exception as e: + st.error(f"FTP error: {e}") + + # ── Existing accounts ───────────────────────────────────────────────── + if acc_cfgs: + st.markdown("**Configured Accounts**") + hdr = st.columns([1, 2, 2, 2, 2, 1]) + hdr[0].markdown("**Folder**") + hdr[1].markdown("**Label**") + hdr[2].markdown("**Balance ($)**") + hdr[3].markdown("**Type**") + hdr[4].markdown("**Last Report**") + hdr[5].markdown("**Remove**") + updated = [] + for idx_ac, ac in enumerate(acc_cfgs): + if idx_ac > 0: + st.divider() + c1, c2, c3, c4, c5, c6 = st.columns([1, 2, 2, 2, 2, 1]) + c1.markdown(f"`{ac['account']}`") + label = c2.text_input("", value=ac.get("label", ac["account"]), + key=f"lbl_{ac['account']}", + label_visibility="collapsed") + balance = c3.number_input("", value=float(ac.get("balance", 10000)), + min_value=0.0, step=1000.0, format="%.0f", + key=f"bal_{ac['account']}", + label_visibility="collapsed") + acc_type = c4.selectbox("", ["Demo", "Personal", "Prop"], + index=["Demo","Personal","Prop"].index( + ac.get("type","Demo")), + key=f"type_{ac['account']}", + label_visibility="collapsed") + # Prop-specific target/loss fields + if acc_type == "Prop": + prop_c1, prop_c2, prop_c3 = st.columns(3) + profit_target = prop_c1.number_input( + "Profit target %", + value=float(ac.get("profit_target", 10.0)), + min_value=0.0, max_value=100.0, step=1.0, format="%.1f", + key=f"pt_{ac['account']}") + max_loss = prop_c2.number_input( + "Max loss %", + value=float(ac.get("max_loss", 10.0)), + min_value=0.0, max_value=100.0, step=1.0, format="%.1f", + key=f"ml_{ac['account']}") + daily_loss = prop_c3.number_input( + "Daily loss %", + value=float(ac.get("daily_loss", 5.0)), + min_value=0.0, max_value=100.0, step=0.5, format="%.1f", + key=f"dl_{ac['account']}") + else: + profit_target = ac.get("profit_target", 10.0) + max_loss = ac.get("max_loss", 10.0) + daily_loss = ac.get("daily_loss", 5.0) + # Last report date from cache + cached = load_cache(ac["account"]) + if cached and cached.get("fetched_at"): + try: + dt = datetime.fromisoformat(cached["fetched_at"]) + last_report = dt.strftime("%d %b %H:%M") + except Exception: + last_report = "—" + else: + last_report = "No cache" + c5.markdown(f'
{last_report}
', + unsafe_allow_html=True) + if c6.button("🗑", key=f"rm_{ac['account']}"): + remaining = [a for a in acc_cfgs if a["account"] != ac["account"]] + save_account_configs(remaining) + # Clear account selector so removed account disappears + if "ftp_sel_accounts" in st.session_state: + del st.session_state["ftp_sel_accounts"] + st.rerun() + updated.append({"account": ac["account"], "label": label, + "balance": balance, "type": acc_type, + "profit_target": profit_target, + "max_loss": max_loss, + "daily_loss": daily_loss}) + + if st.button("💾 Save Changes", type="primary", key="cfg_save"): + save_account_configs(updated) + acc_cfgs = updated + st.success("Saved.") + st.rerun() + + if not acc_cfgs: + st.info("Configure account labels above, then click Refresh All.") + return + + acc_map = {a["account"]: a for a in acc_cfgs} + + # ── Auto-load on first visit + Refresh ──────────────────────────────────── + ages = [cache_age_minutes(a["account"]) for a in acc_cfgs + if cache_age_minutes(a["account"]) < float("inf")] + no_cache = any(cache_age_minutes(a["account"]) == float("inf") for a in acc_cfgs) + + hdr1, hdr2, hdr3, hdr4 = st.columns([3, 1, 1, 1]) + with hdr2: + do_refresh = st.button("🔄 Refresh All", type="primary", + use_container_width=True) + with hdr3: + poll_interval = st.number_input("Auto-refresh (min)", min_value=0, + max_value=60, value=5, step=5, + key="ftp_poll_interval", + help="0 = disabled. Page must be open.") + with hdr4: + if ages: + oldest = max(ages) + st.caption(f"Updated {oldest:.0f}m ago") + + # Auto-refresh via polling — only trigger after poll_interval has passed + # since the last actual refresh (tracked in session state) + auto_refresh = False + if poll_interval > 0 and ages and not no_cache: + last_auto = st.session_state.get("ftp_last_auto_refresh", 0) + now_ts = datetime.now().timestamp() + if (now_ts - last_auto) >= poll_interval * 60: + auto_refresh = True + + if do_refresh or no_cache or auto_refresh: + if auto_refresh and not do_refresh: + st.session_state["ftp_last_auto_refresh"] = datetime.now().timestamp() + label_text = "Loading..." if no_cache else "Refreshing..." + prog = st.progress(0, text=label_text) + errors = [] + for i, acfg in enumerate(acc_cfgs): + prog.progress((i + 1) / len(acc_cfgs), + text=f"Fetching {acfg['label']}...") + result = refresh_account(ftp_cfg, acfg["account"], acfg["label"]) + if result.get("error"): + errors.append(f"**{acfg['label']}**: {result['error']}") + prog.empty() + if errors: + for e in errors: + st.error(e) + elif do_refresh: + st.success(f"✓ Refreshed {len(acc_cfgs)} accounts") + st.rerun() + + # ── Load all cached data ────────────────────────────────────────────────── + all_data = [] + for acfg in acc_cfgs: + data = load_cache(acfg["account"]) + if data: + data["balance"] = acfg["balance"] + data["label"] = acfg["label"] + all_data.append(data) + + if not all_data: + st.info("No cached data. Click **Refresh All**.") + return + + # ── Account selector ────────────────────────────────────────────────────── + st.divider() + all_labels = [d["label"] for d in all_data] + sel_labels = st.multiselect("Accounts", all_labels, default=all_labels, + key="ftp_sel_accounts") + sel_data = [d for d in all_data if d["label"] in sel_labels] + + if not sel_data: + st.info("Select at least one account.") + return + + # Merge all selected DataFrames + dfs = [] + for d in sel_data: + df = d["df"].copy() + df["_account"] = d["label"] + df["_balance"] = d["balance"] + dfs.append(df) + df_all = pd.concat(dfs, ignore_index=True) + df_all["close_time"] = pd.to_datetime(df_all["close_time"], errors="coerce") + df_all["open_time"] = pd.to_datetime(df_all["open_time"], errors="coerce") + df_all = df_all.dropna(subset=["close_time"]).sort_values("close_time").reset_index(drop=True) + + total_balance = sum(d["balance"] for d in sel_data) + + # ── Account summary table ──────────────────────────────────────────────── + st.markdown("**Account Summary**") + _sum_cards = [] + for d in sel_data: + acfg = acc_map.get(d["account_folder"], {}) + acc_type = acfg.get("type", "Demo") + balance = d["balance"] + df_tmp = d["df"].copy() + df_tmp["net_profit"] = pd.to_numeric(df_tmp["net_profit"], errors="coerce").fillna(0) + df_tmp["close_time"] = pd.to_datetime(df_tmp["close_time"], errors="coerce") + df_tmp["open_time"] = pd.to_datetime(df_tmp["open_time"], errors="coerce") + df_tmp = df_tmp.sort_values("close_time").reset_index(drop=True) + + current_pnl = df_tmp["net_profit"].sum() + current_bal = balance + current_pnl + pnl_pct = round(current_pnl / balance * 100, 2) if balance else 0 + pnl_color = "#34C27A" if current_pnl >= 0 else "#E05555" + badge_bg = {"Demo":"rgba(124,106,247,0.3)","Personal":"rgba(52,194,122,0.3)", + "Prop":"rgba(255,165,0,0.3)"}.get(acc_type,"rgba(128,128,128,0.2)") + + # Report date + rpt_date = d.get("report_date") + try: + fetched_str = (datetime.fromisoformat(rpt_date).strftime("%d %b %Y %H:%M") + if rpt_date else + datetime.fromisoformat(d.get("fetched_at","")).strftime("%d %b %H:%M")) + except Exception: + fetched_str = "—" + + # ── Recovery factor: net_profit / abs(max_dd) ───────────────────────── + from mt5_parser import calc_stats as _cs + _stats = _cs(df_tmp, deposit=balance) + max_dd = _stats.get("max_drawdown", 0) + recovery = round(current_pnl / abs(max_dd), 2) if max_dd != 0 else "—" + rec_color = "#34C27A" if isinstance(recovery, float) and recovery >= 1 else "#E05555" + + # ── Consecutive loss streak (most recent trades) ────────────────────── + if not df_tmp.empty: + streak = 0 + for _, row in df_tmp[::-1].iterrows(): + if row.get("win") == False or (isinstance(row.get("win"), bool) and not row["win"]): + streak += 1 + else: + break + else: + streak = 0 + streak_color = "#E05555" if streak >= 3 else ("#F5A623" if streak >= 1 else "#34C27A") + + # ── Stagnation: days since last equity high ─────────────────────────── + if not df_tmp.empty: + df_tmp["_cum"] = df_tmp["net_profit"].cumsum() + df_tmp["_peak"] = df_tmp["_cum"].cummax() + at_peak = df_tmp[df_tmp["_cum"] >= df_tmp["_peak"]] + if not at_peak.empty: + last_high = pd.to_datetime(at_peak["close_time"].max()) + stag_days = (datetime.now() - last_high).days + else: + stag_days = 0 + else: + stag_days = 0 + stag_color = "#E05555" if stag_days >= 14 else ("#F5A623" if stag_days >= 7 else "#34C27A") + + # ── Today's P&L for daily loss tracking ────────────────────────────── + today_str = date.today().isoformat() + today_df = df_tmp[df_tmp["close_time"].dt.date == date.today()] + today_pnl = today_df["net_profit"].sum() + today_pct = round(today_pnl / balance * 100, 2) if balance else 0 + + # ── Prop bars ───────────────────────────────────────────────────────── + prop_bars = "" + ea_stopped = False + if acc_type == "Prop": + pt = acfg.get("profit_target", 10.0) + ml = acfg.get("max_loss", 10.0) + dl = acfg.get("daily_loss", 5.0) + pbw = round(min(max(pnl_pct,0), pt) / pt * 100, 1) if pt else 0 + lbw = round(min(max(-pnl_pct,0), ml) / ml * 100, 1) if ml else 0 + dlv = min(max(-today_pct,0), dl) + dbw = round(dlv / dl * 100, 1) if dl else 0 + dl_color = "#E05555" if dbw >= 80 else ("#F5A623" if dbw >= 50 else "#34C27A") + # EA hard stop triggered when today's loss >= daily limit + ea_stopped = dl > 0 and (-today_pct) >= dl + stopped_banner = ( + '
' + '⛔ EA stopped — daily loss limit reached
' + ) if ea_stopped else "" + prop_bars = ( + '
' + f'
Profit {pnl_pct:+.2f}% / {pt:.0f}%
' + f'
' + f'
' + f'
Max loss {min(max(-pnl_pct,0),ml):.2f}% / {ml:.0f}%
' + f'
' + f'
' + f'
Daily loss {today_pct:.2f}% / {dl:.0f}%
' + f'
' + f'
' + f'{stopped_banner}' + '
' + ) + + card = ( + '
' + f'
' + f'{d["label"]}' + f'{acc_type}' + '
' + f'
Updated: {fetched_str}
' + f'
{current_pnl:+,.2f} ({pnl_pct:+.2f}%)
' + f'
Balance: ${balance:,.0f} → Current: ${current_bal:,.2f}
' + f'
' + f'
Recovery: {recovery}
' + f'
Loss streak: {streak}
' + f'
Stagnation: {stag_days}d
' + f'
Today: =0 else "#E05555"}">{today_pnl:+.2f} ({today_pct:+.2f}%)
' + '
' + f'{prop_bars}' + '
' + ) + _sum_cards.append(card) + + st.markdown( + '
' + + "".join(_sum_cards) + '
', + unsafe_allow_html=True) + + # ── Open trades ────────────────────────────────────────────────────────── + _all_open = [] + for d in sel_data: + # Prefer parsed df_open from Open Positions section + df_op = d.get("df_open") + if df_op is not None and not df_op.empty: + df_op = df_op.copy() + df_op["_account"] = d["label"] + _all_open.append(df_op) + + if _all_open: + open_df = pd.concat(_all_open, ignore_index=True) + show_cols = [c for c in ["_account","symbol","type","volume", + "open_time","open_price","sl","tp"] + if c in open_df.columns] + with st.expander(f"🔴 Open Positions ({len(open_df)})", expanded=True): + st.dataframe(open_df[show_cols], use_container_width=True, hide_index=True) + else: + st.caption("No open positions in current reports.") + + # ── Correlation matrix ──────────────────────────────────────────────────── + if len(sel_data) > 1: + with st.expander("📊 Symbol Correlation across Accounts", expanded=False): + corr_rows = [] + for d in sel_data: + df_c = d["df"].copy() + df_c["net_profit"] = pd.to_numeric(df_c["net_profit"], errors="coerce").fillna(0) + df_c["close_time"] = pd.to_datetime(df_c["close_time"], errors="coerce") + by_sym = df_c.groupby("symbol")["net_profit"].sum() + by_sym.name = d["label"] + corr_rows.append(by_sym) + corr_df = pd.DataFrame(corr_rows).T.fillna(0) + if corr_df.shape[1] > 1 and len(corr_df) > 2: + corr_matrix = corr_df.corr().round(2) + labels = corr_matrix.columns.tolist() + z = corr_matrix.values.tolist() + fig_corr = go.Figure(go.Heatmap( + z=z, x=labels, y=labels, + colorscale=[[0,"#E05555"],[0.5,"#f0f0f0"],[1,"#34C27A"]], + zmin=-1, zmax=1, + text=[[f"{v:.2f}" for v in row] for row in z], + texttemplate="%{text}", + showscale=True, + )) + fig_corr.update_layout( + height=300, title="Account Correlation (by symbol P&L)", + plot_bgcolor="rgba(0,0,0,0)", paper_bgcolor="rgba(0,0,0,0)", + font=dict(family="sans-serif"), + margin=dict(l=80,r=20,t=40,b=80), + ) + st.plotly_chart(fig_corr, use_container_width=True, key="ftp_corr") + else: + st.caption("Not enough shared symbols across accounts to compute correlation." + " Symbols need to overlap between at least 2 accounts.") + + # Force balance update when account selection changes + _bal_key = f"ftp_bal_{'_'.join(sorted(sel_labels))}" + if st.session_state.get("ftp_last_bal_key") != _bal_key: + st.session_state["ftp_last_bal_key"] = _bal_key + st.session_state["ftp_cal_balance"] = float(total_balance) + + # ── Calendar section ────────────────────────────────────────────────────── + st.subheader("Calendar") + cal_bal = st.number_input( + "Combined Balance ($) — for % calc", + value=st.session_state.get("ftp_cal_balance", float(total_balance)), + min_value=100.0, step=1000.0, format="%.0f", + key="ftp_cal_balance", + help="Auto-set from selected account balances. Override if needed." + ) + cal_c1, cal_c2 = st.columns([2, 2]) + cal_view = cal_c1.radio("Calendar", ["Month", "Week", "Year"], + horizontal=True, key="ftp_cal_view") + cal_unit = cal_c2.radio("Calendar unit", ["$", "%"], + horizontal=True, key="ftp_cal_unit") + + today = date.today() + if "ftp_cal_y" not in st.session_state: + st.session_state["ftp_cal_y"] = today.year + st.session_state["ftp_cal_m"] = today.month + st.session_state["ftp_cal_w"] = today.isocalendar()[1] + + nav1, nav2, nav3 = st.columns([1, 3, 1]) + with nav1: + if st.button("◀", key="ftp_prev"): + if cal_view == "Month": + m = st.session_state["ftp_cal_m"] - 1 + if m < 1: m = 12; st.session_state["ftp_cal_y"] -= 1 + st.session_state["ftp_cal_m"] = m + elif cal_view == "Week": + w = st.session_state["ftp_cal_w"] - 1 + if w < 1: + st.session_state["ftp_cal_y"] -= 1 + w = 52 + st.session_state["ftp_cal_w"] = w + else: + st.session_state["ftp_cal_y"] -= 1 + st.rerun() + with nav3: + if st.button("▶", key="ftp_next"): + if cal_view == "Month": + m = st.session_state["ftp_cal_m"] + 1 + if m > 12: m = 1; st.session_state["ftp_cal_y"] += 1 + st.session_state["ftp_cal_m"] = m + elif cal_view == "Week": + w = st.session_state["ftp_cal_w"] + 1 + if w > 52: + st.session_state["ftp_cal_y"] += 1 + w = 1 + st.session_state["ftp_cal_w"] = w + else: + st.session_state["ftp_cal_y"] += 1 + st.rerun() + with nav2: + if cal_view == "Month": + nav_label = f"{calendar.month_name[st.session_state['ftp_cal_m']]} {st.session_state['ftp_cal_y']}" + elif cal_view == "Week": + nav_label = f"Week {st.session_state['ftp_cal_w']} — {st.session_state['ftp_cal_y']}" + else: + nav_label = str(st.session_state["ftp_cal_y"]) + st.markdown(f"

{nav_label}

", + unsafe_allow_html=True) + + # Build daily aggregates + df_all["_day"] = df_all["close_time"].dt.date + day_agg = df_all.groupby("_day").agg( + pnl_dollar = ("net_profit", "sum"), + trades = ("net_profit", "count"), + wins = ("win", "sum"), + ).reset_index() + day_agg["losses"] = day_agg["trades"] - day_agg["wins"] + day_agg["pnl_pct"] = (day_agg["pnl_dollar"] / cal_bal * 100).round(3) + day_map = {row["_day"]: row for _, row in day_agg.iterrows()} + + # ── Summary cards for selected period ───────────────────────────────────── + sel_y = st.session_state["ftp_cal_y"] + sel_m = st.session_state["ftp_cal_m"] + sel_w = st.session_state["ftp_cal_w"] + + if cal_view == "Month": + period_days = [d for d in day_map if d.year == sel_y and d.month == sel_m] + elif cal_view == "Week": + period_days = [d for d in day_map + if d.isocalendar()[0] == sel_y and d.isocalendar()[1] == sel_w] + else: + period_days = [d for d in day_map if d.year == sel_y] + + period_rows = day_agg[day_agg["_day"].isin(period_days)] + tot_pnl = period_rows["pnl_dollar"].sum() + tot_pct = period_rows["pnl_pct"].sum() + tot_tr = int(period_rows["trades"].sum()) + tot_w = int(period_rows["wins"].sum()) + tot_l = int(period_rows["losses"].sum()) + wr = round(tot_w / tot_tr * 100, 1) if tot_tr > 0 else 0 + trd_days = len(period_rows) + + sc1,sc2,sc3,sc4,sc5,sc6 = st.columns(6) + sc1.metric("P&L ($)", f"${tot_pnl:,.2f}") + sc2.metric("P&L (%)", f"{tot_pct:+.2f}%") + sc3.metric("Trades", tot_tr) + sc4.metric("Win Rate", f"{wr}%") + sc5.metric("Wins / Losses", f"{tot_w} / {tot_l}") + sc6.metric("Trading Days", trd_days) + + st.markdown("
", unsafe_allow_html=True) + + # ── Calendar grid ───────────────────────────────────────────────────────── + if cal_view == "Month": + _render_month_grid(sel_y, sel_m, day_map, today, cal_unit, cal_bal) + elif cal_view == "Week": + _render_week_grid(sel_y, sel_w, day_map, today, cal_unit, cal_bal) + else: + _render_year_grid(sel_y, day_map, today, cal_unit, cal_bal) + + # ── Trade Analysis section ──────────────────────────────────────────────── + st.divider() + + st.divider() + st.subheader("Trade Analysis") + + # Filters + fc1, fc2, fc3, fc4, fc5 = st.columns(5) + with fc1: + valid_times = df_all["open_time"].dropna() + d_min = valid_times.min().date() + d_max = valid_times.max().date() + date_from = st.date_input("From", value=d_min, min_value=d_min, + max_value=d_max, key="ftp_from") + date_to = st.date_input("To", value=d_max, min_value=d_min, + max_value=d_max, key="ftp_to") + with fc2: + syms = sorted(df_all["symbol"].dropna().unique().tolist()) + sel_sym = st.multiselect("Symbol", syms, key="ftp_sym") + with fc3: + # Algo from comment field + algos = sorted(df_all["comment"].dropna().unique().tolist()) + algos = [a for a in algos if a.strip()] + sel_algo = st.multiselect("Algo (comment)", algos, key="ftp_algo") + with fc4: + days = ["Monday","Tuesday","Wednesday","Thursday","Friday"] + sel_days = st.multiselect("Day of week", days, key="ftp_days") + sel_type = st.multiselect("Type", ["buy","sell"], key="ftp_type") + with fc5: + sel_accs = st.multiselect("Account", all_labels, default=sel_labels, + key="ftp_acc_filter") + # Auto-calculate balance from selected accounts in filter + _acc_bal = sum( + d["balance"] for d in all_data if d["label"] in (sel_accs or sel_labels) + ) + _dep_key = f"ftp_dep_{'_'.join(sorted(sel_accs or sel_labels))}" + if st.session_state.get("ftp_last_dep_key") != _dep_key: + st.session_state["ftp_last_dep_key"] = _dep_key + st.session_state["ftp_deposit"] = float(_acc_bal) + deposit = st.number_input( + "Balance ($)", + value=st.session_state.get("ftp_deposit", float(_acc_bal)), + min_value=100.0, step=1000.0, format="%.0f", + key="ftp_deposit", + help="Auto-set from selected accounts. Override if needed." + ) + + # Apply filters + df = df_all.copy() + df = df[(df["open_time"].dt.date >= date_from) & + (df["open_time"].dt.date <= date_to)] + if sel_sym: df = df[df["symbol"].isin(sel_sym)] + if sel_algo: df = df[df["comment"].isin(sel_algo)] + if sel_days: df = df[df["day_of_week"].isin(sel_days)] + if sel_type: df = df[df["type"].isin(sel_type)] + if sel_accs: df = df[df["_account"].isin(sel_accs)] + df = df.reset_index(drop=True) + + st.caption(f"Showing **{len(df)}** trades after filters · " + f"Combined balance: **${deposit:,.0f}**") + + if df.empty: + st.info("No trades match the current filters.") + return + + # Analysis mode + from mt5_parser import calc_stats + mode = st.radio("Analysis mode", + ["Overall", "By Account", "By Symbol", "By Algo", "By Day of Week"], + horizontal=True, key="ftp_mode") + st.divider() + + if mode == "Overall": + _render_analysis(df, calc_stats(df, deposit=deposit), deposit, key_prefix="ftp_overall") + + elif mode == "By Account": + accs = sorted(df["_account"].dropna().unique()) + rows = [] + for a in accs: + s = calc_stats(df[df["_account"] == a], deposit=next((d["balance"] for d in all_data if d["label"]==a), 0)) + rows.append({"Account": a, "Trades": s["total_trades"], + "Net P&L": s["net_profit"], "Win Rate %": s["win_rate"], + "Profit Factor": s["profit_factor"], + "Expectancy": s["expectancy"], "Max DD": s["max_drawdown"]}) + st.dataframe(pd.DataFrame(rows).sort_values("Net P&L", ascending=False), + use_container_width=True, hide_index=True) + st.divider() + sel = st.selectbox("Account detail", accs, key="ftp_acc_sel") + if sel: + sub = df[df["_account"] == sel] + _render_analysis(sub, calc_stats(sub, deposit=deposit), deposit, key_prefix=f"ftp_acc_{sel}") + + elif mode == "By Symbol": + syms_u = sorted(df["symbol"].dropna().unique()) + rows = [] + for s in syms_u: + st_ = calc_stats(df[df["symbol"] == s], deposit=deposit) + rows.append({"Symbol": s, "Trades": st_["total_trades"], + "Net P&L": st_["net_profit"], "Win Rate %": st_["win_rate"], + "Profit Factor": st_["profit_factor"], + "Expectancy": st_["expectancy"], "Max DD": st_["max_drawdown"]}) + st.dataframe(pd.DataFrame(rows).sort_values("Net P&L", ascending=False), + use_container_width=True, hide_index=True) + sel = st.selectbox("Symbol detail", syms_u, key="ftp_sym_sel") + if sel: + sub = df[df["symbol"] == sel] + _render_analysis(sub, calc_stats(sub, deposit=deposit), deposit, key_prefix=f"ftp_sym_{sel}") + + elif mode == "By Algo": + algo_u = sorted(df["comment"].dropna().unique()) + algo_u = [a for a in algo_u if a.strip()] + rows = [] + for a in algo_u: + st_ = calc_stats(df[df["comment"] == a], deposit=deposit) + rows.append({"Algo": a, "Trades": st_["total_trades"], + "Net P&L": st_["net_profit"], "Win Rate %": st_["win_rate"], + "Profit Factor": st_["profit_factor"], + "Expectancy": st_["expectancy"], "Max DD": st_["max_drawdown"]}) + st.dataframe(pd.DataFrame(rows).sort_values("Net P&L", ascending=False), + use_container_width=True, hide_index=True) + sel = st.selectbox("Algo detail", algo_u, key="ftp_algo_sel") + if sel: + sub = df[df["comment"] == sel] + _render_analysis(sub, calc_stats(sub, deposit=deposit), deposit, key_prefix=f"ftp_algo_{sel}") + + elif mode == "By Day of Week": + _render_dow(df) + _render_hour(df) + + +# ── Analysis helpers ─────────────────────────────────────────────────────────── + +def _render_analysis(df, stats, deposit, key_prefix="ftp"): + """Stats cards + equity + drawdown + daily P&L + DOW + hour + monthly.""" + _render_stats(stats) + _render_equity(df, key_prefix) + col1, col2 = st.columns(2) + with col1: + _render_dow(df, key_prefix) + with col2: + _render_hour(df, key_prefix) + st.divider() + _render_monthly(df, deposit, key_prefix) + + +def _render_stats(stats): + 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}") + _dd_abs = stats['max_drawdown'] + _dd_pct = stats.get('max_drawdown_pct', 0) + c4.metric("Max DD", f"${_dd_abs:,.2f} ({abs(_dd_pct):.2f}%)") + c5.metric("Best Trade", f"${stats['best_trade']:,.2f}") + + c1,c2,c3,c4,c5 = st.columns(5) + c1.metric("Max Consec W", stats['max_consec_wins']) + c2.metric("Max Consec L", stats['max_consec_losses']) + c3.metric("Trading Days", stats.get('trading_days', 0)) + c4.metric("Trades/Day", stats.get('trades_per_day', 0)) + 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 WR", f"{stats['long_win_rate']}%") + c3.metric("Short Trades", stats['short_trades']) + c4.metric("Short WR", f"{stats['short_win_rate']}%") + + +def _render_equity(df, key_prefix): + df_s = df.sort_values("close_time").copy() + df_s["_cum"] = df_s["net_profit"].cumsum() + df_s["_peak"] = df_s["_cum"].cummax() + df_s["_dd"] = df_s["_cum"] - df_s["_peak"] + + # Drawdown unit toggle + dd_unit = st.radio("Drawdown", ["$", "%"], horizontal=True, + key=f"{key_prefix}_dd_unit") + # Running balance for % dd — use peak equity as denominator + if dd_unit == "%": + # % drawdown = dd / peak * 100 (avoid div by zero) + peak_safe = df_s["_peak"].replace(0, float("nan")) + dd_vals = (df_s["_dd"] / peak_safe * 100).fillna(0) + dd_prefix = "" + dd_suffix = "%" + else: + dd_vals = df_s["_dd"] + dd_prefix = "$" + dd_suffix = "" + + LAYOUT = dict(plot_bgcolor="rgba(0,0,0,0)", paper_bgcolor="rgba(0,0,0,0)", + font=dict(family="sans-serif"), + margin=dict(l=60,r=20,t=40,b=40), + xaxis=dict(gridcolor="rgba(128,128,128,0.15)"), + yaxis=dict(gridcolor="rgba(128,128,128,0.15)", tickprefix="$")) + + fig_eq = go.Figure(go.Scatter( + x=df_s["close_time"], y=df_s["_cum"], mode="lines", name="Equity", + line=dict(color="#7c6af7", width=2, shape="spline", smoothing=0.6), + fill="tozeroy", fillcolor="rgba(124,106,247,0.08)")) + fig_eq.update_layout(height=300, title="Equity Curve", + hovermode="x unified", **LAYOUT) + st.plotly_chart(fig_eq, use_container_width=True, key=f"{key_prefix}_eq") + + st.markdown("**Drawdown**") + fig_dd = go.Figure(go.Scatter( + x=df_s["close_time"], y=dd_vals, mode="lines", + fill="tozeroy", + line=dict(color="rgba(220,80,80,0.8)", width=1.5, + shape="spline", smoothing=0.6), + fillcolor="rgba(220,80,80,0.15)", + hovertemplate=f"%{{x}}
DD: {dd_prefix}%{{y:.2f}}{dd_suffix}")) + fig_dd.update_layout(height=130, showlegend=False, + xaxis=dict(gridcolor="rgba(128,128,128,0.15)", + showticklabels=False), + yaxis=dict(gridcolor="rgba(128,128,128,0.15)", + tickprefix=dd_prefix, + ticksuffix=dd_suffix), + plot_bgcolor="rgba(0,0,0,0)", + paper_bgcolor="rgba(0,0,0,0)", + font=dict(family="sans-serif"), + margin=dict(l=60,r=20,t=8,b=4)) + st.plotly_chart(fig_dd, use_container_width=True, key=f"{key_prefix}_dd") + + st.markdown("**Daily P&L**") + daily = df_s.groupby(df_s["close_time"].dt.date)["net_profit"].sum().reset_index() + daily.columns = ["date","pnl"] + fig_d = go.Figure(go.Bar( + x=[str(d) for d in daily["date"]], y=daily["pnl"].round(2).tolist(), + marker_color=["rgba(52,194,122,0.85)" if v>=0 + else "rgba(220,80,80,0.85)" for v in daily["pnl"]])) + fig_d.update_layout(height=160, showlegend=False, + xaxis=dict(type="category", + gridcolor="rgba(128,128,128,0.15)", + showticklabels=False), + yaxis=dict(gridcolor="rgba(128,128,128,0.15)", + tickprefix="$", zeroline=True, + zerolinecolor="rgba(128,128,128,0.3)"), + plot_bgcolor="rgba(0,0,0,0)", + paper_bgcolor="rgba(0,0,0,0)", + font=dict(family="sans-serif"), + margin=dict(l=60,r=20,t=4,b=40)) + st.plotly_chart(fig_d, use_container_width=True, key=f"{key_prefix}_daily") + + +def _render_dow(df, key_prefix="ftp_dow"): + dow_order = ["Monday","Tuesday","Wednesday","Thursday","Friday"] + present = [d for d in dow_order if d in df["day_of_week"].values] + wins_dow = df[df["win"]].groupby("day_of_week")["net_profit"].sum().reindex(present, fill_value=0) + losses_dow= df[~df["win"]].groupby("day_of_week")["net_profit"].sum().reindex(present, fill_value=0) + fig = go.Figure() + fig.add_trace(go.Bar(x=present, y=wins_dow.values, name="Profit", + marker_color="rgba(52,194,122,0.85)")) + fig.add_trace(go.Bar(x=present, y=losses_dow.values, name="Loss", + marker_color="rgba(220,80,80,0.85)")) + fig.update_layout(height=260, title="P&L by Day of Week", barmode="relative", + plot_bgcolor="rgba(0,0,0,0)", paper_bgcolor="rgba(0,0,0,0)", + font=dict(family="sans-serif"), + xaxis=dict(type="category", + gridcolor="rgba(128,128,128,0.15)"), + yaxis=dict(gridcolor="rgba(128,128,128,0.15)", + tickprefix="$"), + legend=dict(bgcolor="rgba(0,0,0,0)"), + margin=dict(l=60,r=20,t=40,b=40)) + + st.plotly_chart(fig, use_container_width=True, key=f"{key_prefix}_dow") + + +def _render_hour(df, key_prefix="ftp_hour"): + all_hours = sorted(df["hour"].dropna().unique()) + str_hours = [str(int(h)) for h in all_hours] + wins_h = df[df["win"]].groupby("hour")["net_profit"].sum().reindex(all_hours, fill_value=0) + losses_h = df[~df["win"]].groupby("hour")["net_profit"].sum().reindex(all_hours, fill_value=0) + fig = go.Figure() + fig.add_trace(go.Bar(x=str_hours, y=wins_h.values, name="Profit", + marker_color="rgba(52,194,122,0.85)")) + fig.add_trace(go.Bar(x=str_hours, y=losses_h.values, name="Loss", + marker_color="rgba(220,80,80,0.85)")) + fig.update_layout(height=260, title="P&L by Hour of Day", barmode="relative", + plot_bgcolor="rgba(0,0,0,0)", paper_bgcolor="rgba(0,0,0,0)", + font=dict(family="sans-serif"), + xaxis=dict(type="category", title="Hour (UTC)", + gridcolor="rgba(128,128,128,0.15)"), + yaxis=dict(gridcolor="rgba(128,128,128,0.15)", + tickprefix="$"), + legend=dict(bgcolor="rgba(0,0,0,0)"), + margin=dict(l=60,r=20,t=40,b=40)) + st.plotly_chart(fig, use_container_width=True, key=f"{key_prefix}_hour") + + +def _render_monthly(df, deposit, key_prefix): + tmp = df[["close_time","net_profit"]].dropna().copy() + tmp["close_time"] = pd.to_datetime(tmp["close_time"], errors="coerce") + tmp["year"] = tmp["close_time"].dt.year + tmp["month"] = tmp["close_time"].dt.month + monthly = tmp.groupby(["year","month"])["net_profit"].sum().reset_index() + if monthly.empty: + return + pivot = monthly.pivot(index="year", columns="month", + values="net_profit").fillna(0) + pivot.columns = [pd.Timestamp(2000,int(m),1).strftime("%b") for m in pivot.columns] + pivot["YTD"] = pivot.sum(axis=1) + pivot = pivot.sort_index(ascending=False) + month_order = ["Jan","Feb","Mar","Apr","May","Jun", + "Jul","Aug","Sep","Oct","Nov","Dec","YTD"] + cols = [c for c in month_order if c in pivot.columns] + + c1, c2 = st.columns([1, 5]) + toggle = c1.radio("", ["$", "%"], horizontal=True, key=f"{key_prefix}_mt_toggle", label_visibility="collapsed") + + def _cell(v): + pv = round(v / deposit * 100, 2) if toggle == "%" else v + bg = "rgba(52,194,122,0.18)" if pv>0 else ("rgba(220,80,80,0.18)" if pv<0 else "transparent") + fg = "#34C27A" if pv>0 else ("#E05555" if pv<0 else "#888") + txt = (f"{pv:+.2f}%" if pv!=0 else "—") if toggle=="%" else (f"{pv:+.2f}" if pv!=0 else "—") + return f'{txt}' + + rows_html = "" + for year, row in pivot[cols].iterrows(): + cells = f'{year}' + for col in cols: + cells += _cell(row.get(col, 0)) + rows_html += f"{cells}" + + hdr = 'Year' + hdr += "".join(f'{c}' for c in cols) + hdr += "" + + st.markdown( + f'
' + f'{hdr}{rows_html}
', + unsafe_allow_html=True) + + +# ── Calendar grid renderers ──────────────────────────────────────────────────── + +def _cell_html(day_num: int, row, is_today: bool, unit: str, balance: float) -> str: + if row is not None: + val = row["pnl_pct"] if unit == "%" else row["pnl_dollar"] + pos = val >= 0 + bg = "rgba(52,194,122,0.15)" if pos else "rgba(220,80,80,0.15)" + vc = "#34C27A" if pos else "#E05555" + sign = "+" if pos else "" + disp = f"{sign}{val:.2f}%" if unit=="%" else f"${val:,.2f}" + alt = f"${row['pnl_dollar']:,.2f}" if unit=="%" else f"{row['pnl_pct']:+.2f}%" + tr = int(row["trades"]) + content = ( + f'
' + f'{disp} ({alt})
' + f'
{tr} trade{"s" if tr!=1 else ""}
' + f'
✅{int(row["wins"])} ❌{int(row["losses"])}
' + ) + else: + bg = "rgba(255,255,255,0.02)" + content = '
' + + border = "border:2px solid rgba(124,106,247,0.6);" if is_today \ + else "border:1px solid rgba(255,255,255,0.06);" + return ( + f'' + f'
' + f'
{day_num}
' + f'{content}
' + ) + + +def _table_wrap(hdr: str, body: str) -> str: + return ( + '
' + '' + f'{hdr}{body}
' + ) + + +def _dow_header() -> str: + days_hdr = "".join( + f'{d}' + for d in ["Mon","Tue","Wed","Thu","Fri"] + ) + week_hdr = 'Weekly Total' + return days_hdr + week_hdr + + +def _render_month_grid(year, month, day_map, today, unit, balance): + cal = calendar.monthcalendar(year, month) + body = "" + for week in cal: + row_html = "" + # Mon-Fri only (indices 0-4), skip Sat(5) Sun(6) + for dow in range(5): + day_num = week[dow] + if day_num == 0: + row_html += '' + else: + d = date(year, month, day_num) + row_html += _cell_html(day_num, day_map.get(d), d==today, unit, balance) + + # Weekly summary cell + week_days = [date(year, month, week[i]) for i in range(5) if week[i] != 0] + if week_days: + week_rows = [day_map[d] for d in week_days if d in day_map] + if week_rows: + w_pnl_d = sum(r["pnl_dollar"] for r in week_rows) + w_pnl_p = sum(r["pnl_pct"] for r in week_rows) + w_tr = sum(int(r["trades"]) for r in week_rows) + w_wins = sum(int(r["wins"]) for r in week_rows) + w_loss = sum(int(r["losses"]) for r in week_rows) + pos = (w_pnl_d if unit == "$" else w_pnl_p) >= 0 + bg = "rgba(52,194,122,0.12)" if pos else "rgba(220,80,80,0.12)" + vc = "#34C27A" if pos else "#E05555" + disp = f"${w_pnl_d:,.2f}" if unit == "$" else f"{w_pnl_p:+.2f}%" + alt = f"{w_pnl_p:+.2f}%" if unit == "$" else f"${w_pnl_d:,.2f}" + week_cell = ( + f'' + f'
' + f'
Weekly
' + f'
{disp}
' + f'
({alt})
' + f'
{w_tr} trades
' + f'
✅{w_wins} ❌{w_loss}
' + f'
' + ) + else: + week_cell = '
' + else: + week_cell = '' + + body += f"{row_html}{week_cell}" + st.markdown(_table_wrap(_dow_header(), body), unsafe_allow_html=True) + + +def _render_week_grid(year, week_num, day_map, today, unit, balance): + # Get the Monday of the given ISO week + jan4 = date(year, 1, 4) + week_start = jan4 + timedelta(weeks=week_num - jan4.isocalendar()[1], + days=-jan4.weekday()) + days = [week_start + timedelta(days=i) for i in range(5)] # Mon-Fri only + cells = "" + for d in days: + cells += _cell_html(d.day, day_map.get(d), d==today, unit, balance) + date_hdr = "".join( + f'' + f'{["Mon","Tue","Wed","Thu","Fri"][i]}
' + f'{days[i].strftime("%d %b")}' + for i in range(5) + ) + body = f"{cells}" + st.markdown(_table_wrap(date_hdr, body), unsafe_allow_html=True) + + +def _render_year_grid(year, day_map, today, unit, balance): + """Year view — one row per month, columns = ISO weeks or just month summary.""" + month_order = list(range(1, 13)) + hdr = 'Month' + hdr += 'P&L' + hdr += 'Trades' + hdr += 'Win Rate' + hdr += 'Trading Days' + + body = "" + for m in month_order: + days_in_month = [d for d in day_map if d.year==year and d.month==m] + if not days_in_month: + continue + rows = [day_map[d] for d in days_in_month] + pnl = sum(r["pnl_dollar"] for r in rows) + pct = sum(r["pnl_pct"] for r in rows) + trades = sum(int(r["trades"]) for r in rows) + wins = sum(int(r["wins"]) for r in rows) + wr = round(wins/trades*100,1) if trades else 0 + td = len(days_in_month) + + val = pct if unit=="%" else pnl + pos = val >= 0 + bg = "rgba(52,194,122,0.12)" if pos else "rgba(220,80,80,0.12)" + fg = "#34C27A" if pos else "#E05555" + disp = f"{val:+.2f}%" if unit=="%" else f"${val:,.2f}" + + body += ( + f'' + f'' + f'{calendar.month_name[m]}' + f'{disp}' + f'{trades}' + f'{wr}%' + f'{td}' + f'' + ) + + st.markdown( + f'
' + f'' + f'{hdr}{body}
', + unsafe_allow_html=True) \ No newline at end of file diff --git a/view_portfolio_builder.py b/view_portfolio_builder.py index 3b1deea..9107f75 100644 --- a/view_portfolio_builder.py +++ b/view_portfolio_builder.py @@ -27,7 +27,7 @@ def _parse_uploaded(file_obj): try: parser = _get_parser() raw = file_obj.read() - result = parser.detect_and_parse(raw) + result = parser.detect_and_parse(raw, file_obj.name) return result[0] if isinstance(result, tuple) else result except Exception as e: st.error(f"Failed to parse **{file_obj.name}**: {e}") @@ -74,6 +74,7 @@ def _ensure_columns(df: pd.DataFrame, label: str) -> pd.DataFrame: df["win"] = df["net_profit"] > 0 df["_strategy"] = label + df["_ea"] = label # EA = the uploaded filename stem return df @@ -660,23 +661,31 @@ def render(): if "symbol" in df.columns else [] if _is_multi: - strat_numbered = {f"{i+1} \u2014 {s}": s for i, s in enumerate(strat_labels)} - fc1, fc2 = st.columns(2) - sel_strat_nums = fc1.multiselect( - "Filter strategies", - list(strat_numbered.keys()), - default=list(strat_numbered.keys()), - key="pb_ov_strat", - help="Deselect strategies to exclude them from Overview stats", + # EA filter (file level) + ea_labels = sorted(df["_ea"].dropna().unique().tolist()) \ + if "_ea" in df.columns else strat_labels + fc1, fc2, fc3 = st.columns(3) + sel_eas = fc1.multiselect( + "Filter EA", + ea_labels, default=ea_labels, key="pb_ov_ea", + help="Filter by uploaded file (EA)", ) - sel_syms = fc2.multiselect( + # Strategy filter — cascades from EA selection + if "_ea" in df.columns and sel_eas: + strat_labels_filtered = sorted( + df[df["_ea"].isin(sel_eas)]["strategy"].dropna().unique().tolist() + ) if "strategy" in df.columns else strat_labels + else: + strat_labels_filtered = strat_labels + sel_strats_raw = fc2.multiselect( + "Filter Strategy", + strat_labels_filtered, default=strat_labels_filtered, key="pb_ov_strat", + help="Filter by strategy (comment) within selected EAs", + ) + sel_syms = fc3.multiselect( "Filter symbols", - sym_labels, - default=sym_labels, - key="pb_ov_sym", - help="Deselect symbols to exclude them from Overview stats", + sym_labels, default=sym_labels, key="pb_ov_sym", ) - sel_strats_raw = [strat_numbered[k] for k in sel_strat_nums] else: sel_strats_raw = strat_labels sel_syms = sym_labels @@ -688,8 +697,8 @@ def render(): (ct >= pd.Timestamp(ov_date_from)) & (ct <= pd.Timestamp(ov_date_to) + pd.Timedelta(days=1)) ] - if sel_strats_raw and "_strategy" in ov_df.columns: - ov_df = ov_df[ov_df["_strategy"].isin(sel_strats_raw)] + if sel_strats_raw and "strategy" in ov_df.columns: + ov_df = ov_df[ov_df["strategy"].isin(sel_strats_raw)] if sel_syms and "symbol" in ov_df.columns: ov_df = ov_df[ov_df["symbol"].isin(sel_syms)] @@ -790,14 +799,22 @@ def render(): else: tr_date_from, tr_date_to = _date_slider("pb_tr") - fc1, fc2, fc3, fc4 = st.columns(4) + fc1, fc2, fc3, fc4, fc5 = st.columns(5) + all_eas = sorted(df["_ea"].dropna().unique().tolist()) if "_ea" in df.columns else [] all_syms = sorted(df["symbol"].dropna().unique().tolist()) if "symbol" in df.columns else [] all_types = sorted(df["type"].dropna().unique().tolist()) if "type" in df.columns else [] - all_strats = sorted(df["_strategy"].dropna().unique().tolist()) if "_strategy" in df.columns else [] - filt_sym = fc1.multiselect("Symbol", all_syms, default=all_syms, key="pb_ts") - filt_type = fc2.multiselect("Direction", all_types, default=all_types, key="pb_tt") - filt_strat = fc3.multiselect("Strategy", all_strats, default=all_strats, key="pb_tst") - result_f = fc4.selectbox("Result", ["All","Wins only","Losses only"], key="pb_tr") + filt_ea = fc1.multiselect("EA", all_eas, default=all_eas, key="pb_tea") + # Cascade strategies from EA filter + if filt_ea and "_ea" in df.columns: + avail_strats = sorted(df[df["_ea"].isin(filt_ea)]["strategy"].dropna().unique().tolist()) \ + if "strategy" in df.columns else [] + else: + avail_strats = sorted(df["strategy"].dropna().unique().tolist()) \ + if "strategy" in df.columns else [] + filt_strat = fc2.multiselect("Strategy", avail_strats, default=avail_strats, key="pb_tst") + filt_sym = fc3.multiselect("Symbol", all_syms, default=all_syms, key="pb_ts") + filt_type = fc4.multiselect("Direction", all_types, default=all_types, key="pb_tt") + result_f = fc5.selectbox("Result", ["All","Wins only","Losses only"], key="pb_tr") view = df.copy() if "close_time" in view.columns and tr_date_from and tr_date_to: @@ -806,9 +823,10 @@ def render(): (ct >= pd.Timestamp(tr_date_from)) & (ct <= pd.Timestamp(tr_date_to) + pd.Timedelta(days=1)) ] - if filt_sym and "symbol" in view.columns: view = view[view["symbol"].isin(filt_sym)] - if filt_type and "type" in view.columns: view = view[view["type"].isin(filt_type)] - if filt_strat and "_strategy" in view.columns: view = view[view["_strategy"].isin(filt_strat)] + if filt_ea and "_ea" in view.columns: view = view[view["_ea"].isin(filt_ea)] + if filt_strat and "strategy" in view.columns: view = view[view["strategy"].isin(filt_strat)] + if filt_sym and "symbol" in view.columns: view = view[view["symbol"].isin(filt_sym)] + if filt_type and "type" in view.columns: view = view[view["type"].isin(filt_type)] if result_f == "Wins only": view = view[view["net_profit"] > 0] elif result_f == "Losses only": view = view[view["net_profit"] <= 0] @@ -881,11 +899,11 @@ def render(): if df.empty or "close_time" not in df.columns: st.info("No time-series data available.") else: - # [8] Line mode options: Portfolio | Individual | Portfolio + Individual + # [8] Line mode options ctl1, ctl2, ctl3 = st.columns([3, 2, 2]) chart_view = ctl1.radio( "Lines", - ["Portfolio", "Individual strategies", "Portfolio + Individual"], + ["Portfolio", "By EA", "By Strategy", "EA + Strategy"], horizontal=True, key="pb_cv", ) smooth_window = ctl2.slider("Smoothing", 1, 50, 1, key="pb_sm", @@ -895,40 +913,73 @@ def render(): # Date range slider date_from, date_to = _date_slider("pb_eq") - # [8] Portfolio selector + strategy filter - sel_strats = list(eff_dfs.keys()) - if chart_view in ("Individual strategies", "Portfolio + Individual"): - # Portfolio filter: which portfolio's members to show - port_names = list(portfolios.keys()) - if port_names: - port_filter_opts = ["All strategies"] + port_names - pf_sel = st.selectbox("Filter to portfolio members", - port_filter_opts, key="pb_eq_pf") - if pf_sel != "All strategies" and pf_sel in portfolios: - default_strats = [s for s in portfolios[pf_sel] if s in eff_dfs] - else: - default_strats = list(eff_dfs.keys()) + # EA filter + cascading strategy filter + all_eas_eq = sorted(df["_ea"].dropna().unique().tolist()) if "_ea" in df.columns else list(eff_dfs.keys()) + sel_eas_eq = st.multiselect("Filter EA", all_eas_eq, default=all_eas_eq, key="pb_eq_ea") + + if chart_view in ("By Strategy", "EA + Strategy"): + if sel_eas_eq and "_ea" in df.columns and "strategy" in df.columns: + avail_strats_eq = sorted(df[df["_ea"].isin(sel_eas_eq)]["strategy"].dropna().unique().tolist()) else: - default_strats = list(eff_dfs.keys()) + avail_strats_eq = sorted(df["strategy"].dropna().unique().tolist()) if "strategy" in df.columns else [] + sel_strats_eq = st.multiselect("Filter Strategy", avail_strats_eq, default=avail_strats_eq, key="pb_eq_strat") + else: + sel_strats_eq = [] - sel_strats = st.multiselect( - "Strategies to show", list(eff_dfs.keys()), - default=default_strats, key="pb_sel_strats", - ) + # Build per-EA and per-strategy dfs for charting + # Per-EA: combine all trades for that EA filename + ea_dfs = {} + for ea in all_eas_eq: + if ea not in sel_eas_eq: + continue + ea_trades = df[df["_ea"] == ea] if "_ea" in df.columns else pd.DataFrame() + if not ea_trades.empty: + ea_trades = ea_trades.sort_values("close_time").reset_index(drop=True) + ea_dfs[ea] = ea_trades - # Determine combined df for Portfolio+Individual + # Per-strategy: combine all trades sharing the same strategy comment + strat_dfs = {} + if "strategy" in df.columns: + for strat in (sel_strats_eq if sel_strats_eq else df["strategy"].dropna().unique()): + mask = df["strategy"] == strat + if sel_eas_eq and "_ea" in df.columns: + mask &= df["_ea"].isin(sel_eas_eq) + s_trades = df[mask] + if not s_trades.empty: + s_trades = s_trades.sort_values("close_time").reset_index(drop=True) + strat_dfs[strat] = s_trades + + # Determine what to pass to the chart builder + if chart_view == "By EA": + chart_eff_dfs = ea_dfs + sel_strats = list(ea_dfs.keys()) + chart_cv = "Individual" + elif chart_view == "By Strategy": + chart_eff_dfs = strat_dfs + sel_strats = list(strat_dfs.keys()) + chart_cv = "Individual" + elif chart_view == "EA + Strategy": + chart_eff_dfs = {**ea_dfs, **strat_dfs} + sel_strats = list(ea_dfs.keys()) + list(strat_dfs.keys()) + chart_cv = "Portfolio+Individual" + else: # Portfolio + chart_eff_dfs = eff_dfs + sel_strats = list(eff_dfs.keys()) + chart_cv = "Portfolio" + + # Combined df for portfolio line chart_df = df - if chart_view == "Portfolio + Individual" and sel_strats: - members_dfs = {k: eff_dfs[k] for k in sel_strats if k in eff_dfs} - chart_df = _combine(members_dfs, deposit) if members_dfs else df + if chart_view == "EA + Strategy" and ea_dfs: + chart_df = _combine(ea_dfs, deposit) cv_map = { - "Portfolio": "Portfolio", - "Individual strategies": "Individual", - "Portfolio + Individual": "Portfolio+Individual", + "Portfolio": "Portfolio", + "By EA": "Individual", + "By Strategy": "Individual", + "EA + Strategy":"Portfolio+Individual", } fig = _build_equity_chart( - chart_df, deposit, eff_dfs, portfolios, active_label, + chart_df, deposit, chart_eff_dfs, portfolios, active_label, chart_view=cv_map[chart_view], smooth_window=smooth_window, show_stagnation=show_stag, @@ -979,13 +1030,30 @@ def render(): # Controls row st.markdown("##### Equity Curves") - ctl1, ctl2, ctl3 = st.columns([2, 2, 2]) + ctl1, ctl2, ctl3, ctl4 = st.columns([2, 2, 2, 2]) sc_smooth = ctl1.slider("Curve smoothing", 1, 50, 1, key="pb_st_smooth", help="Rolling-average window (trades).") - show_st_stag = ctl2.toggle("Show stagnation bands", value=False, + st_curve_grp = ctl2.radio("Group by", ["EA", "Strategy"], horizontal=True, key="pb_st_grp", + help="EA = one line per file · Strategy = one line per comment") + show_st_stag = ctl3.toggle("Show stagnation bands", value=False, key="pb_st_show_stag", help="Highlight max stagnation period per strategy in matching colour") + # Build the series to plot + if st_curve_grp == "Strategy" and "strategy" in df.columns: + # One series per unique strategy comment across all loaded files + _st_series = {} + for _strat in sorted(df["strategy"].dropna().unique()): + _s_df = df[df["strategy"] == _strat].copy() + if ov_date_from and ov_date_to and "close_time" in _s_df.columns: + _ct = pd.to_datetime(_s_df["close_time"]).dt.tz_localize(None) + _s_df = _s_df[(_ct >= pd.Timestamp(ov_date_from)) & + (_ct <= pd.Timestamp(ov_date_to) + pd.Timedelta(days=1))] + if not _s_df.empty: + _st_series[_strat] = _s_df.sort_values("close_time").reset_index(drop=True) + else: + _st_series = eff_dfs_filtered # one series per uploaded file + sf = go.Figure() sf.update_layout( height=500, @@ -998,7 +1066,7 @@ def render(): sf.update_xaxes(gridcolor="rgba(128,128,128,0.15)", zeroline=False) sf.update_yaxes(gridcolor="rgba(128,128,128,0.15)", zeroline=False, tickprefix="$") - for i, (lbl, sdf) in enumerate(eff_dfs_filtered.items()): + for i, (lbl, sdf) in enumerate(_st_series.items()): if "close_time" not in sdf.columns or "net_profit" not in sdf.columns: continue color = COLORS[i % len(COLORS)] diff --git a/view_portfolio_master.py b/view_portfolio_master.py index 568a2bc..1dd66c9 100644 --- a/view_portfolio_master.py +++ b/view_portfolio_master.py @@ -33,7 +33,7 @@ def _parse_file(file_obj): try: parser = _get_parser() raw = file_obj.read() - result = parser.detect_and_parse(raw) + result = parser.detect_and_parse(raw, file_obj.name) return result[0] if isinstance(result, tuple) else result except Exception as e: st.error(f"Failed to parse **{file_obj.name}**: {e}") @@ -69,6 +69,7 @@ def _normalise(df: pd.DataFrame, label: str) -> pd.DataFrame: if "net_profit" in df.columns: df["net_profit"] = pd.to_numeric(df["net_profit"], errors="coerce").fillna(0) df["_strategy"] = label + df["_ea"] = label # EA = the uploaded filename stem return df @@ -482,6 +483,32 @@ def _init_state(): st.session_state[k] = v +def _build_strategy_dfs(file_dfs: dict) -> dict: + """ + Given {filename: df}, return {strategy_label: df} where each entry is + all trades for one unique strategy comment across all files. + Label format: "EA — Strategy" when a file has multiple strategies, + otherwise just the strategy name. + """ + result = {} + for ea_label, df in file_dfs.items(): + if "strategy" not in df.columns: + result[ea_label] = df + continue + strategies = df["strategy"].dropna().unique().tolist() + if len(strategies) == 1: + # Single strategy in file — use strategy name as label + lbl = strategies[0] if strategies[0] != "Manual" else ea_label + result[lbl] = df.copy() + else: + for strat in strategies: + s_df = df[df["strategy"] == strat].copy() + if not s_df.empty: + lbl = f"{ea_label} — {strat}" + result[lbl] = s_df + return result + + # ───────────────────────────────────────────────────────────────────────────── # Render # ───────────────────────────────────────────────────────────────────────────── @@ -558,7 +585,9 @@ def render(): st.info("Upload backtest files above to get started.") return - labels = list(strategy_dfs.keys()) + # Explode each uploaded file into per-strategy DataFrames + all_strategy_dfs = _build_strategy_dfs(strategy_dfs) + labels = list(all_strategy_dfs.keys()) # ── Tabs ───────────────────────────────────────────────────────────────── tab_config, tab_strategies, tab_results = st.tabs([ @@ -658,8 +687,26 @@ def render(): step=500, key="pm_mc_samples") # ── Combination count estimate + warning ───────────────────────────── - sel_labels = st.multiselect("Strategies to include", labels, - default=labels, key="pm_sel_labels") + # ── Strategies to include ───────────────────────────────────────────── + st.markdown('
Strategies to Include
', unsafe_allow_html=True) + ea_names = list(strategy_dfs.keys()) + sel_eas = st.multiselect( + "Filter by EA (file)", + ea_names, default=ea_names, key="pm_sel_eas", + help="Select which uploaded files to draw strategies from", + ) + # Build strategy list cascading from EA selection + if sel_eas: + avail_strats = [lbl for lbl in labels + if any(lbl == ea or lbl.startswith(f"{ea} — ") + for ea in sel_eas)] + else: + avail_strats = labels + sel_labels = st.multiselect( + "Strategies to include", + avail_strats, default=avail_strats, key="pm_sel_labels", + help="Each strategy = one unique comment group within an EA file", + ) n_sel = len(sel_labels) if n_sel >= int(min_strats): @@ -766,7 +813,7 @@ def render(): else: filtered_dfs = {} for lbl in sel_labels: - df = strategy_dfs[lbl].copy() + df = all_strategy_dfs[lbl].copy() if date_from and date_to and "close_time" in df.columns: ct = pd.to_datetime(df["close_time"]).dt.tz_localize(None) df = df[(ct >= pd.Timestamp(date_from)) & @@ -859,7 +906,7 @@ def render(): rows = [] for i, label in enumerate(labels): custom = st.session_state.pm_custom_names.get(label, "") - row = _full_stats(strategy_dfs[label], dep_s, i+1, custom) + row = _full_stats(all_strategy_dfs[label], dep_s, i+1, custom) if row: rows.append(row) if rows: @@ -911,7 +958,7 @@ def render(): hc1, hc2 = st.columns(2) with hc1: st.markdown("##### Pairwise Correlation (all days)") - corr = _correlation_matrix(strategy_dfs) + corr = _correlation_matrix(all_strategy_dfs) disp_labels = [st.session_state.pm_custom_names.get(l,l) for l in corr.columns] corr.index = corr.columns = disp_labels st.plotly_chart(_corr_fig(corr, height=max(300, len(labels)*55)), @@ -919,7 +966,7 @@ def render(): with hc2: st.markdown("##### Conditional Correlation (drawdown days only)") dep_s2 = st.session_state.pm_deposit - cond = _conditional_correlation(strategy_dfs, dep_s2) + cond = _conditional_correlation(all_strategy_dfs, dep_s2) cond.index = cond.columns = disp_labels st.plotly_chart(_corr_fig(cond, height=max(300, len(labels)*55)), use_container_width=True, key=f"pm_corr_cond_{len(labels)}") @@ -1087,7 +1134,7 @@ def render(): # Mini equity chart with dc1: - frames = [strategy_dfs[m].copy() for m in r["members"] if m in strategy_dfs] + frames = [all_strategy_dfs[m].copy() for m in r["members"] if m in all_strategy_dfs] if frames: combined = pd.concat(frames, ignore_index=True) if "close_time" in combined.columns: @@ -1111,7 +1158,6 @@ def render(): yaxis2=dict(overlaying="y", side="right", gridcolor="#1E2130", tickprefix="$", showgrid=False), ) - # Add invisible annotation to ensure figure hash is unique per portfolio pfig.add_annotation(text=str(i), x=0, y=0, opacity=0, showarrow=False, xref="paper", yref="paper") st.plotly_chart(pfig, use_container_width=True, key=f"pm_pfig_{i}") @@ -1119,7 +1165,7 @@ def render(): # Per-result correlation heatmap with dc2: if len(r["members"]) > 1: - member_dfs = {m: strategy_dfs[m] for m in r["members"] if m in strategy_dfs} + member_dfs = {m: all_strategy_dfs[m] for m in r["members"] if m in all_strategy_dfs} if len(member_dfs) > 1: r_corr = _correlation_matrix(member_dfs) r_cond = _conditional_correlation(member_dfs, st.session_state.pm_deposit) @@ -1136,8 +1182,8 @@ def render(): # Member stats table m_rows = [] for m in r["members"]: - if m not in strategy_dfs: continue - s = _full_stats(strategy_dfs[m], st.session_state.pm_deposit, + if m not in all_strategy_dfs: continue + s = _full_stats(all_strategy_dfs[m], st.session_state.pm_deposit, labels.index(m)+1, st.session_state.pm_custom_names.get(m,"")) if s: diff --git a/view_trade_analysis.py b/view_trade_analysis.py index 1d26179..b701cb9 100644 --- a/view_trade_analysis.py +++ b/view_trade_analysis.py @@ -210,7 +210,7 @@ def _generate_html_report(df_plot, stats, fmt, view_sel, {_stat("Trades/Day", str(stats.get('trades_per_day',0)), _delta_html('trades_per_day','x'))} {_stat("Avg Win", f"${stats['avg_win']:,.2f}", _delta_html('avg_win','$'))} {_stat("Avg Loss", f"${stats['avg_loss']:,.2f}", _delta_html('avg_loss','$', inverse=True))} - {_stat("Max DD", f"${stats['max_drawdown']:,.2f}", _delta_html('max_drawdown','$', inverse=True))} + {_stat("Max DD", f"${stats['max_drawdown']:,.2f} ({abs(stats.get('max_drawdown_pct',0)):.2f}%)", _delta_html('max_drawdown','$', inverse=True))} {_stat("Best Trade", f"${stats['best_trade']:,.2f}", _delta_html('best_trade','$'))} {_stat("Worst Trade",f"${stats['worst_trade']:,.2f}", _delta_html('worst_trade','$', inverse=True))} {_stat("Max Consec Wins", str(stats['max_consec_wins']), _delta_html('max_consec_wins',''))} @@ -631,7 +631,8 @@ def render(): delta=_delta('avg_win','$')) c3.metric("Avg Loss", f"${stats['avg_loss']:,.2f}", delta=_inv_delta('avg_loss','$'), delta_color="inverse") - c4.metric("Max DD", f"${stats['max_drawdown']:,.2f}", + _dd_pct = stats.get('max_drawdown_pct', 0) + c4.metric("Max DD", f"${stats['max_drawdown']:,.2f} ({abs(_dd_pct):.2f}%)", delta=_inv_delta('max_drawdown','$'), delta_color="inverse") c5.metric("Best Trade", f"${stats['best_trade']:,.2f}", delta=_delta('best_trade','$')) @@ -937,8 +938,8 @@ def render(): # ── Render mode ─────────────────────────────────────────────────────────── if mode == "Overall": - stats = calc_stats(df) - stats_e = calc_stats(df_e) if df_e is not None else None + stats = calc_stats(df, deposit=st.session_state.get("ta_deposit", 0.0)) + stats_e = calc_stats(df_e, deposit=st.session_state.get("ta_deposit", 0.0)) if df_e is not None else None # ── Report download ─────────────────────────────────────────────── _rep_df = df_e if (view_sel in ("Edited","Both") and df_e is not None) else df