feat: Live MT5 EAs page + parser improvements
Live MT5 EAs page (view_ftp_tracker.py): - FTP-based multi-account tracker replacing view_mt5_tracker.py - Account configuration with add/remove, FTP folder validation, Demo/Personal/Prop types - Prop account fields: profit target %, max loss %, daily loss % with EA hard stop banner - Account summary cards: recovery factor, loss streak, stagnation days, today P&L, prop progress bars - Calendar view: month/week/year, Mon-Fri only, weekly total column, $ or % toggle - Open positions table parsed from MT5 HTML Open Positions section - Symbol correlation heatmap (collapsible) - Trade analysis section with full stats, equity/drawdown/daily P&L, DOW/hour, monthly table - Analysis modes: Overall, By Account, By Symbol, By Algo, By Day of Week - Dynamic combined balance field auto-updates from selected accounts - Auto-refresh polling with session state timestamp guard - Report date extracted from MT5 HTML header (Date: field) - Drawdown $ / % toggle with spline smoothing mt5_parser.py: - calc_stats(df, deposit=0.0) — deposit-aware balance drawdown matching MT5 Balance Drawdown Maximal - max_drawdown_pct and peak_equity added to calc_stats return - peak_at_dd uses .loc[] fix (IndexError on filtered DataFrames) - parse_open_positions() — parses Open Positions section from MT5 account HTML - extract_strategy() — filters sl/tp/so close-reason comments, maps nan to Manual ftp_sync_cli.py: - New CLI tool for FTP connection testing and cache population MT5Tools_FTP_Setup_Guide.docx: - FileZilla Server setup, MT5 publisher config, CLI verification, troubleshooting
This commit is contained in:
+5
-1
@@ -1,4 +1,8 @@
|
||||
.venv/
|
||||
mt5_batch_config.json
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyc
|
||||
cache/
|
||||
mt5_accounts.json
|
||||
ftp_config.json
|
||||
ftp_accounts.json
|
||||
@@ -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"
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
|
||||
+297
@@ -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()
|
||||
+130
-21
@@ -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'<tr[^>]*>(.*?)</tr>', 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'<tr[^>]*>(.*?)</tr>', text, re.DOTALL)
|
||||
|
||||
in_open = False
|
||||
in_orders = False
|
||||
positions = []
|
||||
|
||||
for row in rows:
|
||||
cells = re.findall(r'<t[dh][^>]*>(.*?)</t[dh]>', 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):
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+125
-57
@@ -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)]
|
||||
|
||||
+59
-13
@@ -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('<div class="sh">Strategies to Include</div>', 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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user