Files
mt5-tools/view_trade_compare.py
2026-04-12 18:25:46 +10:00

423 lines
18 KiB
Python

"""
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("""
<div class="info-card">
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.
</div>
""", 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("<br>", 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'
)