diff --git a/__pycache__/view_trade_analysis.cpython-314.pyc b/__pycache__/view_trade_analysis.cpython-314.pyc index 0633bbb..6ccd958 100644 Binary files a/__pycache__/view_trade_analysis.cpython-314.pyc and b/__pycache__/view_trade_analysis.cpython-314.pyc differ 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'],