# 11 — GUI Streamlit GUI desktop locale per osservazione e azioni manuali. Implementazione con **Streamlit**: un singolo processo Python, niente HTML/JS custom, niente bundler, niente porte esposte all'esterno. ## Filosofia - **Read-mostly**: la dashboard è soprattutto per *guardare* cosa fa il sistema. Le azioni interattive sono poche e ben delimitate (arm/disarm, force-close di emergenza, conferma manuale di una proposta in coda). - **Stesso state, due frontend**: Streamlit non duplica logica. Legge da `state/repository.py` e dai log, esattamente come fa il decision loop. Non parla mai direttamente al broker. - **Localhost only**: `streamlit run --server.address 127.0.0.1`. Mai bind su `0.0.0.0`. Niente autenticazione, niente HTTPS: la protezione è "macchina di Adriano, browser locale". - **Single-user, single-tab**: Streamlit non è progettato per concorrenza. Una sola sessione attiva alla volta. ## Stack tecnico | Componente | Scelta | |---|---| | Framework | `streamlit` ≥ 1.40 | | Charts | `streamlit` builtin (`st.line_chart`, `st.bar_chart`) + `plotly` per payoff diagram | | Tabelle | `streamlit.dataframe` con filtri | | Auto-refresh | `st_autorefresh` (component) ogni 5-10 sec sulle pagine live | | Config interna | letta da `strategy.yaml` via `config.loader` (sola lettura) | | Sicurezza | localhost-only, lock file condiviso con engine | | Process model | Processo separato da quello dell'engine, comunicazione **solo via SQLite e log** | ## Avvio ```bash cerbero-bite gui # alias per `streamlit run src/cerbero_bite/gui/main.py` ``` oppure manualmente: ```bash uv run streamlit run src/cerbero_bite/gui/main.py \ --server.address 127.0.0.1 \ --server.port 8765 \ --server.headless true \ --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 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) Stato corrente e controlli sul kill switch. Sezioni implementate: - **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. 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. 🔍 Audit Live log stream + verifica integrità della hash chain. Sezioni implementate: - **Chain integrity verify**: bottone che richiama `verify_chain` e riporta numero di entries verificate o l'errore di mismatch. - **Filtri**: limit (10–500) + 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. ### 3. 📈 Equity Curva P&L cumulato e analitica trade chiusi. Sezioni implementate: - **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). 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 con filtri ed esportazione. Sezioni implementate: - **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`. Confronto Monte Carlo side-by-side non ancora implementato. ### 5. 💼 Position Drilldown su una posizione specifica (open o ultime 10 chiuse). Sezioni implementate: - **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. 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 La GUI **non importa** moduli `runtime/` né chiama direttamente i client MCP. Tutto passa via: | Azione GUI | Effetto | |---|---| | 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) | 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, proposal_id TEXT, payload_json TEXT, created_at TEXT NOT NULL, consumed_at TEXT, consumed_by TEXT, result TEXT ); CREATE INDEX idx_manual_actions_unconsumed ON manual_actions(consumed_at); ``` **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 # 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: 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 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. ## Sicurezza - Bind solo `127.0.0.1`. Mai `0.0.0.0`. - Streamlit avviato con `--server.headless true` per evitare apertura automatica del browser via tunnel. - Nessuna autenticazione HTTP: la barriera è il fatto che la macchina è personale di Adriano. Se il sistema fosse mai esposto, va aggiunto reverse proxy con basic auth — ma non è il caso. - Le azioni write richiedono **typed confirmation** (`"yes I am sure"`, identico al flusso CLI). Mai `st.button` senza challenge. - CSRF: non rilevante in Streamlit (no form HTML, tutto via session state). ## Cosa la GUI **non** fa Per chiarezza: - Non interroga direttamente Deribit o altri exchange. - Non chiama mai `cerbero-memory.push_user_instruction`. - Non muta `strategy.yaml` (modifiche restano da CLI con audit chain). - Non riavvia l'engine (start/stop sono CLI). - Non sostituisce Telegram come canale di conferma di apertura. Telegram resta il canale primario; la GUI è canale di **fallback** per quando Adriano è davanti al laptop e non al telefono. ## Stima di sforzo (storica) La Fase 4.5 è stata implementata in quattro round (A–D). 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 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 — stato attuale: - ✅ `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.