"""
view_portfolio_master.py — Portfolio Master
Automated portfolio construction with:
- Composite weighted scoring (Ret/DD, Stability, Stagnation, Win Rate, Growth Quality)
- Three search modes: Exhaustive | Greedy | Monte Carlo
- Combination count estimate + runtime warning before run
- Diversity bonus for multi-symbol / multi-session portfolios
- Average portfolio correlation output metric
- Conditional correlation (drawdown periods only)
- Equity curve growth quality = slope × stability
- Per-result correlation heatmap in detail expander
"""
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, random, time
from datetime import timedelta
# ─────────────────────────────────────────────────────────────────────────────
# Parser
# ─────────────────────────────────────────────────────────────────────────────
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:
df = df.copy() # ensure we never mutate the original
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
# ─────────────────────────────────────────────────────────────────────────────
# Full per-strategy statistics
# ─────────────────────────────────────────────────────────────────────────────
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
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
eq = deposit + profits.cumsum()
rm = eq.cummax()
dd = eq - rm
s["Max DD ($)"] = round(float(dd.min()), 2)
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
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 ($)"] = s["Annual Profit (%)"] = 0.0
else:
s["Annual Profit ($)"] = s["Annual Profit (%)"] = 0.0
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²) and Growth Quality (slope × R²)
if len(eq) > 2:
x = np.arange(len(eq))
slope, intercept, r, p, se = scipy_stats.linregress(x, eq.values)
r2 = float(r ** 2)
s["Stability"] = int(round(r2 * 100)) # 0-100
# Normalise slope to per-trade return as % of deposit, then multiply by R²
norm_slope = float(slope) / deposit * 100
s["Growth Quality"] = int(round(norm_slope * r2 * 10000)) # whole number
else:
s["Stability"] = 0; s["Growth Quality"] = 0
return s
# ─────────────────────────────────────────────────────────────────────────────
# Daily P&L and correlation helpers
# ─────────────────────────────────────────────────────────────────────────────
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 = {lbl: _daily_pnl(df) for lbl, df in dfs.items()}
aligned = pd.DataFrame(series).fillna(0)
return aligned.corr()
def _conditional_correlation(dfs: dict, deposit: float) -> pd.DataFrame:
"""Correlation computed only on days where the combined portfolio is in drawdown."""
series = {lbl: _daily_pnl(df) for lbl, df in dfs.items()}
aligned = pd.DataFrame(series).fillna(0)
combined_daily = aligned.sum(axis=1)
cum = deposit + combined_daily.cumsum()
in_dd = cum < cum.cummax()
dd_days = aligned[in_dd]
if len(dd_days) < 5:
return aligned.corr() # fallback if not enough drawdown days
return dd_days.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
def _avg_correlation(members: list, corr_matrix: pd.DataFrame) -> float:
"""Average pairwise correlation across all member pairs."""
pairs = list(itertools.combinations(members, 2))
if not pairs:
return 0.0
vals = []
for a, b in pairs:
if a in corr_matrix.index and b in corr_matrix.columns:
vals.append(abs(corr_matrix.loc[a, b]))
return round(float(np.mean(vals)), 4) if vals else 0.0
# ─────────────────────────────────────────────────────────────────────────────
# Diversity bonus
# ─────────────────────────────────────────────────────────────────────────────
def _diversity_bonus(members: list, dfs: dict) -> float:
"""
Returns a bonus score 0.0–1.0 based on:
- Symbol diversity (unique symbols / n_members)
- Session diversity (strategies trading at different hours)
"""
if len(members) < 2:
return 0.0
symbols = []
hour_sets = []
for m in members:
df = dfs.get(m)
if df is None: continue
# Symbol
if "symbol" in df.columns:
sym = str(df["symbol"].iloc[0]).split(".")[0].upper()
symbols.append(sym)
# Trading hours — get modal hour of closes
if "close_time" in df.columns:
hrs = pd.to_datetime(df["close_time"], errors="coerce").dt.hour.dropna()
if not hrs.empty:
hour_sets.append(set(hrs.value_counts().head(6).index.tolist()))
sym_score = len(set(symbols)) / len(members) if symbols else 0.0
session_score = 0.0
if len(hour_sets) >= 2:
overlaps = []
for h1, h2 in itertools.combinations(hour_sets, 2):
if h1 | h2:
overlaps.append(len(h1 & h2) / len(h1 | h2))
session_score = 1.0 - (sum(overlaps) / len(overlaps)) if overlaps else 0.0
return int(round((sym_score * 0.6 + session_score * 0.4) * 100)) # 0-100
# ─────────────────────────────────────────────────────────────────────────────
# Composite scoring
# ─────────────────────────────────────────────────────────────────────────────
def _composite_score(full: dict, weights: dict, diversity: float, deposit: float) -> float:
"""
Weighted composite score. Each metric is normalised before weighting.
weights keys: ret_dd, stability, stagnation, win_rate, growth_quality, diversity
"""
def _norm(val, low, high):
if high == low: return 0.5
return max(0.0, min(1.0, (val - low) / (high - low)))
ret_dd = full.get("Ret/DD", 0.0)
stab = full.get("Stability", 0.0)
stag = full.get("Stagnation (%)", 100.0)
wr = full.get("% Wins", 0.0)
gq = full.get("Growth Quality", 0.0)
# Normalise each component (rough reasonable ranges)
n_ret_dd = _norm(ret_dd, 0, 10)
n_stab = _norm(stab, 0, 1)
n_stag = _norm(100-stag, 0, 100) # inverted: lower stagnation = higher score
n_wr = _norm(wr, 40, 90)
n_gq = _norm(gq, 0, 0.05)
n_div = _norm(diversity, 0, 1)
score = (
weights.get("ret_dd", 0.35) * n_ret_dd +
weights.get("stability", 0.25) * n_stab +
weights.get("stagnation", 0.20) * n_stag +
weights.get("win_rate", 0.10) * n_wr +
weights.get("growth_quality",0.05)* n_gq +
weights.get("diversity", 0.05) * n_div
)
return int(round(float(score) * 1000))
# ─────────────────────────────────────────────────────────────────────────────
# Portfolio evaluation (single combo)
# ─────────────────────────────────────────────────────────────────────────────
def _evaluate_combo(members: list, dfs: dict, deposit: float,
weights: dict, corr_matrix: pd.DataFrame,
cond_corr_matrix: pd.DataFrame) -> dict:
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)
combined["_strategy"] = " + ".join(members)
full = _full_stats(combined, deposit, 0, " + ".join(members))
diversity = _diversity_bonus(members, dfs)
score = _composite_score(full, weights, diversity, deposit)
avg_corr = _avg_correlation(members, corr_matrix)
avg_cond = _avg_correlation(members, cond_corr_matrix)
return {
"members": members,
"score": score,
"net_profit": 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),
"stability": full.get("Stability", 0.0),
"growth_quality":full.get("Growth Quality", 0.0),
"diversity": diversity,
"avg_corr": avg_corr,
"avg_cond_corr": avg_cond,
"full_stats": full,
}
# ─────────────────────────────────────────────────────────────────────────────
# Search modes
# ─────────────────────────────────────────────────────────────────────────────
def _search_exhaustive(labels, dfs, deposit, weights, min_s, max_s,
use_corr, corr_limit, corr_matrix, cond_corr_matrix,
max_results, prog_cb, cancelled=None):
results = []
total = sum(
sum(1 for _ in itertools.combinations(labels, r))
for r in range(min_s, max_s + 1)
)
done = 0
for size in range(min_s, max_s + 1):
for combo in itertools.combinations(labels, size):
combo = list(combo)
done += 1
if done % 100 == 0:
prog_cb(done, total, f"Exhaustive: {done:,} / {total:,}")
if use_corr and corr_matrix is not None:
if _portfolio_exceeds_corr(combo, corr_matrix, corr_limit):
continue
if cancelled and cancelled(): break
r = _evaluate_combo(combo, dfs, deposit, weights, corr_matrix, cond_corr_matrix)
if r: results.append(r)
if cancelled and cancelled(): break
prog_cb(total, total, "Cancelled." if (cancelled and cancelled()) else "Done.")
results.sort(key=lambda x: x["score"], reverse=True)
return results[:max_results]
def _search_greedy(labels, dfs, deposit, weights, min_s, max_s,
use_corr, corr_limit, corr_matrix, cond_corr_matrix,
max_results, prog_cb, cancelled=None):
"""
Greedy incremental build: start with best single strategy,
repeatedly add the strategy that most improves the composite score.
Runs once per starting strategy to explore diverse starting points.
"""
results = []
n = len(labels)
total_starts = n
for start_idx, seed in enumerate(labels):
prog_cb(start_idx, total_starts, f"Greedy: seed {start_idx+1}/{total_starts}")
current = [seed]
# Grow until max_s
while len(current) < max_s:
best_score = -999
best_add = None
for candidate in labels:
if candidate in current: continue
trial = current + [candidate]
if use_corr and corr_matrix is not None:
if _portfolio_exceeds_corr(trial, corr_matrix, corr_limit):
continue
r = _evaluate_combo(trial, dfs, deposit, weights, corr_matrix, cond_corr_matrix)
if r and r["score"] > best_score:
best_score = r["score"]
best_add = candidate
if best_add is None: break
current.append(best_add)
# Record each size if >= min_s
if len(current) >= min_s:
r = _evaluate_combo(current[:], dfs, deposit, weights, corr_matrix, cond_corr_matrix)
if r: results.append(r)
if cancelled and cancelled(): break
prog_cb(total_starts, total_starts, "Cancelled." if (cancelled and cancelled()) else "Done.")
# Deduplicate by member set
seen = set(); unique = []
for r in sorted(results, key=lambda x: x["score"], reverse=True):
key = frozenset(r["members"])
if key not in seen:
seen.add(key); unique.append(r)
return unique[:max_results]
def _search_montecarlo(labels, dfs, deposit, weights, min_s, max_s,
use_corr, corr_limit, corr_matrix, cond_corr_matrix,
max_results, n_samples, prog_cb, cancelled=None):
results = []; seen = set()
for i in range(n_samples):
if i % 100 == 0:
prog_cb(i, n_samples, f"Monte Carlo: {i:,} / {n_samples:,} samples")
size = random.randint(min_s, min(max_s, len(labels)))
combo = sorted(random.sample(labels, size))
key = frozenset(combo)
if key in seen: continue
seen.add(key)
if use_corr and corr_matrix is not None:
if _portfolio_exceeds_corr(combo, corr_matrix, corr_limit):
continue
r = _evaluate_combo(combo, dfs, deposit, weights, corr_matrix, cond_corr_matrix)
if r: results.append(r)
if cancelled and cancelled(): break
prog_cb(n_samples, n_samples, "Cancelled." if (cancelled and cancelled()) else "Done.")
results.sort(key=lambda x: x["score"], reverse=True)
return results[:max_results]
# ─────────────────────────────────────────────────────────────────────────────
# Combination count estimate
# ─────────────────────────────────────────────────────────────────────────────
def _combo_estimate(n: int, min_s: int, max_s: int) -> int:
from math import comb
return sum(comb(n, r) for r in range(min_s, max_s + 1))
def _time_estimate(n_combos: int) -> str:
# Rough: ~0.5ms per combo for small DFs, slower for large ones
secs = n_combos * 0.0008
if secs < 60: return f"~{secs:.0f}s"
if secs < 3600: return f"~{secs/60:.0f} min"
return f"~{secs/3600:.1f} hrs"
# ─────────────────────────────────────────────────────────────────────────────
# Correlation heatmap figure (reused in strategies tab and result expanders)
# ─────────────────────────────────────────────────────────────────────────────
def _corr_fig(corr: pd.DataFrame, title: str = "", height: int = 300) -> go.Figure:
labels = list(corr.columns)
fig = go.Figure(go.Heatmap(
z=corr.values, x=labels, y=labels,
colorscale=[
[0.00,"#2166AC"],[0.25,"#92C5DE"],[0.50,"#E8E8E8"],
[0.75,"#F4A582"],[1.00,"#B2182B"],
],
zmid=0, zmin=-1, zmax=1,
text=np.round(corr.values, 2),
texttemplate="%{text}",
textfont=dict(size=10, color="#1a1a2e"),
hovertemplate="%{x} / %{y}: %{z:.3f}
🏆 Portfolio Master
', unsafe_allow_html=True) st.markdown('Automated portfolio construction — composite scoring, greedy & Monte Carlo search
', 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.copy(), stem) st.session_state.pm_files[stem] = df st.success(f"✅ **{stem}** — {len(df):,} trades") if st.session_state.pm_files: # Clear all button if st.button("🗑 Clear All Files", key="pm_clear_all"): st.session_state.pm_files = {} st.session_state.pm_custom_names = {} st.session_state.pm_results = [] st.rerun() 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 = st.tabs([ "⚙️ Configure & Run", "📊 Strategy Stats", "🏆 Results", ]) # ═════════════════════════════════════════════════════════════════════════ # CONFIGURE & RUN # ═════════════════════════════════════════════════════════════════════════ with tab_config: # ── Capital ────────────────────────────────────────────────────────── st.markdown('