""" pages/trade_compare.py ====================== Side-by-side comparison of two trade history files. Matches trades by symbol + type + open time within a tolerance window. Highlights slippage, profit variance, and timing differences. """ import streamlit as st import pandas as pd import plotly.graph_objects as go import sys, os sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from mt5_parser import detect_and_parse, calc_stats # ── Match trades ────────────────────────────────────────────────────────────── def match_trades(df_a, df_b, tolerance_hours): """ Match trades between two DataFrames. Match criteria: same symbol_base + same type + open_time within tolerance. Returns DataFrame of matched pairs with diff columns. """ tol = pd.Timedelta(hours=tolerance_hours) matched = [] used_b = set() for i, a in df_a.iterrows(): best_match = None best_delta = tol + pd.Timedelta(seconds=1) for j, b in df_b.iterrows(): if j in used_b: continue if a['symbol_base'] != b['symbol_base']: continue if a['type'] != b['type']: continue delta = abs(a['open_time'] - b['open_time']) if delta <= tol and delta < best_delta: best_delta = delta best_match = (j, b) if best_match: j, b = best_match used_b.add(j) open_slip = round(float(b['open_price']) - float(a['open_price']), 5) if pd.notna(a['open_price']) and pd.notna(b['open_price']) else None close_slip = round(float(b['close_price']) - float(a['close_price']), 5) if pd.notna(a['close_price']) and pd.notna(b['close_price']) else None profit_var = round(float(b['net_profit']) - float(a['net_profit']), 2) if pd.notna(a['net_profit']) and pd.notna(b['net_profit']) else None time_diff = round((b['open_time'] - a['open_time']).total_seconds() / 60, 1) dur_diff = round(float(b.get('duration_min', 0) or 0) - float(a.get('duration_min', 0) or 0), 1) matched.append({ # File A 'A_open_time' : a['open_time'], 'A_close_time' : a['close_time'], 'A_symbol' : a['symbol'], 'A_type' : a['type'], 'A_volume' : a.get('volume'), 'A_open_price' : a.get('open_price'), 'A_close_price': a.get('close_price'), 'A_profit' : a.get('net_profit'), 'A_duration' : a.get('duration_min'), # File B 'B_open_time' : b['open_time'], 'B_close_time' : b['close_time'], 'B_symbol' : b['symbol'], 'B_type' : b['type'], 'B_volume' : b.get('volume'), 'B_open_price' : b.get('open_price'), 'B_close_price': b.get('close_price'), 'B_profit' : b.get('net_profit'), 'B_duration' : b.get('duration_min'), # Differences 'open_slippage' : open_slip, 'close_slippage': close_slip, 'profit_var' : profit_var, 'time_diff_min' : time_diff, 'duration_diff' : dur_diff, }) return pd.DataFrame(matched) # ── Render ──────────────────────────────────────────────────────────────────── def render(): st.title("🔄 Trade Compare") st.markdown("""
Compare two trade history files — backtest vs real account, or any two exports. Trades are matched by symbol, direction, and open time within a configurable tolerance window to account for gaps, slippage, and market open variations.
""", unsafe_allow_html=True) # ── Session state ───────────────────────────────────────────────────────── for k in ['tc_df_a', 'tc_df_b', 'tc_fmt_a', 'tc_fmt_b']: if k not in st.session_state: st.session_state[k] = None # ── File upload ─────────────────────────────────────────────────────────── st.subheader("Load Files") col_a, col_b = st.columns(2) with col_a: st.markdown("**File A** — Reference (e.g. Backtest)") up_a = st.file_uploader("Upload File A", type=['html','htm','csv'], key='tc_up_a') if up_a: df_a, fmt_a = detect_and_parse(up_a.read(), up_a.name) if df_a is not None: st.session_state['tc_df_a'] = df_a st.session_state['tc_fmt_a'] = fmt_a st.success(f"✓ {len(df_a)} trades — {fmt_a}") else: st.error("Could not parse File A") if st.session_state['tc_df_a'] is not None: st.caption(f"Loaded: **{st.session_state['tc_fmt_a']}** · {len(st.session_state['tc_df_a'])} trades") with col_b: st.markdown("**File B** — Comparison (e.g. Real Account)") up_b = st.file_uploader("Upload File B", type=['html','htm','csv'], key='tc_up_b') if up_b: df_b, fmt_b = detect_and_parse(up_b.read(), up_b.name) if df_b is not None: st.session_state['tc_df_b'] = df_b st.session_state['tc_fmt_b'] = fmt_b st.success(f"✓ {len(df_b)} trades — {fmt_b}") else: st.error("Could not parse File B") if st.session_state['tc_df_b'] is not None: st.caption(f"Loaded: **{st.session_state['tc_fmt_b']}** · {len(st.session_state['tc_df_b'])} trades") df_a = st.session_state['tc_df_a'] df_b = st.session_state['tc_df_b'] if df_a is None or df_b is None: return # ── Filters ─────────────────────────────────────────────────────────────── st.divider() st.subheader("Filters") fa1, fa2, fa3 = st.columns(3) fb1, fb2, fb3 = st.columns(3) with fa1: st.markdown("**File A filters**") with fb1: st.markdown("**File B filters**") col1, col2, col3, col4, col5, col6 = st.columns(6) with col1: a_date_min = df_a['open_time'].min().date() a_date_max = df_a['open_time'].max().date() a_from = st.date_input("A — From", value=a_date_min, min_value=a_date_min, max_value=a_date_max, key='tc_a_from') a_to = st.date_input("A — To", value=a_date_max, min_value=a_date_min, max_value=a_date_max, key='tc_a_to') with col2: a_syms = sorted(df_a['symbol'].dropna().unique().tolist()) a_sel_sym = st.multiselect("A — Symbol", a_syms, key='tc_a_sym') with col3: a_strats = sorted(df_a['strategy'].dropna().unique().tolist()) a_sel_strat = st.multiselect("A — Strategy", a_strats, key='tc_a_strat') a_sel_type = st.multiselect("A — Type", ['buy', 'sell'], key='tc_a_type') with col4: b_date_min = df_b['open_time'].min().date() b_date_max = df_b['open_time'].max().date() b_from = st.date_input("B — From", value=b_date_min, min_value=b_date_min, max_value=b_date_max, key='tc_b_from') b_to = st.date_input("B — To", value=b_date_max, min_value=b_date_min, max_value=b_date_max, key='tc_b_to') with col5: b_syms = sorted(df_b['symbol'].dropna().unique().tolist()) b_sel_sym = st.multiselect("B — Symbol", b_syms, key='tc_b_sym') with col6: b_strats = sorted(df_b['strategy'].dropna().unique().tolist()) b_sel_strat = st.multiselect("B — Strategy", b_strats, key='tc_b_strat') b_sel_type = st.multiselect("B — Type", ['buy', 'sell'], key='tc_b_type') # ── Matching tolerance ──────────────────────────────────────────────────── st.divider() col_tol, col_run = st.columns([3, 1]) with col_tol: tolerance = st.slider( "Match tolerance (hours) — max time difference between A and B open times", min_value=1, max_value=24, value=4, step=1, help="Trades within this window are considered the same setup. " "Increase for daily charts, decrease for intraday." ) with col_run: st.markdown("
", unsafe_allow_html=True) run = st.button("🔍 Match Trades", type="primary", use_container_width=True) if not run and 'tc_matched' not in st.session_state: return # Apply filters fa = df_a.copy() fa = fa[(fa['open_time'].dt.date >= a_from) & (fa['open_time'].dt.date <= a_to)] if a_sel_sym: fa = fa[fa['symbol'].isin(a_sel_sym)] if a_sel_strat: fa = fa[fa['strategy'].isin(a_sel_strat)] if a_sel_type: fa = fa[fa['type'].isin(a_sel_type)] fb = df_b.copy() fb = fb[(fb['open_time'].dt.date >= b_from) & (fb['open_time'].dt.date <= b_to)] if b_sel_sym: fb = fb[fb['symbol'].isin(b_sel_sym)] if b_sel_strat: fb = fb[fb['strategy'].isin(b_sel_strat)] if b_sel_type: fb = fb[fb['type'].isin(b_sel_type)] if run: with st.spinner("Matching trades..."): matched = match_trades(fa, fb, tolerance) st.session_state['tc_matched'] = matched st.session_state['tc_fa_len'] = len(fa) st.session_state['tc_fb_len'] = len(fb) matched = st.session_state.get('tc_matched', pd.DataFrame()) fa_len = st.session_state.get('tc_fa_len', len(fa)) fb_len = st.session_state.get('tc_fb_len', len(fb)) if matched is None or len(matched) == 0: st.warning("No matching trades found — try increasing the tolerance window or adjusting filters.") return # ── Summary stats ───────────────────────────────────────────────────────── st.divider() st.subheader("Match Summary") m1, m2, m3, m4, m5 = st.columns(5) m1.metric("File A Trades", fa_len) m2.metric("File B Trades", fb_len) m3.metric("Matched Pairs", len(matched)) m4.metric("Unmatched A", fa_len - len(matched)) m5.metric("Unmatched B", fb_len - len(matched)) st.divider() # ── Aggregate comparison ─────────────────────────────────────────────────── st.subheader("Aggregate Comparison") ac1, ac2 = st.columns(2) with ac1: st.markdown("**File A (Reference)**") a_net = matched['A_profit'].sum() a_wr = (matched['A_profit'] > 0).mean() * 100 a_avg = matched['A_profit'].mean() a_dur = matched['A_duration'].mean() if 'A_duration' in matched else None st.metric("Net Profit", f"${a_net:,.2f}") st.metric("Win Rate", f"{a_wr:.1f}%") st.metric("Avg Profit", f"${a_avg:,.2f}") if a_dur: st.metric("Avg Duration", f"{a_dur:.0f}m") with ac2: st.markdown("**File B (Comparison)**") b_net = matched['B_profit'].sum() b_wr = (matched['B_profit'] > 0).mean() * 100 b_avg = matched['B_profit'].mean() b_dur = matched['B_duration'].mean() if 'B_duration' in matched else None delta_net = b_net - a_net st.metric("Net Profit", f"${b_net:,.2f}", delta=f"{delta_net:+.2f}", delta_color="normal") st.metric("Win Rate", f"{b_wr:.1f}%", delta=f"{b_wr - a_wr:+.1f}%", delta_color="normal") st.metric("Avg Profit", f"${b_avg:,.2f}", delta=f"{b_avg - a_avg:+.2f}", delta_color="normal") if b_dur and a_dur: st.metric("Avg Duration", f"{b_dur:.0f}m", delta=f"{b_dur - a_dur:+.0f}m", delta_color="off") # ── Slippage summary ─────────────────────────────────────────────────────── st.divider() st.subheader("Slippage & Variance Summary") sc1, sc2, sc3, sc4 = st.columns(4) avg_open_slip = matched['open_slippage'].mean() avg_close_slip = matched['close_slip'].mean() if 'close_slip' in matched else matched['close_slippage'].mean() avg_profit_var = matched['profit_var'].mean() avg_time_diff = matched['time_diff_min'].mean() sc1.metric("Avg Entry Slippage", f"{avg_open_slip:+.5f}" if pd.notna(avg_open_slip) else "N/A", help="B open price minus A open price. Positive = B filled higher.") sc2.metric("Avg Exit Slippage", f"{avg_close_slip:+.5f}" if pd.notna(avg_close_slip) else "N/A", help="B close price minus A close price.") sc3.metric("Avg Profit Variance", f"${avg_profit_var:+.2f}" if pd.notna(avg_profit_var) else "N/A", help="B net profit minus A net profit per trade.") sc4.metric("Avg Time Difference", f"{avg_time_diff:+.0f}m" if pd.notna(avg_time_diff) else "N/A", help="B open time minus A open time in minutes.") # ── Equity curve overlay ─────────────────────────────────────────────────── st.divider() st.subheader("Equity Curve Overlay") m_sorted = matched.sort_values('A_open_time') fig = go.Figure() fig.add_trace(go.Scatter( x=m_sorted['A_open_time'], y=m_sorted['A_profit'].cumsum(), mode='lines', name='File A', line=dict(color='#7c6af7', width=2), fill='tozeroy', fillcolor='rgba(124,106,247,0.05)' )) fig.add_trace(go.Scatter( x=m_sorted['B_open_time'], y=m_sorted['B_profit'].cumsum(), mode='lines', name='File B', line=dict(color='#2dc653', width=2), fill='tozeroy', fillcolor='rgba(45,198,83,0.05)' )) fig.update_layout( height=320, plot_bgcolor='rgba(10,10,15,1)', paper_bgcolor='rgba(10,10,15,1)', font=dict(color='#aaa', family='JetBrains Mono'), xaxis=dict(gridcolor='rgba(255,255,255,0.04)'), yaxis=dict(gridcolor='rgba(255,255,255,0.04)', tickprefix='$'), legend=dict(bgcolor='rgba(0,0,0,0.3)'), margin=dict(l=60, r=20, t=20, b=40) ) st.plotly_chart(fig, use_container_width=True) # ── Profit variance scatter ──────────────────────────────────────────────── st.subheader("Profit Variance per Trade") fig2 = go.Figure() colours = matched['profit_var'].apply( lambda v: 'rgba(45,198,83,0.7)' if v >= 0 else 'rgba(230,57,70,0.7)' ) fig2.add_trace(go.Bar( x=list(range(len(matched))), y=matched['profit_var'], marker_color=colours, name='Profit Variance (B - A)' )) fig2.update_layout( height=250, plot_bgcolor='rgba(10,10,15,1)', paper_bgcolor='rgba(10,10,15,1)', font=dict(color='#aaa'), xaxis=dict(gridcolor='rgba(255,255,255,0.04)', title='Trade #'), yaxis=dict(gridcolor='rgba(255,255,255,0.04)', tickprefix='$'), margin=dict(l=60, r=20, t=20, b=40) ) st.plotly_chart(fig2, use_container_width=True) # ── Matched trade table ──────────────────────────────────────────────────── st.divider() st.subheader("Matched Trade Detail") def colour_diff(val): try: v = float(str(val).replace('+', '')) if v > 0: return 'color: #2dc653; font-weight: 600' if v < 0: return 'color: #e63946; font-weight: 600' except: pass return 'color: #666' def colour_profit_cell(val): try: v = float(str(val).replace(',', '')) if v > 0: return 'background-color: rgba(0,180,0,0.10)' if v < 0: return 'background-color: rgba(180,0,0,0.10)' except: pass return '' display = matched[[ 'A_open_time', 'A_symbol', 'A_type', 'A_open_price', 'A_close_price', 'A_profit', 'A_duration', 'B_open_time', 'B_open_price', 'B_close_price', 'B_profit', 'B_duration', 'open_slippage', 'close_slippage', 'profit_var', 'time_diff_min' ]].copy() display.columns = [ 'A Open Time', 'Symbol', 'Type', 'A Entry', 'A Exit', 'A Profit', 'A Dur(m)', 'B Open Time', 'B Entry', 'B Exit', 'B Profit', 'B Dur(m)', 'Entry Slip', 'Exit Slip', 'Profit Var', 'Time Diff(m)' ] # Format numeric columns for col in ['A Entry', 'A Exit', 'B Entry', 'B Exit']: if col in display.columns: display[col] = display[col].apply( lambda x: f"{x:.5f}" if pd.notna(x) else '') for col in ['A Profit', 'B Profit', 'Profit Var']: display[col] = display[col].apply( lambda x: f"{x:+.2f}" if pd.notna(x) else '') for col in ['Entry Slip', 'Exit Slip']: display[col] = display[col].apply( lambda x: f"{x:+.5f}" if pd.notna(x) else '') st.dataframe( display.style .map(colour_diff, subset=['Entry Slip', 'Exit Slip', 'Profit Var', 'Time Diff(m)']) .map(colour_profit_cell, subset=['A Profit', 'B Profit']), use_container_width=True, hide_index=True, height=500 ) # ── Export ──────────────────────────────────────────────────────────────── st.download_button( "⬇ Download matched trades CSV", data = display.to_csv(index=False), file_name = "trade_comparison.csv", mime = 'text/csv' )