diff --git a/app.py b/app.py
index 5a870ac..9abd7c4 100644
--- a/app.py
+++ b/app.py
@@ -142,8 +142,8 @@ with st.sidebar:
st.markdown("---")
page = option_menu(
menu_title = None,
- options = ["Trade Analysis", "Trade Compare", "EA Comparator", "Batch Backtest", "Settings"],
- icons = ["bar-chart-line", "arrow-left-right", "sliders", "cpu", "gear"],
+ 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"],
default_index = 0,
styles = {
"container" : {"background-color": "transparent", "padding": "0"},
@@ -170,6 +170,14 @@ if page == "Trade Analysis":
importlib.reload(p)
p.render()
+elif page == "Portfolio Builder":
+ import view_portfolio_builder as p
+ p.render()
+
+elif page == "Portfolio Master":
+ import view_portfolio_master as p
+ p.render()
+
elif page == "Trade Compare":
import view_trade_compare as p
importlib.reload(p)
diff --git a/mt5_batch_backtest.py b/mt5_batch_backtest.py
index b027b14..660391e 100644
--- a/mt5_batch_backtest.py
+++ b/mt5_batch_backtest.py
@@ -328,12 +328,25 @@ def update_set_file(set_path, ea_comment, lot_mode, lot_value):
lines = read_utf16(set_path)
def update_param(lines, key, new_val):
+ # Update existing key — also clears the "use default" flag (bit 2) and
+ # updates the default value field so MT5 does not override with the old default.
+ # MT5 .set format: param=value||flags||default||min||max||step||digits
for i, line in enumerate(lines):
if line.strip().startswith(key + '='):
parts = line.strip().split('||')
parts[0] = f'{key}={new_val}'
+ if len(parts) > 1:
+ try:
+ flags = int(parts[1])
+ flags = flags & ~4 # clear bit 2 ("use default")
+ parts[1] = str(flags)
+ except ValueError:
+ pass
+ if len(parts) > 2:
+ parts[2] = str(new_val) # update default field too
lines[i] = '||'.join(parts)
return True
+ lines.append(f'{key}={new_val}')
return False
# Always update EA_Comment
@@ -347,8 +360,16 @@ def update_set_file(set_path, ea_comment, lot_mode, lot_value):
lines.append(f'EA_Comment={ea_comment}')
if lot_mode == 'manual':
+ # Ensure Risk=0 and StartLots are written regardless of original file state
update_param(lines, 'Risk', '0')
update_param(lines, 'StartLots', str(lot_value))
+ # Clear LotPerBalance_step so balance mode can't leak through
+ for i, line in enumerate(lines):
+ if line.strip().startswith('LotPerBalance_step='):
+ parts = line.strip().split('||')
+ parts[0] = 'LotPerBalance_step=0'
+ lines[i] = '||'.join(parts)
+ break
elif lot_mode == 'balance':
update_param(lines, 'Risk', '9999')
update_param(lines, 'LotPerBalance_step', str(lot_value))
diff --git a/view_batch_backtest.py b/view_batch_backtest.py
index 901ea57..371ad21 100644
--- a/view_batch_backtest.py
+++ b/view_batch_backtest.py
@@ -86,12 +86,25 @@ def update_set_file(set_path, ea_comment, lot_mode, lot_value):
lines = read_utf16(set_path)
def update_param(lines, key, new_val):
+ # Update existing key — also clears the "use default" flag (bit 2) and
+ # updates the default value field so MT5 does not override with the old default.
+ # MT5 .set format: param=value||flags||default||min||max||step||digits
for i, line in enumerate(lines):
if line.strip().startswith(key + '='):
parts = line.strip().split('||')
parts[0] = f'{key}={new_val}'
+ if len(parts) > 1:
+ try:
+ flags = int(parts[1])
+ flags = flags & ~4 # clear bit 2 ("use default")
+ parts[1] = str(flags)
+ except ValueError:
+ pass
+ if len(parts) > 2:
+ parts[2] = str(new_val) # update default field too
lines[i] = '||'.join(parts)
return True
+ lines.append(f'{key}={new_val}')
return False
updated = False
@@ -104,8 +117,16 @@ def update_set_file(set_path, ea_comment, lot_mode, lot_value):
lines.append(f'EA_Comment={ea_comment}')
if lot_mode == 'manual':
+ # Ensure Risk=0 and StartLots are written regardless of original file state
update_param(lines, 'Risk', '0')
update_param(lines, 'StartLots', str(lot_value))
+ # Also clear LotPerBalance_step if present so balance mode can't leak through
+ for i, line in enumerate(lines):
+ if line.strip().startswith('LotPerBalance_step='):
+ parts = line.strip().split('||')
+ parts[0] = 'LotPerBalance_step=0'
+ lines[i] = '||'.join(parts)
+ break
elif lot_mode == 'balance':
update_param(lines, 'Risk', '9999')
update_param(lines, 'LotPerBalance_step', str(lot_value))
diff --git a/view_portfolio_builder.py b/view_portfolio_builder.py
new file mode 100644
index 0000000..0a2acda
--- /dev/null
+++ b/view_portfolio_builder.py
@@ -0,0 +1,1117 @@
+"""
+view_portfolio_builder.py — Portfolio Builder page for MT5 Tools
+Tabs: Overview | Trades | Equity Chart | Strategies | Portfolios | What-If
+"""
+
+import streamlit as st
+import pandas as pd
+import numpy as np
+import plotly.graph_objects as go
+from plotly.subplots import make_subplots
+import io, importlib, sys, os
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Parser
+# ─────────────────────────────────────────────────────────────────────────────
+def _get_parser():
+ if "mt5_parser" in sys.modules:
+ return importlib.reload(sys.modules["mt5_parser"])
+ import mt5_parser
+ return mt5_parser
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Parse & normalise
+# ─────────────────────────────────────────────────────────────────────────────
+def _parse_uploaded(file_obj):
+ try:
+ parser = _get_parser()
+ raw = file_obj.read()
+ result = parser.detect_and_parse(raw)
+ return result[0] if isinstance(result, tuple) else result
+ except Exception as e:
+ st.error(f"Failed to parse **{file_obj.name}**: {e}")
+ return None
+
+
+def _ensure_columns(df: pd.DataFrame, label: str) -> pd.DataFrame:
+ col_map = {}
+
+ def _first(targets, dest):
+ for c in targets:
+ if c in df.columns and dest not in col_map.values():
+ col_map[c] = dest
+ return
+
+ _first(["open_time","Open time","Open time ($)","Time"], "open_time")
+ _first(["close_time","Close time"], "close_time")
+ _first(["symbol","Symbol"], "symbol")
+ _first(["type","Type","Direction"], "type")
+ _first(["net_profit","P/L in money","Profit","profit"], "net_profit")
+ _first(["open_price","Open price","Price"], "open_price")
+ _first(["close_price","Close price"], "close_price")
+ _first(["volume","Volume","Size","size"], "volume")
+ _first(["commission","Commission"], "commission")
+ _first(["swap","Swap"], "swap")
+ _first(["comment","Comment"], "comment")
+
+ df = df.rename(columns=col_map)
+
+ if "net_profit" not in df.columns:
+ for c in ["profit","Profit","P/L"]:
+ if c in df.columns:
+ comm = pd.to_numeric(df.get("commission", 0), errors="coerce").fillna(0)
+ swap_ = pd.to_numeric(df.get("swap", 0), errors="coerce").fillna(0)
+ df["net_profit"] = pd.to_numeric(df[c], errors="coerce").fillna(0) + comm + swap_
+ break
+
+ for tc in ["open_time","close_time"]:
+ if tc in df.columns:
+ df[tc] = pd.to_datetime(df[tc], dayfirst=True, errors="coerce")
+
+ if "net_profit" in df.columns:
+ df["net_profit"] = pd.to_numeric(df["net_profit"], errors="coerce").fillna(0)
+ df["win"] = df["net_profit"] > 0
+
+ df["_strategy"] = label
+ return df
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Lot-size scaling — applies a multiplier to net_profit of a strategy copy
+# ─────────────────────────────────────────────────────────────────────────────
+def _scale_df(df: pd.DataFrame, multiplier: float) -> pd.DataFrame:
+ """Return a copy of df with net_profit scaled by multiplier."""
+ out = df.copy()
+ out["net_profit"] = out["net_profit"] * multiplier
+ if "win" in out.columns:
+ out["win"] = out["net_profit"] > 0
+ return out
+
+
+def _get_effective_dfs(strategy_dfs: dict, lot_overrides: dict) -> dict:
+ """Return strategy_dfs with lot-scaled copies substituted where overrides exist."""
+ result = {}
+ for label, df in strategy_dfs.items():
+ mult = lot_overrides.get(label, 1.0)
+ result[label] = _scale_df(df, mult) if mult != 1.0 else df
+ return result
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Combine
+# ─────────────────────────────────────────────────────────────────────────────
+def _combine(dfs_dict: dict, deposit: float) -> pd.DataFrame:
+ if not dfs_dict:
+ return pd.DataFrame()
+ combined = pd.concat([d.copy() for d in dfs_dict.values()], ignore_index=True)
+ if "close_time" in combined.columns:
+ combined = combined.sort_values("close_time").reset_index(drop=True)
+ if "net_profit" in combined.columns:
+ combined["equity"] = deposit + combined["net_profit"].cumsum()
+ return combined
+
+
+def _get_active_df(view_mode: str, eff_dfs: dict, portfolios: dict, deposit: float):
+ if view_mode == "Portfolio (all)":
+ return _combine(eff_dfs, deposit), "Portfolio (all)"
+ if view_mode in portfolios:
+ members = {k: eff_dfs[k] for k in portfolios[view_mode] if k in eff_dfs}
+ return _combine(members, deposit), view_mode
+ if view_mode in eff_dfs:
+ df = eff_dfs[view_mode].copy()
+ if "close_time" in df.columns:
+ df = df.sort_values("close_time").reset_index(drop=True)
+ if "net_profit" in df.columns:
+ df["equity"] = deposit + df["net_profit"].cumsum()
+ return df, view_mode
+ return pd.DataFrame(), view_mode
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Stats
+# ─────────────────────────────────────────────────────────────────────────────
+def _calc_stats(df: pd.DataFrame, deposit: float) -> dict:
+ s = {}
+ if df.empty or "net_profit" not in df.columns:
+ return s
+ profits = df["net_profit"].fillna(0)
+ s["num_trades"] = len(df)
+ s["gross_profit"] = float(profits[profits > 0].sum())
+ s["gross_loss"] = float(profits[profits < 0].sum())
+ s["net_profit"] = float(profits.sum())
+ s["win_count"] = int((profits > 0).sum())
+ s["loss_count"] = int((profits <= 0).sum())
+ s["win_rate"] = s["win_count"] / s["num_trades"] * 100 if s["num_trades"] else 0
+ s["avg_win"] = float(profits[profits > 0].mean()) if s["win_count"] else 0
+ s["avg_loss"] = float(profits[profits < 0].mean()) if s["loss_count"] else 0
+ s["profit_factor"] = s["gross_profit"] / abs(s["gross_loss"]) if s["gross_loss"] else float("inf")
+ s["avg_trade"] = float(profits.mean())
+
+ # Avg lot size
+ if "volume" in df.columns:
+ s["avg_lot"] = float(pd.to_numeric(df["volume"], errors="coerce").mean())
+ else:
+ s["avg_lot"] = 0.0
+
+ eq = deposit + profits.cumsum()
+ rm = eq.cummax()
+ dd = eq - rm
+ s["max_dd"] = float(dd.min())
+ s["max_dd_pct"] = float(dd.min() / deposit * 100)
+ s["ret_dd_ratio"] = s["net_profit"] / abs(s["max_dd"]) if s["max_dd"] else 0
+
+ ws = (profits > 0).astype(int).tolist()
+ cw = cl = mcw = mcl = 0
+ for w in ws:
+ if w: cw += 1; cl = 0
+ else: cl += 1; cw = 0
+ mcw = max(mcw, cw); mcl = max(mcl, cl)
+ s["max_consec_wins"] = mcw
+ s["max_consec_losses"] = mcl
+
+ if "close_time" in df.columns:
+ vc = df["close_time"].dropna()
+ vo = df["open_time"].dropna() if "open_time" in df.columns else vc
+ if not vc.empty:
+ s["start_date"] = vo.min() if not vo.empty else vc.min()
+ s["end_date"] = vc.max()
+ days = max((s["end_date"] - s["start_date"]).days, 1)
+ s["years"] = days / 365.25
+ s["yearly_avg_profit"] = s["net_profit"] / s["years"]
+ s["monthly_avg_profit"] = s["net_profit"] / max(days / 30.44, 1)
+ s["cagr"] = ((deposit + s["net_profit"]) / deposit) ** (1 / s["years"]) - 1
+
+ if "close_time" in df.columns:
+ eq_ts = df[["close_time","net_profit"]].dropna().sort_values("close_time").copy()
+ if not eq_ts.empty:
+ eq_ts["cum"] = deposit + eq_ts["net_profit"].cumsum()
+ eq_ts["date"] = eq_ts["close_time"].dt.date
+ dly = eq_ts.groupby("date")["cum"].last().reset_index()
+ peak = float(dly["cum"].iloc[0]); stag_start = dly["date"].iloc[0]; max_stag = 0
+ for _, r in dly.iterrows():
+ if float(r["cum"]) > peak:
+ peak = float(r["cum"]); stag_start = r["date"]
+ else:
+ max_stag = max(max_stag, (r["date"] - stag_start).days)
+ s["max_stagnation_days"] = max_stag
+
+ # Monthly tables — both $ and %
+ if "close_time" in df.columns:
+ mdf = df[["close_time","net_profit"]].dropna().copy()
+ mdf["year"] = mdf["close_time"].dt.year
+ mdf["month"] = mdf["close_time"].dt.month
+ monthly = mdf.groupby(["year","month"])["net_profit"].sum().reset_index()
+ pivot_d = monthly.pivot(index="year", columns="month", values="net_profit").fillna(0)
+ pivot_d.columns = [pd.Timestamp(2000, int(m), 1).strftime("%b") for m in pivot_d.columns]
+ pivot_d["YTD"] = pivot_d.sum(axis=1)
+ s["monthly_table_dollar"] = pivot_d
+
+ # % — each month relative to deposit
+ pivot_p = pivot_d.copy()
+ for col in pivot_p.columns:
+ pivot_p[col] = pivot_p[col] / deposit * 100
+ s["monthly_table_pct"] = pivot_p
+
+ return s
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Chart helpers
+# ─────────────────────────────────────────────────────────────────────────────
+COLORS = ["#4C8EF5","#F5A623","#7ED321","#BD10E0",
+ "#9B59B6","#1ABC9C","#E67E22","#FF6B9D"]
+
+
+def _smooth(y: pd.Series, window: int) -> pd.Series:
+ if window <= 1:
+ return y
+ return y.rolling(window=window, min_periods=1, center=True).mean()
+
+
+def _add_stagnation_vrect(fig, df: pd.DataFrame, deposit: float):
+ eq_ts = df[["close_time","net_profit"]].dropna().sort_values("close_time").copy()
+ if eq_ts.empty:
+ return
+ eq_ts["cum"] = deposit + eq_ts["net_profit"].cumsum()
+ peak = float(eq_ts["cum"].iloc[0]); stag_start = eq_ts["close_time"].iloc[0]
+ max_days = 0; best_s = stag_start; best_e = stag_start
+ for _, r in eq_ts.iterrows():
+ if float(r["cum"]) > peak:
+ days = (r["close_time"] - stag_start).days
+ if days > max_days:
+ max_days = days; best_s = stag_start; best_e = r["close_time"]
+ peak = float(r["cum"]); stag_start = r["close_time"]
+ if max_days > 0:
+ fig.add_vrect(x0=best_s, x1=best_e,
+ fillcolor="rgba(255,160,80,0.10)", line_width=0,
+ annotation_text=f"Max stagnation: {max_days}d",
+ annotation_position="top left",
+ annotation_font_size=11, annotation_font_color="#FFB366",
+ row=1, col=1)
+
+
+def _build_equity_chart(
+ df: pd.DataFrame, deposit: float,
+ eff_dfs: dict, portfolios: dict, active_label: str,
+ chart_view: str, smooth_window: int,
+ show_stagnation: bool,
+ date_from, date_to,
+ selected_strategies: list,
+ selected_portfolio: str = None,
+):
+ fig = make_subplots(
+ rows=3, cols=1, shared_xaxes=True,
+ row_heights=[0.62, 0.19, 0.19],
+ vertical_spacing=0.02,
+ subplot_titles=("", "Drawdown from Peak ($)", "Daily P&L ($)"),
+ )
+ all_dd_frames = []
+ all_daily_frames = []
+
+ # Compute the global time span across all series being plotted so every
+ # line can be extended to span the full x-axis width.
+ _all_times = []
+ for _sdf in eff_dfs.values():
+ if "close_time" in _sdf.columns:
+ _all_times.append(pd.to_datetime(_sdf["close_time"]).dt.tz_localize(None).dropna())
+ if not df.empty and "close_time" in df.columns:
+ _all_times.append(pd.to_datetime(df["close_time"]).dt.tz_localize(None).dropna())
+ _global_min = min(s.min() for s in _all_times) if _all_times else None
+ _global_max = max(s.max() for s in _all_times) if _all_times else None
+ # Clamp to date filter if set
+ if date_from and _global_min is not None:
+ _global_min = max(_global_min, pd.Timestamp(date_from))
+ if date_to and _global_max is not None:
+ _global_max = min(_global_max, pd.Timestamp(date_to) + pd.Timedelta(days=1))
+
+ def _plot_series(times: pd.Series, profits: pd.Series,
+ name: str, color: str, width: float):
+ times = times.reset_index(drop=True)
+ profits = profits.reset_index(drop=True)
+ eq_full = deposit + profits.cumsum()
+ rm_full = eq_full.cummax()
+ dd_full = eq_full - rm_full
+
+ times_dt = pd.to_datetime(times).dt.tz_localize(None)
+ mask = pd.Series([True] * len(times_dt), dtype=bool)
+ if date_from:
+ mask &= times_dt >= pd.Timestamp(date_from)
+ if date_to:
+ mask &= times_dt <= pd.Timestamp(date_to) + pd.Timedelta(days=1)
+
+ times_f = times_dt[mask].reset_index(drop=True)
+ eq_f = eq_full[mask].reset_index(drop=True)
+ dd_f = dd_full[mask].reset_index(drop=True)
+ if eq_f.empty:
+ return
+
+ all_dd_frames.append(pd.DataFrame({"t": times_f, "dd": dd_f}))
+
+ # Daily P&L — sum net_profit per day within the filtered window
+ profits_f = profits[mask].reset_index(drop=True)
+ daily_pnl = (pd.DataFrame({"t": times_f, "pnl": profits_f})
+ .assign(date=lambda x: x["t"].dt.normalize())
+ .groupby("date")["pnl"].sum()
+ .reset_index()
+ .rename(columns={"date": "t"}))
+ all_daily_frames.append(daily_pnl)
+
+ eq_disp = _smooth(eq_f, smooth_window)
+
+ # Extend line to global span so all series fill the full x-axis
+ if _global_min is not None and len(times_f) > 0 and times_f.iloc[0] > _global_min:
+ times_f = pd.concat([pd.Series([_global_min]), times_f], ignore_index=True)
+ eq_disp = pd.concat([pd.Series([eq_disp.iloc[0]]), eq_disp], ignore_index=True)
+ if _global_max is not None and len(times_f) > 0 and times_f.iloc[-1] < _global_max:
+ times_f = pd.concat([times_f, pd.Series([_global_max])], ignore_index=True)
+ eq_disp = pd.concat([eq_disp, pd.Series([eq_disp.iloc[-1]])], ignore_index=True)
+
+ fig.add_trace(go.Scatter(
+ x=times_f, y=eq_disp, name=name,
+ line=dict(color=color, width=width), mode="lines",
+ connectgaps=True,
+ hovertemplate=f"{name}
%{{x|%d %b %Y}}
${{y:,.2f}}",
+ ), row=1, col=1)
+
+ if chart_view == "Portfolio":
+ # Show the selected portfolio / all as one combined line
+ if not df.empty and "close_time" in df.columns and "net_profit" in df.columns:
+ _plot_series(df["close_time"], df["net_profit"], active_label, COLORS[0], 2.0)
+ if show_stagnation:
+ _add_stagnation_vrect(fig, df, deposit)
+
+ elif chart_view == "Portfolio+Individual":
+ # Combined line + each member underneath
+ if not df.empty and "close_time" in df.columns and "net_profit" in df.columns:
+ _plot_series(df["close_time"], df["net_profit"], f"{active_label} (combined)",
+ "#FFFFFF", 2.5)
+ if show_stagnation:
+ _add_stagnation_vrect(fig, df, deposit)
+ for i, label in enumerate(selected_strategies):
+ if label not in eff_dfs:
+ continue
+ sdf = eff_dfs[label]
+ if "close_time" not in sdf.columns or "net_profit" not in sdf.columns:
+ continue
+ sdf_s = sdf.sort_values("close_time")
+ _plot_series(sdf_s["close_time"], sdf_s["net_profit"],
+ label, COLORS[i % len(COLORS)], 1.2)
+
+ else: # Individual
+ for i, label in enumerate(selected_strategies):
+ if label not in eff_dfs:
+ continue
+ sdf = eff_dfs[label]
+ if "close_time" not in sdf.columns or "net_profit" not in sdf.columns:
+ continue
+ sdf_s = sdf.sort_values("close_time")
+ _plot_series(sdf_s["close_time"], sdf_s["net_profit"],
+ label, COLORS[i % len(COLORS)], 1.5)
+
+ # Row 2 — cumulative drawdown from peak
+ if all_dd_frames:
+ dd_all = pd.concat(all_dd_frames).sort_values("t").reset_index(drop=True)
+ dd_agg = dd_all.groupby("t")["dd"].min().reset_index()
+ fig.add_trace(go.Scatter(
+ x=dd_agg["t"], y=dd_agg["dd"],
+ name="Peak DD", fill="tozeroy",
+ fillcolor="rgba(220,50,50,0.30)",
+ line=dict(color="rgba(220,50,50,0.75)", width=1),
+ mode="lines", showlegend=False,
+ hovertemplate="Peak DD: $%{y:,.2f}",
+ ), row=2, col=1)
+
+ # Row 3 — daily P&L bars
+ if all_daily_frames:
+ daily_all = pd.concat(all_daily_frames).groupby("t")["pnl"].sum().reset_index()
+ pos = daily_all["pnl"].clip(lower=0)
+ neg = daily_all["pnl"].clip(upper=0)
+ # Green bars for positive days
+ fig.add_trace(go.Bar(
+ x=daily_all["t"], y=pos,
+ name="Daily gain",
+ marker_color="rgba(52,194,122,0.70)",
+ showlegend=False,
+ hovertemplate="Daily P&L: $%{y:,.2f}",
+ ), row=3, col=1)
+ # Red bars for negative days
+ fig.add_trace(go.Bar(
+ x=daily_all["t"], y=neg,
+ name="Daily loss",
+ marker_color="rgba(220,50,50,0.70)",
+ showlegend=False,
+ hovertemplate="Daily P&L: $%{y:,.2f}",
+ ), row=3, col=1)
+
+ fig.update_layout(
+ height=1240, margin=dict(l=60,r=20,t=24,b=10),
+ paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="#0E1117",
+ legend=dict(orientation="h", yanchor="bottom", y=1.02,
+ xanchor="left", x=0, font=dict(size=11)),
+ hovermode="x unified", bargap=0, barmode="overlay",
+ )
+ # Force x-axis range to match the selected date window
+ x_min = pd.Timestamp(date_from) if date_from else None
+ x_max = pd.Timestamp(date_to) + pd.Timedelta(days=1) if date_to else None
+
+ fig.update_xaxes(
+ gridcolor="#1E2130", zeroline=False,
+ showspikes=True, spikecolor="#445", spikethickness=1,
+ range=[x_min, x_max] if x_min and x_max else None,
+ )
+ fig.update_yaxes(gridcolor="#1E2130", zeroline=False)
+ fig.update_yaxes(title_text="Equity ($)", row=1, col=1, tickprefix="$")
+ fig.update_yaxes(title_text="Peak DD ($)", row=2, col=1, tickprefix="$")
+ fig.update_yaxes(title_text="Daily P&L ($)", row=3, col=1, tickprefix="$")
+ for ann in fig.layout.annotations:
+ ann.font.size = 11
+ ann.font.color = "#6C7A8D"
+ return fig
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Monthly HTML table (supports $ or %)
+# ─────────────────────────────────────────────────────────────────────────────
+def _monthly_html(pivot: pd.DataFrame, mode: str = "$") -> str:
+ order = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec","YTD"]
+ cols = [c for c in order if c in pivot.columns]
+ pivot = pivot[cols]
+ rows = []
+ for year, row in pivot.iterrows():
+ cells = [f"
{year} | "]
+ for col in cols:
+ v = row.get(col, 0)
+ if pd.isna(v) or v == 0:
+ cls = "z"
+ txt = "0"
+ elif v > 0:
+ cls = "p"
+ txt = f"{v:,.2f}%" if mode == "%" else f"{v:,.2f}"
+ else:
+ cls = "n"
+ txt = f"{v:,.2f}%" if mode == "%" else f"{v:,.2f}"
+ cells.append(f"{txt} | ")
+ rows.append("" + "".join(cells) + "
")
+ hdr = "| Year | " + "".join(f"{c} | " for c in cols) + "
"
+ return (
+ ""
+ f""
+ )
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Strategy comparison table
+# ─────────────────────────────────────────────────────────────────────────────
+def _strategy_table(eff_dfs: dict, deposit: float, lot_overrides: dict) -> pd.DataFrame:
+ rows = []
+ for label, df in eff_dfs.items():
+ s = _calc_stats(df, deposit)
+ pf = s.get("profit_factor", 0)
+ mult = lot_overrides.get(label, 1.0)
+ # Avg lot from original (unscaled) volume; if scaled show effective avg
+ base_avg_lot = s.get("avg_lot", 0.0)
+ rows.append({
+ "Strategy": label,
+ "Lot ×": round(mult, 2),
+ "Avg Lot": round(base_avg_lot, 4),
+ "Trades": s.get("num_trades", 0),
+ "Net Profit ($)": round(s.get("net_profit", 0), 2),
+ "Win Rate (%)": round(s.get("win_rate", 0), 2),
+ "Profit Factor": round(pf, 2) if pf != float("inf") else 999.0,
+ "Max DD ($)": round(s.get("max_dd", 0), 2),
+ "Max DD (%)": round(s.get("max_dd_pct", 0), 2),
+ "Ret/DD": round(s.get("ret_dd_ratio", 0), 2),
+ "Avg Trade ($)": round(s.get("avg_trade", 0), 2),
+ "Stagnation (d)": s.get("max_stagnation_days", 0),
+ "Start": str(s.get("start_date",""))[:10],
+ "End": str(s.get("end_date",""))[:10],
+ })
+ return pd.DataFrame(rows)
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Session state
+# ─────────────────────────────────────────────────────────────────────────────
+def _init_state():
+ for k, v in {
+ "pb_uploaded_files": {},
+ "pb_portfolios": {},
+ "pb_lot_overrides": {}, # label → float multiplier
+ "pb_deposit": 10000.0,
+ }.items():
+ if k not in st.session_state:
+ st.session_state[k] = v
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Render
+# ─────────────────────────────────────────────────────────────────────────────
+def render():
+ _init_state()
+
+ st.markdown("""""", unsafe_allow_html=True)
+
+ st.markdown('📊 Portfolio Builder
', unsafe_allow_html=True)
+ st.markdown('Combine MT5 backtest reports into multi-strategy portfolios
',
+ unsafe_allow_html=True)
+
+ # ── Upload panel ─────────────────────────────────────────────────────────
+ # [8] Show only htm/html/csv in the help text; type=None + manual filter
+ # because Streamlit on Windows sometimes chokes on type=["htm"]
+ with st.expander("📂 Upload Strategy Reports",
+ expanded=not bool(st.session_state.pb_uploaded_files)):
+ st.caption("Accepts: `.htm` · `.html` · `.csv` — other file types are ignored.")
+ uploaded = st.file_uploader(
+ "Select HTM or CSV files (PNG and other files in the same folder are ignored)",
+ type=None, accept_multiple_files=True, key="pb_uploader",
+ )
+ if uploaded:
+ rejected = [f.name for f in uploaded
+ if not f.name.lower().endswith((".htm",".html",".csv"))]
+ if rejected:
+ st.warning(f"Ignored {len(rejected)} unsupported file(s): "
+ + ", ".join(rejected[:5])
+ + (" …" if len(rejected) > 5 else ""))
+ uploaded = [f for f in uploaded
+ if f.name.lower().endswith((".htm",".html",".csv"))]
+ for f in uploaded:
+ stem = os.path.splitext(f.name)[0]
+ if stem not in st.session_state.pb_uploaded_files:
+ df = _parse_uploaded(f)
+ if df is not None:
+ df = _ensure_columns(df, stem)
+ st.session_state.pb_uploaded_files[stem] = df
+ st.success(f"✅ **{stem}** — {len(df):,} trades")
+
+ if st.session_state.pb_uploaded_files:
+ st.markdown("**Loaded strategies:**")
+ to_remove = []
+ for label in list(st.session_state.pb_uploaded_files):
+ c1, c2 = st.columns([6,1])
+ c1.markdown(f"📈 {label}",
+ unsafe_allow_html=True)
+ if c2.button("✕", key=f"rm_{label}"):
+ to_remove.append(label)
+ for k in to_remove:
+ del st.session_state.pb_uploaded_files[k]
+ st.session_state.pb_lot_overrides.pop(k, None)
+ for pn in st.session_state.pb_portfolios:
+ if k in st.session_state.pb_portfolios[pn]:
+ st.session_state.pb_portfolios[pn].remove(k)
+ st.rerun()
+
+ strategy_dfs: dict = st.session_state.pb_uploaded_files
+ portfolios: dict = st.session_state.pb_portfolios
+ lot_overrides: dict = st.session_state.pb_lot_overrides
+
+ if not strategy_dfs:
+ st.info("Upload one or more strategy reports above to get started.")
+ return
+
+ # Effective DFs (with lot scaling applied)
+ eff_dfs = _get_effective_dfs(strategy_dfs, lot_overrides)
+
+ # ── View selector + deposit ───────────────────────────────────────────────
+ view_options = (["Portfolio (all)"]
+ + [f"Portfolio: {p}" for p in portfolios]
+ + list(strategy_dfs.keys()))
+ label_map = {"Portfolio (all)": "Portfolio (all)"}
+ for p in portfolios: label_map[f"Portfolio: {p}"] = p
+ for s in strategy_dfs: label_map[s] = s
+
+ sc1, sc2 = st.columns([4, 2])
+ view_sel = sc1.selectbox("View", view_options, key="pb_view_sel")
+ deposit = sc2.number_input("Initial Deposit ($)", min_value=100.0,
+ max_value=10_000_000.0,
+ value=st.session_state.pb_deposit,
+ step=1000.0, format="%.2f",
+ key="pb_deposit_input")
+ st.session_state.pb_deposit = deposit
+
+ view_mode = label_map.get(view_sel, view_sel)
+ df, active_label = _get_active_df(view_mode, eff_dfs, portfolios, deposit)
+
+ # ── Shared date-range slider (used by Overview + Trades) ───────────────────────────
+ import datetime as _dt
+ _has_dates = not df.empty and "close_time" in df.columns
+ if _has_dates:
+ _all_dates = pd.to_datetime(df["close_time"]).dt.tz_localize(None).dropna()
+ _gmin = _all_dates.min().date()
+ _gmax = _all_dates.max().date()
+ _total_days = (_gmax - _gmin).days
+ _step = max(1, _total_days // 500)
+ _date_options = [_gmin + _dt.timedelta(days=i)
+ for i in range(0, _total_days + 1, _step)]
+ if _date_options[-1] != _gmax:
+ _date_options.append(_gmax)
+ else:
+ _gmin = _gmax = None
+ _date_options = []
+
+ def _date_slider(key_prefix):
+ if not _has_dates or _gmin == _gmax or len(_date_options) < 2:
+ return _gmin, _gmax
+ sel = st.select_slider(
+ "Date range",
+ options=_date_options,
+ value=(_gmin, _gmax),
+ format_func=lambda d: d.strftime("%d %b %Y"),
+ key=f"{key_prefix}_dslider",
+ )
+ return sel[0], sel[1]
+
+
+ # ── Overview filters (only shown for multi-strategy views) ───────────────
+ # Determine if this is a portfolio/all view with multiple strategies
+ _is_multi = view_mode in ("Portfolio (all)",) or view_mode in portfolios
+ ov_df = df # default — filtered below if _is_multi
+
+ if not df.empty:
+ ov_date_from, ov_date_to = _date_slider("pb_ov")
+
+ strat_labels = sorted(df["_strategy"].dropna().unique().tolist()) \
+ if "_strategy" in df.columns else []
+ sym_labels = sorted(df["symbol"].dropna().unique().tolist()) \
+ 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",
+ )
+ sel_syms = fc2.multiselect(
+ "Filter symbols",
+ sym_labels,
+ default=sym_labels,
+ key="pb_ov_sym",
+ help="Deselect symbols to exclude them from Overview stats",
+ )
+ sel_strats_raw = [strat_numbered[k] for k in sel_strat_nums]
+ else:
+ sel_strats_raw = strat_labels
+ sel_syms = sym_labels
+
+ ov_df = df.copy()
+ if "close_time" in ov_df.columns and ov_date_from and ov_date_to:
+ ct = pd.to_datetime(ov_df["close_time"]).dt.tz_localize(None)
+ ov_df = ov_df[
+ (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_syms and "symbol" in ov_df.columns:
+ ov_df = ov_df[ov_df["symbol"].isin(sel_syms)]
+
+ stats = _calc_stats(ov_df, deposit)
+
+ # ── Tabs ─────────────────────────────────────────────────────────────────
+ tab_ov, tab_tr, tab_eq, tab_st, tab_wi, tab_pf = st.tabs([
+ "📋 Overview", "📜 Trades", "📈 Equity Chart",
+ "📊 Strategies", "🔧 What-If", "🗂 Portfolios",
+ ])
+
+ # ═════════════════════════════════════════════════════════════════════════
+ # OVERVIEW
+ # ═════════════════════════════════════════════════════════════════════════
+ with tab_ov:
+ if not stats:
+ st.warning("No data for selected view.")
+ else:
+ np_ = stats.get("net_profit", 0)
+ nc = "pos" if np_ >= 0 else "neg"
+
+ def card(label, val, sub, cls="neutral"):
+ st.markdown(
+ f'{label}
'
+ f'
{val}
'
+ f'
{sub}
',
+ unsafe_allow_html=True)
+
+ c1,c2,c3,c4,c5 = st.columns(5)
+ with c1: card("Total Profit", f"${np_:,.2f}",
+ f"{stats.get('num_trades',0):,} trades", nc)
+ with c2: card("Win Rate", f"{stats.get('win_rate',0):.2f}%",
+ f"{stats.get('win_count',0)}W / {stats.get('loss_count',0)}L")
+ with c3:
+ pf = stats.get("profit_factor", 0)
+ card("Profit Factor", f"{pf:.2f}" if pf != float("inf") else "∞",
+ "Gross P / Gross L")
+ with c4: card("Max Drawdown", f"${stats.get('max_dd',0):,.2f}",
+ f"{stats.get('max_dd_pct',0):.2f}%", "neg")
+ with c5: card("Return / DD", f"{stats.get('ret_dd_ratio',0):.2f}",
+ "Net profit / max DD")
+
+ st.markdown("
", unsafe_allow_html=True)
+ r1,r2,r3,r4,r5 = st.columns(5)
+ with r1: card("Yearly Avg", f"${stats.get('yearly_avg_profit',0):,.2f}", "Annual")
+ with r2: card("Monthly Avg", f"${stats.get('monthly_avg_profit',0):,.2f}", "Per month")
+ with r3: card("CAGR", f"{stats.get('cagr',0)*100:.2f}%", "Compound annual")
+ with r4:
+ at = stats.get("avg_trade",0)
+ card("Avg Trade", f"${at:,.2f}", "Per trade", "pos" if at>=0 else "neg")
+ with r5: card("Max Stagnation",f"{stats.get('max_stagnation_days',0)}d",
+ "Days w/o new high")
+
+ st.markdown("
", unsafe_allow_html=True)
+ lc, rc = st.columns(2)
+
+ def kv(key, val, cls=""):
+ st.markdown(
+ f'{key}'
+ f'{val}
',
+ unsafe_allow_html=True)
+
+ with lc:
+ st.markdown('Strategy
', unsafe_allow_html=True)
+ kv("# Trades", f"{stats.get('num_trades',0):,}")
+ kv("Gross Profit", f"${stats.get('gross_profit',0):,.2f}", "pos")
+ kv("Gross Loss", f"${stats.get('gross_loss',0):,.2f}", "neg")
+ kv("Avg Win", f"${stats.get('avg_win',0):,.2f}", "pos")
+ kv("Avg Loss", f"${stats.get('avg_loss',0):,.2f}", "neg")
+ kv("W/L Ratio", f"{stats.get('win_count',0)} / {stats.get('loss_count',0)}")
+ with rc:
+ st.markdown('Risk
', unsafe_allow_html=True)
+ kv("Max Consec Wins", str(stats.get("max_consec_wins",0)), "pos")
+ kv("Max Consec Losses", str(stats.get("max_consec_losses",0)), "neg")
+ kv("Max DD $", f"${stats.get('max_dd',0):,.2f}", "neg")
+ kv("Max DD %", f"{stats.get('max_dd_pct',0):.2f}%", "neg")
+ kv("Start Date", str(stats.get("start_date","—"))[:10])
+ kv("End Date", str(stats.get("end_date","—"))[:10])
+
+ # [1] Monthly table toggle $ / %
+ if "monthly_table_dollar" in stats:
+ st.markdown("
", unsafe_allow_html=True)
+ mt_col1, mt_col2 = st.columns([3, 1])
+ mt_col1.markdown('Monthly Performance
',
+ unsafe_allow_html=True)
+ mt_mode = mt_col2.radio("Unit", ["$", "%"],
+ horizontal=True, key="pb_mt_mode")
+ tbl_key = "monthly_table_dollar" if mt_mode == "$" else "monthly_table_pct"
+ st.markdown(_monthly_html(stats[tbl_key], mt_mode),
+ unsafe_allow_html=True)
+
+ # ═════════════════════════════════════════════════════════════════════════
+ # TRADES
+ # ═════════════════════════════════════════════════════════════════════════
+ with tab_tr:
+ if df.empty:
+ st.info("No trades in selected view.")
+ else:
+ tr_date_from, tr_date_to = _date_slider("pb_tr")
+
+ fc1, fc2, fc3, fc4 = st.columns(4)
+ 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")
+
+ view = df.copy()
+ if "close_time" in view.columns and tr_date_from and tr_date_to:
+ ct = pd.to_datetime(view["close_time"]).dt.tz_localize(None)
+ view = view[
+ (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 result_f == "Wins only": view = view[view["net_profit"] > 0]
+ elif result_f == "Losses only": view = view[view["net_profit"] <= 0]
+
+ keep = [c for c in ["_strategy","symbol","type","open_time","open_price",
+ "close_time","close_price","volume","net_profit","comment"]
+ if c in view.columns]
+ rename = {"_strategy":"Strategy","symbol":"Symbol","type":"Type",
+ "open_time":"Open Time","open_price":"Open Price",
+ "close_time":"Close Time","close_price":"Close Price",
+ "volume":"Volume","net_profit":"P/L ($)","comment":"Comment"}
+ ddf = view[keep].rename(columns=rename).copy()
+ for dc in ["Open Time","Close Time"]:
+ if dc in ddf.columns:
+ ddf[dc] = pd.to_datetime(ddf[dc], errors="coerce").dt.strftime("%d.%m.%Y %H:%M")
+
+ # [6] Format all numeric columns to 2dp
+ num_cols = ddf.select_dtypes(include="number").columns.tolist()
+ fmt_dict = {c: "{:.2f}" for c in num_cols}
+
+ # Compact stats bar above trade list
+ tr_stats = _calc_stats(view, deposit)
+ if tr_stats:
+ ts1,ts2,ts3,ts4,ts5,ts6 = st.columns(6)
+ _np = tr_stats.get("net_profit",0)
+ ts1.metric("Trades", f"{tr_stats.get('num_trades',0):,}")
+ ts2.metric("Net Profit", f"${_np:,.2f}")
+ ts3.metric("Win Rate", f"{tr_stats.get('win_rate',0):.2f}%")
+ ts4.metric("Profit Factor", f"{tr_stats.get('profit_factor',0):.2f}"
+ if tr_stats.get('profit_factor',0) != float('inf') else "∞")
+ ts5.metric("Max DD", f"${tr_stats.get('max_dd',0):,.2f}")
+ ts6.metric("Avg Trade", f"${tr_stats.get('avg_trade',0):.2f}")
+ st.markdown("")
+
+ st.caption(f"{len(ddf):,} trades · use ⛶ to expand full screen")
+
+ def _hl(val):
+ if not isinstance(val, (int,float)): return ""
+ if val > 0: return "background-color:#1A3A26"
+ if val < 0: return "background-color:#3A1A1A"
+ return ""
+
+ # No fixed height — Streamlit will size to content on the page
+ # and properly fill the screen when the fullscreen icon is clicked.
+ st.dataframe(
+ ddf.style
+ .format(fmt_dict)
+ .map(_hl, subset=["P/L ($)"] if "P/L ($)" in ddf.columns else []),
+ use_container_width=True,
+ )
+ buf = io.StringIO()
+ ddf.to_csv(buf, index=False)
+ st.download_button("⬇️ Export CSV", buf.getvalue(),
+ file_name=f"trades_{active_label}.csv", mime="text/csv")
+
+ # ═════════════════════════════════════════════════════════════════════════
+ # EQUITY CHART
+ # ═════════════════════════════════════════════════════════════════════════
+ with tab_eq:
+ 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
+ ctl1, ctl2, ctl3 = st.columns([3, 2, 2])
+ chart_view = ctl1.radio(
+ "Lines",
+ ["Portfolio", "Individual strategies", "Portfolio + Individual"],
+ horizontal=True, key="pb_cv",
+ )
+ smooth_window = ctl2.slider("Smoothing", 1, 50, 1, key="pb_sm",
+ help="Rolling-average window (trades). 1 = raw.")
+ show_stag = ctl3.checkbox("Show stagnation band", value=True, key="pb_stag")
+
+ # 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())
+ else:
+ default_strats = list(eff_dfs.keys())
+
+ sel_strats = st.multiselect(
+ "Strategies to show", list(eff_dfs.keys()),
+ default=default_strats, key="pb_sel_strats",
+ )
+
+ # Determine combined df for Portfolio+Individual
+ 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
+
+ cv_map = {
+ "Portfolio": "Portfolio",
+ "Individual strategies": "Individual",
+ "Portfolio + Individual": "Portfolio+Individual",
+ }
+ fig = _build_equity_chart(
+ chart_df, deposit, eff_dfs, portfolios, active_label,
+ chart_view=cv_map[chart_view],
+ smooth_window=smooth_window,
+ show_stagnation=show_stag,
+ date_from=date_from, date_to=date_to,
+ selected_strategies=sel_strats,
+ )
+ st.plotly_chart(fig, use_container_width=True)
+
+ # ═════════════════════════════════════════════════════════════════════════
+ # STRATEGIES TABLE [2] lot size, no CAGR [3] smoothing [4] taller chart
+ # ═════════════════════════════════════════════════════════════════════════
+ with tab_st:
+ st.markdown("##### All Strategies — Performance Summary")
+ tdf = _strategy_table(eff_dfs, deposit, lot_overrides)
+ if tdf.empty:
+ st.info("No strategies loaded.")
+ else:
+ def _cc(val, low=0):
+ if not isinstance(val, (int,float)): return ""
+ return "color:#34C27A" if val > low else "color:#E05555" if val < low else ""
+
+ # [6] 2dp formatting for all numeric columns
+ num_cols_t = tdf.select_dtypes(include="number").columns.tolist()
+ fmt_t = {c: "{:.2f}" for c in num_cols_t if c != "Trades"}
+ fmt_t["Trades"] = "{:.0f}"
+
+ styled = (
+ tdf.style
+ .format(fmt_t)
+ .map(_cc, subset=["Net Profit ($)","Avg Trade ($)"])
+ .map(lambda v: _cc(v, 1.0), subset=["Profit Factor"])
+ .map(lambda v: "color:#E05555"
+ if isinstance(v,(int,float)) and v < 0 else "",
+ subset=["Max DD ($)","Max DD (%)"])
+ )
+ st.dataframe(styled, use_container_width=True, hide_index=True)
+
+ # [3] Smoothing slider [4] Taller chart (height=500)
+ st.markdown("##### Equity Curves")
+ sc_smooth = st.slider("Curve smoothing", 1, 50, 1, key="pb_st_smooth",
+ help="Rolling-average window (trades).")
+ sf = go.Figure()
+ sf.update_layout(
+ height=500, # [4] taller
+ margin=dict(l=40, r=20, t=10, b=10),
+ paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="#0E1117",
+ legend=dict(orientation="h", y=1.08, font=dict(size=10)),
+ hovermode="x unified",
+ )
+ sf.update_xaxes(gridcolor="#1E2130", zeroline=False)
+ sf.update_yaxes(gridcolor="#1E2130", zeroline=False, tickprefix="$")
+ for i, (lbl, sdf) in enumerate(eff_dfs.items()):
+ if "close_time" not in sdf.columns or "net_profit" not in sdf.columns:
+ continue
+ sdf_s = sdf.sort_values("close_time")
+ eq = deposit + sdf_s["net_profit"].cumsum()
+ eq_s = _smooth(eq.reset_index(drop=True), sc_smooth)
+ sf.add_trace(go.Scatter(
+ x=sdf_s["close_time"].values, y=eq_s,
+ name=lbl, mode="lines",
+ line=dict(color=COLORS[i % len(COLORS)], width=1.5),
+ ))
+ st.plotly_chart(sf, use_container_width=True)
+
+ # ═════════════════════════════════════════════════════════════════════════
+ # WHAT-IF [5] lot-size scaling per strategy
+ # ═════════════════════════════════════════════════════════════════════════
+ with tab_wi:
+ st.markdown("##### What-If: Lot Size Adjustment")
+ st.caption(
+ "Enter a target lot size for each strategy. The tool calculates the "
+ "multiplier automatically from the strategy's average lot size. "
+ "Changes apply everywhere (Overview, Equity Chart, Strategies table)."
+ )
+
+ changed = False
+ for label in list(strategy_dfs.keys()):
+ orig_df = strategy_dfs[label]
+ orig_stats = _calc_stats(orig_df, deposit)
+ orig_dd = orig_stats.get("max_dd", 0)
+ orig_avg_lot = orig_stats.get("avg_lot", 0.0)
+ current_mult = lot_overrides.get(label, 1.0)
+
+ # Derive the currently displayed lot size from the multiplier
+ # (avg_lot is from original unscaled data, so effective = orig * mult)
+ current_lot = round(orig_avg_lot * current_mult, 4) if orig_avg_lot else current_mult
+
+ wi_c1, wi_c2, wi_c3, wi_c4 = st.columns([3, 1, 1, 1])
+ wi_c1.markdown(f"**{label}**")
+ wi_c2.markdown(
+ f"Avg lot
"
+ f"{orig_avg_lot:.4f}",
+ unsafe_allow_html=True)
+ wi_c3.markdown(
+ f"Current ×
"
+ f"{current_mult:.4f}",
+ unsafe_allow_html=True)
+
+ new_lot = wi_c4.number_input(
+ "Target lot size", min_value=0.0001, max_value=9999.0,
+ value=float(current_lot) if current_lot > 0 else float(orig_avg_lot) if orig_avg_lot else 0.01,
+ step=0.01, format="%.4f",
+ key=f"pb_wi_{label}",
+ label_visibility="collapsed",
+ help="Enter the lot size you want for this strategy",
+ )
+
+ # Compute new multiplier from target lot / original avg lot
+ if orig_avg_lot and orig_avg_lot > 0:
+ new_mult = new_lot / orig_avg_lot
+ else:
+ new_mult = new_lot # fallback: treat as direct multiplier
+
+ new_mult = round(new_mult, 6)
+ if abs(new_mult - current_mult) > 1e-9:
+ st.session_state.pb_lot_overrides[label] = new_mult
+ changed = True
+
+ # Before / after metrics
+ scaled_df = _scale_df(orig_df, new_mult)
+ scaled_stats = _calc_stats(scaled_df, deposit)
+ s1, s2, s3, s4 = st.columns(4)
+ s1.metric("Net Profit", f"${scaled_stats.get('net_profit',0):,.2f}",
+ delta=f"{scaled_stats.get('net_profit',0)-orig_stats.get('net_profit',0):+.2f}")
+ s2.metric("Max DD", f"${scaled_stats.get('max_dd',0):,.2f}",
+ delta=f"{scaled_stats.get('max_dd',0)-orig_dd:+.2f}")
+ s3.metric("Profit Factor", f"{scaled_stats.get('profit_factor',0):.2f}")
+ s4.metric("Win Rate", f"{scaled_stats.get('win_rate',0):.2f}%")
+ st.markdown("---")
+
+ if changed:
+ st.rerun()
+
+ if any(v != 1.0 for v in lot_overrides.values()):
+ if st.button("Reset all lot sizes to original", key="pb_wi_reset"):
+ st.session_state.pb_lot_overrides = {}
+ st.rerun()
+
+ # ═════════════════════════════════════════════════════════════════════════
+ # PORTFOLIOS MANAGER
+ # ═════════════════════════════════════════════════════════════════════════
+ with tab_pf:
+ st.markdown("##### Custom Portfolios")
+ st.caption("Named portfolios appear in the View dropdown. "
+ "Lot-size overrides from the What-If tab are reflected in portfolio stats.")
+
+ with st.expander("➕ Create new portfolio",
+ expanded=not bool(portfolios)):
+ pname = st.text_input("Portfolio name", key="pb_pname",
+ placeholder="e.g. Gold Strategies")
+ members = st.multiselect("Strategies to include",
+ list(strategy_dfs.keys()), key="pb_pmembers")
+ if st.button("Save portfolio", key="pb_psave"):
+ if not pname.strip():
+ st.warning("Enter a portfolio name.")
+ elif not members:
+ st.warning("Select at least one strategy.")
+ else:
+ st.session_state.pb_portfolios[pname.strip()] = list(members)
+ st.success(f"✅ **{pname.strip()}** saved.")
+ st.rerun()
+
+ if not portfolios:
+ st.info("No custom portfolios yet.")
+ else:
+ for pname, members in list(portfolios.items()):
+ with st.expander(f"📁 {pname} ({len(members)} strategies)"):
+ for m in members:
+ mult = lot_overrides.get(m, 1.0)
+ tag = "✅" if m in strategy_dfs else "⚠️ not loaded"
+ mult_str = f" ×{mult:.2f}" if mult != 1.0 else ""
+ st.markdown(f"- {m} {tag}{mult_str}")
+
+ new_members = st.multiselect(
+ "Edit members", list(strategy_dfs.keys()),
+ default=[m for m in members if m in strategy_dfs],
+ key=f"pb_edit_{pname}",
+ )
+ ec1, ec2 = st.columns(2)
+ if ec1.button("Update", key=f"pb_upd_{pname}"):
+ st.session_state.pb_portfolios[pname] = list(new_members)
+ st.success("Updated.")
+ st.rerun()
+ if ec2.button("🗑 Delete", key=f"pb_del_{pname}"):
+ del st.session_state.pb_portfolios[pname]
+ st.rerun()
+
+ pm_dfs = {k: eff_dfs[k] for k in members if k in eff_dfs}
+ if pm_dfs:
+ pdf = _combine(pm_dfs, deposit)
+ pstat = _calc_stats(pdf, deposit)
+ mc1,mc2,mc3,mc4 = st.columns(4)
+ mc1.metric("Net Profit", f"${pstat.get('net_profit',0):,.2f}")
+ mc2.metric("Win Rate", f"{pstat.get('win_rate',0):.2f}%")
+ mc3.metric("Profit Factor", f"{pstat.get('profit_factor',0):.2f}")
+ mc4.metric("Max DD", f"${pstat.get('max_dd',0):,.2f}")
\ No newline at end of file
diff --git a/view_portfolio_master.py b/view_portfolio_master.py
new file mode 100644
index 0000000..355d082
--- /dev/null
+++ b/view_portfolio_master.py
@@ -0,0 +1,951 @@
+"""
+view_portfolio_master.py — Portfolio Master
+Automated portfolio construction from uploaded backtest files.
+Ranks strategy combinations by Return/DD, Net Profit, or Stagnation %.
+Filters by correlation, date range, min/max strategies per portfolio.
+"""
+
+import streamlit as st
+import pandas as pd
+import numpy as np
+import plotly.graph_objects as go
+from scipy import stats as scipy_stats
+import io, importlib, sys, os, itertools
+from datetime import timedelta
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Parser (shared with portfolio builder)
+# ─────────────────────────────────────────────────────────────────────────────
+def _get_parser():
+ if "mt5_parser" in sys.modules:
+ return importlib.reload(sys.modules["mt5_parser"])
+ import mt5_parser
+ return mt5_parser
+
+
+def _parse_file(file_obj):
+ try:
+ parser = _get_parser()
+ raw = file_obj.read()
+ result = parser.detect_and_parse(raw)
+ return result[0] if isinstance(result, tuple) else result
+ except Exception as e:
+ st.error(f"Failed to parse **{file_obj.name}**: {e}")
+ return None
+
+
+def _normalise(df: pd.DataFrame, label: str) -> pd.DataFrame:
+ col_map = {}
+ def _f(targets, dest):
+ for c in targets:
+ if c in df.columns and dest not in col_map.values():
+ col_map[c] = dest; return
+ _f(["open_time","Open time","Open time ($)","Time"], "open_time")
+ _f(["close_time","Close time"], "close_time")
+ _f(["symbol","Symbol"], "symbol")
+ _f(["type","Type","Direction"], "type")
+ _f(["net_profit","P/L in money","Profit","profit"], "net_profit")
+ _f(["volume","Volume","Size","size"], "volume")
+ _f(["commission","Commission"], "commission")
+ _f(["swap","Swap"], "swap")
+ df = df.rename(columns=col_map)
+ if "net_profit" not in df.columns:
+ for c in ["profit","Profit","P/L"]:
+ if c in df.columns:
+ comm = pd.to_numeric(df.get("commission",0), errors="coerce").fillna(0)
+ swap_ = pd.to_numeric(df.get("swap",0), errors="coerce").fillna(0)
+ df["net_profit"] = pd.to_numeric(df[c], errors="coerce").fillna(0)+comm+swap_
+ break
+ for tc in ["open_time","close_time"]:
+ if tc in df.columns:
+ df[tc] = pd.to_datetime(df[tc], dayfirst=True, errors="coerce")
+ if "net_profit" in df.columns:
+ df["net_profit"] = pd.to_numeric(df["net_profit"], errors="coerce").fillna(0)
+ df["_strategy"] = label
+ return df
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Per-strategy statistics (full output columns)
+# ─────────────────────────────────────────────────────────────────────────────
+def _full_stats(df: pd.DataFrame, deposit: float, idx: int, custom_name: str) -> dict:
+ s = {}
+ if df.empty or "net_profit" not in df.columns:
+ return s
+
+ label = df["_strategy"].iloc[0] if "_strategy" in df.columns else f"#{idx}"
+ symbol = df["symbol"].iloc[0] if "symbol" in df.columns else ""
+ profits = df["net_profit"].fillna(0)
+
+ s["#"] = idx
+ s["Strategy Name"] = custom_name if custom_name else label
+ s["Symbol"] = str(symbol).split(".")[0] if symbol else ""
+ s["# Trades"] = len(df)
+ s["Net Profit ($)"] = round(float(profits.sum()), 2)
+ s["Avg Win ($)"] = round(float(profits[profits > 0].mean()), 2) if (profits > 0).any() else 0.0
+ s["Avg Loss ($)"] = round(float(profits[profits < 0].mean()), 2) if (profits < 0).any() else 0.0
+ s["% Wins"] = round(float((profits > 0).sum() / len(profits) * 100), 2)
+
+ gp = float(profits[profits > 0].sum())
+ gl = float(profits[profits < 0].sum())
+ s["Profit Factor"] = round(gp / abs(gl), 2) if gl else 999.0
+
+ # Commission
+ if "commission" in df.columns:
+ s["Commissions ($)"] = round(float(pd.to_numeric(df["commission"], errors="coerce").fillna(0).sum()), 2)
+ else:
+ s["Commissions ($)"] = 0.0
+
+ # Equity & drawdown
+ eq = deposit + profits.cumsum()
+ rm = eq.cummax()
+ dd = eq - rm
+ s["Max DD ($)"] = round(float(dd.min()), 2)
+ # DD % and Annual % both relative to the single initial deposit entered by user
+ s["Max DD (%)"] = round(float(dd.min() / deposit * 100), 2)
+ s["Ret/DD"] = round(s["Net Profit ($)"] / abs(s["Max DD ($)"]), 2) if s["Max DD ($)"] else 0.0
+
+ # Date span
+ if "close_time" in df.columns and "open_time" in df.columns:
+ vc = df["close_time"].dropna()
+ vo = df["open_time"].dropna()
+ if not vc.empty:
+ start = vo.min() if not vo.empty else vc.min()
+ end = vc.max()
+ days = max((end - start).days, 1)
+ yrs = days / 365.25
+ s["Annual Profit ($)"] = round(s["Net Profit ($)"] / yrs, 2)
+ s["Annual Profit (%)"] = round(s["Net Profit ($)"] / deposit / yrs * 100, 2)
+ else:
+ s["Annual Profit ($)"] = 0.0
+ s["Annual Profit (%)"] = 0.0
+ else:
+ s["Annual Profit ($)"] = 0.0
+ s["Annual Profit (%)"] = 0.0
+
+ # Max position exposure — peak number of simultaneously open trades
+ # Uses a timeline sweep: +1 at open_time, -1 at close_time
+ # Works correctly for both single strategies and combined portfolios
+ if "open_time" in df.columns and "close_time" in df.columns:
+ try:
+ trades = df[["open_time","close_time"]].dropna()
+ # Build event list: (timestamp, change, is_open)
+ opens = pd.DataFrame({"dt": pd.to_datetime(trades["open_time"], errors="coerce"), "chg": 1})
+ closes = pd.DataFrame({"dt": pd.to_datetime(trades["close_time"], errors="coerce"), "chg": -1})
+ ev = pd.concat([opens, closes]).dropna(subset=["dt"]).sort_values("dt").reset_index(drop=True)
+ cur = mx = 0; mx_dt = None
+ for _, row in ev.iterrows():
+ cur += int(row["chg"])
+ if cur > mx:
+ mx = cur
+ mx_dt = row["dt"]
+ s["Max Pos Exposure"] = mx
+ s["Max Pos Exposure Dt"] = str(mx_dt)[:10] if mx_dt else ""
+ except Exception:
+ s["Max Pos Exposure"] = 0
+ s["Max Pos Exposure Dt"] = ""
+ else:
+ s["Max Pos Exposure"] = 0
+ s["Max Pos Exposure Dt"] = ""
+
+ # Stagnation
+ if "close_time" in df.columns:
+ eq_ts = df[["close_time","net_profit"]].dropna().sort_values("close_time").copy()
+ if not eq_ts.empty:
+ eq_ts["cum"] = deposit + eq_ts["net_profit"].cumsum()
+ eq_ts["date"] = eq_ts["close_time"].dt.date
+ dly = eq_ts.groupby("date")["cum"].last().reset_index()
+ total_days = max((dly["date"].iloc[-1] - dly["date"].iloc[0]).days, 1)
+ peak = float(dly["cum"].iloc[0])
+ stag_start = dly["date"].iloc[0]
+ max_stag = 0
+ for _, r in dly.iterrows():
+ if float(r["cum"]) > peak:
+ peak = float(r["cum"]); stag_start = r["date"]
+ else:
+ max_stag = max(max_stag, (r["date"] - stag_start).days)
+ s["Stagnation (days)"] = max_stag
+ s["Stagnation (%)"] = round(max_stag / total_days * 100, 2)
+ else:
+ s["Stagnation (days)"] = 0
+ s["Stagnation (%)"] = 0.0
+ else:
+ s["Stagnation (days)"] = 0
+ s["Stagnation (%)"] = 0.0
+
+ # Stability — R² of linear regression on equity curve
+ if len(eq) > 2:
+ x = np.arange(len(eq))
+ slope, intercept, r, p, se = scipy_stats.linregress(x, eq.values)
+ s["Stability"] = round(float(r ** 2), 4)
+ else:
+ s["Stability"] = 0.0
+
+ return s
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Daily P&L series for correlation
+# ─────────────────────────────────────────────────────────────────────────────
+def _daily_pnl(df: pd.DataFrame) -> pd.Series:
+ if df.empty or "close_time" not in df.columns or "net_profit" not in df.columns:
+ return pd.Series(dtype=float)
+ tmp = df[["close_time","net_profit"]].dropna().copy()
+ tmp["date"] = pd.to_datetime(tmp["close_time"]).dt.tz_localize(None).dt.normalize()
+ return tmp.groupby("date")["net_profit"].sum()
+
+
+def _correlation_matrix(dfs: dict) -> pd.DataFrame:
+ series = {label: _daily_pnl(df) for label, df in dfs.items()}
+ aligned = pd.DataFrame(series).fillna(0)
+ return aligned.corr()
+
+
+def _portfolio_exceeds_corr(members: list, corr_matrix: pd.DataFrame, max_corr: float) -> bool:
+ for a, b in itertools.combinations(members, 2):
+ if a in corr_matrix.index and b in corr_matrix.columns:
+ if abs(corr_matrix.loc[a, b]) > max_corr:
+ return True
+ return False
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Portfolio stats (combined)
+# ─────────────────────────────────────────────────────────────────────────────
+def _portfolio_score(members: list, dfs: dict, deposit: float, rank_by: str) -> dict:
+ if not members:
+ return {}
+ frames = [dfs[m].copy() for m in members if m in dfs]
+ if not frames:
+ return {}
+ combined = pd.concat(frames, ignore_index=True)
+ if "close_time" in combined.columns:
+ combined = combined.sort_values("close_time").reset_index(drop=True)
+
+ # Reuse _full_stats on the combined df — give it a synthetic label
+ combined["_strategy"] = " + ".join(members)
+ full = _full_stats(combined, deposit, 0, " + ".join(members))
+
+ net_p = full.get("Net Profit ($)", 0.0)
+ max_dd = full.get("Max DD ($)", 0.0)
+ ret_dd = full.get("Ret/DD", 0.0)
+ stag_pct = full.get("Stagnation (%)", 0.0)
+
+ if rank_by == "Return/DD":
+ score = ret_dd
+ elif rank_by == "Net Profit":
+ score = net_p
+ else: # Stagnation % — lower is better, invert
+ score = -stag_pct
+
+ return {
+ "members": members,
+ "score": score,
+ "net_profit":net_p,
+ "max_dd": max_dd,
+ "ret_dd": ret_dd,
+ "stag_pct": stag_pct,
+ "full_stats":full, # full column set for results table
+ }
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Session state
+# ─────────────────────────────────────────────────────────────────────────────
+def _init_state():
+ for k, v in {
+ "pm_files": {}, # label → df
+ "pm_custom_names": {}, # label → custom name string
+ "pm_results": [], # list of result dicts
+ "pm_deposit": 10000.0,
+ }.items():
+ if k not in st.session_state:
+ st.session_state[k] = v
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Render
+# ─────────────────────────────────────────────────────────────────────────────
+def render():
+ _init_state()
+
+ st.markdown("""""", unsafe_allow_html=True)
+
+ st.markdown('🏆 Portfolio Master
', unsafe_allow_html=True)
+ st.markdown('Automated portfolio construction — rank, filter and score strategy combinations
',
+ unsafe_allow_html=True)
+
+ # ── Upload ───────────────────────────────────────────────────────────────
+ with st.expander("📂 Upload Backtest Files",
+ expanded=not bool(st.session_state.pm_files)):
+ st.caption("Accepts `.htm` · `.html` · `.csv`")
+ uploaded = st.file_uploader(
+ "Select files", type=None, accept_multiple_files=True, key="pm_uploader",
+ )
+ if uploaded:
+ uploaded = [f for f in uploaded
+ if f.name.lower().endswith((".htm",".html",".csv"))]
+ for f in uploaded:
+ stem = os.path.splitext(f.name)[0]
+ if stem not in st.session_state.pm_files:
+ df = _parse_file(f)
+ if df is not None:
+ df = _normalise(df, stem)
+ st.session_state.pm_files[stem] = df
+ st.success(f"✅ **{stem}** — {len(df):,} trades")
+
+ if st.session_state.pm_files:
+ to_remove = []
+ for label in list(st.session_state.pm_files):
+ c1, c2 = st.columns([6,1])
+ c1.markdown(f"📈 {label}", unsafe_allow_html=True)
+ if c2.button("✕", key=f"pmrm_{label}"):
+ to_remove.append(label)
+ for k in to_remove:
+ del st.session_state.pm_files[k]
+ st.session_state.pm_custom_names.pop(k, None)
+ st.rerun()
+
+ strategy_dfs: dict = st.session_state.pm_files
+ if not strategy_dfs:
+ st.info("Upload backtest files above to get started.")
+ return
+
+ labels = list(strategy_dfs.keys())
+
+ # ── Tabs ─────────────────────────────────────────────────────────────────
+ tab_config, tab_strategies, tab_results, tab_compare = st.tabs([
+ "⚙️ Configure & Run", "📊 Strategy Stats", "🏆 Results", "🔀 Compare Import",
+ ])
+
+ # ═════════════════════════════════════════════════════════════════════════
+ # CONFIGURE & RUN
+ # ═════════════════════════════════════════════════════════════════════════
+ with tab_config:
+ st.markdown('Capital & Scoring
', unsafe_allow_html=True)
+ cfg1, cfg2 = st.columns(2)
+ deposit = cfg1.number_input("Initial Deposit ($)", min_value=100.0,
+ max_value=10_000_000.0,
+ value=st.session_state.pm_deposit,
+ step=1000.0, format="%.2f", key="pm_deposit")
+
+ rank_by = cfg2.selectbox("Rank portfolios by",
+ ["Return/DD", "Net Profit", "% Stagnation (lower = better)"],
+ key="pm_rank")
+ rank_key = rank_by.split(" ")[0] if "Stagnation" not in rank_by else "Stagnation %"
+
+ st.markdown('Portfolio Size
', unsafe_allow_html=True)
+ sz1, sz2, sz3 = st.columns(3)
+ min_strats = sz1.number_input("Min strategies", min_value=1,
+ max_value=len(labels), value=2,
+ step=1, key="pm_min")
+ max_strats = sz2.number_input("Max strategies", min_value=1,
+ max_value=len(labels),
+ value=min(5, len(labels)),
+ step=1, key="pm_max")
+ max_results= sz3.number_input("Max portfolios to store", min_value=1,
+ max_value=500, value=50,
+ step=10, key="pm_maxres")
+
+ st.markdown('Correlation Filter
', unsafe_allow_html=True)
+ use_corr = st.checkbox("Enable correlation filter", value=True, key="pm_use_corr")
+ corr_limit = st.slider("Max allowed pairwise correlation",
+ min_value=0.10, max_value=0.70,
+ value=0.50, step=0.05, key="pm_corr",
+ disabled=not use_corr,
+ help="Portfolios containing any pair of strategies "
+ "with |correlation| > this value are excluded. "
+ "Correlation is computed on daily P&L.")
+
+ st.markdown('Date Range Filter
', unsafe_allow_html=True)
+ # Build global min/max from all loaded files
+ all_dates = []
+ for df in strategy_dfs.values():
+ if "close_time" in df.columns:
+ all_dates.append(pd.to_datetime(df["close_time"]).dt.tz_localize(None).dropna())
+ if all_dates:
+ g_min = min(s.min().date() for s in all_dates)
+ g_max = max(s.max().date() for s in all_dates)
+ use_date = st.checkbox("Filter by date range", value=False, key="pm_use_date")
+ if use_date and g_min != g_max:
+ import datetime as _dt
+ total_days = (g_max - g_min).days
+ step = max(1, total_days // 500)
+ date_opts = [g_min + _dt.timedelta(days=i)
+ for i in range(0, total_days+1, step)]
+ if date_opts[-1] != g_max:
+ date_opts.append(g_max)
+ date_sel = st.select_slider(
+ "Date range", options=date_opts, value=(g_min, g_max),
+ format_func=lambda d: d.strftime("%d %b %Y"),
+ key="pm_daterange",
+ )
+ date_from, date_to = date_sel
+ else:
+ date_from, date_to = None, None
+ else:
+ date_from, date_to = None, None
+
+ st.markdown('Strategy Selection
', unsafe_allow_html=True)
+ st.caption("Choose which uploaded strategies to include in the search.")
+ sel_labels = st.multiselect(
+ "Strategies to include", labels, default=labels, key="pm_sel_labels",
+ )
+
+ st.markdown("---")
+ run_btn = st.button("🚀 Run Portfolio Search", type="primary", key="pm_run")
+
+ if run_btn:
+ if len(sel_labels) < max(min_strats, 1):
+ st.error(f"Need at least {min_strats} strategies selected.")
+ else:
+ with st.spinner("Searching combinations…"):
+ # Apply date filter to each df
+ filtered_dfs = {}
+ for lbl in sel_labels:
+ df = 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)) &
+ (ct <= pd.Timestamp(date_to) + timedelta(days=1))]
+ if not df.empty:
+ filtered_dfs[lbl] = df
+
+ if not filtered_dfs:
+ st.error("No data in selected date range.")
+ else:
+ corr_matrix = _correlation_matrix(filtered_dfs) if use_corr else None
+
+ results = []
+ total_combos = sum(
+ len(list(itertools.combinations(list(filtered_dfs.keys()), r)))
+ for r in range(min_strats, max_strats + 1)
+ )
+ prog = st.progress(0, text="Evaluating combinations…")
+ done = 0
+
+ for size in range(int(min_strats), int(max_strats) + 1):
+ for combo in itertools.combinations(list(filtered_dfs.keys()), size):
+ combo = list(combo)
+ done += 1
+ if done % 50 == 0:
+ prog.progress(min(done / max(total_combos, 1), 1.0),
+ text=f"Evaluated {done:,} / {total_combos:,}")
+
+ if use_corr and corr_matrix is not None:
+ if _portfolio_exceeds_corr(combo, corr_matrix, corr_limit):
+ continue
+
+ result = _portfolio_score(combo, filtered_dfs, deposit, rank_key)
+ if result:
+ results.append(result)
+
+ prog.progress(1.0, text="Done.")
+ results.sort(key=lambda x: x["score"], reverse=True)
+ st.session_state.pm_results = results[:int(max_results)]
+ st.success(f"Found **{len(results):,}** valid portfolios → "
+ f"showing top **{len(st.session_state.pm_results)}**.")
+
+ # ═════════════════════════════════════════════════════════════════════════
+ # STRATEGY STATS TABLE
+ # ═════════════════════════════════════════════════════════════════════════
+ with tab_strategies:
+ st.markdown("##### Individual Strategy Statistics")
+ st.caption("Edit the Strategy Name column to assign custom names. "
+ "These names carry through to the Results tab.")
+
+ deposit_s = st.session_state.pm_deposit
+ rows = []
+ for i, label in enumerate(labels):
+ custom = st.session_state.pm_custom_names.get(label, "")
+ row = _full_stats(strategy_dfs[label], deposit_s, i + 1, custom)
+ if row:
+ rows.append(row)
+
+ if rows:
+ stats_df = pd.DataFrame(rows)
+
+ # Column order
+ col_order = [
+ "#", "Strategy Name", "Symbol", "# Trades",
+ "Net Profit ($)", "Max DD ($)", "Max DD (%)",
+ "Annual Profit ($)", "Annual Profit (%)",
+ "Avg Win ($)", "Avg Loss ($)", "% Wins",
+ "Commissions ($)", "Max Pos Exposure", "Max Pos Exposure Dt",
+ "Stagnation (%)", "Stagnation (days)", "Profit Factor",
+ "Ret/DD", "Stability",
+ ]
+ col_order = [c for c in col_order if c in stats_df.columns]
+ stats_df = stats_df[col_order]
+
+ # Editable table — only Strategy Name is editable
+ edited = st.data_editor(
+ stats_df,
+ use_container_width=True,
+ hide_index=True,
+ column_config={
+ "#": st.column_config.NumberColumn("#", disabled=True, width="small"),
+ "Strategy Name": st.column_config.TextColumn("Strategy Name", width="medium"),
+ "Symbol": st.column_config.TextColumn("Symbol", disabled=True),
+ "# Trades": st.column_config.NumberColumn("# Trades", disabled=True, format="%d"),
+ "Net Profit ($)": st.column_config.NumberColumn("Net Profit ($)", disabled=True, format="%.2f"),
+ "Max DD ($)": st.column_config.NumberColumn("Max DD ($)", disabled=True, format="%.2f"),
+ "Max DD (%)": st.column_config.NumberColumn("Max DD (%)", disabled=True, format="%.2f"),
+ "Annual Profit ($)": st.column_config.NumberColumn("Annual Profit ($)", disabled=True, format="%.2f"),
+ "Annual Profit (%)": st.column_config.NumberColumn("Annual Profit (%)", disabled=True, format="%.2f"),
+ "Avg Win ($)": st.column_config.NumberColumn("Avg Win ($)", disabled=True, format="%.2f"),
+ "Avg Loss ($)": st.column_config.NumberColumn("Avg Loss ($)", disabled=True, format="%.2f"),
+ "% Wins": st.column_config.NumberColumn("% Wins", disabled=True, format="%.2f"),
+ "Commissions ($)": st.column_config.NumberColumn("Commissions ($)", disabled=True, format="%.2f"),
+ "Max Pos Exposure": st.column_config.NumberColumn("Max Pos Exp", disabled=True, format="%d"),
+ "Max Pos Exposure Dt":st.column_config.TextColumn("Max Pos Date", disabled=True),
+ "Stagnation (%)": st.column_config.NumberColumn("Stagnation (%)", disabled=True, format="%.2f"),
+ "Stagnation (days)": st.column_config.NumberColumn("Stagnation (d)", disabled=True, format="%d"),
+ "Profit Factor": st.column_config.NumberColumn("PF", disabled=True, format="%.2f"),
+ "Ret/DD": st.column_config.NumberColumn("Ret/DD", disabled=True, format="%.2f"),
+ "Stability": st.column_config.NumberColumn("Stability", disabled=True, format="%.4f",
+ help="R² of linear regression on equity curve. 1.0 = perfectly straight rising line."),
+ },
+ key="pm_stats_editor",
+ )
+
+ # Save any custom name edits back to session state
+ for _, row in edited.iterrows():
+ orig_label = labels[int(row["#"]) - 1]
+ new_name = str(row["Strategy Name"]).strip()
+ if new_name and new_name != orig_label:
+ st.session_state.pm_custom_names[orig_label] = new_name
+ else:
+ st.session_state.pm_custom_names.pop(orig_label, None)
+
+ # Correlation heatmap
+ if len(labels) > 1:
+ st.markdown("##### Pairwise Correlation (Daily P&L)")
+ corr = _correlation_matrix(strategy_dfs)
+ display_labels = [
+ st.session_state.pm_custom_names.get(l, l) for l in corr.columns
+ ]
+ # Text colour: dark for light cells (near zero), white for dark cells
+ text_vals = np.round(corr.values, 2)
+ text_colors = [["#1a1a2e" if abs(v) < 0.4 else "#FFFFFF"
+ for v in row] for row in corr.values]
+
+ fig_corr = go.Figure(go.Heatmap(
+ z=corr.values,
+ x=display_labels, y=display_labels,
+ colorscale=[
+ [0.00, "#2166AC"], # strong negative — blue
+ [0.25, "#92C5DE"], # mild negative — light blue
+ [0.50, "#E8E8E8"], # zero — light grey
+ [0.75, "#F4A582"], # mild positive — salmon
+ [1.00, "#B2182B"], # strong positive — red
+ ],
+ zmid=0, zmin=-1, zmax=1,
+ text=text_vals,
+ texttemplate="%{text}",
+ textfont=dict(size=11, color="#1a1a2e"),
+ hovertemplate="%{x} / %{y}: %{z:.3f}",
+ ))
+ fig_corr.update_layout(
+ height=max(300, len(labels) * 55),
+ margin=dict(l=20, r=80, t=10, b=10),
+ paper_bgcolor="#F0F2F6",
+ plot_bgcolor="#F0F2F6",
+ xaxis=dict(tickfont=dict(size=10, color="#333"),
+ tickangle=-30),
+ yaxis=dict(tickfont=dict(size=10, color="#333")),
+ coloraxis_colorbar=dict(
+ tickfont=dict(color="#333"),
+ outlinecolor="#ccc",
+ ),
+ )
+ st.plotly_chart(fig_corr, use_container_width=True)
+
+ # ═════════════════════════════════════════════════════════════════════════
+ # RESULTS
+ # ═════════════════════════════════════════════════════════════════════════
+ with tab_results:
+ results = st.session_state.pm_results
+ if not results:
+ st.info("Run the portfolio search on the Configure tab first.")
+ else:
+ st.markdown(f"##### Top {len(results)} Portfolios")
+
+ def _name(lbl):
+ return st.session_state.pm_custom_names.get(lbl, lbl)
+
+ rank_label = {
+ "Return/DD": "Ret/DD",
+ "Net Profit": "Net Profit ($)",
+ "Stagnation %": "Stagnation (%)",
+ }.get(rank_key, "Score")
+
+ # Build results table with same columns as Strategy Stats
+ col_order = [
+ "Rank", "Strategies", "# Strategies",
+ "# Trades", "Net Profit ($)", "Max DD ($)", "Max DD (%)",
+ "Annual Profit ($)", "Annual Profit (%)",
+ "Avg Win ($)", "Avg Loss ($)", "% Wins",
+ "Commissions ($)", "Max Pos Exposure", "Max Pos Exposure Dt",
+ "Stagnation (%)", "Stagnation (days)", "Profit Factor",
+ "Ret/DD", "Stability",
+ ]
+ rows_r = []
+ for i, r in enumerate(results):
+ member_names = " + ".join(_name(m) for m in r["members"])
+ fs = r.get("full_stats", {})
+ row = {"Rank": i + 1, "Strategies": member_names,
+ "# Strategies": len(r["members"])}
+ for col in col_order[3:]: # skip Rank, Strategies, # Strategies
+ row[col] = fs.get(col, 0)
+ rows_r.append(row)
+
+ res_df = pd.DataFrame(rows_r)
+ res_df = res_df[[c for c in col_order if c in res_df.columns]]
+
+ def _cc(val, low=0):
+ if not isinstance(val, (int,float)): return ""
+ return "color:#34C27A" if val > low else "color:#E05555" if val < low else ""
+
+ int_cols = {"Rank", "# Strategies", "# Trades", "Max Pos Exposure",
+ "Stagnation (days)"}
+ num_cols = res_df.select_dtypes(include="number").columns.tolist()
+ fmt = {c: ("{:.0f}" if c in int_cols else "{:.2f}") for c in num_cols}
+ fmt["Stability"] = "{:.4f}"
+
+ pos_cols = [c for c in ["Net Profit ($)", "Annual Profit ($)",
+ "Annual Profit (%)", "Avg Win ($)"] if c in res_df.columns]
+ neg_cols = [c for c in ["Max DD ($)", "Max DD (%)",
+ "Avg Loss ($)"] if c in res_df.columns]
+
+ styled = (
+ res_df.style
+ .format(fmt)
+ .map(_cc, subset=pos_cols if pos_cols else [])
+ .map(lambda v: "color:#E05555" if isinstance(v,(int,float)) and v < 0 else "",
+ subset=neg_cols if neg_cols else [])
+ .map(lambda v: _cc(v, 1.0),
+ subset=["Profit Factor"] if "Profit Factor" in res_df.columns else [])
+ )
+ st.dataframe(styled, use_container_width=True, hide_index=True)
+
+ # Export
+ buf = io.StringIO()
+ res_df.to_csv(buf, index=False)
+ st.download_button("⬇️ Export Results CSV", buf.getvalue(),
+ file_name="portfolio_master_results.csv", mime="text/csv")
+
+ # Expandable detail for top N portfolios
+ st.markdown("##### Portfolio Detail")
+ show_top = st.slider("Show detail for top N portfolios", 1, min(10, len(results)),
+ min(5, len(results)), key="pm_show_top")
+ for i, r in enumerate(results[:show_top]):
+ member_names = " + ".join(_name(m) for m in r["members"])
+ with st.expander(f"#{i+1} {member_names} "
+ f"| Ret/DD {r['ret_dd']:.2f} "
+ f"| Net ${r['net_profit']:,.2f} "
+ f"| DD ${r['max_dd']:,.2f}"):
+ # Mini equity chart
+ frames = [strategy_dfs[m].copy() for m in r["members"] if m in strategy_dfs]
+ if frames:
+ combined = pd.concat(frames, ignore_index=True)
+ if "close_time" in combined.columns:
+ combined = combined.sort_values("close_time").reset_index(drop=True)
+ eq = st.session_state.pm_deposit + combined["net_profit"].cumsum()
+ rm = eq.cummax(); dd_c = eq - rm
+ pfig = go.Figure()
+ pfig.add_trace(go.Scatter(
+ x=combined["close_time"], y=eq,
+ name="Equity", line=dict(color="#4C8EF5", width=2),
+ mode="lines",
+ ))
+ pfig.add_trace(go.Scatter(
+ x=combined["close_time"], y=dd_c,
+ name="DD", fill="tozeroy",
+ fillcolor="rgba(220,50,50,0.25)",
+ line=dict(color="rgba(220,50,50,0.6)", width=1),
+ mode="lines", yaxis="y2",
+ ))
+ pfig.update_layout(
+ height=220,
+ margin=dict(l=40,r=40,t=10,b=10),
+ paper_bgcolor="rgba(0,0,0,0)",
+ plot_bgcolor="#0E1117",
+ hovermode="x unified",
+ legend=dict(orientation="h", y=1.1, font=dict(size=10)),
+ yaxis=dict(gridcolor="#1E2130", tickprefix="$"),
+ yaxis2=dict(overlaying="y", side="right",
+ gridcolor="#1E2130", tickprefix="$",
+ showgrid=False),
+ )
+ st.plotly_chart(pfig, use_container_width=True)
+
+ # Member stats
+ 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,
+ labels.index(m)+1,
+ st.session_state.pm_custom_names.get(m,""))
+ if s:
+ m_rows.append({
+ "Strategy": s.get("Strategy Name", m),
+ "Symbol": s.get("Symbol",""),
+ "Net Profit ($)": s.get("Net Profit ($)",0),
+ "Max DD ($)": s.get("Max DD ($)",0),
+ "Ret/DD": s.get("Ret/DD",0),
+ "% Wins": s.get("% Wins",0),
+ "Profit Factor": s.get("Profit Factor",0),
+ "Stability": s.get("Stability",0),
+ })
+ if m_rows:
+ mdf = pd.DataFrame(m_rows)
+ st.dataframe(
+ mdf.style.format({c:"{:.2f}" for c in mdf.select_dtypes("number").columns}),
+ use_container_width=True, hide_index=True,
+ )
+
+ # ═════════════════════════════════════════════════════════════════════════
+ # COMPARE IMPORT
+ # ═════════════════════════════════════════════════════════════════════════
+ with tab_compare:
+ st.markdown("##### Compare External Portfolio Export")
+ st.caption(
+ "Upload a CSV exported from another portfolio tool (e.g. Quant Analyzer) "
+ "alongside your own results export from the Results tab. "
+ "Portfolios are matched by their strategy members — mismatches are flagged."
+ )
+
+ cc1, cc2 = st.columns(2)
+ ext_file = cc1.file_uploader("External tool export (CSV)", type=None,
+ key="pm_ext_file",
+ help="e.g. Portfolios_13_04_2026.csv")
+ our_file = cc2.file_uploader("Our results export (CSV)", type=None,
+ key="pm_our_file",
+ help="Export from the Results tab above")
+
+ # ── Column mapping from external format → our format ─────────────────
+ # External: Strategy Name, Initial deposit, Symbol, # of trades,
+ # Net profit, Drawdown, Max DD %, Annual % Return,
+ # Annual % Return/Max DD %, Avg. Loss, Avg. Win, Win/Loss ratio,
+ # % Wins, Commission, Max Positions Exposure, Max Positions Exposure Date,
+ # % Stagnation, Profit factor, Ret/DD Ratio, R Expectancy, Sharpe Ratio, Stability
+ EXT_MAP = {
+ "# of trades": "# Trades",
+ "Net profit": "Net Profit ($)",
+ "Drawdown": "Max DD ($)",
+ "Max DD %": "Max DD (%)",
+ "Annual % Return": "Annual Profit (%)",
+ "Annual % Return/Max DD %": "Ret/DD",
+ "Avg. Loss": "Avg Loss ($)",
+ "Avg. Win": "Avg Win ($)",
+ "% Wins": "% Wins",
+ "Commission": "Commissions ($)",
+ "Max Positions Exposure": "Max Pos Exposure",
+ "Max Positions Exposure Date":"Max Pos Exposure Dt",
+ "% Stagnation": "Stagnation (%)",
+ "Profit factor": "Profit Factor",
+ "Ret/DD Ratio": "Ret/DD",
+ "Stability": "Stability",
+ }
+
+ COMPARE_COLS = [
+ "# Trades", "Net Profit ($)", "Max DD ($)", "Max DD (%)",
+ "Annual Profit (%)", "Avg Win ($)", "Avg Loss ($)", "% Wins",
+ "Commissions ($)", "Max Pos Exposure", "Stagnation (%)",
+ "Profit Factor", "Ret/DD", "Stability",
+ ]
+
+ def _parse_ext(f) -> pd.DataFrame:
+ raw = f.read().decode("utf-8-sig", errors="replace")
+ from io import StringIO
+ df = pd.read_csv(StringIO(raw))
+ df = df.rename(columns=EXT_MAP)
+ # Build a normalised member key from Symbol column
+ # Symbol looks like "audusd_a,chfjpy_a,Portfolio" — strip "Portfolio",
+ # strip broker suffix (.a/.b), sort alphabetically
+ def _key(sym):
+ parts = [s.strip().lower() for s in str(sym).split(",")
+ if s.strip().lower() not in ("portfolio","")]
+ parts = [p.split(".")[0] if "." in p else p for p in parts]
+ return " + ".join(sorted(parts))
+ df["_match_key"] = df["Symbol"].apply(_key)
+ # Normalise Max DD to negative (external stores as positive)
+ if "Max DD ($)" in df.columns:
+ df["Max DD ($)"] = -df["Max DD ($)"].abs()
+ if "Max DD (%)" in df.columns:
+ df["Max DD (%)"] = -df["Max DD (%)"].abs()
+ if "Avg Loss ($)" in df.columns:
+ df["Avg Loss ($)"] = -df["Avg Loss ($)"].abs()
+ if "Commissions ($)" in df.columns:
+ df["Commissions ($)"] = -df["Commissions ($)"].abs()
+ return df
+
+ def _parse_our(f) -> pd.DataFrame:
+ raw = f.read().decode("utf-8-sig", errors="replace")
+ from io import StringIO
+ df = pd.read_csv(StringIO(raw))
+ # Build match key from Strategies column "audusd_a + chfjpy_a"
+ def _key(strat):
+ parts = [s.strip().lower() for s in str(strat).split("+")]
+ parts = [p.split(".")[0] if "." in p else p for p in parts]
+ return " + ".join(sorted(parts))
+ df["_match_key"] = df["Strategies"].apply(_key)
+ return df
+
+ if ext_file and our_file:
+ try:
+ ext_df = _parse_ext(ext_file)
+ our_df = _parse_our(our_file)
+
+ # ── Match portfolios by member key ────────────────────────────
+ ext_keys = set(ext_df["_match_key"].tolist())
+ our_keys = set(our_df["_match_key"].tolist())
+ matched = ext_keys & our_keys
+ only_ext = ext_keys - our_keys
+ only_our = our_keys - ext_keys
+
+ st.markdown(f"**{len(matched)} matched** · "
+ f"{len(only_ext)} only in external · "
+ f"{len(only_our)} only in our results")
+
+ if only_ext:
+ with st.expander(f"⚠️ {len(only_ext)} portfolios only in external file"):
+ for k in sorted(only_ext):
+ st.markdown(f"- `{k}`")
+ if only_our:
+ with st.expander(f"⚠️ {len(only_our)} portfolios only in our results"):
+ for k in sorted(only_our):
+ st.markdown(f"- `{k}`")
+
+ if matched:
+ # ── Side-by-side diff table ───────────────────────────────
+ st.markdown("##### Side-by-Side Comparison (matched portfolios)")
+
+ show_cols = [c for c in COMPARE_COLS
+ if c in ext_df.columns and c in our_df.columns]
+
+ diff_rows = []
+ for key in sorted(matched):
+ ext_row = ext_df[ext_df["_match_key"] == key].iloc[0]
+ our_row = our_df[our_df["_match_key"] == key].iloc[0]
+
+ # External deposit (each portfolio has its own)
+ ext_dep = float(ext_row.get("Initial deposit", 10000))
+
+ row_base = {"Portfolio": key.replace(" + ", " + ")}
+ for col in show_cols:
+ e_val = ext_row.get(col, None)
+ o_val = our_row.get(col, None)
+ try:
+ e_f = float(e_val) if e_val is not None else None
+ o_f = float(o_val) if o_val is not None else None
+ except (ValueError, TypeError):
+ e_f = o_f = None
+
+ row_base[f"{col} [ext]"] = round(e_f, 2) if e_f is not None else ""
+ row_base[f"{col} [ours]"] = round(o_f, 2) if o_f is not None else ""
+
+ # Delta — only for numeric, skip date/text cols
+ if e_f is not None and o_f is not None:
+ row_base[f"{col} Δ"] = round(o_f - e_f, 2)
+ else:
+ row_base[f"{col} Δ"] = ""
+
+ diff_rows.append(row_base)
+
+ diff_df = pd.DataFrame(diff_rows)
+
+ # Toggle: show all columns or just deltas
+ view_mode = st.radio("Show", ["All columns", "Deltas only", "External only", "Ours only"],
+ horizontal=True, key="pm_cmp_mode")
+
+ if view_mode == "Deltas only":
+ keep = ["Portfolio"] + [c for c in diff_df.columns if c.endswith(" Δ")]
+ elif view_mode == "External only":
+ keep = ["Portfolio"] + [c for c in diff_df.columns if c.endswith("[ext]")]
+ elif view_mode == "Ours only":
+ keep = ["Portfolio"] + [c for c in diff_df.columns if c.endswith("[ours]")]
+ else:
+ keep = diff_df.columns.tolist()
+
+ disp = diff_df[keep].copy()
+
+ # Colour delta columns: green = improvement, red = worse
+ # "improvement" depends on metric direction
+ HIGHER_BETTER = {"Net Profit ($)", "Annual Profit (%)", "% Wins",
+ "Profit Factor", "Ret/DD", "Stability", "Avg Win ($)"}
+ LOWER_BETTER = {"Max DD ($)", "Max DD (%)", "Stagnation (%)",
+ "Commissions ($)", "Avg Loss ($)"}
+
+ def _delta_style(val, col_name):
+ if not isinstance(val, (int,float)) or val == 0:
+ return ""
+ metric = col_name.replace(" Δ","").strip()
+ if metric in HIGHER_BETTER:
+ return "color:#34C27A" if val > 0 else "color:#E05555"
+ if metric in LOWER_BETTER:
+ return "color:#34C27A" if val < 0 else "color:#E05555"
+ return ""
+
+ num_c = disp.select_dtypes(include="number").columns.tolist()
+ fmt_d = {c: "{:.2f}" for c in num_c}
+
+ styler = disp.style.format(fmt_d, na_rep="—")
+ for col in [c for c in disp.columns if c.endswith(" Δ")]:
+ styler = styler.map(lambda v, c=col: _delta_style(v, c), subset=[col])
+
+ st.dataframe(styler, use_container_width=True, hide_index=True)
+
+ # ── Summary metrics ───────────────────────────────────────
+ st.markdown("##### Average Deltas (Ours − External)")
+ delta_cols = [c for c in diff_df.columns if c.endswith(" Δ")]
+ if delta_cols:
+ delta_means = {}
+ for col in delta_cols:
+ vals = pd.to_numeric(diff_df[col], errors="coerce").dropna()
+ if not vals.empty:
+ delta_means[col.replace(" Δ","")] = round(vals.mean(), 3)
+
+ dm_cols = st.columns(min(len(delta_means), 5))
+ for i, (metric, val) in enumerate(delta_means.items()):
+ col_idx = i % len(dm_cols)
+ m = metric
+ if m in HIGHER_BETTER:
+ delta_str = f"+{val:.3f}" if val >= 0 else f"{val:.3f}"
+ color = "#34C27A" if val >= 0 else "#E05555"
+ elif m in LOWER_BETTER:
+ delta_str = f"{val:.3f}"
+ color = "#34C27A" if val <= 0 else "#E05555"
+ else:
+ delta_str = f"{val:+.3f}"
+ color = "#CDD6F4"
+ dm_cols[col_idx].markdown(
+ f""
+ f"
{m}
"
+ f"
"
+ f"{delta_str}
",
+ unsafe_allow_html=True
+ )
+
+ # Export comparison
+ buf = io.StringIO()
+ diff_df.to_csv(buf, index=False)
+ st.download_button("⬇️ Export Comparison CSV", buf.getvalue(),
+ file_name="portfolio_comparison.csv", mime="text/csv")
+
+ except Exception as e:
+ st.error(f"Error processing files: {e}")
+ import traceback
+ st.code(traceback.format_exc())
+
+ else:
+ st.info("Upload both files above to run the comparison.")
\ No newline at end of file