diff --git a/MT5Tools_Handoff_v2.docx b/MT5Tools_Handoff_v2.docx new file mode 100644 index 0000000..ac6d57a Binary files /dev/null and b/MT5Tools_Handoff_v2.docx differ diff --git a/view_portfolio_builder.py b/view_portfolio_builder.py index 78e890f..3b1deea 100644 --- a/view_portfolio_builder.py +++ b/view_portfolio_builder.py @@ -977,37 +977,82 @@ def render(): ) st.dataframe(styled, use_container_width=True, hide_index=True) - # [3] Smoothing slider [4] Taller chart (height=500) + # Controls row st.markdown("##### Equity Curves") - sc_smooth = st.slider("Curve smoothing", 1, 50, 1, key="pb_st_smooth", - help="Rolling-average window (trades).") + ctl1, ctl2, ctl3 = st.columns([2, 2, 2]) + sc_smooth = ctl1.slider("Curve smoothing", 1, 50, 1, key="pb_st_smooth", + help="Rolling-average window (trades).") + show_st_stag = ctl2.toggle("Show stagnation bands", value=False, + key="pb_st_show_stag", + help="Highlight max stagnation period per strategy in matching colour") + sf = go.Figure() sf.update_layout( height=500, - margin=dict(l=40, r=20, t=10, b=10), + margin=dict(l=40, r=20, t=40, b=10), paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)", legend=dict(orientation="h", y=1.08, font=dict(size=10)), - hovermode="closest", - hoverlabel=dict(namelength=-1, font=dict(size=11)), + hovermode="x unified", + hoverlabel=dict(namelength=-1, font=dict(size=12)), ) - sf.update_xaxes(gridcolor="#1E2130", zeroline=False) - sf.update_yaxes(gridcolor="#1E2130", zeroline=False, tickprefix="$") + sf.update_xaxes(gridcolor="rgba(128,128,128,0.15)", zeroline=False) + sf.update_yaxes(gridcolor="rgba(128,128,128,0.15)", zeroline=False, tickprefix="$") + for i, (lbl, sdf) in enumerate(eff_dfs_filtered.items()): if "close_time" not in sdf.columns or "net_profit" not in sdf.columns: continue + color = COLORS[i % len(COLORS)] sdf_s = sdf.sort_values("close_time") eq = deposit + sdf_s["net_profit"].cumsum() eq_s = _smooth(eq.reset_index(drop=True), sc_smooth) sf.add_trace(go.Scatter( x=sdf_s["close_time"].values, y=eq_s, name=lbl, mode="lines", - line=dict(color=COLORS[i % len(COLORS)], width=1.5), + line=dict(color=color, width=1.5), hovertemplate=f"{lbl}
%{{x|%d %b %Y}}: $%{{y:,.2f}}", )) - sf.update_layout( - hovermode="x unified", - hoverlabel=dict(namelength=-1, font=dict(size=12)), - ) + + # Stagnation band per strategy in matching colour + if show_st_stag: + eq_ts = sdf_s[["close_time","net_profit"]].dropna().copy() + eq_ts["cum"] = deposit + eq_ts["net_profit"].cumsum() + if not eq_ts.empty: + peak = float(eq_ts["cum"].iloc[0]) + stag_start = eq_ts["close_time"].iloc[0] + max_days = 0 + best_s = stag_start + best_e = stag_start + for _, r in eq_ts.iterrows(): + if float(r["cum"]) > peak: + days = (r["close_time"] - stag_start).days + if days > max_days: + max_days = days + best_s = stag_start + best_e = r["close_time"] + peak = float(r["cum"]) + stag_start = r["close_time"] + if max_days > 0: + # Convert hex to rgba with low opacity + hex_c = color.lstrip("#") + if len(hex_c) == 6: + r_c = int(hex_c[0:2], 16) + g_c = int(hex_c[2:4], 16) + b_c = int(hex_c[4:6], 16) + fill_color = f"rgba({r_c},{g_c},{b_c},0.12)" + ann_color = color + else: + fill_color = "rgba(255,160,80,0.12)" + ann_color = color + sf.add_vrect( + x0=best_s, x1=best_e, + fillcolor=fill_color, line_width=1, + line_color=f"rgba({r_c},{g_c},{b_c},0.3)" if len(hex_c)==6 else color, + annotation_text=f"{lbl.split()[0]}… {max_days}d", + annotation_position="top left", + annotation_font_size=9, + annotation_font_color=ann_color, + ) + st.plotly_chart(sf, use_container_width=True) # ═════════════════════════════════════════════════════════════════════════ diff --git a/view_portfolio_master.py b/view_portfolio_master.py index a35fa9a..568a2bc 100644 --- a/view_portfolio_master.py +++ b/view_portfolio_master.py @@ -476,6 +476,7 @@ def _init_state(): "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 @@ -498,16 +499,28 @@ def render(): padding:10px 14px;font-size:13px;color:#FFB347;margin:8px 0} """, unsafe_allow_html=True) - st.markdown('

🏆 Portfolio Master

', unsafe_allow_html=True) - st.markdown('

Automated portfolio construction — composite scoring, greedy & Monte Carlo search

', - unsafe_allow_html=True) + _tc1, _tc2 = st.columns([8, 1]) + with _tc1: + st.markdown('

🏆 Portfolio Master

', unsafe_allow_html=True) + st.markdown('

Automated portfolio construction — composite scoring, greedy & Monte Carlo search

', + unsafe_allow_html=True) + with _tc2: + st.markdown("
", 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="pm_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 @@ -568,7 +581,8 @@ def render(): st.markdown('
Composite Score Weights
', unsafe_allow_html=True) st.caption("Weights are normalised automatically — they don't need to sum to 1.") - wc1, wc2, wc3 = st.columns(3) + 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", @@ -579,6 +593,28 @@ def render(): 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""" +
+Ret/DD — Net profit ÷ max drawdown. Primary return efficiency metric. Most important for risk-adjusted performance.
+Stability (R²) — How straight the equity curve is. High R² means consistent gains without large swings.
+Stagnation ↓ — Time spent below a previous equity high, as % of total period. Lower = better; score is inverted.
+Win Rate — Percentage of trades that are profitable. Higher win rate reduces psychological drawdown pressure.
+Growth Quality — Combines equity curve slope with R². Rewards portfolios that rise steadily, not just flat and stable.
+Diversity Bonus — Rewards combinations trading different symbols and/or different hours of the day. +
""", 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, @@ -901,14 +937,37 @@ def render(): # ── Summary table ───────────────────────────────────────────────── st.markdown(f"##### Top {len(results)} Portfolios") - st.markdown(""" -
-Score — Composite ranking (0–1000). Higher is better. Weighted blend of the metrics below based on your sliders.
-Stability — How straight the equity curve is (0–100). 100 = perfectly straight rising line. Computed as R² of linear regression on the equity curve.
-Growth Quality — Combines curve straightness with upward slope. Rewards portfolios that rise consistently, not just ones that are flat and stable.
-Diversity — How different the strategies are from each other (0–100), based on symbol variety and trading session overlap. 100 = completely different symbols and hours.
-Avg Corr — Average pairwise correlation of daily P&L across all strategy pairs. Lower is better — strategies that don't move together reduce portfolio drawdown.
-Avg Cond Corr — Same correlation computed only on days when the portfolio is in drawdown. Strategies that decorrelate during losses are more valuable than those that only decorrelate on good days. + 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'{good}  |  ' + + f'{warn}  |  ' + + f'below = poor' + ) + st.markdown(f""" +
+Score — Composite ranking (0–1000). Higher is better. Weighted blend of the metrics below based on your sliders. +  ≥700 = strong  |  400–700 = average  |  <400 = weak
+Ret/DD — Net profit divided by max drawdown. Measures return efficiency per unit of risk. +  ≥5 = strong  |  2–5 = average  |  <2 = weak
+Stability — How straight the equity curve is (0–100). 100 = perfectly straight rising line. R² of linear regression on the equity curve. +  ≥70 = strong  |  40–70 = average  |  <40 = weak
+Growth Quality — Combines curve straightness with upward slope. Rewards portfolios that rise consistently, not just ones that are flat and stable. +  ≥50 = strong  |  20–50 = average  |  <20 = weak
+Diversity — How different the strategies are from each other (0–100), based on symbol variety and trading session overlap. 100 = completely different. +  ≥60 = strong  |  30–60 = average  |  <30 = low diversity
+Avg Corr — Average pairwise correlation of daily P&L. Lower is better — strategies that don't move together reduce portfolio drawdown. +  ≤0.20 = low (good)  |  0.20–0.50 = moderate  |  >0.50 = high (bad)
+Avg Cond Corr — Same correlation computed only on drawdown days. Strategies that decorrelate during losses are more valuable. +  ≤0.20 = low (good)  |  0.20–0.50 = moderate  |  >0.50 = high (bad)
""", unsafe_allow_html=True) @@ -958,6 +1017,20 @@ def render(): 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 []) @@ -965,6 +1038,20 @@ def render(): 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)