# 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.
