2026-04-13 15:46:52 +10:00
"""
view_portfolio_master.py — Portfolio Master
2026-04-13 17:25:13 +10:00
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
2026-04-13 15:46:52 +10:00
"""
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
2026-04-13 17:25:13 +10:00
import io , importlib , sys , os , itertools , random , time
2026-04-13 15:46:52 +10:00
from datetime import timedelta
# ─────────────────────────────────────────────────────────────────────────────
2026-04-13 17:25:13 +10:00
# Parser
2026-04-13 15:46:52 +10:00
# ─────────────────────────────────────────────────────────────────────────────
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 :
2026-04-15 21:35:19 +10:00
df = df . copy () # ensure we never mutate the original
2026-04-13 15:46:52 +10:00
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
# ─────────────────────────────────────────────────────────────────────────────
2026-04-13 17:25:13 +10:00
# Full per-strategy statistics
2026-04-13 15:46:52 +10:00
# ─────────────────────────────────────────────────────────────────────────────
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
2026-04-13 17:25:13 +10:00
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 )
2026-04-13 15:46:52 +10:00
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
2026-04-13 17:25:13 +10:00
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
2026-04-13 15:46:52 +10:00
if "close_time" in df . columns and "open_time" in df . columns :
2026-04-13 17:25:13 +10:00
vc = df [ "close_time" ] . dropna (); vo = df [ "open_time" ] . dropna ()
2026-04-13 15:46:52 +10:00
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 :
2026-04-13 17:25:13 +10:00
s [ "Annual Profit ($)" ] = s [ "Annual Profit (%)" ] = 0.0
2026-04-13 15:46:52 +10:00
else :
2026-04-13 17:25:13 +10:00
s [ "Annual Profit ($)" ] = s [ "Annual Profit (%)" ] = 0.0
2026-04-13 15:46:52 +10:00
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
2026-04-13 17:25:13 +10:00
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
2026-04-13 15:46:52 +10:00
for _ , r in dly . iterrows ():
2026-04-13 17:25:13 +10:00
if float ( r [ "cum" ]) > peak : peak = float ( r [ "cum" ]); stag_start = r [ "date" ]
else : max_stag = max ( max_stag , ( r [ "date" ] - stag_start ) . days )
2026-04-13 15:46:52 +10:00
s [ "Stagnation (days)" ] = max_stag
s [ "Stagnation (%)" ] = round ( max_stag / total_days * 100 , 2 )
else :
2026-04-13 17:25:13 +10:00
s [ "Stagnation (days)" ] = 0 ; s [ "Stagnation (%)" ] = 0.0
2026-04-13 15:46:52 +10:00
else :
2026-04-13 17:25:13 +10:00
s [ "Stagnation (days)" ] = 0 ; s [ "Stagnation (%)" ] = 0.0
2026-04-13 15:46:52 +10:00
2026-04-13 17:25:13 +10:00
# Stability (R²) and Growth Quality (slope × R²)
2026-04-13 15:46:52 +10:00
if len ( eq ) > 2 :
x = np . arange ( len ( eq ))
slope , intercept , r , p , se = scipy_stats . linregress ( x , eq . values )
2026-04-13 17:25:13 +10:00
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
2026-04-13 15:46:52 +10:00
else :
2026-04-13 17:25:13 +10:00
s [ "Stability" ] = 0 ; s [ "Growth Quality" ] = 0
2026-04-13 15:46:52 +10:00
return s
# ─────────────────────────────────────────────────────────────────────────────
2026-04-13 17:25:13 +10:00
# Daily P&L and correlation helpers
2026-04-13 15:46:52 +10:00
# ─────────────────────────────────────────────────────────────────────────────
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 :
2026-04-13 17:25:13 +10:00
series = { lbl : _daily_pnl ( df ) for lbl , df in dfs . items ()}
2026-04-13 15:46:52 +10:00
aligned = pd . DataFrame ( series ) . fillna ( 0 )
return aligned . corr ()
2026-04-13 17:25:13 +10:00
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 ()
2026-04-13 15:46:52 +10:00
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
2026-04-13 17:25:13 +10:00
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 ))
2026-04-13 15:46:52 +10:00
# ─────────────────────────────────────────────────────────────────────────────
2026-04-13 17:25:13 +10:00
# Portfolio evaluation (single combo)
2026-04-13 15:46:52 +10:00
# ─────────────────────────────────────────────────────────────────────────────
2026-04-13 17:25:13 +10:00
def _evaluate_combo ( members : list , dfs : dict , deposit : float ,
weights : dict , corr_matrix : pd . DataFrame ,
cond_corr_matrix : pd . DataFrame ) -> dict :
2026-04-13 15:46:52 +10:00
frames = [ dfs [ m ] . copy () for m in members if m in dfs ]
2026-04-13 17:25:13 +10:00
if not frames : return {}
2026-04-13 15:46:52 +10:00
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 )
2026-04-13 17:25:13 +10:00
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 )
2026-04-13 15:46:52 +10:00
return {
2026-04-13 17:25:13 +10:00
"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 ,
2026-04-13 15:46:52 +10:00
}
2026-04-13 17:25:13 +10:00
# ─────────────────────────────────────────────────────────────────────────────
# 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
2026-04-13 15:46:52 +10:00
# ─────────────────────────────────────────────────────────────────────────────
# Session state
# ─────────────────────────────────────────────────────────────────────────────
def _init_state ():
for k , v in {
2026-04-13 17:25:13 +10:00
"pm_files" : {},
"pm_custom_names" : {},
"pm_results" : [],
2026-04-13 15:46:52 +10:00
"pm_deposit" : 10000.0 ,
2026-04-13 17:25:13 +10:00
"pm_running" : False ,
"pm_cancel" : False ,
"pm_thread_results" : None ,
"pm_progress_q" : None ,
2026-04-17 09:42:08 +10:00
"pm_uploader_key" : 0 ,
2026-04-13 15:46:52 +10:00
} . 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}
2026-04-13 17:25:13 +10:00
.warn-box{background:#2A1A00;border:1px solid #7A4A00;border-radius:6px;
padding:10px 14px;font-size:13px;color:#FFB347;margin:8px 0}
2026-04-13 15:46:52 +10:00
</style>""" , unsafe_allow_html = True )
2026-04-17 09:42:08 +10:00
_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 ()
2026-04-13 15:46:52 +10:00
# ── 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 (
2026-04-17 09:42:08 +10:00
"Select files" , type = None , accept_multiple_files = True ,
key = f "pm_uploader_ { st . session_state . pm_uploader_key } " ,
2026-04-13 15:46:52 +10:00
)
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 :
2026-04-15 21:35:19 +10:00
df = _normalise ( df . copy (), stem )
2026-04-13 15:46:52 +10:00
st . session_state . pm_files [ stem ] = df
st . success ( f "✅ ** { stem } ** — { len ( df ) : , } trades" )
if st . session_state . pm_files :
2026-04-15 21:35:19 +10:00
# 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 ()
2026-04-13 15:46:52 +10:00
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 ─────────────────────────────────────────────────────────────────
2026-04-13 17:25:13 +10:00
tab_config , tab_strategies , tab_results = st . tabs ([
"⚙️ Configure & Run" , "📊 Strategy Stats" , "🏆 Results" ,
2026-04-13 15:46:52 +10:00
])
# ═════════════════════════════════════════════════════════════════════════
# CONFIGURE & RUN
# ═════════════════════════════════════════════════════════════════════════
with tab_config :
2026-04-13 17:25:13 +10:00
# ── 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." )
2026-04-17 09:42:08 +10:00
wc1 , wc2 , wc3 , wc4 = st . columns ([ 2 , 2 , 2 , 3 ])
2026-04-13 17:25:13 +10:00
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" )
2026-04-17 09:42:08 +10:00
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 )
2026-04-13 17:25:13 +10:00
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 ────────────────────────────────────────────────────
2026-04-13 15:46:52 +10:00
st . markdown ( '<div class="sh">Portfolio Size</div>' , unsafe_allow_html = True )
sz1 , sz2 , sz3 = st . columns ( 3 )
2026-04-13 17:25:13 +10:00
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." ,
)
2026-04-13 15:46:52 +10:00
2026-04-13 17:25:13 +10:00
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 ───────────────────────────────────────────────────────
2026-04-13 15:46:52 +10:00
st . markdown ( '<div class="sh">Correlation Filter</div>' , unsafe_allow_html = True )
2026-04-13 17:25:13 +10:00
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." )
2026-04-13 15:46:52 +10:00
corr_limit = st . slider ( "Max allowed pairwise correlation" ,
2026-04-13 17:25:13 +10:00
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." )
2026-04-13 15:46:52 +10:00
2026-04-13 17:25:13 +10:00
# ── Date range ────────────────────────────────────────────────────────
2026-04-13 15:46:52 +10:00
st . markdown ( '<div class="sh">Date Range Filter</div>' , unsafe_allow_html = True )
2026-04-13 17:25:13 +10:00
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
2026-04-13 15:46:52 +10:00
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 )
2026-04-13 17:25:13 +10:00
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" )
2026-04-13 15:46:52 +10:00
date_from , date_to = date_sel
2026-04-13 17:25:13 +10:00
# ── Run ───────────────────────────────────────────────────────────────
2026-04-13 15:46:52 +10:00
st . markdown ( "---" )
2026-04-13 17:25:13 +10:00
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 ()
2026-04-13 15:46:52 +10:00
if run_btn :
2026-04-13 17:25:13 +10:00
if len ( sel_labels ) < int ( min_strats ):
st . error ( f "Need at least { int ( min_strats ) } strategies selected." )
2026-04-13 15:46:52 +10:00
else :
2026-04-13 17:25:13 +10:00
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 ()
2026-04-13 15:46:52 +10:00
# ═════════════════════════════════════════════════════════════════════════
2026-04-13 17:25:13 +10:00
# STRATEGY STATS
2026-04-13 15:46:52 +10:00
# ═════════════════════════════════════════════════════════════════════════
with tab_strategies :
st . markdown ( "##### Individual Strategy Statistics" )
2026-04-13 17:25:13 +10:00
st . caption ( "Edit Strategy Name to assign custom names — these carry through to Results." )
2026-04-13 15:46:52 +10:00
2026-04-13 17:25:13 +10:00
dep_s = st . session_state . pm_deposit
rows = []
2026-04-13 15:46:52 +10:00
for i , label in enumerate ( labels ):
custom = st . session_state . pm_custom_names . get ( label , "" )
2026-04-13 17:25:13 +10:00
row = _full_stats ( strategy_dfs [ label ], dep_s , i + 1 , custom )
if row : rows . append ( row )
2026-04-13 15:46:52 +10:00
if rows :
2026-04-13 17:25:13 +10:00
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" ]
2026-04-13 15:46:52 +10:00
stats_df = pd . DataFrame ( rows )
2026-04-13 17:25:13 +10:00
stats_df = stats_df [[ c for c in col_order if c in stats_df . columns ]]
2026-04-13 15:46:52 +10:00
edited = st . data_editor (
2026-04-13 17:25:13 +10:00
stats_df , use_container_width = True , hide_index = True ,
2026-04-13 15:46:52 +10:00
column_config = {
2026-04-13 17:25:13 +10:00
"#" : 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." ),
2026-04-13 15:46:52 +10:00
},
key = "pm_stats_editor" ,
)
for _ , row in edited . iterrows ():
2026-04-13 17:25:13 +10:00
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 )
2026-04-13 15:46:52 +10:00
2026-04-13 17:25:13 +10:00
# Overall correlation heatmap
2026-04-13 15:46:52 +10:00
if len ( labels ) > 1 :
2026-04-13 17:25:13 +10:00
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 ) } " )
2026-04-13 15:46:52 +10:00
# ═════════════════════════════════════════════════════════════════════════
# 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 )
2026-04-13 17:25:13 +10:00
# ── Summary table ─────────────────────────────────────────────────
st . markdown ( f "##### Top { len ( results ) } Portfolios" )
2026-04-17 09:42:08 +10:00
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>
2026-04-13 17:25:13 +10:00
</div>
""" , unsafe_allow_html = True )
2026-04-13 15:46:52 +10:00
col_order = [
2026-04-13 17:25:13 +10:00
"Rank" , "Score" , "Strategies" , "# Strategies" ,
"Avg Corr" , "Avg Cond Corr" , "Diversity" ,
2026-04-13 15:46:52 +10:00
"# Trades" , "Net Profit ($)" , "Max DD ($)" , "Max DD (%)" ,
"Annual Profit ($)" , "Annual Profit (%)" ,
"Avg Win ($)" , "Avg Loss ($)" , "% Wins" ,
2026-04-13 17:25:13 +10:00
"Commissions ($)" ,
2026-04-13 15:46:52 +10:00
"Stagnation (%)" , "Stagnation (days)" , "Profit Factor" ,
2026-04-13 17:25:13 +10:00
"Ret/DD" , "Stability" , "Growth Quality" ,
2026-04-13 15:46:52 +10:00
]
rows_r = []
for i , r in enumerate ( results ):
member_names = " + " . join ( _name ( m ) for m in r [ "members" ])
fs = r . get ( "full_stats" , {})
2026-04-13 17:25:13 +10:00
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 :]:
2026-04-13 15:46:52 +10:00
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 ):
2026-04-13 17:25:13 +10:00
if not isinstance ( val ,( int , float )): return ""
2026-04-13 15:46:52 +10:00
return "color:#34C27A" if val > low else "color:#E05555" if val < low else ""
2026-04-13 17:25:13 +10:00
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 }
2026-04-13 15:46:52 +10:00
2026-04-13 17:25:13 +10:00
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 ]
2026-04-13 15:46:52 +10:00
2026-04-17 09:42:08 +10:00
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"
2026-04-13 15:46:52 +10:00
styled = (
2026-04-13 17:25:13 +10:00
res_df . style . format ( fmt )
. map ( _cc , subset = pos_cols if pos_cols else [])
2026-04-13 15:46:52 +10:00
. 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 [])
2026-04-17 09:42:08 +10:00
. 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 [])
2026-04-13 15:46:52 +10:00
)
st . dataframe ( styled , use_container_width = True , hide_index = True )
2026-04-13 17:25:13 +10:00
buf = io . StringIO (); res_df . to_csv ( buf , index = False )
2026-04-13 15:46:52 +10:00
st . download_button ( "⬇️ Export Results CSV" , buf . getvalue (),
file_name = "portfolio_master_results.csv" , mime = "text/csv" )
2026-04-13 17:25:13 +10:00
# ── Detail expanders ──────────────────────────────────────────────
2026-04-13 15:46:52 +10:00
st . markdown ( "##### Portfolio Detail" )
2026-04-13 17:25:13 +10:00
show_top = st . slider ( "Show detail for top N" , 1 , min ( 10 , len ( results )),
2026-04-13 15:46:52 +10:00
min ( 5 , len ( results )), key = "pm_show_top" )
2026-04-13 17:25:13 +10:00
2026-04-13 15:46:52 +10:00
for i , r in enumerate ( results [: show_top ]):
member_names = " + " . join ( _name ( m ) for m in r [ "members" ])
2026-04-13 17:25:13 +10:00
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 )
2026-04-13 15:46:52 +10:00
# Mini equity chart
2026-04-13 17:25:13 +10:00
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
2026-04-13 15:46:52 +10:00
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 ),
2026-04-13 17:25:13 +10:00
"Growth Quality" : s . get ( "Growth Quality" , 0 ),
2026-04-13 15:46:52 +10:00
})
if m_rows :
mdf = pd . DataFrame ( m_rows )
st . dataframe (
2026-04-13 17:25:13 +10:00
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 )