995205ed16
When a user selects a folder in the upload picker (browser behaviour that bypasses type filter), all files come through and only valid extensions get processed. Make the skipped count visible and update the caption to set expectations.
1217 lines
68 KiB
Python
1217 lines
68 KiB
Python
"""
|
||
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, 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 _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
|
||
df["_ea"] = label # EA = the uploaded filename stem
|
||
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}<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
|
||
|
||
|
||
def _build_strategy_dfs(file_dfs: dict) -> dict:
|
||
"""
|
||
Given {filename: df}, return {strategy_label: df} where each entry is
|
||
all trades for one unique strategy comment across all files.
|
||
Label format: "EA — Strategy" when a file has multiple strategies,
|
||
otherwise just the strategy name.
|
||
"""
|
||
result = {}
|
||
for ea_label, df in file_dfs.items():
|
||
if "strategy" not in df.columns:
|
||
result[ea_label] = df
|
||
continue
|
||
strategies = df["strategy"].dropna().unique().tolist()
|
||
if len(strategies) == 1:
|
||
# Single strategy in file — use strategy name as label
|
||
lbl = strategies[0] if strategies[0] != "Manual" else ea_label
|
||
result[lbl] = df.copy()
|
||
else:
|
||
for strat in strategies:
|
||
s_df = df[df["strategy"] == strat].copy()
|
||
if not s_df.empty:
|
||
lbl = f"{ea_label} — {strat}"
|
||
result[lbl] = s_df
|
||
return result
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# 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` — selecting a folder will only "
|
||
"import the supported files inside it (others are skipped).")
|
||
uploaded = st.file_uploader(
|
||
"Select files",
|
||
type=["htm", "html", "csv"],
|
||
accept_multiple_files=True,
|
||
key=f"pm_uploader_{st.session_state.pm_uploader_key}",
|
||
)
|
||
if uploaded:
|
||
# Some browsers ignore the type filter when a folder is selected —
|
||
# filter again here and report what was skipped.
|
||
valid = [f for f in uploaded
|
||
if f.name.lower().endswith((".htm",".html",".csv"))]
|
||
rejected = len(uploaded) - len(valid)
|
||
if rejected:
|
||
st.warning(f"⚠️ Skipped {rejected} non-supported file(s). "
|
||
f"Importing {len(valid)} valid file(s).")
|
||
uploaded = valid
|
||
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
|
||
|
||
# Explode each uploaded file into per-strategy DataFrames
|
||
all_strategy_dfs = _build_strategy_dfs(strategy_dfs)
|
||
labels = list(all_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 ─────────────────────────────
|
||
# ── Strategies to include ─────────────────────────────────────────────
|
||
st.markdown('<div class="sh">Strategies to Include</div>', unsafe_allow_html=True)
|
||
ea_names = list(strategy_dfs.keys())
|
||
sel_eas = st.multiselect(
|
||
"Filter by EA (file)",
|
||
ea_names, default=ea_names, key="pm_sel_eas",
|
||
help="Select which uploaded files to draw strategies from",
|
||
)
|
||
# Build strategy list cascading from EA selection
|
||
if sel_eas:
|
||
avail_strats = [lbl for lbl in labels
|
||
if any(lbl == ea or lbl.startswith(f"{ea} — ")
|
||
for ea in sel_eas)]
|
||
else:
|
||
avail_strats = labels
|
||
sel_labels = st.multiselect(
|
||
"Strategies to include",
|
||
avail_strats, default=avail_strats, key="pm_sel_labels",
|
||
help="Each strategy = one unique comment group within an EA file",
|
||
)
|
||
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 = all_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(all_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(all_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(all_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> | ' +
|
||
f'<span style="color:#f77f00">{warn}</span> | ' +
|
||
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 (0–1000). Higher is better. Weighted blend of the metrics below based on your sliders.
|
||
<span style="color:#34C27A">≥700 = strong</span> | <span style="color:#f77f00">400–700 = average</span> | <span style="color:#E05555"><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.
|
||
<span style="color:#34C27A">≥5 = strong</span> | <span style="color:#f77f00">2–5 = average</span> | <span style="color:#E05555"><2 = weak</span><br>
|
||
<b style="color:{_desc_label}">Stability</b> — How straight the equity curve is (0–100). 100 = perfectly straight rising line. R² of linear regression on the equity curve.
|
||
<span style="color:#34C27A">≥70 = strong</span> | <span style="color:#f77f00">40–70 = average</span> | <span style="color:#E05555"><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.
|
||
<span style="color:#34C27A">≥50 = strong</span> | <span style="color:#f77f00">20–50 = average</span> | <span style="color:#E05555"><20 = weak</span><br>
|
||
<b style="color:{_desc_label}">Diversity</b> — How different the strategies are from each other (0–100), based on symbol variety and trading session overlap. 100 = completely different.
|
||
<span style="color:#34C27A">≥60 = strong</span> | <span style="color:#f77f00">30–60 = average</span> | <span style="color:#E05555"><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.
|
||
<span style="color:#34C27A">≤0.20 = low (good)</span> | <span style="color:#f77f00">0.20–0.50 = moderate</span> | <span style="color:#E05555">>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.
|
||
<span style="color:#34C27A">≤0.20 = low (good)</span> | <span style="color:#f77f00">0.20–0.50 = moderate</span> | <span style="color:#E05555">>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 = [all_strategy_dfs[m].copy() for m in r["members"] if m in all_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),
|
||
)
|
||
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: all_strategy_dfs[m] for m in r["members"] if m in all_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 all_strategy_dfs: continue
|
||
s = _full_stats(all_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) |