docs: align 05/06/09/11 with implemented GUI Phases A–D

* docs/11-gui-streamlit.md — replaces the original spec with what was
  actually built: implementation status table, real page filenames
  (1_Status, 2_Audit, 3_Equity, 4_History, 5_Position), per-page
  inventory of implemented vs deferred sections, GUI ↔ engine table
  showing arm_kill/disarm_kill via manual_actions and the
  not_supported markers for force_close + approve/reject_proposal,
  consumer signature with cron */1, lock model clarified (no GUI
  lockfile), DoD updated with current state.
* docs/05-data-model.md — manual_actions is no longer "pianificata":
  populated by gui/data_layer.py, drained by the manual_actions job;
  per-kind status table (arm/disarm OK, others not_supported).
* docs/09-development-roadmap.md — Phase 4.5 marked implemented with
  per-task / markers for the deferred items (auto-refresh,
  AppTest, force-close hook).
* docs/06-operational-flow.md — adds Flusso 5b describing the
  manual_actions consumer pattern (enqueue → KillSwitch transition →
  audit log linkage).

360/360 tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-30 13:31:25 +02:00
parent e8345a29c8
commit da88e7f746
4 changed files with 251 additions and 160 deletions
+183 -134
View File
@@ -46,129 +46,152 @@ uv run streamlit run src/cerbero_bite/gui/main.py \
--browser.gatherUsageStats false
```
## Stato implementativo
La dashboard è stata costruita in quattro fasi incrementali:
| Fase | Contenuto | Stato |
|---|---|---|
| A | Status + Audit (osservazione di base) | ✅ |
| B | Equity + History (analitica + export CSV) | ✅ |
| C | Position drilldown con payoff plotly + decision history | ✅ |
| D | Kill-switch arm/disarm dalla dashboard via coda `manual_actions` | ✅ |
Per scelta di scope, restano fuori dalla prima iterazione: force-close
dalla GUI (richiede un hook `handle_force_close` nell'orchestrator),
approve/reject di una proposta (il bot decide autonomamente, non c'è un
flusso di proposta in attesa) e auto-refresh attivo via
`st_autorefresh`. Il consumer di `manual_actions` riconosce già i
`kind` corrispondenti e li archivia con `result="not_supported"`
finché i flussi non saranno cablati.
## Layout cartelle
```
src/cerbero_bite/gui/
├── __init__.py
├── main.py # entry point streamlit, sidebar nav
├── pages/
│ ├── 1_📊_status.py
├── 2_📈_equity.py
├── 3_💼_position.py
├── 4_📜_history.py
── 5_🔍_audit.py
├── components/
│ ├── kill_switch_panel.py
│ ├── mcp_health_grid.py
│ ├── pending_proposal_card.py
│ ├── payoff_chart.py
│ └── greeks_panel.py
└── data_layer.py # wrapper read-only verso state.repository
├── main.py # entry Streamlit, sidebar, home
├── data_layer.py # wrapper read-only + write helpers
└── pages/
├── 1_📊_Status.py # health, kill switch, audit anchor
├── 2_🔍_Audit.py # log stream + chain integrity
├── 3_📈_Equity.py # cumulative P&L + drawdown
── 4_📜_History.py # closed trades + KPI + CSV
└── 5_💼_Position.py # drilldown + payoff plotly
```
I componenti riutilizzabili descritti nello spec originale
(`kill_switch_panel`, `payoff_chart`, ecc.) non sono stati estratti in
file separati: ogni pagina è autonoma e tiene la propria UI inline,
così l'evoluzione resta locale al singolo file. La promozione a
componenti separati è giustificata solo se più pagine condividono lo
stesso widget — al momento non è il caso.
## Pagine
### 1. 📊 Status (home)
Vista a colpo d'occhio dello stato corrente.
Stato corrente e controlli sul kill switch.
Sezioni:
Sezioni implementate:
- **Engine status**: badge verde/giallo/rosso (running/degraded/killed),
uptime, ultimo health check, kill_switch state, kill_reason se armato.
- **Capitale**: equity corrente da `cerbero-portfolio` (cache ultimo
valore noto + timestamp), variazione % vs giorno prima, vs settimana,
vs mese.
- **Posizione attiva**: card con riepilogo (proposal_id, expiry, credit,
P&L unrealized stimato, days_to_expiry) o "nessuna posizione aperta".
- **MCP health grid**: 8 box, uno per server, con latenza ms e semaforo.
- **Pending action**: se l'engine ha una proposta in attesa di conferma
e il timeout Telegram è scaduto, qui appare una card con `Approve`/`Reject`.
Effetto: la decisione viene scritta in coda e il decision orchestrator
la legge al prossimo health-check.
- **Big buttons**: `🟢 Disarm` / `🔴 Arm Kill Switch` (con conferma
typed `"yes I am sure"`).
- **Engine status banner** colorato in base alla health derivata dalla
combinazione `system_state.kill_switch` + età di `last_health_check`
(`running`/`degraded`/`stopped`/`killed`/`unknown`).
- **Top metric tiles**: posizioni aperte, età ultimo health check,
`started_at`, `config_version`.
- **Kill switch controls**: form arm/disarm con typed confirmation
(`"yes I am sure"`) + reason obbligatoria. La submission scrive
un'azione in `manual_actions`; il consumer la applica entro un minuto.
- **Pending manual actions**: tabella delle azioni in coda non ancora
consumate (visibile solo se la coda è non vuota).
- **Audit anchor**: hash chain head persistito in `system_state`.
- **Open positions table**: spread type, contracts, credit, max loss,
strikes, status, opened/expiry.
Auto-refresh: 5 secondi.
Sezioni non ancora implementate rispetto allo spec originale: capitale
con variazioni %, MCP health grid (i probe sono fatti dall'engine e
visibili in audit), pending-proposal card. Il refresh automatico è
manuale (la pagina si aggiorna alla navigazione o al re-render
spontaneo di Streamlit).
### 2. 📈 Equity
### 2. 🔍 Audit
Grafico storia capitale e analitica.
Live log stream + verifica integrità della hash chain.
Sezioni:
Sezioni implementate:
- **Equity curve** (line chart): capitale nel tempo dall'inizio del
tracking. Risoluzione giornaliera. Sovrapposizione opzionale:
- banda Monte Carlo P5/P50/P95 (statica, dal documento)
- DVOL nel tempo (asse Y secondario)
- eventi macro (vertical lines sui giorni FOMC/CPI)
- **Drawdown rolling** (sotto curve): area chart del DD% corrente.
- **P&L distribution** (histogram): trade chiusi raggruppati per outcome
(profit_take, stop_loss, vol_stop, time_stop, ecc.).
- **Tabella mensile**: per ogni mese — n trade, win rate, P&L, max DD.
- **Chain integrity verify**: bottone che richiama `verify_chain` e
riporta numero di entries verificate o l'errore di mismatch.
- **Filtri**: limit (10500) + event filter (auto-popolato dagli event
effettivamente presenti nella tail).
- **Event-count strip**: `Counter` dei tipi di evento nella finestra.
- **Tail table**: timestamp, event, payload JSON canonico, hash
abbreviato — newest-first.
Filtri: range temporale, asset (solo ETH per ora).
### 3. 📈 Equity
Auto-refresh: 30 secondi (cambia raramente).
Curva P&L cumulato e analitica trade chiusi.
### 3. 💼 Position
Sezioni implementate:
Drill-down sulla posizione attualmente aperta (se esiste).
- **KPI strip**: closed trades, win rate, total P&L, edge per trade,
max drawdown (USD + %).
- **Cumulative P&L** (Plotly): riempito a zero, con linea zero di
riferimento.
- **Drawdown** (Plotly area chart, asse invertito).
- **P&L distribution by close reason**: istogrammi Plotly sovrapposti
con conteggio trades per reason in metric tiles.
- **Per-month stats**: tabella aggregata UTC (mese, n trade, vincitori,
win rate, P&L totale, P&L medio).
Sezioni:
- **Header**: proposal_id, opened_at, expiry, days_left, status.
- **Legs table**: instrument, side, size, mid corrente, delta,
theta, vega — refresh periodico via `clients.deribit`.
- **Greche aggregate**: delta/theta/vega netti.
- **Payoff diagram** (plotly): P&L vs spot ETH a scadenza, con
breakeven, max profit, max loss, spot corrente come marker.
- **Decision history**: tabella con tutte le `decisions` di tipo
`exit_check` per questa posizione, in ordine cronologico, con
outcome HOLD / CLOSE_*.
- **Distance metrics**: short strike a `X% OTM`, delta corrente,
distanza in sigma.
- **Force close** (collapsibile): typed confirmation + reason field.
Su submit: scrive in coda azione `manual_close`, l'engine la consuma
al prossimo monitor cycle.
Auto-refresh: 10 secondi.
Window picker: All time, last 30/90 giorni, year-to-date. Banda Monte
Carlo, overlay DVOL e linee eventi macro non sono ancora implementati.
### 4. 📜 History
Storico trade chiusi.
Storico trade chiusi con filtri ed esportazione.
Sezioni:
Sezioni implementate:
- **Filtri**: range temporale, outcome (multiselect), P&L > 0 / < 0 / tutti.
- **Tabella trade chiusi** (`st.dataframe` sortable): proposal_id,
opened_at, closed_at, expiry, n_contracts, credit_usd, debit_paid_usd,
pnl_usd, outcome, days_held.
- **KPI strip**: n trade, win rate, avg win, avg loss, edge per trade,
edge cumulato.
- **Confronto Monte Carlo**: side-by-side delle metriche reali vs
attese da simulazione, con delta in %.
- **Export CSV**: bottone download per uso fiscale.
- **Window picker**: All time, last 7/30/90 giorni, year-to-date.
- **Filtri di dettaglio**: multiselect su `close_reason`, radio
vincitori/perdenti/tutti.
- **KPI strip a sei tile**: trades, win rate, total P&L, avg win,
avg loss, edge per trade.
- **Tabella trade chiusi**: proposal_id (short), spread type, asset,
contracts, strikes, credit/max_loss, P&L, close_reason, days_held,
opened/closed/expiry.
- **CSV export**: download diretto via `st.download_button`.
Auto-refresh: manuale (button).
Confronto Monte Carlo side-by-side non ancora implementato.
### 5. 🔍 Audit
### 5. 💼 Position
Log e audit chain.
Drilldown su una posizione specifica (open o ultime 10 chiuse).
Sezioni:
Sezioni implementate:
- **Live log stream**: ultimi 100 eventi, filtro per `level` e `event`.
Auto-refresh 5 sec.
- **Audit chain status**: bottone `Verify`. Mostra "✅ chain integra
fino a 14.382 eventi" o "❌ tampering rilevato a evento N".
- **Search**: ricerca testuale negli ultimi 30 giorni di log.
- **Stats engine**: numero kill switch armati nell'ultimo mese, MCP
failure count per server, average decision loop latency.
- **Export log**: download `.jsonl.gz` per analisi forensica.
- **Position selector** con label `proposal_id · spread_type ·
short/long · status`. Supporta deep-link via query string
`?proposal_id=…`.
- **Header tiles**: status, spread, contracts, credit USD; caption con
proposal_id pieno + opened/expiry.
- **Distance metrics**: short strike OTM%, days-to-expiry, days-held,
delta at entry, width % of spot.
- **Legs table** (snapshot al momento dell'entry, non live): leg, instrument,
strike, side, size, delta. Una caption ricorda che mid e greche live
non sono fetchate dalla GUI.
- **Payoff at expiry** (Plotly): curva P&L con annotazioni per short
strike, long strike, breakeven, entry spot. Tile riassuntivi per
max profit, max loss, breakeven. Implementato per `bull_put` e
`bear_call`; gli iron condor cadono su una curva piatta (placeholder).
- **Decision history**: tabella delle righe `decisions` legate al
`proposal_id`, newest-first, con outputs JSON canonici.
Auto-refresh: manuale.
Le greche/mid live e il force-close manuale richiedono che l'engine
esponga rispettivamente uno snapshot persistito e l'hook
`handle_force_close` — fuori scope della prima iterazione.
## Comunicazione GUI ↔ Engine
@@ -177,53 +200,70 @@ MCP. Tutto passa via:
| Azione GUI | Effetto |
|---|---|
| Visualizzazione stato | Read da `state/repository.py` (SQLite) |
| Equity / storico | Read da SQLite + `data/log/*.jsonl` |
| MCP health | Read da `state.system_state.last_health_check` (l'engine fa il check) |
| **Disarm kill switch** | Write su `system_state` con `kill_switch=0`; l'engine al prossimo health check rileva e log `KILL_SWITCH_DISARMED` |
| **Arm kill switch** | Write su `system_state` con `kill_switch=1, kill_reason="manual via GUI"` |
| **Force close** | Insert riga in tabella `manual_actions` (nuova) con `kind="force_close", proposal_id=...`; l'engine al prossimo monitor cycle la consuma |
| **Approve pending proposal** | Insert riga in `manual_actions` con `kind="approve_proposal", proposal_id=...` |
| Visualizzazione stato | Read da `state/repository.py` (SQLite) tramite `gui/data_layer.py` |
| Equity / storico | Read da SQLite (`positions` con `status='closed'`) + audit log |
| MCP health | Read indiretto da `system_state.last_health_check` (l'engine fa il probe) |
| **Disarm kill switch** | `enqueue_disarm_kill(reason)` → riga in `manual_actions` con `kind="disarm_kill"`; consumer chiama `KillSwitch.disarm` (audit `KILL_SWITCH_DISARMED`, `source="manual_gui"`) |
| **Arm kill switch** | `enqueue_arm_kill(reason)` → riga `kind="arm_kill"`; consumer chiama `KillSwitch.arm` |
| Force close | Pianificato: `kind="force_close"`. Oggi il consumer marca `result="not_supported"`; richiede l'hook `Orchestrator.handle_force_close` |
| Approve / reject pending proposal | Pianificato: `kind="approve_proposal"` / `"reject_proposal"`. Stesso stato (non implementato lato orchestrator) |
**Nuova tabella SQLite** (`05-data-model.md` da estendere):
La GUI **non** scrive direttamente su `system_state`: ogni transizione
del kill switch passa dal consumer e dalla classe `KillSwitch`, così
SQLite e audit chain restano sincronizzati come per le transizioni
automatiche.
**Schema SQLite** (vedi `05-data-model.md`):
```sql
CREATE TABLE manual_actions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL, -- approve_proposal, reject_proposal, force_close, etc.
kind TEXT NOT NULL,
proposal_id TEXT,
payload_json TEXT,
created_at TEXT NOT NULL,
consumed_at TEXT, -- NULL = ancora da processare
consumed_at TEXT,
consumed_by TEXT,
result TEXT
);
CREATE INDEX idx_manual_actions_unconsumed ON manual_actions(consumed_at);
```
L'engine include un nuovo job APScheduler `every 30s`:
**Consumer**: `runtime/manual_actions_consumer.consume_manual_actions`.
Registrato come job APScheduler `manual_actions` con cron
`*/1 * * * *` (latenza ≤ 1 minuto, sufficiente per kill-switch). Il
consumer drena tutta la coda a ogni tick e per ogni azione setta
`consumed_at`, `consumed_by="engine"` e `result` (`"ok"`,
`"not_supported"` o `"error: …"`).
```python
async def consume_manual_actions():
actions = state.fetch_unconsumed_manual_actions()
for a in actions:
if a.kind == "force_close":
await orchestrator.handle_force_close(a.proposal_id, a.payload)
elif a.kind == "approve_proposal":
await orchestrator.handle_proposal_approved(a.proposal_id)
# etc.
state.mark_action_consumed(a.id, result="ok")
# src/cerbero_bite/runtime/manual_actions_consumer.py — sintesi
async def consume_manual_actions(ctx, *, now=None):
while (action := ctx.repository.next_unconsumed_action(...)) is not None:
if action.kind == "arm_kill":
ctx.kill_switch.arm(reason=payload.get("reason"), source="manual_gui")
elif action.kind == "disarm_kill":
ctx.kill_switch.disarm(reason=payload.get("reason"), source="manual_gui")
else:
result = "not_supported"
ctx.repository.mark_action_consumed(...)
```
Le azioni write **non bypassano** i risk control: una `force_close` deve
comunque passare dal `safety.system_healthy()` e da una conferma typed
nella GUI prima di essere scritta in coda.
Le azioni write **non bypassano** i risk control: la transizione passa
sempre per `KillSwitch.arm/disarm`, che valida lo stato e logga in
audit. La typed confirmation (`"yes I am sure"`) è gating lato GUI
prima dell'enqueue.
## Lock e concorrenza
- L'engine tiene `data/.lockfile` esclusivo.
- La GUI tiene `data/.gui-lockfile` esclusivo (impedisce due tab/Streamlit aperti).
- Entrambi possono leggere SQLite (modalità WAL).
- L'engine tiene `data/.lockfile` esclusivo via `runtime/lockfile.py`.
- La GUI **non** acquisisce un lock dedicato; più tab Streamlit
contemporanee sono possibili (sconsigliate ma non impedite). Il
vincolo single-writer su SQLite è preservato perché ogni write
passa dalla riga `manual_actions` (auto-increment) e dal consumer
dell'engine.
- Entrambi possono leggere SQLite (le connessioni sono in modalità
short-lived: aperte per chiamata e chiuse subito).
- Le `manual_actions` sono il **canale di scrittura** condiviso, con
primary key auto-increment e flag `consumed_at` per consumo idempotente.
@@ -251,26 +291,35 @@ Per chiarezza:
Telegram resta il canale primario; la GUI è canale di **fallback**
per quando Adriano è davanti al laptop e non al telefono.
## Stima di sforzo
## Stima di sforzo (storica)
Inserita come **Fase 4.5** nella roadmap, tra Orchestrator e Reporting:
La Fase 4.5 è stata implementata in quattro round (AD). Lo spec
originale stimava ~4 giorni ed è stato consegnato in linea con la
stima, con il caveat che `streamlit.testing.v1.AppTest` non è ancora
cablato (le pagine sono validate manualmente via smoke test HTTP) e
che force-close + approve/reject restano fuori scope.
| Task | Giorni |
|---|---|
| Setup `gui/main.py` + sidebar nav + autorefresh | 0.5 |
| Pagina Status + MCP health grid + kill_switch panel | 0.5 |
| Pagina Equity + drawdown + plot mensili | 0.5 |
| Pagina Position + payoff plotly + decision history | 1.0 |
| Pagina History + filtri + export CSV | 0.5 |
| Pagina Audit + search log + verify chain | 0.5 |
| `manual_actions` table + consumer job APScheduler | 0.5 |
| Test integration (Streamlit AppTest framework) | 0.5 |
| **Totale** | **~4 giorni** |
| Task | Giorni stimati | Stato |
|---|---|---|
| Setup `gui/main.py` + sidebar nav + autorefresh | 0.5 | ✅ (autorefresh non attivo) |
| Pagina Status + kill_switch panel | 0.5 | ✅ (MCP health grid non implementata) |
| Pagina Equity + drawdown + plot mensili | 0.5 | ✅ |
| Pagina Position + payoff plotly + decision history | 1.0 | ✅ (greche live e force-close differiti) |
| Pagina History + filtri + export CSV | 0.5 | ✅ |
| Pagina Audit + verify chain | 0.5 | ✅ (search e export gz differiti) |
| `manual_actions` consumer + APScheduler | 0.5 | ✅ (arm/disarm; force_close = `not_supported`) |
| Test integration (Streamlit AppTest) | 0.5 | ⏳ |
| **Totale stimato** | **~4 giorni** | |
Definition of Done:
Definition of Done — stato attuale:
- `cerbero-bite gui` lancia la dashboard
- Tutte le 5 pagine raggiungibili e popolate (anche con dati fake)
- Disarm da GUI loggato in audit chain ed effettivo entro 30 sec
- Force-close da GUI consumato dall'engine entro 30 sec
- Test integration con `streamlit.testing.v1.AppTest` per ogni pagina
- ✅ `cerbero-bite gui` lancia la dashboard su `127.0.0.1:8765`
- ✅ Tutte le 5 pagine raggiungibili e popolate dai dati di runtime
- ✅ Disarm da GUI loggato in audit chain (`source="manual_gui"`) ed
effettivo entro ~1 minuto (cron `*/1`)
- ⏳ Force-close da GUI: l'enqueue è possibile, ma l'orchestrator non
ha ancora `handle_force_close`; il consumer marca `result="not_supported"`
- ⏳ Test integration con `streamlit.testing.v1.AppTest`: non scritti
Le voci aperte sono follow-up isolati e non bloccano l'uso quotidiano
della dashboard come tableau d'observation.