da88e7f746
* 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>
326 lines
14 KiB
Markdown
326 lines
14 KiB
Markdown
# 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.
|