Files
mt5-tools/view_portfolio_master.py
T

1161 lines
66 KiB
Python
Raw 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_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.01.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}<extra></extra>",
))
fig.update_layout(
title=dict(text=title, font=dict(size=11, color="#6C7A8D")) if title else {},
height=height,
margin=dict(l=20, r=60, t=30 if title else 10, b=10),
paper_bgcolor="#F0F2F6", plot_bgcolor="#F0F2F6",
xaxis=dict(tickfont=dict(size=9, color="#333"), tickangle=-30),
yaxis=dict(tickfont=dict(size=9, color="#333")),
)
return fig
# ─────────────────────────────────────────────────────────────────────────────
# Session state
# ─────────────────────────────────────────────────────────────────────────────
def _init_state():
for k, v in {
"pm_files": {},
"pm_custom_names": {},
"pm_results": [],
"pm_deposit": 10000.0,
"pm_running": False,
"pm_cancel": False,
"pm_thread_results": None,
"pm_progress_q": None,
"pm_uploader_key": 0,
}.items():
if k not in st.session_state:
st.session_state[k] = v
# ─────────────────────────────────────────────────────────────────────────────
# Render
# ─────────────────────────────────────────────────────────────────────────────
def render():
_init_state()
st.markdown("""<style>
.pm-title{font-size:22px;font-weight:700;color:#CDD6F4;letter-spacing:.04em}
.pm-sub{font-size:13px;color:#6C7A8D;margin-bottom:14px}
.sh{font-size:11px;font-weight:600;color:#8899BB;text-transform:uppercase;
letter-spacing:.1em;margin:14px 0 6px;border-bottom:1px solid #1E2535;padding-bottom:4px}
.chip{display:inline-block;padding:3px 10px;border-radius:12px;font-size:11px;
font-weight:600;margin:2px;border:1px solid #2A3550;color:#8899CC}
.warn-box{background:#2A1A00;border:1px solid #7A4A00;border-radius:6px;
padding:10px 14px;font-size:13px;color:#FFB347;margin:8px 0}
</style>""", unsafe_allow_html=True)
_tc1, _tc2 = st.columns([8, 1])
with _tc1:
st.markdown('<p class="pm-title">🏆 Portfolio Master</p>', unsafe_allow_html=True)
st.markdown('<p class="pm-sub">Automated portfolio construction — composite scoring, greedy & Monte Carlo search</p>',
unsafe_allow_html=True)
with _tc2:
st.markdown("<br>", unsafe_allow_html=True)
if st.button("🗑 Clear", key="pm_clear_session", help="Clear all files and results to start fresh"):
st.session_state.pm_uploader_key = st.session_state.get("pm_uploader_key", 0) + 1
for _k in ["pm_files","pm_custom_names","pm_results","pm_running",
"pm_cancel","pm_thread_results","pm_progress_q","pm_cancel_event"]:
if _k in st.session_state:
del st.session_state[_k]
st.rerun()
# ── 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=f"pm_uploader_{st.session_state.pm_uploader_key}",
)
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"<span class='chip'>📈 {label}</span>", 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('<div class="sh">Capital</div>', unsafe_allow_html=True)
deposit = st.number_input("Initial Deposit ($)", min_value=100.0,
max_value=10_000_000.0,
value=st.session_state.pm_deposit,
step=1000.0, format="%.2f", key="pm_deposit")
# ── Composite score weights ───────────────────────────────────────────
st.markdown('<div class="sh">Composite Score Weights</div>', unsafe_allow_html=True)
st.caption("Weights are normalised automatically — they don't need to sum to 1.")
wc1, wc2, wc3, wc4 = st.columns([2, 2, 2, 3])
w_retdd = wc1.slider("Ret/DD", 0, 100, 35, key="pm_w_retdd")
w_stab = wc1.slider("Stability (R²)", 0, 100, 25, key="pm_w_stab")
w_stag = wc2.slider("Stagnation % ↓", 0, 100, 20, key="pm_w_stag",
help="Lower stagnation = higher score")
w_wr = wc2.slider("Win Rate", 0, 100, 10, key="pm_w_wr")
w_gq = wc3.slider("Growth Quality", 0, 100, 5, key="pm_w_gq",
help="Slope × R² — rewards a rising, stable equity curve")
w_div = wc3.slider("Diversity Bonus", 0, 100, 5, key="pm_w_div",
help="Rewards portfolios trading different symbols / sessions")
with wc4:
import os as _osw, re as _rew
_cfgw = _osw.path.join(_osw.path.dirname(_osw.path.abspath(__file__)), ".streamlit", "config.toml")
_lightw = False
if _osw.path.isfile(_cfgw):
_mw = _rew.search(r'base\s*=\s*"([^"]*)"', open(_cfgw).read())
if _mw: _lightw = _mw.group(1) == "light"
_wbg = "#f0f2f6" if _lightw else "#131720"
_wbdr = "#d0d4dc" if _lightw else "#1E2535"
_wtxt = "#555e70" if _lightw else "#8899AA"
_wlbl = "#1a1a2e" if _lightw else "#CDD6F4"
st.markdown(f"""
<div style="background:{_wbg};border:1px solid {_wbdr};border-radius:8px;
padding:10px 14px;font-size:11px;color:{_wtxt};line-height:1.8;margin-top:4px">
<b style="color:{_wlbl}">Ret/DD</b> — Net profit ÷ max drawdown. Primary return efficiency metric. Most important for risk-adjusted performance.<br>
<b style="color:{_wlbl}">Stability (R²)</b> — How straight the equity curve is. High R² means consistent gains without large swings.<br>
<b style="color:{_wlbl}">Stagnation ↓</b> — Time spent below a previous equity high, as % of total period. Lower = better; score is inverted.<br>
<b style="color:{_wlbl}">Win Rate</b> — Percentage of trades that are profitable. Higher win rate reduces psychological drawdown pressure.<br>
<b style="color:{_wlbl}">Growth Quality</b> — Combines equity curve slope with R². Rewards portfolios that rise steadily, not just flat and stable.<br>
<b style="color:{_wlbl}">Diversity Bonus</b> — Rewards combinations trading different symbols and/or different hours of the day.
</div>""", unsafe_allow_html=True)
total_w = w_retdd + w_stab + w_stag + w_wr + w_gq + w_div or 1
weights = {
"ret_dd": w_retdd / total_w,
"stability": w_stab / total_w,
"stagnation": w_stag / total_w,
"win_rate": w_wr / total_w,
"growth_quality": w_gq / total_w,
"diversity": w_div / total_w,
}
st.caption(f"Normalised: Ret/DD {weights['ret_dd']:.0%} "
f"Stability {weights['stability']:.0%} "
f"Stagnation {weights['stagnation']:.0%} "
f"Win Rate {weights['win_rate']:.0%} "
f"Growth Quality {weights['growth_quality']:.0%} "
f"Diversity {weights['diversity']:.0%}")
# ── Portfolio size ────────────────────────────────────────────────────
st.markdown('<div class="sh">Portfolio Size</div>', unsafe_allow_html=True)
sz1, sz2, sz3 = st.columns(3)
min_strats = sz1.number_input("Min strategies", min_value=1, max_value=len(labels),
value=2, step=1, key="pm_min")
max_strats = sz2.number_input("Max strategies", min_value=1, max_value=len(labels),
value=min(5, len(labels)), step=1, key="pm_max")
max_results = sz3.number_input("Max portfolios to store", min_value=1, max_value=500,
value=50, step=10, key="pm_maxres")
# ── Search mode ───────────────────────────────────────────────────────
st.markdown('<div class="sh">Search Mode</div>', unsafe_allow_html=True)
search_mode = st.radio(
"Algorithm",
["Exhaustive", "Greedy (fast)", "Monte Carlo", "Greedy + Monte Carlo"],
horizontal=True, key="pm_search_mode",
help="Exhaustive: every combination. Greedy: incremental build from each seed. "
"Monte Carlo: random sampling. Combined: greedy first then MC to fill gaps.",
)
mc_samples = 1000
if "Monte Carlo" in search_mode:
mc_samples = st.number_input("Monte Carlo samples", min_value=100,
max_value=100_000, value=5000,
step=500, key="pm_mc_samples")
# ── Combination count estimate + warning ─────────────────────────────
sel_labels = st.multiselect("Strategies to include", labels,
default=labels, key="pm_sel_labels")
n_sel = len(sel_labels)
if n_sel >= int(min_strats):
n_combos = _combo_estimate(n_sel, int(min_strats), int(max_strats))
t_estimate = _time_estimate(n_combos)
if search_mode == "Exhaustive":
col_est1, col_est2 = st.columns(2)
col_est1.metric("Combinations to evaluate", f"{n_combos:,}")
col_est2.metric("Estimated run time", t_estimate)
if n_combos > 50_000:
st.markdown(
f'<div class="warn-box">⚠️ <b>{n_combos:,} combinations</b> — '
f'estimated {t_estimate}. Consider switching to Greedy or Monte Carlo '
f'for faster results, or reduce Max strategies / Strategy count.</div>',
unsafe_allow_html=True)
elif n_combos > 5_000:
st.info(f"{n_combos:,} combinations — estimated {t_estimate}. "
f"This may take a moment.")
elif "Greedy" in search_mode:
st.metric("Greedy seeds (one per strategy)", n_sel)
else:
st.metric("Monte Carlo samples", f"{mc_samples:,}")
# ── Correlation ───────────────────────────────────────────────────────
st.markdown('<div class="sh">Correlation Filter</div>', unsafe_allow_html=True)
cc1, cc2 = st.columns(2)
use_corr = cc1.checkbox("Enable pairwise correlation filter", value=True, key="pm_use_corr")
use_cond = cc2.checkbox("Also compute conditional correlation (drawdown periods)",
value=True, key="pm_use_cond",
help="Shown in results but not used for filtering — "
"useful to see how strategies co-move during losses.")
corr_limit = st.slider("Max allowed pairwise correlation",
min_value=0.10, max_value=0.70, value=0.50,
step=0.05, key="pm_corr", disabled=not use_corr,
help="Portfolios with any pair exceeding this are excluded.")
# ── Date range ────────────────────────────────────────────────────────
st.markdown('<div class="sh">Date Range Filter</div>', unsafe_allow_html=True)
all_dates = [pd.to_datetime(df["close_time"]).dt.tz_localize(None).dropna()
for df in strategy_dfs.values() if "close_time" in df.columns]
date_from = date_to = None
if all_dates:
g_min = min(s.min().date() for s in all_dates)
g_max = max(s.max().date() for s in all_dates)
use_date = st.checkbox("Filter by date range", value=False, key="pm_use_date")
if use_date and g_min != g_max:
import datetime as _dt
total_days = (g_max - g_min).days
step = max(1, total_days // 500)
date_opts = [g_min + _dt.timedelta(days=i) for i in range(0, total_days+1, step)]
if date_opts[-1] != g_max: date_opts.append(g_max)
date_sel = st.select_slider("Date range", options=date_opts,
value=(g_min, g_max),
format_func=lambda d: d.strftime("%d %b %Y"),
key="pm_daterange")
date_from, date_to = date_sel
# ── Run ───────────────────────────────────────────────────────────────
st.markdown("---")
rb1, rb2 = st.columns([3, 1])
run_btn = rb1.button("🚀 Run Portfolio Search", type="primary",
key="pm_run",
disabled=st.session_state.pm_running)
cancel_btn = rb2.button("⛔ Cancel", key="pm_cancel_btn",
disabled=not st.session_state.pm_running)
if cancel_btn:
st.session_state.pm_cancel = True
# Signal the thread-safe event so the worker stops without session_state access
ev = st.session_state.get("pm_cancel_event")
if ev is not None:
ev.set()
# Poll for thread completion
if st.session_state.pm_running:
q = st.session_state.pm_progress_q
if q is not None:
import queue as _queue
try:
msg = q.get_nowait()
if msg.get("status") == "done":
st.session_state.pm_running = False
st.session_state.pm_cancel = False
results = msg.get("results", [])
st.session_state.pm_results = results
cancelled = msg.get("cancelled", False)
if cancelled:
st.warning(f"Search cancelled — {len(results)} portfolios found so far.")
else:
st.success(f"Found **{len(results)}** portfolios.")
elif msg.get("status") == "progress":
st.progress(msg["pct"], text=msg["text"])
except _queue.Empty:
pass
st.info("⏳ Search running… Results will appear when complete or cancelled.")
time.sleep(1)
st.rerun()
if run_btn:
if len(sel_labels) < int(min_strats):
st.error(f"Need at least {int(min_strats)} strategies selected.")
else:
filtered_dfs = {}
for lbl in sel_labels:
df = strategy_dfs[lbl].copy()
if date_from and date_to and "close_time" in df.columns:
ct = pd.to_datetime(df["close_time"]).dt.tz_localize(None)
df = df[(ct >= pd.Timestamp(date_from)) &
(ct <= pd.Timestamp(date_to) + timedelta(days=1))]
if not df.empty:
filtered_dfs[lbl] = df
if not filtered_dfs:
st.error("No data in selected date range.")
else:
import queue as _queue, threading as _threading
q = _queue.Queue()
cancel_event = _threading.Event() # thread-safe cancel flag
st.session_state.pm_progress_q = q
st.session_state.pm_cancel_event = cancel_event
st.session_state.pm_running = True
st.session_state.pm_cancel = False
# Capture all search params for the thread
_mode = search_mode
_labels = list(filtered_dfs.keys())
_fdfs = filtered_dfs
_dep = deposit
_wts = dict(weights)
_min_s = int(min_strats)
_max_s = int(max_strats)
_use_corr = use_corr
_corr_lim = corr_limit
_use_cond = use_cond
_max_res = int(max_results)
_mc_samp = int(mc_samples)
def _run_thread():
try:
cm = _correlation_matrix(_fdfs)
ccm = (_conditional_correlation(_fdfs, _dep) if _use_cond else cm)
def _prog(done, total, msg):
pct = min(done / max(total, 1), 1.0)
q.put({"status": "progress", "pct": pct, "text": msg})
def _is_cancelled():
return cancel_event.is_set() # no Streamlit context needed
if _mode == "Exhaustive":
res = _search_exhaustive(
_labels, _fdfs, _dep, _wts, _min_s, _max_s,
_use_corr, _corr_lim, cm, ccm, _max_res, _prog, _is_cancelled)
elif _mode == "Greedy (fast)":
res = _search_greedy(
_labels, _fdfs, _dep, _wts, _min_s, _max_s,
_use_corr, _corr_lim, cm, ccm, _max_res, _prog, _is_cancelled)
elif _mode == "Monte Carlo":
res = _search_montecarlo(
_labels, _fdfs, _dep, _wts, _min_s, _max_s,
_use_corr, _corr_lim, cm, ccm, _max_res, _mc_samp,
_prog, _is_cancelled)
else: # Greedy + Monte Carlo
gr = _search_greedy(
_labels, _fdfs, _dep, _wts, _min_s, _max_s,
_use_corr, _corr_lim, cm, ccm, _max_res, _prog, _is_cancelled)
mc = _search_montecarlo(
_labels, _fdfs, _dep, _wts, _min_s, _max_s,
_use_corr, _corr_lim, cm, ccm, _max_res, _mc_samp,
_prog, _is_cancelled)
seen = set(); combined = []
for r in sorted(gr + mc, key=lambda x: x["score"], reverse=True):
k = frozenset(r["members"])
if k not in seen: seen.add(k); combined.append(r)
res = combined[:_max_res]
q.put({"status": "done", "results": res,
"cancelled": _is_cancelled()})
except Exception as e:
q.put({"status": "done", "results": [],
"cancelled": False, "error": str(e)})
t = _threading.Thread(target=_run_thread, daemon=True)
t.start()
st.rerun()
# ═════════════════════════════════════════════════════════════════════════
# STRATEGY STATS
# ═════════════════════════════════════════════════════════════════════════
with tab_strategies:
st.markdown("##### Individual Strategy Statistics")
st.caption("Edit Strategy Name to assign custom names — these carry through to Results.")
dep_s = st.session_state.pm_deposit
rows = []
for i, label in enumerate(labels):
custom = st.session_state.pm_custom_names.get(label, "")
row = _full_stats(strategy_dfs[label], dep_s, i+1, custom)
if row: rows.append(row)
if rows:
col_order = ["#","Strategy Name","Symbol","# Trades",
"Net Profit ($)","Max DD ($)","Max DD (%)",
"Annual Profit ($)","Annual Profit (%)",
"Avg Win ($)","Avg Loss ($)","% Wins",
"Commissions ($)",
"Stagnation (%)","Stagnation (days)","Profit Factor",
"Ret/DD","Stability","Growth Quality"]
stats_df = pd.DataFrame(rows)
stats_df = stats_df[[c for c in col_order if c in stats_df.columns]]
edited = st.data_editor(
stats_df, use_container_width=True, hide_index=True,
column_config={
"#": st.column_config.NumberColumn("#", disabled=True, width="small"),
"Strategy Name": st.column_config.TextColumn("Strategy Name", width="medium"),
"Symbol": st.column_config.TextColumn("Symbol", disabled=True),
"# Trades": st.column_config.NumberColumn("# Trades", disabled=True, format="%d"),
"Net Profit ($)": st.column_config.NumberColumn("Net Profit ($)", disabled=True, format="%.2f"),
"Max DD ($)": st.column_config.NumberColumn("Max DD ($)", disabled=True, format="%.2f"),
"Max DD (%)": st.column_config.NumberColumn("Max DD (%)", disabled=True, format="%.2f"),
"Annual Profit ($)": st.column_config.NumberColumn("Annual Profit ($)", disabled=True, format="%.2f"),
"Annual Profit (%)": st.column_config.NumberColumn("Annual Profit (%)", disabled=True, format="%.2f"),
"Avg Win ($)": st.column_config.NumberColumn("Avg Win ($)", disabled=True, format="%.2f"),
"Avg Loss ($)": st.column_config.NumberColumn("Avg Loss ($)", disabled=True, format="%.2f"),
"% Wins": st.column_config.NumberColumn("% Wins", disabled=True, format="%.2f"),
"Commissions ($)": st.column_config.NumberColumn("Commissions ($)", disabled=True, format="%.2f"),
"Stagnation (%)": st.column_config.NumberColumn("Stagnation (%)", disabled=True, format="%.2f"),
"Stagnation (days)": st.column_config.NumberColumn("Stagnation (d)", disabled=True, format="%d"),
"Profit Factor": st.column_config.NumberColumn("PF", disabled=True, format="%.2f"),
"Ret/DD": st.column_config.NumberColumn("Ret/DD", disabled=True, format="%.2f"),
"Stability": st.column_config.NumberColumn("Stability", disabled=True, format="%d",
help="R² of equity curve linear regression. 1.0 = perfectly straight."),
"Growth Quality": st.column_config.NumberColumn("Growth Quality", disabled=True, format="%d",
help="Normalised slope × R² — rewards a consistently rising equity curve."),
},
key="pm_stats_editor",
)
for _, row in edited.iterrows():
orig = labels[int(row["#"]) - 1]
name = str(row["Strategy Name"]).strip()
if name and name != orig: st.session_state.pm_custom_names[orig] = name
else: st.session_state.pm_custom_names.pop(orig, None)
# Overall correlation heatmap
if len(labels) > 1:
hc1, hc2 = st.columns(2)
with hc1:
st.markdown("##### Pairwise Correlation (all days)")
corr = _correlation_matrix(strategy_dfs)
disp_labels = [st.session_state.pm_custom_names.get(l,l) for l in corr.columns]
corr.index = corr.columns = disp_labels
st.plotly_chart(_corr_fig(corr, height=max(300, len(labels)*55)),
use_container_width=True, key=f"pm_corr_all_{len(labels)}")
with hc2:
st.markdown("##### Conditional Correlation (drawdown days only)")
dep_s2 = st.session_state.pm_deposit
cond = _conditional_correlation(strategy_dfs, dep_s2)
cond.index = cond.columns = disp_labels
st.plotly_chart(_corr_fig(cond, height=max(300, len(labels)*55)),
use_container_width=True, key=f"pm_corr_cond_{len(labels)}")
# ═════════════════════════════════════════════════════════════════════════
# RESULTS
# ═════════════════════════════════════════════════════════════════════════
with tab_results:
results = st.session_state.pm_results
if not results:
st.info("Run the portfolio search on the Configure tab first.")
else:
def _name(lbl):
return st.session_state.pm_custom_names.get(lbl, lbl)
# ── Summary table ─────────────────────────────────────────────────
st.markdown(f"##### Top {len(results)} Portfolios")
import os as _os2, re as _re3
_cfg2 = _os2.path.join(_os2.path.dirname(_os2.path.abspath(__file__)), ".streamlit", "config.toml")
_light2 = False
if _os2.path.isfile(_cfg2):
_m2 = _re3.search(r'base\s*=\s*"([^"]*)"', open(_cfg2).read())
if _m2: _light2 = _m2.group(1) == "light"
_desc_bg = "#f0f2f6" if _light2 else "#131720"
_desc_border = "#d0d4dc" if _light2 else "#1E2535"
_desc_text = "#555e70" if _light2 else "#8899AA"
_desc_label = "#1a1a2e" if _light2 else "#CDD6F4"
_desc_thresh = lambda good, warn: (
f'<span style="color:#34C27A">{good}</span> &nbsp;|&nbsp; ' +
f'<span style="color:#f77f00">{warn}</span> &nbsp;|&nbsp; ' +
f'<span style="color:#E05555">below = poor</span>'
)
st.markdown(f"""
<div style="background:{_desc_bg};border:1px solid {_desc_border};border-radius:8px;padding:12px 16px;font-size:12px;color:{_desc_text};margin-bottom:12px;line-height:1.9">
<b style="color:{_desc_label}">Score</b> — Composite ranking (01000). Higher is better. Weighted blend of the metrics below based on your sliders.
&nbsp; <span style="color:#34C27A">≥700 = strong</span> &nbsp;|&nbsp; <span style="color:#f77f00">400700 = average</span> &nbsp;|&nbsp; <span style="color:#E05555">&lt;400 = weak</span><br>
<b style="color:{_desc_label}">Ret/DD</b> — Net profit divided by max drawdown. Measures return efficiency per unit of risk.
&nbsp; <span style="color:#34C27A">≥5 = strong</span> &nbsp;|&nbsp; <span style="color:#f77f00">25 = average</span> &nbsp;|&nbsp; <span style="color:#E05555">&lt;2 = weak</span><br>
<b style="color:{_desc_label}">Stability</b> — How straight the equity curve is (0100). 100 = perfectly straight rising line. R² of linear regression on the equity curve.
&nbsp; <span style="color:#34C27A">≥70 = strong</span> &nbsp;|&nbsp; <span style="color:#f77f00">4070 = average</span> &nbsp;|&nbsp; <span style="color:#E05555">&lt;40 = weak</span><br>
<b style="color:{_desc_label}">Growth Quality</b> — Combines curve straightness with upward slope. Rewards portfolios that rise consistently, not just ones that are flat and stable.
&nbsp; <span style="color:#34C27A">≥50 = strong</span> &nbsp;|&nbsp; <span style="color:#f77f00">2050 = average</span> &nbsp;|&nbsp; <span style="color:#E05555">&lt;20 = weak</span><br>
<b style="color:{_desc_label}">Diversity</b> — How different the strategies are from each other (0100), based on symbol variety and trading session overlap. 100 = completely different.
&nbsp; <span style="color:#34C27A">≥60 = strong</span> &nbsp;|&nbsp; <span style="color:#f77f00">3060 = average</span> &nbsp;|&nbsp; <span style="color:#E05555">&lt;30 = low diversity</span><br>
<b style="color:{_desc_label}">Avg Corr</b> — Average pairwise correlation of daily P&L. Lower is better — strategies that don't move together reduce portfolio drawdown.
&nbsp; <span style="color:#34C27A">≤0.20 = low (good)</span> &nbsp;|&nbsp; <span style="color:#f77f00">0.200.50 = moderate</span> &nbsp;|&nbsp; <span style="color:#E05555">&gt;0.50 = high (bad)</span><br>
<b style="color:{_desc_label}">Avg Cond Corr</b> — Same correlation computed only on drawdown days. Strategies that decorrelate during losses are more valuable.
&nbsp; <span style="color:#34C27A">≤0.20 = low (good)</span> &nbsp;|&nbsp; <span style="color:#f77f00">0.200.50 = moderate</span> &nbsp;|&nbsp; <span style="color:#E05555">&gt;0.50 = high (bad)</span>
</div>
""", unsafe_allow_html=True)
col_order = [
"Rank", "Score", "Strategies", "# Strategies",
"Avg Corr", "Avg Cond Corr", "Diversity",
"# Trades", "Net Profit ($)", "Max DD ($)", "Max DD (%)",
"Annual Profit ($)", "Annual Profit (%)",
"Avg Win ($)", "Avg Loss ($)", "% Wins",
"Commissions ($)",
"Stagnation (%)", "Stagnation (days)", "Profit Factor",
"Ret/DD", "Stability", "Growth Quality",
]
rows_r = []
for i, r in enumerate(results):
member_names = " + ".join(_name(m) for m in r["members"])
fs = r.get("full_stats", {})
row = {
"Rank": i + 1,
"Score": round(r.get("score", 0), 4),
"Strategies": member_names,
"# Strategies": len(r["members"]),
"Avg Corr": round(r.get("avg_corr", 0), 4),
"Avg Cond Corr":round(r.get("avg_cond_corr", 0), 4),
"Diversity": round(r.get("diversity", 0), 4),
}
for col in col_order[7:]:
row[col] = fs.get(col, 0)
rows_r.append(row)
res_df = pd.DataFrame(rows_r)
res_df = res_df[[c for c in col_order if c in res_df.columns]]
def _cc(val, low=0):
if not isinstance(val,(int,float)): return ""
return "color:#34C27A" if val > low else "color:#E05555" if val < low else ""
int_cols = {"Rank","# Strategies","# Trades","Stagnation (days)"}
nc = res_df.select_dtypes(include="number").columns.tolist()
fmt = {c: ("{:.0f}" if c in int_cols or c in
("Score","Stability","Growth Quality","Diversity")
else "{:.3f}" if c in ("Avg Corr","Avg Cond Corr")
else "{:.2f}") for c in nc}
pos_cols = [c for c in ["Net Profit ($)","Annual Profit ($)","Annual Profit (%)","Avg Win ($)","Score"]
if c in res_df.columns]
neg_cols = [c for c in ["Max DD ($)","Max DD (%)","Avg Loss ($)","Avg Corr","Avg Cond Corr"]
if c in res_df.columns]
def _grade(val, good, avg):
"""Return green/orange/red based on good/avg thresholds (higher=better)."""
if not isinstance(val, (int, float)): return ""
if val >= good: return "background-color:rgba(52,194,122,0.15);color:#34C27A"
if val >= avg: return "background-color:rgba(247,127,0,0.12);color:#f77f00"
return "background-color:rgba(220,50,50,0.12);color:#E05555"
def _grade_inv(val, good, avg):
"""Return green/orange/red — lower is better (correlation)."""
if not isinstance(val, (int, float)): return ""
if val <= good: return "background-color:rgba(52,194,122,0.15);color:#34C27A"
if val <= avg: return "background-color:rgba(247,127,0,0.12);color:#f77f00"
return "background-color:rgba(220,50,50,0.12);color:#E05555"
styled = (
res_df.style.format(fmt)
.map(_cc, subset=pos_cols if pos_cols else [])
.map(lambda v: "color:#E05555" if isinstance(v,(int,float)) and v < 0 else "",
subset=neg_cols if neg_cols else [])
.map(lambda v: _cc(v, 1.0),
subset=["Profit Factor"] if "Profit Factor" in res_df.columns else [])
.map(lambda v: _grade(v, 700, 400),
subset=["Score"] if "Score" in res_df.columns else [])
.map(lambda v: _grade(v, 5, 2),
subset=["Ret/DD"] if "Ret/DD" in res_df.columns else [])
.map(lambda v: _grade(v, 70, 40),
subset=["Stability"] if "Stability" in res_df.columns else [])
.map(lambda v: _grade(v, 50, 20),
subset=["Growth Quality"] if "Growth Quality" in res_df.columns else [])
.map(lambda v: _grade(v, 60, 30),
subset=["Diversity"] if "Diversity" in res_df.columns else [])
.map(lambda v: _grade_inv(v, 0.20, 0.50),
subset=["Avg Corr"] if "Avg Corr" in res_df.columns else [])
.map(lambda v: _grade_inv(v, 0.20, 0.50),
subset=["Avg Cond Corr"] if "Avg Cond Corr" in res_df.columns else [])
)
st.dataframe(styled, use_container_width=True, hide_index=True)
buf = io.StringIO(); res_df.to_csv(buf, index=False)
st.download_button("⬇️ Export Results CSV", buf.getvalue(),
file_name="portfolio_master_results.csv", mime="text/csv")
# ── Detail expanders ──────────────────────────────────────────────
st.markdown("##### Portfolio Detail")
show_top = st.slider("Show detail for top N", 1, min(10, len(results)),
min(5, len(results)), key="pm_show_top")
for i, r in enumerate(results[:show_top]):
member_names = " + ".join(_name(m) for m in r["members"])
with st.expander(
f"#{i+1} {member_names} "
f"| Score {r['score']:.4f} "
f"| Ret/DD {r['ret_dd']:.2f} "
f"| Net ${r['net_profit']:,.2f} "
f"| DD ${r['max_dd']:,.2f}"
):
# Key metrics row
mc1,mc2,mc3,mc4,mc5,mc6 = st.columns(6)
mc1.metric("Score", f"{r['score']:.4f}")
mc2.metric("Ret/DD", f"{r['ret_dd']:.2f}")
mc3.metric("Stability", f"{r['stability']}")
mc4.metric("Growth Quality", f"{r['growth_quality']}")
mc5.metric("Avg Correlation",f"{r['avg_corr']:.3f}")
mc6.metric("Avg Cond Corr", f"{r['avg_cond_corr']:.3f}",
help="Correlation during drawdown days only")
dc1, dc2 = st.columns(2)
# Mini equity chart
with dc1:
frames = [strategy_dfs[m].copy() for m in r["members"] if m in strategy_dfs]
if frames:
combined = pd.concat(frames, ignore_index=True)
if "close_time" in combined.columns:
combined = combined.sort_values("close_time").reset_index(drop=True)
eq = st.session_state.pm_deposit + combined["net_profit"].cumsum()
rm = eq.cummax(); dd_c = eq - rm
pfig = go.Figure()
pfig.add_trace(go.Scatter(x=combined["close_time"], y=eq,
name="Equity", line=dict(color="#4C8EF5", width=2), mode="lines"))
pfig.add_trace(go.Scatter(x=combined["close_time"], y=dd_c,
name="DD", fill="tozeroy",
fillcolor="rgba(220,50,50,0.25)",
line=dict(color="rgba(220,50,50,0.6)", width=1),
mode="lines", yaxis="y2"))
pfig.update_layout(
height=200, margin=dict(l=40,r=40,t=10,b=10),
paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="#0E1117",
hovermode="x unified",
legend=dict(orientation="h", y=1.1, font=dict(size=9)),
yaxis=dict(gridcolor="#1E2130", tickprefix="$"),
yaxis2=dict(overlaying="y", side="right",
gridcolor="#1E2130", tickprefix="$", showgrid=False),
)
# Add invisible annotation to ensure figure hash is unique per portfolio
pfig.add_annotation(text=str(i), x=0, y=0, opacity=0,
showarrow=False, xref="paper", yref="paper")
st.plotly_chart(pfig, use_container_width=True, key=f"pm_pfig_{i}")
# Per-result correlation heatmap
with dc2:
if len(r["members"]) > 1:
member_dfs = {m: strategy_dfs[m] for m in r["members"] if m in strategy_dfs}
if len(member_dfs) > 1:
r_corr = _correlation_matrix(member_dfs)
r_cond = _conditional_correlation(member_dfs, st.session_state.pm_deposit)
disp = [_name(m) for m in r_corr.columns]
r_corr.index = r_corr.columns = disp
r_cond.index = r_cond.columns = disp
st.plotly_chart(
_corr_fig(r_corr, title="Correlation (all days)", height=180),
use_container_width=True, key=f"pm_rcorr_{i}")
st.plotly_chart(
_corr_fig(r_cond, title="Conditional (DD days)", height=180),
use_container_width=True, key=f"pm_rcond_{i}")
# Member stats table
m_rows = []
for m in r["members"]:
if m not in strategy_dfs: continue
s = _full_stats(strategy_dfs[m], st.session_state.pm_deposit,
labels.index(m)+1,
st.session_state.pm_custom_names.get(m,""))
if s:
m_rows.append({
"Strategy": s.get("Strategy Name", m),
"Symbol": s.get("Symbol",""),
"Net Profit ($)": s.get("Net Profit ($)",0),
"Max DD ($)": s.get("Max DD ($)",0),
"Ret/DD": s.get("Ret/DD",0),
"% Wins": s.get("% Wins",0),
"Profit Factor": s.get("Profit Factor",0),
"Stability": s.get("Stability",0),
"Growth Quality": s.get("Growth Quality",0),
})
if m_rows:
mdf = pd.DataFrame(m_rows)
st.dataframe(
mdf.style.format({c:"{:.0f}" if c in ("Stability","Growth Quality")
else "{:.2f}"
for c in mdf.select_dtypes("number").columns}),
use_container_width=True, hide_index=True)