From fa5ceca982f51fcce25ccc688f0ca74c1951fff2 Mon Sep 17 00:00:00 2001 From: unknown Date: Sat, 18 Apr 2026 08:54:02 +1000 Subject: [PATCH] =?UTF-8?q?feat:=20trade=20analysis=20overhaul=20=E2=80=94?= =?UTF-8?q?=20editing,=20grouping,=20comparison=20views?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trade Analysis: - Add editable raw trade log with # index and Group column - Group column merges multiple entries into one position on Update - Reset button clears edited version back to original upload - View selector: Original / Edited / Both — appears after first Update - Both mode: stats show with coloured delta arrows vs edited version - Both mode: equity, drawdown and daily P&L charts overlay both versions - Both mode: strategy comparison table shows inline delta values - Position Summary expander above monthly table — all positions listed (grouped with entry count, individual as single rows) sorted by open time - Download exports edited version when available, original otherwise - Trade # multiselect filter to show specific trades - All charts and tables (DOW, hour, monthly) respect view selection - Fix: Group column positioned second after # index - Fix: DataFrame truth value error in download button - Fix: Both mode now uses edited df for DOW/hour/monthly charts mt5_parser: - Add trading_days and trades_per_day to calc_stats - Add ISO datetime format fallback for CSV imports - Fix missing comment column KeyError in _enrich - Fix NaT in date filter when open_time has null values --- .streamlit/config.toml | 10 +- __pycache__/mt5_parser.cpython-314.pyc | Bin 19940 -> 20274 bytes .../view_trade_analysis.cpython-314.pyc | Bin 36462 -> 57025 bytes mt5_parser.py | 5 + view_trade_analysis.py | 567 ++++++++++++++---- 5 files changed, 470 insertions(+), 112 deletions(-) diff --git a/.streamlit/config.toml b/.streamlit/config.toml index e9e9e5e..7d1bf4b 100644 --- a/.streamlit/config.toml +++ b/.streamlit/config.toml @@ -1,7 +1,7 @@ [theme] -base = "light" -primaryColor = "#2E75B6" -backgroundColor = "#ffffff" -secondaryBackgroundColor = "#f0f2f6" -textColor = "#1a1a1a" +base = "dark" +primaryColor = "#7c6af7" +backgroundColor = "#0e1117" +secondaryBackgroundColor = "#1a1f2e" +textColor = "#fafafa" font = "sans serif" diff --git a/__pycache__/mt5_parser.cpython-314.pyc b/__pycache__/mt5_parser.cpython-314.pyc index 73a59fac17c07da973a0e141736cc4a8e09306d4..3f8f750f4ffaaae511f17649498303ec8e59a6a2 100644 GIT binary patch delta 960 zcmZ{jNlX(_7=Yg^v~;>bm+3N7_6liiEQHD?L7|8XOLVL+E~HJ7vM3gqLZXQX-b_4b z@-U9k7>*n;QG4;|NkOARfV9C2Ats(o2KC_4|Fz=Hm(2IR|9}7g&rBxs^$C9U3|kIb z%mxJO!1C_c+qRdMg%%?ZVF6q}ySeo9mUf*+GSaDb(Hua?iwKg80RbTztnzBkcxj#C z&|DvmqDeP#M^WPg~ZJVYirsLzWX*oWWNI?YqSaKvb6u+6;w&b(1$%M=j>6uOs##VYid|8m&p;TJ`1bDX6v~Jl45)q%kZ`9d~Jpgtn+~k zA6n-_n|$Ni#Y^+O*{1ND_-50Ih4aec#kMUm_`{kU?^g>wCS6wVR1#_d*bdmZ zss&SZi1R2(wTQVMadu@?<(L}~$17LWIU@^9h;u=MnYkr@P|Umpac*T$wKBIM&Zdm0 zcIJ-!AfCCC`Jn1z?#}mmm#1%Ho=;!s;j#W3r2Sg?JDutE8+IX(8Sc@g-en{EVDjl)j>h^* Zv6jyEMeJ=@_v1F!~=SgP4n37=GkiFA+%D2qOD3Hi8-{iJ%l(HMM*T#+St_HfPzX7 zLJ!_#JS-@ZgLtwB&Bcoc@t{%>N|9<#MS2iV|?Y>%YNnode{Ikz$Bo?WMGgfz(J-0 zL(B^dGaoR*{J<#FfI15TV=N3bcs`%xUq_R?b<&F?O-K|&aTWnjuqZHD(aR}*FRvTv zvlK6giy0I9Ud})*$~W_O=ny|$;6bTKN60xc-v8%@`QO5r*7LVp?P~LU+p1oy+ucer zPN~M1Po0_Rxvy4lSI={OaoO(4&6aJ~ZR-tGthb1Fh$GnOT4`7t7`^XD zmSuh5he|D%ZSqbj$zPVj-rrEk!yRq2`?+-6wJJP@U;d=ToVsv>?I>aAvXH=pOjO;u zA_mYEq8d(9sOa9kybs+^RMV-60J=uhuyaWS(FchdfjJ@cFnUcy(4%{Mb@Uif6V7Tw z7#NsDO*st_M^E5k4Ut4o!G?$RPN8$_V|HRYJGq&i+{zw*k>AQpi!?T5h#LD}!w@zM z6Ez9fI)Hu=m#2WuI|I~+%YR&d@e0?EJU3_Dq5;;cYA8o48qrYMb4Ou$W{{dWI$c+F1 diff --git a/__pycache__/view_trade_analysis.cpython-314.pyc b/__pycache__/view_trade_analysis.cpython-314.pyc index 9da82a4394b4550c84969723bfeca2bc7620a42e..0633bbb952b16d774b8712602fbe97440409455d 100644 GIT binary patch literal 57025 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

VgK=zn(Ka9-F92kE~_(hKHd#pYPC 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 delta 12122 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 E4 0 else 0 + return { 'total_trades' : total, + 'trading_days' : trading_days, + 'trades_per_day' : trades_per_day, 'win_rate' : win_rate, 'net_profit' : net_profit, 'gross_profit' : gross_profit, diff --git a/view_trade_analysis.py b/view_trade_analysis.py index e1532c0..2f62f25 100644 --- a/view_trade_analysis.py +++ b/view_trade_analysis.py @@ -54,8 +54,11 @@ def render(): # ── Session state ───────────────────────────────────────────────────────── for _k, _v in { - 'ta_df': None, 'ta_format': None, - 'ta_accounts': [], 'ta_ic_bytes': None, + 'ta_df': None, 'ta_format': None, + 'ta_accounts': [], 'ta_ic_bytes': None, + 'ta_df_original': None, + 'ta_df_edited': None, + 'ta_group_summary': None, }.items(): if _k not in st.session_state: st.session_state[_k] = _v @@ -85,9 +88,10 @@ def render(): if uploaded and uploaded.name.lower().endswith(('.htm','.html','.csv')): df, fmt = detect_and_parse(uploaded.read(), uploaded.name) if df is not None: - st.session_state['ta_df'] = df - st.session_state['ta_format'] = fmt - st.session_state['ta_accounts'] = [] + st.session_state['ta_df'] = df + st.session_state['ta_df_original'] = df.copy() + st.session_state['ta_format'] = fmt + st.session_state['ta_accounts'] = [] st.success(f"✓ Loaded {len(df)} trades — {fmt}") else: st.error("Could not parse report — check file format") @@ -117,7 +121,8 @@ def render(): st.session_state['ta_format'] = "IC Markets XLSX" df_ic = parse_icmarkets_xlsx(file_bytes, account=accounts[0]) df_ic = _normalise_ic(df_ic) - st.session_state['ta_df'] = df_ic + st.session_state['ta_df'] = df_ic + st.session_state['ta_df_original'] = df_ic.copy() st.success(f"✓ Loaded {len(df_ic)} trades — {len(accounts)} account(s) found") except Exception as e: st.error(f"Error parsing file: {e}") @@ -138,8 +143,9 @@ def render(): df_ic = _normalise_ic(df_ic) st.session_state['ta_df'] = df_ic - df_all = st.session_state['ta_df'] - fmt = st.session_state['ta_format'] + df_all = st.session_state['ta_df'] + df_edited = st.session_state.get('ta_df_edited') + fmt = st.session_state['ta_format'] if df_all is None or len(df_all) == 0: st.markdown(""" @@ -153,6 +159,15 @@ def render(): if fmt: st.caption(f"Format detected: **{fmt}** · {len(df_all)} total trades") + # ── View selector ───────────────────────────────────────────────────────── + has_edited = st.session_state.get('ta_df_edited') is not None + if has_edited: + view_opts = ["Original", "Edited", "Both"] + view_sel = st.radio("View", view_opts, horizontal=True, key='ta_view_sel') + else: + view_sel = "Original" + st.session_state['ta_view_sel'] = "Original" + # ── Filters ─────────────────────────────────────────────────────────────── st.divider() if 'ta_deposit' not in st.session_state: @@ -181,20 +196,30 @@ def render(): days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'] sel_days = st.multiselect("Day of week", days, key='ta_days') sel_type = st.multiselect("Type", ['buy', 'sell'], key='ta_type') + trade_nums = [str(i) for i in range(1, len(df_all)+1)] + sel_trades = st.multiselect("Trade #", trade_nums, key='ta_idx_sel', + placeholder="All trades (filter by #)") # Apply filters - df = df_all.copy() - df = df[(df['open_time'].dt.date >= date_from) & - (df['open_time'].dt.date <= date_to)] - if sel_symbol: - df = df[df['symbol'].isin(sel_symbol)] - if sel_strategy: - df = df[df['strategy'].isin(sel_strategy)] - if sel_days: - df = df[df['day_of_week'].isin(sel_days)] - if sel_type: - df = df[df['type'].isin(sel_type)] + def _apply_filters(src_df): + d = src_df.copy() + d = d[(d['open_time'].dt.date >= date_from) & + (d['open_time'].dt.date <= date_to)] + if sel_symbol: d = d[d['symbol'].isin(sel_symbol)] + if sel_strategy: d = d[d['strategy'].isin(sel_strategy)] + if sel_days: d = d[d['day_of_week'].isin(sel_days)] + if sel_type: d = d[d['type'].isin(sel_type)] + d = d.reset_index(drop=True) + if sel_trades: + sel_idx = [int(t)-1 for t in sel_trades if int(t)-1 < len(d)] + d = d.iloc[sel_idx].reset_index(drop=True) + return d + + df = _apply_filters(df_all) + + # Also prepare edited df if available + df_e = _apply_filters(df_edited) if df_edited is not None else None st.caption(f"Showing **{len(df)}** trades after filters") @@ -207,38 +232,93 @@ def render(): st.divider() # ── Helpers ─────────────────────────────────────────────────────────────── - def render_stats(stats, label=""): + def render_stats(stats, label="", stats_compare=None): if label: st.markdown(f"**{label}**") - c1, c2, c3, c4, c5 = st.columns(5) - c1.metric("Net Profit", f"${stats['net_profit']:,.2f}") - c2.metric("Win Rate", f"{stats['win_rate']}%") - c3.metric("Profit Factor", f"{stats['profit_factor']}") - c4.metric("R:R Ratio", f"{stats['rr_ratio']}") - c5.metric("Expectancy", f"${stats['expectancy']:,.2f}") + def _delta(key, fmt='$', higher_is_better=True): + """Return delta string for st.metric when compare stats available.""" + if stats_compare is None or key not in stats_compare: + return None + diff = stats_compare[key] - stats[key] + if diff == 0: + return None + if fmt == '$': + return f"${diff:+,.2f}" + elif fmt == '%': + return f"{diff:+.1f}%" + elif fmt == 'x': + return f"{diff:+.2f}" + else: + return f"{diff:+g}" + + def _inv_delta(key, fmt='$'): + """Delta where lower is better (e.g. drawdown, losses).""" + if stats_compare is None or key not in stats_compare: + return None + diff = stats_compare[key] - stats[key] + if diff == 0: + return None + if fmt == '$': + return f"${diff:+,.2f}" + elif fmt == '%': + return f"{diff:+.1f}%" + else: + return f"{diff:+g}" c1, c2, c3, c4, c5 = st.columns(5) - c1.metric("Total Trades", stats['total_trades']) - c2.metric("Avg Win", f"${stats['avg_win']:,.2f}") - c3.metric("Avg Loss", f"${stats['avg_loss']:,.2f}") - c4.metric("Max DD", f"${stats['max_drawdown']:,.2f}") - c5.metric("Best Trade", f"${stats['best_trade']:,.2f}") + c1.metric("Net Profit", f"${stats['net_profit']:,.2f}", + delta=_delta('net_profit','$')) + c2.metric("Win Rate", f"{stats['win_rate']}%", + delta=_delta('win_rate','%')) + c3.metric("Profit Factor", f"{stats['profit_factor']}", + delta=_delta('profit_factor','x')) + c4.metric("R:R Ratio", f"{stats['rr_ratio']}", + delta=_delta('rr_ratio','x')) + c5.metric("Expectancy", f"${stats['expectancy']:,.2f}", + delta=_delta('expectancy','$')) c1, c2, c3, c4, c5 = st.columns(5) - c1.metric("Max Consec Wins", stats['max_consec_wins']) - c2.metric("Max Consec Losses", stats['max_consec_losses']) - c3.metric("Avg Win Dur", f"{stats['avg_win_duration']}m") - c4.metric("Avg Loss Dur", f"{stats['avg_loss_duration']}m") - c5.metric("Worst Trade", f"${stats['worst_trade']:,.2f}") + c1.metric("Total Trades", stats['total_trades'], + delta=_delta('total_trades','')) + c2.metric("Avg Win", f"${stats['avg_win']:,.2f}", + delta=_delta('avg_win','$')) + c3.metric("Avg Loss", f"${stats['avg_loss']:,.2f}", + delta=_inv_delta('avg_loss','$'), delta_color="inverse") + c4.metric("Max DD", f"${stats['max_drawdown']:,.2f}", + delta=_inv_delta('max_drawdown','$'), delta_color="inverse") + c5.metric("Best Trade", f"${stats['best_trade']:,.2f}", + delta=_delta('best_trade','$')) + + c1, c2, c3, c4, c5 = st.columns(5) + c1.metric("Max Consec Wins", stats['max_consec_wins'], + delta=_delta('max_consec_wins','')) + c2.metric("Max Consec Losses", stats['max_consec_losses'], + delta=_inv_delta('max_consec_losses',''), delta_color="inverse") + c3.metric("Avg Win Dur", f"{stats['avg_win_duration']}m", + delta=_delta('avg_win_duration','')) + c4.metric("Avg Loss Dur", f"{stats['avg_loss_duration']}m", + delta=_inv_delta('avg_loss_duration',''), delta_color="inverse") + c5.metric("Worst Trade", f"${stats['worst_trade']:,.2f}", + delta=_inv_delta('worst_trade','$'), delta_color="inverse") c1, c2, c3, c4 = st.columns(4) - c1.metric("Long Trades", stats['long_trades']) - c2.metric("Long Win Rate", f"{stats['long_win_rate']}%") - c3.metric("Short Trades", stats['short_trades']) - c4.metric("Short Win Rate",f"{stats['short_win_rate']}%") + c1.metric("Long Trades", stats['long_trades'], + delta=_delta('long_trades','')) + c2.metric("Long Win Rate", f"{stats['long_win_rate']}%", + delta=_delta('long_win_rate','%')) + c3.metric("Short Trades", stats['short_trades'], + delta=_delta('short_trades','')) + c4.metric("Short Win Rate",f"{stats['short_win_rate']}%", + delta=_delta('short_win_rate','%')) - def render_equity_curve(df_plot, label="Equity Curve"): + c1, c2, c3, c4 = st.columns(4) + c1.metric("Trading Days", stats.get('trading_days', 0), + delta=_delta('trading_days','')) + c2.metric("Trades / Day", f"{stats.get('trades_per_day', 0)}", + delta=_delta('trades_per_day','x')) + + def render_equity_curve(df_plot, label="Equity Curve", df_compare=None, compare_label="Edited"): import pandas as pd df_s = df_plot.sort_values('close_time').copy() @@ -257,10 +337,20 @@ def render(): if show_account: df_s['_cum'] = df_s['net_profit'].cumsum() fig.add_trace(go.Scatter( - x=df_s['close_time'], y=df_s['_cum'], mode='lines', name='Account', + x=df_s['close_time'], y=df_s['_cum'], mode='lines', name='Original', line=dict(color='#7c6af7', width=2), fill='tozeroy', fillcolor='rgba(124,106,247,0.06)', )) + # Overlay edited/compare line if provided + if df_compare is not None: + df_c = df_compare.sort_values('close_time').copy() + df_c['_cum'] = df_c['net_profit'].cumsum() + fig.add_trace(go.Scatter( + x=df_c['close_time'], y=df_c['_cum'], mode='lines', + name=compare_label, + line=dict(color='#34C27A', width=2, dash='dash'), + fill='tozeroy', fillcolor='rgba(52,194,122,0.04)', + )) if show_strategy and 'strategy' in df_s.columns: for i, strat in enumerate(sorted(df_s['strategy'].dropna().unique())): @@ -302,53 +392,86 @@ def render(): ) st.plotly_chart(fig, use_container_width=True, key=f"eq_fig_{safe_key}") + # ── Drawdown panel ──────────────────────────────────────────────── + st.markdown("**Drawdown**") + df_s['_cum2'] = df_s['net_profit'].cumsum() + df_s['_peak'] = df_s['_cum2'].cummax() + df_s['_dd'] = df_s['_cum2'] - df_s['_peak'] + fig_dd = go.Figure() + fig_dd.add_trace(go.Scatter( + x=df_s['close_time'], y=df_s['_dd'], + mode='lines', fill='tozeroy', + line=dict(color='rgba(124,106,247,0.8)', width=1.5), + fillcolor='rgba(124,106,247,0.08)', name='DD Original', + )) + if df_compare is not None: + df_c2 = df_compare.sort_values('close_time').copy() + df_c2['_cum2'] = df_c2['net_profit'].cumsum() + df_c2['_peak'] = df_c2['_cum2'].cummax() + df_c2['_dd'] = df_c2['_cum2'] - df_c2['_peak'] + fig_dd.add_trace(go.Scatter( + x=df_c2['close_time'], y=df_c2['_dd'], + mode='lines', fill='tozeroy', + line=dict(color='rgba(220,80,80,0.8)', width=1.5), + fillcolor='rgba(220,80,80,0.08)', name=f'DD {compare_label}', + )) + fig_dd.update_layout( + height=130, + plot_bgcolor='rgba(0,0,0,0)', paper_bgcolor='rgba(0,0,0,0)', + font=dict(family='sans-serif'), + xaxis=dict(gridcolor='rgba(128,128,128,0.15)', showgrid=True, + showticklabels=False), + yaxis=dict(gridcolor='rgba(128,128,128,0.15)', tickprefix='$', + showgrid=True), + margin=dict(l=60, r=20, t=8, b=4), + showlegend=False, + ) + st.plotly_chart(fig_dd, use_container_width=True, key=f"eq_dd_{safe_key}") + # ── Daily P&L bars ──────────────────────────────────────────────── st.markdown("**Daily P&L**") daily = (df_s.groupby(df_s['close_time'].dt.date)['net_profit'] .sum().reset_index()) daily.columns = ['date','pnl'] daily['color'] = daily['pnl'].apply( - lambda v: 'rgba(52,194,122,0.75)' if v >= 0 else 'rgba(220,80,80,0.75)') - fig_d = go.Figure(go.Bar( - x=daily['date'], y=daily['pnl'], - marker_color=daily['color'], name='Daily P&L', - )) - fig_d.update_layout( - height=160, - plot_bgcolor='rgba(0,0,0,0)', paper_bgcolor='rgba(0,0,0,0)', - font=dict(family='sans-serif'), - xaxis=dict(gridcolor='rgba(128,128,128,0.15)', showgrid=False, - showticklabels=False), - yaxis=dict(gridcolor='rgba(128,128,128,0.15)', tickprefix='$', - showgrid=True, zeroline=True, - zerolinecolor='rgba(128,128,128,0.3)'), - margin=dict(l=60, r=20, t=8, b=20), - showlegend=False, - ) - st.plotly_chart(fig_d, use_container_width=True, key=f"eq_daily_{safe_key}") + lambda v: 'rgba(52,194,122,0.85)' if v >= 0 else 'rgba(220,80,80,0.85)') - # ── Drawdown panel ──────────────────────────────────────────────── - st.markdown("**Drawdown**") - df_s['_cum2'] = df_s['net_profit'].cumsum() - df_s['_peak'] = df_s['_cum2'].cummax() - df_s['_dd'] = df_s['_cum2'] - df_s['_peak'] - fig_dd = go.Figure(go.Scatter( - x=df_s['close_time'], y=df_s['_dd'], - mode='lines', fill='tozeroy', - line=dict(color='rgba(220,80,80,0.6)', width=1), - fillcolor='rgba(220,80,80,0.12)', name='Drawdown', + fig_d = go.Figure() + fig_d.add_trace(go.Bar( + x=daily['date'], y=daily['pnl'], + marker_color=daily['color'], + name='Original', + offsetgroup=0, )) - fig_dd.update_layout( - height=120, + + if df_compare is not None: + dc = df_compare.sort_values('close_time').copy() + daily_c = (dc.groupby(dc['close_time'].dt.date)['net_profit'] + .sum().reset_index()) + daily_c.columns = ['date','pnl'] + daily_c['color'] = daily_c['pnl'].apply( + lambda v: 'rgba(124,106,247,0.45)' if v >= 0 else 'rgba(255,165,0,0.45)') + fig_d.add_trace(go.Bar( + x=daily_c['date'], y=daily_c['pnl'], + marker_color=daily_c['color'], + name=compare_label, + offsetgroup=1, + )) + + fig_d.update_layout( + height=160, barmode='group', plot_bgcolor='rgba(0,0,0,0)', paper_bgcolor='rgba(0,0,0,0)', font=dict(family='sans-serif'), xaxis=dict(gridcolor='rgba(128,128,128,0.15)', showgrid=True), yaxis=dict(gridcolor='rgba(128,128,128,0.15)', tickprefix='$', - showgrid=True), - margin=dict(l=60, r=20, t=8, b=40), - showlegend=False, + showgrid=True, zeroline=True, + zerolinecolor='rgba(128,128,128,0.4)'), + margin=dict(l=60, r=20, t=4, b=40), + legend=dict(bgcolor='rgba(0,0,0,0)', orientation='h', + yanchor='bottom', y=1.02), + showlegend=df_compare is not None, ) - st.plotly_chart(fig_dd, use_container_width=True, key=f"eq_dd_{safe_key}") + st.plotly_chart(fig_d, use_container_width=True, key=f"eq_daily_{safe_key}") def render_dow_chart(df_plot): dow_order = ['Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday'] @@ -477,16 +600,55 @@ def render(): # ── Render mode ─────────────────────────────────────────────────────────── if mode == "Overall": - stats = calc_stats(df) - render_stats(stats, "Overall Statistics") - render_equity_curve(df) + stats = calc_stats(df) + stats_e = calc_stats(df_e) if df_e is not None else None + if view_sel == "Edited" and stats_e: + render_stats(stats_e, "Overall Statistics (Edited)") + elif view_sel == "Both" and stats_e: + render_stats(stats, "Overall Statistics", stats_compare=stats_e) + else: + render_stats(stats, "Overall Statistics") + if view_sel == "Both" and df_e is not None: + render_equity_curve(df, label="Equity Curve", df_compare=df_e, + compare_label="Edited") + elif view_sel == "Edited" and df_e is not None: + render_equity_curve(df_e, label="Equity Curve (Edited)") + else: + render_equity_curve(df) + _df_charts = df_e if (view_sel in ("Edited","Both") and df_e is not None) else df col1, col2 = st.columns(2) with col1: - render_dow_chart(df) + render_dow_chart(_df_charts) with col2: - render_hour_chart(df) + render_hour_chart(_df_charts) st.divider() - render_monthly_table(df, "Monthly Performance", key_prefix="mt_overall") + # ── Grouped trades summary (collapsible) ────────────────────────── + group_summary = st.session_state.get('ta_group_summary') + if group_summary and view_sel in ("Edited", "Both"): + import pandas as _pd + n_groups = len([r for r in group_summary if r['Group'] != '—']) + n_single = len([r for r in group_summary if r['Group'] == '—']) + with st.expander( + f"Position Summary — {len(group_summary)} positions " + f"({n_groups} grouped, {n_single} individual)", expanded=False): + st.caption("Grouped positions show merged entries. Individual trades show as single rows. Sorted by open time.") + gs_df = _pd.DataFrame(group_summary) + def _colour_pnl(val): + try: + v = float(val) + if v > 0: return 'background-color: rgba(52,194,122,0.15)' + if v < 0: return 'background-color: rgba(220,80,80,0.15)' + except: pass + return '' + st.dataframe( + gs_df.style.map(_colour_pnl, subset=['Net P&L ($)']), + use_container_width=True, hide_index=True + ) + if view_sel == "Both" and df_e is not None: + render_monthly_table(df, "Monthly Performance (Original)", key_prefix="mt_overall_orig") + render_monthly_table(df_e,"Monthly Performance (Edited)", key_prefix="mt_overall_edit") + else: + render_monthly_table(_df_charts, "Monthly Performance", key_prefix="mt_overall") elif mode == "By Strategy": strats = sorted(df['strategy'].dropna().unique().tolist()) @@ -494,11 +656,12 @@ def render(): st.info("No strategies found") else: st.subheader("Strategy Comparison") + _df_s = df_e if (view_sel in ("Edited","Both") and df_e is not None) else df rows = [] for s in strats: - sdf = df[df['strategy'] == s] + sdf = _df_s[_df_s['strategy'] == s] if s in _df_s['strategy'].values else df[df['strategy']==s] stat = calc_stats(sdf) - rows.append({ + row = { 'Strategy' : s, 'Trades' : stat['total_trades'], 'Net Profit' : stat['net_profit'], @@ -509,24 +672,49 @@ def render(): 'Max DD' : stat['max_drawdown'], 'Max Consec W' : stat['max_consec_wins'], 'Max Consec L' : stat['max_consec_losses'], - }) - sdf_sum = __import__('pandas').DataFrame(rows).sort_values('Net Profit', ascending=False) - st.dataframe( - sdf_sum.style.map(colour_profit, subset=['Net Profit', 'Expectancy', 'Max DD']), - use_container_width=True, hide_index=True - ) + } + if view_sel == "Both" and df_e is not None: + sdf_e = df_e[df_e['strategy'] == s] if s in df_e['strategy'].values else None + if sdf_e is not None and len(sdf_e): + stat_e = calc_stats(sdf_e) + def _arr(k, higher=''): + diff = stat_e[k] - stat[k] + if abs(diff) < 0.001: return '' + arrow = '▲' if diff > 0 else '▼' + color = 'green' if (diff > 0) == (k not in ('max_drawdown','max_consec_losses')) else 'red' + return f" {arrow}{abs(diff):.2f}" + row['Net Profit'] = f"{stat['net_profit']:.2f}{_arr('net_profit')}" + row['Win Rate %'] = f"{stat['win_rate']}{_arr('win_rate')}" + row['Profit Factor'] = f"{stat['profit_factor']}{_arr('profit_factor')}" + row['Expectancy'] = f"{stat['expectancy']:.2f}{_arr('expectancy')}" + row['Max DD'] = f"{stat['max_drawdown']:.2f}{_arr('max_drawdown')}" + rows.append(row) + import pandas as pd + sdf_sum = pd.DataFrame(rows).sort_values('Net Profit', ascending=False) + st.dataframe(sdf_sum, use_container_width=True, hide_index=True) st.divider() sel = st.selectbox("Select strategy for detail", strats) if sel: - sdf = df[df['strategy'] == sel] + sdf = _df_s[_df_s['strategy'] == sel] if sel in _df_s['strategy'].values else df[df['strategy']==sel] stat = calc_stats(sdf) - render_stats(stat, sel) + sdf_e_sel = df_e[df_e['strategy']==sel] if (df_e is not None and sel in df_e['strategy'].values) else None + stats_e_sel = calc_stats(sdf_e_sel) if sdf_e_sel is not None and len(sdf_e_sel) else None + if view_sel == "Both" and stats_e_sel: + render_stats(stat, sel, stats_compare=stats_e_sel) + elif view_sel == "Edited" and stats_e_sel: + render_stats(stats_e_sel, f"{sel} (Edited)") + else: + render_stats(stat, sel) render_equity_curve(sdf, f"{sel} — Equity Curve") col1, col2 = st.columns(2) with col1: render_dow_chart(sdf) with col2: render_hour_chart(sdf) st.divider() - render_monthly_table(sdf, "Monthly Performance", key_prefix=f"mt_strat_{sel}") + if view_sel == "Both" and sdf_e_sel is not None and len(sdf_e_sel): + render_monthly_table(sdf, "Monthly Performance (Original)", key_prefix=f"mt_strat_orig_{sel}") + render_monthly_table(sdf_e_sel,"Monthly Performance (Edited)", key_prefix=f"mt_strat_edit_{sel}") + else: + render_monthly_table(sdf, "Monthly Performance", key_prefix=f"mt_strat_{sel}") elif mode == "By Symbol": syms = sorted(df['symbol'].dropna().unique().tolist()) @@ -544,34 +732,198 @@ def render(): 'Expectancy' : stat['expectancy'], 'Max DD' : stat['max_drawdown'], }) - sdf_sum = __import__('pandas').DataFrame(rows).sort_values('Net Profit', ascending=False) + import pandas as pd + sdf_sum = pd.DataFrame(rows).sort_values('Net Profit', ascending=False) st.dataframe( sdf_sum.style.map(colour_profit, subset=['Net Profit', 'Expectancy', 'Max DD']), use_container_width=True, hide_index=True ) + _df_sym = df_e if (view_sel in ("Edited","Both") and df_e is not None) else df sel = st.selectbox("Select symbol for detail", syms) if sel: - sdf = df[df['symbol'] == sel] + sdf = _df_sym[_df_sym['symbol'] == sel] if sel in _df_sym['symbol'].values else df[df['symbol']==sel] stat = calc_stats(sdf) - render_stats(stat, sel) + sdf_e_sel = df_e[df_e['symbol']==sel] if (df_e is not None and sel in df_e['symbol'].values) else None + stats_e_sel = calc_stats(sdf_e_sel) if sdf_e_sel is not None and len(sdf_e_sel) else None + if view_sel == "Both" and stats_e_sel: + render_stats(stat, sel, stats_compare=stats_e_sel) + elif view_sel == "Edited" and stats_e_sel: + render_stats(stats_e_sel, f"{sel} (Edited)") + else: + render_stats(stat, sel) render_equity_curve(sdf, f"{sel} — Equity Curve") col1, col2 = st.columns(2) with col1: render_dow_chart(sdf) with col2: render_hour_chart(sdf) st.divider() - render_monthly_table(sdf, "Monthly Performance", key_prefix=f"mt_sym_{sel}") + if view_sel == "Both" and sdf_e_sel is not None and len(sdf_e_sel): + render_monthly_table(sdf, "Monthly Performance (Original)", key_prefix=f"mt_sym_orig_{sel}") + render_monthly_table(sdf_e_sel,"Monthly Performance (Edited)", key_prefix=f"mt_sym_edit_{sel}") + else: + render_monthly_table(sdf, "Monthly Performance", key_prefix=f"mt_sym_{sel}") elif mode == "By Day of Week": - render_dow_chart(df) - render_hour_chart(df) + _df_dow = df_e if (view_sel in ("Edited","Both") and df_e is not None) else df + render_dow_chart(_df_dow) + render_hour_chart(_df_dow) # ── Raw trade log ───────────────────────────────────────────────────────── st.divider() with st.expander("Raw Trade Log"): - show_cols = ['open_time', 'close_time', 'symbol', 'type', 'strategy', + edit_cols = ['open_time', 'close_time', 'symbol', 'type', 'strategy', 'volume', 'open_price', 'close_price', 'sl', 'tp', 'commission', 'swap', 'profit', 'net_profit', 'duration_min'] - show_cols = [c for c in show_cols if c in df.columns] + edit_cols = [c for c in edit_cols if c in st.session_state['ta_df'].columns] + + # Show full dataset (not filtered) with trade index and Group column + df_edit = st.session_state['ta_df'][edit_cols].copy() + df_edit.insert(0, '#', range(1, len(df_edit) + 1)) + # Preserve existing Group column if already edited + existing_edited = st.session_state.get('ta_df_edited') + if existing_edited is not None and 'Group' in existing_edited.columns: + df_edit.insert(1, 'Group', existing_edited['Group'].values[:len(df_edit)]) + else: + df_edit.insert(1, 'Group', '') + + st.caption( + "Edit any cell then click **Update**. " + "Enter the same label in **Group** for trades to merge into one position. " + "**Reset** restores the original upload." + ) + bc1, bc2, bc3 = st.columns([1, 1, 6]) + do_update = bc1.button("✅ Update", type="primary", key="ta_log_update") + do_reset = bc2.button("↩️ Reset", key="ta_log_reset") + + edited = st.data_editor( + df_edit, + use_container_width=True, + hide_index=True, + height=400, + column_config={ + '#': st.column_config.NumberColumn('#', disabled=True, width='small'), + 'Group': st.column_config.TextColumn('Group', width='small', + help='Same label = merge into one trade on Update'), + 'open_time': st.column_config.DatetimeColumn('open_time', format='YYYY-MM-DD HH:mm:ss'), + 'close_time': st.column_config.DatetimeColumn('close_time', format='YYYY-MM-DD HH:mm:ss'), + 'symbol': st.column_config.TextColumn('symbol'), + 'type': st.column_config.SelectboxColumn('type', options=['buy','sell']), + 'strategy': st.column_config.TextColumn('strategy'), + 'volume': st.column_config.NumberColumn('volume', format='%.2f'), + 'open_price': st.column_config.NumberColumn('open_price', format='%.5f'), + 'close_price': st.column_config.NumberColumn('close_price', format='%.5f'), + 'profit': st.column_config.NumberColumn('profit', format='%.2f'), + 'net_profit': st.column_config.NumberColumn('net_profit', format='%.2f'), + }, + key='ta_log_editor' + ) + + if do_update: + import pandas as pd + upd = edited.drop(columns=['#']) + for col in ['open_time','close_time']: + if col in upd.columns: + upd[col] = pd.to_datetime(upd[col], errors='coerce') + for col in ['profit','net_profit','volume','open_price','close_price', + 'commission','swap','sl','tp','duration_min']: + if col in upd.columns: + upd[col] = pd.to_numeric(upd[col], errors='coerce') + + # ── Merge grouped trades ────────────────────────────────────── + upd_with_groups = upd.copy() # preserve Group labels for summary + groups = upd['Group'].fillna('').str.strip() + ungrouped = upd[groups == ''].drop(columns=['Group']) + grouped_rows = [] + for label, grp in upd[groups != ''].groupby(groups): + merged = { + 'open_time': grp['open_time'].min(), + 'close_time': grp['close_time'].max(), + 'symbol': grp['symbol'].iloc[0], + 'type': grp['type'].iloc[0], + 'strategy': grp['strategy'].iloc[0], + 'volume': grp['volume'].sum(), + 'open_price': grp['open_price'].iloc[0], + 'close_price': grp['close_price'].iloc[-1], + 'profit': grp['profit'].sum() if 'profit' in grp else 0, + 'net_profit': grp['net_profit'].sum(), + 'commission': grp['commission'].sum() if 'commission' in grp else 0, + 'swap': grp['swap'].sum() if 'swap' in grp else 0, + } + if 'sl' in grp: merged['sl'] = grp['sl'].iloc[0] + if 'tp' in grp: merged['tp'] = grp['tp'].iloc[0] + grouped_rows.append(merged) + + if grouped_rows: + df_grouped = pd.DataFrame(grouped_rows) + upd = pd.concat([ungrouped, df_grouped], ignore_index=True) + upd = upd.sort_values('open_time').reset_index(drop=True) + else: + upd = ungrouped + + upd['duration_min'] = ((upd['close_time'] - upd['open_time']) + .dt.total_seconds() / 60).round(1) + upd['win'] = upd['net_profit'] > 0 + upd['day_of_week'] = upd['open_time'].dt.day_name() + upd['hour'] = upd['open_time'].dt.hour + # Preserve non-editable columns + orig = st.session_state['ta_df'] + for col in orig.columns: + if col not in upd.columns: + upd[col] = orig[col].values[:len(upd)] + upd['comment'] = upd.get('comment', '') + upd['source'] = upd.get('source', 'manual') + st.session_state['ta_df_edited'] = upd + + # Build full position summary — grouped and ungrouped trades + summary_rows = [] + grp_labels = upd_with_groups['Group'].fillna('').str.strip() + + # Grouped trades first + for label, grp in upd_with_groups[grp_labels != ''].groupby(grp_labels[grp_labels != '']): + net = grp['net_profit'].sum() + summary_rows.append({ + 'Group': label, + 'Entries': len(grp), + 'Symbol': grp['symbol'].iloc[0], + 'Type': grp['type'].iloc[0], + 'Open Time': grp['open_time'].min(), + 'Close Time': grp['close_time'].max(), + 'Total Volume': round(grp['volume'].sum(), 2), + 'Net P&L ($)': round(net, 2), + 'Win': '✅' if net > 0 else '❌', + }) + + # Individual (ungrouped) trades + for _, row in upd_with_groups[grp_labels == ''].iterrows(): + net = row['net_profit'] + summary_rows.append({ + 'Group': '—', + 'Entries': 1, + 'Symbol': row['symbol'], + 'Type': row['type'], + 'Open Time': row['open_time'], + 'Close Time': row['close_time'], + 'Total Volume': round(row['volume'], 2), + 'Net P&L ($)': round(net, 2), + 'Win': '✅' if net > 0 else '❌', + }) + + # Sort by open time + summary_rows.sort(key=lambda r: r['Open Time'] if r['Open Time'] is not None else pd.Timestamp.min) + st.session_state['ta_group_summary'] = summary_rows if summary_rows else None + + n_merged = len(groups[groups != ''].unique()) + st.success( + f"Saved — {len(upd)} trades " + f"({n_merged} group(s) merged). " + "Select 'Edited' or 'Both' to compare." + ) + st.rerun() + + if do_reset: + st.session_state['ta_df_edited'] = None + st.session_state['ta_group_summary'] = None + st.success("Edited version cleared.") + st.rerun() def colour_net(val): try: @@ -582,13 +934,14 @@ def render(): pass return '' - st.dataframe( - df[show_cols].style.map(colour_net, subset=['net_profit', 'profit']), - use_container_width=True, hide_index=True, height=400 - ) + st.divider() + # Download uses edited version if available, otherwise original + _dl_df = st.session_state['ta_df_edited'] if st.session_state.get('ta_df_edited') is not None else st.session_state['ta_df'] + _dl_cols = [c for c in edit_cols if c in _dl_df.columns] + _dl_label = "Edited" if st.session_state.get('ta_df_edited') is not None else "Original" st.download_button( - "⬇ Download filtered trades CSV", - data = df[show_cols].to_csv(index=False), + f"⬇ Download {_dl_label} trades CSV", + data = _dl_df[_dl_cols].to_csv(index=False), file_name = f"mt5_trades_{date_from}_{date_to}.csv", mime = 'text/csv' ) \ No newline at end of file