diff --git a/view_portfolio_builder.py b/view_portfolio_builder.py index 672aad5..b2103fe 100644 --- a/view_portfolio_builder.py +++ b/view_portfolio_builder.py @@ -217,6 +217,88 @@ def _calc_stats(df: pd.DataFrame, deposit: float) -> dict: return s +# ───────────────────────────────────────────────────────────────────────────── +# Portfolio comparison — correlation across bucketed P&L series +# ───────────────────────────────────────────────────────────────────────────── +def _bucket_pnl(df: pd.DataFrame, mode: str, time_col: str = "close_time") -> pd.Series: + """ + Aggregate net_profit into a time-bucketed series for correlation analysis. + + mode: + - "daily" — sum P&L per calendar day + - "weekly" — sum P&L per ISO week + - "monthly" — sum P&L per calendar month + - "close_hour" — sum P&L per hour bucket of close_time (1-hour windows) + - "open_hour" — sum P&L per hour bucket of open_time + - "trade" — one row per trade keyed by close_time (no aggregation) + + Returns a Series indexed by the bucket key with summed net_profit. + """ + if df.empty or "net_profit" not in df.columns: + return pd.Series(dtype=float) + + if mode == "open_hour": + time_col = "open_time" + elif mode == "close_hour": + time_col = "close_time" + + if time_col not in df.columns: + return pd.Series(dtype=float) + + times = pd.to_datetime(df[time_col], errors="coerce") + profits = pd.to_numeric(df["net_profit"], errors="coerce").fillna(0) + valid = times.notna() + times, profits = times[valid], profits[valid] + if times.empty: + return pd.Series(dtype=float) + + if mode == "daily": + key = times.dt.normalize() + elif mode == "weekly": + key = times.dt.to_period("W").apply(lambda p: p.start_time) + elif mode == "monthly": + key = times.dt.to_period("M").apply(lambda p: p.start_time) + elif mode in ("close_hour", "open_hour"): + # Truncate to the hour — trades closing/opening within the same 1-hour + # window get bucketed together. + key = times.dt.floor("h") + elif mode == "trade": + # Each trade is its own row, indexed by exact close time + return pd.Series(profits.values, index=times.values).sort_index() + else: + key = times.dt.normalize() + + return profits.groupby(key).sum().sort_index() + + +def _portfolio_pnl_series(portfolio_name: str, portfolios: dict, eff_dfs: dict, + mode: str) -> pd.Series: + """Build a bucketed P&L series for a named portfolio (or 'Portfolio (all)').""" + if portfolio_name == "Portfolio (all)": + members = list(eff_dfs.keys()) + else: + members = portfolios.get(portfolio_name, []) + member_dfs = [eff_dfs[m] for m in members if m in eff_dfs] + if not member_dfs: + return pd.Series(dtype=float) + combined = pd.concat(member_dfs, ignore_index=True) + return _bucket_pnl(combined, mode) + + +def _correlation_matrix(series_dict: dict, method: str = "pearson") -> pd.DataFrame: + """ + Compute correlation matrix across portfolio P&L series. + Series are aligned on the union of bucket keys; missing buckets fill 0 + (i.e. a portfolio that didn't trade in that bucket contributed $0 P&L). + """ + if not series_dict: + return pd.DataFrame() + aligned = pd.concat(series_dict, axis=1).fillna(0) + if aligned.shape[1] < 2: + return pd.DataFrame() + return aligned.corr(method=method) + + # ───────────────────────────────────────────────────────────────────────────── # Chart helpers # ───────────────────────────────────────────────────────────────────────────── @@ -287,12 +369,7 @@ def _build_equity_chart( _global_max = min(_global_max, pd.Timestamp(date_to) + pd.Timedelta(days=1)) def _plot_series(times: pd.Series, profits: pd.Series, - name: str, color: str, width: float, - contribute_to_aggregates: bool = True): - """Plot one equity line. Only contributes to row 2 (drawdown) and row 3 - (daily P&L) when contribute_to_aggregates=True. This prevents - double-counting in modes that show both a combined line and individual - strategy lines for the same underlying trades.""" + name: str, color: str, width: float): times = times.reset_index(drop=True) profits = profits.reset_index(drop=True) eq_full = deposit + profits.cumsum() @@ -312,17 +389,16 @@ def _build_equity_chart( if eq_f.empty: return - if contribute_to_aggregates: - all_dd_frames.append(pd.DataFrame({"t": times_f, "dd": dd_f})) + all_dd_frames.append(pd.DataFrame({"t": times_f, "dd": dd_f})) - # Daily P&L — sum net_profit per day within the filtered window - profits_f = profits[mask].reset_index(drop=True) - daily_pnl = (pd.DataFrame({"t": times_f, "pnl": profits_f}) - .assign(date=lambda x: x["t"].dt.normalize()) - .groupby("date")["pnl"].sum() - .reset_index() - .rename(columns={"date": "t"})) - all_daily_frames.append(daily_pnl) + # Daily P&L — sum net_profit per day within the filtered window + profits_f = profits[mask].reset_index(drop=True) + daily_pnl = (pd.DataFrame({"t": times_f, "pnl": profits_f}) + .assign(date=lambda x: x["t"].dt.normalize()) + .groupby("date")["pnl"].sum() + .reset_index() + .rename(columns={"date": "t"})) + all_daily_frames.append(daily_pnl) eq_disp = _smooth(eq_f, smooth_window) @@ -344,14 +420,12 @@ def _build_equity_chart( if chart_view == "Portfolio": # Show the selected portfolio / all as one combined line if not df.empty and "close_time" in df.columns and "net_profit" in df.columns: - _plot_series(df["close_time"], df["net_profit"], active_label, COLORS[0], 2.0, - contribute_to_aggregates=True) + _plot_series(df["close_time"], df["net_profit"], active_label, COLORS[0], 2.0) if show_stagnation: _add_stagnation_vrect(fig, df, deposit) elif chart_view == "Portfolio+Individual": - # Combined line + each member underneath. Only the combined line - # contributes to drawdown / daily-P&L subplots so we don't double-count. + # Combined line + each member underneath if not df.empty and "close_time" in df.columns and "net_profit" in df.columns: import os as _os, re as _re2 _cfg = _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), ".streamlit", "config.toml") @@ -361,13 +435,9 @@ def _build_equity_chart( if _m: _light = _m.group(1) == "light" _portfolio_color = "#1a3a5c" if _light else "#FFFFFF" _plot_series(df["close_time"], df["net_profit"], f"{active_label} (combined)", - _portfolio_color, 2.5, contribute_to_aggregates=True) + _portfolio_color, 2.5) if show_stagnation: _add_stagnation_vrect(fig, df, deposit) - # Dedupe individual lines: skip any whose trade fingerprint matches another - # already-plotted line (prevents EA/Strategy duplicates when each EA file - # contains only one strategy comment). - _seen_fingerprints = set() for i, label in enumerate(selected_strategies): if label not in eff_dfs: continue @@ -375,29 +445,10 @@ def _build_equity_chart( if "close_time" not in sdf.columns or "net_profit" not in sdf.columns: continue sdf_s = sdf.sort_values("close_time") - # Fingerprint: count + sum of net_profit + first/last close_time. - # Two series with the same fingerprint are the same trades. - try: - fp = ( - len(sdf_s), - round(float(sdf_s["net_profit"].sum()), 4), - str(pd.to_datetime(sdf_s["close_time"]).min()), - str(pd.to_datetime(sdf_s["close_time"]).max()), - ) - except Exception: - fp = (label,) - if fp in _seen_fingerprints: - continue - _seen_fingerprints.add(fp) _plot_series(sdf_s["close_time"], sdf_s["net_profit"], - label, COLORS[i % len(COLORS)], 1.2, - contribute_to_aggregates=False) + label, COLORS[i % len(COLORS)], 1.2) else: # Individual - # No combined line; each strategy's trades contribute to aggregates once. - # Dedupe identical trade-sets (e.g. EA == Strategy when each file has - # one strategy comment). - _seen_fingerprints = set() for i, label in enumerate(selected_strategies): if label not in eff_dfs: continue @@ -405,21 +456,8 @@ def _build_equity_chart( if "close_time" not in sdf.columns or "net_profit" not in sdf.columns: continue sdf_s = sdf.sort_values("close_time") - try: - fp = ( - len(sdf_s), - round(float(sdf_s["net_profit"].sum()), 4), - str(pd.to_datetime(sdf_s["close_time"]).min()), - str(pd.to_datetime(sdf_s["close_time"]).max()), - ) - except Exception: - fp = (label,) - if fp in _seen_fingerprints: - continue - _seen_fingerprints.add(fp) _plot_series(sdf_s["close_time"], sdf_s["net_profit"], - label, COLORS[i % len(COLORS)], 1.5, - contribute_to_aggregates=True) + label, COLORS[i % len(COLORS)], 1.5) # Row 2 — cumulative drawdown from peak if all_dd_frames: @@ -578,16 +616,17 @@ def render(): unsafe_allow_html=True) # ── Upload panel ───────────────────────────────────────────────────────── - # [8] Show only htm/html/csv in the help text; type=None + manual filter - # because Streamlit on Windows sometimes chokes on type=["htm"] with st.expander("📂 Upload Strategy Reports", expanded=not bool(st.session_state.pb_uploaded_files)): - st.caption("Accepts: `.htm` · `.html` · `.csv` — other file types are ignored.") + st.caption("Accepts: `.htm` · `.html` · `.csv`") uploaded = st.file_uploader( - "Select HTM or CSV files (PNG and other files in the same folder are ignored)", - type=None, accept_multiple_files=True, key="pb_uploader", + "Select HTM, HTML or CSV files", + type=["htm", "html", "csv"], + accept_multiple_files=True, key="pb_uploader", ) if uploaded: + # Streamlit already filters by type, but keep a defensive check + # in case type filter is ever loosened rejected = [f.name for f in uploaded if not f.name.lower().endswith((".htm",".html",".csv"))] if rejected: @@ -749,9 +788,9 @@ def render(): stats = _calc_stats(ov_df, deposit) # ── Tabs ───────────────────────────────────────────────────────────────── - tab_ov, tab_tr, tab_eq, tab_st, tab_wi, tab_pf = st.tabs([ + tab_ov, tab_tr, tab_eq, tab_st, tab_wi, tab_pf, tab_cmp = st.tabs([ "📋 Overview", "📜 Trades", "📈 Equity Chart", - "📊 Strategies", "🔧 What-If", "🗂 Portfolios", + "📊 Strategies", "🔧 What-If", "🗂 Portfolios", "🔗 Compare", ]) # ═════════════════════════════════════════════════════════════════════════ @@ -1298,4 +1337,222 @@ def render(): mc1.metric("Net Profit", f"${pstat.get('net_profit',0):,.2f}") mc2.metric("Win Rate", f"{pstat.get('win_rate',0):.2f}%") mc3.metric("Profit Factor", f"{pstat.get('profit_factor',0):.2f}") - mc4.metric("Max DD", f"${pstat.get('max_dd',0):,.2f}") \ No newline at end of file + mc4.metric("Max DD", f"${pstat.get('max_dd',0):,.2f}") + + # ═════════════════════════════════════════════════════════════════════════ + # COMPARE — correlation between custom portfolios + # ═════════════════════════════════════════════════════════════════════════ + with tab_cmp: + st.markdown("##### Portfolio Correlation Comparison") + st.caption( + "Compare how the constructed portfolios move relative to each other. " + "Choose a bucketing mode — daily P&L correlation tells you if portfolios " + "are diversified day-to-day; hourly close/open windows reveal whether they " + "tend to enter or exit positions together." + ) + + # Build the list of comparable portfolios — custom ones plus the 'all' aggregate + all_options = [] + if eff_dfs: + all_options.append("Portfolio (all)") + all_options.extend(list(portfolios.keys())) + + if len(all_options) < 2: + st.info("Create at least 2 custom portfolios in the **Portfolios** tab " + "to enable comparison. (Or load strategies + create one portfolio — " + "you can then compare it against the full aggregate.)") + else: + cmp_c1, cmp_c2, cmp_c3 = st.columns([3, 2, 2]) + with cmp_c1: + selected_pfs = st.multiselect( + "Portfolios to compare", + all_options, + default=all_options[:min(4, len(all_options))], + key="pb_cmp_pfs", + ) + with cmp_c2: + bucket_mode = st.selectbox( + "Correlation bucket", + [ + ("daily", "Daily P&L"), + ("close_hour", "Close within 1hr"), + ("open_hour", "Open within 1hr"), + ("weekly", "Weekly P&L"), + ("monthly", "Monthly P&L"), + ], + format_func=lambda x: x[1], + key="pb_cmp_bucket", + ) + bucket_mode = bucket_mode[0] + with cmp_c3: + corr_method = st.selectbox( + "Method", + ["pearson", "spearman", "kendall"], + key="pb_cmp_method", + help="Pearson = linear, Spearman = rank-based (robust to outliers), " + "Kendall = ordinal concordance", + ) + + if len(selected_pfs) < 2: + st.warning("Select at least 2 portfolios to compute correlation.") + else: + # Build bucketed P&L series for each selected portfolio + series_dict = {} + empty_pfs = [] + for pf in selected_pfs: + s = _portfolio_pnl_series(pf, portfolios, eff_dfs, bucket_mode) + if s.empty: + empty_pfs.append(pf) + else: + series_dict[pf] = s + + if empty_pfs: + st.warning( + f"No data for: {', '.join(empty_pfs)} (no trades or missing time column)." + ) + + if len(series_dict) < 2: + st.error("Need at least 2 non-empty portfolios for correlation.") + else: + corr = _correlation_matrix(series_dict, method=corr_method) + + # ── Correlation matrix heatmap ──────────────────────── + st.markdown("**Correlation Matrix**") + + # Custom diverging colorscale: red (high corr = bad for diversification) + # through neutral grey at 0, to green (negative corr = great diversifier) + fig_corr = go.Figure(data=go.Heatmap( + z=corr.values, + x=list(corr.columns), + y=list(corr.index), + colorscale=[ + [0.0, "#2E7D32"], # -1 dark green (great diversifier) + [0.25, "#7ED321"], # -0.5 green + [0.5, "#3A4255"], # 0 neutral grey-blue + [0.75, "#F5A623"], # 0.5 amber + [1.0, "#E63946"], # 1 red (highly correlated) + ], + zmin=-1, zmax=1, + text=[[f"{v:.2f}" for v in row] for row in corr.values], + texttemplate="%{text}", + textfont={"size": 13, "color": "white"}, + hovertemplate="%{y} vs %{x}
r = %{z:.3f}", + colorbar=dict(title="r", thickness=15), + )) + fig_corr.update_layout( + height=max(320, 80 + 50 * len(corr)), + margin=dict(l=10, r=10, t=10, b=10), + paper_bgcolor="rgba(0,0,0,0)", + plot_bgcolor="rgba(0,0,0,0)", + font=dict(color="#CDD6F4"), + xaxis=dict(side="bottom", tickangle=-25), + yaxis=dict(autorange="reversed"), + ) + st.plotly_chart(fig_corr, use_container_width=True) + + # ── Pair summary table ──────────────────────────────── + st.markdown("**Pair-wise summary**") + pairs = [] + cols = list(corr.columns) + for i in range(len(cols)): + for j in range(i + 1, len(cols)): + r = corr.iloc[i, j] + if r >= 0.7: rating = "🔴 Strongly correlated" + elif r >= 0.3: rating = "🟠 Moderately correlated" + elif r >= -0.3: rating = "⚪ Weakly / uncorrelated" + elif r >= -0.7: rating = "🟢 Moderate diversifier" + else: rating = "🟢🟢 Strong diversifier" + # Count overlapping buckets for context + sa, sb = series_dict[cols[i]], series_dict[cols[j]] + overlap = len(sa.index.intersection(sb.index)) + pairs.append({ + "Portfolio A": cols[i], + "Portfolio B": cols[j], + "Correlation": round(float(r), 3), + "Overlap buckets": overlap, + "Assessment": rating, + }) + pair_df = pd.DataFrame(pairs).sort_values( + "Correlation", ascending=False, key=lambda s: s.abs() + ) + st.dataframe(pair_df, use_container_width=True, hide_index=True) + + # ── Combined equity overlay ─────────────────────────── + st.markdown("**Equity Curves Overlay**") + eq_fig = go.Figure() + for idx, pf in enumerate(series_dict.keys()): + s = series_dict[pf].sort_index() + equity = deposit + s.cumsum() + eq_fig.add_trace(go.Scatter( + x=equity.index, y=equity.values, name=pf, + mode="lines", + line=dict(color=COLORS[idx % len(COLORS)], width=2), + hovertemplate=f"{pf}
%{{x|%d %b %Y}}
" + f"$%{{y:,.2f}}", + )) + eq_fig.update_layout( + height=420, + margin=dict(l=10, r=10, t=10, b=10), + paper_bgcolor="rgba(0,0,0,0)", + plot_bgcolor="rgba(0,0,0,0)", + font=dict(color="#CDD6F4"), + xaxis=dict(gridcolor="rgba(255,255,255,0.05)"), + yaxis=dict(gridcolor="rgba(255,255,255,0.05)", + title="Equity ($)"), + legend=dict(orientation="h", y=1.08, x=0), + hovermode="x unified", + ) + st.plotly_chart(eq_fig, use_container_width=True) + + # ── Rolling correlation between top pair ────────────── + if len(series_dict) >= 2: + with st.expander("📈 Rolling correlation (top pair)"): + # Pick the highest |r| pair for the rolling chart + top = max(pairs, key=lambda p: abs(p["Correlation"])) + a, b = top["Portfolio A"], top["Portfolio B"] + sa = series_dict[a] + sb = series_dict[b] + aligned = pd.concat({a: sa, b: sb}, axis=1).fillna(0) + + # Window depends on bucket mode + window_default = { + "daily": 30, "close_hour": 168, "open_hour": 168, + "weekly": 8, "monthly": 6, + }.get(bucket_mode, 30) + window = st.slider( + "Rolling window (buckets)", + min_value=5, + max_value=max(20, min(252, len(aligned))), + value=min(window_default, max(5, len(aligned) // 4)), + key="pb_cmp_roll_win", + ) + if len(aligned) > window: + roll = aligned[a].rolling(window).corr(aligned[b]) + rfig = go.Figure() + rfig.add_trace(go.Scatter( + x=roll.index, y=roll.values, + mode="lines", + line=dict(color="#4C8EF5", width=2), + name=f"{a} vs {b}", + )) + rfig.add_hline(y=0, line_dash="dash", + line_color="rgba(255,255,255,0.3)") + rfig.add_hline(y=0.7, line_dash="dot", + line_color="rgba(230,57,70,0.4)") + rfig.add_hline(y=-0.7, line_dash="dot", + line_color="rgba(46,125,50,0.4)") + rfig.update_layout( + height=320, + margin=dict(l=10, r=10, t=30, b=10), + paper_bgcolor="rgba(0,0,0,0)", + plot_bgcolor="rgba(0,0,0,0)", + font=dict(color="#CDD6F4"), + title=f"{a} vs {b} — rolling {window}-bucket correlation", + yaxis=dict(range=[-1, 1], + gridcolor="rgba(255,255,255,0.05)"), + xaxis=dict(gridcolor="rgba(255,255,255,0.05)"), + ) + st.plotly_chart(rfig, use_container_width=True) + else: + st.info(f"Need at least {window+1} buckets for rolling " + f"correlation — only {len(aligned)} available.") \ No newline at end of file diff --git a/view_portfolio_master.py b/view_portfolio_master.py index 1dd66c9..f677bfd 100644 --- a/view_portfolio_master.py +++ b/view_portfolio_master.py @@ -546,10 +546,13 @@ def render(): 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, + "Select files", + type=["htm", "html", "csv"], + accept_multiple_files=True, key=f"pm_uploader_{st.session_state.pm_uploader_key}", ) if uploaded: + # Streamlit already filters by type, defensive check anyway uploaded = [f for f in uploaded if f.name.lower().endswith((".htm",".html",".csv"))] for f in uploaded: