From 6b677b8259af7d47d26f01558b86edd193cb9642 Mon Sep 17 00:00:00 2001 From: unknown Date: Sun, 12 Apr 2026 18:25:46 +1000 Subject: [PATCH] Initial commit - MT5 Tools dashboard --- README.md | 48 ++ __pycache__/mt5_parser.cpython-314.pyc | Bin 0 -> 19657 bytes __pycache__/set_comparator.cpython-314.pyc | Bin 0 -> 3865 bytes .../view_set_comparator.cpython-314.pyc | Bin 0 -> 11580 bytes __pycache__/view_settings.cpython-314.pyc | Bin 0 -> 1640 bytes .../view_trade_analysis.cpython-314.pyc | Bin 0 -> 20285 bytes .../view_trade_compare.cpython-314.pyc | Bin 0 -> 25642 bytes app.py | 186 +++++ mt5_batch_backtest.py | 669 ++++++++++++++++++ mt5_batch_config.json | 13 + mt5_parser.py | 389 ++++++++++ requirements.txt | 4 + set_comparator.py | 94 +++ settings.py | 37 + trade_analysis.py | 333 +++++++++ trade_compare.py | 422 +++++++++++ view_set_comparator.py | 215 ++++++ view_settings.py | 37 + view_trade_analysis.py | 333 +++++++++ view_trade_compare.py | 422 +++++++++++ 20 files changed, 3202 insertions(+) create mode 100644 README.md create mode 100644 __pycache__/mt5_parser.cpython-314.pyc create mode 100644 __pycache__/set_comparator.cpython-314.pyc create mode 100644 __pycache__/view_set_comparator.cpython-314.pyc create mode 100644 __pycache__/view_settings.cpython-314.pyc create mode 100644 __pycache__/view_trade_analysis.cpython-314.pyc create mode 100644 __pycache__/view_trade_compare.cpython-314.pyc create mode 100644 app.py create mode 100644 mt5_batch_backtest.py create mode 100644 mt5_batch_config.json create mode 100644 mt5_parser.py create mode 100644 requirements.txt create mode 100644 set_comparator.py create mode 100644 settings.py create mode 100644 trade_analysis.py create mode 100644 trade_compare.py create mode 100644 view_set_comparator.py create mode 100644 view_settings.py create mode 100644 view_trade_analysis.py create mode 100644 view_trade_compare.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..fbef15f --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# MT5 Tools + +Standalone Streamlit dashboard for MT5 trade analysis and comparison. + +## Setup + +```powershell +cd C:\Users\pc\MT5Tools +pip install -r requirements.txt +``` + +## Launch + +```powershell +streamlit run app.py +``` + +## Files + +``` +MT5Tools\ +├── app.py ← main Streamlit app +├── mt5_parser.py ← parsers for all 3 formats + stats +├── mt5_batch_backtest.py ← batch backtest runner (copy from ea\) +├── set_comparator.py ← EA set file comparator (copy from ea\) +├── requirements.txt +├── mt5_batch_config.json ← gitignored, created on first run +└── pages\ + ├── trade_analysis.py ← single report analysis + ├── trade_compare.py ← side-by-side comparison + └── settings.py + +``` + +## Supported Formats + +| Format | Extension | Notes | +|---|---|---| +| MT5 Account History | `.htm` / `.html` | Export from MT5 → Account History → Save as Report | +| MT5 Backtest Report | `.htm` / `.html` | Generated by batch backtest runner or manual tester | +| Quant Analyzer CSV | `.csv` | Export listOfTrades from Quant Analyzer | + +## .gitignore + +Add the following to `.gitignore`: +``` +mt5_batch_config.json +``` diff --git a/__pycache__/mt5_parser.cpython-314.pyc b/__pycache__/mt5_parser.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e488a62d996b2032c43b7e7b08a38f1d5c98cb15 GIT binary patch literal 19657 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 literal 0 HcmV?d00001 diff --git a/__pycache__/view_set_comparator.cpython-314.pyc b/__pycache__/view_set_comparator.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..89df84dbc3ad6994ea4cceed52782d6580277f1b GIT binary patch literal 11580 zcmb_iYj7LabzZz*1PJ0mf*?RF_y!1)A}NUvk&;M>3@J)7y_8ljiYN$xBxDeP7l0m2 zlPGCsj3}8Aq{tO*$s;;-#%$wEIn(@LC+Sp8(y9IxFa!fxvt!rQjN|^IEM=5x+L@la zzyeDN7M0d3Vej3&=bn4+x#xW6+`DX5DdiYkSC4<}TWP?s|3DAyCCyhJZ9?TTMqm+a z1|xXmya=Bu1rZ^X{BhBkC?c}t+Qbnt()nhhMbP=fK z=Y8G_&agM)bO#qhF3J@NQthEj^1+gqd}Iip@Yvs*3VK7e@vQ1)Gjg3}Om=Qgz`H-l%GGxJT)z}DcbOPopK0(fi z9OB~m4r8&t&8mP7p1nZJYEnGIb8s5Uur5AP(as|@q?oB|3zi@uCEou*`X?omHJmQ_ zDr<=$DYToOZx6|)E$rJ9KVWCh*!0grWu%;=InE5(9)yrPS5k(&hg59SM^vIu**1N| zCHidR^@OBEAK5nJNV$C&+_f>fl}}pPITuS-w|@%HoQ^m>>Wci>8d!jil$BwGe!7u; zN6J|ofcKSMBK8TQ$1_MU!kakyoM&yjb|EEaEG5f*hTr=u3>y)9U|~&MPu!%7cy1>W3~8*>bNtW>|jSLmNPuU+>=j-XL!Be2QC_-7U9~-a@LSN zfS7={AQw@~<;ShR2rrJ5!;D^lCm}tlpX@Bk#MX#fhyWH=I-pWccC&58a;mWdD_D3F z*+4duP2BHDZTDZ}bXkhgfswPYv4Gk1~u(ip3 zY^_)lCZdsOV*Rv(7c!FInWJDXV9^D#$(Q6ts)^Bn^Pywv7v#Ym-Yw6s!l~fT9qOFjt)Q~l-4~_6W zPoIe?rSNJ?JT7JJWKGf9L)^71#cc}`mFCu7@SV_hIms48EX|E0n_~JPtH#MuwpJ`* z<;noj%y~qs2HPoGrK~eT8{ON6~|(0FlH^W z19kwN|3|Cm5nZg+6YZR_UeeNuc2?33KG99qFq!Snp8U#%EYpx(=dS4^Y>l)KCN7r1 zb36BW@)ee{SWZu}b#^>fbL=xm_EL7U#R|!qCSb?uQ6e^3u^B$g`vRa8#rulg;S?(w zS;OJ@5;Ab-eBxKz^xc{X*Kqsp%wt3d=kPejN>wbu^LOP|h{VYnVmHx4^cL~u+03N$ zZnPZ>Ss7&fDT9o~xpPr++qswY1~sYNn$55)%$Zdw&RcwVD~D}*Zo!g2sprekc1-5f zWFI>&+W!E*0?}k?`u$t!ajX?%BhftgrwQ}%EtnazF#krRhgbIq%$Se2E#2_T(M`bl z!b?$#55F8o@nQQ*^3&$%qA)**4Qe0UnVPmxGyAY@=E*z!#mkI_Rw74dJFxVo9M4|^ z;m9SwcW>eO44mA$+9KX?#OE2T3r7yZ<0$1?^wyolzgeI{ofloyyf4t^_s&K7aaS}F z+*7KV^35+i*Z!>nP;UguFrD>_x z!tAE9M1HEQANIh$9wVU zl5k*_+8c!(3$}UZ<~96)-|M3AA-|uNg}v@zz~iDW0ktr4Ddb%ejl!TfEr4TnS`OT! zA%D>2aoYG4I-R2huX!&;(Vp|O+dsym2*{bU^CbzKqk}%fOU9{SlyZ9+8SuFvh3C%Z z(TVtybqv(zN27MU)>EG&u3o zTs$J-!w?QwdMY<(MjM3S6r(3IHnENY)=vDA#YCH!#whd&YpG?{<$i6R3PuB-HZYzb z)sIv2v#yrT9XlN8*R^YxV@G@EF56P$S5P8~&fXmk_}AWLTWWq0S)M6F*z_X8JU2hG0a`E*E-CW{qKjVior*&KOpE5gJtMThH5;}mX#uzkEoQI>e1h>vS`?<-&hs?y z94&z9(PFpP?}ul87=G^av^3<7FwR3OQ7y7SdBY1qzlT=jJdy#S!UH&9IE)V3npmX8 zFJHb)w;t%9J|6Z`;pvcj`pB_e$AUqBc)IlGIpBq`*@@%`(>37b0BP+5{-7Ip?``Ka zx?r4e8JNl+FJswCL;Tq711o}CV{bZRqglOSxhAb|jE!XV_)X_E=gRTb;gr53HnL%? zUpesp_`BmD8QZUkMa@zHDJs6ku3Oxze)*|j?S z-kxlg`Nns$4HL`aw`9<^$yZez=518hE=O;f;=`zN{+2mDoUN)}e)YzMY>j!P;&xBA z<<;fUTjLJ}n5q7;7;9)=<^No=*7ehGr|NgFYt!`u%hGI3{mSC~n(jPl*4((tyFHz4 znap>rwLfgdYC1M|Vmf2&$R9T)d>FSV%x&410?g3-G;9E^{94d*pq>Bg4m;d`Q=>rl z_OgSRXo+vPN6{CJ92z5t4BPEFgs#qEX%1R(mzxTP!?{S|OMolW;zbA~(M1Ha7kr+` z0vOIBge!!ofe1jcO-P~hAzHrR^LU+1WTC|iUWDefGKaeG^)oQ%n_p6nKuE%y@Db+g z=aP2H>-V}NxsVVE;wWIzihSgPZ=-%2k3#TFi%5j;6pDG4h8^1Nj<)> zYu4}eFi{Dy2vSa)h>?jx`67kRiYRmt#4w8@xcTgZH}XQ@O9odc*tGXTU>+!e-8 zk5AHNz!2gm^QqanByK#_xY+1f61Kxv-=%V{81RBt7DoqlFV!7q0xs@~;1H(65xgt6 zNH{1i7fQWLt$CVu7$o27nhl=!BAQt*q%WMHYZH6{3dE=baQgxUZqOORehPszg_K00 zkIxixYFj73LWGt=4)mHQcp*TGBEATG+g66X;V?vMXE=g@2U#FW9nhlA1~1Y=zYlte zC|6+KON(ctkw_4_QeG+=pd}vPd9W9X7A*P#w31O|E~S8XLQubl79zz`5cx1#=njT1 z!O}gZEkEYH7&*Y?2(%0>?i@-bU^da&1rP>=;)gu6tdJkl3Pi-@SV0ij z<8uSpqGc1q+CaVpiBg23P{^mtk;<54DX#)Cj0@;Sp(KEU%mWNO67;w((NgF$hyIR$ z7Wx8nL0dDLxWALRcQN;Fm|HIW%Xx+(pN0WhnCAItXEM)YA?1&Duv2?9=(YSH|;K#p%1y2H});ZGTOZd%TyU*O~* zgWNL*9l{~M>k`$Drk0C3AP81)Z;eRNXh^&t_m`L)**!RW~O51&* zeN&7{HMi?BMtjm|zb~=h8~$K8;}}Rf2G&m}9b;KZdFjTJNylhbQc=1w*YkyqUoqyH zq+=YykimF!>H5-7Y^f?oQmDIpFfRN=ro5ut(CBVDt~(Nz9lvP!MRfhuyZpcV^HkTt zdqej%M`MyF0!*&@oS~D-uPUx6;-jmcw6uFep}g96r7xjxT%E`$dQ*ztkE@8dAlqWU z!Oy7iq#9q*rqqoP0u58V2W6_OV^_w0bm)l!xII=QWgYt7q3<6?W81IWZ%?gMty*t6 zQ<}ER6B|lRyyxmnjF(j!mS25GpHW)i|3+1HrmFeFs^+YwYMHleeMb?YCW7(x5ULOyRtol z1!;|GwPW>oQr(^H*x=e*NSz%qVrz_enA9^5>$NK-`JImIzq-Fz_M}9^A zqp}A&!?N*?=RhrM4CR6R5@wdklP2=}E@3|9G$5SRUc7)Szw%j@O_Nhek&^`Tq^DD8V zOw&UZR)s&3i7Mp}rBI9WHzk-#eM7?$d382j(Yc{4&nV4FrFnTFt%TGlt!yp6I+s@3 zR_oHrwnrkqvg4r`7;VZhnRXKczK_c=m376KQaWP%PwN`qxBtYRsvLUPzWnMMZ|zKS z$NrnI$6tTf9+M|3hag2)oz@>G`Lg-}J@gS-k~ilB@Hpr{d0(z9%OCv=YA=xE5GBvsRa? z+!a$i5!T9#kN5L1nF2XR7UrsrpZ>8Wp|&Qh-5ceacvpOOx$4c=-rSebwj{MJs}*T& zYpUFlP_-r6MmGgKNE%CSm3qi;;=8#DUWq`q}^V%?R{x2E*_W8$p7Dkk61 z8*XctPv5ev_NS|Nt@G2>{p$x)`k|QQ(@N`mU77msWPNw0es8jV@A@|r#OXx+-cx^{s$4r2YQDV#=4N10ZH6>U)uOUS9$aB^}`A4?seJSZ{61&kI6n)=wf{v zrrM0jku*70&!kO#8Pj0WG`Q}&r%9O($Bq;mPp3`28Ph=0G_Zc^9zSK8fONgk^JLnz zJ7elkn)=uK?#`x6;8YQu6-mDG^n7A4{ZuCjd|GxzSEYm?@8MCtPibI zDf@6rec*T1wGR>9BTP_L9{a{)(6Qm>%=MY9x@!5-+OfO!pXw~{X)@N%q_s0+9Y|US z*3TrSP9&@YDeK9suI8Dx6DjM+rbuAXY=UuHSE^SO37zAyQc|gkjRO4l4s58k@zLcy zX&c{*=+|c;8 zab@V1`3Hw$2S2IMU)OC|+Osa=bT_#Cc@m%%DkXfx`% z57l+qy0%r{>X`)Im&F}f+?K`rvv}7Z6ygfalX6T``J@(Z*LB%iJg)fISdTU`Em?zc z!(hx9T9SsA)tap@RUh46Tt|_T&TIH_}uUz~{x8q?2R?`6bGdHh%GiBRT_|IN2k9s4pJW66%Odxlg8k!YVnYlWZ8;+5fg%(kQXIvN%CEBWeYEe=%RPSpLrqnW@O_-_`!+oRA z;%k?F+CJ3i>lS_1c%c8BMD%&9VtzpO`98&hTK0RbVxdv-`xeDQpX~Po3ZGK;g<9dW z%D(7UoD<6aQ=&L$klD1f#Od?|-A*TDw#+8S?~70)OqvsN!GT|xa=}9`DHVhvzvc(S zv@qm?ZJ*c|fSr5=3161Vk9`c1e;iEE)SM@Qb{Wr|~38uS+z2Eb0&-?q{-M7}8svk&c2k&EhKf%tVurrUvV*=hj I>NYU_Uy(THJpcdz literal 0 HcmV?d00001 diff --git a/__pycache__/view_settings.cpython-314.pyc b/__pycache__/view_settings.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8037d13e3cad3574b137e3cee00e2c22e47c23a4 GIT binary patch literal 1640 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 GYyBTLWzb>( literal 0 HcmV?d00001 diff --git a/__pycache__/view_trade_compare.cpython-314.pyc b/__pycache__/view_trade_compare.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d6eb6be51611047087f5665d55e67c9fe54c3fda GIT binary patch literal 25642 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 yJvWZMd@R!bcF!9i-8)QTHPN literal 0 HcmV?d00001 diff --git a/app.py b/app.py new file mode 100644 index 0000000..533adf4 --- /dev/null +++ b/app.py @@ -0,0 +1,186 @@ +""" +MT5 Tools Dashboard +=================== +Streamlit app for MT5 trade analysis and comparison. + +Launch: streamlit run app.py +""" + +import streamlit as st +from streamlit_option_menu import option_menu +import importlib, sys, os + +# ── Page config ─────────────────────────────────────────────────────────────── +st.set_page_config( + page_title = "MT5 Tools", + page_icon = "📈", + layout = "wide", + initial_sidebar_state = "expanded" +) + +# ── Theme ───────────────────────────────────────────────────────────────────── +st.markdown(""" + +""", unsafe_allow_html=True) + +# ── Sidebar nav ─────────────────────────────────────────────────────────────── +with st.sidebar: + st.markdown("### 📈 MT5 Tools") + st.markdown("---") + page = option_menu( + menu_title = None, + options = ["Trade Analysis", "Trade Compare", "EA Comparator", "Settings"], + icons = ["bar-chart-line", "arrow-left-right", "sliders", "gear"], + default_index = 0, + styles = { + "container" : {"background-color": "transparent", "padding": "0"}, + "icon" : {"color": "#7c6af7", "font-size": "14px"}, + "nav-link" : { + "font-family" : "Syne, sans-serif", + "font-size" : "13px", + "font-weight" : "600", + "color" : "#aaa", + "border-radius": "6px", + "margin" : "2px 0", + }, + "nav-link-selected": { + "background-color": "rgba(124,106,247,0.15)", + "color" : "#c5beff", + "border" : "1px solid rgba(124,106,247,0.25)", + }, + } + ) + +# ── Route pages ─────────────────────────────────────────────────────────────── +if page == "Trade Analysis": + import view_trade_analysis as p + importlib.reload(p) + p.render() + +elif page == "Trade Compare": + import view_trade_compare as p + importlib.reload(p) + p.render() + +elif page == "EA Comparator": + import view_set_comparator as p + importlib.reload(p) + p.render() + +elif page == "Settings": + import view_settings as p + importlib.reload(p) + p.render() \ No newline at end of file diff --git a/mt5_batch_backtest.py b/mt5_batch_backtest.py new file mode 100644 index 0000000..b027b14 --- /dev/null +++ b/mt5_batch_backtest.py @@ -0,0 +1,669 @@ +""" +MT5 Batch Backtest Runner +========================= +Generates a MT5 tester .ini file for each .set file in a folder, +updates EA_Comment in each .set file, then launches MT5 terminal +for each backtest sequentially. + +On first run: detects MT5 installations and saves config. +Subsequent runs: loads saved config, prompts to use same or modify. + +Usage: python mt5_batch_backtest.py +""" + +import os +import sys +import glob +import subprocess +import time +import shutil +import json + +# ── MT5 Period constants ─────────────────────────────────────────────────────── +PERIOD_MAP = { + 'M1' : 'M1', + 'M5' : 'M5', + 'M15' : 'M15', + 'M30' : 'M30', + 'H1' : 'H1', + 'H4' : 'H4', + 'D' : 'Daily', + 'D1' : 'Daily', + 'DAILY': 'Daily', + 'W1' : 'Weekly', + 'MN' : 'Monthly', +} + +# ── Model labels ───────────────────────────────────────────────────────────── +MODEL_LABELS = { + '1' : 'OHLC', + '2' : 'CTRLPTS', + '4' : 'EVERYTICK', + '5' : 'EVERYTICKREAL', +} + +# ── Defaults (used if no config found) ──────────────────────────────────────── +DEFAULTS = { + 'terminal_path' : r"C:\Program Files\MetaTrader 5\terminal64.exe", + 'tester_folder' : '', + 'ea_name' : r"Market\Ultimate Breakout System.ex5", + 'from_date' : '2018.01.01', + 'to_date' : '2026.04.01', + 'model' : '1', + 'deposit' : '10000', + 'currency' : 'USD', + 'leverage' : '100', + 'optimization' : '0', + 'suffix' : '.a', +} + +CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'mt5_batch_config.json') + + +# ── Helpers ──────────────────────────────────────────────────────────────────── + +def prompt(text, default=None): + if default is not None and default != '': + val = input(f" {text} [{default}]: ").strip() + return val if val else default + else: + while True: + val = input(f" {text}: ").strip() + if val: + return val + print(" (required)") + + +def load_config(): + if os.path.isfile(CONFIG_FILE): + try: + with open(CONFIG_FILE, 'r') as f: + return json.load(f) + except: + pass + return None + + +def save_config(cfg): + try: + with open(CONFIG_FILE, 'w') as f: + json.dump(cfg, f, indent=2) + except Exception as e: + print(f" WARNING: Could not save config: {e}") + + +def find_mt5_terminals(): + """Scan MetaQuotes Terminal folder for all MT5 installations.""" + appdata = os.environ.get('APPDATA', '') + base = os.path.join(appdata, 'MetaQuotes', 'Terminal') + results = [] + if not os.path.isdir(base): + return results + for entry in os.listdir(base): + entry_path = os.path.join(base, entry) + if not os.path.isdir(entry_path): + continue + # Check for origin.txt which contains the terminal exe path + origin = os.path.join(entry_path, 'origin.txt') + tester = os.path.join(entry_path, 'Tester') + label = entry + if os.path.isfile(origin): + try: + with open(origin, 'r', encoding='utf-8', errors='replace') as f: + label = f.read().strip() or entry + except: + pass + # Skip folders that don't look like real MT5 terminals + if not os.path.isdir(os.path.join(entry_path, 'MQL5')): + continue + results.append({ + 'id' : entry, + 'label' : label, + 'tester_folder' : tester, + 'data_folder' : entry_path, + }) + return results + + +def pick_terminal(): + """Let user pick from detected MT5 terminals.""" + terminals = find_mt5_terminals() + if not terminals: + print(" No MT5 terminals found in AppData\\MetaQuotes\\Terminal\\") + print(" You will need to enter the tester folder path manually.") + return None, None + + print() + print(" Detected MT5 terminal(s):") + for i, t in enumerate(terminals, 1): + print(f" {i}) {t['label']}") + print(f" Tester: {t['tester_folder']}") + + if len(terminals) == 1: + choice = input(f"\n Select terminal [1]: ").strip() + idx = 0 + else: + while True: + choice = input(f"\n Select terminal [1-{len(terminals)}]: ").strip() + try: + idx = int(choice) - 1 + if 0 <= idx < len(terminals): + break + except: + pass + print(" Invalid selection.") + + selected = terminals[idx] + return selected['tester_folder'], selected['label'] + + +def find_ea_files(tester_folder): + """ + Scan MQL5/Experts folder for .ex5 files. + Returns list of dicts with label (display) and value (ini path). + """ + experts_dir = os.path.join(os.path.dirname(tester_folder), 'MQL5', 'Experts') + results = [] + if not os.path.isdir(experts_dir): + return results + for root, dirs, files in os.walk(experts_dir): + dirs[:] = [d for d in dirs if not d.startswith('.')] + for fn in sorted(files): + if fn.lower().endswith('.ex5'): + full_path = os.path.join(root, fn) + rel = os.path.relpath(full_path, experts_dir) + # Only include EAs in the Market subfolder + if rel.startswith('Market' + os.sep) or rel.startswith('Market/'): + results.append({'label': rel, 'value': rel}) + return results + + +def pick_ea_name(tester_folder, current=None): + """List available EAs and let user pick, or enter manually.""" + eas = find_ea_files(tester_folder) + + if not eas: + print(" No .ex5 files found in MQL5/Experts -- enter EA name manually.") + return prompt("EA Name", current or DEFAULTS['ea_name']) + + print() + print(" Available EAs:") + for i, ea in enumerate(eas, 1): + marker = ' <' if current and ea['value'] == current else '' + print(f" {i:>3}) {ea['label']}{marker}") + print(f" {len(eas)+1:>3}) Enter manually") + + while True: + default_idx = None + if current: + for i, ea in enumerate(eas, 1): + if ea['value'] == current: + default_idx = i + break + hint = f"1-{len(eas)+1}" + (f", Enter={default_idx}" if default_idx else "") + choice = input(f" Select EA [{hint}]: ").strip() + + if choice == '' and default_idx: + return eas[default_idx - 1]['value'] + try: + idx = int(choice) - 1 + if idx == len(eas): + return prompt("EA Name", current or DEFAULTS['ea_name']) + if 0 <= idx < len(eas): + return eas[idx]['value'] + except: + pass + print(" Invalid selection.") + + +def setup_config(): + """First-run setup — detect terminals and build config.""" + print() + print(" ── First Run Setup ──────────────────────────────────────") + + tester_folder, terminal_label = pick_terminal() + + if not tester_folder: + tester_folder = prompt("Tester folder path") + + terminal_path = prompt("Path to terminal64.exe", DEFAULTS['terminal_path']) + + print() + print(" ── Backtest Defaults ────────────────────────────────────") + cfg = { + 'terminal_path' : terminal_path, + 'tester_folder' : tester_folder, + 'terminal_label': terminal_label or '', + 'ea_name' : pick_ea_name(tester_folder, DEFAULTS['ea_name']), + 'from_date' : prompt("From Date (YYYY.MM.DD)", DEFAULTS['from_date']), + 'to_date' : prompt("To Date (YYYY.MM.DD)", DEFAULTS['to_date']), + 'model' : prompt("Model (1=OHLC M1, 2=Control points, 4=Every tick)", DEFAULTS['model']), + 'deposit' : prompt("Deposit", DEFAULTS['deposit']), + 'currency' : prompt("Currency", DEFAULTS['currency']), + 'leverage' : prompt("Leverage", DEFAULTS['leverage']), + 'suffix' : prompt("Instrument suffix (e.g. .a)", DEFAULTS['suffix']), + } + save_config(cfg) + print() + print(f" Config saved to: {CONFIG_FILE}") + return cfg + + +def review_config(cfg): + """Show saved config and ask to use same or modify.""" + print() + print(" ── Saved Settings ───────────────────────────────────────") + print(f" Terminal : {cfg.get('terminal_label', cfg['tester_folder'])}") + print(f" Tester : {cfg['tester_folder']}") + print(f" EA : {cfg['ea_name']}") + print(f" Dates : {cfg['from_date']} → {cfg['to_date']}") + print(f" Model : {cfg['model']} Deposit: {cfg['deposit']} {cfg['currency']} Leverage: {cfg['leverage']}") + print(f" Suffix : {cfg['suffix']}") + print() + choice = input(" Use these settings? [Y/n/reset]: ").strip().lower() + + if choice == 'reset': + os.remove(CONFIG_FILE) + print(" Config reset — re-running setup.") + return setup_config() + + if choice == 'n': + print() + print(" ── Modify Settings ──────────────────────────────────────") + redetect = input(" Re-detect MT5 terminals? [y/N]: ").strip().lower() + if redetect == 'y': + tester_folder, terminal_label = pick_terminal() + if tester_folder: + cfg['tester_folder'] = tester_folder + cfg['terminal_label'] = terminal_label or '' + + cfg['terminal_path'] = prompt("terminal64.exe path", cfg['terminal_path']) + cfg['ea_name'] = pick_ea_name(cfg['tester_folder'], cfg['ea_name']) + cfg['from_date'] = prompt("From Date", cfg['from_date']) + cfg['to_date'] = prompt("To Date", cfg['to_date']) + cfg['model'] = prompt("Model (1=OHLC M1, 2=Control points, 4=Every tick)", cfg['model']) + cfg['deposit'] = prompt("Deposit", cfg['deposit']) + cfg['currency'] = prompt("Currency", cfg['currency']) + cfg['leverage'] = prompt("Leverage", cfg['leverage']) + cfg['suffix'] = prompt("Suffix", cfg['suffix']) + save_config(cfg) + print(" Config updated.") + + return cfg + + +def detect_timeframe(filename): + name = os.path.splitext(filename)[0].upper() + for token in PERIOD_MAP: + if name.endswith('_' + token) or name.endswith('-' + token) or \ + ('_' + token + '_') in name or ('-' + token + '-') in name: + return PERIOD_MAP[token] + return None + + +def detect_instrument(filename, n_chars): + return os.path.splitext(filename)[0][:n_chars].upper() + + +def read_utf16(path): + with open(path, 'rb') as f: + raw = f.read() + if raw[:2] == b'\xff\xfe': + text = raw[2:].decode('utf-16-le') + elif raw[:2] == b'\xfe\xff': + text = raw[2:].decode('utf-16-be') + else: + text = raw.decode('utf-8', errors='replace') + return text.splitlines() + + +def write_utf16(path, lines): + text = '\r\n'.join(lines) + '\r\n' + with open(path, 'wb') as f: + f.write(b'\xff\xfe') + f.write(text.encode('utf-16-le')) + + +def update_set_file(set_path, ea_comment, lot_mode, lot_value): + lines = read_utf16(set_path) + + def update_param(lines, key, new_val): + for i, line in enumerate(lines): + if line.strip().startswith(key + '='): + parts = line.strip().split('||') + parts[0] = f'{key}={new_val}' + lines[i] = '||'.join(parts) + return True + return False + + # Always update EA_Comment + updated = False + for i, line in enumerate(lines): + if line.strip().startswith('EA_Comment='): + lines[i] = f'EA_Comment={ea_comment}' + updated = True + break + if not updated: + lines.append(f'EA_Comment={ea_comment}') + + if lot_mode == 'manual': + update_param(lines, 'Risk', '0') + update_param(lines, 'StartLots', str(lot_value)) + elif lot_mode == 'balance': + update_param(lines, 'Risk', '9999') + update_param(lines, 'LotPerBalance_step', str(lot_value)) + # lot_mode None/'asis': only EA_Comment updated + + write_utf16(set_path, lines) + + +def build_ini(symbol, period, set_file_path, ini_out_path, report_folder, cfg): + name_stem = os.path.splitext(os.path.basename(set_file_path))[0] + model_label = MODEL_LABELS.get(cfg['model'], f"M{cfg['model']}") + report_name = f"{name_stem}_{model_label}" + content = ( + '[Tester]\r\n' + f'Expert={cfg["ea_name"]}\r\n' + f'Symbol={symbol}\r\n' + f'Period={period}\r\n' + f'Optimization={cfg.get("optimization","0")}\r\n' + f'Model={cfg["model"]}\r\n' + f'FromDate={cfg["from_date"]}\r\n' + f'ToDate={cfg["to_date"]}\r\n' + 'ForwardMode=0\r\n' + f'Deposit={cfg["deposit"]}\r\n' + f'Currency={cfg["currency"]}\r\n' + 'ProfitInPips=0\r\n' + f'Leverage={cfg["leverage"]}\r\n' + 'ExecutionMode=0\r\n' + 'OptimizationCriterion=0\r\n' + 'Visual=0\r\n' + f'Report={report_name}\r\n' + 'ReplaceReport=1\r\n' + f'Inputs={set_file_path}\r\n' + 'ShutdownTerminal=1\r\n' + ) + with open(ini_out_path, 'wb') as f: + f.write(content.encode('utf-8')) + + +# ── Main ─────────────────────────────────────────────────────────────────────── + +def main(): + print("=" * 60) + print(" MT5 Batch Backtest Runner") + print("=" * 60) + + # ── Load or create config ────────────────────────────────────── + cfg = load_config() + if cfg is None: + cfg = setup_config() + else: + cfg = review_config(cfg) + + terminal_path = cfg['terminal_path'] + tester_folder = cfg['tester_folder'] + os.makedirs(tester_folder, exist_ok=True) + + if not os.path.isfile(terminal_path): + print(f"\n WARNING: terminal64.exe not found at: {terminal_path}") + + # ── Folder of set files ──────────────────────────────────────── + print() + set_folder = prompt("Path to folder containing .set files") + set_folder = os.path.expandvars(set_folder.strip('"').strip("'")) + if not os.path.isdir(set_folder): + print(f" ERROR: Folder not found: {set_folder}") + sys.exit(1) + + all_set_files = sorted(glob.glob(os.path.join(set_folder, '**', '*.set'), recursive=True)) + set_files = [f for f in all_set_files + if not os.path.basename(f).lower().startswith('optimization') + and '_batch_modified' not in f.replace('\\', '/')] + skipped = len(all_set_files) - len(set_files) + if not set_files: + print(" ERROR: No .set files found (after exclusions).") + sys.exit(1) + print(f" Found {len(set_files)} .set file(s).", end='') + if skipped: + print(f" ({skipped} Optimization file(s) excluded)", end='') + print() + + # ── Report output folder ─────────────────────────────────────── + default_reports = os.path.join(set_folder, 'reports') + report_folder = prompt("Path to save reports", default_reports) + report_folder = report_folder.strip('"').strip("'") + os.makedirs(report_folder, exist_ok=True) + + # ── Lot size mode ────────────────────────────────────────────── + print() + print(" Lot size mode:") + print(" 0 = Use set file as-is (no changes to Risk/Lots)") + print(" 1 = Manual lot size (same for all) — sets Risk=0, StartLots=X") + print(" 2 = Lots per balance (from each .set file) — sets Risk=9999") + print(" 3 = Lots per balance (enter per file) — sets Risk=9999") + lot_mode_choice = prompt("Choose [0/1/2/3]", "2") + + lot_mode = {'0': 'asis', '1': 'manual'}.get(lot_mode_choice, 'balance') + balance_ask = lot_mode_choice == '3' + manual_lots = None + + if lot_mode == 'asis': + print(" Set files used as-is — no Risk/Lots changes.") + elif lot_mode == 'manual': + manual_lots = prompt("StartLots for all files", "0.01") + elif balance_ask: + print(" Will prompt LotPerBalance_step per file. Risk=9999.") + else: + print(" Using LotPerBalance_step from each file. Risk=9999.") + + # ── Instrument mode ──────────────────────────────────────────── + print() + print(" Instrument detection:") + print(" 1 = Enter one instrument for all set files") + print(" 2 = Extract from filename (specify number of characters)") + print(" 3 = Ask per file") + instr_mode = prompt("Choose [1/2/3]", "1") + instr_global = None + instr_n_chars = None + + if instr_mode == '1': + instr_global = prompt("Instrument (without suffix, e.g. GBPJPY)").upper() + elif instr_mode == '2': + instr_n_chars = int(prompt("Characters from start of filename", "6")) + + # ── Timeframe mode ───────────────────────────────────────────── + print() + print(" Timeframe:") + print(" 1 = One timeframe for all set files") + print(" 2 = Detect from filename (D/Daily/H1/H4 etc.)") + print(" 3 = Ask per file") + tf_mode = prompt("Choose [1/2/3]", "2") + tf_global = None + + if tf_mode == '1': + tf_raw = prompt("Timeframe (e.g. Daily, H1, H4, M15)").upper() + tf_global = PERIOD_MAP.get(tf_raw, tf_raw) + + # ── Output folder for modified .set copies ───────────────────── + # (subfolders mirrored inside _batch_modified and reports) + out_set_base = os.path.join(set_folder, '_batch_modified') + os.makedirs(out_set_base, exist_ok=True) + + # ── Process each set file ────────────────────────────────────── + print() + print("=" * 60) + print(f" Processing {len(set_files)} file(s)...") + print("=" * 60) + + results = [] + + for set_path in set_files: + filename = os.path.basename(set_path) + name_stem = os.path.splitext(filename)[0] + + # Mirror subfolder structure from set_folder root + rel_path = os.path.relpath(set_path, set_folder) + rel_subdir = os.path.dirname(rel_path) + out_set_folder = os.path.join(out_set_base, rel_subdir) if rel_subdir else out_set_base + file_report_dir = os.path.join(report_folder, rel_subdir) if rel_subdir else report_folder + os.makedirs(out_set_folder, exist_ok=True) + os.makedirs(file_report_dir, exist_ok=True) + + subfolder_label = f" ({rel_subdir})" if rel_subdir else "" + print(f"\n [{filename}]{subfolder_label}") + + # Instrument + if instr_mode == '1': + instrument = instr_global + elif instr_mode == '2': + instrument = detect_instrument(filename, instr_n_chars) + print(f" Instrument: {instrument}") + else: + instrument = prompt(f"Instrument for {filename} (without suffix)").upper() + + symbol = instrument + cfg['suffix'] + + # Timeframe + if tf_mode == '1': + period = tf_global + elif tf_mode == '2': + period = detect_timeframe(filename) + if period: + print(f" Timeframe : {period}") + else: + tf_raw = prompt(f"Timeframe for {filename} (e.g. Daily, H1, H4)").upper() + period = PERIOD_MAP.get(tf_raw, tf_raw) + else: + tf_raw = prompt(f"Timeframe for {filename}").upper() + period = PERIOD_MAP.get(tf_raw, tf_raw) + + # Lot value + if lot_mode == 'asis': + lot_value = None + print(f" Lots : as-is") + elif lot_mode == 'manual': + lot_value = manual_lots + print(f" Lots : Manual StartLots={lot_value}") + else: + if balance_ask: + file_lines = read_utf16(set_path) + file_lot = None + for line in file_lines: + if line.strip().startswith('LotPerBalance_step='): + parts = line.strip().split('||') + file_lot = parts[0].replace('LotPerBalance_step=', '').strip() + break + lot_value = prompt(f"LotPerBalance_step for {filename}", file_lot or "100") + else: + file_lines = read_utf16(set_path) + lot_value = None + for line in file_lines: + if line.strip().startswith('LotPerBalance_step='): + parts = line.strip().split('||') + lot_value = parts[0].replace('LotPerBalance_step=', '').strip() + break + if lot_value is None: + lot_value = prompt(f"LotPerBalance_step not found, enter value", "100") + print(f" Lots : Balance LotPerBalance_step={lot_value} Risk=9999") + + # EA_Comment + model_label = MODEL_LABELS.get(cfg['model'], f"M{cfg['model']}") + ea_comment = f"{name_stem} {symbol} {period} {model_label}" + print(f" EA_Comment: {ea_comment}") + + # Copy and modify set file + modified_set = os.path.join(out_set_folder, filename) + shutil.copy2(set_path, modified_set) + if lot_mode == 'asis': + update_set_file(modified_set, ea_comment, None, None) + else: + update_set_file(modified_set, ea_comment, lot_mode, lot_value) + + # Write ini + model_label = MODEL_LABELS.get(cfg['model'], f"M{cfg['model']}") + report_name = f"{name_stem}_{model_label}" + ini_path = os.path.join(tester_folder, f"{name_stem}.ini") + build_ini(symbol, period, modified_set, ini_path, report_folder, cfg) + print(f" INI : {ini_path}") + print(f" Report as : {report_name}.htm") + + # Launch MT5 minimised + cmd = [terminal_path, f'/config:{ini_path}'] + print(f" Launching MT5", end='', flush=True) + si = subprocess.STARTUPINFO() + si.dwFlags |= subprocess.STARTF_USESHOWWINDOW + si.wShowWindow = 6 # SW_MINIMIZE + proc = subprocess.Popen(cmd, startupinfo=si) + while proc.poll() is None: + time.sleep(10) + print('.', end='', flush=True) + print(f" done (exit {proc.returncode})") + + # Copy report files from MT5 terminal folder + search_dirs = [ + os.path.dirname(tester_folder), + os.path.join(os.path.dirname(tester_folder), 'MQL5', 'Profiles', 'Tester'), + tester_folder, + ] + + success = False + for search_dir in search_dirs: + htm_src = os.path.join(search_dir, report_name + '.htm') + if os.path.isfile(htm_src): + htm_dest = os.path.join(file_report_dir, report_name + '.htm') + shutil.copy2(htm_src, htm_dest) + copied = [report_name + '.htm'] + for fn in os.listdir(search_dir): + if fn.startswith(report_name) and not fn.endswith('.htm'): + shutil.copy2(os.path.join(search_dir, fn), + os.path.join(file_report_dir, fn)) + copied.append(fn) + # Remove from MT5 folder + os.remove(htm_src) + for fn in os.listdir(search_dir): + if fn.startswith(report_name) and not fn.endswith('.htm'): + try: + os.remove(os.path.join(search_dir, fn)) + except: + pass + print(f" Report : {len(copied)} file(s) → {file_report_dir}") + success = True + break + + if not success: + print(f" FAIL : Report not found. Checked:") + for d in search_dirs: + print(f" {d}") + + results.append({ + 'file' : filename, + 'subfolder': rel_subdir, + 'symbol' : symbol, + 'period' : period, + 'success' : success, + }) + + # ── Summary ──────────────────────────────────────────────────── + print() + print("=" * 60) + print(" SUMMARY") + print("=" * 60) + passed = [r for r in results if r['success']] + failed = [r for r in results if not r['success']] + print(f" Completed: {len(passed)}/{len(results)}") + if failed: + print(f"\n Failed (no report generated):") + for r in failed: + subfolder = f" [{r['subfolder']}]" if r.get('subfolder') else "" + print(f" - {r['file']}{subfolder} ({r['symbol']} {r['period']})") + failed_log = os.path.join(report_folder, 'failed_backtests.txt') + with open(failed_log, 'w') as f: + for r in failed: + f.write(f"{r['file']}\t{r['symbol']}\t{r['period']}\n") + print(f"\n Failed list saved to: {failed_log}") + print() + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/mt5_batch_config.json b/mt5_batch_config.json new file mode 100644 index 0000000..5575889 --- /dev/null +++ b/mt5_batch_config.json @@ -0,0 +1,13 @@ +{ + "terminal_path": "C:\\Program Files\\MetaTrader 5\\terminal64.exe", + "tester_folder": "C:\\Users\\pc\\AppData\\Roaming\\MetaQuotes\\Terminal\\D0E8209F77C8CF37AD8BF550E51FF075\\Tester", + "terminal_label": "\ufffd\ufffdC\u0000:\u0000\\\u0000P\u0000r\u0000o\u0000g\u0000r\u0000a\u0000m\u0000 \u0000F\u0000i\u0000l\u0000e\u0000s\u0000\\\u0000M\u0000e\u0000t\u0000a\u0000T\u0000r\u0000a\u0000d\u0000e\u0000r\u0000 \u00005\u0000", + "ea_name": "Market\\Ultimate Breakout System.ex5", + "from_date": "2018.01.01", + "to_date": "2026.04.01", + "model": "1", + "deposit": "10000", + "currency": "USD", + "leverage": "100", + "suffix": ".a" +} \ No newline at end of file diff --git a/mt5_parser.py b/mt5_parser.py new file mode 100644 index 0000000..32956fc --- /dev/null +++ b/mt5_parser.py @@ -0,0 +1,389 @@ +""" +mt5_parser.py +============= +Parsers for three MT5 trade report formats: + 1. MT5 Real Account HTM export + 2. MT5 Backtest HTM report + 3. Quant Analyzer CSV export + +All normalise to a common DataFrame schema. +""" + +import pandas as pd +import re + + +# ── Common schema ───────────────────────────────────────────────────────────── +# open_time, close_time, symbol, type, volume, open_price, close_price, +# sl, tp, commission, swap, profit, net_profit, comment, strategy, +# duration_min, win, day_of_week, hour, source + +def _decode(file_bytes): + for enc in ['utf-16', 'utf-8', 'latin-1', 'cp1252']: + try: + return file_bytes.decode(enc) + except: + continue + return '' + + +def _strip(s): + return re.sub(r'<[^>]+>', '', s).strip().replace('\xa0', '').replace('\u00a0', '') + + +def _to_float(s): + try: + return float(str(s).replace(' ', '').replace(',', '')) + except: + return None + + +def _to_dt(s, fmt='%Y.%m.%d %H:%M:%S'): + return pd.to_datetime(s, format=fmt, errors='coerce') + + +def _enrich(df): + """Add derived columns common to all formats.""" + df['open_time'] = pd.to_datetime(df['open_time'], errors='coerce') + df['close_time'] = pd.to_datetime(df['close_time'], errors='coerce') + df['open_date'] = df['open_time'].dt.date + df['close_date'] = df['close_time'].dt.date + df['day_of_week'] = df['open_time'].dt.day_name() + df['hour'] = df['open_time'].dt.hour + df['duration_min'] = ((df['close_time'] - df['open_time']) + .dt.total_seconds() / 60).round(1) + for col in ['volume', 'open_price', 'close_price', 'sl', 'tp', + 'commission', 'swap', 'profit']: + if col in df.columns: + df[col] = pd.to_numeric( + df[col].astype(str).str.replace(' ', '').str.replace(',', ''), + errors='coerce' + ) + if 'net_profit' not in df.columns: + df['net_profit'] = ( + df.get('profit', 0).fillna(0) + + df.get('commission', 0).fillna(0) + + df.get('swap', 0).fillna(0) + ) + df['win'] = df['net_profit'] > 0 + df['type'] = df['type'].str.lower().str.strip() + df['strategy'] = df['comment'].apply(extract_strategy) + # Normalise symbol — strip .a suffix for display matching + df['symbol_base'] = df['symbol'].str.replace(r'\.[a-z]+$', '', regex=True).str.upper() + return df + + +# ── Format 1: Real Account HTM ──────────────────────────────────────────────── + +def parse_mt5_report(file_bytes): + """Parse MT5 real account HTML trade history report.""" + text = _decode(file_bytes) + rows = re.findall(r']*>(.*?)', text, re.DOTALL) + trades = [] + in_trades = False + + COLS = ['open_time', 'position', 'symbol', 'type', 'comment', 'volume', + 'open_price', 'sl', 'tp', 'close_time', 'close_price', + 'commission', 'swap', 'profit'] + + for row in rows: + cells = re.findall(r']*>(.*?)', row, re.DOTALL) + cells = [re.sub(r'\s+', ' ', _strip(c)).strip() for c in cells] + + if cells and cells[0] == 'Time' and len(cells) >= 13: + in_trades = True + continue + if not in_trades: + continue + if cells and any(kw in cells[0] for kw in + ['Total Net Profit', 'Results', 'Balance', 'Equity']): + break + + if len(cells) >= 14 and re.match(r'\d{4}\.\d{2}\.\d{2}', cells[0]): + last = [c.lower() for c in cells if c] + if any(s in last for s in ['placed', 'cancelled', 'expired', 'partial']): + continue + if len(cells) < 10 or not re.match(r'\d{4}\.\d{2}\.\d{2}', cells[9]): + continue + if '/' in str(cells[5]): + continue + try: + trade = dict(zip(COLS, cells[:14])) + trades.append(trade) + except: + continue + + if not trades: + return None + + df = pd.DataFrame(trades) + df['source'] = 'real' + return _enrich(df) + + +# ── Format 2: Backtest HTM ──────────────────────────────────────────────────── + +def parse_backtest_report(file_bytes): + """ + Parse MT5 Strategy Tester HTML report. + Pairs in/out deals into complete trades. + """ + text = _decode(file_bytes) + tables = re.findall(r']*>(.*?)', text, re.DOTALL) + if len(tables) < 2: + return None + + rows = re.findall(r']*>(.*?)', tables[1], re.DOTALL) + + # Find deals section + in_deals = False + deal_rows = [] + for row in rows: + cells = [re.sub(r'\s+', ' ', _strip(c)).strip() + for c in re.findall(r']*>(.*?)', row, re.DOTALL)] + cells = [c for c in cells if c] + if not cells: + continue + if 'Deals' in cells: + in_deals = True + continue + if in_deals and cells[0] == 'Time' and 'Deal' in cells: + continue # header row + if in_deals and len(cells) >= 7 and re.match(r'\d{4}\.\d{2}\.\d{2}', cells[0]): + deal_rows.append(cells) + + if not deal_rows: + return None + + # Columns: Time, Deal, Symbol, Type, Direction, Volume, Price, Order, + # Commission, Swap, Profit, Balance, Comment + DEAL_COLS = ['time', 'deal', 'symbol', 'type', 'direction', 'volume', + 'price', 'order', 'commission', 'swap', 'profit', 'balance', 'comment'] + + deals = [] + for row in deal_rows: + d = dict(zip(DEAL_COLS, row[:len(DEAL_COLS)])) + deals.append(d) + + df_deals = pd.DataFrame(deals) + df_deals = df_deals[df_deals['direction'].isin(['in', 'out'])] + + # Match in/out pairs — pair consecutive in→out by symbol+type + open_stack = {} # key: (symbol, type_) -> list of open deals + trades = [] + + for _, deal in df_deals.iterrows(): + sym = deal.get('symbol', '') + typ = deal.get('type', '') + dirn = deal.get('direction', '') + key = (sym, typ) + + if dirn == 'in': + open_stack.setdefault(key, []).append(deal) + elif dirn == 'out': + stack = open_stack.get(key, []) + if stack: + entry = stack.pop(0) + trades.append({ + 'open_time' : entry['time'], + 'close_time' : deal['time'], + 'symbol' : sym, + 'type' : typ, + 'volume' : entry['volume'], + 'open_price' : entry['price'], + 'close_price': deal['price'], + 'sl' : None, + 'tp' : None, + 'commission' : _to_float(entry.get('commission', 0)), + 'swap' : _to_float(deal.get('swap', 0)), + 'profit' : _to_float(deal.get('profit', 0)), + 'comment' : deal.get('comment', ''), + 'position' : entry.get('deal', ''), + }) + + if not trades: + return None + + df = pd.DataFrame(trades) + df['source'] = 'backtest' + return _enrich(df) + + +# ── Format 3: Quant Analyzer CSV ───────────────────────────────────────────── + +def parse_quant_csv(file_bytes): + """Parse Quant Analyzer listOfTrades CSV export.""" + try: + text = file_bytes.decode('utf-8-sig') + except: + text = file_bytes.decode('latin-1') + + from io import StringIO + df_raw = pd.read_csv(StringIO(text)) + + # Normalise column names + df_raw.columns = [c.strip().lower().replace(' ', '_').replace('/', '_').replace('(', '').replace(')', '') for c in df_raw.columns] + + col_map = { + 'open_time' : ['open_time_$', 'open_time_$_', 'open_time', 'opentime'], + 'close_time' : ['close_time_$', 'close_time_$_', 'close_time', 'closetime'], + 'symbol' : ['symbol_$', 'symbol_$_', 'symbol'], + 'type' : ['type_$', 'type_$_', 'type', 'direction'], + 'volume' : ['size_$', 'size_$_', 'size', 'volume', 'lots'], + 'open_price' : ['open_price_$', 'open_price_$_', 'open_price', 'openprice'], + 'close_price' : ['close_price_$', 'close_price_$_', 'close_price', 'closeprice'], + 'profit' : ['profit_loss_$', 'profit_loss_$_', 'profit_loss', 'profit', 'net_profit'], + 'commission' : ['comm_swap_$', 'comm_swap_$_', 'commission', 'comm'], + 'swap' : ['swap_$', 'swap'], + 'sl' : ['stop_loss_$', 'stop_loss_$_', 'stop_loss', 'sl'], + 'comment' : ['comment_$', 'comment_$_', 'comment'], + 'strategy' : ['strategy_name_$', 'strategy_name_$_', 'strategy_name', 'strategy'], + 'mae' : ['mae_$', 'mae_$_', 'mae'], + 'mfe' : ['mfe_$', 'mfe_$_', 'mfe'], + 'drawdown' : ['drawdown_$', 'drawdown_$_', 'drawdown'], + } + + result = {} + for target, candidates in col_map.items(): + for cand in candidates: + if cand in df_raw.columns: + result[target] = df_raw[cand] + break + + df = pd.DataFrame(result) + + # Parse datetimes — QA uses DD.MM.YYYY HH:MM:SS + for col in ['open_time', 'close_time']: + if col in df.columns: + df[col] = pd.to_datetime(df[col], format='%d.%m.%Y %H:%M:%S', errors='coerce') + if df[col].isna().all(): + df[col] = pd.to_datetime(df[col], infer_datetime_format=True, errors='coerce') + + # QA comm_swap is combined — split evenly as approximation if no separate swap + if 'commission' in df.columns and 'swap' not in df.columns: + df['swap'] = 0.0 + + if 'sl' not in df.columns: + df['sl'] = None + if 'tp' not in df.columns: + df['tp'] = None + if 'position' not in df.columns: + df['position'] = df.get('ticket', range(len(df))) + + df['source'] = 'quant_csv' + + # Add extra QA-specific columns if present + for extra in ['mae', 'mfe', 'drawdown']: + if extra in df_raw.columns: + df[extra] = pd.to_numeric(df_raw[extra], errors='coerce') + + return _enrich(df) + + +# ── Auto-detect format ──────────────────────────────────────────────────────── + +def detect_and_parse(file_bytes, filename=''): + """ + Auto-detect file format and parse. + Returns (df, format_name) or (None, None). + """ + fname = filename.lower() + + if fname.endswith('.csv'): + df = parse_quant_csv(file_bytes) + return df, 'Quant Analyzer CSV' + + # HTML/HTM — detect backtest vs real account + try: + text = _decode(file_bytes) + except: + return None, None + + if 'Strategy Tester Report' in text or 'strategy tester' in text.lower(): + df = parse_backtest_report(file_bytes) + return df, 'MT5 Backtest Report' + + df = parse_mt5_report(file_bytes) + return df, 'MT5 Account History' + + +# ── Stats ───────────────────────────────────────────────────────────────────── + +def calc_stats(df): + if df is None or len(df) == 0: + return {} + + total = len(df) + wins = df[df['win'] == True] + losses = df[df['win'] == False] + win_rate = round(len(wins) / total * 100, 1) if total > 0 else 0 + gross_profit = round(wins['net_profit'].sum(), 2) + gross_loss = round(losses['net_profit'].sum(), 2) + net_profit = round(df['net_profit'].sum(), 2) + pf = round(abs(gross_profit / gross_loss), 2) if gross_loss != 0 else float('inf') + avg_win = round(wins['net_profit'].mean(), 2) if len(wins) > 0 else 0 + avg_loss = round(losses['net_profit'].mean(), 2) if len(losses) > 0 else 0 + rr = round(abs(avg_win / avg_loss), 2) if avg_loss != 0 else float('inf') + expectancy = round((win_rate/100 * avg_win) + ((1 - win_rate/100) * avg_loss), 2) + + results = df.sort_values('close_time')['win'].tolist() + max_cw = _max_consec(results, True) + max_cl = _max_consec(results, False) + + cumulative = df.sort_values('close_time')['net_profit'].cumsum() + rolling_max = cumulative.cummax() + max_dd = round((cumulative - rolling_max).min(), 2) + + avg_dur = round(df['duration_min'].mean(), 1) if 'duration_min' in df.columns else 0 + avg_win_dur = round(wins['duration_min'].mean(), 1) if len(wins) > 0 else 0 + avg_los_dur = round(losses['duration_min'].mean(), 1) if len(losses) > 0 else 0 + + longs = df[df['type'] == 'buy'] + shorts = df[df['type'] == 'sell'] + + return { + 'total_trades' : total, + 'win_rate' : win_rate, + 'net_profit' : net_profit, + 'gross_profit' : gross_profit, + 'gross_loss' : gross_loss, + 'profit_factor' : pf, + 'avg_win' : avg_win, + 'avg_loss' : avg_loss, + 'rr_ratio' : rr, + 'expectancy' : expectancy, + 'max_consec_wins' : max_cw, + 'max_consec_losses' : max_cl, + 'max_drawdown' : max_dd, + 'best_trade' : round(df['net_profit'].max(), 2), + 'worst_trade' : round(df['net_profit'].min(), 2), + 'avg_duration_min' : avg_dur, + 'avg_win_duration' : avg_win_dur, + 'avg_loss_duration' : avg_los_dur, + 'long_trades' : len(longs), + 'short_trades' : len(shorts), + 'long_win_rate' : round(len(longs[longs['win']]) / len(longs) * 100, 1) if len(longs) > 0 else 0, + 'short_win_rate' : round(len(shorts[shorts['win']]) / len(shorts) * 100, 1) if len(shorts) > 0 else 0, + } + + +def extract_strategy(comment): + if not comment or str(comment).strip() == '': + return 'Manual' + parts = str(comment).split('_') + while parts and re.match(r'^\d+$', parts[-1]): + parts.pop() + if parts and re.match(r'^[A-Z]{3,8}(\.a)?$', parts[-1]): + parts.pop() + return '_'.join(parts) if parts else str(comment) + + +def _max_consec(results, target): + max_c = cur_c = 0 + for r in results: + if r == target: + cur_c += 1 + max_c = max(max_c, cur_c) + else: + cur_c = 0 + return max_c \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..898da09 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +streamlit +streamlit-option-menu +pandas +plotly diff --git a/set_comparator.py b/set_comparator.py new file mode 100644 index 0000000..d993df0 --- /dev/null +++ b/set_comparator.py @@ -0,0 +1,94 @@ +import io +import zipfile +from pathlib import Path + +def parse_set_file(file_bytes, filename): + """Parse a .set file and return dict of {param: value} and ordered param list""" + text = file_bytes.decode('utf-16') + params = {} + raw_lines = {} # preserve full line for export + order = [] + + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith(';'): + continue + if '=' not in line: + continue + key, _, rest = line.partition('=') + key = key.strip() + # Value is first field before || + parts = rest.split('||') + value = parts[0].strip() + params[key] = value + raw_lines[key] = rest # preserve everything after = + order.append(key) + + return params, raw_lines, order + +def build_comparison_df(files_data): + """ + files_data: list of (filename, params, raw_lines, order) + Returns DataFrame with param names as index, filenames as columns + """ + import pandas as pd + + # Build union of all param keys preserving order from first file + all_keys = [] + seen = set() + for _, _, _, order in files_data: + for k in order: + if k not in seen: + all_keys.append(k) + seen.add(k) + + # Build dataframe + rows = [] + for key in all_keys: + row = {'Parameter': key} + for filename, params, _, _ in files_data: + row[filename] = params.get(key, '') + rows.append(row) + + return pd.DataFrame(rows) + +def export_set_file(filename, params_edited, raw_lines, order, original_bytes): + """ + Rebuild .set file with edited values, preserving || fields + Returns bytes (utf-16 encoded) + """ + # Get original header comments + text = original_bytes.decode('utf-16') + lines = text.splitlines() + header_lines = [] + for line in lines: + if line.startswith(';'): + header_lines.append(line) + else: + break + + output_lines = header_lines.copy() + + for key in order: + if key not in params_edited: + continue + new_value = params_edited[key] + rest = raw_lines.get(key, new_value) + parts = rest.split('||') + parts[0] = str(new_value) + output_lines.append(f"{key}={'||'.join(parts)}") + + content = '\r\n'.join(output_lines) + '\r\n' + return content.encode('utf-16') + +def create_zip(files_export): + """ + files_export: list of (filename, bytes) + Returns zip bytes + """ + buf = io.BytesIO() + with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf: + for fname, fbytes in files_export: + zf.writestr(fname, fbytes) + buf.seek(0) + return buf.read() \ No newline at end of file diff --git a/settings.py b/settings.py new file mode 100644 index 0000000..39d7a04 --- /dev/null +++ b/settings.py @@ -0,0 +1,37 @@ +""" +pages/settings.py +================= +Settings page for MT5 Tools dashboard. +""" + +import streamlit as st + + +def render(): + st.title("⚙️ Settings") + + st.markdown(""" +
+ MT5 Tools — settings and information. +
+ """, unsafe_allow_html=True) + + st.subheader("About") + st.markdown(""" + **MT5 Tools** is a standalone trade analysis and comparison dashboard. + + **Supported file formats:** + - MT5 Account History HTML export (`.htm` / `.html`) + - MT5 Strategy Tester Backtest Report (`.htm` / `.html`) + - Quant Analyzer CSV export (`listOfTrades_*.csv`) + + **Pages:** + - **Trade Analysis** — statistics, equity curves, day/hour breakdown for a single report + - **Trade Compare** — match and compare two reports to measure slippage and variance + """) + + st.subheader("Requirements") + st.code("pip install streamlit streamlit-option-menu pandas plotly", language="bash") + + st.subheader("Launch") + st.code("streamlit run app.py", language="bash") diff --git a/trade_analysis.py b/trade_analysis.py new file mode 100644 index 0000000..95bf2ff --- /dev/null +++ b/trade_analysis.py @@ -0,0 +1,333 @@ +""" +pages/trade_analysis.py +======================= +MT5 Trade Analysis page — migrated from main dashboard. +""" + +import streamlit as st +import plotly.graph_objects as go +import sys, os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from mt5_parser import detect_and_parse, calc_stats + + +def render(): + st.title("📊 Trade Analysis") + + # ── Session state ───────────────────────────────────────────────────────── + if 'ta_df' not in st.session_state: + st.session_state['ta_df'] = None + st.session_state['ta_format'] = None + + # ── File upload ─────────────────────────────────────────────────────────── + col1, col2 = st.columns([4, 1]) + with col1: + uploaded = st.file_uploader( + "Upload MT5 Report (HTM/HTML) or Quant Analyzer CSV", + type=['html', 'htm', 'csv'], + key='ta_upload' + ) + with col2: + st.markdown("
", unsafe_allow_html=True) + if st.button("🗑 Clear", key='ta_clear'): + st.session_state['ta_df'] = None + st.session_state['ta_format'] = None + st.rerun() + + if uploaded: + 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.success(f"✓ Loaded {len(df)} trades — {fmt}") + else: + st.error("Could not parse report — check file format") + + df_all = st.session_state['ta_df'] + fmt = st.session_state['ta_format'] + + if df_all is None or len(df_all) == 0: + st.markdown(""" +
+ Upload an MT5 account history report (.htm/.html), MT5 backtest report, + or a Quant Analyzer CSV export to begin analysis. +
+ """, unsafe_allow_html=True) + return + + if fmt: + st.caption(f"Format detected: **{fmt}** · {len(df_all)} total trades") + + # ── Filters ─────────────────────────────────────────────────────────────── + st.divider() + fc1, fc2, fc3, fc4 = st.columns(4) + + with fc1: + date_min = df_all['open_time'].min().date() + date_max = df_all['open_time'].max().date() + date_from = st.date_input("From", value=date_min, min_value=date_min, + max_value=date_max, key='ta_from') + date_to = st.date_input("To", value=date_max, min_value=date_min, + max_value=date_max, key='ta_to') + + with fc2: + symbols = sorted(df_all['symbol'].dropna().unique().tolist()) + sel_symbol = st.multiselect("Symbol", symbols, key='ta_sym') + + with fc3: + strategies = sorted(df_all['strategy'].dropna().unique().tolist()) + sel_strategy = st.multiselect("Strategy / EA", strategies, key='ta_strat') + + with fc4: + 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') + + # 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)] + + st.caption(f"Showing **{len(df)}** trades after filters") + + # ── Analysis mode ───────────────────────────────────────────────────────── + mode = st.radio( + "Analysis mode", + ["Overall", "By Strategy", "By Symbol", "By Day of Week"], + horizontal=True, key='ta_mode' + ) + st.divider() + + # ── Helpers ─────────────────────────────────────────────────────────────── + def render_stats(stats, label=""): + 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}") + + 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, 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, 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']}%") + + def render_equity_curve(df_plot, label="Equity Curve"): + df_s = df_plot.sort_values('close_time').copy() + df_s['cumulative'] = df_s['net_profit'].cumsum() + fig = go.Figure() + fig.add_trace(go.Scatter( + x=df_s['close_time'], y=df_s['cumulative'], + mode='lines', + line=dict(color='#7c6af7', width=2), + fill='tozeroy', + fillcolor='rgba(124,106,247,0.08)', + name='Equity' + )) + fig.update_layout( + title=label, height=300, + 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='$'), + margin=dict(l=60, r=20, t=40, b=40) + ) + st.plotly_chart(fig, use_container_width=True) + + def render_dow_chart(df_plot): + dow_order = ['Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday'] + dow = df_plot.groupby('day_of_week').agg( + trades = ('net_profit', 'count'), + net_profit = ('net_profit', 'sum'), + win_rate = ('win', lambda x: round(x.mean()*100, 1)) + ).reindex([d for d in dow_order if d in df_plot['day_of_week'].unique()]) + + wins_dow = df_plot[df_plot['win']].groupby('day_of_week')['net_profit'].sum().reindex(dow.index, fill_value=0) + losses_dow = df_plot[~df_plot['win']].groupby('day_of_week')['net_profit'].sum().reindex(dow.index, fill_value=0) + + fig = go.Figure() + fig.add_trace(go.Bar(x=dow.index, y=wins_dow, name='Profit', marker_color='rgba(45,198,83,0.8)')) + fig.add_trace(go.Bar(x=dow.index, y=losses_dow, name='Loss', marker_color='rgba(230,57,70,0.8)')) + fig.update_layout( + title='P&L by Day of Week', height=280, barmode='relative', + plot_bgcolor='rgba(10,10,15,1)', paper_bgcolor='rgba(10,10,15,1)', + font=dict(color='#aaa'), margin=dict(l=60, r=20, t=40, b=40), + 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)') + ) + st.plotly_chart(fig, use_container_width=True) + + dt = dow.reset_index() + dt.columns = ['Day', 'Trades', 'Net Profit', 'Win Rate %'] + dt['Net Profit'] = dt['Net Profit'].round(2) + st.dataframe(dt, use_container_width=True, hide_index=True) + + def render_hour_chart(df_plot): + hourly = df_plot.groupby('hour').agg( + trades = ('net_profit', 'count'), + net_profit = ('net_profit', 'sum'), + ) + wins_h = df_plot[df_plot['win']].groupby('hour')['net_profit'].sum().reindex(hourly.index, fill_value=0) + losses_h = df_plot[~df_plot['win']].groupby('hour')['net_profit'].sum().reindex(hourly.index, fill_value=0) + + fig = go.Figure() + fig.add_trace(go.Bar(x=wins_h.index, y=wins_h, name='Profit', marker_color='rgba(45,198,83,0.8)')) + fig.add_trace(go.Bar(x=losses_h.index, y=losses_h, name='Loss', marker_color='rgba(230,57,70,0.8)')) + fig.update_layout( + title='P&L by Hour of Day', height=280, barmode='relative', + plot_bgcolor='rgba(10,10,15,1)', paper_bgcolor='rgba(10,10,15,1)', + font=dict(color='#aaa'), margin=dict(l=60, r=20, t=40, b=40), + xaxis=dict(gridcolor='rgba(255,255,255,0.04)', title='Hour (UTC)'), + yaxis=dict(gridcolor='rgba(255,255,255,0.04)', tickprefix='$'), + legend=dict(bgcolor='rgba(0,0,0,0.3)') + ) + st.plotly_chart(fig, use_container_width=True) + + def colour_profit(val): + try: + v = float(str(val).replace(',', '')) + if v > 0: return 'background-color: rgba(0,180,0,0.12)' + if v < 0: return 'background-color: rgba(180,0,0,0.12)' + except: + pass + return '' + + # ── Render mode ─────────────────────────────────────────────────────────── + if mode == "Overall": + stats = calc_stats(df) + render_stats(stats, "Overall Statistics") + render_equity_curve(df) + col1, col2 = st.columns(2) + with col1: + render_dow_chart(df) + with col2: + render_hour_chart(df) + + elif mode == "By Strategy": + strats = sorted(df['strategy'].dropna().unique().tolist()) + if not strats: + st.info("No strategies found") + else: + st.subheader("Strategy Comparison") + rows = [] + for s in strats: + sdf = df[df['strategy'] == s] + stat = calc_stats(sdf) + rows.append({ + 'Strategy' : s, + 'Trades' : stat['total_trades'], + 'Net Profit' : stat['net_profit'], + 'Win Rate %' : stat['win_rate'], + 'Profit Factor' : stat['profit_factor'], + 'R:R' : stat['rr_ratio'], + 'Expectancy' : stat['expectancy'], + '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 + ) + st.divider() + sel = st.selectbox("Select strategy for detail", strats) + if sel: + sdf = df[df['strategy'] == sel] + stat = calc_stats(sdf) + 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) + + elif mode == "By Symbol": + syms = sorted(df['symbol'].dropna().unique().tolist()) + rows = [] + for s in syms: + sdf = df[df['symbol'] == s] + stat = calc_stats(sdf) + rows.append({ + 'Symbol' : s, + 'Trades' : stat['total_trades'], + 'Net Profit' : stat['net_profit'], + 'Win Rate %' : stat['win_rate'], + 'Profit Factor' : stat['profit_factor'], + 'R:R' : stat['rr_ratio'], + 'Expectancy' : stat['expectancy'], + 'Max DD' : stat['max_drawdown'], + }) + 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 + ) + sel = st.selectbox("Select symbol for detail", syms) + if sel: + sdf = df[df['symbol'] == sel] + stat = calc_stats(sdf) + 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) + + elif mode == "By Day of Week": + render_dow_chart(df) + render_hour_chart(df) + + # ── Raw trade log ───────────────────────────────────────────────────────── + st.divider() + with st.expander("Raw Trade Log"): + show_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] + + def colour_net(val): + try: + v = float(val) + if v > 0: return 'background-color: rgba(0,180,0,0.12)' + if v < 0: return 'background-color: rgba(180,0,0,0.12)' + except: + 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.download_button( + "⬇ Download filtered trades CSV", + data = df[show_cols].to_csv(index=False), + file_name = f"mt5_trades_{date_from}_{date_to}.csv", + mime = 'text/csv' + ) diff --git a/trade_compare.py b/trade_compare.py new file mode 100644 index 0000000..23b8e66 --- /dev/null +++ b/trade_compare.py @@ -0,0 +1,422 @@ +""" +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.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' + ) diff --git a/view_set_comparator.py b/view_set_comparator.py new file mode 100644 index 0000000..5e78b10 --- /dev/null +++ b/view_set_comparator.py @@ -0,0 +1,215 @@ +""" +view_set_comparator.py +====================== +EA Settings Comparator page — migrated from main dashboard. +""" + +import streamlit as st +import pandas as pd +from datetime import datetime +import sys, os +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from set_comparator import parse_set_file, build_comparison_df, export_set_file, create_zip + + +def render(): + st.markdown(""" + + """, unsafe_allow_html=True) + + st.title("⚙ EA Settings Comparator") + + # ── Session state init ──────────────────────────────────────────────────── + if 'ea_files' not in st.session_state: st.session_state['ea_files'] = {} + if 'ea_raw' not in st.session_state: st.session_state['ea_raw'] = {} + if 'ea_order' not in st.session_state: st.session_state['ea_order'] = {} + if 'ea_bytes' not in st.session_state: st.session_state['ea_bytes'] = {} + if 'ea_edited' not in st.session_state: st.session_state['ea_edited'] = {} + + # ── Controls ────────────────────────────────────────────────────────────── + col1, col2, col3 = st.columns([1, 1, 4]) + with col1: + n_files = st.selectbox("Number of files", list(range(2, 11)), index=0) + with col2: + st.markdown("
", unsafe_allow_html=True) + if st.button("🗑 Clear All", type="secondary"): + for key in ['ea_files', 'ea_raw', 'ea_order', 'ea_bytes', 'ea_edited']: + st.session_state[key] = {} + st.rerun() + + # ── File upload slots ───────────────────────────────────────────────────── + st.divider() + upload_cols = st.columns(min(n_files, 5)) + for i in range(n_files): + with upload_cols[i % 5]: + uploaded = st.file_uploader( + f"File {i+1}", type=['set'], key=f"ea_upload_{i}" + ) + if uploaded is not None: + file_bytes = uploaded.read() + fname = uploaded.name + params, raw_lines, order = parse_set_file(file_bytes, fname) + st.session_state['ea_files'][fname] = params + st.session_state['ea_raw'][fname] = raw_lines + st.session_state['ea_order'][fname] = order + st.session_state['ea_bytes'][fname] = file_bytes + if fname not in st.session_state['ea_edited']: + st.session_state['ea_edited'][fname] = params.copy() + st.success(f"✓ {fname} — {len(params)} params") + + # ── Comparison table ────────────────────────────────────────────────────── + files_data = st.session_state['ea_files'] + + if len(files_data) >= 2: + st.divider() + + filenames = list(files_data.keys()) + + col1, col2, col3 = st.columns(3) + with col1: + source_file = st.selectbox("Source file for comparison", filenames) + with col2: + pct_threshold = st.slider("Highlight % variation from source", 0, 100, 10) + with col3: + show_diff_only = st.toggle("Show different rows only", value=False) + + files_list = [ + (fn, st.session_state['ea_files'][fn], + st.session_state['ea_raw'][fn], + st.session_state['ea_order'][fn]) + for fn in filenames + ] + df = build_comparison_df(files_list) + + value_cols = [c for c in df.columns if c != 'Parameter'] + df['_diff'] = df[value_cols].nunique(axis=1) > 1 + + df_display = df[df['_diff']].copy() if show_diff_only else df.copy() + df_display = df_display.drop(columns=['_diff']) + + source_vals = files_data.get(source_file, {}) + + def style_cells(row): + styles = [''] * len(row) + param = row['Parameter'] + src_v = source_vals.get(param, '') + for j, col in enumerate(row.index): + if col == 'Parameter': + continue + cell_v = row[col] + if col == source_file: + styles[j] = 'background-color: rgba(100,100,255,0.15)' + continue + if cell_v == '' or src_v == '': + if cell_v != src_v: + styles[j] = 'background-color: rgba(255,180,0,0.2)' + continue + try: + sv = float(src_v) + cv = float(cell_v) + if sv == 0: + if cv != 0: + styles[j] = 'background-color: rgba(255,100,100,0.2)' + else: + pct_diff = abs((cv - sv) / sv) * 100 + if pct_diff > pct_threshold: + styles[j] = 'background-color: rgba(255,100,100,0.2)' + elif pct_diff > 0: + styles[j] = 'background-color: rgba(255,180,0,0.15)' + except: + if cell_v != src_v: + styles[j] = 'background-color: rgba(255,180,0,0.2)' + return styles + + st.markdown( + f"**{len(df_display)} parameters** — " + f"{int(df[df['_diff']].shape[0])} rows differ across files" + ) + + styled = df_display.style.apply(style_cells, axis=1) + row_height = 35 + table_h = min(len(df_display) * row_height + 40, 2000) + + col_config = {'Parameter': st.column_config.TextColumn('Parameter', width='medium')} + for fn in filenames: + col_config[fn] = st.column_config.TextColumn(fn, width='small') + + st.dataframe( + styled, width='content', hide_index=True, + height=table_h, column_config=col_config + ) + + # ── Edit & Export ───────────────────────────────────────────────────── + st.divider() + st.subheader("Edit & Export") + + edit_file = st.selectbox("Select file to edit", filenames, key='ea_edit_sel') + + if edit_file: + edited_params = st.session_state['ea_edited'].get(edit_file, {}) + order = st.session_state['ea_order'].get(edit_file, []) + raw_lines = st.session_state['ea_raw'].get(edit_file, {}) + + edit_df = pd.DataFrame([ + {'Parameter': k, 'Value': edited_params.get(k, '')} + for k in order + ]) + + edited = st.data_editor( + edit_df, width='stretch', hide_index=True, height=400, + column_config={ + 'Parameter': st.column_config.TextColumn('Parameter', disabled=True), + 'Value' : st.column_config.TextColumn('Value'), + }, + key=f"ea_editor_{edit_file}" + ) + + st.session_state['ea_edited'][edit_file] = dict( + zip(edited['Parameter'], edited['Value'].astype(str)) + ) + + col1, col2 = st.columns(2) + with col1: + export_bytes = export_set_file( + edit_file, + st.session_state['ea_edited'][edit_file], + raw_lines, order, + st.session_state['ea_bytes'][edit_file] + ) + st.download_button( + label = f"⬇ Export {edit_file}", + data = export_bytes, + file_name = edit_file, + mime = 'application/octet-stream', + key = 'ea_export_single' + ) + + with col2: + all_exports = [] + for fn in filenames: + fb = export_set_file( + fn, + st.session_state['ea_edited'].get(fn, files_data[fn]), + st.session_state['ea_raw'][fn], + st.session_state['ea_order'][fn], + st.session_state['ea_bytes'][fn] + ) + all_exports.append((fn, fb)) + zip_bytes = create_zip(all_exports) + st.download_button( + label = "⬇ Export All as ZIP", + data = zip_bytes, + file_name = f"ea_settings_{datetime.today().strftime('%Y%m%d')}.zip", + mime = 'application/zip', + key = 'ea_export_all' + ) + + elif len(files_data) == 1: + st.info("Upload at least 2 files to compare") + else: + st.info("Upload .set files above to begin comparison") \ No newline at end of file diff --git a/view_settings.py b/view_settings.py new file mode 100644 index 0000000..39d7a04 --- /dev/null +++ b/view_settings.py @@ -0,0 +1,37 @@ +""" +pages/settings.py +================= +Settings page for MT5 Tools dashboard. +""" + +import streamlit as st + + +def render(): + st.title("⚙️ Settings") + + st.markdown(""" +
+ MT5 Tools — settings and information. +
+ """, unsafe_allow_html=True) + + st.subheader("About") + st.markdown(""" + **MT5 Tools** is a standalone trade analysis and comparison dashboard. + + **Supported file formats:** + - MT5 Account History HTML export (`.htm` / `.html`) + - MT5 Strategy Tester Backtest Report (`.htm` / `.html`) + - Quant Analyzer CSV export (`listOfTrades_*.csv`) + + **Pages:** + - **Trade Analysis** — statistics, equity curves, day/hour breakdown for a single report + - **Trade Compare** — match and compare two reports to measure slippage and variance + """) + + st.subheader("Requirements") + st.code("pip install streamlit streamlit-option-menu pandas plotly", language="bash") + + st.subheader("Launch") + st.code("streamlit run app.py", language="bash") diff --git a/view_trade_analysis.py b/view_trade_analysis.py new file mode 100644 index 0000000..19eb112 --- /dev/null +++ b/view_trade_analysis.py @@ -0,0 +1,333 @@ +""" +pages/trade_analysis.py +======================= +MT5 Trade Analysis page — migrated from main dashboard. +""" + +import streamlit as st +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 + + +def render(): + st.title("📊 Trade Analysis") + + # ── Session state ───────────────────────────────────────────────────────── + if 'ta_df' not in st.session_state: + st.session_state['ta_df'] = None + st.session_state['ta_format'] = None + + # ── File upload ─────────────────────────────────────────────────────────── + col1, col2 = st.columns([4, 1]) + with col1: + uploaded = st.file_uploader( + "Upload MT5 Report (HTM/HTML) or Quant Analyzer CSV", + type=['html', 'htm', 'csv'], + key='ta_upload' + ) + with col2: + st.markdown("
", unsafe_allow_html=True) + if st.button("🗑 Clear", key='ta_clear'): + st.session_state['ta_df'] = None + st.session_state['ta_format'] = None + st.rerun() + + if uploaded: + 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.success(f"✓ Loaded {len(df)} trades — {fmt}") + else: + st.error("Could not parse report — check file format") + + df_all = st.session_state['ta_df'] + fmt = st.session_state['ta_format'] + + if df_all is None or len(df_all) == 0: + st.markdown(""" +
+ Upload an MT5 account history report (.htm/.html), MT5 backtest report, + or a Quant Analyzer CSV export to begin analysis. +
+ """, unsafe_allow_html=True) + return + + if fmt: + st.caption(f"Format detected: **{fmt}** · {len(df_all)} total trades") + + # ── Filters ─────────────────────────────────────────────────────────────── + st.divider() + fc1, fc2, fc3, fc4 = st.columns(4) + + with fc1: + date_min = df_all['open_time'].min().date() + date_max = df_all['open_time'].max().date() + date_from = st.date_input("From", value=date_min, min_value=date_min, + max_value=date_max, key='ta_from') + date_to = st.date_input("To", value=date_max, min_value=date_min, + max_value=date_max, key='ta_to') + + with fc2: + symbols = sorted(df_all['symbol'].dropna().unique().tolist()) + sel_symbol = st.multiselect("Symbol", symbols, key='ta_sym') + + with fc3: + strategies = sorted(df_all['strategy'].dropna().unique().tolist()) + sel_strategy = st.multiselect("Strategy / EA", strategies, key='ta_strat') + + with fc4: + 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') + + # 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)] + + st.caption(f"Showing **{len(df)}** trades after filters") + + # ── Analysis mode ───────────────────────────────────────────────────────── + mode = st.radio( + "Analysis mode", + ["Overall", "By Strategy", "By Symbol", "By Day of Week"], + horizontal=True, key='ta_mode' + ) + st.divider() + + # ── Helpers ─────────────────────────────────────────────────────────────── + def render_stats(stats, label=""): + 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}") + + 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, 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, 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']}%") + + def render_equity_curve(df_plot, label="Equity Curve"): + df_s = df_plot.sort_values('close_time').copy() + df_s['cumulative'] = df_s['net_profit'].cumsum() + fig = go.Figure() + fig.add_trace(go.Scatter( + x=df_s['close_time'], y=df_s['cumulative'], + mode='lines', + line=dict(color='#7c6af7', width=2), + fill='tozeroy', + fillcolor='rgba(124,106,247,0.08)', + name='Equity' + )) + fig.update_layout( + title=label, height=300, + 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='$'), + margin=dict(l=60, r=20, t=40, b=40) + ) + st.plotly_chart(fig, use_container_width=True) + + def render_dow_chart(df_plot): + dow_order = ['Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday'] + dow = df_plot.groupby('day_of_week').agg( + trades = ('net_profit', 'count'), + net_profit = ('net_profit', 'sum'), + win_rate = ('win', lambda x: round(x.mean()*100, 1)) + ).reindex([d for d in dow_order if d in df_plot['day_of_week'].unique()]) + + wins_dow = df_plot[df_plot['win']].groupby('day_of_week')['net_profit'].sum().reindex(dow.index, fill_value=0) + losses_dow = df_plot[~df_plot['win']].groupby('day_of_week')['net_profit'].sum().reindex(dow.index, fill_value=0) + + fig = go.Figure() + fig.add_trace(go.Bar(x=dow.index, y=wins_dow, name='Profit', marker_color='rgba(45,198,83,0.8)')) + fig.add_trace(go.Bar(x=dow.index, y=losses_dow, name='Loss', marker_color='rgba(230,57,70,0.8)')) + fig.update_layout( + title='P&L by Day of Week', height=280, barmode='relative', + plot_bgcolor='rgba(10,10,15,1)', paper_bgcolor='rgba(10,10,15,1)', + font=dict(color='#aaa'), margin=dict(l=60, r=20, t=40, b=40), + 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)') + ) + st.plotly_chart(fig, use_container_width=True) + + dt = dow.reset_index() + dt.columns = ['Day', 'Trades', 'Net Profit', 'Win Rate %'] + dt['Net Profit'] = dt['Net Profit'].round(2) + st.dataframe(dt, use_container_width=True, hide_index=True) + + def render_hour_chart(df_plot): + hourly = df_plot.groupby('hour').agg( + trades = ('net_profit', 'count'), + net_profit = ('net_profit', 'sum'), + ) + wins_h = df_plot[df_plot['win']].groupby('hour')['net_profit'].sum().reindex(hourly.index, fill_value=0) + losses_h = df_plot[~df_plot['win']].groupby('hour')['net_profit'].sum().reindex(hourly.index, fill_value=0) + + fig = go.Figure() + fig.add_trace(go.Bar(x=wins_h.index, y=wins_h, name='Profit', marker_color='rgba(45,198,83,0.8)')) + fig.add_trace(go.Bar(x=losses_h.index, y=losses_h, name='Loss', marker_color='rgba(230,57,70,0.8)')) + fig.update_layout( + title='P&L by Hour of Day', height=280, barmode='relative', + plot_bgcolor='rgba(10,10,15,1)', paper_bgcolor='rgba(10,10,15,1)', + font=dict(color='#aaa'), margin=dict(l=60, r=20, t=40, b=40), + xaxis=dict(gridcolor='rgba(255,255,255,0.04)', title='Hour (UTC)'), + yaxis=dict(gridcolor='rgba(255,255,255,0.04)', tickprefix='$'), + legend=dict(bgcolor='rgba(0,0,0,0.3)') + ) + st.plotly_chart(fig, use_container_width=True) + + def colour_profit(val): + try: + v = float(str(val).replace(',', '')) + if v > 0: return 'background-color: rgba(0,180,0,0.12)' + if v < 0: return 'background-color: rgba(180,0,0,0.12)' + except: + pass + return '' + + # ── Render mode ─────────────────────────────────────────────────────────── + if mode == "Overall": + stats = calc_stats(df) + render_stats(stats, "Overall Statistics") + render_equity_curve(df) + col1, col2 = st.columns(2) + with col1: + render_dow_chart(df) + with col2: + render_hour_chart(df) + + elif mode == "By Strategy": + strats = sorted(df['strategy'].dropna().unique().tolist()) + if not strats: + st.info("No strategies found") + else: + st.subheader("Strategy Comparison") + rows = [] + for s in strats: + sdf = df[df['strategy'] == s] + stat = calc_stats(sdf) + rows.append({ + 'Strategy' : s, + 'Trades' : stat['total_trades'], + 'Net Profit' : stat['net_profit'], + 'Win Rate %' : stat['win_rate'], + 'Profit Factor' : stat['profit_factor'], + 'R:R' : stat['rr_ratio'], + 'Expectancy' : stat['expectancy'], + '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 + ) + st.divider() + sel = st.selectbox("Select strategy for detail", strats) + if sel: + sdf = df[df['strategy'] == sel] + stat = calc_stats(sdf) + 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) + + elif mode == "By Symbol": + syms = sorted(df['symbol'].dropna().unique().tolist()) + rows = [] + for s in syms: + sdf = df[df['symbol'] == s] + stat = calc_stats(sdf) + rows.append({ + 'Symbol' : s, + 'Trades' : stat['total_trades'], + 'Net Profit' : stat['net_profit'], + 'Win Rate %' : stat['win_rate'], + 'Profit Factor' : stat['profit_factor'], + 'R:R' : stat['rr_ratio'], + 'Expectancy' : stat['expectancy'], + 'Max DD' : stat['max_drawdown'], + }) + 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 + ) + sel = st.selectbox("Select symbol for detail", syms) + if sel: + sdf = df[df['symbol'] == sel] + stat = calc_stats(sdf) + 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) + + elif mode == "By Day of Week": + render_dow_chart(df) + render_hour_chart(df) + + # ── Raw trade log ───────────────────────────────────────────────────────── + st.divider() + with st.expander("Raw Trade Log"): + show_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] + + def colour_net(val): + try: + v = float(val) + if v > 0: return 'background-color: rgba(0,180,0,0.12)' + if v < 0: return 'background-color: rgba(180,0,0,0.12)' + except: + 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.download_button( + "⬇ Download filtered trades CSV", + data = df[show_cols].to_csv(index=False), + file_name = f"mt5_trades_{date_from}_{date_to}.csv", + mime = 'text/csv' + ) diff --git a/view_trade_compare.py b/view_trade_compare.py new file mode 100644 index 0000000..935f4ae --- /dev/null +++ b/view_trade_compare.py @@ -0,0 +1,422 @@ +""" +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' + )