""" 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 try: from streamlit_autorefresh import st_autorefresh HAS_AUTOREFRESH = True except ImportError: HAS_AUTOREFRESH = False 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: return {"error": f"Could not parse report for {account_folder}"} stats = calc_stats(df) if not df.empty else {} 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 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.warning("⚙️ **FTP not configured** — follow the setup guide below to get started.") st.markdown("---") st.markdown("## 📡 Setup Guide") st.markdown( "The Live MT5 EAs page pulls account history HTML reports from an FTP server " "that each MT5 terminal publishes to automatically. No MetaTrader5 Python library " "is required — the connection uses Python's built-in `ftplib`." ) st.markdown(""" **Architecture overview:** - **Remote Windows machine** — one or more MT5 terminals running EAs, each configured to auto-publish account history reports to a FileZilla FTP server every 5 minutes - **FTP server (FileZilla Server)** — receives reports and stores them in per-account subfolders - **MT5 Tools** — pulls reports from FTP, parses them, and displays them here > **Key point:** MT5 and FileZilla are typically on the same machine. MT5 connects to FileZilla via `127.0.0.1` (loopback) so no firewall rule is needed between them. The only firewall rule needed is **port 21 open inbound** so MT5 Tools can connect from outside the LAN. """) with st.expander("**Part 1 — FileZilla Server Setup**", expanded=True): st.markdown(""" **Installation** Download FileZilla Server from [filezilla-project.org](https://filezilla-project.org) and install it on the remote Windows machine that runs the MT5 terminals. The free version is sufficient. **Create FTP User** After installation, open the FileZilla Server interface (system tray icon or Start menu): | Step | Action | |------|--------| | 1 | Go to **Server → Configure** (or Edit → Users in older versions) | | 2 | Click **Add user** and create a user named `mt5ftp` (or any name you choose) | | 3 | Set a strong password for the user | | 4 | Under **Directories** (or Mount Points), add a home directory — e.g. `C:\\MT5FTP\\` | | 5 | Grant the user **Read** and **Write** permissions on that directory | | 6 | Click **OK** to save | *The home directory becomes the FTP root. MT5 will create subfolders here — one per account.* **Disable TLS (Required for MT5 Compatibility)** MT5's built-in FTP publisher uses plain FTP and does not support TLS. FileZilla Server defaults to requiring TLS, which causes a connection failure. | Step | Action | |------|--------| | 1 | In FileZilla Server, go to **Server → Configure** | | 2 | Navigate to the **FTP over TLS** settings section | | 3 | Change from **Require explicit FTP over TLS** to **Allow plain FTP** | | 4 | Click OK to save and restart FileZilla Server if prompted | > ⚠️ **Security note:** Plain FTP transmits credentials unencrypted. This is acceptable on a private local network. If exposing the FTP server to the internet, consider using a VPN. **Passive Mode Port Range** FileZilla Server uses passive mode for data connections. If accessing from outside the local network, open the passive port range in the Windows firewall. The default range is `49152–65534`. You can restrict this (e.g. `50000–50100`) in FileZilla Server settings to reduce the number of ports to open. **Verify FileZilla is Running** Confirm FileZilla Server is running and listening on port 21 by checking the system tray. You can also test from FileZilla Client on the same machine using `127.0.0.1` as the host. """) with st.expander("**Part 2 — MT5 Terminal Configuration**"): st.markdown(""" Each MT5 terminal must be configured individually to publish its account history to a dedicated subfolder on the FTP server. **FTP Publisher Settings** In each MT5 terminal: | Step | Action | |------|--------| | 1 | Go to **Tools → Options** | | 2 | Click the **Publisher** tab (some versions label it FTP or Report Publishing) | | 3 | Check **Enable automatic publishing of reports via FTP** | | 4 | Set **Server** to `127.0.0.1` (loopback — MT5 and FileZilla are on the same machine) | | 5 | Set **Port** to `21` | | 6 | Set **Login** to the FileZilla username (e.g. `mt5ftp`) | | 7 | Set **Password** to the FileZilla user password | | 8 | Set **Path** to `/ACCOUNT_NUMBER/` — e.g. `/123456/` — using the account number of that terminal | | 9 | Check **Passive mode** | | 10 | Set the publishing interval (recommended: **5 minutes**) | | 11 | Click **Test** — you should see a success message | | 12 | Click **OK** to save, then click **Publish manually** to create the first report | > MT5 creates the subfolder automatically on first publish. The path must be unique per terminal — use the account number to keep them separate. **Common Issues** | Issue | Fix | |-------|-----| | TLS error on Test | TLS has not been disabled in FileZilla Server — see Part 1 | | Test succeeds but no file appears | Ensure path is set correctly (e.g. `/123456/` not `/inetpub/shots`). Click Publish manually and check FileZilla Server log | | Settings not saving | Try running MT5 as Administrator. Check each instance has its own data folder via File → Open Data Folder | """) with st.expander("**Part 3 — CLI Verification**"): st.markdown(""" Before using this page, verify the FTP connection using the included CLI tool `ftp_sync_cli.py`. **Initial connection test:** ``` python ftp_sync_cli.py --host 192.168.x.x --user mt5ftp --password yourpass --list ``` **Save credentials to ftp_config.json:** ``` python ftp_sync_cli.py --host 192.168.x.x --user mt5ftp --password yourpass --save ``` **Pull and parse a single account:** ``` python ftp_sync_cli.py --config --account 123456 ``` **Cache all accounts:** ``` python ftp_sync_cli.py --config --account 123456 --save-cache python ftp_sync_cli.py --config --account 789012 --save-cache ``` Each account is saved to `cache/ftp_ACCOUNT.pkl`. This page loads these automatically on startup. """) with st.expander("**Part 4 — Configuration Files**"): st.markdown(""" | File | Location | Contents | |------|----------|----------| | `ftp_config.json` | MT5Tools/ | FTP host, user, password, port — **gitignored** | | `ftp_accounts.json` | MT5Tools/ | Account labels, balances, types, prop settings — **gitignored** | | `cache/ftp_*.pkl` | MT5Tools/cache/ | Parsed DataFrames per account — **gitignored**, auto-refreshed | Ensure your `.gitignore` contains: ``` ftp_config.json ftp_accounts.json mt5_accounts.json cache/ ``` """) with st.expander("**Part 5 — Troubleshooting**"): st.markdown(""" | Issue | Solution | |-------|----------| | Connection refused on port 21 | Check FileZilla Server is running (system tray). Check port 21 is open in Windows firewall. Try FileZilla Client first to isolate. | | 530 Login incorrect | Verify username and password in `ftp_config.json` match exactly. Passwords are case-sensitive. | | No .htm/.html file found | MT5 terminal has not published yet. Go to Tools → Options → Publisher and click manual publish. Check FileZilla Server log. | | Could not parse report | Try uploading the HTML file directly to Trade Analysis to test parsing. | | Old data after Refresh All | MT5 publishes on a timer. Wait for the next publish cycle or trigger a manual publish in MT5. | """) st.info("Once `ftp_config.json` is created via the CLI, refresh this page and the live dashboard will load.") 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="123456", 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, 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("**Order**") hdr[6].markdown("**Remove**") updated = [] for idx_ac, ac in enumerate(acc_cfgs): if idx_ac > 0: st.divider() c1, c2, c3, c4, c5, c6, c7 = st.columns([1, 2, 2, 2, 2, 1, 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) # Up / down ordering buttons ob1, ob2 = c6.columns(2) if ob1.button("↑", key=f"up_{ac['account']}", disabled=(idx_ac == 0)): acc_cfgs.insert(idx_ac - 1, acc_cfgs.pop(idx_ac)) save_account_configs(acc_cfgs) st.rerun() if ob2.button("↓", key=f"dn_{ac['account']}", disabled=(idx_ac == len(acc_cfgs) - 1)): acc_cfgs.insert(idx_ac + 1, acc_cfgs.pop(idx_ac)) save_account_configs(acc_cfgs) st.rerun() if c7.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() # ── Refresh controls inside expander ───────────────────────────────── st.divider() rc1, rc2 = st.columns([1, 1]) with rc1: do_refresh = st.button("🔄 Refresh All", type="primary", use_container_width=True, key="ftp_refresh_btn") with rc2: 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.") 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} # Initialise auto-refresh timer — first load always pulls from FTP first_load = "ftp_last_auto_refresh" not in st.session_state if first_load: st.session_state["ftp_last_auto_refresh"] = 0 # force pull on first load # ── Updated timestamp + autorefresh JS ─────────────────────────────────── 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) if ages: oldest = max(ages) st.caption(f"🕐 Updated {oldest:.0f}m ago") # JavaScript-based auto-refresh — triggers a full rerun on a timer if poll_interval > 0: if HAS_AUTOREFRESH: st_autorefresh(interval=poll_interval * 60 * 1000, key="ftp_autorefresh") else: st.caption("💡 Install `streamlit-autorefresh` for auto-refresh: " "`pip install streamlit-autorefresh`") # Pull from FTP on first load or when poll interval has elapsed last_auto = st.session_state.get("ftp_last_auto_refresh", 0) now_ts = datetime.now().timestamp() interval_elapsed = poll_interval > 0 and (now_ts - last_auto) >= poll_interval * 60 auto_refresh = first_load or interval_elapsed if do_refresh or no_cache or auto_refresh: st.session_state["ftp_last_auto_refresh"] = datetime.now().timestamp() label_text = "Loading..." if (no_cache or first_load) 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 (skip empty dfs from new accounts) dfs = [] for d in sel_data: df = d["df"].copy() if df.empty or "close_time" not in df.columns: continue df["_account"] = d["label"] df["_balance"] = d["balance"] dfs.append(df) if dfs: 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) else: df_all = pd.DataFrame() 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() # New account with no closed trades yet — ensure required columns exist if df_tmp.empty or "net_profit" not in df_tmp.columns: df_tmp = pd.DataFrame(columns=["net_profit", "close_time", "open_time", "win"]) 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) col_order = ["_account","open_time","position","symbol","type","volume", "open_price","sl","tp","market_price","swap","profit","comment"] show_cols = [c for c in col_order if c in open_df.columns] rename_map = { "_account" : "Account", "open_time" : "Time", "position" : "Position", "symbol" : "Symbol", "type" : "Type", "volume" : "Volume", "open_price" : "Price", "sl" : "S/L", "tp" : "T/P", "market_price": "Market Price", "swap" : "Swap", "profit" : "Profit", "comment" : "Comment", } disp = open_df[show_cols].rename(columns=rename_map).copy() if "Time" in disp.columns: disp["Time"] = pd.to_datetime( disp["Time"], errors="coerce" ).dt.strftime("%d.%m.%Y %H:%M") with st.expander(f"🔴 Open Positions ({len(open_df)})", expanded=True): st.dataframe(disp, 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() if df_c.empty or "net_profit" not in df_c.columns: continue 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 if df_all.empty: day_map = {} else: 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") if df_all.empty: st.info("No closed trades yet. Trade analysis will appear once trades are recorded.") return # 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)