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'
Max loss {min(max(-pnl_pct,0),ml):.2f}% / {ml:.0f}%
'
+ f'
'
+ f'
Daily loss {today_pct:.2f}% / {dl:.0f}%
'
+ 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'',
+ 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 (
+ ''
+ )
+
+
+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'',
+ 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