"""
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}}
📊 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'