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''
+
+ 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}
+
+
+
+
+'''
+
+ 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''
+ 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''
+ 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'],