"""
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'
Max loss {min(max(-pnl_pct,0),ml):.2f}% / {ml:.0f}%
'
f'
'
f'
Daily loss {today_pct:.2f}% / {dl:.0f}%
'
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'',
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 (
''
)
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'',
unsafe_allow_html=True)