From e1b426c9b2b3f8dceab22617196e00c6ce3717d3 Mon Sep 17 00:00:00 2001 From: unknown Date: Sat, 18 Apr 2026 12:06:01 +1000 Subject: [PATCH] feat: HTML report generation for trade analysis - Add _generate_html_report() function with self-contained HTML output - Charts built with direct Plotly.newPlot JSON (no pio.to_html conflicts) - Equity curve, drawdown, daily P&L, DOW, hour of day charts - Both view overlays original and edited equity curves - Stats grid with delta arrows in Both mode - Monthly performance table with JS $ / % toggle - Initial balance passed explicitly from session state at download time - Position summary and trade log as collapsible
dropdowns - Daily P&L x-axis labels hidden for readability - Download button at top of Overall mode, filename includes view and timestamp - Move Initial Balance input to filters row so it's set before report renders - Remove duplicate deposit input from monthly table section --- .../view_trade_analysis.cpython-314.pyc | Bin 57025 -> 81681 bytes view_trade_analysis.py | 396 +++++++++++++++++- 2 files changed, 384 insertions(+), 12 deletions(-) diff --git a/__pycache__/view_trade_analysis.cpython-314.pyc b/__pycache__/view_trade_analysis.cpython-314.pyc index 0633bbb952b16d774b8712602fbe97440409455d..6ccd958948251fb913b12998f08a6abd3cd29f86 100644 GIT binary patch delta 36374 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 diff --git a/view_trade_analysis.py b/view_trade_analysis.py index 2f62f25..1d26179 100644 --- a/view_trade_analysis.py +++ b/view_trade_analysis.py @@ -49,6 +49,345 @@ def _normalise_ic(df): return out + +def _generate_html_report(df_plot, stats, fmt, view_sel, + stats_compare=None, df_compare=None, + group_summary=None, date_from=None, date_to=None, + deposit=10000.0): + """Generate a self-contained HTML report of the trade analysis.""" + import pandas as pd + import json + from datetime import datetime + + now = datetime.now().strftime('%Y-%m-%d %H:%M') + title = f"Trade Analysis Report — {view_sel}" + + df_s = df_plot.copy() + df_s['net_profit'] = pd.to_numeric(df_s['net_profit'], errors='coerce').fillna(0) + df_s['close_time'] = pd.to_datetime(df_s['close_time'], errors='coerce') + df_s = df_s.dropna(subset=['close_time']).sort_values('close_time').reset_index(drop=True) + df_s['_cum'] = df_s['net_profit'].cumsum() + df_s['_peak'] = df_s['_cum'].cummax() + df_s['_dd'] = df_s['_cum'] - df_s['_peak'] + df_s['win'] = df_s['net_profit'] > 0 + if 'day_of_week' not in df_s.columns: + df_s['day_of_week'] = df_s['close_time'].dt.day_name() + if 'hour' not in df_s.columns: + df_s['hour'] = df_s['close_time'].dt.hour + + LAYOUT_BASE = { + 'plot_bgcolor': 'rgba(20,20,30,1)', + 'paper_bgcolor': 'rgba(20,20,30,1)', + 'font': {'color': '#ccc', 'family': 'sans-serif'}, + 'legend': {'bgcolor': 'rgba(0,0,0,0)', 'borderwidth': 0}, + 'xaxis': {'gridcolor': 'rgba(128,128,128,0.15)'}, + 'yaxis': {'gridcolor': 'rgba(128,128,128,0.15)', 'tickprefix': '$'}, + } + + def _chart(div_id, traces, layout_extra=None): + layout = {**LAYOUT_BASE, **(layout_extra or {})} + traces_json = json.dumps(traces) + layout_json = json.dumps(layout) + return ( + f'
\n' + f'' + ) + + # Equity + eq_traces = [{ + 'type': 'scatter', 'mode': 'lines', 'name': 'Equity', + 'x': df_s['close_time'].dt.strftime('%Y-%m-%d %H:%M:%S').tolist(), + 'y': df_s['_cum'].round(2).tolist(), + 'line': {'color': '#7c6af7', 'width': 2}, + 'fill': 'tozeroy', 'fillcolor': 'rgba(124,106,247,0.08)', + }] + if df_compare is not None: + dc = df_compare.copy() + dc['net_profit'] = pd.to_numeric(dc['net_profit'], errors='coerce').fillna(0) + dc['close_time'] = pd.to_datetime(dc['close_time'], errors='coerce') + dc = dc.dropna(subset=['close_time']).sort_values('close_time') + dc['_cum'] = dc['net_profit'].cumsum() + eq_traces.append({ + 'type': 'scatter', 'mode': 'lines', 'name': 'Edited', + 'x': dc['close_time'].dt.strftime('%Y-%m-%d %H:%M:%S').tolist(), + 'y': dc['_cum'].round(2).tolist(), + 'line': {'color': '#34C27A', 'width': 2, 'dash': 'dash'}, + }) + eq_html = _chart('eq_chart', eq_traces, {'height': 300, 'title': 'Equity Curve', + 'hovermode': 'x unified', 'margin': {'l':60,'r':20,'t':40,'b':40}}) + + # Drawdown + dd_html = _chart('dd_chart', [{ + 'type': 'scatter', 'mode': 'lines', 'name': 'Drawdown', + 'x': df_s['close_time'].dt.strftime('%Y-%m-%d %H:%M:%S').tolist(), + 'y': df_s['_dd'].round(2).tolist(), + 'line': {'color': '#dc5050', 'width': 1.5}, + 'fill': 'tozeroy', 'fillcolor': 'rgba(220,80,80,0.18)', + }], {'height': 150, 'title': 'Drawdown', 'showlegend': False, + 'margin': {'l':60,'r':20,'t':40,'b':20}, + 'xaxis': {'gridcolor':'rgba(128,128,128,0.15)', 'showticklabels': False}, + 'yaxis': {'gridcolor':'rgba(128,128,128,0.15)', 'tickprefix':'$'}, + 'plot_bgcolor':'rgba(20,20,30,1)', 'paper_bgcolor':'rgba(20,20,30,1)', + 'font':{'color':'#ccc','family':'sans-serif'}}) + + # Daily P&L + daily = df_s.groupby(df_s['close_time'].dt.strftime('%Y-%m-%d'))['net_profit'].sum() + daily_dates = daily.index.tolist() + daily_vals = daily.round(2).tolist() + daily_colors = ['rgba(52,194,122,0.85)' if v >= 0 else 'rgba(220,80,80,0.85)' for v in daily_vals] + daily_html = _chart('daily_chart', [{ + 'type': 'bar', 'name': 'Daily P&L', + 'x': daily_dates, 'y': daily_vals, + 'marker': {'color': daily_colors}, + }], {'height': 160, 'title': 'Daily P&L', 'showlegend': False, + 'bargap': 0.2, 'margin': {'l':60,'r':20,'t':40,'b':40}, + 'xaxis': {'type': 'category', 'gridcolor': 'rgba(128,128,128,0.15)', 'showticklabels': False}, + 'yaxis': {'gridcolor': 'rgba(128,128,128,0.15)', 'tickprefix': '$', + 'zeroline': True, 'zerolinecolor': 'rgba(128,128,128,0.4)'}, + 'plot_bgcolor':'rgba(20,20,30,1)', 'paper_bgcolor':'rgba(20,20,30,1)', + 'font':{'color':'#ccc','family':'sans-serif'}}) + + # DOW + dow_order = ['Monday','Tuesday','Wednesday','Thursday','Friday'] + present_days = [d for d in dow_order if d in df_s['day_of_week'].values] + wins_dow = df_s[df_s['win']].groupby('day_of_week')['net_profit'].sum().reindex(present_days, fill_value=0) + losses_dow = df_s[~df_s['win']].groupby('day_of_week')['net_profit'].sum().reindex(present_days, fill_value=0) + dow_html = _chart('dow_chart', [ + {'type':'bar','name':'Profit','x':present_days,'y':wins_dow.round(2).tolist(), + 'marker':{'color':'rgba(52,194,122,0.85)'}}, + {'type':'bar','name':'Loss', 'x':present_days,'y':losses_dow.round(2).tolist(), + 'marker':{'color':'rgba(220,80,80,0.85)'}}, + ], {'height':280,'title':'P&L by Day of Week','barmode':'relative','bargap':0.3, + 'margin':{'l':60,'r':20,'t':40,'b':40}, + 'xaxis':{'type':'category','gridcolor':'rgba(128,128,128,0.15)'}, + 'yaxis':{'gridcolor':'rgba(128,128,128,0.15)','tickprefix':'$'}, + 'plot_bgcolor':'rgba(20,20,30,1)','paper_bgcolor':'rgba(20,20,30,1)', + 'font':{'color':'#ccc','family':'sans-serif'}, + 'legend':{'bgcolor':'rgba(0,0,0,0)'}}) + + # Hour + all_hours = sorted(df_s['hour'].unique()) + str_hours = [str(h) for h in all_hours] + wins_h = df_s[df_s['win']].groupby('hour')['net_profit'].sum().reindex(all_hours, fill_value=0) + losses_h = df_s[~df_s['win']].groupby('hour')['net_profit'].sum().reindex(all_hours, fill_value=0) + hour_html = _chart('hour_chart', [ + {'type':'bar','name':'Profit','x':str_hours,'y':wins_h.round(2).tolist(), + 'marker':{'color':'rgba(52,194,122,0.85)'}}, + {'type':'bar','name':'Loss', 'x':str_hours,'y':losses_h.round(2).tolist(), + 'marker':{'color':'rgba(220,80,80,0.85)'}}, + ], {'height':280,'title':'P&L by Hour of Day','barmode':'relative','bargap':0.3, + 'margin':{'l':60,'r':20,'t':40,'b':40}, + 'xaxis':{'type':'category','title':'Hour (UTC)','gridcolor':'rgba(128,128,128,0.15)'}, + 'yaxis':{'gridcolor':'rgba(128,128,128,0.15)','tickprefix':'$'}, + 'plot_bgcolor':'rgba(20,20,30,1)','paper_bgcolor':'rgba(20,20,30,1)', + 'font':{'color':'#ccc','family':'sans-serif'}, + 'legend':{'bgcolor':'rgba(0,0,0,0)'}}) + + # ── Stats table ─────────────────────────────────────────────────────────── + def _delta_html(key, fmt='$', inverse=False): + if stats_compare is None or key not in stats_compare: return '' + diff = stats_compare[key] - stats[key] + if abs(diff) < 0.001: return '' + better = diff > 0 if not inverse else diff < 0 + col = '#34C27A' if better else '#E05555' + arrow = '▲' if diff > 0 else '▼' + val = f"${abs(diff):.2f}" if fmt=='$' else f"{abs(diff):.2f}" + return f'{arrow}{val}' + + def _stat(label, val, delta=''): + return f'
{label}
{val}{delta}
' + + stats_html = f""" +
+ {_stat("Net Profit", f"${stats['net_profit']:,.2f}", _delta_html('net_profit','$'))} + {_stat("Win Rate", f"{stats['win_rate']}%", _delta_html('win_rate','%'))} + {_stat("Profit Factor", str(stats['profit_factor']), _delta_html('profit_factor','x'))} + {_stat("R:R Ratio", str(stats['rr_ratio']), _delta_html('rr_ratio','x'))} + {_stat("Expectancy", f"${stats['expectancy']:,.2f}", _delta_html('expectancy','$'))} + {_stat("Total Trades", str(stats['total_trades']), _delta_html('total_trades',''))} + {_stat("Trading Days", str(stats.get('trading_days',0)), _delta_html('trading_days',''))} + {_stat("Trades/Day", str(stats.get('trades_per_day',0)), _delta_html('trades_per_day','x'))} + {_stat("Avg Win", f"${stats['avg_win']:,.2f}", _delta_html('avg_win','$'))} + {_stat("Avg Loss", f"${stats['avg_loss']:,.2f}", _delta_html('avg_loss','$', inverse=True))} + {_stat("Max DD", f"${stats['max_drawdown']:,.2f}", _delta_html('max_drawdown','$', inverse=True))} + {_stat("Best Trade", f"${stats['best_trade']:,.2f}", _delta_html('best_trade','$'))} + {_stat("Worst Trade",f"${stats['worst_trade']:,.2f}", _delta_html('worst_trade','$', inverse=True))} + {_stat("Max Consec Wins", str(stats['max_consec_wins']), _delta_html('max_consec_wins',''))} + {_stat("Max Consec Losses", str(stats['max_consec_losses']), _delta_html('max_consec_losses','', inverse=True))} + {_stat("Long Trades", str(stats['long_trades']), _delta_html('long_trades',''))} + {_stat("Long Win Rate",f"{stats['long_win_rate']}%", _delta_html('long_win_rate','%'))} + {_stat("Short Trades", str(stats['short_trades']), _delta_html('short_trades',''))} + {_stat("Short Win Rate",f"{stats['short_win_rate']}%", _delta_html('short_win_rate','%'))} +
""" + + # ── Monthly table ───────────────────────────────────────────────────────── + def _monthly_html(df_m, label, deposit=10000.0, table_id='mt1'): + if df_m is None or df_m.empty: return '' + tmp = df_m[['close_time','net_profit']].dropna().copy() + tmp['year'] = pd.to_datetime(tmp['close_time']).dt.year + tmp['month'] = pd.to_datetime(tmp['close_time']).dt.month + monthly = tmp.groupby(['year','month'])['net_profit'].sum().reset_index() + if monthly.empty: return '' + pivot = monthly.pivot(index='year', columns='month', values='net_profit').fillna(0) + pivot.columns = [pd.Timestamp(2000,int(m),1).strftime('%b') for m in pivot.columns] + pivot['YTD'] = pivot.sum(axis=1) + pivot = pivot.sort_index(ascending=False) + month_order = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec','YTD'] + cols = [c for c in month_order if c in pivot.columns] + hdr = 'Year' + ''.join(f'{c}' for c in cols) + '' + + def _rows(use_pct): + out = '' + for year, row in pivot[cols].iterrows(): + cells = f'{year}' + for col in cols: + v = row.get(col, 0) + pv = round(v / deposit * 100, 2) if use_pct else v + bg = 'rgba(52,194,122,0.18)' if pv>0 else ('rgba(220,80,80,0.18)' if pv<0 else 'transparent') + fg = '#34C27A' if pv>0 else ('#E05555' if pv<0 else '#888') + txt = (f'{pv:+.2f}%' if pv!=0 else '—') if use_pct else (f'{pv:+.2f}' if pv!=0 else '—') + cells += f'{txt}' + out += f'{cells}' + return out + + rows_d = _rows(False) + rows_p = _rows(True) + + return f''' +
+
+

{label}

+
+ + +
+ Initial balance: ${deposit:,.0f} +
+
{hdr}{rows_d}
+ +
+''' + + monthly_html = _monthly_html(df_s, "Monthly Performance", deposit=deposit, table_id='mt1') + + # ── Position summary ────────────────────────────────────────────────────── + pos_html = '' + if group_summary: + gs_df = pd.DataFrame(group_summary) + hdr = '' + ''.join(f'{c}' for c in gs_df.columns) + '' + rows_html = '' + for _, row in gs_df.iterrows(): + v = row.get('Net P&L ($)', 0) + try: v = float(v) + except: v = 0 + bg = 'rgba(52,194,122,0.12)' if v>0 else ('rgba(220,80,80,0.12)' if v<0 else '') + cells = ''.join(f'{row[col]}' + for col in gs_df.columns) + rows_html += f'{cells}' + n_pos = len(gs_df) + pos_html = ( + f'
Position Summary ({n_pos} positions)' + f'{hdr}{rows_html}
' + f'
' + ) + + # ── Trade log ───────────────────────────────────────────────────────────── + log_cols = ['open_time','close_time','symbol','type','volume', + 'open_price','close_price','net_profit'] + log_cols = [c for c in log_cols if c in df_s.columns] + log_hdr = '' + ''.join(f'{c}' for c in log_cols) + '' + log_rows = '' + for i, (_, row) in enumerate(df_s[log_cols].iterrows()): + bg = '' + try: + v = float(row['net_profit']) + bg = 'rgba(52,194,122,0.08)' if v>0 else 'rgba(220,80,80,0.08)' + except: pass + cells = ''.join(f'{row[col]}' + for col in log_cols) + log_rows += f'{cells}' + n_log = len(df_s) + log_html = ( + f'
Trade Log ({n_log} trades)' + f'{log_hdr}{log_rows}
' + f'
' + ) + + # ── Assemble ────────────────────────────────────────────────────────────── + date_str = f"{date_from} — {date_to}" if date_from else '' + html = f""" + + +{title} + + + +

{title}

+
Generated {now}  ·  {date_str}  ·  Format: {fmt}
+{view_sel} + +

Statistics

+{stats_html} + +

Charts

+
{eq_html}
+
{dd_html}
+
{daily_html}
+
+
{dow_html}
+
{hour_html}
+
+ +{pos_html} + +{monthly_html} + +

Trade Log

+{log_html} + +""" + return html + + def render(): st.title("📊 Trade Analysis") @@ -173,7 +512,7 @@ def render(): if 'ta_deposit' not in st.session_state: st.session_state['ta_deposit'] = 10000.0 - fc1, fc2, fc3, fc4 = st.columns(4) + fc1, fc2, fc3, fc4, fc5 = st.columns(5) with fc1: valid_times = df_all['open_time'].dropna() @@ -200,6 +539,13 @@ def render(): sel_trades = st.multiselect("Trade #", trade_nums, key='ta_idx_sel', placeholder="All trades (filter by #)") + with fc5: + st.session_state['ta_deposit'] = st.number_input( + "Initial Balance ($)", min_value=100.0, max_value=10_000_000.0, + value=st.session_state.get('ta_deposit', 10000.0), + step=1000.0, format="%.0f", key='ta_deposit_filter', + help="Used for % calculations in monthly table and report") + # Apply filters def _apply_filters(src_df): @@ -539,17 +885,8 @@ def render(): pivot['YTD'] = pivot.sum(axis=1) pivot = pivot.sort_index(ascending=False) - # Deposit for % calc — use initial deposit from session state or fallback to first equity point deposit = st.session_state.get('ta_deposit', 10000.0) - - tog1, tog2 = st.columns([2, 3]) - toggle = tog1.radio("Unit", ["$", "%"], horizontal=True, key=f"{key_prefix}_toggle") - deposit = tog2.number_input( - "Initial Balance ($)", min_value=100.0, max_value=10_000_000.0, - value=st.session_state.get('ta_deposit', 10000.0), - step=1000.0, format="%.2f", key=f"{key_prefix}_deposit", - help="Used for % calculations") - st.session_state['ta_deposit'] = deposit + toggle = st.radio("Unit", ["$", "%"], horizontal=True, key=f"{key_prefix}_toggle") month_order = ['Jan','Feb','Mar','Apr','May','Jun', 'Jul','Aug','Sep','Oct','Nov','Dec','YTD'] @@ -602,6 +939,34 @@ def render(): if mode == "Overall": stats = calc_stats(df) stats_e = calc_stats(df_e) if df_e is not None else None + + # ── Report download ─────────────────────────────────────────────── + _rep_df = df_e if (view_sel in ("Edited","Both") and df_e is not None) else df + _rep_stats = stats_e if (view_sel == "Edited" and stats_e) else stats + _rep_cmp_s = stats_e if (view_sel == "Both" and stats_e) else None + _rep_cmp_d = df_e if (view_sel == "Both" and df_e is not None) else None + _rep_grp = st.session_state.get('ta_group_summary') if view_sel in ("Edited","Both") else None + try: + from datetime import datetime as _dt + _rep_html = _generate_html_report( + _rep_df, _rep_stats, fmt or '', view_sel, + stats_compare=_rep_cmp_s, df_compare=_rep_cmp_d, + group_summary=_rep_grp, + date_from=str(date_from), date_to=str(date_to), + deposit=st.session_state.get('ta_deposit', 10000.0), + ) + st.download_button( + "📄 Download HTML Report", + data = _rep_html, + file_name = f"trade_report_{view_sel.lower()}_{_dt.now().strftime('%Y%m%d_%H%M')}.html", + mime = 'text/html', + key = 'ta_report_dl', + ) + except Exception as _e: + import traceback + st.error(f"Report generation error: {_e}") + st.code(traceback.format_exc()) + if view_sel == "Edited" and stats_e: render_stats(stats_e, "Overall Statistics (Edited)") elif view_sel == "Both" and stats_e: @@ -877,10 +1242,16 @@ def render(): summary_rows = [] grp_labels = upd_with_groups['Group'].fillna('').str.strip() + # Add 1-based index to upd_with_groups for trade # reference + upd_with_groups = upd_with_groups.reset_index(drop=True) + upd_with_groups['_idx'] = range(1, len(upd_with_groups) + 1) + # Grouped trades first for label, grp in upd_with_groups[grp_labels != ''].groupby(grp_labels[grp_labels != '']): - net = grp['net_profit'].sum() + net = grp['net_profit'].sum() + trade_nums = ', '.join(str(i) for i in sorted(grp['_idx'].tolist())) summary_rows.append({ + 'Trade #': trade_nums, 'Group': label, 'Entries': len(grp), 'Symbol': grp['symbol'].iloc[0], @@ -896,6 +1267,7 @@ def render(): for _, row in upd_with_groups[grp_labels == ''].iterrows(): net = row['net_profit'] summary_rows.append({ + 'Trade #': str(int(row['_idx'])), 'Group': '—', 'Entries': 1, 'Symbol': row['symbol'],