db888ce0e8
Adds the analytics surface of the dashboard: * gui/data_layer.py — extended with load_closed_positions (windowed filter on closed_at) and three pure-function aggregators: compute_equity_curve, compute_kpis, compute_monthly_stats. Drawdown is measured against the running peak of cumulative realised P&L. * gui/pages/3_📈_Equity.py — KPI strip, plotly cumulative-PnL line, drawdown area below, P&L histogram by close_reason, per-month table with win-rate. * gui/pages/4_📜_History.py — windowed table of closed trades with multiselect close-reason and winners/losers radio filters, six-tile KPI strip, CSV export button. * pyproject.toml — relax mypy on plotly + pandas (no shipped stubs). Validated with synthetic data: 3 trades, 67% win rate, $50 total, max drawdown $30 — all matching expected math. GUI launches, HTTP 200 on / and /_stcore/health. 353/353 tests still pass; ruff clean; mypy strict src clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
173 lines
5.1 KiB
Python
173 lines
5.1 KiB
Python
"""Equity page — cumulative PnL, drawdown, distributions."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from collections import Counter
|
|
from datetime import UTC, datetime, timedelta
|
|
from pathlib import Path
|
|
|
|
import pandas as pd
|
|
import plotly.graph_objects as go
|
|
import streamlit as st
|
|
|
|
from cerbero_bite.gui.data_layer import (
|
|
DEFAULT_DB_PATH,
|
|
compute_equity_curve,
|
|
compute_kpis,
|
|
compute_monthly_stats,
|
|
load_closed_positions,
|
|
)
|
|
|
|
|
|
def _resolve_db() -> Path:
|
|
return Path(os.environ.get("CERBERO_BITE_GUI_DB", DEFAULT_DB_PATH))
|
|
|
|
|
|
def _date_window(label: str) -> tuple[datetime | None, datetime | None]:
|
|
"""UI control for picking the analytics window."""
|
|
options = {
|
|
"All time": (None, None),
|
|
"Last 30 days": (datetime.now(UTC) - timedelta(days=30), None),
|
|
"Last 90 days": (datetime.now(UTC) - timedelta(days=90), None),
|
|
"Year to date": (datetime(datetime.now(UTC).year, 1, 1, tzinfo=UTC), None),
|
|
}
|
|
pick = st.selectbox(label, list(options.keys()), index=0)
|
|
return options[pick]
|
|
|
|
|
|
def render() -> None:
|
|
st.title("📈 Equity")
|
|
st.caption(
|
|
"Cumulative realised P&L, drawdown, and per-trade distribution. "
|
|
"Computed from closed positions in `data/state.sqlite`."
|
|
)
|
|
|
|
start, end = _date_window("Window")
|
|
|
|
db_path = _resolve_db()
|
|
positions = load_closed_positions(db_path=db_path, start=start, end=end)
|
|
|
|
if not positions:
|
|
st.info(
|
|
"No closed positions in the selected window yet. "
|
|
"The equity curve will populate as soon as the engine closes its first trade."
|
|
)
|
|
return
|
|
|
|
# KPI strip
|
|
kpis = compute_kpis(positions)
|
|
cols = st.columns(5)
|
|
cols[0].metric("Closed trades", kpis.n_trades)
|
|
cols[1].metric("Win rate", f"{kpis.win_rate:.0%}")
|
|
cols[2].metric("Total P&L", f"${float(kpis.total_pnl_usd):+.2f}")
|
|
cols[3].metric("Edge / trade", f"${float(kpis.edge_per_trade_usd):+.2f}")
|
|
cols[4].metric(
|
|
"Max drawdown",
|
|
f"${float(kpis.max_drawdown_usd):.2f}",
|
|
delta=f"{kpis.max_drawdown_pct:.1%}",
|
|
delta_color="inverse",
|
|
)
|
|
|
|
st.divider()
|
|
|
|
# Equity curve + drawdown
|
|
curve = compute_equity_curve(positions)
|
|
df = pd.DataFrame(
|
|
{
|
|
"timestamp": [p.timestamp for p in curve],
|
|
"cumulative_pnl_usd": [float(p.cumulative_pnl_usd) for p in curve],
|
|
"drawdown_usd": [float(p.drawdown_usd) for p in curve],
|
|
"realized_pnl_usd": [float(p.realized_pnl_usd) for p in curve],
|
|
}
|
|
)
|
|
|
|
st.subheader("Cumulative P&L (USD)")
|
|
fig = go.Figure()
|
|
fig.add_trace(
|
|
go.Scatter(
|
|
x=df["timestamp"],
|
|
y=df["cumulative_pnl_usd"],
|
|
mode="lines+markers",
|
|
name="cumulative P&L",
|
|
line={"color": "#2ecc71", "width": 2},
|
|
)
|
|
)
|
|
fig.add_hline(y=0, line_dash="dot", line_color="grey", opacity=0.5)
|
|
fig.update_layout(
|
|
height=320,
|
|
margin={"l": 10, "r": 10, "t": 30, "b": 10},
|
|
xaxis_title=None,
|
|
yaxis_title="USD",
|
|
)
|
|
st.plotly_chart(fig, use_container_width=True)
|
|
|
|
st.subheader("Drawdown (USD)")
|
|
dd_fig = go.Figure()
|
|
dd_fig.add_trace(
|
|
go.Scatter(
|
|
x=df["timestamp"],
|
|
y=-df["drawdown_usd"],
|
|
mode="lines",
|
|
fill="tozeroy",
|
|
name="drawdown",
|
|
line={"color": "#e74c3c", "width": 1.5},
|
|
)
|
|
)
|
|
dd_fig.update_layout(
|
|
height=220,
|
|
margin={"l": 10, "r": 10, "t": 30, "b": 10},
|
|
xaxis_title=None,
|
|
yaxis_title="USD",
|
|
)
|
|
st.plotly_chart(dd_fig, use_container_width=True)
|
|
|
|
# PnL distribution
|
|
st.subheader("P&L distribution by close reason")
|
|
by_reason: dict[str, list[float]] = {}
|
|
for pos in positions:
|
|
if pos.pnl_usd is None:
|
|
continue
|
|
by_reason.setdefault(pos.close_reason or "(unknown)", []).append(
|
|
float(pos.pnl_usd)
|
|
)
|
|
|
|
counts = Counter(
|
|
(pos.close_reason or "(unknown)") for pos in positions
|
|
)
|
|
cols = st.columns(min(len(counts), 6) or 1)
|
|
for col, (reason, count) in zip(cols, counts.most_common(6), strict=False):
|
|
col.metric(reason, count)
|
|
|
|
hist_fig = go.Figure()
|
|
for reason, pnls in by_reason.items():
|
|
hist_fig.add_trace(go.Histogram(x=pnls, name=reason, opacity=0.6, nbinsx=30))
|
|
hist_fig.update_layout(
|
|
barmode="overlay",
|
|
height=320,
|
|
margin={"l": 10, "r": 10, "t": 30, "b": 10},
|
|
xaxis_title="P&L (USD)",
|
|
yaxis_title="trades",
|
|
legend={"orientation": "h", "y": 1.1},
|
|
)
|
|
st.plotly_chart(hist_fig, use_container_width=True)
|
|
|
|
# Monthly table
|
|
st.subheader("Per-month stats")
|
|
months = compute_monthly_stats(positions)
|
|
rows = [
|
|
{
|
|
"month": m.year_month,
|
|
"trades": m.n_trades,
|
|
"wins": m.n_wins,
|
|
"win_rate": f"{m.win_rate:.0%}",
|
|
"P&L (USD)": f"{float(m.pnl_usd):+.2f}",
|
|
"avg / trade": f"{float(m.avg_pnl_usd):+.2f}",
|
|
}
|
|
for m in months
|
|
]
|
|
st.dataframe(rows, use_container_width=True, hide_index=True)
|
|
|
|
|
|
render()
|