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"{hdr}{''.join(rows)}
" + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# 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