From 6f8e01bcf1ac2c00b6f0fa647f7c87285a22bee2 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 20 Apr 2026 08:30:46 +1000 Subject: [PATCH] feat: Live MT5 EAs page + parser improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live MT5 EAs page (view_ftp_tracker.py): - FTP-based multi-account tracker replacing view_mt5_tracker.py - Account configuration with add/remove, FTP folder validation, Demo/Personal/Prop types - Prop account fields: profit target %, max loss %, daily loss % with EA hard stop banner - Account summary cards: recovery factor, loss streak, stagnation days, today P&L, prop progress bars - Calendar view: month/week/year, Mon-Fri only, weekly total column, $ or % toggle - Open positions table parsed from MT5 HTML Open Positions section - Symbol correlation heatmap (collapsible) - Trade analysis section with full stats, equity/drawdown/daily P&L, DOW/hour, monthly table - Analysis modes: Overall, By Account, By Symbol, By Algo, By Day of Week - Dynamic combined balance field auto-updates from selected accounts - Auto-refresh polling with session state timestamp guard - Report date extracted from MT5 HTML header (Date: field) - Drawdown $ / % toggle with spline smoothing mt5_parser.py: - calc_stats(df, deposit=0.0) — deposit-aware balance drawdown matching MT5 Balance Drawdown Maximal - max_drawdown_pct and peak_equity added to calc_stats return - peak_at_dd uses .loc[] fix (IndexError on filtered DataFrames) - parse_open_positions() — parses Open Positions section from MT5 account HTML - extract_strategy() — filters sl/tp/so close-reason comments, maps nan to Manual ftp_sync_cli.py: - New CLI tool for FTP connection testing and cache population MT5Tools_FTP_Setup_Guide.docx: - FileZilla Server setup, MT5 publisher config, CLI verification, troubleshooting --- .gitignore | 6 +- .streamlit/config.toml | 10 +- MT5Tools_FTP_Setup_Guide.docx | Bin 0 -> 15318 bytes __pycache__/mt5_parser.cpython-314.pyc | Bin 20274 -> 25098 bytes __pycache__/view_settings.cpython-314.pyc | Bin 15006 -> 15006 bytes .../view_trade_analysis.cpython-314.pyc | Bin 81681 -> 82149 bytes app.py | 12 +- ftp_sync_cli.py | 297 ++++ mt5_parser.py | 151 ++- view_live_mt5_eas.py | 1200 +++++++++++++++++ view_portfolio_builder.py | 182 ++- view_portfolio_master.py | 72 +- view_trade_analysis.py | 9 +- 13 files changed, 1834 insertions(+), 105 deletions(-) create mode 100644 MT5Tools_FTP_Setup_Guide.docx create mode 100644 ftp_sync_cli.py create mode 100644 view_live_mt5_eas.py diff --git a/.gitignore b/.gitignore index f9a8a2b..10866a1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,8 @@ .venv/ mt5_batch_config.json __pycache__/ -*.pyc \ No newline at end of file +*.pyc +cache/ +mt5_accounts.json +ftp_config.json +ftp_accounts.json \ No newline at end of file diff --git a/.streamlit/config.toml b/.streamlit/config.toml index 7d1bf4b..e9e9e5e 100644 --- a/.streamlit/config.toml +++ b/.streamlit/config.toml @@ -1,7 +1,7 @@ [theme] -base = "dark" -primaryColor = "#7c6af7" -backgroundColor = "#0e1117" -secondaryBackgroundColor = "#1a1f2e" -textColor = "#fafafa" +base = "light" +primaryColor = "#2E75B6" +backgroundColor = "#ffffff" +secondaryBackgroundColor = "#f0f2f6" +textColor = "#1a1a1a" font = "sans serif" diff --git a/MT5Tools_FTP_Setup_Guide.docx b/MT5Tools_FTP_Setup_Guide.docx new file mode 100644 index 0000000000000000000000000000000000000000..e512a886db04e654c1e2a028bb0c057c19207941 GIT binary patch literal 15318 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 literal 0 HcmV?d00001 diff --git a/__pycache__/mt5_parser.cpython-314.pyc b/__pycache__/mt5_parser.cpython-314.pyc index 3f8f750f4ffaaae511f17649498303ec8e59a6a2..cf9eb19cb3198effdf52394f828eb51d5b4298a3 100644 GIT binary patch delta 10311 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 z®>p?UK%tU=B(0fiJGIsT*pN&Ng}ipJ6a)Zi^f zSH4F795g-;%aQsjxRbCb%Nj~=JKuMD?EbtkMPj zPUL86E?UrcpOGvX3+619bH>U!Rpl*p(aib)c+2v zZ_?%MH~Db1I=9}i|1{Nj*RG^tnkD&YT4|R+6(y~H8or^omY}1p>(OkhuFSwlviDfd zI0V~AOss*C=Hws!X|@2Pe>QPu){H-Uw7NzbU(@ZJgLMp3jbJ>Z zV2xItNE_>b)o|Hz1MQ`UDXtJ)YHGlTZnrzo7uve%B3Oc$-nn-ErEq4@Pg?>g*lwU* z=qv4h+Kv9X-LLe3LoJrCu|W^lCgSr1Go-0vwivpGJu$GVtGXs(i}M$6cy$-O5HHfP zIHIFxi-8(gFHj>Z2P$J#KuxRyXaTDPYG&0yEvyEpmDK{Zu>l=C?5rNBgEayzWKBSe zSQ}6$TL{#}+JU;s#(Z04!455677614(Y7w8I8?-VLr%sQaxo=&v*XV0b!J_j)RU9? z^Q3O{gN}gI&AQPO+iPI{zPG&^^>3H!OY?L*D6%~u^{^hI;{lzTR`m{WGKZ-RlD2(YZN@CU*8`1Su4NyYx7hJ|#%Uk|Tm59-SPICwOw@ z-;Y}g;ntOjiA4OwL@eMV=p`tlG2&$i@-$p)#{}8Qcp?S=Balp`AQJjZT<%wx{2CL| ze+hS#zm4BM0wPFwp1(t(^3IA*{#h*0^2xC=a3JB{KPl*PLxBIs5g?)ggNpK>U_A{P zPb3WjK_h11aiSxgn{EGvtN(8xDIX9KSAFRD&MHkAAm~>yie7)^M(1mE=BC9tbNITY z0z4jz^O?3M+vY9)=^o%`9=L9)oZkDsrEtc*SQNNc6qqlnn%y^V*)ZLOg|274m%Q^u z>t{RWEy3xYn;pH29sA*Lu4Dg&mS@_ZY`?s9(O-YfUqA0}{IR8JZZJCCz0|z(9s1P` zPi>j8UtWK`sCqV~yIxd#rDMTT|IUf&ZuI)D4>K;EVo7D6i7iWky9wObvK%<@egLx* z%Sz0vD79tg*s>b)8cJ=Od3afic^##8fW98{22pOryopj5&Ky}Tz`U7Kn`cIsEtt28 z_1G|P$NZ7yj03+EiiL|X@1)eunWM`t%)2SI8+Z@qi*aK|m%W(xQEC@-q6G8nD7Ag& z__81KrBKh8VwbmHK7GBkZn3oST502aY4g>N`Qo-E)qQgmB+H3r_FQb(f~%lha>a5T z#nC6|+d07>$3<2=7V6_$6pD0rN~KK{da=6%o$GeVI9iNzh=CFOBE~sXnJmyoH-SL> zmtJ{piHY5g6miY_3P|1atR)Oi2^ delta 5984 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 n)}48DMY-V zolG&P?iaJzm85jd63$zSJnd33ON4%EBzCOr)sqd(ol#|Y ztYDxXg||TL@D^J0C)cetq_96^WXC){Q&FM3UQ6PPdKO{Kir?5A(ULe*Q5~mHw5t?~ zG=;c{)f%(Zo3Zv5HajDitu_i8sihbke~fK1CYLt$RbtKWce+G;!de0nPg)BApRyJL zZYk4v^NJMW8|-UibDyJI*#uKo@r{m_gQFEQZtgc5Wi1`tW1$9=Y!7FLO{t`w{mE3S zdAga1&v-_&Lz#(9Eu&k;z{qdo9+wVLR9rXI?_6<9g?POIWvYUxzIqf2Z^f-G3WZjY zp_tLgyqWsKXRVds_;c2B-iV6pji|C}a<413hhraEHBjVa z2`YXSNHb^d1*(cu`l;tPM(%$%f{< zO0w8zIlI+kkzX5JnH#GjGucyl>7<4o%6kE1ocUj>v#@j-n^>?yzaB_gXm_~$b{_UuFDmPx+gL|c zRn19Yn^khS)9;|vGuKD!Q8*pB(Xyt;=dgL({M3ZZcL8J-2+2a5!{zePbxf|RBbmXp z>RSl--Z@gMCZDj`WA%Ci$|ob>{N|{F`JxV!n$ZFrb?pYoeoyf+E zd4XXx5le1G$iZTj+cTF&V_)Dv$`O8#&E3=Opw2d`LoOd#4N$Vq=b?Vv9J{N>;iD1A zQMdJU`+B;0+HLQpNobWvK`K)5Xi)9MV9uzS4@cRLM7(^`ZNQ1W+7J7s9% z5X@st%Gw6I-`+&+;KzB^G$ltbq10+D3ZLV2%(eL(F1mp&o>HF50|Hw@%Te8qGGXl< zHXoh9KAO@_U_p@S-_=cCzwnMA+h&E0Q7wccQRJA*B#h-CUpAS}b|GrXa z&2~iotTel?6gDWOx5%0(em4?SS<4DN>zwb_@LaozE%FS7eVbj3*0QvFbi~1i-?Nc) zu($5X*K}G(h+WpiHHQ<{#)gN(PUEyzOm16iNM$4LP0;_UuL3AAlnLV9qD$;%Gw&@X z9=7t{(qyl-NSs}TodiFlqFHOrh2uk^=w+YXTSzEVFED5MM1R%wg8)c`^8-eIvy2LK znoVAj9K3r$3L)RI2Nssb_6**U#W~e;Sw1 zfreT0p;_7DQt^So+(u1v5ih9{XNk+iCG3MmZ<7bvy2ZsY%lrHoXdwG`@mBpqtqtOe zDRab!2e*)|zHc=vyZ_bp@2zvJbK!Ks&qEMD<3MudU`g?bD1~(#oaR>c*^g9|DO;Pw zM{w$b^yG^NxvB8 zL4zBLz|rK{;KM6^?larH>;xOXva;spN_M> z-Na9M*QWvd*Gd<8fpx7ilE1PCSA9eFGVkhTs-D&<>|-H;9a|x=_%#^<e-GpiDVyJ{j{Fl5*823C>)3X#;&Z%7xs$>#MhW>XZPuIT~ZVetsNwsvgPQSr_Gxv>LEjm*IkgdHOA*+Ok;J}3rTZ!8L85vzS%8@>B_Nd{M9S2vWVw9XQD<3to+zM;%6z6{kDML_Y(Slnvk z6Y|pIo#X>%-B_pp-9R@qD_duJr?*arR_)xlO#3RzTc?AkTU7C?>Eeg%@l9rS&!!CW z5l~~5$8y->dotOfRnaVB^LG8m;*7qV94LJpQilB`<`ePwpn^}>h0UeBKYMfobvFUH6I!JwGY4F2#Rbn2(>;d#P6J`>~l~2!rBSon|Ya4w5r4-7A#hpM$;61c>G< zYd4gfWj}7q&Adnax-S64Z>G3`@0{WROoDCvFXFkXFt*T{#AZI5rOKG_A)CBC8`4|Z z&S&pbrA;`?BA*MA;q35prDPH>OPcVn>*;gM@_Z@Dr3{#Rt!67ec3AvM{>@us1V6xI^Hhx!G z>?)9vl`S4w@Y`*E4=rX7?_!{D+Px;@5fodA@GyWJ=JPwea4GW8Zo41sezZG772t>U zVw6#1ZT!}W^|nwmAe`p;9SaZEaJ04i9dek{=XCQkIE_NJT5j6u^0b+CaNwtHw)PG> z1xvb6Ndw1ao3p)_HX-X~6b?sLyM3Ndj`T6to-uI0+Og;NBo!|tZP(95Kq?e)l~>?F z{`=KzlFib70ds}C4R8moM*Sd-2< z8Enyh1Ib_;_S+3ckT4C7FRKcU1tvD+K%pTM%7)%h76xTQS?%k^?Cfi?@PZ&_!>bS~ z35 zj2L)2f+GyNIYNx;pURrP4a=wyE3*{lk=K-Yd9)@t=TIDJ!|U8NA-{m%){%ybdua!4 z0g`@&d1Zblznz-Z6vHia85+{%?Cf$-o6~2Tq+tLQ0d>Bvg=D@G5?Au=- zfcJ}Shp#qZZFCO?!#1Q|MQSes-lWaKKn*{)CvbZqbMlehX?TF8TM#q|TLH|3K7sVp zY|GIkHE+Q4?AJ%rtr%MLWrSA{f(UyM)&Q85TuavRw-UT>c_@7qOZOt|L)ee-I>JGO zHxLf7Ti?tgdgggEJ8%FQuOa*z;V{Axgrf*=BD{t08-(8?yo9g|V18r^wYNKbreafr zeIDHntcl$i!jV_EgVhkk2QhTe~61uws| zI?j}UR&GbYmmQgyXd9T6NijW(%w_D!FO1W1WeJVhi$FEW8pmuKT)A!ZC6wj4dEGX; zt1k!c+d>1Dk4A1oSPF&E;!>nr5vBvoXOnXEqbkj2O(+tlV!>nt5y6Tu4FNBH)COQy z4KiV4oqsX~T)>u9?H)e_77vI2WQDJOxbQihjF( zq|WxxxTrXn-h^VGf-=0{bRgY{(4`m;=mZk9@j_0k5ZaLu!CwpLSY#9-6eE-%R3nT= za3EA5w6RGihPiR_&5z8>YoPYI7zTNHvS5REkTP0;LWNkYr=7Fx!%NF53Q9{V3(6|0 z3QCHq&2$Eq=^7eLlc=-P>9)Im*{!@YdAIT&71`+%$#uh^QS()x2c3%@z*|W@K*84V zUwH3kqfe%ir`YW$Ylgm$z3W9Ivk`E2b@Nd3JYO z7c3kYVKBVBZx*A*e=)pnG^d4`({6XV=Gj2Kf7;F0WHpVbt1K($FRPHNsIP8hdrp-U z$0~wWC3QO(@PIyNfH}!I)zlS{L>jJb;uZvph36^0rzmK zW^Jc4ryfBP4?&@KvJ`B=Q<&v1X8h?*fQK~KJXvM2Q*#QnU?E?18i10c+C1G}cn`L@ z?6csZ%!vBq*ypFGkuFw##vIj*LPCeL)7DOJVa_uJ@Nm8UObyu>{PfHTO&AUZv;UA7 zeE)nPEPgoJ#+eyfF`|HyZi^;~%y(JLN{5^r*6pynVU@(m{qvER%h>KS2KLkCR5F^S zUdafcad?DQ!=p8o==~O5e20qs|%{j`Q(8~J+OB0#pg@(YvF)= zS$Rpph^m6BlF(#ZgysSW_aXExC;g`r`sgL`OlPXj^m*rtL&YfbQJ3H7~=84UsTT{?^}cm2;U)G zM!1B)1M&(|7)+seTRxQ9c6+Vg-hnT#u(t595>a9bf&yN{=v1Vv2>(LB*!1BUF=t?3 z8}nTisSM8f&ME}(gV07OAfhzLBVsEGRjJG%oGX6eM!14-72!L8P|1k;K- z>p?{pU}bW8_y-Gm9&L<7dH!(%7iM$fz;Y``-0hfW^HN8Lvp2NN#-nTk0Hz}hY%tV0zod>pfdKEJs@%a;gDILx!fXA(%J z)S!W0?%KCRLq1ih>riQ#G*d^!tg%=_4Z=47u%_`ry95&bpU0%*I#NouNJ1oWWM)GN z+$uJp3EVztBSQaGk^Ue_ks#eBy&FmD$to!-iimM24abqq{Yu|Uv!h6Q6f$JtX1Yz0u<)QPlI1!b8{h_Ajgdx)!jYH z=Y!pPvvfLv6tv*xMD^Hxcv`}vgR7_84Tn27oDcY=0%Z9Nj6sV-0L(;=bauKuaKLcl zcE(>RXcQL5A;e2|J(-uC1C~J-BN6)Mb1cELeg9x1;|u90J@my_Qbr;Vu2)* zPZFdZNu-Y4EL}_@*#TVlWnH&_ge`O`vCR+nJ9_=aZN51W<73dqScF;tvub`=5qu}4 z3&2ad5WuXQFBJ8~NaF>KU(5Jy3>pY-2H>u#qO(AL zBG&?L8i>itZaP-95(S+D zEj@=ez=3FFU^v2&ml_ZH6pK>I8^PYtdDO^T{ z4a`C4#=;o9=ea!gcH68Tzu)5yeQD-f2`7)^Q@yT>DtrjOL{4W5W(ed8_eDnom;CFRL=>Go>I>iUhgnc~>0bjc4CTQk26SoN*Soi#3!qK@Op`RA>5yMjZ>r_ zGe|R$r3pqdiL8@08A&DCEFCuz%Mkt?0`}lv1v;T|RLVAy%*uh?3)c?14QqK8;W>oo z5w;`ThTe(*zFv@%7MqBZEt)Dw=S@Us$V3;^@NYFZv{ylB8L0Sr_eExs0*$(b2$es$ zq%p%uK|CvZQ1~n-?~|O$b$J_FK8+i?zhU3@;bc=h>5-alB5#n1QdkYyA6QT=yeU|O zx`mO#`=;=^g(k)OrN+8|UhzR}B2R`}sxbZBXo;}Y77JgDu1mDkse}_*m>PsHb#(?y zU5RjNXo96KKm1f>8s<(9tBbZ+!h~;<^%hH#;+tYiB&HWkT1$PIaACC8(x4VDr46wJ z8dAb9m8va}T-FS+G#JA#r$NpbekHBWWN8>C9H|Qj;c7^;w3fz5;mCv>NKL|#W&&x2 zaAY!+H^o63JKWL~9)4u<7&T9C3!9+f1$Pdq;OR{5_zJH0K%X=r?PGo zCwfaQH*w;g!gxzlRQQp51(3j0SC_>L7F6r%7HYLe7Dhsv4yj337l>0GS(u+z7l=|F z2}B7zAJFS8wFQtj)e%c=uIgxQu7HfAwZ%G1okDxGP6cTo0SE&S!y(NVuA`qy|WbK{^7`YMrG~t3BEn z328c{CP+&mtZ^jVhHte%KJettK{!-3t_?rF2xWG%cEIPBdn zIL{xJPK_mI{dovCm<;enuY~_Y3G>?hT`;pUYDs0@P>gC^oOoRF(>Z@Z;oGAeep9!n Xy~pJkL;pu9EvzLowRc662rlryAKa@N diff --git a/app.py b/app.py index 8e737d4..1d1b305 100644 --- a/app.py +++ b/app.py @@ -9,8 +9,6 @@ Launch: streamlit run app.py import streamlit as st from streamlit_option_menu import option_menu import importlib, sys, os -import view_settings -view_settings.inject_theme_css() # ── Page config ─────────────────────────────────────────────────────────────── st.set_page_config( @@ -144,8 +142,8 @@ with st.sidebar: st.markdown("---") page = option_menu( menu_title = None, - options = ["Trade Analysis", "Trade Compare", "Portfolio Builder", "Portfolio Master", "EA Comparator", "Batch Backtest", "Settings"], - icons = ["bar-chart-line", "arrow-left-right", "briefcase", "trophy", "sliders", "cpu", "gear"], + options = ["Trade Analysis", "Trade Compare", "Portfolio Builder", "Portfolio Master", "Live MT5 EAs", "EA Comparator", "Batch Backtest", "Settings"], + icons = ["bar-chart-line", "arrow-left-right", "briefcase", "trophy", "wifi", "sliders", "cpu", "gear"], default_index = 0, styles = { "container" : {"background-color": "transparent", "padding": "0"}, @@ -174,6 +172,7 @@ if page == "Trade Analysis": elif page == "Portfolio Builder": import view_portfolio_builder as p + importlib.reload(p) p.render() elif page == "Portfolio Master": @@ -195,6 +194,11 @@ elif page == "Batch Backtest": importlib.reload(p) p.render() +elif page == "Live MT5 EAs": + import view_live_mt5_eas as p + importlib.reload(p) + p.render() + elif page == "Settings": import view_settings as p importlib.reload(p) diff --git a/ftp_sync_cli.py b/ftp_sync_cli.py new file mode 100644 index 0000000..0e0d942 --- /dev/null +++ b/ftp_sync_cli.py @@ -0,0 +1,297 @@ +""" +ftp_sync_cli.py +=============== +CLI tool to pull MT5 published account history from FTP and display stats. +Run from MT5Tools folder with venv activated. + +Usage: + python ftp_sync_cli.py --host 192.168.1.x --user ftpuser --pass ftppass + python ftp_sync_cli.py --host 192.168.1.x --user ftpuser --pass ftppass --list + python ftp_sync_cli.py --host 192.168.1.x --user ftpuser --pass ftppass --account 12345 + python ftp_sync_cli.py --config (use saved config in ftp_config.json) + +Config is saved to ftp_config.json after first run (gitignored). +""" + +import argparse +import ftplib +import json +import os +import sys +from pathlib import Path +from datetime import datetime + +CONFIG_FILE = Path(__file__).parent / "ftp_config.json" +CACHE_DIR = Path(__file__).parent / "cache" + + +# ── Config ──────────────────────────────────────────────────────────────────── + +def load_config() -> dict: + if CONFIG_FILE.exists(): + return json.loads(CONFIG_FILE.read_text()) + return {} + + +def save_config(cfg: dict): + CONFIG_FILE.write_text(json.dumps(cfg, indent=2)) + print(f"Config saved to {CONFIG_FILE}") + + +# ── FTP helpers ─────────────────────────────────────────────────────────────── + +def connect_ftp(host: str, user: str, password: str, port: int = 21) -> ftplib.FTP: + ftp = ftplib.FTP() + ftp.connect(host, port, timeout=10) + ftp.login(user, password) + ftp.set_pasv(True) + return ftp + + +def list_accounts(ftp: ftplib.FTP) -> list: + """List top-level directories on FTP — each should be an account folder.""" + items = [] + ftp.retrlines("LIST", items.append) + folders = [] + for item in items: + parts = item.split() + if item.startswith("d") and parts: + folders.append(parts[-1]) + return folders + + +def find_report_file(ftp: ftplib.FTP, account_folder: str) -> str | None: + """ + Find the HTML report file inside an account folder. + MT5 typically publishes as: account_folder/report.htm or account_folder/Report.htm + """ + try: + ftp.cwd(f"/{account_folder}") + except ftplib.error_perm: + try: + ftp.cwd(account_folder) + except ftplib.error_perm: + return None + + files = [] + ftp.retrlines("NLST", files.append) + for f in files: + if f.lower().endswith(('.htm', '.html')): + return f + return None + + +def download_report(ftp: ftplib.FTP, account_folder: str, + filename: str) -> bytes: + """Download report file and return raw bytes.""" + buf = [] + ftp.retrbinary(f"RETR {filename}", buf.append) + return b"".join(buf) + + +# ── Parse + display ─────────────────────────────────────────────────────────── + +def display_stats(stats: dict, fmt: str, account_folder: str): + """Print stats to console in a readable format.""" + sep = "─" * 60 + print(f"\n{sep}") + print(f" Account: {account_folder} | Format: {fmt}") + print(sep) + + rows = [ + ("Net Profit", f"${stats.get('net_profit', 0):,.2f}"), + ("Total Trades", stats.get('total_trades', 0)), + ("Win Rate", f"{stats.get('win_rate', 0)}%"), + ("Profit Factor", stats.get('profit_factor', 0)), + ("R:R Ratio", stats.get('rr_ratio', 0)), + ("Expectancy", f"${stats.get('expectancy', 0):,.2f}"), + ("Max Drawdown", f"${stats.get('max_drawdown', 0):,.2f}"), + ("Avg Win", f"${stats.get('avg_win', 0):,.2f}"), + ("Avg Loss", f"${stats.get('avg_loss', 0):,.2f}"), + ("Best Trade", f"${stats.get('best_trade', 0):,.2f}"), + ("Worst Trade", f"${stats.get('worst_trade', 0):,.2f}"), + ("Max Consec Wins", stats.get('max_consec_wins', 0)), + ("Max Consec Loss", stats.get('max_consec_losses', 0)), + ("Trading Days", stats.get('trading_days', 0)), + ("Trades/Day", stats.get('trades_per_day', 0)), + ("Long Trades", f"{stats.get('long_trades', 0)} ({stats.get('long_win_rate', 0)}% WR)"), + ("Short Trades", f"{stats.get('short_trades', 0)} ({stats.get('short_win_rate', 0)}% WR)"), + ] + + for label, value in rows: + print(f" {label:<22} {value}") + print(sep) + + +def display_monthly(df): + """Print monthly P&L breakdown.""" + import pandas as pd + if df is None or df.empty: + return + tmp = df[['close_time', 'net_profit']].dropna().copy() + tmp['close_time'] = pd.to_datetime(tmp['close_time'], errors='coerce') + tmp['ym'] = tmp['close_time'].dt.strftime('%Y-%m') + monthly = tmp.groupby('ym')['net_profit'].sum().sort_index() + + print("\n Monthly P&L:") + print(" " + "─" * 30) + for ym, pnl in monthly.items(): + bar = "█" * min(int(abs(pnl) / 10), 30) + sign = "+" if pnl >= 0 else "" + color = "\033[92m" if pnl >= 0 else "\033[91m" + reset = "\033[0m" + print(f" {ym} {color}{sign}${pnl:>8.2f} {bar}{reset}") + print() + + +def display_recent_trades(df, n=10): + """Print the most recent N trades.""" + if df is None or df.empty: + return + import pandas as pd + df = df.sort_values('close_time', ascending=False).head(n) + print(f"\n Last {n} trades:") + print(" " + "─" * 70) + print(f" {'Date':<22} {'Symbol':<12} {'Type':<6} {'Profit':>10}") + print(" " + "─" * 70) + for _, row in df.iterrows(): + pnl = row.get('net_profit', 0) + color = "\033[92m" if pnl >= 0 else "\033[91m" + reset = "\033[0m" + print(f" {str(row.get('close_time','')):<22} " + f"{str(row.get('symbol','')):<12} " + f"{str(row.get('type','')):<6} " + f"{color}${pnl:>9.2f}{reset}") + print() + + +# ── Main ────────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser( + description="Pull MT5 FTP published reports and display stats", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__ + ) + parser.add_argument("--host", help="FTP host/IP") + parser.add_argument("--user", help="FTP username") + parser.add_argument("--password","--pass", dest="password", help="FTP password") + parser.add_argument("--port", type=int, default=21, help="FTP port (default: 21)") + parser.add_argument("--account", help="Account folder name (default: first found)") + parser.add_argument("--list", action="store_true", help="List account folders and exit") + parser.add_argument("--config", action="store_true", help="Use saved ftp_config.json") + parser.add_argument("--save", action="store_true", help="Save connection details to ftp_config.json") + parser.add_argument("--trades", type=int, default=10, metavar="N", help="Show last N trades (default: 10)") + parser.add_argument("--no-monthly", action="store_true", help="Skip monthly breakdown") + parser.add_argument("--save-cache", action="store_true", help="Save parsed data to cache/ folder") + args = parser.parse_args() + + # Load from config if requested + cfg = {} + if args.config or (not args.host): + cfg = load_config() + if not cfg: + print("No ftp_config.json found. Run with --host, --user, --password first.") + sys.exit(1) + + host = args.host or cfg.get("host") + user = args.user or cfg.get("user") + password = args.password or cfg.get("password") + port = args.port or cfg.get("port", 21) + + if not all([host, user, password]): + parser.print_help() + sys.exit(1) + + if args.save: + save_config({"host": host, "user": user, "password": password, "port": port}) + + # Connect + print(f"\nConnecting to {host}:{port}...") + try: + ftp = connect_ftp(host, user, password, port) + print(f"✓ Connected as {user}") + except Exception as e: + print(f"✗ Connection failed: {e}") + sys.exit(1) + + # List accounts + accounts = list_accounts(ftp) + if not accounts: + print("No account folders found on FTP root.") + ftp.quit() + sys.exit(1) + + print(f"Found {len(accounts)} folder(s): {', '.join(accounts)}") + + if args.list: + ftp.quit() + return + + # Pick account + target = args.account or accounts[0] + if target not in accounts: + print(f"Account folder '{target}' not found. Available: {', '.join(accounts)}") + ftp.quit() + sys.exit(1) + + # Find and download report + print(f"\nLooking for report in '{target}'...") + report_file = find_report_file(ftp, target) + if not report_file: + print(f"No .htm/.html file found in '{target}'") + ftp.quit() + sys.exit(1) + + print(f"Downloading {report_file}...") + try: + raw = download_report(ftp, target, report_file) + ftp.quit() + print(f"✓ Downloaded {len(raw):,} bytes") + except Exception as e: + print(f"✗ Download failed: {e}") + ftp.quit() + sys.exit(1) + + # Parse + print("Parsing report...") + try: + sys.path.insert(0, str(Path(__file__).parent)) + from mt5_parser import detect_and_parse, calc_stats + df, fmt = detect_and_parse(raw, f"{target}.htm") + if df is None or df.empty: + print("✗ Could not parse report — check file format") + sys.exit(1) + print(f"✓ Parsed {len(df)} trades — format: {fmt}") + except ImportError: + print("✗ mt5_parser.py not found — run from MT5Tools folder") + sys.exit(1) + + # Stats + stats = calc_stats(df) + display_stats(stats, fmt, target) + + if not args.no_monthly: + display_monthly(df) + + if args.trades > 0: + display_recent_trades(df, args.trades) + + # Save cache + if args.save_cache: + import pickle + CACHE_DIR.mkdir(exist_ok=True) + cache_data = { + "account_folder": target, + "df" : df, + "stats" : stats, + "fmt" : fmt, + "fetched_at" : datetime.now().isoformat(), + } + cache_file = CACHE_DIR / f"ftp_{target}.pkl" + cache_file.write_bytes(pickle.dumps(cache_data)) + print(f"Cache saved to {cache_file}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/mt5_parser.py b/mt5_parser.py index a8de624..56601d1 100644 --- a/mt5_parser.py +++ b/mt5_parser.py @@ -42,7 +42,36 @@ def _to_dt(s, fmt='%Y.%m.%d %H:%M:%S'): return pd.to_datetime(s, format=fmt, errors='coerce') -def _enrich(df): +def _strategy_from_filename(filename): + """ + Derive a clean strategy name from a filename. + Strips leading date prefix (DD_MM_YYYY_ or YYYY_MM_DD_) and file extension. + E.g. '22_03_2026GoldPhantomModerate.csv' -> 'GoldPhantomModerate' + 'GoldPhantom_XAUUSD_Daily_OHLC_A.htm' -> 'GoldPhantom' + """ + import os + stem = os.path.splitext(os.path.basename(filename))[0] + # Strip leading date prefix like 22_03_2026 or 2026_03_22 (with optional separator) + stem = re.sub(r'^\d{2}_\d{2}_\d{4}', '', stem) + stem = re.sub(r'^\d{4}_\d{2}_\d{2}', '', stem) + # Strip leading underscores/hyphens left after date removal + stem = stem.lstrip('_-') + # If underscore-delimited, take only parts that look like a name (not symbol/period/model) + parts = stem.split('_') + clean = [] + for p in parts: + # Stop at parts that look like: instrument suffix (.a), timeframe (H1/M15/Daily), + # model label (OHLC/EVERYTICK), or single uppercase letter (instance A/B/C) + if re.match(r'^(H\d+|M\d+|Daily|Weekly|Monthly|OHLC|EVERYTICK|CTRLPTS|[A-Z])$', p): + break + if re.match(r'^[A-Z]{3,8}(\.a)?$', p): + break + clean.append(p) + result = '_'.join(clean) if clean else stem + return result if result else stem + + +def _enrich(df, fallback_strategy=None): """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') @@ -70,6 +99,10 @@ def _enrich(df): if 'comment' not in df.columns: df['comment'] = '' df['strategy'] = df['comment'].apply(extract_strategy) + # If every trade resolved to 'Manual' and a fallback name was supplied + # (e.g. derived from the filename), use it instead. + if fallback_strategy and (df['strategy'] == 'Manual').all(): + df['strategy'] = fallback_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 @@ -77,7 +110,7 @@ def _enrich(df): # ── Format 1: Real Account HTM ──────────────────────────────────────────────── -def parse_mt5_report(file_bytes): +def parse_mt5_report(file_bytes, fallback_strategy=None): """Parse MT5 real account HTML trade history report.""" text = _decode(file_bytes) rows = re.findall(r']*>(.*?)', text, re.DOTALL) @@ -120,12 +153,12 @@ def parse_mt5_report(file_bytes): df = pd.DataFrame(trades) df['source'] = 'real' - return _enrich(df) + return _enrich(df, fallback_strategy=fallback_strategy) # ── Format 2: Backtest HTM ──────────────────────────────────────────────────── -def parse_backtest_report(file_bytes): +def parse_backtest_report(file_bytes, fallback_strategy=None): """ Parse MT5 Strategy Tester HTML report. Pairs in/out deals into complete trades. @@ -203,7 +236,7 @@ def parse_backtest_report(file_bytes): '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', ''), + 'comment' : entry.get('comment', '') or deal.get('comment', ''), 'position' : entry.get('deal', ''), }) @@ -212,12 +245,12 @@ def parse_backtest_report(file_bytes): df = pd.DataFrame(trades) df['source'] = 'backtest' - return _enrich(df) + return _enrich(df, fallback_strategy=fallback_strategy) # ── Format 3: Quant Analyzer CSV ───────────────────────────────────────────── -def parse_quant_csv(file_bytes): +def parse_quant_csv(file_bytes, fallback_strategy=None): """Parse Quant Analyzer listOfTrades CSV export.""" try: text = file_bytes.decode('utf-8-sig') @@ -286,7 +319,71 @@ def parse_quant_csv(file_bytes): if extra in df_raw.columns: df[extra] = pd.to_numeric(df_raw[extra], errors='coerce') - return _enrich(df) + return _enrich(df, fallback_strategy=fallback_strategy) + + + + +def parse_open_positions(file_bytes) -> 'pd.DataFrame | None': + """Parse the Open Positions section from MT5 account history HTML.""" + import pandas as pd + text = _decode(file_bytes) + rows = re.findall(r']*>(.*?)', text, re.DOTALL) + + in_open = False + in_orders = False + positions = [] + + for row in rows: + cells = re.findall(r']*>(.*?)', row, re.DOTALL) + cells = [re.sub(r'\s+', ' ', _strip(c)).strip() for c in cells] + cells = [c for c in cells if c] + + if not cells: + continue + + flat = ' '.join(cells) + if 'Open Positions' in flat: + in_open = True + in_orders = False + continue + if 'Working Orders' in flat or 'Pending Orders' in flat: + in_orders = True + in_open = False + continue + if 'Results' in flat or 'Closed Positions' in flat or 'Balance:' in flat: + if in_open or in_orders: + break + + # Header row + if cells[0] in ('Time', 'Open Time'): + continue + + # Open position row: Time, Position, Symbol, Type, Volume, Price, SL, TP + # followed sometimes by a profit row (fewer cols) + if in_open and len(cells) >= 6 and re.match(r'\d{4}\.', cells[0]): + try: + vol_str = cells[4].split('/')[0].strip() + positions.append({ + 'open_time' : _to_dt(cells[0]), + 'position' : cells[1], + 'symbol' : cells[2], + 'type' : cells[3].lower(), + 'volume' : _to_float(vol_str), + 'open_price' : _to_float(cells[5]), + 'sl' : _to_float(cells[6]) if len(cells) > 6 else None, + 'tp' : _to_float(cells[7]) if len(cells) > 7 else None, + 'status' : 'open', + }) + except Exception: + pass + + if not positions: + return None + + df = pd.DataFrame(positions) + df['symbol_base'] = df['symbol'].str.replace(r'\.[a-z]+$', '', regex=True).str.upper() + return df # ── Auto-detect format ──────────────────────────────────────────────────────── @@ -297,9 +394,13 @@ def detect_and_parse(file_bytes, filename=''): Returns (df, format_name) or (None, None). """ fname = filename.lower() + # Derive a fallback strategy name from the filename for files where + # all comments are MT5 close-reason tags (sl/tp/so) and no strategy + # name is embedded in the comment field. + fallback = _strategy_from_filename(filename) if filename else None if fname.endswith('.csv'): - df = parse_quant_csv(file_bytes) + df = parse_quant_csv(file_bytes, fallback_strategy=fallback) return df, 'Quant Analyzer CSV' # HTML/HTM — detect backtest vs real account @@ -309,16 +410,16 @@ def detect_and_parse(file_bytes, filename=''): return None, None if 'Strategy Tester Report' in text or 'strategy tester' in text.lower(): - df = parse_backtest_report(file_bytes) + df = parse_backtest_report(file_bytes, fallback_strategy=fallback) return df, 'MT5 Backtest Report' - df = parse_mt5_report(file_bytes) + df = parse_mt5_report(file_bytes, fallback_strategy=fallback) return df, 'MT5 Account History' # ── Stats ───────────────────────────────────────────────────────────────────── -def calc_stats(df): +def calc_stats(df, deposit=0.0): if df is None or len(df) == 0: return {} @@ -340,8 +441,15 @@ def calc_stats(df): 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) + # Balance series: deposit + cumulative P&L (matches MT5 Balance Drawdown Maximal) + balance = deposit + cumulative + rolling_max = balance.cummax() + drawdown_ser = balance - rolling_max + max_dd = round(drawdown_ser.min(), 2) + peak_equity = round(rolling_max.max(), 2) + # % = max_dd / local peak at the point of max drawdown + peak_at_dd = rolling_max.loc[drawdown_ser.idxmin()] if not drawdown_ser.empty else peak_equity + max_dd_pct = round(max_dd / peak_at_dd * 100, 2) if peak_at_dd != 0 else 0 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 @@ -369,6 +477,8 @@ def calc_stats(df): 'max_consec_wins' : max_cw, 'max_consec_losses' : max_cl, 'max_drawdown' : max_dd, + 'max_drawdown_pct' : max_dd_pct, + 'peak_equity' : peak_equity, 'best_trade' : round(df['net_profit'].max(), 2), 'worst_trade' : round(df['net_profit'].min(), 2), 'avg_duration_min' : avg_dur, @@ -382,14 +492,13 @@ def calc_stats(df): def extract_strategy(comment): - if not comment or str(comment).strip() == '': + if not comment or str(comment).strip() in ('', 'nan'): 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) + s = str(comment).strip() + # Filter out MT5 close-reason comments: "sl 1234.56", "tp 1234.56", "so 50%" + if re.match(r'^(sl|tp|so)\s+[\d\.]+%?$', s, re.IGNORECASE): + return 'Manual' + return s def _max_consec(results, target): diff --git a/view_live_mt5_eas.py b/view_live_mt5_eas.py new file mode 100644 index 0000000..7b5a515 --- /dev/null +++ b/view_live_mt5_eas.py @@ -0,0 +1,1200 @@ +""" +view_ftp_tracker.py +=================== +FTP-based multi-account MT5 tracker. +Pulls HTML reports from FTP, parses via mt5_parser, shows calendar + analysis. + +Config: ftp_accounts.json (labels, balances per account) +Cache: cache/ftp_*.pkl (parsed DataFrames, refreshed on demand) +FTP: ftp_config.json (host/user/pass) +""" + +import streamlit as st +import pandas as pd +import plotly.graph_objects as go +from datetime import date, datetime, timedelta +import calendar +import pickle +import json +import ftplib +from pathlib import Path + +CONFIG_FILE = Path("ftp_config.json") +ACCOUNTS_FILE = Path("ftp_accounts.json") +CACHE_DIR = Path("cache") +CACHE_MAX_AGE = 5 # minutes before auto-refresh on load + + +# ── Config helpers ───────────────────────────────────────────────────────────── + +def load_ftp_config() -> dict: + if CONFIG_FILE.exists(): + return json.loads(CONFIG_FILE.read_text()) + return {} + + +def load_account_configs() -> list: + if ACCOUNTS_FILE.exists(): + return json.loads(ACCOUNTS_FILE.read_text()) + return [] + + +def save_account_configs(accounts: list): + ACCOUNTS_FILE.write_text(json.dumps(accounts, indent=2)) + + +# ── FTP + parse ──────────────────────────────────────────────────────────────── + +def ftp_list_accounts(cfg: dict) -> list: + ftp = ftplib.FTP() + ftp.connect(cfg["host"], cfg.get("port", 21), timeout=10) + ftp.login(cfg["user"], cfg["password"]) + ftp.set_pasv(True) + items = [] + ftp.retrlines("LIST", items.append) + folders = [i.split()[-1] for i in items if i.startswith("d")] + ftp.quit() + return folders + + +def ftp_download_report(cfg: dict, account_folder: str) -> bytes | None: + ftp = ftplib.FTP() + ftp.connect(cfg["host"], cfg.get("port", 21), timeout=15) + ftp.login(cfg["user"], cfg["password"]) + ftp.set_pasv(True) + try: + ftp.cwd(f"/{account_folder}") + except ftplib.error_perm: + ftp.quit() + return None + files = [] + ftp.retrlines("NLST", files.append) + htm = next((f for f in files if f.lower().endswith(('.htm','.html'))), None) + if not htm: + ftp.quit() + return None + buf = [] + ftp.retrbinary(f"RETR {htm}", buf.append) + ftp.quit() + return b"".join(buf) + + +def _extract_report_date(raw: bytes) -> str | None: + """Extract the report generation date from MT5 HTML report header.""" + import re + for enc in ['utf-16', 'utf-8', 'latin-1']: + try: + text = raw.decode(enc) + break + except Exception: + text = None + if not text: + return None + # Look for Date: 2026.04.17 20:06 pattern in table cells + # Strip tags first so whitespace/newlines between 'Date:' and value don't block match + text_clean = re.sub(r'<[^>]+>', ' ', text) + match = re.search(r'Date:\s*(\d{4}\.\d{2}\.\d{2}\s+\d{2}:\d{2})', text_clean) + if match: + try: + from datetime import datetime as _dt + return _dt.strptime(match.group(1).strip(), '%Y.%m.%d %H:%M').isoformat() + except Exception: + return None + return None + + +def refresh_account(cfg: dict, account_folder: str, label: str = "") -> dict: + """Download, parse, cache one account. Returns {df, stats, error}.""" + from mt5_parser import detect_and_parse, calc_stats + raw = ftp_download_report(cfg, account_folder) + if raw is None: + return {"error": f"No report found for {account_folder}"} + df, fmt = detect_and_parse(raw, f"{account_folder}.htm") + if df is None or df.empty: + return {"error": f"Could not parse report for {account_folder}"} + stats = calc_stats(df) + # Also parse open positions if present + from mt5_parser import parse_open_positions + df_open = parse_open_positions(raw) + data = { + "account_folder": account_folder, + "label" : label or account_folder, + "df" : df, + "stats" : stats, + "fmt" : fmt, + "df_open" : df_open, + "fetched_at" : datetime.now().isoformat(), + "report_date" : _extract_report_date(raw), + "error" : None, + } + CACHE_DIR.mkdir(exist_ok=True) + (CACHE_DIR / f"ftp_{account_folder}.pkl").write_bytes(pickle.dumps(data)) + return data + + +def load_cache(account_folder: str) -> dict | None: + p = CACHE_DIR / f"ftp_{account_folder}.pkl" + if not p.exists(): + return None + try: + return pickle.loads(p.read_bytes()) + except Exception: + return None + + +def cache_age_minutes(account_folder: str) -> float: + p = CACHE_DIR / f"ftp_{account_folder}.pkl" + if not p.exists(): + return float("inf") + return (datetime.now().timestamp() - p.stat().st_mtime) / 60 + + +def get_all_cached() -> list[dict]: + if not CACHE_DIR.exists(): + return [] + out = [] + for p in sorted(CACHE_DIR.glob("ftp_*.pkl")): + try: + out.append(pickle.loads(p.read_bytes())) + except Exception: + pass + return out + + +# ── Render ───────────────────────────────────────────────────────────────────── + +def render(): + st.title("📡 Live MT5 EA's") + + ftp_cfg = load_ftp_config() + if not ftp_cfg: + st.error("No FTP config found. Run `python ftp_sync_cli.py --host ... --save` first.") + return + + acc_cfgs = load_account_configs() + + # ── Account config expander ────────────────────────────────────────────── + with st.expander("⚙️ Account Configuration", expanded=not acc_cfgs): + st.caption("Add accounts by folder name (must match FTP folder). Set label and starting balance.") + + # ── Add account form ────────────────────────────────────────────────── + st.markdown("**Add Account**") + add_c1, add_c2, add_c3, add_c4, add_c5 = st.columns([2, 2, 2, 2, 1]) + new_folder = add_c1.text_input("FTP Folder", placeholder="144032", + key="cfg_new_folder") + new_label = add_c2.text_input("Label", placeholder="Gold EA", + key="cfg_new_label") + new_balance = add_c3.number_input("Starting Balance ($)", value=10000.0, + min_value=0.0, step=1000.0, format="%.0f", + key="cfg_new_balance") + new_type = add_c4.selectbox("Type", ["Demo","Personal","Prop"], + key="cfg_new_type") + add_c5.markdown("
", unsafe_allow_html=True) + if add_c5.button("➕ Add", key="cfg_add"): + if not new_folder.strip(): + st.error("FTP folder name is required.") + else: + # Verify folder exists on FTP + try: + ftp_folders = ftp_list_accounts(ftp_cfg) + if new_folder.strip() not in ftp_folders: + st.error(f"Folder `{new_folder}` not found on FTP. " + f"Available: {', '.join(ftp_folders)}") + else: + existing_accs = load_account_configs() + if any(a["account"] == new_folder.strip() for a in existing_accs): + st.warning(f"Account `{new_folder}` already configured.") + else: + existing_accs.append({ + "account": new_folder.strip(), + "label" : new_label.strip() or new_folder.strip(), + "balance": float(new_balance), + "type" : new_type, + }) + save_account_configs(existing_accs) + acc_cfgs = existing_accs + st.success(f"✓ Added `{new_folder}`") + st.rerun() + except Exception as e: + st.error(f"FTP error: {e}") + + # ── Existing accounts ───────────────────────────────────────────────── + if acc_cfgs: + st.markdown("**Configured Accounts**") + hdr = st.columns([1, 2, 2, 2, 2, 1]) + hdr[0].markdown("**Folder**") + hdr[1].markdown("**Label**") + hdr[2].markdown("**Balance ($)**") + hdr[3].markdown("**Type**") + hdr[4].markdown("**Last Report**") + hdr[5].markdown("**Remove**") + updated = [] + for idx_ac, ac in enumerate(acc_cfgs): + if idx_ac > 0: + st.divider() + c1, c2, c3, c4, c5, c6 = st.columns([1, 2, 2, 2, 2, 1]) + c1.markdown(f"`{ac['account']}`") + label = c2.text_input("", value=ac.get("label", ac["account"]), + key=f"lbl_{ac['account']}", + label_visibility="collapsed") + balance = c3.number_input("", value=float(ac.get("balance", 10000)), + min_value=0.0, step=1000.0, format="%.0f", + key=f"bal_{ac['account']}", + label_visibility="collapsed") + acc_type = c4.selectbox("", ["Demo", "Personal", "Prop"], + index=["Demo","Personal","Prop"].index( + ac.get("type","Demo")), + key=f"type_{ac['account']}", + label_visibility="collapsed") + # Prop-specific target/loss fields + if acc_type == "Prop": + prop_c1, prop_c2, prop_c3 = st.columns(3) + profit_target = prop_c1.number_input( + "Profit target %", + value=float(ac.get("profit_target", 10.0)), + min_value=0.0, max_value=100.0, step=1.0, format="%.1f", + key=f"pt_{ac['account']}") + max_loss = prop_c2.number_input( + "Max loss %", + value=float(ac.get("max_loss", 10.0)), + min_value=0.0, max_value=100.0, step=1.0, format="%.1f", + key=f"ml_{ac['account']}") + daily_loss = prop_c3.number_input( + "Daily loss %", + value=float(ac.get("daily_loss", 5.0)), + min_value=0.0, max_value=100.0, step=0.5, format="%.1f", + key=f"dl_{ac['account']}") + else: + profit_target = ac.get("profit_target", 10.0) + max_loss = ac.get("max_loss", 10.0) + daily_loss = ac.get("daily_loss", 5.0) + # Last report date from cache + cached = load_cache(ac["account"]) + if cached and cached.get("fetched_at"): + try: + dt = datetime.fromisoformat(cached["fetched_at"]) + last_report = dt.strftime("%d %b %H:%M") + except Exception: + last_report = "—" + else: + last_report = "No cache" + c5.markdown(f'
{last_report}
', + unsafe_allow_html=True) + if c6.button("🗑", key=f"rm_{ac['account']}"): + remaining = [a for a in acc_cfgs if a["account"] != ac["account"]] + save_account_configs(remaining) + # Clear account selector so removed account disappears + if "ftp_sel_accounts" in st.session_state: + del st.session_state["ftp_sel_accounts"] + st.rerun() + updated.append({"account": ac["account"], "label": label, + "balance": balance, "type": acc_type, + "profit_target": profit_target, + "max_loss": max_loss, + "daily_loss": daily_loss}) + + if st.button("💾 Save Changes", type="primary", key="cfg_save"): + save_account_configs(updated) + acc_cfgs = updated + st.success("Saved.") + st.rerun() + + if not acc_cfgs: + st.info("Configure account labels above, then click Refresh All.") + return + + acc_map = {a["account"]: a for a in acc_cfgs} + + # ── Auto-load on first visit + Refresh ──────────────────────────────────── + ages = [cache_age_minutes(a["account"]) for a in acc_cfgs + if cache_age_minutes(a["account"]) < float("inf")] + no_cache = any(cache_age_minutes(a["account"]) == float("inf") for a in acc_cfgs) + + hdr1, hdr2, hdr3, hdr4 = st.columns([3, 1, 1, 1]) + with hdr2: + do_refresh = st.button("🔄 Refresh All", type="primary", + use_container_width=True) + with hdr3: + poll_interval = st.number_input("Auto-refresh (min)", min_value=0, + max_value=60, value=5, step=5, + key="ftp_poll_interval", + help="0 = disabled. Page must be open.") + with hdr4: + if ages: + oldest = max(ages) + st.caption(f"Updated {oldest:.0f}m ago") + + # Auto-refresh via polling — only trigger after poll_interval has passed + # since the last actual refresh (tracked in session state) + auto_refresh = False + if poll_interval > 0 and ages and not no_cache: + last_auto = st.session_state.get("ftp_last_auto_refresh", 0) + now_ts = datetime.now().timestamp() + if (now_ts - last_auto) >= poll_interval * 60: + auto_refresh = True + + if do_refresh or no_cache or auto_refresh: + if auto_refresh and not do_refresh: + st.session_state["ftp_last_auto_refresh"] = datetime.now().timestamp() + label_text = "Loading..." if no_cache else "Refreshing..." + prog = st.progress(0, text=label_text) + errors = [] + for i, acfg in enumerate(acc_cfgs): + prog.progress((i + 1) / len(acc_cfgs), + text=f"Fetching {acfg['label']}...") + result = refresh_account(ftp_cfg, acfg["account"], acfg["label"]) + if result.get("error"): + errors.append(f"**{acfg['label']}**: {result['error']}") + prog.empty() + if errors: + for e in errors: + st.error(e) + elif do_refresh: + st.success(f"✓ Refreshed {len(acc_cfgs)} accounts") + st.rerun() + + # ── Load all cached data ────────────────────────────────────────────────── + all_data = [] + for acfg in acc_cfgs: + data = load_cache(acfg["account"]) + if data: + data["balance"] = acfg["balance"] + data["label"] = acfg["label"] + all_data.append(data) + + if not all_data: + st.info("No cached data. Click **Refresh All**.") + return + + # ── Account selector ────────────────────────────────────────────────────── + st.divider() + all_labels = [d["label"] for d in all_data] + sel_labels = st.multiselect("Accounts", all_labels, default=all_labels, + key="ftp_sel_accounts") + sel_data = [d for d in all_data if d["label"] in sel_labels] + + if not sel_data: + st.info("Select at least one account.") + return + + # Merge all selected DataFrames + dfs = [] + for d in sel_data: + df = d["df"].copy() + df["_account"] = d["label"] + df["_balance"] = d["balance"] + dfs.append(df) + df_all = pd.concat(dfs, ignore_index=True) + df_all["close_time"] = pd.to_datetime(df_all["close_time"], errors="coerce") + df_all["open_time"] = pd.to_datetime(df_all["open_time"], errors="coerce") + df_all = df_all.dropna(subset=["close_time"]).sort_values("close_time").reset_index(drop=True) + + total_balance = sum(d["balance"] for d in sel_data) + + # ── Account summary table ──────────────────────────────────────────────── + st.markdown("**Account Summary**") + _sum_cards = [] + for d in sel_data: + acfg = acc_map.get(d["account_folder"], {}) + acc_type = acfg.get("type", "Demo") + balance = d["balance"] + df_tmp = d["df"].copy() + df_tmp["net_profit"] = pd.to_numeric(df_tmp["net_profit"], errors="coerce").fillna(0) + df_tmp["close_time"] = pd.to_datetime(df_tmp["close_time"], errors="coerce") + df_tmp["open_time"] = pd.to_datetime(df_tmp["open_time"], errors="coerce") + df_tmp = df_tmp.sort_values("close_time").reset_index(drop=True) + + current_pnl = df_tmp["net_profit"].sum() + current_bal = balance + current_pnl + pnl_pct = round(current_pnl / balance * 100, 2) if balance else 0 + pnl_color = "#34C27A" if current_pnl >= 0 else "#E05555" + badge_bg = {"Demo":"rgba(124,106,247,0.3)","Personal":"rgba(52,194,122,0.3)", + "Prop":"rgba(255,165,0,0.3)"}.get(acc_type,"rgba(128,128,128,0.2)") + + # Report date + rpt_date = d.get("report_date") + try: + fetched_str = (datetime.fromisoformat(rpt_date).strftime("%d %b %Y %H:%M") + if rpt_date else + datetime.fromisoformat(d.get("fetched_at","")).strftime("%d %b %H:%M")) + except Exception: + fetched_str = "—" + + # ── Recovery factor: net_profit / abs(max_dd) ───────────────────────── + from mt5_parser import calc_stats as _cs + _stats = _cs(df_tmp, deposit=balance) + max_dd = _stats.get("max_drawdown", 0) + recovery = round(current_pnl / abs(max_dd), 2) if max_dd != 0 else "—" + rec_color = "#34C27A" if isinstance(recovery, float) and recovery >= 1 else "#E05555" + + # ── Consecutive loss streak (most recent trades) ────────────────────── + if not df_tmp.empty: + streak = 0 + for _, row in df_tmp[::-1].iterrows(): + if row.get("win") == False or (isinstance(row.get("win"), bool) and not row["win"]): + streak += 1 + else: + break + else: + streak = 0 + streak_color = "#E05555" if streak >= 3 else ("#F5A623" if streak >= 1 else "#34C27A") + + # ── Stagnation: days since last equity high ─────────────────────────── + if not df_tmp.empty: + df_tmp["_cum"] = df_tmp["net_profit"].cumsum() + df_tmp["_peak"] = df_tmp["_cum"].cummax() + at_peak = df_tmp[df_tmp["_cum"] >= df_tmp["_peak"]] + if not at_peak.empty: + last_high = pd.to_datetime(at_peak["close_time"].max()) + stag_days = (datetime.now() - last_high).days + else: + stag_days = 0 + else: + stag_days = 0 + stag_color = "#E05555" if stag_days >= 14 else ("#F5A623" if stag_days >= 7 else "#34C27A") + + # ── Today's P&L for daily loss tracking ────────────────────────────── + today_str = date.today().isoformat() + today_df = df_tmp[df_tmp["close_time"].dt.date == date.today()] + today_pnl = today_df["net_profit"].sum() + today_pct = round(today_pnl / balance * 100, 2) if balance else 0 + + # ── Prop bars ───────────────────────────────────────────────────────── + prop_bars = "" + ea_stopped = False + if acc_type == "Prop": + pt = acfg.get("profit_target", 10.0) + ml = acfg.get("max_loss", 10.0) + dl = acfg.get("daily_loss", 5.0) + pbw = round(min(max(pnl_pct,0), pt) / pt * 100, 1) if pt else 0 + lbw = round(min(max(-pnl_pct,0), ml) / ml * 100, 1) if ml else 0 + dlv = min(max(-today_pct,0), dl) + dbw = round(dlv / dl * 100, 1) if dl else 0 + dl_color = "#E05555" if dbw >= 80 else ("#F5A623" if dbw >= 50 else "#34C27A") + # EA hard stop triggered when today's loss >= daily limit + ea_stopped = dl > 0 and (-today_pct) >= dl + stopped_banner = ( + '
' + '⛔ EA stopped — daily loss limit reached
' + ) if ea_stopped else "" + prop_bars = ( + '
' + f'
Profit {pnl_pct:+.2f}% / {pt:.0f}%
' + f'
' + f'
' + f'
Max loss {min(max(-pnl_pct,0),ml):.2f}% / {ml:.0f}%
' + f'
' + f'
' + f'
Daily loss {today_pct:.2f}% / {dl:.0f}%
' + f'
' + f'
' + f'{stopped_banner}' + '
' + ) + + card = ( + '
' + f'
' + f'{d["label"]}' + f'{acc_type}' + '
' + f'
Updated: {fetched_str}
' + f'
{current_pnl:+,.2f} ({pnl_pct:+.2f}%)
' + f'
Balance: ${balance:,.0f} → Current: ${current_bal:,.2f}
' + f'
' + f'
Recovery: {recovery}
' + f'
Loss streak: {streak}
' + f'
Stagnation: {stag_days}d
' + f'
Today: =0 else "#E05555"}">{today_pnl:+.2f} ({today_pct:+.2f}%)
' + '
' + f'{prop_bars}' + '
' + ) + _sum_cards.append(card) + + st.markdown( + '
' + + "".join(_sum_cards) + '
', + unsafe_allow_html=True) + + # ── Open trades ────────────────────────────────────────────────────────── + _all_open = [] + for d in sel_data: + # Prefer parsed df_open from Open Positions section + df_op = d.get("df_open") + if df_op is not None and not df_op.empty: + df_op = df_op.copy() + df_op["_account"] = d["label"] + _all_open.append(df_op) + + if _all_open: + open_df = pd.concat(_all_open, ignore_index=True) + show_cols = [c for c in ["_account","symbol","type","volume", + "open_time","open_price","sl","tp"] + if c in open_df.columns] + with st.expander(f"🔴 Open Positions ({len(open_df)})", expanded=True): + st.dataframe(open_df[show_cols], use_container_width=True, hide_index=True) + else: + st.caption("No open positions in current reports.") + + # ── Correlation matrix ──────────────────────────────────────────────────── + if len(sel_data) > 1: + with st.expander("📊 Symbol Correlation across Accounts", expanded=False): + corr_rows = [] + for d in sel_data: + df_c = d["df"].copy() + df_c["net_profit"] = pd.to_numeric(df_c["net_profit"], errors="coerce").fillna(0) + df_c["close_time"] = pd.to_datetime(df_c["close_time"], errors="coerce") + by_sym = df_c.groupby("symbol")["net_profit"].sum() + by_sym.name = d["label"] + corr_rows.append(by_sym) + corr_df = pd.DataFrame(corr_rows).T.fillna(0) + if corr_df.shape[1] > 1 and len(corr_df) > 2: + corr_matrix = corr_df.corr().round(2) + labels = corr_matrix.columns.tolist() + z = corr_matrix.values.tolist() + fig_corr = go.Figure(go.Heatmap( + z=z, x=labels, y=labels, + colorscale=[[0,"#E05555"],[0.5,"#f0f0f0"],[1,"#34C27A"]], + zmin=-1, zmax=1, + text=[[f"{v:.2f}" for v in row] for row in z], + texttemplate="%{text}", + showscale=True, + )) + fig_corr.update_layout( + height=300, title="Account Correlation (by symbol P&L)", + plot_bgcolor="rgba(0,0,0,0)", paper_bgcolor="rgba(0,0,0,0)", + font=dict(family="sans-serif"), + margin=dict(l=80,r=20,t=40,b=80), + ) + st.plotly_chart(fig_corr, use_container_width=True, key="ftp_corr") + else: + st.caption("Not enough shared symbols across accounts to compute correlation." + " Symbols need to overlap between at least 2 accounts.") + + # Force balance update when account selection changes + _bal_key = f"ftp_bal_{'_'.join(sorted(sel_labels))}" + if st.session_state.get("ftp_last_bal_key") != _bal_key: + st.session_state["ftp_last_bal_key"] = _bal_key + st.session_state["ftp_cal_balance"] = float(total_balance) + + # ── Calendar section ────────────────────────────────────────────────────── + st.subheader("Calendar") + cal_bal = st.number_input( + "Combined Balance ($) — for % calc", + value=st.session_state.get("ftp_cal_balance", float(total_balance)), + min_value=100.0, step=1000.0, format="%.0f", + key="ftp_cal_balance", + help="Auto-set from selected account balances. Override if needed." + ) + cal_c1, cal_c2 = st.columns([2, 2]) + cal_view = cal_c1.radio("Calendar", ["Month", "Week", "Year"], + horizontal=True, key="ftp_cal_view") + cal_unit = cal_c2.radio("Calendar unit", ["$", "%"], + horizontal=True, key="ftp_cal_unit") + + today = date.today() + if "ftp_cal_y" not in st.session_state: + st.session_state["ftp_cal_y"] = today.year + st.session_state["ftp_cal_m"] = today.month + st.session_state["ftp_cal_w"] = today.isocalendar()[1] + + nav1, nav2, nav3 = st.columns([1, 3, 1]) + with nav1: + if st.button("◀", key="ftp_prev"): + if cal_view == "Month": + m = st.session_state["ftp_cal_m"] - 1 + if m < 1: m = 12; st.session_state["ftp_cal_y"] -= 1 + st.session_state["ftp_cal_m"] = m + elif cal_view == "Week": + w = st.session_state["ftp_cal_w"] - 1 + if w < 1: + st.session_state["ftp_cal_y"] -= 1 + w = 52 + st.session_state["ftp_cal_w"] = w + else: + st.session_state["ftp_cal_y"] -= 1 + st.rerun() + with nav3: + if st.button("▶", key="ftp_next"): + if cal_view == "Month": + m = st.session_state["ftp_cal_m"] + 1 + if m > 12: m = 1; st.session_state["ftp_cal_y"] += 1 + st.session_state["ftp_cal_m"] = m + elif cal_view == "Week": + w = st.session_state["ftp_cal_w"] + 1 + if w > 52: + st.session_state["ftp_cal_y"] += 1 + w = 1 + st.session_state["ftp_cal_w"] = w + else: + st.session_state["ftp_cal_y"] += 1 + st.rerun() + with nav2: + if cal_view == "Month": + nav_label = f"{calendar.month_name[st.session_state['ftp_cal_m']]} {st.session_state['ftp_cal_y']}" + elif cal_view == "Week": + nav_label = f"Week {st.session_state['ftp_cal_w']} — {st.session_state['ftp_cal_y']}" + else: + nav_label = str(st.session_state["ftp_cal_y"]) + st.markdown(f"

{nav_label}

", + unsafe_allow_html=True) + + # Build daily aggregates + df_all["_day"] = df_all["close_time"].dt.date + day_agg = df_all.groupby("_day").agg( + pnl_dollar = ("net_profit", "sum"), + trades = ("net_profit", "count"), + wins = ("win", "sum"), + ).reset_index() + day_agg["losses"] = day_agg["trades"] - day_agg["wins"] + day_agg["pnl_pct"] = (day_agg["pnl_dollar"] / cal_bal * 100).round(3) + day_map = {row["_day"]: row for _, row in day_agg.iterrows()} + + # ── Summary cards for selected period ───────────────────────────────────── + sel_y = st.session_state["ftp_cal_y"] + sel_m = st.session_state["ftp_cal_m"] + sel_w = st.session_state["ftp_cal_w"] + + if cal_view == "Month": + period_days = [d for d in day_map if d.year == sel_y and d.month == sel_m] + elif cal_view == "Week": + period_days = [d for d in day_map + if d.isocalendar()[0] == sel_y and d.isocalendar()[1] == sel_w] + else: + period_days = [d for d in day_map if d.year == sel_y] + + period_rows = day_agg[day_agg["_day"].isin(period_days)] + tot_pnl = period_rows["pnl_dollar"].sum() + tot_pct = period_rows["pnl_pct"].sum() + tot_tr = int(period_rows["trades"].sum()) + tot_w = int(period_rows["wins"].sum()) + tot_l = int(period_rows["losses"].sum()) + wr = round(tot_w / tot_tr * 100, 1) if tot_tr > 0 else 0 + trd_days = len(period_rows) + + sc1,sc2,sc3,sc4,sc5,sc6 = st.columns(6) + sc1.metric("P&L ($)", f"${tot_pnl:,.2f}") + sc2.metric("P&L (%)", f"{tot_pct:+.2f}%") + sc3.metric("Trades", tot_tr) + sc4.metric("Win Rate", f"{wr}%") + sc5.metric("Wins / Losses", f"{tot_w} / {tot_l}") + sc6.metric("Trading Days", trd_days) + + st.markdown("
", unsafe_allow_html=True) + + # ── Calendar grid ───────────────────────────────────────────────────────── + if cal_view == "Month": + _render_month_grid(sel_y, sel_m, day_map, today, cal_unit, cal_bal) + elif cal_view == "Week": + _render_week_grid(sel_y, sel_w, day_map, today, cal_unit, cal_bal) + else: + _render_year_grid(sel_y, day_map, today, cal_unit, cal_bal) + + # ── Trade Analysis section ──────────────────────────────────────────────── + st.divider() + + st.divider() + st.subheader("Trade Analysis") + + # Filters + fc1, fc2, fc3, fc4, fc5 = st.columns(5) + with fc1: + valid_times = df_all["open_time"].dropna() + d_min = valid_times.min().date() + d_max = valid_times.max().date() + date_from = st.date_input("From", value=d_min, min_value=d_min, + max_value=d_max, key="ftp_from") + date_to = st.date_input("To", value=d_max, min_value=d_min, + max_value=d_max, key="ftp_to") + with fc2: + syms = sorted(df_all["symbol"].dropna().unique().tolist()) + sel_sym = st.multiselect("Symbol", syms, key="ftp_sym") + with fc3: + # Algo from comment field + algos = sorted(df_all["comment"].dropna().unique().tolist()) + algos = [a for a in algos if a.strip()] + sel_algo = st.multiselect("Algo (comment)", algos, key="ftp_algo") + with fc4: + days = ["Monday","Tuesday","Wednesday","Thursday","Friday"] + sel_days = st.multiselect("Day of week", days, key="ftp_days") + sel_type = st.multiselect("Type", ["buy","sell"], key="ftp_type") + with fc5: + sel_accs = st.multiselect("Account", all_labels, default=sel_labels, + key="ftp_acc_filter") + # Auto-calculate balance from selected accounts in filter + _acc_bal = sum( + d["balance"] for d in all_data if d["label"] in (sel_accs or sel_labels) + ) + _dep_key = f"ftp_dep_{'_'.join(sorted(sel_accs or sel_labels))}" + if st.session_state.get("ftp_last_dep_key") != _dep_key: + st.session_state["ftp_last_dep_key"] = _dep_key + st.session_state["ftp_deposit"] = float(_acc_bal) + deposit = st.number_input( + "Balance ($)", + value=st.session_state.get("ftp_deposit", float(_acc_bal)), + min_value=100.0, step=1000.0, format="%.0f", + key="ftp_deposit", + help="Auto-set from selected accounts. Override if needed." + ) + + # Apply filters + df = df_all.copy() + df = df[(df["open_time"].dt.date >= date_from) & + (df["open_time"].dt.date <= date_to)] + if sel_sym: df = df[df["symbol"].isin(sel_sym)] + if sel_algo: df = df[df["comment"].isin(sel_algo)] + if sel_days: df = df[df["day_of_week"].isin(sel_days)] + if sel_type: df = df[df["type"].isin(sel_type)] + if sel_accs: df = df[df["_account"].isin(sel_accs)] + df = df.reset_index(drop=True) + + st.caption(f"Showing **{len(df)}** trades after filters · " + f"Combined balance: **${deposit:,.0f}**") + + if df.empty: + st.info("No trades match the current filters.") + return + + # Analysis mode + from mt5_parser import calc_stats + mode = st.radio("Analysis mode", + ["Overall", "By Account", "By Symbol", "By Algo", "By Day of Week"], + horizontal=True, key="ftp_mode") + st.divider() + + if mode == "Overall": + _render_analysis(df, calc_stats(df, deposit=deposit), deposit, key_prefix="ftp_overall") + + elif mode == "By Account": + accs = sorted(df["_account"].dropna().unique()) + rows = [] + for a in accs: + s = calc_stats(df[df["_account"] == a], deposit=next((d["balance"] for d in all_data if d["label"]==a), 0)) + rows.append({"Account": a, "Trades": s["total_trades"], + "Net P&L": s["net_profit"], "Win Rate %": s["win_rate"], + "Profit Factor": s["profit_factor"], + "Expectancy": s["expectancy"], "Max DD": s["max_drawdown"]}) + st.dataframe(pd.DataFrame(rows).sort_values("Net P&L", ascending=False), + use_container_width=True, hide_index=True) + st.divider() + sel = st.selectbox("Account detail", accs, key="ftp_acc_sel") + if sel: + sub = df[df["_account"] == sel] + _render_analysis(sub, calc_stats(sub, deposit=deposit), deposit, key_prefix=f"ftp_acc_{sel}") + + elif mode == "By Symbol": + syms_u = sorted(df["symbol"].dropna().unique()) + rows = [] + for s in syms_u: + st_ = calc_stats(df[df["symbol"] == s], deposit=deposit) + rows.append({"Symbol": s, "Trades": st_["total_trades"], + "Net P&L": st_["net_profit"], "Win Rate %": st_["win_rate"], + "Profit Factor": st_["profit_factor"], + "Expectancy": st_["expectancy"], "Max DD": st_["max_drawdown"]}) + st.dataframe(pd.DataFrame(rows).sort_values("Net P&L", ascending=False), + use_container_width=True, hide_index=True) + sel = st.selectbox("Symbol detail", syms_u, key="ftp_sym_sel") + if sel: + sub = df[df["symbol"] == sel] + _render_analysis(sub, calc_stats(sub, deposit=deposit), deposit, key_prefix=f"ftp_sym_{sel}") + + elif mode == "By Algo": + algo_u = sorted(df["comment"].dropna().unique()) + algo_u = [a for a in algo_u if a.strip()] + rows = [] + for a in algo_u: + st_ = calc_stats(df[df["comment"] == a], deposit=deposit) + rows.append({"Algo": a, "Trades": st_["total_trades"], + "Net P&L": st_["net_profit"], "Win Rate %": st_["win_rate"], + "Profit Factor": st_["profit_factor"], + "Expectancy": st_["expectancy"], "Max DD": st_["max_drawdown"]}) + st.dataframe(pd.DataFrame(rows).sort_values("Net P&L", ascending=False), + use_container_width=True, hide_index=True) + sel = st.selectbox("Algo detail", algo_u, key="ftp_algo_sel") + if sel: + sub = df[df["comment"] == sel] + _render_analysis(sub, calc_stats(sub, deposit=deposit), deposit, key_prefix=f"ftp_algo_{sel}") + + elif mode == "By Day of Week": + _render_dow(df) + _render_hour(df) + + +# ── Analysis helpers ─────────────────────────────────────────────────────────── + +def _render_analysis(df, stats, deposit, key_prefix="ftp"): + """Stats cards + equity + drawdown + daily P&L + DOW + hour + monthly.""" + _render_stats(stats) + _render_equity(df, key_prefix) + col1, col2 = st.columns(2) + with col1: + _render_dow(df, key_prefix) + with col2: + _render_hour(df, key_prefix) + st.divider() + _render_monthly(df, deposit, key_prefix) + + +def _render_stats(stats): + 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}") + _dd_abs = stats['max_drawdown'] + _dd_pct = stats.get('max_drawdown_pct', 0) + c4.metric("Max DD", f"${_dd_abs:,.2f} ({abs(_dd_pct):.2f}%)") + c5.metric("Best Trade", f"${stats['best_trade']:,.2f}") + + c1,c2,c3,c4,c5 = st.columns(5) + c1.metric("Max Consec W", stats['max_consec_wins']) + c2.metric("Max Consec L", stats['max_consec_losses']) + c3.metric("Trading Days", stats.get('trading_days', 0)) + c4.metric("Trades/Day", stats.get('trades_per_day', 0)) + 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 WR", f"{stats['long_win_rate']}%") + c3.metric("Short Trades", stats['short_trades']) + c4.metric("Short WR", f"{stats['short_win_rate']}%") + + +def _render_equity(df, key_prefix): + df_s = df.sort_values("close_time").copy() + df_s["_cum"] = df_s["net_profit"].cumsum() + df_s["_peak"] = df_s["_cum"].cummax() + df_s["_dd"] = df_s["_cum"] - df_s["_peak"] + + # Drawdown unit toggle + dd_unit = st.radio("Drawdown", ["$", "%"], horizontal=True, + key=f"{key_prefix}_dd_unit") + # Running balance for % dd — use peak equity as denominator + if dd_unit == "%": + # % drawdown = dd / peak * 100 (avoid div by zero) + peak_safe = df_s["_peak"].replace(0, float("nan")) + dd_vals = (df_s["_dd"] / peak_safe * 100).fillna(0) + dd_prefix = "" + dd_suffix = "%" + else: + dd_vals = df_s["_dd"] + dd_prefix = "$" + dd_suffix = "" + + LAYOUT = dict(plot_bgcolor="rgba(0,0,0,0)", paper_bgcolor="rgba(0,0,0,0)", + font=dict(family="sans-serif"), + margin=dict(l=60,r=20,t=40,b=40), + xaxis=dict(gridcolor="rgba(128,128,128,0.15)"), + yaxis=dict(gridcolor="rgba(128,128,128,0.15)", tickprefix="$")) + + fig_eq = go.Figure(go.Scatter( + x=df_s["close_time"], y=df_s["_cum"], mode="lines", name="Equity", + line=dict(color="#7c6af7", width=2, shape="spline", smoothing=0.6), + fill="tozeroy", fillcolor="rgba(124,106,247,0.08)")) + fig_eq.update_layout(height=300, title="Equity Curve", + hovermode="x unified", **LAYOUT) + st.plotly_chart(fig_eq, use_container_width=True, key=f"{key_prefix}_eq") + + st.markdown("**Drawdown**") + fig_dd = go.Figure(go.Scatter( + x=df_s["close_time"], y=dd_vals, mode="lines", + fill="tozeroy", + line=dict(color="rgba(220,80,80,0.8)", width=1.5, + shape="spline", smoothing=0.6), + fillcolor="rgba(220,80,80,0.15)", + hovertemplate=f"%{{x}}
DD: {dd_prefix}%{{y:.2f}}{dd_suffix}")) + fig_dd.update_layout(height=130, showlegend=False, + xaxis=dict(gridcolor="rgba(128,128,128,0.15)", + showticklabels=False), + yaxis=dict(gridcolor="rgba(128,128,128,0.15)", + tickprefix=dd_prefix, + ticksuffix=dd_suffix), + plot_bgcolor="rgba(0,0,0,0)", + paper_bgcolor="rgba(0,0,0,0)", + font=dict(family="sans-serif"), + margin=dict(l=60,r=20,t=8,b=4)) + st.plotly_chart(fig_dd, use_container_width=True, key=f"{key_prefix}_dd") + + st.markdown("**Daily P&L**") + daily = df_s.groupby(df_s["close_time"].dt.date)["net_profit"].sum().reset_index() + daily.columns = ["date","pnl"] + fig_d = go.Figure(go.Bar( + x=[str(d) for d in daily["date"]], y=daily["pnl"].round(2).tolist(), + marker_color=["rgba(52,194,122,0.85)" if v>=0 + else "rgba(220,80,80,0.85)" for v in daily["pnl"]])) + fig_d.update_layout(height=160, showlegend=False, + xaxis=dict(type="category", + gridcolor="rgba(128,128,128,0.15)", + showticklabels=False), + yaxis=dict(gridcolor="rgba(128,128,128,0.15)", + tickprefix="$", zeroline=True, + zerolinecolor="rgba(128,128,128,0.3)"), + plot_bgcolor="rgba(0,0,0,0)", + paper_bgcolor="rgba(0,0,0,0)", + font=dict(family="sans-serif"), + margin=dict(l=60,r=20,t=4,b=40)) + st.plotly_chart(fig_d, use_container_width=True, key=f"{key_prefix}_daily") + + +def _render_dow(df, key_prefix="ftp_dow"): + dow_order = ["Monday","Tuesday","Wednesday","Thursday","Friday"] + present = [d for d in dow_order if d in df["day_of_week"].values] + wins_dow = df[df["win"]].groupby("day_of_week")["net_profit"].sum().reindex(present, fill_value=0) + losses_dow= df[~df["win"]].groupby("day_of_week")["net_profit"].sum().reindex(present, fill_value=0) + fig = go.Figure() + fig.add_trace(go.Bar(x=present, y=wins_dow.values, name="Profit", + marker_color="rgba(52,194,122,0.85)")) + fig.add_trace(go.Bar(x=present, y=losses_dow.values, name="Loss", + marker_color="rgba(220,80,80,0.85)")) + fig.update_layout(height=260, title="P&L by Day of Week", barmode="relative", + plot_bgcolor="rgba(0,0,0,0)", paper_bgcolor="rgba(0,0,0,0)", + font=dict(family="sans-serif"), + xaxis=dict(type="category", + gridcolor="rgba(128,128,128,0.15)"), + yaxis=dict(gridcolor="rgba(128,128,128,0.15)", + tickprefix="$"), + legend=dict(bgcolor="rgba(0,0,0,0)"), + margin=dict(l=60,r=20,t=40,b=40)) + + st.plotly_chart(fig, use_container_width=True, key=f"{key_prefix}_dow") + + +def _render_hour(df, key_prefix="ftp_hour"): + all_hours = sorted(df["hour"].dropna().unique()) + str_hours = [str(int(h)) for h in all_hours] + wins_h = df[df["win"]].groupby("hour")["net_profit"].sum().reindex(all_hours, fill_value=0) + losses_h = df[~df["win"]].groupby("hour")["net_profit"].sum().reindex(all_hours, fill_value=0) + fig = go.Figure() + fig.add_trace(go.Bar(x=str_hours, y=wins_h.values, name="Profit", + marker_color="rgba(52,194,122,0.85)")) + fig.add_trace(go.Bar(x=str_hours, y=losses_h.values, name="Loss", + marker_color="rgba(220,80,80,0.85)")) + fig.update_layout(height=260, title="P&L by Hour of Day", barmode="relative", + plot_bgcolor="rgba(0,0,0,0)", paper_bgcolor="rgba(0,0,0,0)", + font=dict(family="sans-serif"), + xaxis=dict(type="category", title="Hour (UTC)", + gridcolor="rgba(128,128,128,0.15)"), + yaxis=dict(gridcolor="rgba(128,128,128,0.15)", + tickprefix="$"), + legend=dict(bgcolor="rgba(0,0,0,0)"), + margin=dict(l=60,r=20,t=40,b=40)) + st.plotly_chart(fig, use_container_width=True, key=f"{key_prefix}_hour") + + +def _render_monthly(df, deposit, key_prefix): + tmp = df[["close_time","net_profit"]].dropna().copy() + tmp["close_time"] = pd.to_datetime(tmp["close_time"], errors="coerce") + tmp["year"] = tmp["close_time"].dt.year + tmp["month"] = tmp["close_time"].dt.month + monthly = tmp.groupby(["year","month"])["net_profit"].sum().reset_index() + if monthly.empty: + return + pivot = monthly.pivot(index="year", columns="month", + values="net_profit").fillna(0) + pivot.columns = [pd.Timestamp(2000,int(m),1).strftime("%b") for m in pivot.columns] + pivot["YTD"] = pivot.sum(axis=1) + pivot = pivot.sort_index(ascending=False) + month_order = ["Jan","Feb","Mar","Apr","May","Jun", + "Jul","Aug","Sep","Oct","Nov","Dec","YTD"] + cols = [c for c in month_order if c in pivot.columns] + + c1, c2 = st.columns([1, 5]) + toggle = c1.radio("", ["$", "%"], horizontal=True, key=f"{key_prefix}_mt_toggle", label_visibility="collapsed") + + def _cell(v): + pv = round(v / deposit * 100, 2) if toggle == "%" else v + bg = "rgba(52,194,122,0.18)" if pv>0 else ("rgba(220,80,80,0.18)" if pv<0 else "transparent") + fg = "#34C27A" if pv>0 else ("#E05555" if pv<0 else "#888") + txt = (f"{pv:+.2f}%" if pv!=0 else "—") if toggle=="%" else (f"{pv:+.2f}" if pv!=0 else "—") + return f'{txt}' + + rows_html = "" + for year, row in pivot[cols].iterrows(): + cells = f'{year}' + for col in cols: + cells += _cell(row.get(col, 0)) + rows_html += f"{cells}" + + hdr = 'Year' + hdr += "".join(f'{c}' for c in cols) + hdr += "" + + st.markdown( + f'
' + f'{hdr}{rows_html}
', + unsafe_allow_html=True) + + +# ── Calendar grid renderers ──────────────────────────────────────────────────── + +def _cell_html(day_num: int, row, is_today: bool, unit: str, balance: float) -> str: + if row is not None: + val = row["pnl_pct"] if unit == "%" else row["pnl_dollar"] + pos = val >= 0 + bg = "rgba(52,194,122,0.15)" if pos else "rgba(220,80,80,0.15)" + vc = "#34C27A" if pos else "#E05555" + sign = "+" if pos else "" + disp = f"{sign}{val:.2f}%" if unit=="%" else f"${val:,.2f}" + alt = f"${row['pnl_dollar']:,.2f}" if unit=="%" else f"{row['pnl_pct']:+.2f}%" + tr = int(row["trades"]) + content = ( + f'
' + f'{disp} ({alt})
' + f'
{tr} trade{"s" if tr!=1 else ""}
' + f'
✅{int(row["wins"])} ❌{int(row["losses"])}
' + ) + else: + bg = "rgba(255,255,255,0.02)" + content = '
' + + border = "border:2px solid rgba(124,106,247,0.6);" if is_today \ + else "border:1px solid rgba(255,255,255,0.06);" + return ( + f'' + f'
' + f'
{day_num}
' + f'{content}
' + ) + + +def _table_wrap(hdr: str, body: str) -> str: + return ( + '
' + '' + f'{hdr}{body}
' + ) + + +def _dow_header() -> str: + days_hdr = "".join( + f'{d}' + for d in ["Mon","Tue","Wed","Thu","Fri"] + ) + week_hdr = 'Weekly Total' + return days_hdr + week_hdr + + +def _render_month_grid(year, month, day_map, today, unit, balance): + cal = calendar.monthcalendar(year, month) + body = "" + for week in cal: + row_html = "" + # Mon-Fri only (indices 0-4), skip Sat(5) Sun(6) + for dow in range(5): + day_num = week[dow] + if day_num == 0: + row_html += '' + else: + d = date(year, month, day_num) + row_html += _cell_html(day_num, day_map.get(d), d==today, unit, balance) + + # Weekly summary cell + week_days = [date(year, month, week[i]) for i in range(5) if week[i] != 0] + if week_days: + week_rows = [day_map[d] for d in week_days if d in day_map] + if week_rows: + w_pnl_d = sum(r["pnl_dollar"] for r in week_rows) + w_pnl_p = sum(r["pnl_pct"] for r in week_rows) + w_tr = sum(int(r["trades"]) for r in week_rows) + w_wins = sum(int(r["wins"]) for r in week_rows) + w_loss = sum(int(r["losses"]) for r in week_rows) + pos = (w_pnl_d if unit == "$" else w_pnl_p) >= 0 + bg = "rgba(52,194,122,0.12)" if pos else "rgba(220,80,80,0.12)" + vc = "#34C27A" if pos else "#E05555" + disp = f"${w_pnl_d:,.2f}" if unit == "$" else f"{w_pnl_p:+.2f}%" + alt = f"{w_pnl_p:+.2f}%" if unit == "$" else f"${w_pnl_d:,.2f}" + week_cell = ( + f'' + f'
' + f'
Weekly
' + f'
{disp}
' + f'
({alt})
' + f'
{w_tr} trades
' + f'
✅{w_wins} ❌{w_loss}
' + f'
' + ) + else: + week_cell = '
' + else: + week_cell = '' + + body += f"{row_html}{week_cell}" + st.markdown(_table_wrap(_dow_header(), body), unsafe_allow_html=True) + + +def _render_week_grid(year, week_num, day_map, today, unit, balance): + # Get the Monday of the given ISO week + jan4 = date(year, 1, 4) + week_start = jan4 + timedelta(weeks=week_num - jan4.isocalendar()[1], + days=-jan4.weekday()) + days = [week_start + timedelta(days=i) for i in range(5)] # Mon-Fri only + cells = "" + for d in days: + cells += _cell_html(d.day, day_map.get(d), d==today, unit, balance) + date_hdr = "".join( + f'' + f'{["Mon","Tue","Wed","Thu","Fri"][i]}
' + f'{days[i].strftime("%d %b")}' + for i in range(5) + ) + body = f"{cells}" + st.markdown(_table_wrap(date_hdr, body), unsafe_allow_html=True) + + +def _render_year_grid(year, day_map, today, unit, balance): + """Year view — one row per month, columns = ISO weeks or just month summary.""" + month_order = list(range(1, 13)) + hdr = 'Month' + hdr += 'P&L' + hdr += 'Trades' + hdr += 'Win Rate' + hdr += 'Trading Days' + + body = "" + for m in month_order: + days_in_month = [d for d in day_map if d.year==year and d.month==m] + if not days_in_month: + continue + rows = [day_map[d] for d in days_in_month] + pnl = sum(r["pnl_dollar"] for r in rows) + pct = sum(r["pnl_pct"] for r in rows) + trades = sum(int(r["trades"]) for r in rows) + wins = sum(int(r["wins"]) for r in rows) + wr = round(wins/trades*100,1) if trades else 0 + td = len(days_in_month) + + val = pct if unit=="%" else pnl + pos = val >= 0 + bg = "rgba(52,194,122,0.12)" if pos else "rgba(220,80,80,0.12)" + fg = "#34C27A" if pos else "#E05555" + disp = f"{val:+.2f}%" if unit=="%" else f"${val:,.2f}" + + body += ( + f'' + f'' + f'{calendar.month_name[m]}' + f'{disp}' + f'{trades}' + f'{wr}%' + f'{td}' + f'' + ) + + st.markdown( + f'
' + f'' + f'{hdr}{body}
', + unsafe_allow_html=True) \ No newline at end of file diff --git a/view_portfolio_builder.py b/view_portfolio_builder.py index 3b1deea..9107f75 100644 --- a/view_portfolio_builder.py +++ b/view_portfolio_builder.py @@ -27,7 +27,7 @@ def _parse_uploaded(file_obj): try: parser = _get_parser() raw = file_obj.read() - result = parser.detect_and_parse(raw) + result = parser.detect_and_parse(raw, file_obj.name) return result[0] if isinstance(result, tuple) else result except Exception as e: st.error(f"Failed to parse **{file_obj.name}**: {e}") @@ -74,6 +74,7 @@ def _ensure_columns(df: pd.DataFrame, label: str) -> pd.DataFrame: df["win"] = df["net_profit"] > 0 df["_strategy"] = label + df["_ea"] = label # EA = the uploaded filename stem return df @@ -660,23 +661,31 @@ def render(): if "symbol" in df.columns else [] if _is_multi: - strat_numbered = {f"{i+1} \u2014 {s}": s for i, s in enumerate(strat_labels)} - fc1, fc2 = st.columns(2) - sel_strat_nums = fc1.multiselect( - "Filter strategies", - list(strat_numbered.keys()), - default=list(strat_numbered.keys()), - key="pb_ov_strat", - help="Deselect strategies to exclude them from Overview stats", + # EA filter (file level) + ea_labels = sorted(df["_ea"].dropna().unique().tolist()) \ + if "_ea" in df.columns else strat_labels + fc1, fc2, fc3 = st.columns(3) + sel_eas = fc1.multiselect( + "Filter EA", + ea_labels, default=ea_labels, key="pb_ov_ea", + help="Filter by uploaded file (EA)", ) - sel_syms = fc2.multiselect( + # Strategy filter — cascades from EA selection + if "_ea" in df.columns and sel_eas: + strat_labels_filtered = sorted( + df[df["_ea"].isin(sel_eas)]["strategy"].dropna().unique().tolist() + ) if "strategy" in df.columns else strat_labels + else: + strat_labels_filtered = strat_labels + sel_strats_raw = fc2.multiselect( + "Filter Strategy", + strat_labels_filtered, default=strat_labels_filtered, key="pb_ov_strat", + help="Filter by strategy (comment) within selected EAs", + ) + sel_syms = fc3.multiselect( "Filter symbols", - sym_labels, - default=sym_labels, - key="pb_ov_sym", - help="Deselect symbols to exclude them from Overview stats", + sym_labels, default=sym_labels, key="pb_ov_sym", ) - sel_strats_raw = [strat_numbered[k] for k in sel_strat_nums] else: sel_strats_raw = strat_labels sel_syms = sym_labels @@ -688,8 +697,8 @@ def render(): (ct >= pd.Timestamp(ov_date_from)) & (ct <= pd.Timestamp(ov_date_to) + pd.Timedelta(days=1)) ] - if sel_strats_raw and "_strategy" in ov_df.columns: - ov_df = ov_df[ov_df["_strategy"].isin(sel_strats_raw)] + if sel_strats_raw and "strategy" in ov_df.columns: + ov_df = ov_df[ov_df["strategy"].isin(sel_strats_raw)] if sel_syms and "symbol" in ov_df.columns: ov_df = ov_df[ov_df["symbol"].isin(sel_syms)] @@ -790,14 +799,22 @@ def render(): else: tr_date_from, tr_date_to = _date_slider("pb_tr") - fc1, fc2, fc3, fc4 = st.columns(4) + fc1, fc2, fc3, fc4, fc5 = st.columns(5) + all_eas = sorted(df["_ea"].dropna().unique().tolist()) if "_ea" in df.columns else [] all_syms = sorted(df["symbol"].dropna().unique().tolist()) if "symbol" in df.columns else [] all_types = sorted(df["type"].dropna().unique().tolist()) if "type" in df.columns else [] - all_strats = sorted(df["_strategy"].dropna().unique().tolist()) if "_strategy" in df.columns else [] - filt_sym = fc1.multiselect("Symbol", all_syms, default=all_syms, key="pb_ts") - filt_type = fc2.multiselect("Direction", all_types, default=all_types, key="pb_tt") - filt_strat = fc3.multiselect("Strategy", all_strats, default=all_strats, key="pb_tst") - result_f = fc4.selectbox("Result", ["All","Wins only","Losses only"], key="pb_tr") + filt_ea = fc1.multiselect("EA", all_eas, default=all_eas, key="pb_tea") + # Cascade strategies from EA filter + if filt_ea and "_ea" in df.columns: + avail_strats = sorted(df[df["_ea"].isin(filt_ea)]["strategy"].dropna().unique().tolist()) \ + if "strategy" in df.columns else [] + else: + avail_strats = sorted(df["strategy"].dropna().unique().tolist()) \ + if "strategy" in df.columns else [] + filt_strat = fc2.multiselect("Strategy", avail_strats, default=avail_strats, key="pb_tst") + filt_sym = fc3.multiselect("Symbol", all_syms, default=all_syms, key="pb_ts") + filt_type = fc4.multiselect("Direction", all_types, default=all_types, key="pb_tt") + result_f = fc5.selectbox("Result", ["All","Wins only","Losses only"], key="pb_tr") view = df.copy() if "close_time" in view.columns and tr_date_from and tr_date_to: @@ -806,9 +823,10 @@ def render(): (ct >= pd.Timestamp(tr_date_from)) & (ct <= pd.Timestamp(tr_date_to) + pd.Timedelta(days=1)) ] - if filt_sym and "symbol" in view.columns: view = view[view["symbol"].isin(filt_sym)] - if filt_type and "type" in view.columns: view = view[view["type"].isin(filt_type)] - if filt_strat and "_strategy" in view.columns: view = view[view["_strategy"].isin(filt_strat)] + if filt_ea and "_ea" in view.columns: view = view[view["_ea"].isin(filt_ea)] + if filt_strat and "strategy" in view.columns: view = view[view["strategy"].isin(filt_strat)] + if filt_sym and "symbol" in view.columns: view = view[view["symbol"].isin(filt_sym)] + if filt_type and "type" in view.columns: view = view[view["type"].isin(filt_type)] if result_f == "Wins only": view = view[view["net_profit"] > 0] elif result_f == "Losses only": view = view[view["net_profit"] <= 0] @@ -881,11 +899,11 @@ def render(): if df.empty or "close_time" not in df.columns: st.info("No time-series data available.") else: - # [8] Line mode options: Portfolio | Individual | Portfolio + Individual + # [8] Line mode options ctl1, ctl2, ctl3 = st.columns([3, 2, 2]) chart_view = ctl1.radio( "Lines", - ["Portfolio", "Individual strategies", "Portfolio + Individual"], + ["Portfolio", "By EA", "By Strategy", "EA + Strategy"], horizontal=True, key="pb_cv", ) smooth_window = ctl2.slider("Smoothing", 1, 50, 1, key="pb_sm", @@ -895,40 +913,73 @@ def render(): # Date range slider date_from, date_to = _date_slider("pb_eq") - # [8] Portfolio selector + strategy filter - sel_strats = list(eff_dfs.keys()) - if chart_view in ("Individual strategies", "Portfolio + Individual"): - # Portfolio filter: which portfolio's members to show - port_names = list(portfolios.keys()) - if port_names: - port_filter_opts = ["All strategies"] + port_names - pf_sel = st.selectbox("Filter to portfolio members", - port_filter_opts, key="pb_eq_pf") - if pf_sel != "All strategies" and pf_sel in portfolios: - default_strats = [s for s in portfolios[pf_sel] if s in eff_dfs] - else: - default_strats = list(eff_dfs.keys()) + # EA filter + cascading strategy filter + all_eas_eq = sorted(df["_ea"].dropna().unique().tolist()) if "_ea" in df.columns else list(eff_dfs.keys()) + sel_eas_eq = st.multiselect("Filter EA", all_eas_eq, default=all_eas_eq, key="pb_eq_ea") + + if chart_view in ("By Strategy", "EA + Strategy"): + if sel_eas_eq and "_ea" in df.columns and "strategy" in df.columns: + avail_strats_eq = sorted(df[df["_ea"].isin(sel_eas_eq)]["strategy"].dropna().unique().tolist()) else: - default_strats = list(eff_dfs.keys()) + avail_strats_eq = sorted(df["strategy"].dropna().unique().tolist()) if "strategy" in df.columns else [] + sel_strats_eq = st.multiselect("Filter Strategy", avail_strats_eq, default=avail_strats_eq, key="pb_eq_strat") + else: + sel_strats_eq = [] - sel_strats = st.multiselect( - "Strategies to show", list(eff_dfs.keys()), - default=default_strats, key="pb_sel_strats", - ) + # Build per-EA and per-strategy dfs for charting + # Per-EA: combine all trades for that EA filename + ea_dfs = {} + for ea in all_eas_eq: + if ea not in sel_eas_eq: + continue + ea_trades = df[df["_ea"] == ea] if "_ea" in df.columns else pd.DataFrame() + if not ea_trades.empty: + ea_trades = ea_trades.sort_values("close_time").reset_index(drop=True) + ea_dfs[ea] = ea_trades - # Determine combined df for Portfolio+Individual + # Per-strategy: combine all trades sharing the same strategy comment + strat_dfs = {} + if "strategy" in df.columns: + for strat in (sel_strats_eq if sel_strats_eq else df["strategy"].dropna().unique()): + mask = df["strategy"] == strat + if sel_eas_eq and "_ea" in df.columns: + mask &= df["_ea"].isin(sel_eas_eq) + s_trades = df[mask] + if not s_trades.empty: + s_trades = s_trades.sort_values("close_time").reset_index(drop=True) + strat_dfs[strat] = s_trades + + # Determine what to pass to the chart builder + if chart_view == "By EA": + chart_eff_dfs = ea_dfs + sel_strats = list(ea_dfs.keys()) + chart_cv = "Individual" + elif chart_view == "By Strategy": + chart_eff_dfs = strat_dfs + sel_strats = list(strat_dfs.keys()) + chart_cv = "Individual" + elif chart_view == "EA + Strategy": + chart_eff_dfs = {**ea_dfs, **strat_dfs} + sel_strats = list(ea_dfs.keys()) + list(strat_dfs.keys()) + chart_cv = "Portfolio+Individual" + else: # Portfolio + chart_eff_dfs = eff_dfs + sel_strats = list(eff_dfs.keys()) + chart_cv = "Portfolio" + + # Combined df for portfolio line chart_df = df - if chart_view == "Portfolio + Individual" and sel_strats: - members_dfs = {k: eff_dfs[k] for k in sel_strats if k in eff_dfs} - chart_df = _combine(members_dfs, deposit) if members_dfs else df + if chart_view == "EA + Strategy" and ea_dfs: + chart_df = _combine(ea_dfs, deposit) cv_map = { - "Portfolio": "Portfolio", - "Individual strategies": "Individual", - "Portfolio + Individual": "Portfolio+Individual", + "Portfolio": "Portfolio", + "By EA": "Individual", + "By Strategy": "Individual", + "EA + Strategy":"Portfolio+Individual", } fig = _build_equity_chart( - chart_df, deposit, eff_dfs, portfolios, active_label, + chart_df, deposit, chart_eff_dfs, portfolios, active_label, chart_view=cv_map[chart_view], smooth_window=smooth_window, show_stagnation=show_stag, @@ -979,13 +1030,30 @@ def render(): # Controls row st.markdown("##### Equity Curves") - ctl1, ctl2, ctl3 = st.columns([2, 2, 2]) + ctl1, ctl2, ctl3, ctl4 = st.columns([2, 2, 2, 2]) sc_smooth = ctl1.slider("Curve smoothing", 1, 50, 1, key="pb_st_smooth", help="Rolling-average window (trades).") - show_st_stag = ctl2.toggle("Show stagnation bands", value=False, + st_curve_grp = ctl2.radio("Group by", ["EA", "Strategy"], horizontal=True, key="pb_st_grp", + help="EA = one line per file · Strategy = one line per comment") + show_st_stag = ctl3.toggle("Show stagnation bands", value=False, key="pb_st_show_stag", help="Highlight max stagnation period per strategy in matching colour") + # Build the series to plot + if st_curve_grp == "Strategy" and "strategy" in df.columns: + # One series per unique strategy comment across all loaded files + _st_series = {} + for _strat in sorted(df["strategy"].dropna().unique()): + _s_df = df[df["strategy"] == _strat].copy() + if ov_date_from and ov_date_to and "close_time" in _s_df.columns: + _ct = pd.to_datetime(_s_df["close_time"]).dt.tz_localize(None) + _s_df = _s_df[(_ct >= pd.Timestamp(ov_date_from)) & + (_ct <= pd.Timestamp(ov_date_to) + pd.Timedelta(days=1))] + if not _s_df.empty: + _st_series[_strat] = _s_df.sort_values("close_time").reset_index(drop=True) + else: + _st_series = eff_dfs_filtered # one series per uploaded file + sf = go.Figure() sf.update_layout( height=500, @@ -998,7 +1066,7 @@ def render(): sf.update_xaxes(gridcolor="rgba(128,128,128,0.15)", zeroline=False) sf.update_yaxes(gridcolor="rgba(128,128,128,0.15)", zeroline=False, tickprefix="$") - for i, (lbl, sdf) in enumerate(eff_dfs_filtered.items()): + for i, (lbl, sdf) in enumerate(_st_series.items()): if "close_time" not in sdf.columns or "net_profit" not in sdf.columns: continue color = COLORS[i % len(COLORS)] diff --git a/view_portfolio_master.py b/view_portfolio_master.py index 568a2bc..1dd66c9 100644 --- a/view_portfolio_master.py +++ b/view_portfolio_master.py @@ -33,7 +33,7 @@ def _parse_file(file_obj): try: parser = _get_parser() raw = file_obj.read() - result = parser.detect_and_parse(raw) + result = parser.detect_and_parse(raw, file_obj.name) return result[0] if isinstance(result, tuple) else result except Exception as e: st.error(f"Failed to parse **{file_obj.name}**: {e}") @@ -69,6 +69,7 @@ def _normalise(df: pd.DataFrame, label: str) -> pd.DataFrame: if "net_profit" in df.columns: df["net_profit"] = pd.to_numeric(df["net_profit"], errors="coerce").fillna(0) df["_strategy"] = label + df["_ea"] = label # EA = the uploaded filename stem return df @@ -482,6 +483,32 @@ def _init_state(): st.session_state[k] = v +def _build_strategy_dfs(file_dfs: dict) -> dict: + """ + Given {filename: df}, return {strategy_label: df} where each entry is + all trades for one unique strategy comment across all files. + Label format: "EA — Strategy" when a file has multiple strategies, + otherwise just the strategy name. + """ + result = {} + for ea_label, df in file_dfs.items(): + if "strategy" not in df.columns: + result[ea_label] = df + continue + strategies = df["strategy"].dropna().unique().tolist() + if len(strategies) == 1: + # Single strategy in file — use strategy name as label + lbl = strategies[0] if strategies[0] != "Manual" else ea_label + result[lbl] = df.copy() + else: + for strat in strategies: + s_df = df[df["strategy"] == strat].copy() + if not s_df.empty: + lbl = f"{ea_label} — {strat}" + result[lbl] = s_df + return result + + # ───────────────────────────────────────────────────────────────────────────── # Render # ───────────────────────────────────────────────────────────────────────────── @@ -558,7 +585,9 @@ def render(): st.info("Upload backtest files above to get started.") return - labels = list(strategy_dfs.keys()) + # Explode each uploaded file into per-strategy DataFrames + all_strategy_dfs = _build_strategy_dfs(strategy_dfs) + labels = list(all_strategy_dfs.keys()) # ── Tabs ───────────────────────────────────────────────────────────────── tab_config, tab_strategies, tab_results = st.tabs([ @@ -658,8 +687,26 @@ def render(): step=500, key="pm_mc_samples") # ── Combination count estimate + warning ───────────────────────────── - sel_labels = st.multiselect("Strategies to include", labels, - default=labels, key="pm_sel_labels") + # ── Strategies to include ───────────────────────────────────────────── + st.markdown('
Strategies to Include
', unsafe_allow_html=True) + ea_names = list(strategy_dfs.keys()) + sel_eas = st.multiselect( + "Filter by EA (file)", + ea_names, default=ea_names, key="pm_sel_eas", + help="Select which uploaded files to draw strategies from", + ) + # Build strategy list cascading from EA selection + if sel_eas: + avail_strats = [lbl for lbl in labels + if any(lbl == ea or lbl.startswith(f"{ea} — ") + for ea in sel_eas)] + else: + avail_strats = labels + sel_labels = st.multiselect( + "Strategies to include", + avail_strats, default=avail_strats, key="pm_sel_labels", + help="Each strategy = one unique comment group within an EA file", + ) n_sel = len(sel_labels) if n_sel >= int(min_strats): @@ -766,7 +813,7 @@ def render(): else: filtered_dfs = {} for lbl in sel_labels: - df = strategy_dfs[lbl].copy() + df = all_strategy_dfs[lbl].copy() if date_from and date_to and "close_time" in df.columns: ct = pd.to_datetime(df["close_time"]).dt.tz_localize(None) df = df[(ct >= pd.Timestamp(date_from)) & @@ -859,7 +906,7 @@ def render(): rows = [] for i, label in enumerate(labels): custom = st.session_state.pm_custom_names.get(label, "") - row = _full_stats(strategy_dfs[label], dep_s, i+1, custom) + row = _full_stats(all_strategy_dfs[label], dep_s, i+1, custom) if row: rows.append(row) if rows: @@ -911,7 +958,7 @@ def render(): hc1, hc2 = st.columns(2) with hc1: st.markdown("##### Pairwise Correlation (all days)") - corr = _correlation_matrix(strategy_dfs) + corr = _correlation_matrix(all_strategy_dfs) disp_labels = [st.session_state.pm_custom_names.get(l,l) for l in corr.columns] corr.index = corr.columns = disp_labels st.plotly_chart(_corr_fig(corr, height=max(300, len(labels)*55)), @@ -919,7 +966,7 @@ def render(): with hc2: st.markdown("##### Conditional Correlation (drawdown days only)") dep_s2 = st.session_state.pm_deposit - cond = _conditional_correlation(strategy_dfs, dep_s2) + cond = _conditional_correlation(all_strategy_dfs, dep_s2) cond.index = cond.columns = disp_labels st.plotly_chart(_corr_fig(cond, height=max(300, len(labels)*55)), use_container_width=True, key=f"pm_corr_cond_{len(labels)}") @@ -1087,7 +1134,7 @@ def render(): # Mini equity chart with dc1: - frames = [strategy_dfs[m].copy() for m in r["members"] if m in strategy_dfs] + frames = [all_strategy_dfs[m].copy() for m in r["members"] if m in all_strategy_dfs] if frames: combined = pd.concat(frames, ignore_index=True) if "close_time" in combined.columns: @@ -1111,7 +1158,6 @@ def render(): yaxis2=dict(overlaying="y", side="right", gridcolor="#1E2130", tickprefix="$", showgrid=False), ) - # Add invisible annotation to ensure figure hash is unique per portfolio pfig.add_annotation(text=str(i), x=0, y=0, opacity=0, showarrow=False, xref="paper", yref="paper") st.plotly_chart(pfig, use_container_width=True, key=f"pm_pfig_{i}") @@ -1119,7 +1165,7 @@ def render(): # Per-result correlation heatmap with dc2: if len(r["members"]) > 1: - member_dfs = {m: strategy_dfs[m] for m in r["members"] if m in strategy_dfs} + member_dfs = {m: all_strategy_dfs[m] for m in r["members"] if m in all_strategy_dfs} if len(member_dfs) > 1: r_corr = _correlation_matrix(member_dfs) r_cond = _conditional_correlation(member_dfs, st.session_state.pm_deposit) @@ -1136,8 +1182,8 @@ def render(): # Member stats table m_rows = [] for m in r["members"]: - if m not in strategy_dfs: continue - s = _full_stats(strategy_dfs[m], st.session_state.pm_deposit, + if m not in all_strategy_dfs: continue + s = _full_stats(all_strategy_dfs[m], st.session_state.pm_deposit, labels.index(m)+1, st.session_state.pm_custom_names.get(m,"")) if s: diff --git a/view_trade_analysis.py b/view_trade_analysis.py index 1d26179..b701cb9 100644 --- a/view_trade_analysis.py +++ b/view_trade_analysis.py @@ -210,7 +210,7 @@ def _generate_html_report(df_plot, stats, fmt, view_sel, {_stat("Trades/Day", str(stats.get('trades_per_day',0)), _delta_html('trades_per_day','x'))} {_stat("Avg Win", f"${stats['avg_win']:,.2f}", _delta_html('avg_win','$'))} {_stat("Avg Loss", f"${stats['avg_loss']:,.2f}", _delta_html('avg_loss','$', inverse=True))} - {_stat("Max DD", f"${stats['max_drawdown']:,.2f}", _delta_html('max_drawdown','$', inverse=True))} + {_stat("Max DD", f"${stats['max_drawdown']:,.2f} ({abs(stats.get('max_drawdown_pct',0)):.2f}%)", _delta_html('max_drawdown','$', inverse=True))} {_stat("Best Trade", f"${stats['best_trade']:,.2f}", _delta_html('best_trade','$'))} {_stat("Worst Trade",f"${stats['worst_trade']:,.2f}", _delta_html('worst_trade','$', inverse=True))} {_stat("Max Consec Wins", str(stats['max_consec_wins']), _delta_html('max_consec_wins',''))} @@ -631,7 +631,8 @@ def render(): delta=_delta('avg_win','$')) c3.metric("Avg Loss", f"${stats['avg_loss']:,.2f}", delta=_inv_delta('avg_loss','$'), delta_color="inverse") - c4.metric("Max DD", f"${stats['max_drawdown']:,.2f}", + _dd_pct = stats.get('max_drawdown_pct', 0) + c4.metric("Max DD", f"${stats['max_drawdown']:,.2f} ({abs(_dd_pct):.2f}%)", delta=_inv_delta('max_drawdown','$'), delta_color="inverse") c5.metric("Best Trade", f"${stats['best_trade']:,.2f}", delta=_delta('best_trade','$')) @@ -937,8 +938,8 @@ def render(): # ── Render mode ─────────────────────────────────────────────────────────── if mode == "Overall": - stats = calc_stats(df) - stats_e = calc_stats(df_e) if df_e is not None else None + stats = calc_stats(df, deposit=st.session_state.get("ta_deposit", 0.0)) + stats_e = calc_stats(df_e, deposit=st.session_state.get("ta_deposit", 0.0)) if df_e is not None else None # ── Report download ─────────────────────────────────────────────── _rep_df = df_e if (view_sel in ("Edited","Both") and df_e is not None) else df