* 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>
14 KiB
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.pye 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 su0.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
cerbero-bite gui # alias per `streamlit run src/cerbero_bite/gui/main.py`
oppure manualmente:
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à dilast_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 inmanual_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_chaine 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:
Counterdei 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_putebear_call; gli iron condor cadono su una curva piatta (placeholder). - Decision history: tabella delle righe
decisionslegate alproposal_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):
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: …").
# 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/.lockfileesclusivo viaruntime/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_actionssono il canale di scrittura condiviso, con primary key auto-increment e flagconsumed_atper consumo idempotente.
Sicurezza
- Bind solo
127.0.0.1. Mai0.0.0.0. - Streamlit avviato con
--server.headless trueper 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). Maist.buttonsenza 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 guilancia la dashboard su127.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 marcaresult="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.