Files
mt5-tools/view_portfolio_builder.py

1618 lines
83 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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, file_obj.name)
return result[0] if isinstance(result, tuple) else result
except Exception as e:
st.error(f"Failed to parse **{file_obj.name}**: {e}")
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
df["_ea"] = label # EA = the uploaded filename stem
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
# ─────────────────────────────────────────────────────────────────────────────
# Portfolio comparison — correlation across bucketed P&L series
# ─────────────────────────────────────────────────────────────────────────────
def _bucket_pnl(df: pd.DataFrame, mode: str, time_col: str = "close_time") -> pd.Series:
"""
Aggregate net_profit into a time-bucketed series for correlation analysis.
mode:
- "daily" — sum P&L per calendar day
- "weekly" — sum P&L per ISO week
- "monthly" — sum P&L per calendar month
- "close_hour" — sum P&L per hour bucket of close_time (1-hour windows)
- "open_hour" — sum P&L per hour bucket of open_time
- "trade" — one row per trade keyed by close_time (no aggregation)
Returns a Series indexed by the bucket key with summed net_profit.
"""
if df.empty or "net_profit" not in df.columns:
return pd.Series(dtype=float)
if mode == "open_hour":
time_col = "open_time"
elif mode == "close_hour":
time_col = "close_time"
if time_col not in df.columns:
return pd.Series(dtype=float)
times = pd.to_datetime(df[time_col], errors="coerce")
profits = pd.to_numeric(df["net_profit"], errors="coerce").fillna(0)
valid = times.notna()
times, profits = times[valid], profits[valid]
if times.empty:
return pd.Series(dtype=float)
if mode == "daily":
key = times.dt.normalize()
elif mode == "weekly":
key = times.dt.to_period("W").apply(lambda p: p.start_time)
elif mode == "monthly":
key = times.dt.to_period("M").apply(lambda p: p.start_time)
elif mode in ("close_hour", "open_hour"):
# Truncate to the hour — trades closing/opening within the same 1-hour
# window get bucketed together.
key = times.dt.floor("h")
elif mode == "trade":
# Each trade is its own row, indexed by exact close time
return pd.Series(profits.values, index=times.values).sort_index()
else:
key = times.dt.normalize()
return profits.groupby(key).sum().sort_index()
def _portfolio_pnl_series(portfolio_name: str, portfolios: dict, eff_dfs: dict,
mode: str) -> pd.Series:
"""Build a bucketed P&L series for a named portfolio (or 'Portfolio (all)')."""
if portfolio_name == "Portfolio (all)":
members = list(eff_dfs.keys())
else:
members = portfolios.get(portfolio_name, [])
member_dfs = [eff_dfs[m] for m in members if m in eff_dfs]
if not member_dfs:
return pd.Series(dtype=float)
combined = pd.concat(member_dfs, ignore_index=True)
return _bucket_pnl(combined, mode)
def _correlation_matrix(series_dict: dict, method: str = "pearson") -> pd.DataFrame:
"""
Compute correlation matrix across portfolio P&L series.
Series are aligned on the union of bucket keys; missing buckets fill 0
(i.e. a portfolio that didn't trade in that bucket contributed $0 P&L).
"""
if not series_dict:
return pd.DataFrame()
aligned = pd.concat(series_dict, axis=1).fillna(0)
if aligned.shape[1] < 2:
return pd.DataFrame()
return aligned.corr(method=method)
# ─────────────────────────────────────────────────────────────────────────────
# 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"<b>{name}</b><br>%{{x|%d %b %Y}}<br>${{y:,.2f}}<extra></extra>",
), 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:
import os as _os, re as _re2
_cfg = _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), ".streamlit", "config.toml")
_light = False
if _os.path.isfile(_cfg):
_m = _re2.search(r'base\s*=\s*"([^"]*)"', open(_cfg).read())
if _m: _light = _m.group(1) == "light"
_portfolio_color = "#1a3a5c" if _light else "#FFFFFF"
_plot_series(df["close_time"], df["net_profit"], f"{active_label} (combined)",
_portfolio_color, 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}<extra></extra>",
), 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}<extra></extra>",
), 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}<extra></extra>",
), 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="rgba(0,0,0,0)",
legend=dict(orientation="h", yanchor="bottom", y=1.02,
xanchor="left", x=0, font=dict(size=11)),
hovermode="x unified", bargap=0, barmode="overlay",
hoverlabel=dict(namelength=-1, font=dict(size=11)),
)
# 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="rgba(128,128,128,0.15)", 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="rgba(128,128,128,0.15)", 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"<td class='yc'>{year}</td>"]
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"<td class='{cls}'>{txt}</td>")
rows.append("<tr>" + "".join(cells) + "</tr>")
hdr = "<tr><th>Year</th>" + "".join(f"<th>{c}</th>" for c in cols) + "</tr>"
return (
"<style>.mt{width:100%;border-collapse:collapse;font-size:12px;"
"font-family:'Courier New',monospace}"
".mt .p{color:#34C27A}.mt .n{color:#E05555}</style>"
f"<table class='mt'><thead>{hdr}</thead><tbody>{''.join(rows)}</tbody></table>"
)
# ─────────────────────────────────────────────────────────────────────────────
# 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,
"pb_n_slots": 5, # number of file upload slots shown
}.items():
if k not in st.session_state:
st.session_state[k] = v
# ─────────────────────────────────────────────────────────────────────────────
# Render
# ─────────────────────────────────────────────────────────────────────────────
def render():
_init_state()
# Card/stat styling injected globally by inject_theme_css() in app.py
# Also expand multiselect tags so full strategy names are visible
st.markdown("""<style>
span[data-baseweb="tag"] { max-width: none !important; }
span[data-baseweb="tag"] span { max-width: none !important; overflow: visible !important;
white-space: normal !important; text-overflow: unset !important; }
</style>""", unsafe_allow_html=True)
st.markdown('<p class="pb-title">📊 Portfolio Builder</p>', unsafe_allow_html=True)
st.markdown('<p class="pb-sub">Combine MT5 backtest reports into multi-strategy portfolios</p>',
unsafe_allow_html=True)
# ── Upload panel ─────────────────────────────────────────────────────────
with st.expander("📂 Load Strategy Reports",
expanded=not bool(st.session_state.pb_uploaded_files)):
load_mode = st.radio(
"Load method",
["📁 Upload files", "🗂 Scan folder"],
horizontal=True, key="pb_load_mode",
)
if load_mode == "📁 Upload files":
st.caption("Select one or more `.htm` · `.html` · `.csv` files — hold Ctrl/Cmd to pick multiple.")
uploaded_files = st.file_uploader(
"Strategy reports",
type=["htm", "html", "csv"],
accept_multiple_files=True,
key="pb_multi_uploader",
label_visibility="collapsed",
)
if uploaded_files:
added = 0
for f in uploaded_files:
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
added += 1
if added:
st.success(f"✅ Loaded {added} file(s)")
st.rerun()
else: # Scan folder
st.caption("Enter a folder path — all `.htm` / `.html` / `.csv` files inside will be loaded.")
fp_col, sub_col = st.columns([4, 1])
folder_path = fp_col.text_input(
"Folder path",
placeholder=r"e.g. C:\MT5\Reports\Backtest",
key="pb_folder_path",
label_visibility="collapsed",
)
include_sub = sub_col.checkbox("Subfolders", value=False, key="pb_folder_sub")
if st.button("📂 Scan & Load", key="pb_scan_btn", type="primary"):
if not folder_path or not os.path.isdir(folder_path):
st.error("Folder not found — check the path and try again.")
else:
import glob as _glob
exts = ("*.htm", "*.html", "*.csv")
found = []
for ext in exts:
pattern = os.path.join(folder_path, "**", ext) if include_sub \
else os.path.join(folder_path, ext)
found.extend(_glob.glob(pattern, recursive=include_sub))
found = sorted(set(found))
if not found:
st.warning("No `.htm` / `.html` / `.csv` files found in that folder.")
else:
added = skipped = errors = 0
for fpath in found:
stem = os.path.splitext(os.path.basename(fpath))[0]
if stem in st.session_state.pb_uploaded_files:
skipped += 1
continue
try:
with open(fpath, "rb") as fh:
raw = fh.read()
parser = _get_parser()
result = parser.detect_and_parse(raw, os.path.basename(fpath))
df = result[0] if isinstance(result, tuple) else result
if df is not None:
df = _ensure_columns(df, stem)
st.session_state.pb_uploaded_files[stem] = df
added += 1
except Exception as e:
st.warning(f"Could not load **{stem}**: {e}")
errors += 1
parts = [f"✅ {added} loaded"]
if skipped: parts.append(f"{skipped} already present")
if errors: parts.append(f"{errors} failed")
st.success(" · ".join(parts))
if added:
st.rerun()
if st.session_state.pb_uploaded_files:
st.markdown("---")
st.markdown(f"**{len(st.session_state.pb_uploaded_files)} strategies loaded:**")
to_remove = []
for label in list(st.session_state.pb_uploaded_files):
c1, c2 = st.columns([6, 1])
c1.markdown(f"<span class='chip'>📈 {label}</span>",
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
skey = f"{key_prefix}_dslider"
# Use existing session state value if valid, otherwise default to full range
existing = st.session_state.get(skey)
if (existing and isinstance(existing, (list, tuple)) and len(existing) == 2
and existing[0] in _date_options and existing[1] in _date_options):
default_val = (existing[0], existing[1])
else:
default_val = (_gmin, _gmax)
sel = st.select_slider(
"Date range",
options=_date_options,
value=default_val,
format_func=lambda d: d.strftime("%d %b %Y"),
key=skey,
)
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:
# EA filter (file level)
ea_labels = sorted(df["_ea"].dropna().unique().tolist()) \
if "_ea" in df.columns else strat_labels
fc1, fc2, fc3 = st.columns(3)
sel_eas = fc1.multiselect(
"Filter EA",
ea_labels, default=ea_labels, key="pb_ov_ea",
help="Filter by uploaded file (EA)",
)
# Strategy filter — cascades from EA selection
if "_ea" in df.columns and sel_eas:
strat_labels_filtered = sorted(
df[df["_ea"].isin(sel_eas)]["strategy"].dropna().unique().tolist()
) if "strategy" in df.columns else strat_labels
else:
strat_labels_filtered = strat_labels
sel_strats_raw = fc2.multiselect(
"Filter Strategy",
strat_labels_filtered, default=strat_labels_filtered, key="pb_ov_strat",
help="Filter by strategy (comment) within selected EAs",
)
sel_syms = fc3.multiselect(
"Filter symbols",
sym_labels, default=sym_labels, key="pb_ov_sym",
)
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, tab_cmp = st.tabs([
"📋 Overview", "📜 Trades", "📈 Equity Chart",
"📊 Strategies", "🔧 What-If", "🗂 Portfolios", "🔗 Compare",
])
# ═════════════════════════════════════════════════════════════════════════
# 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'<div class="stat-card"><div class="stat-label">{label}</div>'
f'<div class="stat-value {cls}">{val}</div>'
f'<div class="stat-sub">{sub}</div></div>',
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("<br>", 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("<br>", unsafe_allow_html=True)
lc, rc = st.columns(2)
def kv(key, val, cls=""):
st.markdown(
f'<div class="kv-row"><span class="kk">{key}</span>'
f'<span class="kv-v {cls}">{val}</span></div>',
unsafe_allow_html=True)
with lc:
st.markdown('<div class="sh">Strategy</div>', 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('<div class="sh">Risk</div>', 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("<br>", unsafe_allow_html=True)
mt_col1, mt_col2 = st.columns([3, 1])
mt_col1.markdown('<div class="sh">Monthly Performance</div>',
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, fc5 = st.columns(5)
all_eas = sorted(df["_ea"].dropna().unique().tolist()) if "_ea" in df.columns else []
all_syms = sorted(df["symbol"].dropna().unique().tolist()) if "symbol" in df.columns else []
all_types = sorted(df["type"].dropna().unique().tolist()) if "type" in df.columns else []
filt_ea = fc1.multiselect("EA", all_eas, default=all_eas, key="pb_tea")
# Cascade strategies from EA filter
if filt_ea and "_ea" in df.columns:
avail_strats = sorted(df[df["_ea"].isin(filt_ea)]["strategy"].dropna().unique().tolist()) \
if "strategy" in df.columns else []
else:
avail_strats = sorted(df["strategy"].dropna().unique().tolist()) \
if "strategy" in df.columns else []
filt_strat = fc2.multiselect("Strategy", avail_strats, default=avail_strats, key="pb_tst")
filt_sym = fc3.multiselect("Symbol", all_syms, default=all_syms, key="pb_ts")
filt_type = fc4.multiselect("Direction", all_types, default=all_types, key="pb_tt")
result_f = fc5.selectbox("Result", ["All","Wins only","Losses only"], key="pb_tr")
view = df.copy()
if "close_time" in view.columns and tr_date_from and tr_date_to:
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_ea and "_ea" in view.columns: view = view[view["_ea"].isin(filt_ea)]
if filt_strat and "strategy" in view.columns: view = view[view["strategy"].isin(filt_strat)]
if filt_sym and "symbol" in view.columns: view = view[view["symbol"].isin(filt_sym)]
if filt_type and "type" in view.columns: view = view[view["type"].isin(filt_type)]
if result_f == "Wins only": view = view[view["net_profit"] > 0]
elif result_f == "Losses only": view = view[view["net_profit"] <= 0]
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:
st.caption("Stats reflect the filtered trade list below — does not affect Overview, Equity Chart or Strategies tabs.")
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 ""
import os, re as _re
_cfg = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".streamlit", "config.toml")
_light = False
if os.path.isfile(_cfg):
_m = _re.search(r'base\s*=\s*"([^"]*)"', open(_cfg).read())
if _m: _light = _m.group(1) == "light"
if _light:
if val > 0: return "background-color:rgba(52,194,122,0.15)"
if val < 0: return "background-color:rgba(220,50,50,0.12)"
else:
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
ctl1, ctl2, ctl3 = st.columns([3, 2, 2])
chart_view = ctl1.radio(
"Lines",
["Portfolio", "By EA", "By Strategy", "EA + Strategy"],
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")
# EA filter + cascading strategy filter
all_eas_eq = sorted(df["_ea"].dropna().unique().tolist()) if "_ea" in df.columns else list(eff_dfs.keys())
sel_eas_eq = st.multiselect("Filter EA", all_eas_eq, default=all_eas_eq, key="pb_eq_ea")
if chart_view in ("By Strategy", "EA + Strategy"):
if sel_eas_eq and "_ea" in df.columns and "strategy" in df.columns:
avail_strats_eq = sorted(df[df["_ea"].isin(sel_eas_eq)]["strategy"].dropna().unique().tolist())
else:
avail_strats_eq = sorted(df["strategy"].dropna().unique().tolist()) if "strategy" in df.columns else []
sel_strats_eq = st.multiselect("Filter Strategy", avail_strats_eq, default=avail_strats_eq, key="pb_eq_strat")
else:
sel_strats_eq = []
# Build per-EA and per-strategy dfs for charting
# Per-EA: combine all trades for that EA filename
ea_dfs = {}
for ea in all_eas_eq:
if ea not in sel_eas_eq:
continue
ea_trades = df[df["_ea"] == ea] if "_ea" in df.columns else pd.DataFrame()
if not ea_trades.empty:
ea_trades = ea_trades.sort_values("close_time").reset_index(drop=True)
ea_dfs[ea] = ea_trades
# Per-strategy: combine all trades sharing the same strategy comment
strat_dfs = {}
if "strategy" in df.columns:
for strat in (sel_strats_eq if sel_strats_eq else df["strategy"].dropna().unique()):
mask = df["strategy"] == strat
if sel_eas_eq and "_ea" in df.columns:
mask &= df["_ea"].isin(sel_eas_eq)
s_trades = df[mask]
if not s_trades.empty:
s_trades = s_trades.sort_values("close_time").reset_index(drop=True)
strat_dfs[strat] = s_trades
# Determine what to pass to the chart builder
if chart_view == "By EA":
chart_eff_dfs = ea_dfs
sel_strats = list(ea_dfs.keys())
chart_cv = "Individual"
elif chart_view == "By Strategy":
chart_eff_dfs = strat_dfs
sel_strats = list(strat_dfs.keys())
chart_cv = "Individual"
elif chart_view == "EA + Strategy":
chart_eff_dfs = {**ea_dfs, **strat_dfs}
sel_strats = list(ea_dfs.keys()) + list(strat_dfs.keys())
chart_cv = "Portfolio+Individual"
else: # Portfolio
chart_eff_dfs = eff_dfs
sel_strats = list(eff_dfs.keys())
chart_cv = "Portfolio"
# Combined df for portfolio line
chart_df = df
if chart_view == "EA + Strategy" and ea_dfs:
chart_df = _combine(ea_dfs, deposit)
cv_map = {
"Portfolio": "Portfolio",
"By EA": "Individual",
"By Strategy": "Individual",
"EA + Strategy":"Portfolio+Individual",
}
fig = _build_equity_chart(
chart_df, deposit, chart_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")
# Apply same date range as Overview
eff_dfs_filtered = {}
for _lbl, _sdf in eff_dfs.items():
if "close_time" in _sdf.columns and ov_date_from and ov_date_to:
_ct = pd.to_datetime(_sdf["close_time"]).dt.tz_localize(None)
eff_dfs_filtered[_lbl] = _sdf[
(_ct >= pd.Timestamp(ov_date_from)) &
(_ct <= pd.Timestamp(ov_date_to) + pd.Timedelta(days=1))
]
else:
eff_dfs_filtered[_lbl] = _sdf
tdf = _strategy_table(eff_dfs_filtered, 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)
# Controls row
st.markdown("##### Equity Curves")
ctl1, ctl2, ctl3, ctl4 = st.columns([2, 2, 2, 2])
sc_smooth = ctl1.slider("Curve smoothing", 1, 50, 1, key="pb_st_smooth",
help="Rolling-average window (trades).")
st_curve_grp = ctl2.radio("Group by", ["EA", "Strategy"], horizontal=True, key="pb_st_grp",
help="EA = one line per file · Strategy = one line per comment")
show_st_stag = ctl3.toggle("Show stagnation bands", value=False,
key="pb_st_show_stag",
help="Highlight max stagnation period per strategy in matching colour")
# Build the series to plot
if st_curve_grp == "Strategy" and "strategy" in df.columns:
# One series per unique strategy comment across all loaded files
_st_series = {}
for _strat in sorted(df["strategy"].dropna().unique()):
_s_df = df[df["strategy"] == _strat].copy()
if ov_date_from and ov_date_to and "close_time" in _s_df.columns:
_ct = pd.to_datetime(_s_df["close_time"]).dt.tz_localize(None)
_s_df = _s_df[(_ct >= pd.Timestamp(ov_date_from)) &
(_ct <= pd.Timestamp(ov_date_to) + pd.Timedelta(days=1))]
if not _s_df.empty:
_st_series[_strat] = _s_df.sort_values("close_time").reset_index(drop=True)
else:
_st_series = eff_dfs_filtered # one series per uploaded file
sf = go.Figure()
sf.update_layout(
height=500,
margin=dict(l=40, r=20, t=40, b=10),
paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)",
legend=dict(orientation="h", y=1.08, font=dict(size=10)),
hovermode="x unified",
hoverlabel=dict(namelength=-1, font=dict(size=12)),
)
sf.update_xaxes(gridcolor="rgba(128,128,128,0.15)", zeroline=False)
sf.update_yaxes(gridcolor="rgba(128,128,128,0.15)", zeroline=False, tickprefix="$")
for i, (lbl, sdf) in enumerate(_st_series.items()):
if "close_time" not in sdf.columns or "net_profit" not in sdf.columns:
continue
color = COLORS[i % len(COLORS)]
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=color, width=1.5),
hovertemplate=f"<b>{lbl}</b><br>%{{x|%d %b %Y}}: $%{{y:,.2f}}<extra></extra>",
))
# Stagnation band per strategy in matching colour
if show_st_stag:
eq_ts = sdf_s[["close_time","net_profit"]].dropna().copy()
eq_ts["cum"] = deposit + eq_ts["net_profit"].cumsum()
if not eq_ts.empty:
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:
# Convert hex to rgba with low opacity
hex_c = color.lstrip("#")
if len(hex_c) == 6:
r_c = int(hex_c[0:2], 16)
g_c = int(hex_c[2:4], 16)
b_c = int(hex_c[4:6], 16)
fill_color = f"rgba({r_c},{g_c},{b_c},0.12)"
ann_color = color
else:
fill_color = "rgba(255,160,80,0.12)"
ann_color = color
sf.add_vrect(
x0=best_s, x1=best_e,
fillcolor=fill_color, line_width=1,
line_color=f"rgba({r_c},{g_c},{b_c},0.3)" if len(hex_c)==6 else color,
annotation_text=f"{lbl.split()[0]}{max_days}d",
annotation_position="top left",
annotation_font_size=9,
annotation_font_color=ann_color,
)
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"<small style='color:#6C7A8D'>Avg lot<br>"
f"<b style='color:#CDD6F4'>{orig_avg_lot:.4f}</b></small>",
unsafe_allow_html=True)
wi_c3.markdown(
f"<small style='color:#6C7A8D'>Current ×<br>"
f"<b style='color:#CDD6F4'>{current_mult:.4f}</b></small>",
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}")
# ═════════════════════════════════════════════════════════════════════════
# COMPARE — correlation between custom portfolios
# ═════════════════════════════════════════════════════════════════════════
with tab_cmp:
st.markdown("##### Portfolio Correlation Comparison")
st.caption(
"Compare how the constructed portfolios move relative to each other. "
"Choose a bucketing mode — daily P&L correlation tells you if portfolios "
"are diversified day-to-day; hourly close/open windows reveal whether they "
"tend to enter or exit positions together."
)
# Build the list of comparable portfolios — custom ones plus the 'all' aggregate
all_options = []
if eff_dfs:
all_options.append("Portfolio (all)")
all_options.extend(list(portfolios.keys()))
if len(all_options) < 2:
st.info("Create at least 2 custom portfolios in the **Portfolios** tab "
"to enable comparison. (Or load strategies + create one portfolio — "
"you can then compare it against the full aggregate.)")
else:
cmp_c1, cmp_c2, cmp_c3 = st.columns([3, 2, 2])
with cmp_c1:
selected_pfs = st.multiselect(
"Portfolios to compare",
all_options,
default=all_options[:min(4, len(all_options))],
key="pb_cmp_pfs",
)
with cmp_c2:
bucket_mode = st.selectbox(
"Correlation bucket",
[
("daily", "Daily P&L"),
("close_hour", "Close within 1hr"),
("open_hour", "Open within 1hr"),
("weekly", "Weekly P&L"),
("monthly", "Monthly P&L"),
],
format_func=lambda x: x[1],
key="pb_cmp_bucket",
)
bucket_mode = bucket_mode[0]
with cmp_c3:
corr_method = st.selectbox(
"Method",
["pearson", "spearman", "kendall"],
key="pb_cmp_method",
help="Pearson = linear, Spearman = rank-based (robust to outliers), "
"Kendall = ordinal concordance",
)
if len(selected_pfs) < 2:
st.warning("Select at least 2 portfolios to compute correlation.")
else:
# Build bucketed P&L series for each selected portfolio
series_dict = {}
empty_pfs = []
for pf in selected_pfs:
s = _portfolio_pnl_series(pf, portfolios, eff_dfs, bucket_mode)
if s.empty:
empty_pfs.append(pf)
else:
series_dict[pf] = s
if empty_pfs:
st.warning(
f"No data for: {', '.join(empty_pfs)} (no trades or missing time column)."
)
if len(series_dict) < 2:
st.error("Need at least 2 non-empty portfolios for correlation.")
else:
corr = _correlation_matrix(series_dict, method=corr_method)
# ── Correlation matrix heatmap ────────────────────────
st.markdown("**Correlation Matrix**")
# Custom diverging colorscale: red (high corr = bad for diversification)
# through neutral grey at 0, to green (negative corr = great diversifier)
fig_corr = go.Figure(data=go.Heatmap(
z=corr.values,
x=list(corr.columns),
y=list(corr.index),
colorscale=[
[0.0, "#2E7D32"], # -1 dark green (great diversifier)
[0.25, "#7ED321"], # -0.5 green
[0.5, "#3A4255"], # 0 neutral grey-blue
[0.75, "#F5A623"], # 0.5 amber
[1.0, "#E63946"], # 1 red (highly correlated)
],
zmin=-1, zmax=1,
text=[[f"{v:.2f}" for v in row] for row in corr.values],
texttemplate="%{text}",
textfont={"size": 13, "color": "white"},
hovertemplate="<b>%{y}</b> vs <b>%{x}</b><br>r = %{z:.3f}<extra></extra>",
colorbar=dict(title="r", thickness=15),
))
fig_corr.update_layout(
height=max(320, 80 + 50 * len(corr)),
margin=dict(l=10, r=10, t=10, b=10),
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font=dict(color="#CDD6F4"),
xaxis=dict(side="bottom", tickangle=-25),
yaxis=dict(autorange="reversed"),
)
st.plotly_chart(fig_corr, use_container_width=True)
# ── Pair summary table ────────────────────────────────
st.markdown("**Pair-wise summary**")
pairs = []
cols = list(corr.columns)
for i in range(len(cols)):
for j in range(i + 1, len(cols)):
r = corr.iloc[i, j]
if r >= 0.7: rating = "🔴 Strongly correlated"
elif r >= 0.3: rating = "🟠 Moderately correlated"
elif r >= -0.3: rating = "⚪ Weakly / uncorrelated"
elif r >= -0.7: rating = "🟢 Moderate diversifier"
else: rating = "🟢🟢 Strong diversifier"
# Count overlapping buckets for context
sa, sb = series_dict[cols[i]], series_dict[cols[j]]
overlap = len(sa.index.intersection(sb.index))
pairs.append({
"Portfolio A": cols[i],
"Portfolio B": cols[j],
"Correlation": round(float(r), 3),
"Overlap buckets": overlap,
"Assessment": rating,
})
pair_df = pd.DataFrame(pairs).sort_values(
"Correlation", ascending=False, key=lambda s: s.abs()
)
st.dataframe(pair_df, use_container_width=True, hide_index=True)
# ── Combined equity overlay ───────────────────────────
st.markdown("**Equity Curves Overlay**")
eq_fig = go.Figure()
for idx, pf in enumerate(series_dict.keys()):
s = series_dict[pf].sort_index()
equity = deposit + s.cumsum()
eq_fig.add_trace(go.Scatter(
x=equity.index, y=equity.values, name=pf,
mode="lines",
line=dict(color=COLORS[idx % len(COLORS)], width=2),
hovertemplate=f"<b>{pf}</b><br>%{{x|%d %b %Y}}<br>"
f"$%{{y:,.2f}}<extra></extra>",
))
eq_fig.update_layout(
height=420,
margin=dict(l=10, r=10, t=10, b=10),
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font=dict(color="#CDD6F4"),
xaxis=dict(gridcolor="rgba(255,255,255,0.05)"),
yaxis=dict(gridcolor="rgba(255,255,255,0.05)",
title="Equity ($)"),
legend=dict(orientation="h", y=1.08, x=0),
hovermode="x unified",
)
st.plotly_chart(eq_fig, use_container_width=True)
# ── Rolling correlation between top pair ──────────────
if len(series_dict) >= 2:
with st.expander("📈 Rolling correlation (top pair)"):
# Pick the highest |r| pair for the rolling chart
top = max(pairs, key=lambda p: abs(p["Correlation"]))
a, b = top["Portfolio A"], top["Portfolio B"]
sa = series_dict[a]
sb = series_dict[b]
aligned = pd.concat({a: sa, b: sb}, axis=1).fillna(0)
# Window depends on bucket mode
window_default = {
"daily": 30, "close_hour": 168, "open_hour": 168,
"weekly": 8, "monthly": 6,
}.get(bucket_mode, 30)
window = st.slider(
"Rolling window (buckets)",
min_value=5,
max_value=max(20, min(252, len(aligned))),
value=min(window_default, max(5, len(aligned) // 4)),
key="pb_cmp_roll_win",
)
if len(aligned) > window:
roll = aligned[a].rolling(window).corr(aligned[b])
rfig = go.Figure()
rfig.add_trace(go.Scatter(
x=roll.index, y=roll.values,
mode="lines",
line=dict(color="#4C8EF5", width=2),
name=f"{a} vs {b}",
))
rfig.add_hline(y=0, line_dash="dash",
line_color="rgba(255,255,255,0.3)")
rfig.add_hline(y=0.7, line_dash="dot",
line_color="rgba(230,57,70,0.4)")
rfig.add_hline(y=-0.7, line_dash="dot",
line_color="rgba(46,125,50,0.4)")
rfig.update_layout(
height=320,
margin=dict(l=10, r=10, t=30, b=10),
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font=dict(color="#CDD6F4"),
title=f"{a} vs {b} — rolling {window}-bucket correlation",
yaxis=dict(range=[-1, 1],
gridcolor="rgba(255,255,255,0.05)"),
xaxis=dict(gridcolor="rgba(255,255,255,0.05)"),
)
st.plotly_chart(rfig, use_container_width=True)
else:
st.info(f"Need at least {window+1} buckets for rolling "
f"correlation — only {len(aligned)} available.")