""" Performance analytics page — strategy comparison and win/loss analysis. Shows strategy scatter plot with themed Plotly charts, per-strategy breakdown table, win rate distribution, and full recommendation history. """ import numpy as np import pandas as pd import plotly.express as px import plotly.graph_objects as go import streamlit as st from tradingagents.ui.theme import COLORS, get_plotly_template, page_header, pnl_color from tradingagents.ui.utils import ( load_performance_database, load_statistics, load_strategy_metrics, ) def render() -> None: """Render the performance analytics page.""" st.markdown( page_header("Performance", "Strategy analytics & win/loss breakdown"), unsafe_allow_html=True, ) strategy_metrics = load_strategy_metrics() stats = load_statistics() if not strategy_metrics: st.warning( "No performance data available yet. Run the discovery pipeline and track outcomes." ) return template = get_plotly_template() df = pd.DataFrame(strategy_metrics) # ---- Summary KPIs ---- total_trades = df["Count"].sum() # Weighted averages only over strategies that have evaluated data (non-NaN) eval_df = df.dropna(subset=["Win Rate", "Avg Return"]) eval_trades = eval_df["Count"].sum() avg_wr = (eval_df["Win Rate"] * eval_df["Count"]).sum() / eval_trades if eval_trades > 0 else 0 avg_ret = ( (eval_df["Avg Return"] * eval_df["Count"]).sum() / eval_trades if eval_trades > 0 else 0 ) n_strategies = len(df) cols = st.columns(4) summaries = [ ("Total Trades", str(int(total_trades))), ("Weighted Win Rate", f"{avg_wr:.1f}%"), ("Weighted Avg Return", f"{avg_ret:+.2f}%"), ("Active Strategies", str(n_strategies)), ] for col, (label, val) in zip(cols, summaries): with col: st.markdown( f"""
{label}
{val}
""", unsafe_allow_html=True, ) st.markdown("
", unsafe_allow_html=True) # ---- Two-column: scatter + bar chart ---- left_col, right_col = st.columns(2) with left_col: st.markdown( '
Win Rate vs Return // scatter
', unsafe_allow_html=True, ) fig = px.scatter( df, x="Win Rate", y="Avg Return", size="Count", color="Win Rate", hover_name="Strategy", hover_data={"Win Rate": ":.1f", "Avg Return": ":.2f", "Count": True}, labels={"Win Rate": "Win Rate (%)", "Avg Return": "Avg Return (%)"}, color_continuous_scale=[ [0, COLORS["red"]], [0.5, COLORS["amber"]], [1.0, COLORS["green"]], ], size_max=45, ) fig.add_hline(y=0, line_dash="dot", line_color=COLORS["text_muted"], opacity=0.4) fig.add_vline(x=50, line_dash="dot", line_color=COLORS["text_muted"], opacity=0.4) fig.update_layout( **template, height=400, showlegend=False, coloraxis_showscale=False, ) st.plotly_chart(fig, width="stretch") with right_col: st.markdown( '
Win Rate by Strategy // bar
', unsafe_allow_html=True, ) df_bar = df.dropna(subset=["Win Rate"]).sort_values("Win Rate", ascending=True) colors = [COLORS["green"] if wr >= 50 else COLORS["red"] for wr in df_bar["Win Rate"]] fig_bar = go.Figure( go.Bar( x=df_bar["Win Rate"], y=df_bar["Strategy"], orientation="h", marker_color=colors, text=[f"{wr:.0f}%" for wr in df_bar["Win Rate"]], textposition="auto", textfont=dict(family="JetBrains Mono", size=11, color=COLORS["text_primary"]), ) ) fig_bar.add_vline(x=50, line_dash="dot", line_color=COLORS["text_muted"], opacity=0.5) fig_bar.update_layout( **template, height=400, xaxis_title="Win Rate (%)", yaxis_title="", ) fig_bar.update_yaxes(tickfont=dict(family="JetBrains Mono", size=11)) st.plotly_chart(fig_bar, width="stretch") # ---- Strategy breakdown table ---- st.markdown("
", unsafe_allow_html=True) st.markdown( '
Detailed Breakdown // table
', unsafe_allow_html=True, ) display_df = df.copy() display_df = display_df.sort_values("Win Rate", ascending=False) display_df["Count"] = display_df["Count"].astype(int) st.dataframe( display_df, width="stretch", hide_index=True, column_config={ "Win Rate": st.column_config.NumberColumn(format="%.1f%%"), "Avg Return": st.column_config.NumberColumn(format="%+.2f%%"), "Count": st.column_config.NumberColumn(format="%d"), }, ) # ---- Per-strategy stats from statistics.json ---- if stats and stats.get("by_strategy"): st.markdown("
", unsafe_allow_html=True) st.markdown( '
Time-Period Breakdown // 1d / 7d / 30d
', unsafe_allow_html=True, ) by_strat = stats["by_strategy"] rows = [] for strat_name, data in by_strat.items(): rows.append( { "Strategy": strat_name, "Count": data.get("count", 0), "Win Rate 1d": data.get("win_rate_1d") if "win_rate_1d" in data else None, "Avg Ret 1d": data.get("avg_return_1d") if "avg_return_1d" in data else None, "W/L 1d": ( f"{data.get('wins_1d', 0)}W/{data.get('losses_1d', 0)}L" if data.get("wins_1d", 0) + data.get("losses_1d", 0) > 0 else "—" ), "Win Rate 7d": data.get("win_rate_7d") if "win_rate_7d" in data else None, "Avg Ret 7d": data.get("avg_return_7d") if "avg_return_7d" in data else None, "W/L 7d": ( f"{data.get('wins_7d', 0)}W/{data.get('losses_7d', 0)}L" if data.get("wins_7d", 0) + data.get("losses_7d", 0) > 0 else "—" ), } ) if rows: period_df = pd.DataFrame(rows).sort_values("Count", ascending=False) st.dataframe( period_df, width="stretch", hide_index=True, column_config={ "Count": st.column_config.NumberColumn(format="%d"), "Win Rate 1d": st.column_config.NumberColumn(format="%.1f%%"), "Avg Ret 1d": st.column_config.NumberColumn(format="%+.2f%%"), "Win Rate 7d": st.column_config.NumberColumn(format="%.1f%%"), "Avg Ret 7d": st.column_config.NumberColumn(format="%+.2f%%"), }, ) # ---- Recommendation History ---- _render_recommendation_history(template) # --------------------------------------------------------------------------- # Recommendation history helpers # --------------------------------------------------------------------------- def _return_cell(val) -> str: """Format a return value as a colored HTML span.""" if val is None or (isinstance(val, float) and np.isnan(val)): return ''.format(c=COLORS["text_muted"]) color = pnl_color(val) return f'{val:+.2f}%' def _win_dot(val) -> str: """Green/red dot for win/loss boolean.""" if val is None or (isinstance(val, float) and np.isnan(val)): return "" color = COLORS["green"] if val else COLORS["red"] return f'' def _render_recommendation_history(template: dict) -> None: """Full recommendation history with charts and filterable table.""" recs = load_performance_database() if not recs: return st.markdown("
", unsafe_allow_html=True) st.markdown( '
Recommendation History ' '// all picks
', unsafe_allow_html=True, ) # Build DataFrame hist_df = pd.DataFrame(recs) # Ensure numeric types for col in ["return_1d", "return_7d", "return_30d", "return_pct", "final_score", "confidence"]: if col in hist_df.columns: hist_df[col] = pd.to_numeric(hist_df[col], errors="coerce") # Parse dates if "discovery_date" in hist_df.columns: hist_df["discovery_date"] = pd.to_datetime(hist_df["discovery_date"], errors="coerce") # ---- Filters row ---- filter_cols = st.columns([2, 2, 2, 1]) with filter_cols[0]: strategies = sorted(hist_df["strategy_match"].dropna().unique()) selected_strategies = st.multiselect( "Strategy", strategies, default=[], placeholder="All strategies", ) with filter_cols[1]: dates = hist_df["discovery_date"].dropna().sort_values() min_date = dates.min().date() if len(dates) > 0 else None max_date = dates.max().date() if len(dates) > 0 else None if min_date and max_date: date_range = st.date_input( "Date range", value=(min_date, max_date), min_value=min_date, max_value=max_date, ) else: date_range = None with filter_cols[2]: outcome_filter = st.selectbox( "Outcome (7d)", ["All", "Winners", "Losers", "Pending"], index=0, ) with filter_cols[3]: sort_by = st.selectbox("Sort", ["Date", "Return 1d", "Return 7d", "Score"], index=0) # Apply filters mask = pd.Series(True, index=hist_df.index) if selected_strategies: mask &= hist_df["strategy_match"].isin(selected_strategies) if date_range and len(date_range) == 2: start, end = pd.Timestamp(date_range[0]), pd.Timestamp(date_range[1]) mask &= (hist_df["discovery_date"] >= start) & (hist_df["discovery_date"] <= end) if outcome_filter == "Winners": mask &= hist_df.get("win_7d", pd.Series(dtype=bool)) == True # noqa: E712 elif outcome_filter == "Losers": mask &= hist_df.get("win_7d", pd.Series(dtype=bool)) == False # noqa: E712 elif outcome_filter == "Pending": mask &= hist_df.get("return_7d").isna() if "return_7d" in hist_df.columns else True filtered = hist_df[mask].copy() # Sort sort_map = { "Date": ("discovery_date", False), "Return 1d": ("return_1d", False), "Return 7d": ("return_7d", False), "Score": ("final_score", False), } sort_col, sort_asc = sort_map.get(sort_by, ("discovery_date", False)) if sort_col in filtered.columns: filtered = filtered.sort_values(sort_col, ascending=sort_asc, na_position="last") st.caption(f"Showing {len(filtered)} of {len(hist_df)} recommendations") # ---- Two-column charts ---- if len(filtered) > 0: left_ch, right_ch = st.columns(2) with left_ch: st.markdown( '
Return Distribution ' '// 1d vs 7d
', unsafe_allow_html=True, ) _render_return_distribution(filtered, template) with right_ch: st.markdown( '
Cumulative P/L by Date ' '// equity curve
', unsafe_allow_html=True, ) _render_cumulative_pnl(filtered, template) # ---- Full history table ---- st.markdown("
", unsafe_allow_html=True) st.markdown( '
All Picks ' '// detail table
', unsafe_allow_html=True, ) _render_history_table(filtered) def _render_return_distribution(df: pd.DataFrame, template: dict) -> None: """Box plot comparing 1d vs 7d return distributions.""" ret_data = [] for _, row in df.iterrows(): if pd.notna(row.get("return_1d")): ret_data.append({"Period": "1-Day", "Return (%)": row["return_1d"]}) if pd.notna(row.get("return_7d")): ret_data.append({"Period": "7-Day", "Return (%)": row["return_7d"]}) if not ret_data: st.info("No return data available for the selected filters.") return ret_df = pd.DataFrame(ret_data) fig = go.Figure() for period, color in [("1-Day", COLORS["blue"]), ("7-Day", COLORS["cyan"])]: subset = ret_df[ret_df["Period"] == period]["Return (%)"] if len(subset) == 0: continue fig.add_trace( go.Box( y=subset, name=period, marker_color=color, boxmean=True, jitter=0.3, pointpos=-1.5, boxpoints="outliers", ) ) fig.add_hline(y=0, line_dash="dot", line_color=COLORS["text_muted"], opacity=0.4) fig.update_layout( **template, height=350, showlegend=True, legend=dict(orientation="h", y=1.02, x=0.5, xanchor="center"), yaxis_title="Return (%)", ) st.plotly_chart(fig, width="stretch") def _render_cumulative_pnl(df: pd.DataFrame, template: dict) -> None: """Cumulative average return by discovery date (equity curve style).""" if "discovery_date" not in df.columns: st.info("No date data available.") return # Use 7d return where available, fall back to 1d df_dated = df.dropna(subset=["discovery_date"]).copy() df_dated["best_return"] = df_dated["return_7d"].fillna(df_dated.get("return_1d", 0)) df_dated = df_dated.dropna(subset=["best_return"]) if len(df_dated) == 0: st.info("No return data available for equity curve.") return # Group by date, get mean return per day daily = ( df_dated.groupby("discovery_date")["best_return"] .mean() .reset_index() .sort_values("discovery_date") ) daily.columns = ["Date", "Avg Return"] daily["Cumulative"] = daily["Avg Return"].cumsum() # Color based on cumulative being positive/negative colors = [COLORS["green"] if v >= 0 else COLORS["red"] for v in daily["Cumulative"]] fig = go.Figure() fig.add_trace( go.Scatter( x=daily["Date"], y=daily["Cumulative"], mode="lines+markers", line=dict(color=COLORS["green"], width=2), marker=dict(color=colors, size=7, line=dict(color=COLORS["bg_card"], width=1)), fill="tozeroy", fillcolor="rgba(34, 197, 94, 0.08)", hovertemplate="Date: %{x|%b %d}
Cumulative: %{y:+.2f}%", ) ) fig.add_hline(y=0, line_dash="dot", line_color=COLORS["text_muted"], opacity=0.4) fig.update_layout( **template, height=350, showlegend=False, yaxis_title="Cumulative Avg Return (%)", xaxis_title="", ) st.plotly_chart(fig, width="stretch") def _render_history_table(df: pd.DataFrame) -> None: """Render the full recommendation history as a styled dataframe.""" if len(df) == 0: st.info("No recommendations match the selected filters.") return # Build display dataframe with readable columns display_rows = [] for _, row in df.iterrows(): disc_date = row.get("discovery_date") date_str = disc_date.strftime("%Y-%m-%d") if pd.notna(disc_date) else "—" display_rows.append( { "Date": date_str, "Ticker": row.get("ticker", "—"), "#": int(row["rank"]) if pd.notna(row.get("rank")) else 0, "Strategy": row.get("strategy_match", "—"), "Score": row.get("final_score"), "Conf": int(row["confidence"]) if pd.notna(row.get("confidence")) else None, "Entry $": row.get("entry_price"), "Now $": row.get("current_price"), "Ret 1d %": row.get("return_1d"), "Ret 7d %": row.get("return_7d"), "Ret 30d %": row.get("return_30d") if "return_30d" in row.index else None, "Current %": row.get("return_pct"), "Days": int(row["days_held"]) if pd.notna(row.get("days_held")) else None, "Status": row.get("status", "—"), } ) table_df = pd.DataFrame(display_rows) st.dataframe( table_df, width="stretch", hide_index=True, height=min(len(table_df) * 35 + 38, 600), column_config={ "Date": st.column_config.TextColumn(width="small"), "Ticker": st.column_config.TextColumn(width="small"), "#": st.column_config.NumberColumn(format="%d", width="small"), "Strategy": st.column_config.TextColumn(width="medium"), "Score": st.column_config.NumberColumn(format="%.0f", width="small"), "Conf": st.column_config.NumberColumn(format="%d/10", width="small"), "Entry $": st.column_config.NumberColumn(format="$%.2f"), "Now $": st.column_config.NumberColumn(format="$%.2f"), "Ret 1d %": st.column_config.NumberColumn(format="%+.2f%%"), "Ret 7d %": st.column_config.NumberColumn(format="%+.2f%%"), "Ret 30d %": st.column_config.NumberColumn(format="%+.2f%%"), "Current %": st.column_config.NumberColumn(format="%+.2f%%"), "Days": st.column_config.NumberColumn(format="%d"), "Status": st.column_config.TextColumn(width="small"), }, )