Compare commits
49 Commits
067f74bc89
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 76d1a4a32d | |||
| e978a44bff | |||
| efa829f7aa | |||
| b1836d91c2 | |||
| 6f4f2ce02e | |||
| d2ff29fde3 | |||
| eb0662e44d | |||
| 64f4d4e09e | |||
| 080acf829d | |||
| 98111814d2 | |||
| 3190764f64 | |||
| 8221aba10f | |||
| 395191ea13 | |||
| d36cdff609 | |||
| 3a5cf2554b | |||
| ef3c512684 | |||
| 6eff8aab0f | |||
| 7dc2fda524 | |||
| 0fcfff7d7e | |||
| f889258952 | |||
| 2a4a82c8ef | |||
| 467c8952e3 | |||
| 3aaa059417 | |||
| a2e7a78f8a | |||
| 6ff021fbf4 | |||
| dabcc8d15b | |||
| 7fdd8b47a5 | |||
| a1a9f74ed2 | |||
| a9df399db4 | |||
| e06f4d5c96 | |||
| f24511fcad | |||
| 954baaa354 | |||
| 3e46169278 | |||
| c0a0ee416f | |||
| f664ea1a15 | |||
| 18cc27a76e | |||
| 1c6baaee83 | |||
| c4cd2986a4 | |||
| 4ab7590745 | |||
| 21e865ffb0 | |||
| ce158a92dd | |||
| d9454fc996 | |||
| 63d1aa4262 | |||
| da88e7f746 | |||
| e8345a29c8 | |||
| 6f6dd4c8dd | |||
| db888ce0e8 | |||
| 1af983aff1 | |||
| abf5a140e2 |
@@ -5,7 +5,6 @@
|
||||
.pytest_cache/
|
||||
__pycache__/
|
||||
data/
|
||||
docs/
|
||||
tests/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Template per `.env` (questo file viene committato; `.env` no).
|
||||
#
|
||||
# Copia: `cp .env.example .env` e popola i valori effettivi.
|
||||
|
||||
# --- Endpoint MCP ---
|
||||
# Default Docker network (interno alla suite Cerbero_mcp V2):
|
||||
# CERBERO_BITE_MCP_DERIBIT_URL=http://cerbero-mcp:9000/mcp-deribit
|
||||
# ...
|
||||
# Gateway pubblico (host esterno alla rete Docker):
|
||||
CERBERO_BITE_MCP_DERIBIT_URL=https://cerbero-mcp.tielogic.xyz/mcp-deribit
|
||||
CERBERO_BITE_MCP_HYPERLIQUID_URL=https://cerbero-mcp.tielogic.xyz/mcp-hyperliquid
|
||||
CERBERO_BITE_MCP_MACRO_URL=https://cerbero-mcp.tielogic.xyz/mcp-macro
|
||||
CERBERO_BITE_MCP_SENTIMENT_URL=https://cerbero-mcp.tielogic.xyz/mcp-sentiment
|
||||
|
||||
# --- Token bearer MCP ---
|
||||
# Cerbero MCP V2 sceglie l'ambiente upstream (testnet vs mainnet) in
|
||||
# base al token presentato nell'header Authorization. Per switchare a
|
||||
# mainnet sostituire il valore con il MAINNET_TOKEN emesso dal cluster
|
||||
# Cerbero_mcp e riavviare il bot. Il token NON viene mai loggato.
|
||||
CERBERO_BITE_MCP_TOKEN=
|
||||
|
||||
# --- Bot tag (header X-Bot-Tag) ---
|
||||
# Identifica il bot nell'audit log del server MCP. Default fissato dal
|
||||
# progetto: `BOT__CERBERO_BITE`. Ridefinirlo solo per ambienti
|
||||
# alternativi (es. shadow run, replay).
|
||||
CERBERO_BITE_MCP_BOT_TAG=BOT__CERBERO_BITE
|
||||
|
||||
# --- Modalità operativa ---
|
||||
# Due interruttori indipendenti che decidono cosa fa il bot a ogni
|
||||
# giro del decision loop:
|
||||
# * ENABLE_DATA_ANALYSIS=true → raccolta dati MCP, snapshot di
|
||||
# mercato, calcolo indicatori, log e audit ATTIVI
|
||||
# * ENABLE_STRATEGY=true → valutazione regole §2-§9 e
|
||||
# proposta/esecuzione di entry/exit ATTIVE
|
||||
# Periodo iniziale ("solo analisi dati"): tenere
|
||||
# ENABLE_DATA_ANALYSIS=true e ENABLE_STRATEGY=false.
|
||||
CERBERO_BITE_ENABLE_DATA_ANALYSIS=true
|
||||
CERBERO_BITE_ENABLE_STRATEGY=false
|
||||
|
||||
# --- Telegram (notify-only) ---
|
||||
# Lascia commentato per modalità disabled (no notifiche).
|
||||
# CERBERO_BITE_TELEGRAM_BOT_TOKEN=123456:ABC-DEF...
|
||||
# CERBERO_BITE_TELEGRAM_CHAT_ID=-1001234567890
|
||||
+3
-3
@@ -43,6 +43,6 @@ data/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
secrets/*
|
||||
!secrets/.gitkeep
|
||||
!secrets/README.md
|
||||
|
||||
# Tooling state (oh-my-claudecode session/memory dir)
|
||||
.omc/
|
||||
|
||||
+9
-4
@@ -14,12 +14,12 @@ ENV UV_PROJECT_ENVIRONMENT=/opt/venv \
|
||||
# Install only the dependencies first so the layer is cached when the
|
||||
# source tree changes.
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN uv sync --frozen --no-dev --no-install-project
|
||||
RUN uv sync --frozen --no-dev --no-install-project --extra gui
|
||||
|
||||
# Now copy the source tree and install the project itself.
|
||||
COPY src ./src
|
||||
COPY README.md ./
|
||||
RUN uv sync --frozen --no-dev
|
||||
RUN uv sync --frozen --no-dev --extra gui
|
||||
|
||||
|
||||
FROM python:3.13-slim AS runtime
|
||||
@@ -34,13 +34,18 @@ WORKDIR /app
|
||||
|
||||
ENV PATH=/opt/venv/bin:$PATH \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
CERBERO_BITE_CORE_TOKEN_FILE=/run/secrets/core_token
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
COPY --from=builder /app/src /app/src
|
||||
COPY scripts /app/scripts
|
||||
COPY strategy.yaml /app/strategy.yaml
|
||||
# Profili alternativi confrontati nella pagina "📚 Strategia".
|
||||
COPY strategy.conservativa.yaml /app/strategy.conservativa.yaml
|
||||
COPY strategy.aggressiva.yaml /app/strategy.aggressiva.yaml
|
||||
# Documentation is shipped at runtime so the Streamlit "Strategia"
|
||||
# page can render the canonical strategy doc directly.
|
||||
COPY docs /app/docs
|
||||
|
||||
# Persistent state + audit go into /app/data, mounted as a volume in
|
||||
# docker-compose.yml.
|
||||
|
||||
@@ -15,7 +15,10 @@ attiva, sizing Quarter Kelly e disciplina di uscita rigida.
|
||||
- **Gestione attiva:** profit take 50% credito, stop loss 1.5× credito,
|
||||
vol stop +10 punti DVOL, time stop 7 DTE, exit su short strike testato
|
||||
(|delta| ≥ 0.30). Su ETH **non si difende rollando**: si esce.
|
||||
- **Frequenza:** apertura ogni 7 giorni, una posizione alla volta.
|
||||
- **Frequenza:** candidatura giornaliera (cron `0 14 * * *`, crypto è 24/7),
|
||||
fino a **5 posizioni concorrenti** sul profilo principale (3 sul
|
||||
conservativo, 8 sull'aggressivo). I gate quantitativi decidono se entrare;
|
||||
nei giorni in cui falliscono, niente trade.
|
||||
|
||||
Il sistema è **deterministico**: nessun LLM partecipa al decision loop.
|
||||
Le regole sono codificate, le soglie sono parametri di configurazione,
|
||||
@@ -48,7 +51,7 @@ leggere in ordine per chi implementa.
|
||||
| `docs/03-algorithms.md` | Specifiche dettagliate dei sette algoritmi core |
|
||||
| `docs/04-mcp-integration.md` | Mappa dei tool MCP usati e contratti |
|
||||
| `docs/05-data-model.md` | Schema persistenza posizioni, log, KB |
|
||||
| `docs/06-operational-flow.md` | Flussi operativi: avvio, settimanale, monitoring |
|
||||
| `docs/06-operational-flow.md` | Flussi operativi: avvio, entry daily, monitoring |
|
||||
| `docs/07-risk-controls.md` | Kill switch, cap, dead-man, audit |
|
||||
| `docs/08-testing-validation.md` | TDD, paper trading, golden tests |
|
||||
| `docs/09-development-roadmap.md` | Fasi di sviluppo e milestone |
|
||||
|
||||
+100
-28
@@ -1,27 +1,48 @@
|
||||
# docker-compose.yml — Cerbero Bite
|
||||
#
|
||||
# Bite runs in its own Compose project but joins the same Docker
|
||||
# network used by Cerbero_mcp so it can resolve `mcp-deribit`,
|
||||
# `mcp-macro` and friends by their service name (see the gateway
|
||||
# Caddyfile in Cerbero_mcp).
|
||||
# network used by Cerbero MCP V2 and Traefik (`traefik`) so it can
|
||||
# either resolve the in-cluster service name (`cerbero-mcp:9000`)
|
||||
# or reach the public gateway (`https://cerbero-mcp.tielogic.xyz`)
|
||||
# transparently.
|
||||
#
|
||||
# The shared network is declared as external here. Create it once on
|
||||
# the host with `docker network create cerbero-suite` (or rename the
|
||||
# Cerbero_mcp network to `cerbero-suite` and mark it external).
|
||||
# The reverse-proxy network (`traefik`) is declared as external
|
||||
# here. It is created by the Traefik stack at /opt/docker/traefik
|
||||
# and shared by every web-facing service on the host.
|
||||
#
|
||||
# Secrets are read from ./secrets/, which is .gitignore'd.
|
||||
# Authentication: a single bearer token is passed through from the
|
||||
# host `.env` file via `CERBERO_BITE_MCP_TOKEN`. The Cerbero MCP V2
|
||||
# server uses the token to decide whether the upstream environment
|
||||
# is testnet or mainnet; switching environment = switching token.
|
||||
#
|
||||
# Two services are defined:
|
||||
# * `cerbero-bite` — the trading engine / CLI worker
|
||||
# * `cerbero-bite-gui` — the Streamlit dashboard, exposed by
|
||||
# Traefik at https://cerbero-bite.<DOMAIN>
|
||||
|
||||
networks:
|
||||
cerbero-suite:
|
||||
traefik:
|
||||
external: true
|
||||
|
||||
secrets:
|
||||
core_token:
|
||||
file: ./secrets/core.token
|
||||
|
||||
volumes:
|
||||
bite-data:
|
||||
|
||||
x-bite-env: &bite-env
|
||||
CERBERO_BITE_MCP_TOKEN: ${CERBERO_BITE_MCP_TOKEN:?missing CERBERO_BITE_MCP_TOKEN}
|
||||
CERBERO_BITE_MCP_BOT_TAG: ${CERBERO_BITE_MCP_BOT_TAG:-BOT__CERBERO_BITE}
|
||||
# Two independent runtime flags that decide what each cycle does.
|
||||
# Initial period ("data-only"): DATA_ANALYSIS=true, STRATEGY=false.
|
||||
CERBERO_BITE_ENABLE_DATA_ANALYSIS: ${CERBERO_BITE_ENABLE_DATA_ANALYSIS:-true}
|
||||
CERBERO_BITE_ENABLE_STRATEGY: ${CERBERO_BITE_ENABLE_STRATEGY:-false}
|
||||
# Service URLs — defaults below match the in-cluster Traefik network
|
||||
# DNS (V2 unified image listening on port 9000). Override any of
|
||||
# them via .env to point at the public gateway, a custom host, or
|
||||
# localhost for dev work.
|
||||
CERBERO_BITE_MCP_DERIBIT_URL: ${CERBERO_BITE_MCP_DERIBIT_URL:-http://cerbero-mcp:9000/mcp-deribit}
|
||||
CERBERO_BITE_MCP_HYPERLIQUID_URL: ${CERBERO_BITE_MCP_HYPERLIQUID_URL:-http://cerbero-mcp:9000/mcp-hyperliquid}
|
||||
CERBERO_BITE_MCP_MACRO_URL: ${CERBERO_BITE_MCP_MACRO_URL:-http://cerbero-mcp:9000/mcp-macro}
|
||||
CERBERO_BITE_MCP_SENTIMENT_URL: ${CERBERO_BITE_MCP_SENTIMENT_URL:-http://cerbero-mcp:9000/mcp-sentiment}
|
||||
|
||||
services:
|
||||
cerbero-bite:
|
||||
build:
|
||||
@@ -29,23 +50,18 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
image: cerbero-bite:dev
|
||||
restart: unless-stopped
|
||||
networks: [cerbero-suite]
|
||||
networks: [traefik]
|
||||
cap_drop: [ALL]
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
secrets:
|
||||
- core_token
|
||||
environment:
|
||||
CERBERO_BITE_CORE_TOKEN_FILE: /run/secrets/core_token
|
||||
# Service URLs — the defaults below match the cerbero-suite
|
||||
# network DNS. Override per service if you need to point at a
|
||||
# different host (dev only).
|
||||
CERBERO_BITE_MCP_DERIBIT_URL: http://mcp-deribit:9011
|
||||
CERBERO_BITE_MCP_HYPERLIQUID_URL: http://mcp-hyperliquid:9012
|
||||
CERBERO_BITE_MCP_MACRO_URL: http://mcp-macro:9013
|
||||
CERBERO_BITE_MCP_SENTIMENT_URL: http://mcp-sentiment:9014
|
||||
CERBERO_BITE_MCP_TELEGRAM_URL: http://mcp-telegram:9017
|
||||
CERBERO_BITE_MCP_PORTFOLIO_URL: http://mcp-portfolio:9018
|
||||
<<: *bite-env
|
||||
# Telegram and Portfolio are no longer shared MCP services. The
|
||||
# bot now calls the Telegram Bot API directly and aggregates
|
||||
# portfolio in-process from Deribit + Hyperliquid + Macro.
|
||||
# Set the two env vars below to enable Telegram notifications.
|
||||
# CERBERO_BITE_TELEGRAM_BOT_TOKEN: ...
|
||||
# CERBERO_BITE_TELEGRAM_CHAT_ID: ...
|
||||
volumes:
|
||||
- bite-data:/app/data
|
||||
healthcheck:
|
||||
@@ -55,6 +71,62 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 120s
|
||||
# Default command runs the engine status check; override with the
|
||||
# CLI subcommand of choice (start, ping, dry-run, ...).
|
||||
command: ["status"]
|
||||
# Engine main loop (scheduler + monitoring). Switch to `status`,
|
||||
# `ping`, `dry-run`, ... for one-shot diagnostics. The MCP token in
|
||||
# `.env` decides the upstream environment server-side; the `start`
|
||||
# flag below tells the local boot check what to expect (must match,
|
||||
# otherwise the engine arms the kill switch).
|
||||
command: ["start", "--environment", "mainnet"]
|
||||
|
||||
# Streamlit dashboard published by Traefik on
|
||||
# https://cerbero-bite.${DOMAIN_NAME:-tielogic.xyz}
|
||||
#
|
||||
# The CLI sub-command `cerbero-bite gui` hard-codes the listen
|
||||
# address to 127.0.0.1, so we bypass the entrypoint and invoke
|
||||
# Streamlit directly. The two `CERBERO_BITE_GUI_*` env vars match
|
||||
# what the CLI normally injects (see src/cerbero_bite/cli.py).
|
||||
cerbero-bite-gui:
|
||||
image: cerbero-bite:dev
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- cerbero-bite
|
||||
networks: [traefik]
|
||||
cap_drop: [ALL]
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
environment:
|
||||
<<: *bite-env
|
||||
CERBERO_BITE_GUI_DB: /app/data/state.sqlite
|
||||
CERBERO_BITE_GUI_AUDIT: /app/data/log/audit.jsonl
|
||||
volumes:
|
||||
- bite-data:/app/data
|
||||
entrypoint:
|
||||
- python
|
||||
- -m
|
||||
- streamlit
|
||||
- run
|
||||
- /app/src/cerbero_bite/gui/main.py
|
||||
- --server.address=0.0.0.0
|
||||
- --server.port=8765
|
||||
- --server.headless=true
|
||||
- --browser.gatherUsageStats=false
|
||||
command: []
|
||||
healthcheck:
|
||||
test:
|
||||
- "CMD"
|
||||
- "python"
|
||||
- "-c"
|
||||
- "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8765/_stcore/health', timeout=3).close()"
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=traefik
|
||||
- "traefik.http.routers.cerbero-bite.rule=Host(`cerbero-bite.${DOMAIN_NAME:-tielogic.xyz}`)"
|
||||
- traefik.http.routers.cerbero-bite.tls=true
|
||||
- traefik.http.routers.cerbero-bite.entrypoints=websecure
|
||||
- traefik.http.routers.cerbero-bite.tls.certresolver=mytlschallenge
|
||||
- traefik.http.services.cerbero-bite.loadbalancer.server.port=8765
|
||||
- com.centurylinklabs.watchtower.enable=true
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ per imparare, ma **non sta nel loop di esecuzione**.
|
||||
### Cosa fa Cerbero Bite
|
||||
|
||||
1. Legge dati di mercato dagli MCP (Deribit, Hyperliquid, sentiment, macro).
|
||||
2. Valuta condizioni di entrata su finestra temporale fissa (settimanale).
|
||||
2. Valuta condizioni di entrata su finestra temporale fissa (giornaliera, 14:00 UTC; crypto è 24/7).
|
||||
3. Calcola la struttura ottimale dello spread secondo le regole.
|
||||
4. Verifica liquidità, cap di rischio, calendar macro.
|
||||
5. Calcola sizing in contratti.
|
||||
|
||||
@@ -19,10 +19,12 @@ Sorgente teorica: `Cerbero_Office/NewStrategy/strategia-credit-spread-eth.md`
|
||||
|
||||
## 2. Trigger di apertura (entry)
|
||||
|
||||
Il rule engine valuta l'apertura di un nuovo trade **una sola volta al
|
||||
giorno**, alle **14:00 UTC** del lunedì (orario UE pomeridiano stabile,
|
||||
fuori dai picchi di funding statunitensi). Se il lunedì è festività
|
||||
italiana, l'engine ignora la regola e attende il lunedì successivo.
|
||||
Il rule engine valuta l'apertura di un nuovo trade **una volta al
|
||||
giorno**, alle **14:00 UTC** (orario UE pomeridiano stabile, fuori
|
||||
dai picchi di funding statunitensi). Crypto è 24/7: non c'è un "giorno
|
||||
buono" intrinseco, sono i gate quantitativi a decidere se entrare o
|
||||
saltare. Se il giorno è festività italiana e `skip_holidays_country`
|
||||
è attivo, l'engine attende il giorno successivo.
|
||||
|
||||
Una nuova posizione viene aperta **solo se tutte** le seguenti condizioni
|
||||
sono vere:
|
||||
@@ -41,8 +43,36 @@ sono vere:
|
||||
patrimonio totale (correlazione direzionale già alta).
|
||||
8. **Liquidità degli strike candidati** entro le soglie del §3.
|
||||
|
||||
### 2.8 Filtri quant (introdotti in Phase 4)
|
||||
|
||||
Due gate aggiuntivi che leggono i campi del `market_snapshot`:
|
||||
|
||||
- **Dealer net gamma > 0** (default: `dealer_gamma_min: 0`). Long-gamma
|
||||
regime = dealer hedge che sopprime la vol → ideale per vendere
|
||||
credit spread. Short-gamma = vol-amplifying flow, statisticamente
|
||||
perdente. Disattivabile via `dealer_gamma_filter_enabled: false`.
|
||||
- **Liquidation risk ≠ high** (default: `liquidation_filter_enabled:
|
||||
true`). Salta entry quando il modulo sentiment flagga uno squeeze
|
||||
imminente su long o short side.
|
||||
|
||||
### 2.9 IV richness gate (introdotto in Phase 5, opt-in)
|
||||
|
||||
Filtro a maggior impatto sul win-rate. **Disabilitato** di default
|
||||
nella golden config — abilitato esplicitamente nel profilo
|
||||
`strategy.aggressiva.yaml`:
|
||||
|
||||
- `iv_minus_rv_filter_enabled: true|false` — master switch.
|
||||
- `iv_minus_rv_min: <pt vol>` — soglia: l'entry passa solo se la
|
||||
IV implicita 30g supera la realized vol 30g di almeno tot punti.
|
||||
Default 0; valori sensati 3-5 dopo calibrazione sui dati raccolti.
|
||||
|
||||
Razionale: il selling vol nudo è strutturalmente neutro a win-rate
|
||||
70-72%. L'edge della strategia esiste solo quando il premio è
|
||||
"ricco" — IV30 > RV30 + N. Vedere `13-strategia-spiegata.md §4-quater`
|
||||
per il razionale completo.
|
||||
|
||||
Se anche **una sola** condizione fallisce → **no entry**, log con motivo,
|
||||
ritento la settimana successiva.
|
||||
ritento il giorno successivo.
|
||||
|
||||
## 3. Selezione struttura
|
||||
|
||||
@@ -77,6 +107,20 @@ assoluto.
|
||||
| Distanza minima OTM | 15% (anche se delta è dentro tolleranza) |
|
||||
| Distanza massima OTM | 25% |
|
||||
|
||||
**Variante dinamica per regime DVOL (Phase 5, opt-in).** Il campo
|
||||
`short_strike.delta_by_dvol` (lista step-function) sostituisce il
|
||||
delta target singolo con bande ordinate per `dvol_under`: a DVOL
|
||||
bassa il bot prende delta più alto (più premio), a DVOL alta sceglie
|
||||
delta più basso (più safety distance). Lista vuota = comportamento
|
||||
classico col delta target sopra. Esempio bande nel profilo
|
||||
`strategy.aggressiva.yaml`:
|
||||
|
||||
| dvol_under | delta_target | delta_min | delta_max |
|
||||
|---|---|---|---|
|
||||
| 50 | 0.15 | 0.13 | 0.17 |
|
||||
| 70 | 0.12 | 0.10 | 0.15 |
|
||||
| 90 | 0.10 | 0.08 | 0.12 |
|
||||
|
||||
Se nessuno strike disponibile rientra in entrambe le tolleranze → no entry.
|
||||
|
||||
### 3.3 Strike long (protezione)
|
||||
@@ -179,6 +223,49 @@ Per ogni posizione aperta, il rule engine valuta in ordine:
|
||||
|
||||
L'ordine è importante: il primo trigger soddisfatto vince.
|
||||
|
||||
## 7-bis. Estensioni opzionali (Phase 5)
|
||||
|
||||
Tre miglioramenti opt-in al decision loop. Tutti **disabled** di
|
||||
default nella golden config; il profilo `strategy.aggressiva.yaml`
|
||||
li abilita.
|
||||
|
||||
### 7-bis.1 Vol-collapse harvest (D)
|
||||
|
||||
Inserito tra il profit-take §7.1 e lo stop-loss §7.2:
|
||||
|
||||
> Se `vol_harvest_dvol_decrease > 0`, siamo in profit
|
||||
> (`debit < credit`) E `DVOL_now ≤ DVOL_entry − vol_harvest_dvol_decrease`,
|
||||
> `decision = CLOSE_VOL_HARVEST`.
|
||||
|
||||
Razionale: edge IV-RV già catturato, vol attesa rientrata, non c'è
|
||||
motivo di tenere fino al profit-take. Default disabilitato (`0`);
|
||||
profilo aggressivo: `15` punti vol.
|
||||
|
||||
### 7-bis.2 Profit-take graduale (C — scaffolding)
|
||||
|
||||
Schema in place per chiusure parziali; pipeline runtime di
|
||||
chiusura partial-close NON ancora wirata. Default vuoto. Quando
|
||||
popolato, ogni livello `{mark_at_pct_credit, close_pct_of_initial_contracts}`
|
||||
emette un'azione `CLOSE_PROFIT_PARTIAL` advisory che il runtime
|
||||
attualmente ignora. Il completamento richiede refactor del position
|
||||
model (contracts_open vs contracts_initial) — PR dedicato.
|
||||
|
||||
### 7-bis.3 Auto-pause su drawdown (F)
|
||||
|
||||
Circuit breaker sopra il kill-switch tecnico. Valutato all'inizio di
|
||||
ogni entry-cycle:
|
||||
|
||||
> Se `auto_pause.enabled` e P/L cumulato delle ultime
|
||||
> `lookback_trades` posizioni chiuse < `−max_drawdown_pct ×
|
||||
> capitale_corrente`, l'engine si auto-mette in pausa per
|
||||
> `pause_days` giorni (skip-day mode).
|
||||
|
||||
Difende dai regime change non rilevati dai filtri quant. La pausa
|
||||
si annulla automaticamente alla scadenza, oppure manualmente con
|
||||
`UPDATE system_state SET auto_pause_until = NULL`. Default
|
||||
disabilitato; profilo aggressivo: lookback 5 trade, soglia 15%, 14
|
||||
giorni di pausa.
|
||||
|
||||
## 8. Esecuzione di apertura
|
||||
|
||||
1. Engine costruisce **combo order Deribit** (un solo ordine atomico
|
||||
@@ -211,7 +298,7 @@ L'ordine è importante: il primo trigger soddisfatto vince.
|
||||
durante un trade aperto).
|
||||
- Non aggiusta strike o size dopo l'apertura.
|
||||
- Non apre nuovi trade per "compensare" perdite recenti.
|
||||
- Non opera fuori dalla finestra del lunedì 14:00 UTC, eccetto chiusure.
|
||||
- Non opera fuori dalla finestra delle 14:00 UTC, eccetto chiusure.
|
||||
- Non deroga ai cap nemmeno per "opportunità eccezionali".
|
||||
- Non si aggiorna automaticamente: nuovo set di regole = nuovo deploy
|
||||
con review esplicita.
|
||||
|
||||
@@ -75,7 +75,7 @@ Adriano gli eventi post-fact (entry placed, exit filled, alert).
|
||||
| Format/lint | `ruff` | Standard del progetto |
|
||||
| Dependency manager | `uv` | Coerente con `Cerbero_mcp` |
|
||||
| Client MCP | `httpx.AsyncClient` long-lived (pooling) + `tenacity` per retry | HTTP REST diretto, non SDK `mcp` |
|
||||
| Notifiche | MCP `cerbero-telegram` (notify-only) | Riusa il canale esistente |
|
||||
| Notifiche | Bot API Telegram in-process (notify-only) | Token e chat-id da env, no-op se non configurati |
|
||||
| GUI | `streamlit` ≥ 1.40 + `plotly` (Fase 4.5) | Dashboard locale, processo separato |
|
||||
|
||||
## Layout cartelle
|
||||
@@ -88,9 +88,9 @@ Cerbero_Bite/
|
||||
├── strategy.yaml # config golden + execution.environment
|
||||
├── strategy.local.yaml.example # override locale (gitignored)
|
||||
├── Dockerfile # image runtime + HEALTHCHECK
|
||||
├── docker-compose.yml # rete external cerbero-suite + secrets
|
||||
├── docker-compose.yml # rete external cerbero-suite, env passthrough
|
||||
├── .env.example # template variabili (token MCP, bot tag, modalità)
|
||||
├── docs/ # questa documentazione
|
||||
├── secrets/ # gitignored (solo .gitkeep + README)
|
||||
├── src/cerbero_bite/
|
||||
│ ├── __init__.py
|
||||
│ ├── __main__.py # entry point CLI
|
||||
@@ -122,7 +122,7 @@ Cerbero_Bite/
|
||||
│ │ ├── lockfile.py # fcntl.flock single-instance
|
||||
│ │ ├── alert_manager.py # severity routing
|
||||
│ │ ├── health_check.py # ping + 3-strikes kill switch
|
||||
│ │ ├── entry_cycle.py # weekly entry auto-execute
|
||||
│ │ ├── entry_cycle.py # daily entry auto-execute (crypto 24/7)
|
||||
│ │ ├── monitor_cycle.py # 12h exit auto-execute
|
||||
│ │ └── recovery.py # state reconcile al boot
|
||||
│ ├── state/ # persistenza
|
||||
@@ -135,7 +135,8 @@ Cerbero_Bite/
|
||||
│ ├── config/ # caricamento e validazione yaml
|
||||
│ │ ├── schema.py
|
||||
│ │ ├── loader.py
|
||||
│ │ └── mcp_endpoints.py # URL + token loader
|
||||
│ │ ├── mcp_endpoints.py # URL + token + bot tag (da .env)
|
||||
│ │ └── runtime_flags.py # ENABLE_DATA_ANALYSIS / ENABLE_STRATEGY
|
||||
│ ├── reporting/ # report umani (Fase 5)
|
||||
│ ├── gui/ # Streamlit dashboard (Fase 4.5)
|
||||
│ └── safety/ # kill switch, dead man, audit
|
||||
@@ -170,8 +171,9 @@ Cerbero_Bite/
|
||||
effetti collaterali. Espone `Orchestrator` come façade per il CLI.
|
||||
- **`state/`** persistenza. Mai logica di business. Solo CRUD.
|
||||
- **`config/`** caricamento di `strategy.yaml`, validazione,
|
||||
esposizione immutabile dei parametri. Risolve gli URL MCP e legge
|
||||
il bearer token al boot.
|
||||
esposizione immutabile dei parametri. Risolve gli URL MCP, legge
|
||||
il bearer token + il bot tag al boot ed espone i due interruttori
|
||||
operativi `RuntimeFlags(data_analysis_enabled, strategy_enabled)`.
|
||||
- **`safety/`** controlli trasversali (vedere `07-risk-controls.md`).
|
||||
- **`reporting/`** generazione di stringhe per Telegram. Niente
|
||||
logica di trading, solo formatting.
|
||||
|
||||
+81
-28
@@ -1,10 +1,22 @@
|
||||
# 04 — MCP Integration
|
||||
|
||||
Cerbero Bite consuma sei servizi MCP HTTP della suite (`Cerbero_mcp`).
|
||||
Non utilizza l'SDK Python `mcp`: ogni server espone gli endpoint REST
|
||||
`POST <base_url>/tools/<tool_name>` con autenticazione Bearer, e Cerbero
|
||||
Bite vi si collega tramite `httpx.AsyncClient` long-lived
|
||||
(`clients/_base.py`).
|
||||
Cerbero Bite consuma quattro router MCP HTTP della suite Cerbero MCP V2
|
||||
(`Cerbero_mcp`): `mcp-deribit`, `mcp-hyperliquid`, `mcp-macro`,
|
||||
`mcp-sentiment`. Dalla V2 i quattro router vivono nello stesso processo
|
||||
FastAPI dietro lo stesso host (default in-cluster
|
||||
`http://cerbero-mcp:9000/mcp-{exchange}`, gateway pubblico
|
||||
`https://cerbero-mcp.tielogic.xyz/mcp-{exchange}`). Cerbero Bite non
|
||||
utilizza l'SDK Python `mcp`: ogni router espone gli endpoint REST
|
||||
`POST <base_url>/tools/<tool_name>` con autenticazione Bearer e header
|
||||
`X-Bot-Tag`, e Cerbero Bite vi si collega tramite `httpx.AsyncClient`
|
||||
long-lived (`clients/_base.py`).
|
||||
|
||||
Telegram e Portfolio, in passato esposti come servizi MCP condivisi,
|
||||
sono stati rimossi dal layer MCP e gestiti **in-process** da ogni bot
|
||||
della suite: il client Telegram chiama direttamente la Bot API
|
||||
pubblica e l'aggregatore di portafoglio compone equity ed esposizioni
|
||||
dai client di scambio (Deribit + Hyperliquid) convertendo in EUR
|
||||
attraverso `cerbero-macro.get_asset_price("EURUSD")`.
|
||||
|
||||
## Configurazione di connessione
|
||||
|
||||
@@ -14,26 +26,50 @@ con default che corrispondono al DNS della rete Docker
|
||||
ecc.). Ogni servizio può essere sovrascritto da una variabile
|
||||
d'ambiente dedicata, utile in sviluppo:
|
||||
|
||||
| Servizio | Variabile d'ambiente | Default Docker DNS |
|
||||
| Servizio | Variabile d'ambiente | Default Docker DNS legacy |
|
||||
|---|---|---|
|
||||
| Deribit | `CERBERO_BITE_MCP_DERIBIT_URL` | `http://mcp-deribit:9011` |
|
||||
| Hyperliquid | `CERBERO_BITE_MCP_HYPERLIQUID_URL` | `http://mcp-hyperliquid:9012` |
|
||||
| Macro | `CERBERO_BITE_MCP_MACRO_URL` | `http://mcp-macro:9013` |
|
||||
| Sentiment | `CERBERO_BITE_MCP_SENTIMENT_URL` | `http://mcp-sentiment:9014` |
|
||||
| Telegram | `CERBERO_BITE_MCP_TELEGRAM_URL` | `http://mcp-telegram:9017` |
|
||||
| Portfolio | `CERBERO_BITE_MCP_PORTFOLIO_URL` | `http://mcp-portfolio:9018` |
|
||||
|
||||
Il bearer token per le chiamate è il token con capability `core` letto
|
||||
da `secrets/core.token` (path configurabile via
|
||||
`CERBERO_BITE_CORE_TOKEN_FILE`, default `/run/secrets/core_token` nel
|
||||
container). Non è loggato.
|
||||
I default mostrati sopra sono il legacy della topologia V1 (un container
|
||||
per servizio). Sulla V2 unificata ogni URL deve includere il prefisso di
|
||||
router, ad esempio `http://cerbero-mcp:9000/mcp-deribit` o
|
||||
`https://cerbero-mcp.tielogic.xyz/mcp-deribit`. Le URL effettive sono
|
||||
configurate in `.env`.
|
||||
|
||||
Telegram (notify-only) viene configurato direttamente via due
|
||||
variabili d'ambiente, lette al boot dal client in-process:
|
||||
|
||||
| Variabile | Uso |
|
||||
|---|---|
|
||||
| `CERBERO_BITE_TELEGRAM_BOT_TOKEN` | Token del bot fornito da BotFather |
|
||||
| `CERBERO_BITE_TELEGRAM_CHAT_ID` | Identificativo della chat o del gruppo destinatario |
|
||||
|
||||
Quando una delle due manca, il client Telegram entra in modalità
|
||||
**disabled** e ogni `notify_*` diventa un no-op a livello di DEBUG.
|
||||
|
||||
Il bearer token per le chiamate è letto dalla variabile d'ambiente
|
||||
`CERBERO_BITE_MCP_TOKEN` (vedi `.env`). Sulla V2 il valore del token
|
||||
decide quale ambiente upstream serve la richiesta: lo stesso server MCP
|
||||
fronteggia testnet e mainnet contemporaneamente, e si passa da uno
|
||||
all'altro semplicemente sostituendo il valore della variabile e
|
||||
riavviando il bot. Il token non viene mai loggato.
|
||||
|
||||
A ogni chiamata Cerbero Bite aggiunge anche l'header `X-Bot-Tag`, con
|
||||
valore di default `BOT__CERBERO_BITE` (override via
|
||||
`CERBERO_BITE_MCP_BOT_TAG`). Il server MCP scrive il valore nell'audit
|
||||
record di ogni operazione di scrittura, così ogni write resta
|
||||
attribuibile al bot d'origine.
|
||||
|
||||
```python
|
||||
# clients/_base.py — sintesi
|
||||
class HttpToolClient:
|
||||
service: str # "deribit", "macro", ...
|
||||
base_url: str # "http://mcp-deribit:9011"
|
||||
token: str # bearer
|
||||
base_url: str # "https://cerbero-mcp.tielogic.xyz/mcp-deribit"
|
||||
token: str # bearer (testnet o mainnet, scelto da env)
|
||||
bot_tag: str = "BOT__CERBERO_BITE" # X-Bot-Tag header
|
||||
timeout_s: float = 8.0
|
||||
retry_max: int = 3 # esponenziale 1s/5s/30s
|
||||
client: httpx.AsyncClient | None # condiviso dal RuntimeContext
|
||||
@@ -100,22 +136,35 @@ Cerbero Bite è deterministico e non interpreta testi liberi.
|
||||
| Tool | Uso |
|
||||
|---|---|
|
||||
| `get_macro_calendar(days, country_filter, importance_min)` | Filtro entry §2.5: zero eventi `high` in `country_filter` (default `["US","EU"]`) entro la finestra DTE |
|
||||
| `get_asset_price(ticker="EURUSD")` | Tasso di cambio EUR/USD usato dall'aggregatore di portafoglio per convertire l'equity USD degli scambi in EUR |
|
||||
|
||||
### `cerbero-portfolio`
|
||||
## Componenti in-process
|
||||
|
||||
| Tool | Uso |
|
||||
### Portfolio aggregator (`clients/portfolio.py`)
|
||||
|
||||
Il client `PortfolioClient` non chiama più un servizio MCP dedicato;
|
||||
compone i dati dei due exchange usati dal bot e applica il cambio
|
||||
EUR/USD letto da `cerbero-macro`.
|
||||
|
||||
| Metodo | Comportamento |
|
||||
|---|---|
|
||||
| `get_total_portfolio_value(currency="EUR")` | Capitale di base per il sizing engine, dopo conversione in USD |
|
||||
| `get_holdings()` | Aggregazione manuale di `current_value_eur` per i ticker che contengono `"ETH"`, usata dal filtro §2.7 (`eth_holdings_pct_max`) |
|
||||
| `total_equity_eur()` | Somma `equity` USD di Deribit (USDC) e Hyperliquid, divide per `EURUSD` per ottenere il capitale in EUR consumato dal sizing engine |
|
||||
| `asset_pct_of_portfolio(ticker)` | Somma il notional USD assoluto delle posizioni aperte su entrambi gli scambi il cui `instrument`/`coin` contiene `ticker`, e lo divide per l'equity totale USD. Usato dal filtro §2.7 (`eth_holdings_pct_max`) |
|
||||
|
||||
### `cerbero-telegram`
|
||||
**Nota di scope**: la vista è la *slice* del singolo bot. Holdings su
|
||||
exchange esterni, in cold storage, o gestiti da altri bot della suite
|
||||
non vengono contati. Il filtro §2.7 va quindi inteso come cap
|
||||
per-bot, non come cap suite-wide.
|
||||
|
||||
Cerbero Bite usa Telegram in modalità **notify-only**: nessuna conferma
|
||||
manuale, nessun callback. L'engine apre e chiude le posizioni
|
||||
automaticamente quando le regole sono soddisfatte; Telegram viene
|
||||
informato post-fact.
|
||||
### Telegram client (`clients/telegram.py`)
|
||||
|
||||
| Tool | Uso |
|
||||
Cerbero Bite usa Telegram in modalità **notify-only**: nessuna
|
||||
conferma manuale, nessun callback. L'engine apre e chiude le
|
||||
posizioni automaticamente quando le regole sono soddisfatte; il
|
||||
client invia il messaggio al `chat_id` configurato chiamando
|
||||
direttamente `https://api.telegram.org/bot<TOKEN>/sendMessage`.
|
||||
|
||||
| Metodo | Uso |
|
||||
|---|---|
|
||||
| `notify(message, priority, tag)` | Alert MEDIUM o messaggi informativi |
|
||||
| `notify_position_opened(instrument, side, size, strategy, greeks, expected_pnl)` | Notifica di entry placed |
|
||||
@@ -123,16 +172,20 @@ informato post-fact.
|
||||
| `notify_alert(source, message, priority)` | Alert HIGH (kill switch) |
|
||||
| `notify_system_error(message, component, priority)` | Alert CRITICAL |
|
||||
|
||||
Quando le credenziali env non sono configurate, il client è in
|
||||
modalità disabled e ogni invio diventa un no-op silente: il ciclo
|
||||
decisionale non viene bloccato.
|
||||
|
||||
## Errori e degradation
|
||||
|
||||
| Server fuori uso | Comportamento |
|
||||
| Componente fuori uso | Comportamento |
|
||||
|---|---|
|
||||
| `cerbero-deribit` | **Hard fail**: senza dati di mercato e canale di esecuzione il ciclo viene saltato; in monitor le posizioni esistenti restano nello stato corrente, alert HIGH e kill switch |
|
||||
| `cerbero-hyperliquid` | Skip del filtro funding §2.6 con warning; il ciclo prosegue se le altre condizioni sono soddisfatte |
|
||||
| `cerbero-sentiment` | Bias §3.1 cade su `no_entry` per default (senza funding cross il bias non può fissare la direzione) |
|
||||
| `cerbero-macro` | Hard fail per il filtro §2.5; senza calendar non si apre |
|
||||
| `cerbero-portfolio` | Skip dei filtri §2.7 con warning; il sizing usa l'ultimo capitale noto da SQLite |
|
||||
| `cerbero-telegram` | Skip notifiche post-fact; il ciclo decisionale non viene bloccato (l'engine non aspetta risposte) |
|
||||
| `cerbero-macro` | Hard fail per il filtro §2.5 e per la conversione EUR/USD del portfolio aggregator; senza calendar/FX non si apre |
|
||||
| Portfolio aggregator (deribit o hyperliquid down) | I metodi di `PortfolioClient` propagano l'eccezione dell'exchange sottostante; il sizing engine si comporta come per un guasto MCP del livello inferiore |
|
||||
| Telegram client | Errore HTTP o `ok=false` dalla Bot API → `TelegramError` propagata dal chiamante. In modalità disabled (env mancanti) tutti i `notify_*` sono no-op silenti e il ciclo decisionale prosegue |
|
||||
|
||||
I trigger HIGH e CRITICAL armano il kill switch e propagano un alert
|
||||
in audit chain.
|
||||
|
||||
+89
-16
@@ -152,27 +152,40 @@ CREATE TABLE dvol_history (
|
||||
### `manual_actions`
|
||||
|
||||
Coda di azioni manuali generate dalla GUI Streamlit (vedi
|
||||
`11-gui-streamlit.md`). Schema previsto in vista della Fase 4.5; al
|
||||
momento la GUI non è implementata e la tabella resta vuota.
|
||||
`11-gui-streamlit.md`). La tabella è popolata dal layer
|
||||
`gui/data_layer.py` (`enqueue_arm_kill`, `enqueue_disarm_kill`) ed è
|
||||
drenata dal job APScheduler `manual_actions`
|
||||
(`runtime/manual_actions_consumer.consume_manual_actions`, cron
|
||||
`*/1 * * * *`).
|
||||
|
||||
```sql
|
||||
CREATE TABLE manual_actions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT NOT NULL, -- approve_proposal, reject_proposal,
|
||||
-- force_close, arm_kill, disarm_kill
|
||||
kind TEXT NOT NULL, -- arm_kill, disarm_kill,
|
||||
-- force_close, approve_proposal, reject_proposal
|
||||
proposal_id TEXT, -- NULL se l'azione non è legata a una proposta
|
||||
payload_json TEXT, -- JSON con motivo, conferma typed, ecc.
|
||||
payload_json TEXT, -- JSON con reason, conferma typed, ecc.
|
||||
created_at TEXT NOT NULL,
|
||||
consumed_at TEXT, -- NULL = ancora da processare
|
||||
consumed_by TEXT,
|
||||
result TEXT
|
||||
consumed_by TEXT, -- "engine" quando applicata dal consumer
|
||||
result TEXT -- "ok" / "not_supported" / "error: ..."
|
||||
);
|
||||
CREATE INDEX idx_manual_actions_unconsumed ON manual_actions(consumed_at);
|
||||
```
|
||||
|
||||
Le `manual_actions` non bypassano i risk control: il consumer
|
||||
(quando esisterà) applicherà gli stessi check di
|
||||
`safety.system_healthy()` prima di eseguire.
|
||||
Stato implementativo per `kind`:
|
||||
|
||||
| `kind` | Implementato | Effetto |
|
||||
|---|---|---|
|
||||
| `arm_kill` | ✅ | `KillSwitch.arm(reason, source="manual_gui")` |
|
||||
| `disarm_kill` | ✅ | `KillSwitch.disarm(reason, source="manual_gui")` |
|
||||
| `force_close` | ⏳ | Marcato `result="not_supported"` finché l'orchestrator non espone `handle_force_close` |
|
||||
| `approve_proposal` / `reject_proposal` | ⏳ | Idem |
|
||||
|
||||
Le `manual_actions` **non** bypassano i risk control: ogni azione di
|
||||
kill switch passa dalla classe `KillSwitch`, che valida lo stato e
|
||||
appende l'evento corrispondente alla audit chain. La typed
|
||||
confirmation lato GUI è gating prima dell'enqueue.
|
||||
|
||||
### `system_state`
|
||||
|
||||
@@ -189,7 +202,9 @@ CREATE TABLE system_state (
|
||||
last_kelly_calib TEXT,
|
||||
config_version TEXT NOT NULL,
|
||||
started_at TEXT NOT NULL,
|
||||
last_audit_hash TEXT -- aggiunto dalla migration 0002
|
||||
last_audit_hash TEXT, -- aggiunto dalla migration 0002
|
||||
auto_pause_until TEXT, -- aggiunto dalla migration 0004 (§7-bis.3)
|
||||
auto_pause_reason TEXT -- aggiunto dalla migration 0004
|
||||
);
|
||||
```
|
||||
|
||||
@@ -199,6 +214,57 @@ Al boot l'orchestrator confronta questo valore con il tail del file
|
||||
`audit.log`: discrepanza → kill switch CRITICAL, vedi
|
||||
`07-risk-controls.md`.
|
||||
|
||||
I campi `auto_pause_until` / `auto_pause_reason` implementano il
|
||||
circuit breaker §7-bis.3 (pausa automatica su drawdown rolling).
|
||||
NULL = engine attivo.
|
||||
|
||||
### `option_chain_snapshots`
|
||||
|
||||
Snapshot della catena opzioni Deribit prelevata in continuo
|
||||
(cron `*/15 * * * *`, allineato a `market_snapshots`). Crypto è
|
||||
24/7: l'accumulo dataset deve essere continuo, non gateato sulla
|
||||
settimana. Ogni tick contiene un quote per strumento entro la
|
||||
finestra `[dte_min, dte_max]` di config; tutti i quote prelevati
|
||||
nello stesso tick condividono ``timestamp``. Migration `0005`.
|
||||
|
||||
```sql
|
||||
CREATE TABLE option_chain_snapshots (
|
||||
timestamp TEXT NOT NULL,
|
||||
asset TEXT NOT NULL,
|
||||
instrument_name TEXT NOT NULL,
|
||||
strike TEXT NOT NULL,
|
||||
expiry TEXT NOT NULL,
|
||||
option_type TEXT NOT NULL CHECK (option_type IN ('C','P')),
|
||||
bid TEXT,
|
||||
ask TEXT,
|
||||
mid TEXT,
|
||||
iv TEXT,
|
||||
delta TEXT,
|
||||
gamma TEXT,
|
||||
theta TEXT,
|
||||
vega TEXT,
|
||||
open_interest INTEGER,
|
||||
volume_24h INTEGER,
|
||||
book_depth_top3 INTEGER,
|
||||
PRIMARY KEY (timestamp, instrument_name)
|
||||
) WITHOUT ROWID;
|
||||
```
|
||||
|
||||
Indici: `(asset, timestamp DESC)` per listing recenti, `(asset,
|
||||
expiry)` per query per scadenza specifica. ``book_depth_top3`` è
|
||||
NULL by design — il collector non chiama l'order book per ogni
|
||||
strike per non saturare l'API; lo legge il liquidity gate live solo
|
||||
sugli strike candidati al picker.
|
||||
|
||||
**Sblocca**: il backtest non-stilizzato (modulo `core/backtest.py`
|
||||
con prezzi reali invece di Black-Scholes), la calibrazione empirica
|
||||
dello skew premium, la validazione ex-post dello strike picker.
|
||||
|
||||
Volume atteso (cron `*/15 * * * *`, ~96 snapshot/giorno):
|
||||
~50 strike × 3 scadenze × 96 snap/giorno × 17 colonne ≈ ~1.1 MB/giorno,
|
||||
~400 MB/anno. Considerare politiche di retention (archive trimestrale
|
||||
in parquet) se il bot gira a lungo.
|
||||
|
||||
## Log file
|
||||
|
||||
Sotto `data/log/` un file per giorno: `cerbero-bite-YYYY-MM-DD.jsonl`.
|
||||
@@ -276,11 +342,18 @@ da altri processi (es. CLI `state inspect`) non vedano stati parziali.
|
||||
|
||||
## Migrations
|
||||
|
||||
Lo schema viene tracciato con il counter `PRAGMA user_version`. La
|
||||
prima volta `0001_init.sql` viene applicato e versione → 1; alla
|
||||
seconda esecuzione (o su DB già a versione 1) `0002_audit_anchor.sql`
|
||||
viene applicato e versione → 2. `state.db.run_migrations` è
|
||||
idempotente. Nessun rollback supportato (migrations forward-only).
|
||||
Lo schema viene tracciato con il counter `PRAGMA user_version`.
|
||||
`state.db.run_migrations` applica in ordine ogni file
|
||||
`NNNN_<name>.sql` con versione superiore a quella corrente,
|
||||
idempotente, forward-only:
|
||||
|
||||
| Versione | File | Cosa aggiunge |
|
||||
|---|---|---|
|
||||
| 1 | `0001_init.sql` | tabelle base (positions, decisions, ...) |
|
||||
| 2 | `0002_audit_anchor.sql` | `system_state.last_audit_hash` |
|
||||
| 3 | `0003_market_snapshots.sql` | tabella `market_snapshots` |
|
||||
| 4 | `0004_auto_pause.sql` | `system_state.auto_pause_until / _reason` |
|
||||
| 5 | `0005_option_chain_snapshots.sql` | tabella `option_chain_snapshots` |
|
||||
|
||||
## Backup
|
||||
|
||||
|
||||
@@ -27,9 +27,11 @@ L'avvio è progettato per essere **safe**: se qualcosa non torna, il
|
||||
sistema si rifiuta di operare. Mai partire con uno stato dubbio o un
|
||||
ambiente diverso da quello atteso.
|
||||
|
||||
## Flusso 2 — Settimanale (entry)
|
||||
## Flusso 2 — Daily (entry)
|
||||
|
||||
Trigger: cron `0 14 * * MON` (lunedì 14:00 UTC).
|
||||
Trigger: cron `0 14 * * *` (ogni giorno 14:00 UTC). Crypto è 24/7:
|
||||
la cadenza di candidatura non è gateata sulla settimana — sono i gate
|
||||
quantitativi a decidere se entrare o saltare il giorno.
|
||||
|
||||
```
|
||||
START
|
||||
@@ -140,7 +142,7 @@ Trigger: ogni 5 minuti.
|
||||
- macro.get_macro_calendar(days=1)
|
||||
- sentiment.get_cross_exchange_funding (no asset filter)
|
||||
- hyperliquid.get_funding_rate("ETH")
|
||||
- portfolio.get_total_portfolio_value
|
||||
- portfolio: skip (componente in-process, copertura indiretta dai probe deribit/hyperliquid/macro)
|
||||
- telegram: skip (notify-only, no probe non invasivo)
|
||||
2. SQLite read-write probe (transazione fittizia)
|
||||
3. Lock file ancora valido
|
||||
@@ -154,6 +156,26 @@ Trigger: ogni 5 minuti.
|
||||
Il dead-man (`scripts/dead_man.sh`) sorveglia che `HEALTH_OK` venga
|
||||
scritto: silenzio > 15 min → kill switch via SQLite e alert.
|
||||
|
||||
## Flusso 5b — Manual actions consumer
|
||||
|
||||
Trigger: cron `*/1 * * * *` (job APScheduler `manual_actions`).
|
||||
|
||||
```
|
||||
1. Mentre la coda ha righe non consumate:
|
||||
- leggi `next_unconsumed_action` (oldest-first)
|
||||
- dispatch per kind:
|
||||
arm_kill → KillSwitch.arm(reason, source="manual_gui")
|
||||
disarm_kill → KillSwitch.disarm(reason, source="manual_gui")
|
||||
force_close / approve_proposal / reject_proposal → result="not_supported"
|
||||
- mark_action_consumed con consumed_by="engine" e result
|
||||
2. Latenza tipica end-to-end (enqueue da GUI → effetto): ≤ 60 sec.
|
||||
```
|
||||
|
||||
Il consumer è il **canale unico** di scrittura dalla GUI verso il
|
||||
runtime: ogni transizione del kill switch passa dalla classe
|
||||
`KillSwitch` per mantenere SQLite e audit chain in lock-step. Vedi
|
||||
`runtime/manual_actions_consumer.py` e `docs/11-gui-streamlit.md`.
|
||||
|
||||
## Flusso 6 — Recovery dopo crash
|
||||
|
||||
All'avvio o dopo un riavvio del container:
|
||||
@@ -199,11 +221,65 @@ proposed
|
||||
|
||||
| Cron | Trigger | Frequenza |
|
||||
|---|---|---|
|
||||
| `0 14 * * MON` | Entry evaluation | Settimanale |
|
||||
| `0 14 * * *` | Entry evaluation | Giornaliera |
|
||||
| `0 2,14 * * *` | Position monitoring | 2× giorno |
|
||||
| `0 12 1 * *` | Kelly recalibration | Mensile |
|
||||
| `*/5 * * * *` | Health check | 5 min |
|
||||
| `*/15 * * * *` | Market snapshot (calibrazione soglie) | 15 min |
|
||||
| `0 0 * * *` | Backup SQLite + rotation log | Giornaliero |
|
||||
| `0 8 * * *` | Daily digest Telegram | Giornaliero |
|
||||
|
||||
Tutti gli orari in UTC.
|
||||
|
||||
## Modalità operativa (interruttori `RuntimeFlags`)
|
||||
|
||||
Il bot riconosce due interruttori indipendenti, letti da
|
||||
`.env` al boot tramite `cerbero_bite.config.runtime_flags.load_runtime_flags()`:
|
||||
|
||||
| Variabile d'ambiente | Default | Cosa abilita |
|
||||
|---|---|---|
|
||||
| `CERBERO_BITE_ENABLE_DATA_ANALYSIS` | `true` | Job `market_snapshot` ogni 15 min: raccolta dati MCP, scrittura tabella `market_snapshots`, calibrazione soglie. |
|
||||
| `CERBERO_BITE_ENABLE_STRATEGY` | `false` | Job `entry` (daily 14:00 UTC) e `monitor` (2× giorno): valutazione regole §2-§9 di `01-strategy-rules.md` e proposta/esecuzione ordini. |
|
||||
|
||||
I job di infrastruttura (`health`, `backup`, `manual_actions`) sono
|
||||
**sempre attivi**, indipendentemente dai flag, perché tengono in vita il
|
||||
kill switch e la persistenza.
|
||||
|
||||
### Profilo "solo analisi dati" (default)
|
||||
|
||||
Configurazione standard del periodo di soak post-deploy:
|
||||
|
||||
```env
|
||||
CERBERO_BITE_ENABLE_DATA_ANALYSIS=true
|
||||
CERBERO_BITE_ENABLE_STRATEGY=false
|
||||
```
|
||||
|
||||
Effetto: il bot raccoglie snapshot di mercato, alimenta `market_snapshots`,
|
||||
ma **non** invia entry né chiude posizioni autonomamente. I metodi
|
||||
`run_entry`/`run_monitor` restano richiamabili manualmente da CLI
|
||||
(`cerbero-bite dry-run --cycle entry|monitor`) e tramite `manual_actions`
|
||||
per testing e validazione.
|
||||
|
||||
### Profilo "trading attivo"
|
||||
|
||||
```env
|
||||
CERBERO_BITE_ENABLE_DATA_ANALYSIS=true
|
||||
CERBERO_BITE_ENABLE_STRATEGY=true
|
||||
```
|
||||
|
||||
Effetto: tutti i job canonici vengono installati nello scheduler. Lo
|
||||
switch va fatto solo dopo che la qualità dei dati raccolti è stata
|
||||
validata e Adriano dà esplicito consenso al passaggio.
|
||||
|
||||
### Disattivazione completa dell'analisi dati
|
||||
|
||||
Caso eccezionale (manutenzione, problema MCP):
|
||||
|
||||
```env
|
||||
CERBERO_BITE_ENABLE_DATA_ANALYSIS=false
|
||||
CERBERO_BITE_ENABLE_STRATEGY=false
|
||||
```
|
||||
|
||||
Il bot resta vivo per health check e ricezione di manual actions, ma
|
||||
non interroga MCP per dati di mercato e non opera. Il kill switch resta
|
||||
operativo.
|
||||
|
||||
@@ -34,7 +34,7 @@ infrastrutturali o decisioni umane fuori posto.
|
||||
| Causa | Auto-arm | Implementato | Note |
|
||||
|---|---|---|---|
|
||||
| MCP `cerbero-deribit` non risponde per 3 health check consecutivi | Sì | `runtime/health_check.py` | Severity HIGH |
|
||||
| MCP `cerbero-macro` / `cerbero-portfolio` / `cerbero-hyperliquid` / `cerbero-sentiment` non risponde per 3 health check consecutivi | Sì | `runtime/health_check.py` | Severity HIGH |
|
||||
| MCP `cerbero-macro` / `cerbero-hyperliquid` / `cerbero-sentiment` non risponde per 3 health check consecutivi | Sì | `runtime/health_check.py` | Severity HIGH |
|
||||
| `mcp-deribit.environment_info.environment` ≠ `strategy.execution.environment` | Sì | `runtime/orchestrator.boot` + health check | Severity CRITICAL al boot, HIGH a runtime |
|
||||
| Mismatch tra il tail del file `data/audit.log` e `system_state.last_audit_hash` (truncation o tampering) | Sì | `runtime/orchestrator._verify_audit_anchor` | Severity CRITICAL al boot |
|
||||
| Stato SQLite incoerente con il broker (recovery non risolutivo) | Sì | `runtime/recovery.py` | Severity CRITICAL al boot |
|
||||
@@ -54,7 +54,7 @@ cerbero-bite kill-switch disarm --reason "<motivo>" \
|
||||
L'operazione è transazionale: SQLite `system_state.kill_switch = 0` +
|
||||
una linea `KILL_SWITCH_DISARMED` nella audit chain con il motivo. Il
|
||||
disarm non riavvia automaticamente lo scheduler; è il prossimo tick
|
||||
naturale (entry settimanale o monitor 12h) a far ripartire la
|
||||
naturale (entry giornaliero o monitor 12h) a far ripartire la
|
||||
decisione.
|
||||
|
||||
## Cap di rischio (oltre alle regole di strategia)
|
||||
|
||||
@@ -157,9 +157,9 @@ chain:
|
||||
|
||||
| Test | Scenario |
|
||||
|---|---|
|
||||
| `test_weekly_open_happy_path` | Tutto OK → proposta inviata |
|
||||
| `test_weekly_open_no_strike_available` | Chain vuota nel range delta |
|
||||
| `test_weekly_open_macro_blocks` | FOMC entro 5 giorni |
|
||||
| `test_daily_open_happy_path` | Tutto OK → proposta inviata |
|
||||
| `test_daily_open_no_strike_available` | Chain vuota nel range delta |
|
||||
| `test_daily_open_macro_blocks` | FOMC entro 5 giorni |
|
||||
| `test_monitor_profit_take` | Mark = 50% credito → close_profit |
|
||||
| `test_monitor_vol_stop` | DVOL +12 → close_vol |
|
||||
| `test_recovery_after_crash_open_position` | Crash mid-fill, restart, riconcilia |
|
||||
@@ -175,8 +175,8 @@ checked-in.
|
||||
|
||||
```
|
||||
tests/golden/
|
||||
├── 2026-04-27_weekly_open_bull_put.yaml # input snapshot
|
||||
├── 2026-04-27_weekly_open_bull_put.golden # output atteso
|
||||
├── 2026-04-27_daily_open_bull_put.yaml # input snapshot
|
||||
├── 2026-04-27_daily_open_bull_put.golden # output atteso
|
||||
└── runner.py
|
||||
```
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ Tasks:
|
||||
4. `runtime/alert_manager.py` — escalation policy
|
||||
|
||||
Test integration su scenari completi (vedi `08-testing-validation.md`):
|
||||
- weekly open happy path
|
||||
- daily open happy path
|
||||
- monitor profit take
|
||||
- monitor vol stop
|
||||
- recovery dopo crash
|
||||
@@ -126,29 +126,38 @@ Definition of Done:
|
||||
- Engine può girare in `--dry-run` per 24h senza errori
|
||||
- I log sono leggibili e completi
|
||||
|
||||
## Fase 4.5 — GUI Streamlit (4 giorni)
|
||||
## Fase 4.5 — GUI Streamlit (4 giorni) ✅ implementata
|
||||
|
||||
**Obiettivo:** dashboard locale per osservazione e azioni manuali. Spec
|
||||
dettagliata in `11-gui-streamlit.md`.
|
||||
|
||||
Tasks:
|
||||
Implementata in quattro round (A–D):
|
||||
|
||||
1. Setup `gui/main.py` + sidebar nav + auto-refresh
|
||||
2. Pagina Status (engine, capitale, MCP health, kill switch panel)
|
||||
3. Pagina Equity (curve, drawdown, monthly stats)
|
||||
4. Pagina Position (legs, payoff plotly, decision history, force-close)
|
||||
5. Pagina History (filtri, KPI, export CSV)
|
||||
6. Pagina Audit (log live, verify chain, search)
|
||||
7. Tabella `manual_actions` + consumer job APScheduler nell'engine
|
||||
8. Test integration con `streamlit.testing.v1.AppTest`
|
||||
1. ✅ `gui/main.py` + sidebar nav (auto-refresh attivo non cablato; il
|
||||
re-render Streamlit è sufficiente per la frequenza tipica)
|
||||
2. ✅ Pagina Status (engine state, kill switch panel con typed
|
||||
confirmation, audit anchor, open positions)
|
||||
3. ✅ Pagina Equity (cumulative P&L, drawdown, P&L distribution per
|
||||
close reason, per-month stats)
|
||||
4. ✅ Pagina Position (legs from entry snapshot, payoff plotly per
|
||||
bull_put/bear_call con annotazioni, decision history) — greche
|
||||
live e force-close differiti
|
||||
5. ✅ Pagina History (filtri window/reason/winners-losers, KPI strip,
|
||||
CSV export)
|
||||
6. ✅ Pagina Audit (live log stream, chain verify, event filter)
|
||||
7. ✅ Consumer `runtime/manual_actions_consumer.py` con job APScheduler
|
||||
`*/1` per arm/disarm (force_close = `not_supported` per ora)
|
||||
8. ⏳ Test integration con `streamlit.testing.v1.AppTest`
|
||||
|
||||
Definition of Done:
|
||||
Definition of Done — stato:
|
||||
|
||||
- `cerbero-bite gui` lancia la dashboard su `127.0.0.1:8765`
|
||||
- Tutte le 5 pagine raggiungibili e popolate
|
||||
- Disarm da GUI loggato in audit chain ed effettivo entro 30 sec
|
||||
- Force-close da GUI consumato dall'engine entro 30 sec
|
||||
- Test integration su ogni pagina passing
|
||||
- ✅ `cerbero-bite gui` lancia la dashboard su `127.0.0.1:8765`
|
||||
- ✅ Tutte le 5 pagine raggiungibili e popolate
|
||||
- ✅ Disarm da GUI loggato in audit chain (`source="manual_gui"`) ed
|
||||
effettivo entro ~1 minuto
|
||||
- ⏳ Force-close da GUI: l'enqueue funziona ma l'orchestrator deve
|
||||
ancora esporre `handle_force_close`
|
||||
- ⏳ Test integration AppTest: non scritti
|
||||
|
||||
## Fase 5 — Reporting e UX (3-5 giorni)
|
||||
|
||||
@@ -197,7 +206,7 @@ Setup:
|
||||
|
||||
Metriche da raccogliere:
|
||||
|
||||
- Numero proposte settimanali emesse
|
||||
- Numero proposte giornaliere emesse
|
||||
- Quante passano i filtri
|
||||
- Win rate, avg P&L paper
|
||||
- Discrepanze tra mid stimato e fill reale (slippage)
|
||||
|
||||
+30
-2
@@ -24,8 +24,8 @@ asset:
|
||||
# === ENTRY ===
|
||||
|
||||
entry:
|
||||
# finestra di valutazione settimanale
|
||||
cron: "0 14 * * MON" # lunedì 14:00 UTC
|
||||
# finestra di valutazione giornaliera (crypto 24/7)
|
||||
cron: "0 14 * * *" # ogni giorno 14:00 UTC
|
||||
skip_holidays_country: "IT"
|
||||
|
||||
# filtri di accesso (vedi 01-strategy-rules.md §2)
|
||||
@@ -307,3 +307,31 @@ Non è permesso parametrizzare:
|
||||
superiori, non ulteriormente liberalizzabili).
|
||||
- Lo **scheduler** per intervalli più stretti (un'ottimizzazione che
|
||||
non si fa via config).
|
||||
|
||||
## Variabili d'ambiente
|
||||
|
||||
`strategy.yaml` definisce **cosa** fa il bot quando è acceso. Le
|
||||
variabili d'ambiente in `.env` definiscono **come** si collega al
|
||||
mondo esterno e **quali interruttori operativi** sono attivi.
|
||||
Queste vivono fuori da `strategy.yaml` perché cambiano per ambiente
|
||||
(testnet vs mainnet, soak vs trading) ma non per regola di strategia.
|
||||
|
||||
| Variabile | Tipo | Default | Uso |
|
||||
|---|---|---|---|
|
||||
| `CERBERO_BITE_MCP_TOKEN` | string (obbligatoria) | — | Bearer token presentato a Cerbero MCP V2. Il valore decide l'ambiente upstream (testnet o mainnet). Cambia il valore = cambia l'ambiente. |
|
||||
| `CERBERO_BITE_MCP_BOT_TAG` | string ≤ 64 char | `BOT__CERBERO_BITE` | Header `X-Bot-Tag` registrato nell'audit log del server MCP per ogni write. |
|
||||
| `CERBERO_BITE_MCP_DERIBIT_URL` | URL | gateway pubblico | Override URL router Deribit. |
|
||||
| `CERBERO_BITE_MCP_HYPERLIQUID_URL` | URL | gateway pubblico | Override URL router Hyperliquid. |
|
||||
| `CERBERO_BITE_MCP_MACRO_URL` | URL | gateway pubblico | Override URL router Macro. |
|
||||
| `CERBERO_BITE_MCP_SENTIMENT_URL` | URL | gateway pubblico | Override URL router Sentiment. |
|
||||
| `CERBERO_BITE_ENABLE_DATA_ANALYSIS` | bool (`true`/`false`) | `true` | Abilita il job `market_snapshot` (raccolta dati MCP ogni 15 min). |
|
||||
| `CERBERO_BITE_ENABLE_STRATEGY` | bool (`true`/`false`) | `false` | Abilita i job `entry` e `monitor` (esecuzione regole §2-§9). |
|
||||
| `CERBERO_BITE_TELEGRAM_BOT_TOKEN` | string | — | Token bot Telegram (notify-only). Senza, il client è in modalità disabled. |
|
||||
| `CERBERO_BITE_TELEGRAM_CHAT_ID` | string | — | Chat ID destinatario notifiche Telegram. |
|
||||
|
||||
I valori bool accettano in input `1`/`0`, `true`/`false`, `yes`/`no`,
|
||||
`on`/`off`, `enabled`/`disabled` (case-insensitive). Qualunque altro
|
||||
valore fa fallire il boot con `ValueError`.
|
||||
|
||||
Vedi `06-operational-flow.md` §"Modalità operativa" per i profili
|
||||
canonici di `ENABLE_DATA_ANALYSIS` e `ENABLE_STRATEGY`.
|
||||
|
||||
+183
-134
@@ -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 (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.
|
||||
|
||||
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 (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 |
|
||||
|---|---|
|
||||
| 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.
|
||||
|
||||
@@ -0,0 +1,699 @@
|
||||
# 13 — Strategia spiegata: dalle regole ai dati
|
||||
|
||||
> Documento operativo che lega ogni decisione del rule engine al dato
|
||||
> osservabile da cui dipende. Pensato per chi guarda il cruscotto e
|
||||
> vuole capire **a cosa servono** le metriche raccolte ogni 15 minuti
|
||||
> nella tabella `market_snapshots`. La versione canonica e immutabile
|
||||
> delle regole resta in `01-strategy-rules.md`; questo documento è la
|
||||
> guida descrittiva da leggere prima di toccare le soglie in
|
||||
> `strategy.yaml`.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
Cerbero Bite vende **credit spread su ETH/Deribit (DTE 14-21, valutati ogni giorno)** quando
|
||||
la volatilità implicita è **abbastanza alta da pagare bene**, il
|
||||
mercato non è in **stress di liquidazione**, non ci sono **eventi macro
|
||||
forti** in finestra, e il bias direzionale è **chiaro** (bull o bear).
|
||||
Tutto il resto del tempo, l'engine **non opera**: la disciplina è la
|
||||
strategia.
|
||||
|
||||
Ogni 15 minuti raccoglie 1 riga per asset (ETH e BTC) nella tabella
|
||||
`market_snapshots`. Quei dati alimentano tre obiettivi distinti:
|
||||
|
||||
1. **Decisione live** — l'entry ciclo daily alle 14:00 UTC legge i
|
||||
campi più freschi per dire "go/no-go" (crypto è 24/7: la cadenza
|
||||
non è gateata sulla settimana, decidono i gate quantitativi).
|
||||
2. **Monitoring continuo** — il decision loop di gestione attiva
|
||||
confronta la situazione con quella all'apertura.
|
||||
3. **Calibrazione** — la pagina `📐 Calibrazione` usa la distribuzione
|
||||
storica di ciascun campo per scegliere soglie basate sui percentili
|
||||
reali del proprio ambiente, non a istinto.
|
||||
|
||||
---
|
||||
|
||||
## 1. Cosa c'è in `market_snapshots` (1 riga ogni 15 min, per asset)
|
||||
|
||||
| Campo | Unità | Sorgente MCP | A che serve nella strategia |
|
||||
|---|---|---|---|
|
||||
| `timestamp` | UTC ISO | scheduler | indicizzazione della time-series |
|
||||
| `asset` | ETH / BTC | scheduler | partizionamento (ETH = sottostante operativo, BTC = controllo macro) |
|
||||
| `spot` | USD | mcp-deribit `spot_perp_price` | trend 30g (§3.1), distanza % strike (§3.2/3.3), context generale |
|
||||
| `dvol` | indice 0–200 | mcp-deribit `latest_dvol` | gate entry §2.3-§2.4 (35 ≤ DVOL ≤ 90), aggiustamento sizing §5.3, vol-stop §7.3 |
|
||||
| `realized_vol_30d` | % annualizzata | mcp-deribit `realized_vol` | confronto con DVOL → mean-reversion edge |
|
||||
| `iv_minus_rv` | punti vol | derivato | richness della IV: > 0 = premio "ricco" da vendere |
|
||||
| `funding_perp_annualized` | frazione | mcp-hyperliquid `funding_rate_annualized` | gate entry §2.6 (\|f\| ≤ 80% annualizzato), bias §3.1 |
|
||||
| `funding_cross_annualized` | frazione | mcp-sentiment `funding_cross_median_annualized` | bias direzionale §3.1 (mediana 4 maggiori exchange) |
|
||||
| `dealer_net_gamma` | USD | mcp-deribit `dealer_gamma_profile` | filtro quant §2.8 (long-gamma regime sopprime la vol → ideale per vendere spread) |
|
||||
| `gamma_flip_level` | USD | mcp-deribit `dealer_gamma_profile` | livello spot oltre il quale il regime di gamma flippa |
|
||||
| `oi_delta_pct_4h` | % | mcp-sentiment `liquidation_heatmap` | proxy di accumulo/sgonfiaggio leverage nelle ultime 4h |
|
||||
| `liquidation_long_risk` | low / med / high | mcp-sentiment `liquidation_heatmap` | rischio long squeeze imminente |
|
||||
| `liquidation_short_risk` | low / med / high | mcp-sentiment `liquidation_heatmap` | rischio short squeeze imminente |
|
||||
| `macro_days_to_event` | giorni | mcp-macro `next_high_severity_within` | gate §2.5 (no entry se evento macro entro DTE) |
|
||||
| `fetch_ok` | bool | scheduler | qualità riga (true = tutte le sotto-chiamate sono andate) |
|
||||
| `fetch_errors_json` | json o NULL | scheduler | mappa errori per debugging best-effort |
|
||||
|
||||
> Un campo `NULL` non invalida la riga: la collezione è
|
||||
> **best-effort**, una MCP giù non blocca le altre. Le distribuzioni
|
||||
> si calcolano sui campi disponibili, l'engine entry-cycle invece
|
||||
> rifiuta l'entry se il dato che gli serve è `NULL` (sicurezza:
|
||||
> meglio saltare un trade che operare alla cieca).
|
||||
|
||||
---
|
||||
|
||||
## 2. Le sei famiglie di dati e il "perché"
|
||||
|
||||
### 2.1 — Volatilità implicita (DVOL, realized vol, IV−RV)
|
||||
|
||||
**Cosa misura.** DVOL è l'indice Deribit della IV ETM 30g. Realized
|
||||
30g è la deviazione standard annualizzata dei rendimenti spot. La
|
||||
differenza `IV − RV` quantifica quanto le opzioni stanno **pagando
|
||||
sopra** la volatilità che il mercato ha effettivamente realizzato:
|
||||
**questa è la materia prima del credit spread venditore.**
|
||||
|
||||
**Come la usa l'engine.**
|
||||
|
||||
- §2.3 / §2.4: `dvol_min = 35`, `dvol_max = 90` — sotto 35 il premio
|
||||
è troppo magro rispetto a fees+slippage, sopra 90 si è in
|
||||
stress-regime (rischio gap > edge).
|
||||
- §5.3: `dvol_adjustment` riduce la size all'aumentare del DVOL
|
||||
(×1.0 sotto 45, ×0.85 fra 45–60, ×0.65 fra 60–80, no entry > 80).
|
||||
- §7.3: `vol_stop_dvol_increase = 10` — se durante la posizione
|
||||
DVOL sale di 10 punti rispetto all'entry, si chiude.
|
||||
|
||||
**Cosa si calibra dai dati raccolti.** Un mese di tick ti dà la
|
||||
distribuzione di DVOL nel TUO regime (testnet vs mainnet, bull vs
|
||||
bear). I percentili P25/P50/P75 nella pagina `📐 Calibrazione`
|
||||
dicono se 35 è davvero il "fondo" o se andrebbe alzato.
|
||||
|
||||
### 2.2 — Funding rate (perpetual + cross-exchange median)
|
||||
|
||||
**Cosa misura.** Il funding annualizzato del perpetual ETH-PERP
|
||||
(Hyperliquid principalmente) e la mediana dei funding sui 4 maggiori
|
||||
exchange. Il funding è la fee periodica che paga il lato sbilanciato
|
||||
del perp: **è il termometro più diretto del posizionamento leveraged
|
||||
del mercato.**
|
||||
|
||||
**Come la usa l'engine.**
|
||||
|
||||
- §2.6: `funding_perp_abs_max_annualized = 0.80` — funding > 80%
|
||||
annualizzato (in valore assoluto) = liquidazioni a cascata
|
||||
imminenti, no entry.
|
||||
- §3.1: il **bias direzionale** dipende dal funding cross:
|
||||
- `funding_bull_threshold_annualized = 0.20` ⇒ bias bull se
|
||||
cross-funding ≥ +20%.
|
||||
- `funding_bear_threshold_annualized = -0.20` ⇒ bias bear se
|
||||
≤ -20%.
|
||||
- In mezzo + trend neutro = candidato Iron Condor.
|
||||
- Trend e funding discordi = no entry.
|
||||
|
||||
**Perché due funding diversi.** Il perp di Hyperliquid è il segnale
|
||||
"è esecutibile la chiusura?" (l'ETH-PERP è la sede di hedge
|
||||
pratico). La mediana cross-exchange è il segnale macro
|
||||
"dove sta il mercato globale": più robusta a manipolazioni o picchi
|
||||
locali.
|
||||
|
||||
### 2.3 — Dealer gamma (net gamma + flip level)
|
||||
|
||||
**Cosa misura.** L'esposizione netta di gamma dei dealer di opzioni
|
||||
su Deribit, ricostruita da OI per strike e direzione. Quando
|
||||
`dealer_net_gamma > 0` (long gamma), i dealer **sopprimono** la
|
||||
volatilità realizzata col loro hedge (vendono salendo, comprano
|
||||
scendendo). Quando è negativo, **amplificano** ogni movimento.
|
||||
|
||||
**Come la usa l'engine.**
|
||||
|
||||
- §2.8: `dealer_gamma_min = 0`, `dealer_gamma_filter_enabled = true`
|
||||
— entry solo in regime long-gamma. Vendere credit spread con
|
||||
dealer corto-gamma è statisticamente perdente.
|
||||
- `gamma_flip_level` è il prezzo spot al quale il regime cambierebbe.
|
||||
Se siamo a 1% dal flip, il margine di sicurezza è basso anche se
|
||||
il segno è positivo.
|
||||
|
||||
**Cosa si calibra dai dati raccolti.** La distribuzione di
|
||||
`dealer_net_gamma` nel proprio universo (qualche miliardo USD su
|
||||
mainnet, ordini di grandezza diversi su testnet) suggerisce se
|
||||
`min = 0` è troppo permissivo — su mainnet è frequente che il segno
|
||||
si trovi positivo per molto tempo, qui ha senso una soglia più alta.
|
||||
|
||||
### 2.4 — Liquidation heatmap (OI delta + long/short squeeze risk)
|
||||
|
||||
**Cosa misura.** Da `mcp-sentiment`:
|
||||
|
||||
- `oi_delta_pct_4h`: variazione % dell'open interest aggregato nelle
|
||||
ultime 4h. Spike positivo → leverage in entrata (rischio fragile);
|
||||
spike negativo → squeeze appena avvenuta.
|
||||
- `liquidation_long_risk` / `liquidation_short_risk`: classificazione
|
||||
qualitativa (`low` / `med` / `high`) della densità di livelli di
|
||||
liquidazione vicini allo spot.
|
||||
|
||||
**Come la usa l'engine.**
|
||||
|
||||
- §2.8 (`liquidation_filter_enabled = true`): l'entry cycle scarta
|
||||
setup con `_risk = high` sul lato che ci interesserebbe (es. un
|
||||
bull put spread in regime di `long_risk = high` è esposto a un
|
||||
long-squeeze giù).
|
||||
- Anche fuori dall'entry, queste due colonne servono come "filtro di
|
||||
realtà" per il monitoring: se durante la posizione lo squeeze risk
|
||||
cambia da low a high, è un primo segnale di vol-stop in arrivo.
|
||||
|
||||
### 2.5 — Macro calendar (giorni al prossimo evento)
|
||||
|
||||
**Cosa misura.** `mcp-macro` restituisce il numero di giorni al
|
||||
prossimo evento ad alta severità (FOMC, CPI USA, NFP, ECB, Powell
|
||||
speech) per US/EU. `NULL` = nessun evento entro la finestra DTE.
|
||||
|
||||
**Come la usa l'engine.**
|
||||
|
||||
- §2.5: se `macro_days_to_event ≤ dte_target = 18`, no entry. Le
|
||||
uscite macro si trasformano in gap di volatilità che mangiano in
|
||||
un'ora il credito di tre settimane.
|
||||
- Le entry sono comunque possibili poco dopo l'evento (vol elevata
|
||||
appena dopo + RV destinata a comprimersi → IV−RV alto = setup di
|
||||
scuola).
|
||||
|
||||
### 2.6 — Spot ETH (e BTC come controllo)
|
||||
|
||||
**Cosa misura.** Prezzo last/perp di ETH (e BTC come controllo).
|
||||
|
||||
**Come la usa l'engine.**
|
||||
|
||||
- §3.1: trend 30g calcolato come `(spot_now / spot_30g_ago - 1)`.
|
||||
Soglie ±5% definiscono bias bull / bear / neutro.
|
||||
- §3.2: distanza % degli strike short dallo spot (15–25% OTM).
|
||||
- §7.6: `adverse_move_4h_pct = 0.05` — close su movimento contrario
|
||||
≥ 5% in 4h.
|
||||
|
||||
**Perché anche BTC.** ETH è il sottostante operativo, BTC è il
|
||||
**termometro macro crypto**: in regimi di alta correlazione, un
|
||||
movimento BTC che ETH non sta seguendo è un segnale di divergenza che
|
||||
spesso precede un riallineamento brusco.
|
||||
|
||||
---
|
||||
|
||||
## 3. Il flusso decisionale, allineato al dato
|
||||
|
||||
Quanto segue è la versione "leggibile" delle regole §2-§9 di
|
||||
`01-strategy-rules.md`. Ogni passo cita i campi di
|
||||
`market_snapshots` che lo alimentano.
|
||||
|
||||
### Fase 1 — Trigger (daily 14:00 UTC, festività italiane escluse se `skip_holidays_country` è on)
|
||||
|
||||
```
|
||||
SE NESSUNA posizione aperta
|
||||
E capitale ≥ 720 USD
|
||||
E 35 ≤ dvol ≤ 90 # market_snapshots.dvol
|
||||
E |funding_perp_annualized| ≤ 0.80 # market_snapshots.funding_perp_annualized
|
||||
E macro_days_to_event > dte_target (oppure NULL) # market_snapshots.macro_days_to_event
|
||||
E ETH holdings cerbero-portfolio ≤ 30%
|
||||
E (filtri quant: dealer_net_gamma > 0,
|
||||
liquidation_*_risk ≠ high) # market_snapshots.dealer_net_gamma + liquidation_*
|
||||
ALLORA
|
||||
procedi alla Fase 2
|
||||
ALTRIMENTI
|
||||
no entry, log motivo, ritento il giorno successivo
|
||||
```
|
||||
|
||||
### Fase 2 — Bias e struttura
|
||||
|
||||
```
|
||||
trend_30g = spot_now / spot_30g_ago - 1 # market_snapshots.spot
|
||||
funding_x = funding_cross_annualized # market_snapshots.funding_cross_annualized
|
||||
|
||||
SE trend_30g ≥ +5% E funding_x ≥ +20%:
|
||||
struttura = Bull Put Spread
|
||||
SE trend_30g ≤ -5% E funding_x ≤ -20%:
|
||||
struttura = Bear Call Spread
|
||||
SE |trend_30g| < 5% E |funding_x| < 20%
|
||||
E dvol ≥ 55 E ADX(14) < 20:
|
||||
struttura = Iron Condor
|
||||
ALTRIMENTI:
|
||||
no entry (mercato indeciso o discordante)
|
||||
```
|
||||
|
||||
### Fase 3 — Selezione strike (delta-target + distanza % spot)
|
||||
|
||||
Lo strike short è quello a delta target ≈ 0.12 (tolleranza 0.10–0.15)
|
||||
**e** OTM 15–25%. Lo strike long è a 4% del spot (3–5% accettabile).
|
||||
Tutti i numeri sono parametrizzati in `strategy.yaml > structure`.
|
||||
Lo `spot` corrente per il calcolo viene da `market_snapshots.spot`.
|
||||
|
||||
### Fase 4 — Sizing (Kelly frazionario + cap aggregato + DVOL clamp)
|
||||
|
||||
```
|
||||
risk_target = capitale * 0.13 # quarter Kelly
|
||||
risk_target = min(risk_target, 200 EUR) # cap per-trade
|
||||
n = floor(risk_target / max_loss_per_contract)
|
||||
n = min(n, 4, vincolo aggregato 1000 EUR)
|
||||
n = round_down(n * dvol_multiplier) # market_snapshots.dvol → §5.3
|
||||
```
|
||||
|
||||
### Fase 5 — Esecuzione (combo limit GTC al mid)
|
||||
|
||||
Limit al mid del combo, riprezzamento +1 tick / 30min fino a 3 step.
|
||||
Su trigger urgenti (CLOSE_STOP / CLOSE_VOL / CLOSE_DELTA) l'engine
|
||||
accetta fino a 5 step di slippage perché l'urgenza prevale sul
|
||||
prezzo.
|
||||
|
||||
### Fase 6 — Monitoring (cron di gestione attiva, default ogni 12h)
|
||||
|
||||
Per ogni posizione aperta, in **ordine** (primo trigger vince):
|
||||
|
||||
| # | Trigger | Dato sorgente |
|
||||
|---|---|---|
|
||||
| 1 | Profit take: mark ≤ 50% credito | combo mark via deribit |
|
||||
| 2 | Stop loss: mark ≥ 250% credito | combo mark via deribit |
|
||||
| 3 | Vol stop: dvol_now ≥ dvol_entry + 10 | `market_snapshots.dvol` |
|
||||
| 4 | Time stop: dte ≤ 7 (skip se ≥ 70% profit) | scadenza struttura |
|
||||
| 5 | Delta breach: \|delta_short\| ≥ 0.30 | option chain via deribit |
|
||||
| 6 | Adverse move: \|return_4h_ETH\| ≥ 5% contro | `market_snapshots.spot` |
|
||||
| 7 | Altrimenti | HOLD |
|
||||
|
||||
Il monitoring NON consulta `market_snapshots` per i prezzi opzioni
|
||||
(legge live), ma li consulta per `dvol` e `spot` con il vantaggio di
|
||||
una serie storica già normalizzata e auditabile.
|
||||
|
||||
---
|
||||
|
||||
## 4. Cosa fa OGGI il bot in modalità "data-only"
|
||||
|
||||
Il bot oggi è in **modalità raccolta dati** (`ENABLE_DATA_ANALYSIS=true`,
|
||||
`ENABLE_STRATEGY=false`). Vuol dire:
|
||||
|
||||
- Il job `market_snapshot` (cron `*/15`) gira: scrive nuove righe in
|
||||
SQLite, alimenta calibrazione e monitoring storico.
|
||||
- Il job `health` (`*/5`) verifica disponibilità MCP e ambiente
|
||||
Deribit; alza il kill switch se qualcosa non torna.
|
||||
- Il job `backup` (`0 *`) snapshotta lo stato ogni ora.
|
||||
- Il job `manual_actions` (`*/1`) consuma comandi dalla GUI.
|
||||
- I cicli `entry` e `monitor` **non sono nemmeno schedulati**: nessun
|
||||
ordine può partire, nessuno strike viene letto.
|
||||
|
||||
Quando si vuole passare alla fase operativa (paper trading o
|
||||
mainnet), basta:
|
||||
|
||||
1. Riempire `strategy.yaml` con le **soglie calibrate** sui
|
||||
percentili reali della pagina `📐 Calibrazione` (non lasciare i
|
||||
valori default a istinto).
|
||||
2. Bumpare `config_version` + rigenerare `config_hash` con
|
||||
`cerbero-bite config hash --file strategy.yaml`.
|
||||
3. Settare `ENABLE_STRATEGY=true` in `.env` e ricreare il container.
|
||||
4. Disarmare il kill switch da GUI o CLI con motivazione esplicita.
|
||||
5. **Una settimana di paper trading** (mainnet con ordini disabilitati
|
||||
o testnet) prima di alzare il flag definitivo.
|
||||
|
||||
---
|
||||
|
||||
## 4-bis. P/L atteso (realistico)
|
||||
|
||||
I numeri qui sotto sono **stime ex-ante**, non promesse. Servono ad
|
||||
allineare le aspettative con la geometria della strategia: capire
|
||||
**quanto poco si rischia per trade**, **quanto raramente si entra**, e
|
||||
**perché l'edge è strutturalmente sottile**.
|
||||
|
||||
> **Domanda onesta che chiunque guardi i numeri dovrebbe farsi:** se a
|
||||
> win-rate 70–72% l'aspettativa per trade è circa zero, **che senso ha
|
||||
> la strategia?**
|
||||
>
|
||||
> **Risposta:** il selling vol nudo è effettivamente neutro a quel
|
||||
> win-rate. **L'edge della Cerbero Bite non è "vendere vol"; è
|
||||
> "vendere vol solo quando i filtri quant alzano il win-rate sopra il
|
||||
> 75%".** I gate §2 (DVOL band, dealer gamma > 0, no macro entro DTE,
|
||||
> liquidation risk ≠ high, bias trend × funding concorde) sono
|
||||
> **costruiti per saltare proprio le finestre statisticamente
|
||||
> perdenti** e operare solo in quelle favorevoli. La pagina
|
||||
> `📚 Strategia` ha una tabella di sensibilità che mostra come l'APR
|
||||
> passa da ≈0% (win 0.72) a +3-5% (win 0.78–0.80): è esattamente la
|
||||
> distanza che i filtri devono coprire. Per questo i primi giorni di
|
||||
> raccolta dati servono a **misurare** se i filtri stanno effettivamente
|
||||
> alzando il win-rate prima di committare capitale.
|
||||
|
||||
### Per singolo trade (riferimento: ETH spot ≈ 3000 USD)
|
||||
|
||||
| Voce | Formula / fonte | Valore tipico |
|
||||
|---|---|---|
|
||||
| Larghezza spread | 4% × spot | **120 USD / contratto** |
|
||||
| Credito incassato | ≥ 30% × larghezza | **36–48 USD / contratto** |
|
||||
| Max profit teorico | = credito (a scadenza OTM) | 36–48 USD / contratto |
|
||||
| **Profit-take §7.1 (50% credito)** | 0.5 × credito | **+18–24 USD / contratto** |
|
||||
| **Stop-loss §7.2 (mark = 2.5× credito)** | 1.5 × credito | **−54–72 USD / contratto** |
|
||||
| Margine bloccato | ≈ larghezza | 120 USD / contratto |
|
||||
| Fees Deribit | 0.03% notional × 2 leg | ~1–2 USD / contratto / trade |
|
||||
|
||||
> Su spot più basso (2000 USD) la larghezza scende a 80 USD/contratto
|
||||
> e i numeri assoluti seguono proporzionalmente.
|
||||
|
||||
### Sizing tipico vs capitale
|
||||
|
||||
Il sizing è governato dal Quarter-Kelly **+ cap per-trade 200 EUR
|
||||
(~215 USD)**. Sopra una certa soglia, il cap domina: alzare il
|
||||
capitale **non aumenta** i contratti per trade.
|
||||
|
||||
| Capitale | risk_target (Kelly) | risk effettivo (post-cap) | Contratti tipici (spot=3000) |
|
||||
|---|---|---|---|
|
||||
| 720 USD (minimo) | 94 USD | 94 USD | **0–1** (entry spesso saltata per sizing) |
|
||||
| 1 500 USD | 195 USD | 195 USD | **1** |
|
||||
| 3 000 USD | 390 USD | **215 USD** (cap) | **1** |
|
||||
| 10 000 USD | 1 300 USD | **215 USD** (cap) | **1** |
|
||||
| 50 000 USD+ | 6 500 USD | **215 USD** (cap) | **1** (cap aggregato 1 075 USD = max 4 trade aperti, ma `max_concurrent_positions: 1`) |
|
||||
|
||||
> Con i cap correnti la strategia è **dimensionata per capitale
|
||||
> piccolo (1.5–10 k USD)**: oltre, il rendimento sul totale scala
|
||||
> sotto-lineare e tende a zero.
|
||||
|
||||
### Frequenza realistica di entry
|
||||
|
||||
La regola si valuta **una volta al giorno** (crypto è 24/7), ma la
|
||||
maggioranza dei giorni viene saltata per:
|
||||
|
||||
| Motivo di skip | Frequenza tipica |
|
||||
|---|---|
|
||||
| DVOL fuori banda (35–90) | 25–40% |
|
||||
| Bias non chiaro (trend × funding discordi o entrambi neutri senza IC) | 25–35% |
|
||||
| Macro entro DTE | 10–20% |
|
||||
| Funding o liquidation risk fuori soglia | 5–15% |
|
||||
| Capitale, sizing insufficiente o concurrency cap raggiunto | 5–15% |
|
||||
|
||||
**Risultato netto: ~30–40% dei giorni finisce in entry effettiva
|
||||
⇒ 110–145 trade / anno** (365 candidature × pass-rate, capped da
|
||||
`max_concurrent_positions`). I restanti giorni il bot sta fermo:
|
||||
è il design — la disciplina è la strategia.
|
||||
|
||||
### Win-rate atteso (short delta 0.12 + profit-take 50%)
|
||||
|
||||
Letteratura e backtest su credit spread short delta 0.10–0.15 con
|
||||
TP@50% e SL@1.5×:
|
||||
|
||||
| Esito | Probabilità tipica | Risultato |
|
||||
|---|---|---|
|
||||
| Profit-take a 50% credito | **~70–75%** | +18–24 USD/contratto |
|
||||
| Stop-loss a 1.5× credito | ~15–20% | −54–72 USD/contratto |
|
||||
| Time-stop o exit DTE 7g | ~5–10% | piccolo positivo (~+5–10 USD) |
|
||||
| Vol/delta/macro stop | ~3–5% | variabile, mediamente neutro |
|
||||
|
||||
Atteso medio per contratto:
|
||||
|
||||
```
|
||||
E[trade] ≈ 0.72 × 21 + 0.18 × (-63) + 0.07 × 7 + 0.03 × 0
|
||||
≈ 15.1 − 11.3 + 0.5 + 0
|
||||
≈ +4.3 USD lordi / contratto
|
||||
```
|
||||
|
||||
**Al netto di fees (~1.5 USD round-trip) e slippage (~5% del credito
|
||||
≈ 2 USD): E[trade] ≈ +1–3 USD per contratto.**
|
||||
|
||||
### Proiezione annuale (1 contratto medio per trade)
|
||||
|
||||
| Scenario | Trade/anno | E[trade] netto | P/L lordo annuo | Su capitale 1 500 USD | Su capitale 3 000 USD |
|
||||
|---|---|---|---|---|---|
|
||||
| **Pessimistico** (vol bassa, regime bear vol) | 12 | +1 USD | **+12 USD** | +0.8% | +0.4% |
|
||||
| **Realistico medio** | 18 | +2.5 USD | **+45 USD** | +3% | +1.5% |
|
||||
| **Buono** (regime favorevole, IV−RV alto) | 22 | +4 USD | **+88 USD** | +5.9% | +2.9% |
|
||||
| **Eccellente** (cherry-picking ex-post) | 25 | +6 USD | **+150 USD** | +10% | +5% |
|
||||
|
||||
**Realisticamente: +1.5% / +5% APR sul capitale totale**, con i cap
|
||||
correnti. È in linea con la letteratura su short-vol systematic con
|
||||
disciplina di stop. **Non è una strategia "raddoppia il capitale".**
|
||||
È una strategia che vuole guadagnare il **premio di rischio della
|
||||
volatilità** in modo controllato.
|
||||
|
||||
### Drawdown e rischio coda
|
||||
|
||||
- **Streak realistico di perdite consecutive**: 3–5 stop-loss di fila
|
||||
capitano. Drawdown su 1 contratto: −150 / −300 USD assoluti.
|
||||
- **Su capitale 1 500 USD** = drawdown del 10–20% del capitale
|
||||
totale. Aspettarselo, è dentro il design.
|
||||
- **Tail risk:** un evento gap notturno (sentenza SEC, hack
|
||||
exchange, default importante) può portare il mark a 100% della
|
||||
larghezza prima che lo stop sia eseguibile. **Perdita massima
|
||||
reale per trade = larghezza intera** (`width - credit_iniziale`),
|
||||
cioè 72–96 USD/contratto, non i 54–72 USD del modello stop-loss.
|
||||
- I **filtri quant** (`dealer_gamma_min`, `liquidation_filter`) e
|
||||
il **macro filter** sono stati introdotti **per ridurre la coda**,
|
||||
non per migliorare l'aspettativa media.
|
||||
|
||||
### Sharpe atteso
|
||||
|
||||
Strategie short-vol sistematiche con disciplina hanno:
|
||||
|
||||
- **Sharpe 0.8–1.5** in regimi favorevoli (mercato lento + IV alta).
|
||||
- **Sharpe 0.3–0.8** in regimi normali.
|
||||
- **Sharpe negativo** in regimi di vol-of-vol (es. Q1 2020, Maggio
|
||||
2021, FTX week). I filtri li mitigano, non li annullano.
|
||||
|
||||
### Cosa cambia con `ENABLE_STRATEGY=true`
|
||||
|
||||
In modalità data-only (oggi) il P/L atteso è **0** — l'engine
|
||||
**non opera**. Il valore della raccolta di oggi è:
|
||||
|
||||
1. **Calibrare** soglie su percentili reali → P/L atteso più
|
||||
realistico al go-live.
|
||||
2. **Validare** i filtri quant osservando ex-post quanti tick
|
||||
sarebbero stati filtrati (vedi pagina `📐 Calibrazione`, colonna
|
||||
"% bloccato dalla soglia").
|
||||
3. **Misurare** la quota effettiva di giorni che superano i filtri
|
||||
nel proprio regime, prima di committare capitale.
|
||||
|
||||
> Suggerimento: 30 giorni di dati = 30 candidature × probabilità entry
|
||||
> ≈ 9–12 candidate entry effettive. **Aspettare almeno 60 giorni**
|
||||
> prima di tarare le soglie dà uno storico con dispersione
|
||||
> sufficiente per decisioni non-rumorose.
|
||||
|
||||
---
|
||||
|
||||
## 4-ter. Due profili: Conservativa vs Aggressiva
|
||||
|
||||
Il P/L del §4-bis assume i cap della golden config v1.0.0
|
||||
(`cap_per_trade_eur: 200`, `max_concurrent_positions: 1`,
|
||||
`max_contracts_per_trade: 4`). Su quel profilo il P/L assoluto è
|
||||
piccolo per design — la strategia è dimensionata come **macchina di
|
||||
conservazione del capitale** con premio modesto su T-bill.
|
||||
|
||||
Per chi vuole rendimenti significativi, il repo include un secondo
|
||||
file di config — `strategy.aggressiva.yaml` — che **deroga
|
||||
esplicitamente** alla §11 di `01-strategy-rules.md` allargando le tre
|
||||
leve dominanti:
|
||||
|
||||
| Leva | Conservativa | Aggressiva | Effetto sul P/L |
|
||||
|---|---|---|---|
|
||||
| `cap_per_trade_eur` | 200 | **800** | 4× la size per trade |
|
||||
| `cap_aggregate_open_eur` | 1 000 | **3 200** | 4× il rischio aggregato |
|
||||
| `max_concurrent_positions` | 1 | **2** | 2× le posizioni aperte simultanee |
|
||||
| `max_contracts_per_trade` | 4 | **16** | toglie il vincolo aggregato anche su capitali maggiori |
|
||||
| `kelly_fraction` | 0.13 | **0.13** | invariato (la disciplina Kelly resta) |
|
||||
| Filtri quant (gamma, liquidation, macro) | ON | **ON** | invariati (l'edge è qui, non si tocca) |
|
||||
|
||||
**Risultato atteso (a parità di filtri e win-rate):** P/L ≈ 4–8× il
|
||||
profilo conservativo. Drawdown atteso scala con lo stesso fattore
|
||||
(20–40% del capitale impiegato in streak avverse, contro 10–20% del
|
||||
conservativo). La pagina `📚 Strategia` ha un pannello affiancato che
|
||||
calcola entrambi sugli stessi slider.
|
||||
|
||||
**Il rovescio della medaglia.**
|
||||
|
||||
- La deroga alla §11 va **autorizzata esplicitamente** nel commit che
|
||||
switcha la config; tre settimane di paper trading dedicato sono
|
||||
raccomandate.
|
||||
- Il drawdown maggiore richiede capitale "growth", non capitale di
|
||||
parcheggio.
|
||||
- I filtri quant restano **identici** — non c'è "più aggressivo" sui
|
||||
trigger di entry, perché lì non c'è alpha da spremere senza
|
||||
peggiorare il win-rate.
|
||||
|
||||
**Multi-asset (ETH + BTC) — caveat.**
|
||||
|
||||
L'ulteriore moltiplicatore 2× citato nel §4-bis (multi-asset) **non è
|
||||
abilitato** dalla sola modifica della config: il rule engine attuale è
|
||||
single-asset (`asset.symbol`). Per estenderlo servono modifiche in:
|
||||
|
||||
- `cerbero_bite/runtime/entry_cycle.py` (loop sui simboli)
|
||||
- `cerbero_bite/state/repository.py` (multi-position chiave per asset)
|
||||
- `cerbero_bite/runtime/orchestrator.py` (scheduler one-asset → N)
|
||||
|
||||
Il job di raccolta dati è già multi-asset (`DEFAULT_ASSETS = ("ETH",
|
||||
"BTC")`), quindi tutto il dataset utile per validare l'estensione è
|
||||
già disponibile. È un lavoro di codice ben circoscritto, da fare in
|
||||
un branch dedicato dopo che il dataset di calibrazione è abbondante.
|
||||
|
||||
**Quando passare dal profilo conservativo all'aggressivo.**
|
||||
|
||||
Solo se **tutte** le seguenti sono vere:
|
||||
|
||||
1. ≥ 8 settimane di dati raccolti su mainnet (≥ ~2k snapshot).
|
||||
2. Win-rate empirico misurato (paper trading o backtest sui tick
|
||||
raccolti) **≥ 0.75**.
|
||||
3. APR atteso del profilo aggressivo (vedi pannello GUI) **≥ 8%**
|
||||
netto a quel win-rate.
|
||||
4. Capitale impegnato è **growth capital**, non riserva tattica.
|
||||
5. Sopporti emotivamente un drawdown a doppia cifra senza disarmare
|
||||
manualmente la strategia in mezzo a una streak.
|
||||
|
||||
Se anche solo uno dei 5 manca → **resta sulla conservativa**, è
|
||||
quella che il sistema parte ad eseguire.
|
||||
|
||||
---
|
||||
|
||||
## 4-quater. IV richness gate (§2.9): il filtro che alza il win-rate
|
||||
|
||||
Il filtro a maggior impatto sull'edge è anche il più semplice da
|
||||
descrivere: **non vendere vol quando la IV non sta pagando un margine
|
||||
misurabile sopra la RV**. È implementato come gate hard nel
|
||||
`validate_entry`:
|
||||
|
||||
```
|
||||
if iv_minus_rv_filter_enabled and iv_minus_rv < iv_minus_rv_min:
|
||||
skip entry
|
||||
```
|
||||
|
||||
con due parametri in `entry:` di `strategy.yaml`:
|
||||
|
||||
| Parametro | Default | Effetto |
|
||||
|---|---|---|
|
||||
| `iv_minus_rv_filter_enabled` | `false` (golden) / `true` (aggressiva) | Master switch del gate |
|
||||
| `iv_minus_rv_min` | `0` (golden) / `3` (aggressiva) | Soglia in punti vol che IV30g − RV30g deve eccedere |
|
||||
|
||||
Il dato è già raccolto in `market_snapshots.iv_minus_rv` ogni 15
|
||||
minuti. Il gate consulta l'ultimo tick disponibile al momento
|
||||
dell'entry cycle (non un percentile rolling — quello è il prossimo
|
||||
step di calibrazione, vedi §4-quinquies in roadmap).
|
||||
|
||||
**Profili di default ragionati.**
|
||||
|
||||
- **Conservativa / golden config**: `enabled=false, min=0`. Tutti i
|
||||
setup passano questo gate, anche con IV-RV negativa. Motivo: nei
|
||||
primi 60 giorni non si hanno abbastanza tick per stabilire che
|
||||
soglia ha senso nel proprio regime. Lasciamo la pagina
|
||||
`📐 Calibrazione` mostrare la distribuzione e poi alziamo
|
||||
manualmente.
|
||||
- **Aggressiva**: `enabled=true, min=3`. Il profilo aggressivo già di
|
||||
suo prende size più grande; pretendere `IV-RV ≥ 3 vol points` come
|
||||
prerequisito è coerente — se stai betting più grosso, vuoi
|
||||
win-rate più alto. La soglia 3 è conservativa; la letteratura
|
||||
short-vol systematic suggerisce 5 dopo calibrazione.
|
||||
|
||||
**Cosa cambia nel P/L atteso quando attivi il gate.**
|
||||
|
||||
Il gate **riduce** il numero di entry (saltiamo settimane con premio
|
||||
magro) ma **alza** la qualità di quelle che passano (premio ricco =
|
||||
win-rate empirico più alto). Effetto netto sul P/L annuo:
|
||||
|
||||
- Trade/anno: 18 → 12-14 (skip più aggressivo)
|
||||
- Win-rate atteso: 0.72 → 0.78-0.80
|
||||
- E[trade] netto: +0.6 USD → +4-6 USD per contratto
|
||||
- **P/L annuo proiettato sale anche se i trade scendono**, perché
|
||||
ogni trade ha edge più alto.
|
||||
|
||||
La pagina `📚 Strategia` ha lo slider win-rate già coerente con
|
||||
questa logica: muovi da 0.72 a 0.78 e vedi l'APR scattare.
|
||||
|
||||
**Roadmap di hardening (passi successivi al merge di questo PR).**
|
||||
|
||||
1. **Soglia adattiva**: sostituire `iv_minus_rv_min: 3` con un valore
|
||||
calcolato a runtime come `P25 rolling 60d` di `market_snapshots.iv_minus_rv`.
|
||||
2. **Vol-of-vol guard**: bloccare entry quando `dvol` è cambiato di
|
||||
≥5 punti nelle ultime 24h, anche se `iv_minus_rv` è alto (regime
|
||||
instabile).
|
||||
3. **Multi-asset (ETH+BTC)**: come da §4-ter, sblocca il
|
||||
moltiplicatore 2× sulle opportunità a parità di filtri.
|
||||
|
||||
## 4-quinquies. Catena opzioni storica (Phase 5)
|
||||
|
||||
In aggiunta a `market_snapshots` (cron `*/15`), il bot raccoglie
|
||||
ora una seconda fonte di dati: la **catena opzioni Deribit completa**
|
||||
ogni 15 minuti (cron `*/15`, allineato a `market_snapshots` — crypto
|
||||
è 24/7, l'accumulo dataset deve essere continuo).
|
||||
|
||||
Tabella `option_chain_snapshots` — vedi `05-data-model.md` per lo
|
||||
schema. Cosa registra per ogni strumento entro la finestra
|
||||
`[dte_min, dte_max]`:
|
||||
|
||||
| Campo | Cosa misura | A che serve |
|
||||
|---|---|---|
|
||||
| `bid` / `ask` / `mid` | Prezzi reali Deribit in ETH | P/L reale per backtest |
|
||||
| `iv` | IV implicita allo strike | Calibrazione skew premium |
|
||||
| `delta`/`gamma`/`theta`/`vega` | Greeks | Sizing + risk monitoring |
|
||||
| `open_interest` / `volume_24h` | Liquidità | Validazione gate §4 |
|
||||
|
||||
**Cosa sblocca:**
|
||||
|
||||
- **Backtest non-stilizzato**: il modulo `core/backtest.py` può
|
||||
sostituire la stima Black-Scholes (con `skew_premium` hardcoded a
|
||||
1.5×) leggendo i `mid` reali. Numeri da "stime ex-ante per
|
||||
ranking" a "P/L empirico per dimensionare capitale".
|
||||
- **Calibrazione empirica dello skew**: rapporto fra prezzi
|
||||
Deribit reali e BS pulito → valore data-driven invece di stima a
|
||||
istinto. Va fatto dopo 4-8 settimane di accumulo.
|
||||
- **Validazione ex-post strike picker**: "il delta-0.12 era davvero
|
||||
a 25% OTM in quella settimana?" diventa una `SELECT`.
|
||||
|
||||
**Strumenti CLI:**
|
||||
|
||||
- `cerbero-bite option-chain trigger` — esegue UNA volta il
|
||||
collector senza aspettare il cron. Utile per test e per popolare
|
||||
prima del primo tick utile.
|
||||
- `cerbero-bite option-chain analyze [--bias bull_put|bear_call]` —
|
||||
legge l'ultimo snapshot, simula il selector di strike con la
|
||||
strategy passata e stampa: short/long strike, delta, width,
|
||||
credito reale, ratio credit/width, PASS/FAIL del gate
|
||||
`credit_to_width_ratio_min`. Risponde in tempo reale: "il rule
|
||||
engine aprirebbe un trade adesso?".
|
||||
|
||||
## 5. Come leggere il dato giorno per giorno
|
||||
|
||||
Tre euristiche operative sui campi raccolti:
|
||||
|
||||
1. **Premio "ricco":** `iv_minus_rv` consistentemente > 5 punti per
|
||||
N giorni → il regime sta pagando bene la vendita di vol. Sono i
|
||||
periodi in cui la strategia ha edge maggiore.
|
||||
2. **Premio "magro":** `dvol < 35` per più giorni → la finestra
|
||||
giornaliera viene saltata. Non è un fallimento: è la disciplina
|
||||
che funziona.
|
||||
3. **Stress imminente:** `liquidation_*_risk = high` o spike di
|
||||
`oi_delta_pct_4h` (> 5% in valore assoluto) + funding ai limiti
|
||||
→ atteso vol stop / time stop attivi nei prossimi cicli, anche
|
||||
se la posizione è in profit.
|
||||
|
||||
Nei giorni di **eventi macro** (`macro_days_to_event` piccolo) la
|
||||
combinazione utile è: aspettare l'evento, lasciare che `dvol` scenda
|
||||
quando `realized_vol_30d` non si è realizzata, e cogliere il setup
|
||||
classico **post-evento**.
|
||||
|
||||
---
|
||||
|
||||
## 6. Glossario rapido
|
||||
|
||||
- **Credit spread:** vendita di un'opzione e acquisto di un'opzione
|
||||
più OTM stessa scadenza per cap del rischio. Si incassa un credito,
|
||||
si vince se il sottostante non rompe lo strike short.
|
||||
- **Bull put / Bear call:** credit spread direzionali (rispettivamente
|
||||
bullish / bearish).
|
||||
- **Iron condor:** Bull put + bear call sullo stesso sottostante e
|
||||
scadenza. Si vince in regime laterale.
|
||||
- **DVOL:** indice Deribit della IV ETM 30g, scala 0–200.
|
||||
- **Realized vol 30g:** σ annualizzata dei rendimenti spot sui 30g
|
||||
rolling.
|
||||
- **IV − RV:** differenza tra IV implicita (DVOL) e RV; > 0 = "premio"
|
||||
positivo per il venditore di vol.
|
||||
- **Funding annualizzato:** funding rate del perp moltiplicato per le
|
||||
finestre standard (di solito 8h × 3 al giorno × 365).
|
||||
- **Dealer net gamma:** somma di gamma per tutti gli strike, pesata
|
||||
per direzione dei dealer (long = riduce vol, short = amplifica).
|
||||
- **OI delta % 4h:** variazione % dell'open interest aggregato nelle
|
||||
ultime 4 ore.
|
||||
- **DTE:** Days To Expiry, giorni alla scadenza dell'opzione.
|
||||
- **Kill switch:** flag persistente che blocca apertura di nuove
|
||||
posizioni; armato automaticamente su mismatch ambiente o failure
|
||||
ripetuti, disarmato solo manualmente con motivazione.
|
||||
|
||||
---
|
||||
|
||||
## 7. Riferimenti incrociati
|
||||
|
||||
- Regole canoniche e immutabili: `01-strategy-rules.md`
|
||||
- Schema dati persistente: `05-data-model.md`
|
||||
- Algoritmi (calcolo trend, IV−RV, ecc.): `03-algorithms.md`
|
||||
- Dettaglio integrazioni MCP: `04-mcp-integration.md`
|
||||
- Pagina GUI di calibrazione: `📐 Calibrazione`
|
||||
- Sorgente del collector: `src/cerbero_bite/runtime/market_snapshot_cycle.py`
|
||||
- Modello pydantic riga: `cerbero_bite.state.models.MarketSnapshotRecord`
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,316 @@
|
||||
# IV-RV adaptive entry gate — design
|
||||
|
||||
**Status**: drafted, awaiting implementation plan
|
||||
**Date**: 2026-05-08
|
||||
**Author**: brainstorming session (operator + Claude)
|
||||
**Roadmap origin**: `docs/13-strategia-spiegata.md` §4-quater, hardening punti 1 e 2
|
||||
|
||||
## 1. Problema
|
||||
|
||||
Il gate IV-richness in `core/entry_validator.py:140-152` confronta `ctx.iv_minus_rv` con la soglia statica `entry.iv_minus_rv_min` (config). I dati raccolti in `market_snapshots` mostrano due problemi sul campione 2026-05-01 → 2026-05-08:
|
||||
|
||||
| metrica | ETH | BTC |
|
||||
|---|---|---|
|
||||
| IV-RV p25 | 1.87 | 5.48 |
|
||||
| IV-RV p50 | 2.70 | 6.88 |
|
||||
| IV-RV p90 | 8.52 | 8.26 |
|
||||
| Pearson(\|drift%\|, IV-RV mean) | **−0.02** | **+0.54** |
|
||||
| Trend giornaliero IV-RV mean | 1.64 → 8.96 (+5.4×) | 4.78 → 8.11 (+1.7×) |
|
||||
|
||||
ETH mostra un IV richening monotono **decoupled dal drift realised** — la soglia statica `min=3` (profilo Aggressiva) avrebbe escluso il 50%+ dei tick nei primi 4 giorni e il 0% degli ultimi 3, sopra una distribuzione che non è stazionaria. Su BTC è meno drammatico ma il problema è strutturale: il regime di IV cambia, la soglia no.
|
||||
|
||||
## 2. Obiettivo
|
||||
|
||||
Sostituire la soglia statica con un meccanismo adattivo che si auto-calibra al regime corrente, senza richiedere intervento manuale dell'operatore. Il gate deve restare semanticamente "non vendere vol senza margine misurabile sopra la RV", solo che il "margine misurabile" è ora derivato dalla distribuzione storica recente invece che hardcoded.
|
||||
|
||||
## 3. Approccio scelto: Hybrid (P25 rolling + Vol-of-Vol guard)
|
||||
|
||||
Decisione presa nel brainstorming dopo aver scartato:
|
||||
- **Solo percentile rolling**: insufficiente, non protegge da regime shift bruschi (DVOL salta di 5+ pt in 24h)
|
||||
- **Solo regime detection (HMM/cluster)**: troppo opaco e ad alto rischio di overfit con 8 giorni di dati
|
||||
|
||||
L'hybrid bilancia due controlli additivi:
|
||||
1. **Soglia adattiva** = P25 di IV-RV nella finestra rolling
|
||||
2. **Vol-of-Vol guard** = blocco se |ΔDVOL_24h| ≥ 5 pt (regime shift detector)
|
||||
|
||||
## 4. Comportamento del gate
|
||||
|
||||
> **Errata 2026-05-10** — design originale assumeva `n_days = len(history) // 96` (cadenza fissa 96 tick/giorno). Refattorizzato a **distinct-days policy**: il caller interroga il repository per (a) il numero di giorni di calendario distinti coperti e (b) i valori della finestra scelta. Questo permette di mischiare cadenze (tick live 15 min + backfill daily) senza assumere un fattore costante. Sotto il pseudo-codice aggiornato.
|
||||
|
||||
```
|
||||
def validate_iv_richness_adaptive(ctx, cfg, repo):
|
||||
if not cfg.entry.iv_minus_rv_filter_enabled:
|
||||
return PASS # gate off
|
||||
|
||||
# 1) Soglia adattiva — distinct-days policy
|
||||
n_days = repo.count_iv_rv_distinct_days(
|
||||
asset=ctx.asset, max_days=cfg.window_target_days,
|
||||
)
|
||||
|
||||
if n_days < 1:
|
||||
return PASS # warmup hard: nessun giorno coperto
|
||||
|
||||
if n_days >= cfg.window_target_days:
|
||||
window_days = cfg.window_target_days # ≥60g → finestra fissa 60g
|
||||
elif n_days >= cfg.window_min_days:
|
||||
window_days = cfg.window_min_days # 30-60g → finestra fissa 30g
|
||||
else:
|
||||
window_days = cfg.window_target_days # 1-30g → query tutta la storia disp.
|
||||
|
||||
history = repo.iv_rv_values_for_window(
|
||||
asset=ctx.asset, window_days=window_days,
|
||||
)
|
||||
|
||||
threshold = max(percentile(history, cfg.percentile),
|
||||
cfg.absolute_floor)
|
||||
|
||||
if ctx.iv_minus_rv < threshold:
|
||||
return SKIP("IV richness below P25 rolling")
|
||||
|
||||
# 2) Vol-of-vol guard (additivo)
|
||||
if cfg.vol_of_vol_guard_enabled:
|
||||
dvol_24h_ago = repo.dvol_lookback(asset=ctx.asset, hours=24)
|
||||
if dvol_24h_ago is not None and \
|
||||
abs(ctx.dvol - dvol_24h_ago) >= cfg.vol_of_vol_threshold:
|
||||
return SKIP("DVOL shifted ≥5pt in 24h")
|
||||
|
||||
return PASS
|
||||
```
|
||||
|
||||
### 4.1 Warmup behavior
|
||||
|
||||
Tutte le soglie sono espresse in **giorni di calendario distinti** coperti da almeno un record valido (`fetch_ok=1` ∧ `iv_minus_rv IS NOT NULL`).
|
||||
|
||||
| storia disponibile | finestra usata | comportamento |
|
||||
|---|---|---|
|
||||
| 0 giorni distinti | — | gate disabled (PASS), log `GATE_WARMUP_INSUFFICIENT` |
|
||||
| 1 g ≤ giorni < 30 g | tutta la storia | percentile della finestra disponibile (decisione utente) |
|
||||
| 30 g ≤ giorni < 60 g | ultimi 30 g | finestra fissa 30g |
|
||||
| ≥ 60 g | ultimi 60 g | finestra fissa 60g (target) |
|
||||
|
||||
I valori della finestra contribuiscono uno-a-uno al percentile: un tick a 15 min e un record di backfill daily hanno lo stesso peso. Mix di cadenze diverse è statisticamente sbilanciato finché i tick live non saturano la finestra; questa è una scelta deliberata per non rinunciare allo storico backfill.
|
||||
|
||||
### 4.2 Soglia = `max(P25, floor)`
|
||||
|
||||
`floor` è il vecchio `iv_minus_rv_min` riutilizzato come *absolute floor*. Permette:
|
||||
- backwards compat: se `adaptive_enabled=False`, comportamento identico ad oggi
|
||||
- safety: anche se P25 storico fosse ≈0 (regime IV bassa persistente), l'operatore può tenere un floor minimo (es. 1 vol pt) per evitare di vendere vol mai
|
||||
|
||||
## 5. Schema config (`config/schema.py`)
|
||||
|
||||
Aggiunte alla classe `EntryConfig`:
|
||||
|
||||
```python
|
||||
class EntryConfig(BaseModel):
|
||||
# campi esistenti
|
||||
iv_minus_rv_filter_enabled: bool = False
|
||||
iv_minus_rv_min: Decimal = Decimal("0") # ora è absolute_floor
|
||||
|
||||
# nuovi — gate adattivo
|
||||
iv_minus_rv_adaptive_enabled: bool = False
|
||||
iv_minus_rv_percentile: Decimal = Decimal("0.25")
|
||||
iv_minus_rv_window_target_days: int = 60
|
||||
iv_minus_rv_window_min_days: int = 30
|
||||
|
||||
# nuovi — vol-of-vol guard
|
||||
vol_of_vol_guard_enabled: bool = False
|
||||
vol_of_vol_threshold_pt: Decimal = Decimal("5")
|
||||
vol_of_vol_lookback_hours: int = 24
|
||||
```
|
||||
|
||||
### 5.1 Profili predefiniti
|
||||
|
||||
**Conservativa / golden** (`config/golden.yaml`):
|
||||
```yaml
|
||||
entry:
|
||||
iv_minus_rv_filter_enabled: false
|
||||
iv_minus_rv_adaptive_enabled: false
|
||||
vol_of_vol_guard_enabled: false
|
||||
```
|
||||
Comportamento invariato rispetto a oggi.
|
||||
|
||||
**Aggressiva** (`config/aggressive.yaml`):
|
||||
```yaml
|
||||
entry:
|
||||
iv_minus_rv_filter_enabled: true
|
||||
iv_minus_rv_adaptive_enabled: true
|
||||
iv_minus_rv_min: 0 # floor 0, lascia decidere il P25 rolling
|
||||
iv_minus_rv_percentile: 0.25
|
||||
iv_minus_rv_window_target_days: 60
|
||||
iv_minus_rv_window_min_days: 30
|
||||
vol_of_vol_guard_enabled: true
|
||||
vol_of_vol_threshold_pt: 5
|
||||
```
|
||||
|
||||
### 5.2 Backwards compat
|
||||
|
||||
Se `iv_minus_rv_adaptive_enabled=False` e `iv_minus_rv_filter_enabled=True`, il validator usa il path legacy `iv_rv < iv_minus_rv_min` esattamente come oggi. Nessuna regressione comportamentale per chi non ha attivato l'adaptive.
|
||||
|
||||
## 6. Architettura
|
||||
|
||||
### 6.1 Modulo `core/adaptive_threshold.py`
|
||||
|
||||
Funzione pura, testabile senza I/O. La selezione della finestra è
|
||||
delegata al caller (separation of concerns):
|
||||
|
||||
```python
|
||||
def compute_adaptive_threshold(
|
||||
history: Sequence[Decimal],
|
||||
*,
|
||||
n_days: int,
|
||||
percentile: Decimal,
|
||||
absolute_floor: Decimal,
|
||||
) -> Decimal | None:
|
||||
"""Ritorna None se warmup hard (n_days==0 o history vuota),
|
||||
altrimenti max(P_q(history), absolute_floor)."""
|
||||
```
|
||||
|
||||
### 6.2 Repository (`state/repository.py`)
|
||||
|
||||
Tre metodi su `Repository` (uno preesistente):
|
||||
|
||||
```python
|
||||
def count_iv_rv_distinct_days(
|
||||
self, *, asset: str, max_days: int, as_of: datetime | None = None,
|
||||
) -> int:
|
||||
"""Numero di giorni di calendario distinti con almeno un IV-RV
|
||||
valido nell'intervallo [as_of - max_days, as_of]."""
|
||||
|
||||
def iv_rv_values_for_window(
|
||||
self, *, asset: str, window_days: int, as_of: datetime | None = None,
|
||||
) -> list[Decimal]:
|
||||
"""Valori IV-RV ordinati ASC su [as_of - window_days, as_of]."""
|
||||
|
||||
def dvol_lookback(self, *, asset: str, hours: int) -> Decimal | None:
|
||||
"""DVOL del tick più vicino a now-hours, ±15min tolerance. None se gap."""
|
||||
```
|
||||
|
||||
Usa l'index esistente `idx_market_snapshots_asset_ts`. Nessuna nuova migration.
|
||||
|
||||
### 6.3 Inline nel validator
|
||||
|
||||
`core/entry_validator.py` chiama `compute_adaptive_threshold` con i dati dal repo. Nessun caching, stateless. La query per finestra 60g (5760 righe per asset) costa ms-level con index — non vale la pena introdurre cache da invalidare.
|
||||
|
||||
### 6.4 Audit / logging
|
||||
|
||||
Ogni entry cycle scrive in `decisions`:
|
||||
- `inputs_json`: `{iv_rv_now, threshold_used, dvol_now, dvol_24h_ago, n_history, window_used_days}`
|
||||
- `outputs_json`: `{gate: "iv_richness_adaptive", verdict: PASS|SKIP, reason}`
|
||||
|
||||
Permette ricostruzione ex-post: perché un trade è stato saltato e con quali numeri.
|
||||
|
||||
## 7. GUI Calibrazione (`pages/6_📐_Calibrazione.py`)
|
||||
|
||||
Aggiunta sezione "Gate adattivo" sopra ai percentili statici esistenti — questi ultimi NON vengono modificati (restano per analisi).
|
||||
|
||||
```
|
||||
┌─ 🎯 Gate IV-RV adattivo ──────────────────────────────────┐
|
||||
│ Status: 🟢 Attivo (Aggressiva) | 🟡 Warmup (n=8/30g) │
|
||||
│ │
|
||||
│ Soglia P25 rolling (corrente) 2.74 vol pts │
|
||||
│ IV-RV ultimo tick 8.96 vol pts ✅ │
|
||||
│ Floor assoluto 0.00 vol pts │
|
||||
│ │
|
||||
│ Evoluzione 7g (sparkline) ▁▂▂▃▄▆▇ │
|
||||
│ │
|
||||
│ ── VoV guard ── │
|
||||
│ ΔDVOL ultime 24h 0.43 pt ✅ │
|
||||
│ Soglia VoV 5.00 pt │
|
||||
│ │
|
||||
│ Decisione hypothetical: PASS │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
La GUI usa la stessa funzione `compute_adaptive_threshold` del validator → unica fonte di verità. Refresh manuale al page load (coerente con resto GUI).
|
||||
|
||||
## 8. Error handling
|
||||
|
||||
Principio: **fail-open** in tutti i casi di dato mancante. Il gate adattivo è additivo sopra ai gate hard esistenti (delta band, credit, ecc.); il dato mancante non deve trasformare un trade non voluto in trade fatto, ma neppure deve bloccare entry valide se è il dato del gate stesso a mancare.
|
||||
|
||||
| Scenario | Comportamento | Loggato come |
|
||||
|---|---|---|
|
||||
| `iv_rv_history` ritorna 0 righe | gate = PASS | `GATE_WARMUP_NO_DATA` |
|
||||
| Storia < 96 tick (1g) | gate = PASS, threshold None | `GATE_WARMUP_INSUFFICIENT` |
|
||||
| `dvol_lookback` = None (gap dati 24h fa) | VoV guard = PASS | `VOV_GUARD_NO_LOOKBACK` |
|
||||
| `ctx.iv_minus_rv` = None | gate bypassato (riga 146 esistente) | invariato |
|
||||
| `ctx.dvol` = None | VoV guard bypassato, gate adattivo prosegue | invariato |
|
||||
|
||||
## 9. Testing strategy
|
||||
|
||||
### 9.1 Unit — `core/adaptive_threshold.py`
|
||||
|
||||
- Warmup: `n_days=0` → None
|
||||
- Warmup difensivo: `n_days=0` ma history non vuota → None
|
||||
- Difensivo: history vuota con `n_days>0` → None
|
||||
- `n_days=1`, 96 tick → P25 sui 96
|
||||
- Mix di cadenze (30 daily + 96 live) → percentile uno-a-uno
|
||||
- Floor binding: P25=0.5, floor=3 → 3
|
||||
- Floor non binding: P25=5, floor=0 → 5
|
||||
- Percentile diverso: percentile=0.5 → mediana
|
||||
- Validation: percentile ∉ [0,1] o `n_days<0` → ValueError
|
||||
|
||||
### 9.1bis Unit — `state/repository.py`
|
||||
|
||||
- `count_iv_rv_distinct_days`: 1 giorno → 1; 3 giorni misti → 3
|
||||
- esclusione asset diversi, NULL e fetch_ok=0
|
||||
- rispetto del cutoff `max_days`
|
||||
- ValueError su `as_of` naive o `max_days≤0`
|
||||
- `iv_rv_values_for_window`: ordine ASC, filtri equivalenti, ValueError input
|
||||
|
||||
### 9.2 Unit — `core/entry_validator.py`
|
||||
|
||||
Mock repo, focus sul flusso decisionale:
|
||||
- Adaptive disabled, statico passa → PASS legacy
|
||||
- Adaptive disabled, statico fail → SKIP legacy
|
||||
- Adaptive enabled, IV-RV sopra P25 → PASS
|
||||
- Adaptive enabled, IV-RV sotto P25 sopra floor → SKIP("rolling")
|
||||
- VoV guard ON, ΔDVOL=6 pt → SKIP("vov")
|
||||
- VoV guard ON, ΔDVOL=4 pt, gate principale pass → PASS
|
||||
- VoV guard ON, dvol_lookback=None → guard bypass
|
||||
- ctx.iv_minus_rv=None → bypass
|
||||
- decisions log popolato con threshold, n_history, dvol_lookback
|
||||
|
||||
### 9.3 Integration — `tests/integration/test_entry_cycle_adaptive.py`
|
||||
|
||||
SQLite temp + fixture market_snapshots con 5760 tick (60g):
|
||||
- Aggressiva con flag adattivo → entry passa solo nei tick sopra P25 della fixture
|
||||
- Golden → invariato
|
||||
- Warmup: DB con 50 tick → tutti pass, log `GATE_WARMUP_INSUFFICIENT`
|
||||
- Regime shift fixture: DVOL salta da 50 a 56 in 24h → VoV guard scatta
|
||||
|
||||
### 9.4 Backtest sanity
|
||||
|
||||
Aggiunta nel report del CLI `backtest`: count distinto skip-reasons (`iv_rv_static`, `iv_rv_rolling`, `vov_guard`) per analisi ex-post.
|
||||
|
||||
### 9.5 GUI smoke
|
||||
|
||||
Manuale al deploy:
|
||||
- Calibrazione carica con `enabled=False` (fallback grafico)
|
||||
- Calibrazione mostra warmup status quando DB < 30g
|
||||
- Refresh ricalcola coerente
|
||||
|
||||
### 9.6 Cosa NON testiamo
|
||||
|
||||
- Performance query (5760 righe con index = trascurabile)
|
||||
- Concorrenza entry cycle / GUI (WAL abilitato)
|
||||
- Migrazione (nessuna tabella nuova)
|
||||
|
||||
## 10. Out of scope
|
||||
|
||||
- Regime detection avanzato (HMM, cluster) — esplicitamente scartato per opacità
|
||||
- Soglie per-asset diverse — il P25 si calibra naturalmente per asset (history filtrata per asset)
|
||||
- Auto-attivazione adaptive su Conservativa quando warmup è completo — l'operatore decide manualmente quando passare al profilo aggressivo
|
||||
- Multi-asset (ETH+BTC simultanei) — già scope §4-ter, indipendente da questo design
|
||||
- Override manuale soglia da GUI — explicit no, l'obiettivo è autocalibrante
|
||||
|
||||
## 11. Decisioni prese durante brainstorming
|
||||
|
||||
| # | Domanda | Scelta |
|
||||
|---|---|---|
|
||||
| 1 | Approccio | Hybrid (percentile + VoV guard) |
|
||||
| 2 | Warmup | Percentile della finestra disponibile (anche se <30g) |
|
||||
| 3 | Percentile | P25 (allineato roadmap) |
|
||||
| 4 | Window | Target 60g, attivazione a 30g, sotto usa quel che c'è |
|
||||
| 5 | VoV soglia | 5 pt vol in 24h |
|
||||
| 6 | Architettura | Inline nel validator, stateless, no cache DB |
|
||||
| 7 | GUI | Pannello informativo aggiunto, slider esistenti invariati |
|
||||
+5
-1
@@ -20,6 +20,7 @@ dependencies = [
|
||||
"httpx>=0.27",
|
||||
"tenacity>=9.0",
|
||||
"python-dateutil>=2.9",
|
||||
"python-dotenv>=1.2.2",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -96,6 +97,9 @@ ignore = [
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/**" = ["PLR2004", "ARG", "S101", "ERA001", "B017"]
|
||||
# Streamlit auto-discovers pages whose file names start with a number and
|
||||
# may contain icons; the convention conflicts with N999.
|
||||
"src/cerbero_bite/gui/pages/*" = ["N999"]
|
||||
|
||||
[tool.ruff.format]
|
||||
quote-style = "double"
|
||||
@@ -113,7 +117,7 @@ no_implicit_reexport = true
|
||||
files = ["src/cerbero_bite"]
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = ["apscheduler.*"]
|
||||
module = ["apscheduler.*", "plotly.*", "pandas.*"]
|
||||
ignore_missing_imports = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
"""Backfill IV-RV history from Deribit public REST API.
|
||||
|
||||
Use case: il gate IV-RV adattivo richiede ≥30 giorni di storia per
|
||||
attivarsi (spec ``docs/superpowers/specs/2026-05-08-iv-rv-adaptive-gate-design.md``).
|
||||
Quando la pipeline ha pochi giorni di tick live, questo script popola
|
||||
``market_snapshots`` con record giornalieri storici calcolati da
|
||||
DVOL Deribit + closes ETH-PERPETUAL/BTC-PERPETUAL pubblici.
|
||||
|
||||
Idempotente: usa ``INSERT OR REPLACE`` sulla PK ``(timestamp, asset)``
|
||||
con timestamp fissato a 12:00 UTC del giorno di calendario.
|
||||
``fetch_errors_json='{"backfill":true}'`` permette di distinguere i
|
||||
record sintetici dai tick live in audit.
|
||||
|
||||
I record contribuiscono al gate adattivo come singoli punti
|
||||
(distinct-days policy), uno per giorno: lo statistical bias è coperto
|
||||
dalla spec §4.1.
|
||||
|
||||
Esempio:
|
||||
python scripts/backfill_iv_rv.py --db data/state.sqlite --days 45
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import sqlite3
|
||||
import statistics
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
__all__ = [
|
||||
"BackfillRow",
|
||||
"build_backfill_records",
|
||||
"compute_rv30d_annualized",
|
||||
]
|
||||
|
||||
|
||||
_DERIBIT = "https://www.deribit.com/api/v2/public"
|
||||
_RV_LOOKBACK_DAYS = 30
|
||||
_TRADING_DAYS_PER_YEAR = 365
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BackfillRow:
|
||||
"""Una riga sintetica destinata a ``market_snapshots``."""
|
||||
|
||||
timestamp: datetime
|
||||
asset: str
|
||||
spot: Decimal
|
||||
dvol: Decimal
|
||||
realized_vol_30d: Decimal
|
||||
iv_minus_rv: Decimal
|
||||
fetch_ok: bool = True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pure compute layer (TDD: tests/unit/test_backfill_iv_rv.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def compute_rv30d_annualized(closes: list[Decimal]) -> Decimal:
|
||||
"""Volatilità realizzata 30g annualizzata in **punti vol** (% annuali).
|
||||
|
||||
Args:
|
||||
closes: ``31`` close consecutivi (uno al giorno) — produce 30
|
||||
log-returns.
|
||||
|
||||
Returns:
|
||||
``stdev(log_returns) * sqrt(365) * 100`` come ``Decimal``.
|
||||
|
||||
Raises:
|
||||
ValueError: se ``len(closes) < 31``.
|
||||
"""
|
||||
if len(closes) < _RV_LOOKBACK_DAYS + 1:
|
||||
raise ValueError(
|
||||
f"need at least {_RV_LOOKBACK_DAYS + 1} closes, got {len(closes)}"
|
||||
)
|
||||
log_returns = [
|
||||
math.log(float(closes[i] / closes[i - 1]))
|
||||
for i in range(1, _RV_LOOKBACK_DAYS + 1)
|
||||
]
|
||||
sigma_daily = statistics.stdev(log_returns)
|
||||
annualized = sigma_daily * math.sqrt(_TRADING_DAYS_PER_YEAR) * 100.0
|
||||
return Decimal(str(annualized))
|
||||
|
||||
|
||||
def build_backfill_records(
|
||||
*,
|
||||
asset: str,
|
||||
spots_by_day: dict[str, Decimal],
|
||||
dvols_by_day: dict[str, Decimal],
|
||||
oldest_day: date,
|
||||
) -> list[BackfillRow]:
|
||||
"""Compone le righe di backfill per i giorni nella finestra richiesta.
|
||||
|
||||
Per ogni giorno target ``D`` (da ``oldest_day`` a oggi compreso) la
|
||||
riga viene emessa solo se: (a) DVOL e spot sono presenti per ``D``,
|
||||
(b) la serie di spot dispone dei 30 giorni precedenti necessari per
|
||||
il calcolo di RV30d.
|
||||
|
||||
Il timestamp è fissato a 12:00 UTC, scelta che evita il rollover
|
||||
delle candele Deribit (vedi anomalia DVOL 00:00 UTC nei market
|
||||
snapshots live).
|
||||
"""
|
||||
sorted_days = sorted(spots_by_day.keys())
|
||||
records: list[BackfillRow] = []
|
||||
for day_str in sorted_days:
|
||||
day = date.fromisoformat(day_str)
|
||||
if day < oldest_day:
|
||||
continue
|
||||
if day_str not in dvols_by_day:
|
||||
continue
|
||||
rv_window = [
|
||||
day - timedelta(days=i) for i in range(_RV_LOOKBACK_DAYS, -1, -1)
|
||||
]
|
||||
if not all(d.isoformat() in spots_by_day for d in rv_window):
|
||||
continue
|
||||
closes = [spots_by_day[d.isoformat()] for d in rv_window]
|
||||
rv = compute_rv30d_annualized(closes)
|
||||
dvol = dvols_by_day[day_str]
|
||||
spot = spots_by_day[day_str]
|
||||
records.append(
|
||||
BackfillRow(
|
||||
timestamp=datetime(day.year, day.month, day.day, 12, 0, tzinfo=UTC),
|
||||
asset=asset,
|
||||
spot=spot,
|
||||
dvol=dvol,
|
||||
realized_vol_30d=rv,
|
||||
iv_minus_rv=dvol - rv,
|
||||
)
|
||||
)
|
||||
return records
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# I/O layer (network + sqlite)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _http_get_json(url: str, timeout_s: float = 30.0) -> dict:
|
||||
with urllib.request.urlopen(url, timeout=timeout_s) as resp:
|
||||
return json.loads(resp.read())
|
||||
|
||||
|
||||
def fetch_dvol_daily(currency: str, days: int) -> dict[str, Decimal]:
|
||||
"""Mappa ``YYYY-MM-DD -> DVOL close`` per gli ultimi ``days`` giorni."""
|
||||
end_ms = int(datetime.now(UTC).timestamp() * 1000)
|
||||
start_ms = end_ms - days * 86_400_000
|
||||
url = (
|
||||
f"{_DERIBIT}/get_volatility_index_data"
|
||||
f"?currency={currency}"
|
||||
f"&start_timestamp={start_ms}&end_timestamp={end_ms}"
|
||||
f"&resolution=86400"
|
||||
)
|
||||
payload = _http_get_json(url)
|
||||
data = (payload.get("result") or {}).get("data") or []
|
||||
out: dict[str, Decimal] = {}
|
||||
for row in data:
|
||||
# row = [ts_ms, open, high, low, close]
|
||||
if not isinstance(row, list) or len(row) < 5:
|
||||
continue
|
||||
ts = datetime.fromtimestamp(row[0] / 1000, tz=UTC).date().isoformat()
|
||||
out[ts] = Decimal(str(row[4]))
|
||||
return out
|
||||
|
||||
|
||||
def fetch_spot_daily(instrument: str, days: int) -> dict[str, Decimal]:
|
||||
"""Mappa ``YYYY-MM-DD -> close USD`` per ``instrument`` su ``days`` giorni."""
|
||||
end_ms = int(datetime.now(UTC).timestamp() * 1000)
|
||||
start_ms = end_ms - days * 86_400_000
|
||||
url = (
|
||||
f"{_DERIBIT}/get_tradingview_chart_data"
|
||||
f"?instrument_name={instrument}"
|
||||
f"&start_timestamp={start_ms}&end_timestamp={end_ms}"
|
||||
f"&resolution=1D"
|
||||
)
|
||||
payload = _http_get_json(url)
|
||||
result = payload.get("result") or {}
|
||||
ticks = result.get("ticks") or []
|
||||
closes = result.get("close") or []
|
||||
out: dict[str, Decimal] = {}
|
||||
for ts_ms, close in zip(ticks, closes, strict=False):
|
||||
ts = datetime.fromtimestamp(ts_ms / 1000, tz=UTC).date().isoformat()
|
||||
out[ts] = Decimal(str(close))
|
||||
return out
|
||||
|
||||
|
||||
def write_records(db_path: str, records: list[BackfillRow]) -> int:
|
||||
"""Insert/replace dei record in market_snapshots. Ritorna la rowcount."""
|
||||
if not records:
|
||||
return 0
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
with conn:
|
||||
for r in records:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO market_snapshots ("
|
||||
"timestamp, asset, spot, dvol, realized_vol_30d, iv_minus_rv, "
|
||||
"funding_perp_annualized, funding_cross_annualized, "
|
||||
"dealer_net_gamma, gamma_flip_level, oi_delta_pct_4h, "
|
||||
"liquidation_long_risk, liquidation_short_risk, "
|
||||
"macro_days_to_event, fetch_ok, fetch_errors_json"
|
||||
") VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
r.timestamp.astimezone(UTC).isoformat(),
|
||||
r.asset,
|
||||
str(r.spot),
|
||||
str(r.dvol),
|
||||
str(r.realized_vol_30d),
|
||||
str(r.iv_minus_rv),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
1 if r.fetch_ok else 0,
|
||||
'{"backfill":true}',
|
||||
),
|
||||
)
|
||||
return len(records)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def backfill_asset(db_path: str, asset: str, days: int) -> int:
|
||||
"""Esegue l'intero backfill per ``asset`` e ritorna il numero di
|
||||
record inseriti/sostituiti.
|
||||
"""
|
||||
instrument = f"{asset.upper()}-PERPETUAL"
|
||||
fetch_window_days = days + _RV_LOOKBACK_DAYS + 5 # margine per il lookback RV
|
||||
spots = fetch_spot_daily(instrument, fetch_window_days)
|
||||
dvols = fetch_dvol_daily(asset.upper(), fetch_window_days)
|
||||
today = datetime.now(UTC).date()
|
||||
oldest = today - timedelta(days=days)
|
||||
records = build_backfill_records(
|
||||
asset=asset.upper(),
|
||||
spots_by_day=spots,
|
||||
dvols_by_day=dvols,
|
||||
oldest_day=oldest,
|
||||
)
|
||||
return write_records(db_path, records)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--db",
|
||||
default="data/state.sqlite",
|
||||
help="path a state.sqlite (default: data/state.sqlite)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--days",
|
||||
type=int,
|
||||
default=45,
|
||||
help="quanti giorni di backfill emettere (default: 45)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--assets",
|
||||
nargs="+",
|
||||
default=["ETH", "BTC"],
|
||||
help="asset symbols (default: ETH BTC)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
total = 0
|
||||
for asset in args.assets:
|
||||
n = backfill_asset(args.db, asset, args.days)
|
||||
print(f"{asset}: inserted/replaced {n} backfill rows")
|
||||
total += n
|
||||
print(f"TOTAL: {total}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,28 +0,0 @@
|
||||
# `secrets/`
|
||||
|
||||
Cartella runtime per i credenziali sensibili. Tutti i file in questa
|
||||
directory sono `.gitignore`d eccetto questo README e `.gitkeep`.
|
||||
|
||||
## Contenuto atteso
|
||||
|
||||
| File | Origine | Uso |
|
||||
|---|---|---|
|
||||
| `core.token` | copia di `Cerbero_mcp/secrets/core.token` | bearer token con capability `core` per chiamare i tool MCP. Letta una sola volta al boot del container. |
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cp /path/to/Cerbero_mcp/secrets/core.token secrets/core.token
|
||||
chmod 600 secrets/core.token
|
||||
```
|
||||
|
||||
Il `docker-compose.yml` di Cerbero Bite monta `secrets/core.token`
|
||||
come Docker secret a `/run/secrets/core_token` dentro il container, e
|
||||
la variabile d'ambiente `CERBERO_BITE_CORE_TOKEN_FILE` punta lì per
|
||||
default.
|
||||
|
||||
## Rotazione
|
||||
|
||||
Quando il token core viene ruotato sul cluster Cerbero_mcp, sostituire
|
||||
anche la copia locale. Il container va riavviato perché il token è
|
||||
letto solo all'avvio.
|
||||
+486
-27
@@ -10,9 +10,10 @@ without changing the surface.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -26,14 +27,15 @@ from cerbero_bite.clients import HttpToolClient, McpError
|
||||
from cerbero_bite.clients.deribit import DeribitClient
|
||||
from cerbero_bite.clients.hyperliquid import HyperliquidClient
|
||||
from cerbero_bite.clients.macro import MacroClient
|
||||
from cerbero_bite.clients.portfolio import PortfolioClient
|
||||
from cerbero_bite.clients.sentiment import SentimentClient
|
||||
from cerbero_bite.config.loader import compute_config_hash, load_strategy
|
||||
from cerbero_bite.config.mcp_endpoints import (
|
||||
DEFAULT_ENDPOINTS,
|
||||
load_bot_tag,
|
||||
load_endpoints,
|
||||
load_token,
|
||||
)
|
||||
from cerbero_bite.config.runtime_flags import load_runtime_flags
|
||||
from cerbero_bite.logging import configure as configure_logging
|
||||
from cerbero_bite.logging import get_logger
|
||||
from cerbero_bite.runtime.orchestrator import Orchestrator, make_orchestrator
|
||||
@@ -74,6 +76,14 @@ def _phase0_notice(action: str) -> None:
|
||||
@click.pass_context
|
||||
def main(ctx: click.Context, log_dir: Path, log_level: str) -> None:
|
||||
"""Cerbero Bite — rule-based ETH credit spread engine."""
|
||||
# Load `.env` once at CLI entry, unless we are running under
|
||||
# pytest (which sets ``PYTEST_CURRENT_TEST`` for the duration of
|
||||
# the test). Existing env vars win over the file (override=False).
|
||||
if "PYTEST_CURRENT_TEST" not in os.environ:
|
||||
from dotenv import load_dotenv # noqa: PLC0415
|
||||
|
||||
load_dotenv(Path.cwd() / ".env", override=False)
|
||||
|
||||
configure_logging(log_dir=log_dir, level=log_level.upper())
|
||||
ctx.ensure_object(dict)
|
||||
ctx.obj["log_dir"] = log_dir
|
||||
@@ -197,9 +207,14 @@ def _engine_options(func: Callable[..., Any]) -> Callable[..., Any]:
|
||||
show_default=True,
|
||||
),
|
||||
click.option(
|
||||
"--token-file",
|
||||
type=click.Path(dir_okay=False, path_type=Path),
|
||||
"--token",
|
||||
type=str,
|
||||
default=None,
|
||||
help=(
|
||||
"MCP bearer token (overrides CERBERO_BITE_MCP_TOKEN). "
|
||||
"The server uses the token to choose between testnet "
|
||||
"and mainnet upstream environments."
|
||||
),
|
||||
),
|
||||
click.option(
|
||||
"--db",
|
||||
@@ -235,7 +250,7 @@ def _engine_options(func: Callable[..., Any]) -> Callable[..., Any]:
|
||||
def _build_orchestrator(
|
||||
*,
|
||||
strategy_path: Path,
|
||||
token_file: Path | None,
|
||||
token: str | None,
|
||||
db: Path,
|
||||
audit: Path,
|
||||
environment: str,
|
||||
@@ -243,7 +258,7 @@ def _build_orchestrator(
|
||||
enforce_hash: bool = True,
|
||||
) -> Orchestrator:
|
||||
loaded = load_strategy(strategy_path, enforce_hash=enforce_hash)
|
||||
token = load_token(path=token_file)
|
||||
resolved_token = load_token(value=token)
|
||||
# Strategy file values win over the CLI defaults; explicit overrides
|
||||
# via env-style values (CLI flags) still apply when the user provides
|
||||
# them — Click signals "default" via Click's resilient_parsing flag,
|
||||
@@ -262,11 +277,13 @@ def _build_orchestrator(
|
||||
return make_orchestrator(
|
||||
cfg=loaded.config,
|
||||
endpoints=load_endpoints(),
|
||||
token=token,
|
||||
token=resolved_token,
|
||||
db_path=db,
|
||||
audit_path=audit,
|
||||
expected_environment=chosen_env, # type: ignore[arg-type]
|
||||
eur_to_usd=chosen_fx,
|
||||
bot_tag=load_bot_tag(),
|
||||
flags=load_runtime_flags(),
|
||||
)
|
||||
|
||||
|
||||
@@ -274,7 +291,7 @@ def _build_orchestrator(
|
||||
@_engine_options
|
||||
def start(
|
||||
strategy_path: Path,
|
||||
token_file: Path | None,
|
||||
token: str | None,
|
||||
db: Path,
|
||||
audit: Path,
|
||||
environment: str,
|
||||
@@ -284,7 +301,7 @@ def start(
|
||||
try:
|
||||
orch = _build_orchestrator(
|
||||
strategy_path=strategy_path,
|
||||
token_file=token_file,
|
||||
token=token,
|
||||
db=db,
|
||||
audit=audit,
|
||||
environment=environment,
|
||||
@@ -314,7 +331,7 @@ def start(
|
||||
)
|
||||
def dry_run(
|
||||
strategy_path: Path,
|
||||
token_file: Path | None,
|
||||
token: str | None,
|
||||
db: Path,
|
||||
audit: Path,
|
||||
environment: str,
|
||||
@@ -324,7 +341,7 @@ def dry_run(
|
||||
"""Execute one cycle without starting the scheduler."""
|
||||
orch = _build_orchestrator(
|
||||
strategy_path=strategy_path,
|
||||
token_file=token_file,
|
||||
token=token,
|
||||
db=db,
|
||||
audit=audit,
|
||||
environment=environment,
|
||||
@@ -498,10 +515,13 @@ def kill_switch_status(db: Path) -> None:
|
||||
|
||||
@main.command()
|
||||
@click.option(
|
||||
"--token-file",
|
||||
type=click.Path(dir_okay=False, path_type=Path),
|
||||
"--token",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Path to the bearer token file (default: secrets/core_token).",
|
||||
help=(
|
||||
"MCP bearer token (overrides CERBERO_BITE_MCP_TOKEN). The "
|
||||
"server uses the token to choose between testnet and mainnet."
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"--timeout",
|
||||
@@ -510,16 +530,16 @@ def kill_switch_status(db: Path) -> None:
|
||||
show_default=True,
|
||||
help="Per-service timeout in seconds for the ping call.",
|
||||
)
|
||||
def ping(token_file: Path | None, timeout: float) -> None:
|
||||
def ping(token: str | None, timeout: float) -> None:
|
||||
"""Print health status for every MCP service Cerbero Bite uses."""
|
||||
try:
|
||||
token = load_token(path=token_file)
|
||||
except (FileNotFoundError, ValueError) as exc:
|
||||
resolved_token = load_token(value=token)
|
||||
except ValueError as exc:
|
||||
console.print(f"[red]token error[/red]: {exc}")
|
||||
sys.exit(1)
|
||||
|
||||
endpoints = load_endpoints()
|
||||
rows = asyncio.run(_ping_all(endpoints, token=token, timeout=timeout))
|
||||
rows = asyncio.run(_ping_all(endpoints, token=resolved_token, timeout=timeout))
|
||||
|
||||
table = Table(title="MCP services")
|
||||
table.add_column("service")
|
||||
@@ -560,12 +580,6 @@ async def _ping_one(
|
||||
if service == "hyperliquid":
|
||||
await HyperliquidClient(http).funding_rate_annualized("ETH")
|
||||
return "ok", "ETH-PERP reachable"
|
||||
if service == "portfolio":
|
||||
await PortfolioClient(http).total_equity_eur()
|
||||
return "ok", "portfolio reachable"
|
||||
if service == "telegram":
|
||||
# Notify-only: no read tool. Skip without hitting the bot.
|
||||
return "skipped", "notify-only client (no health probe)"
|
||||
return "skipped", "no probe defined" # pragma: no cover
|
||||
except McpError as exc:
|
||||
return "fail", f"{type(exc).__name__}: {exc}"
|
||||
@@ -587,9 +601,70 @@ async def _ping_all(
|
||||
|
||||
|
||||
@main.command()
|
||||
def gui() -> None:
|
||||
"""Launch the Streamlit dashboard."""
|
||||
_phase0_notice("gui command not yet implemented (will run streamlit on 127.0.0.1:8765).")
|
||||
@click.option(
|
||||
"--db",
|
||||
type=click.Path(path_type=Path),
|
||||
default=_DEFAULT_DB_PATH,
|
||||
show_default=True,
|
||||
help="SQLite state file the dashboard reads.",
|
||||
)
|
||||
@click.option(
|
||||
"--audit",
|
||||
type=click.Path(path_type=Path),
|
||||
default=_DEFAULT_AUDIT_PATH,
|
||||
show_default=True,
|
||||
help="Audit log file the dashboard streams.",
|
||||
)
|
||||
@click.option(
|
||||
"--port",
|
||||
type=int,
|
||||
default=8765,
|
||||
show_default=True,
|
||||
help="Local port to bind (always 127.0.0.1).",
|
||||
)
|
||||
@click.option(
|
||||
"--headless/--no-headless",
|
||||
default=True,
|
||||
show_default=True,
|
||||
help="When true, do not auto-open the browser.",
|
||||
)
|
||||
def gui(db: Path, audit: Path, port: int, headless: bool) -> None:
|
||||
"""Launch the Streamlit dashboard (read-only, localhost only)."""
|
||||
try:
|
||||
import streamlit # noqa: F401, PLC0415
|
||||
except ImportError:
|
||||
click.echo(
|
||||
"streamlit not installed. Run `uv sync --extra gui` first.",
|
||||
err=True,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
main_path = Path(__file__).parent / "gui" / "main.py"
|
||||
if not main_path.is_file():
|
||||
click.echo(f"GUI entry point not found: {main_path}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
env = os.environ.copy()
|
||||
env["CERBERO_BITE_GUI_DB"] = str(db.resolve())
|
||||
env["CERBERO_BITE_GUI_AUDIT"] = str(audit.resolve())
|
||||
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"streamlit",
|
||||
"run",
|
||||
str(main_path),
|
||||
"--server.address",
|
||||
"127.0.0.1",
|
||||
"--server.port",
|
||||
str(port),
|
||||
"--server.headless",
|
||||
"true" if headless else "false",
|
||||
"--browser.gatherUsageStats",
|
||||
"false",
|
||||
]
|
||||
click.echo(f"Launching GUI on http://127.0.0.1:{port} …")
|
||||
os.execvpe(cmd[0], cmd, env)
|
||||
|
||||
|
||||
@main.command()
|
||||
@@ -604,6 +679,155 @@ def replay(date_from: str, date_to: str, capital: float, dry_run: bool) -> None:
|
||||
)
|
||||
|
||||
|
||||
@main.command()
|
||||
@click.option(
|
||||
"--strategy",
|
||||
"strategy_path",
|
||||
type=click.Path(path_type=Path),
|
||||
default=Path("strategy.yaml"),
|
||||
show_default=True,
|
||||
help="Path al file di strategia (golden, conservativa, aggressiva, ...).",
|
||||
)
|
||||
@click.option(
|
||||
"--db",
|
||||
"db_path",
|
||||
type=click.Path(path_type=Path),
|
||||
default=_DEFAULT_DB_PATH,
|
||||
show_default=True,
|
||||
help="SQLite con `market_snapshots` storiche.",
|
||||
)
|
||||
@click.option(
|
||||
"--from",
|
||||
"date_from",
|
||||
type=click.DateTime(formats=["%Y-%m-%d"]),
|
||||
default=None,
|
||||
help="ISO date YYYY-MM-DD (default: 90 giorni fa).",
|
||||
)
|
||||
@click.option(
|
||||
"--to",
|
||||
"date_to",
|
||||
type=click.DateTime(formats=["%Y-%m-%d"]),
|
||||
default=None,
|
||||
help="ISO date YYYY-MM-DD (default: oggi).",
|
||||
)
|
||||
@click.option(
|
||||
"--capital",
|
||||
type=float,
|
||||
default=1500.0,
|
||||
show_default=True,
|
||||
help="Capitale di partenza per il backtest, in USD.",
|
||||
)
|
||||
@click.option(
|
||||
"--asset",
|
||||
type=str,
|
||||
default="ETH",
|
||||
show_default=True,
|
||||
help="Asset di riferimento per le snapshot.",
|
||||
)
|
||||
@click.option(
|
||||
"--no-enforce-hash",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Salta la verifica del config_hash (utile per profili sperimentali).",
|
||||
)
|
||||
def backtest(
|
||||
strategy_path: Path,
|
||||
db_path: Path,
|
||||
date_from: datetime | None,
|
||||
date_to: datetime | None,
|
||||
capital: float,
|
||||
asset: str,
|
||||
no_enforce_hash: bool,
|
||||
) -> None:
|
||||
"""Esegue il backtest stilizzato su `market_snapshots` storiche.
|
||||
|
||||
Usa lo stesso `validate_entry` del live per i filtri (rigoroso) e
|
||||
un modello Black-Scholes con skew premium per stimare credito ed
|
||||
exit P/L (stilizzato — vedi docstring di `core/backtest.py`).
|
||||
"""
|
||||
from cerbero_bite.config.loader import load_strategy # noqa: PLC0415
|
||||
from cerbero_bite.core.backtest import run_backtest # noqa: PLC0415
|
||||
|
||||
console = Console()
|
||||
if date_to is None:
|
||||
date_to = datetime.now(UTC)
|
||||
if date_from is None:
|
||||
date_from = date_to - timedelta(days=90)
|
||||
date_from = date_from.replace(tzinfo=UTC) if date_from.tzinfo is None else date_from
|
||||
date_to = date_to.replace(tzinfo=UTC) if date_to.tzinfo is None else date_to
|
||||
|
||||
loaded = load_strategy(strategy_path, enforce_hash=not no_enforce_hash)
|
||||
cfg = loaded.config
|
||||
|
||||
conn = connect_state(db_path)
|
||||
try:
|
||||
repo = Repository()
|
||||
snapshots = repo.list_market_snapshots(
|
||||
conn,
|
||||
asset=asset.upper(),
|
||||
start=date_from,
|
||||
end=date_to,
|
||||
limit=10000,
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if not snapshots:
|
||||
console.print(
|
||||
f"[yellow]Nessuno snapshot {asset} trovato fra {date_from.date()} "
|
||||
f"e {date_to.date()}.[/yellow]"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
console.print(
|
||||
f"[green]Caricate {len(snapshots)} snapshot {asset} "
|
||||
f"({snapshots[-1].timestamp.date()} → {snapshots[0].timestamp.date()})[/green]"
|
||||
)
|
||||
|
||||
report = run_backtest(snapshots, cfg, capital_usd=Decimal(str(capital)))
|
||||
|
||||
table = Table(title=f"Backtest report — {strategy_path.name}")
|
||||
table.add_column("Metrica", style="cyan")
|
||||
table.add_column("Valore", style="bold")
|
||||
table.add_row("Picks (daily 14:00)", str(report.n_picks))
|
||||
table.add_row(
|
||||
"Accettati dai filtri",
|
||||
f"{report.n_accepted} ({report.n_accepted / max(1, report.n_picks):.0%})",
|
||||
)
|
||||
table.add_row("Saltati per dato mancante", str(report.n_skipped_data))
|
||||
table.add_row("Trade completati (con P/L)", str(report.n_completed))
|
||||
table.add_row("Vincenti", f"{report.n_winners} ({report.win_rate:.0%})")
|
||||
table.add_row("P/L cumulato (USD)", f"{report.cumulative_pnl_usd:+.2f}")
|
||||
table.add_row(
|
||||
"P/L su capitale", f"{report.cumulative_pnl_pct_of_capital:+.2%}"
|
||||
)
|
||||
table.add_row(
|
||||
"Max drawdown", f"−{report.max_drawdown_usd:.0f} USD "
|
||||
f"({report.max_drawdown_pct:.1%})",
|
||||
)
|
||||
table.add_row(
|
||||
"Sharpe (annualized)",
|
||||
f"{report.sharpe_annualized}" if report.sharpe_annualized is not None
|
||||
else "—",
|
||||
)
|
||||
console.print(table)
|
||||
|
||||
if report.skip_reasons:
|
||||
skip_table = Table(title="Motivi di skip aggregati")
|
||||
skip_table.add_column("Motivo")
|
||||
skip_table.add_column("Giorni", justify="right")
|
||||
for reason, count in sorted(
|
||||
report.skip_reasons.items(), key=lambda kv: -kv[1]
|
||||
):
|
||||
skip_table.add_row(reason, str(count))
|
||||
console.print(skip_table)
|
||||
|
||||
console.print(
|
||||
"[dim]Il modello P/L è stilizzato: BS + skew premium 1.5×. "
|
||||
"Numeri ottimi per ranking config, non per promesse operative.[/dim]"
|
||||
)
|
||||
|
||||
|
||||
@main.group()
|
||||
def config() -> None:
|
||||
"""Strategy configuration utilities."""
|
||||
@@ -737,6 +961,241 @@ def state_inspect(db: Path) -> None:
|
||||
console.print(table)
|
||||
|
||||
|
||||
@main.group(name="option-chain")
|
||||
def option_chain() -> None:
|
||||
"""Strumenti per la catena opzioni storica (`option_chain_snapshots`)."""
|
||||
|
||||
|
||||
@option_chain.command(name="trigger")
|
||||
@click.option(
|
||||
"--strategy",
|
||||
"strategy_path",
|
||||
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
||||
default=_DEFAULT_STRATEGY_PATH,
|
||||
show_default=True,
|
||||
)
|
||||
@click.option(
|
||||
"--db",
|
||||
"db_path",
|
||||
type=click.Path(dir_okay=False, path_type=Path),
|
||||
default=_DEFAULT_DB_PATH,
|
||||
show_default=True,
|
||||
)
|
||||
@click.option(
|
||||
"--audit",
|
||||
"audit_path",
|
||||
type=click.Path(dir_okay=False, path_type=Path),
|
||||
default=_DEFAULT_AUDIT_PATH,
|
||||
show_default=True,
|
||||
)
|
||||
@click.option(
|
||||
"--token",
|
||||
type=str,
|
||||
default=None,
|
||||
help="MCP bearer token (override su CERBERO_BITE_MCP_TOKEN).",
|
||||
)
|
||||
@click.option("--asset", default="ETH", show_default=True)
|
||||
def option_chain_trigger(
|
||||
strategy_path: Path,
|
||||
db_path: Path,
|
||||
audit_path: Path,
|
||||
token: str | None,
|
||||
asset: str,
|
||||
) -> None:
|
||||
"""Esegue UNA volta il collector della catena opzioni e persiste in DB.
|
||||
|
||||
Utile per popolare i dati senza aspettare il cron del job
|
||||
``option_chain_snapshot``. Riusa esattamente la stessa pipeline
|
||||
schedulata.
|
||||
"""
|
||||
from cerbero_bite.runtime.dependencies import build_runtime # noqa: PLC0415
|
||||
from cerbero_bite.runtime.option_chain_snapshot_cycle import ( # noqa: PLC0415
|
||||
collect_option_chain_snapshot,
|
||||
)
|
||||
|
||||
cfg = load_strategy(strategy_path).config
|
||||
ctx = build_runtime(
|
||||
cfg=cfg,
|
||||
endpoints=load_endpoints(),
|
||||
token=load_token(value=token),
|
||||
db_path=db_path,
|
||||
audit_path=audit_path,
|
||||
bot_tag=load_bot_tag(),
|
||||
)
|
||||
n = asyncio.run(collect_option_chain_snapshot(ctx, asset=asset))
|
||||
console.print(
|
||||
f"[green]Persisted {n} option chain quote(s) for {asset}[/green]"
|
||||
if n > 0
|
||||
else f"[yellow]No quotes persisted (chain empty or fetch failed)[/yellow]"
|
||||
)
|
||||
|
||||
|
||||
@option_chain.command(name="analyze")
|
||||
@click.option(
|
||||
"--strategy",
|
||||
"strategy_path",
|
||||
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
||||
default=_DEFAULT_STRATEGY_PATH,
|
||||
show_default=True,
|
||||
)
|
||||
@click.option(
|
||||
"--db",
|
||||
"db_path",
|
||||
type=click.Path(dir_okay=False, path_type=Path),
|
||||
default=_DEFAULT_DB_PATH,
|
||||
show_default=True,
|
||||
)
|
||||
@click.option("--asset", default="ETH", show_default=True)
|
||||
@click.option(
|
||||
"--bias",
|
||||
type=click.Choice(["bull_put", "bear_call"], case_sensitive=False),
|
||||
default="bull_put",
|
||||
show_default=True,
|
||||
help="Direzione da simulare (il rule engine lo deciderebbe da trend×funding).",
|
||||
)
|
||||
def option_chain_analyze(
|
||||
strategy_path: Path,
|
||||
db_path: Path,
|
||||
asset: str,
|
||||
bias: str,
|
||||
) -> None:
|
||||
"""Analizza l'ultimo snapshot di catena salvato.
|
||||
|
||||
Per la strategia indicata, simula la selezione strike (delta
|
||||
target, OTM range, width 4%, credit/width ratio min) e mostra:
|
||||
* lo strike che il rule engine sceglierebbe come short e long,
|
||||
* credito atteso, larghezza, rapporto credit/width,
|
||||
* pass/fail del gate `credit_to_width_ratio_min`.
|
||||
"""
|
||||
from cerbero_bite.core.combo_builder import select_strikes # noqa: PLC0415
|
||||
from cerbero_bite.core.types import OptionQuote # noqa: PLC0415
|
||||
|
||||
cfg = load_strategy(strategy_path).config
|
||||
|
||||
conn = connect_state(db_path)
|
||||
try:
|
||||
repo = Repository()
|
||||
latest_ts = repo.latest_option_chain_timestamp(conn, asset=asset.upper())
|
||||
if latest_ts is None:
|
||||
console.print(
|
||||
"[red]Nessuno snapshot di catena trovato. Lancia prima "
|
||||
"`cerbero-bite option-chain trigger`.[/red]"
|
||||
)
|
||||
sys.exit(1)
|
||||
quotes_records = repo.list_option_chain_snapshots(
|
||||
conn, asset=asset.upper(), start=latest_ts, end=latest_ts,
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
console.print(
|
||||
f"[cyan]Snapshot del {latest_ts.isoformat()} — {len(quotes_records)} "
|
||||
f"quote totali[/cyan]"
|
||||
)
|
||||
|
||||
# Costruzione OptionQuote da OptionChainQuoteRecord per riusare select_strikes.
|
||||
quotes: list[OptionQuote] = []
|
||||
for q in quotes_records:
|
||||
if q.bid is None or q.ask is None or q.mid is None or q.delta is None:
|
||||
continue
|
||||
quotes.append(
|
||||
OptionQuote(
|
||||
instrument=q.instrument_name,
|
||||
strike=q.strike,
|
||||
expiry=q.expiry,
|
||||
option_type=q.option_type,
|
||||
bid=q.bid,
|
||||
ask=q.ask,
|
||||
mid=q.mid,
|
||||
delta=q.delta,
|
||||
gamma=q.gamma or Decimal("0"),
|
||||
theta=q.theta or Decimal("0"),
|
||||
vega=q.vega or Decimal("0"),
|
||||
open_interest=q.open_interest or 0,
|
||||
volume_24h=q.volume_24h or 0,
|
||||
book_depth_top3=q.book_depth_top3 or 0,
|
||||
)
|
||||
)
|
||||
|
||||
if not quotes:
|
||||
console.print("[red]Nessun quote completo per la simulazione.[/red]")
|
||||
sys.exit(1)
|
||||
|
||||
# Lo spot al momento dello snapshot: estraiamo dall'ultimo
|
||||
# `market_snapshot` ETH a quel timestamp (tolleranza ±15 min).
|
||||
spot = _resolve_spot_at(db_path, asset=asset.upper(), at=latest_ts)
|
||||
if spot is None:
|
||||
console.print(
|
||||
"[yellow]Spot non recuperabile dai market_snapshots; "
|
||||
"stimato dal mid ATM.[/yellow]"
|
||||
)
|
||||
spot = _atm_spot_proxy(quotes)
|
||||
|
||||
selection = select_strikes(
|
||||
chain=quotes,
|
||||
bias=bias, # type: ignore[arg-type]
|
||||
spot=spot,
|
||||
now=latest_ts,
|
||||
cfg=cfg,
|
||||
)
|
||||
if selection is None:
|
||||
console.print(
|
||||
"[red]Il rule engine NON aprirebbe trade con questa catena[/red] "
|
||||
"(no strike compatibile coi gate delta/distance/width/credit-ratio)."
|
||||
)
|
||||
sys.exit(0)
|
||||
|
||||
short, long_ = selection
|
||||
width_usd = (short.strike - long_.strike).copy_abs()
|
||||
credit_eth = short.mid - long_.mid
|
||||
credit_usd = credit_eth * spot
|
||||
ratio = credit_usd / width_usd if width_usd > 0 else Decimal("0")
|
||||
ratio_target = cfg.structure.credit_to_width_ratio_min
|
||||
|
||||
table = Table(title=f"Simulazione picker — bias={bias}, spot={spot:.0f}")
|
||||
table.add_column("Campo", style="cyan")
|
||||
table.add_column("Valore", style="bold")
|
||||
table.add_row("Short strike", f"{short.strike} ({short.delta:+.3f}δ)")
|
||||
table.add_row("Long strike", f"{long_.strike} ({long_.delta:+.3f}δ)")
|
||||
table.add_row("Width", f"{width_usd:.0f} USD")
|
||||
table.add_row("Credit", f"{credit_eth:.4f} ETH ≈ {credit_usd:.2f} USD")
|
||||
table.add_row(
|
||||
"Credit/width ratio",
|
||||
f"{ratio:.2%} (gate ≥ {float(ratio_target):.0%})",
|
||||
)
|
||||
pass_str = (
|
||||
"[green]PASS — entry possibile[/green]"
|
||||
if ratio >= ratio_target
|
||||
else "[red]FAIL — premio troppo magro[/red]"
|
||||
)
|
||||
table.add_row("Verdetto gate ratio", pass_str)
|
||||
console.print(table)
|
||||
|
||||
|
||||
def _resolve_spot_at(db_path: Path, *, asset: str, at: datetime) -> Decimal | None:
|
||||
"""Best-effort lookup dello spot al timestamp ``at`` ± 15 min."""
|
||||
conn = connect_state(db_path)
|
||||
try:
|
||||
rows = Repository().list_market_snapshots(
|
||||
conn,
|
||||
asset=asset,
|
||||
start=at - timedelta(minutes=15),
|
||||
end=at + timedelta(minutes=15),
|
||||
limit=1,
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
if not rows:
|
||||
return None
|
||||
return rows[0].spot
|
||||
|
||||
|
||||
def _atm_spot_proxy(quotes: list[Any]) -> Decimal:
|
||||
"""Stima dello spot prendendo lo strike il cui delta è più vicino a 0.5."""
|
||||
quote = min(quotes, key=lambda q: abs(abs(q.delta) - Decimal("0.5")))
|
||||
return quote.strike
|
||||
|
||||
|
||||
def _entrypoint() -> None:
|
||||
"""Wrapper used by ``cerbero-bite`` console script."""
|
||||
try:
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"""HTTP tool client common to every MCP wrapper.
|
||||
|
||||
Each MCP service exposes ``POST <base_url>/tools/<tool_name>`` with a
|
||||
JSON body and a ``Bearer <core_token>`` header. ``HttpToolClient`` is a
|
||||
thin wrapper around :class:`httpx.AsyncClient` that:
|
||||
JSON body, a ``Bearer <token>`` header (the token decides the upstream
|
||||
environment, testnet or mainnet, on the Cerbero MCP V2 server), and an
|
||||
``X-Bot-Tag`` header that identifies the calling bot in the audit log.
|
||||
``HttpToolClient`` is a thin wrapper around :class:`httpx.AsyncClient`
|
||||
that:
|
||||
|
||||
* Adds the auth header.
|
||||
* Adds the auth and bot-tag headers.
|
||||
* Applies the project-wide timeout (default 8 s, see
|
||||
``docs/10-config-spec.md`` ``mcp.call_timeout_s``).
|
||||
* Retries the call on transient failures with exponential backoff
|
||||
@@ -44,7 +47,7 @@ from cerbero_bite.clients._exceptions import (
|
||||
McpToolError,
|
||||
)
|
||||
|
||||
__all__ = ["HttpToolClient"]
|
||||
__all__ = ["DEFAULT_BOT_TAG", "HttpToolClient"]
|
||||
|
||||
|
||||
_log = logging.getLogger("cerbero_bite.clients")
|
||||
@@ -53,6 +56,12 @@ _RETRYABLE: tuple[type[BaseException], ...] = (
|
||||
McpServerError,
|
||||
)
|
||||
|
||||
# Bot identifier sent on every MCP call via the ``X-Bot-Tag`` header.
|
||||
# The Cerbero MCP V2 server logs this value in the audit record so each
|
||||
# write operation can be traced back to the originating bot.
|
||||
DEFAULT_BOT_TAG = "BOT__CERBERO_BITE"
|
||||
_BOT_TAG_MAX_LEN = 64
|
||||
|
||||
|
||||
class HttpToolClient:
|
||||
"""Async client for ``POST <base>/tools/<tool>`` style MCP services.
|
||||
@@ -61,7 +70,14 @@ class HttpToolClient:
|
||||
service: short service identifier (``"deribit"``, ``"macro"`` …).
|
||||
base_url: e.g. ``"http://mcp-deribit:9011"``. Trailing slash
|
||||
is stripped.
|
||||
token: bearer token for the ``Authorization`` header.
|
||||
token: bearer token for the ``Authorization`` header. On
|
||||
Cerbero MCP V2 the value of the token decides whether the
|
||||
upstream environment is testnet or mainnet; the bot does
|
||||
not need to know which is which.
|
||||
bot_tag: value of the ``X-Bot-Tag`` header. Defaults to
|
||||
:data:`DEFAULT_BOT_TAG` (``"BOT__CERBERO_BITE"``). The
|
||||
server rejects requests with a missing/empty/over-long
|
||||
value with HTTP 400.
|
||||
timeout_s: per-request timeout, default 8 seconds.
|
||||
retry_max: max number of attempts (1 = no retry).
|
||||
retry_base_delay: base delay for exponential backoff.
|
||||
@@ -74,15 +90,24 @@ class HttpToolClient:
|
||||
service: str,
|
||||
base_url: str,
|
||||
token: str,
|
||||
bot_tag: str = DEFAULT_BOT_TAG,
|
||||
timeout_s: float = 8.0,
|
||||
retry_max: int = 3,
|
||||
retry_base_delay: float = 1.0,
|
||||
sleep: Callable[[int | float], Awaitable[None] | None] | None = None,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
) -> None:
|
||||
cleaned_tag = bot_tag.strip()
|
||||
if not cleaned_tag:
|
||||
raise ValueError("bot_tag must be a non-empty string")
|
||||
if len(cleaned_tag) > _BOT_TAG_MAX_LEN:
|
||||
raise ValueError(
|
||||
f"bot_tag exceeds {_BOT_TAG_MAX_LEN} characters: {cleaned_tag!r}"
|
||||
)
|
||||
self._service = service
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._token = token
|
||||
self._bot_tag = cleaned_tag
|
||||
self._timeout = httpx.Timeout(timeout_s)
|
||||
self._retry_max = max(1, retry_max)
|
||||
self._retry_base_delay = retry_base_delay
|
||||
@@ -114,6 +139,7 @@ class HttpToolClient:
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self._token}",
|
||||
"Content-Type": "application/json",
|
||||
"X-Bot-Tag": self._bot_tag,
|
||||
}
|
||||
payload = body or {}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ the ``core/`` algorithms stay in their preferred numeric domain.
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Any, Literal
|
||||
|
||||
@@ -167,9 +167,12 @@ class DeribitClient:
|
||||
) -> Decimal:
|
||||
"""Return the latest DVOL value for ``currency``."""
|
||||
when = (now or datetime.now(UTC)).astimezone(UTC)
|
||||
# Window starts one day back so a tick fired exactly at 00:00 UTC
|
||||
# — before Deribit has built today's 1D candle — still has
|
||||
# yesterday's close to fall back on (see candles[-1] branch).
|
||||
body = {
|
||||
"currency": currency,
|
||||
"start_date": (when.date()).isoformat(),
|
||||
"start_date": (when.date() - timedelta(days=1)).isoformat(),
|
||||
"end_date": when.date().isoformat(),
|
||||
"resolution": "1D",
|
||||
}
|
||||
@@ -303,14 +306,15 @@ class DeribitClient:
|
||||
return Decimal(str(entry["close"]))
|
||||
return None
|
||||
|
||||
async def dealer_gamma_profile_eth(
|
||||
async def dealer_gamma_profile(
|
||||
self,
|
||||
currency: str,
|
||||
*,
|
||||
expiry_from: datetime | None = None,
|
||||
expiry_to: datetime | None = None,
|
||||
top_n_strikes: int = 50,
|
||||
) -> DealerGammaSnapshot:
|
||||
"""Return the aggregated dealer net gamma snapshot for ETH options.
|
||||
"""Return the aggregated dealer net gamma snapshot for ``currency``.
|
||||
|
||||
Long-gamma regime (``total_net_dealer_gamma > 0``) is associated
|
||||
with vol-suppressing dealer hedging — the entry filter §2.8 uses
|
||||
@@ -318,7 +322,7 @@ class DeribitClient:
|
||||
(vol-amplifying dealer flow).
|
||||
"""
|
||||
body: dict[str, Any] = {
|
||||
"currency": "ETH",
|
||||
"currency": currency.upper(),
|
||||
"top_n_strikes": top_n_strikes,
|
||||
}
|
||||
if expiry_from is not None:
|
||||
@@ -347,6 +351,68 @@ class DeribitClient:
|
||||
strikes_analyzed=int(raw.get("strikes_analyzed") or 0),
|
||||
)
|
||||
|
||||
async def dealer_gamma_profile_eth(
|
||||
self,
|
||||
*,
|
||||
expiry_from: datetime | None = None,
|
||||
expiry_to: datetime | None = None,
|
||||
top_n_strikes: int = 50,
|
||||
) -> DealerGammaSnapshot:
|
||||
"""Backwards-compatible alias of :py:meth:`dealer_gamma_profile`."""
|
||||
return await self.dealer_gamma_profile(
|
||||
"ETH",
|
||||
expiry_from=expiry_from,
|
||||
expiry_to=expiry_to,
|
||||
top_n_strikes=top_n_strikes,
|
||||
)
|
||||
|
||||
async def realized_vol(
|
||||
self,
|
||||
currency: str,
|
||||
*,
|
||||
windows: tuple[int, ...] = (14, 30),
|
||||
) -> dict[str, Decimal | None]:
|
||||
"""Annualised realised vol for ``currency`` plus IV-RV spread.
|
||||
|
||||
Returns ``{"rv_14d", "rv_30d", "iv_minus_rv_30d", "iv_current"}``
|
||||
(``None`` for any missing field). Pure read-only — no side
|
||||
effects on the engine.
|
||||
"""
|
||||
raw = await self._http.call(
|
||||
"get_realized_vol",
|
||||
{"currency": currency.upper(), "windows": list(windows)},
|
||||
)
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
rv = raw.get("realized_vol_pct") or {}
|
||||
spread = raw.get("iv_minus_rv_pct") or {}
|
||||
return {
|
||||
"rv_14d": _to_decimal(rv.get("14d")),
|
||||
"rv_30d": _to_decimal(rv.get("30d")),
|
||||
"iv_current": _to_decimal(raw.get("iv_current_pct")),
|
||||
"iv_minus_rv_30d": _to_decimal(spread.get("30d")),
|
||||
"iv_minus_rv_14d": _to_decimal(spread.get("14d")),
|
||||
}
|
||||
|
||||
async def spot_perp_price(self, asset: str) -> Decimal:
|
||||
"""Mark price of ``<ASSET>-PERPETUAL`` (cheap proxy for spot)."""
|
||||
instrument = f"{asset.upper()}-PERPETUAL"
|
||||
raw = await self._http.call("get_ticker", {"instrument": instrument})
|
||||
if not isinstance(raw, dict):
|
||||
raise McpDataAnomalyError(
|
||||
f"get_ticker: unexpected shape for {instrument}",
|
||||
service=self.SERVICE,
|
||||
tool="get_ticker",
|
||||
)
|
||||
mark = raw.get("mark_price") or raw.get("last_price")
|
||||
if mark is None:
|
||||
raise McpDataAnomalyError(
|
||||
f"get_ticker: missing mark_price for {instrument}",
|
||||
service=self.SERVICE,
|
||||
tool="get_ticker",
|
||||
)
|
||||
return Decimal(str(mark))
|
||||
|
||||
async def adx_14(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
"""Wrapper around ``mcp-hyperliquid``.
|
||||
|
||||
Cerbero Bite consumes a single tool: ``get_funding_rate`` for ETH-PERP,
|
||||
used by entry filter §2.6 of ``docs/01-strategy-rules.md`` (cap on the
|
||||
absolute annualised funding rate).
|
||||
Cerbero Bite consumes:
|
||||
|
||||
* ``get_funding_rate`` — entry filter §2.6 cap on absolute annualised
|
||||
funding rate (``docs/01-strategy-rules.md``).
|
||||
* ``get_account_summary`` and ``get_positions`` — feed the in-process
|
||||
portfolio aggregator (equity + ETH/BTC exposure on the perp side).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from cerbero_bite.clients._base import HttpToolClient
|
||||
from cerbero_bite.clients._exceptions import McpDataAnomalyError
|
||||
@@ -47,3 +51,19 @@ class HyperliquidClient:
|
||||
tool="get_funding_rate",
|
||||
)
|
||||
return Decimal(str(rate)) * Decimal(HOURLY_FUNDING_PERIODS_PER_YEAR)
|
||||
|
||||
async def get_account_summary(self) -> dict[str, Any]:
|
||||
"""Account equity and balances (USD)."""
|
||||
raw: Any = await self._http.call("get_account_summary", {})
|
||||
return raw if isinstance(raw, dict) else {}
|
||||
|
||||
async def get_positions(self) -> list[dict[str, Any]]:
|
||||
"""Open perp positions (list of dicts)."""
|
||||
raw: Any = await self._http.call("get_positions", {})
|
||||
if isinstance(raw, list):
|
||||
return raw
|
||||
if isinstance(raw, dict):
|
||||
inner = raw.get("positions")
|
||||
if isinstance(inner, list):
|
||||
return inner
|
||||
return []
|
||||
|
||||
@@ -9,11 +9,13 @@ the requested window. The orchestrator feeds the result straight into
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from cerbero_bite.clients._base import HttpToolClient
|
||||
from cerbero_bite.clients._exceptions import McpDataAnomalyError
|
||||
|
||||
__all__ = ["MacroClient", "MacroEvent"]
|
||||
|
||||
@@ -71,6 +73,34 @@ class MacroClient:
|
||||
)
|
||||
return out
|
||||
|
||||
async def get_asset_price(self, ticker: str) -> Decimal:
|
||||
"""Return the latest cross-asset price for ``ticker`` (e.g. ``EURUSD``)."""
|
||||
raw = await self._http.call("get_asset_price", {"ticker": ticker})
|
||||
if not isinstance(raw, dict):
|
||||
raise McpDataAnomalyError(
|
||||
f"macro get_asset_price unexpected shape: {type(raw).__name__}",
|
||||
service=self.SERVICE,
|
||||
tool="get_asset_price",
|
||||
)
|
||||
if raw.get("error"):
|
||||
raise McpDataAnomalyError(
|
||||
f"macro get_asset_price error for {ticker}: {raw['error']}",
|
||||
service=self.SERVICE,
|
||||
tool="get_asset_price",
|
||||
)
|
||||
price = raw.get("price")
|
||||
if price is None:
|
||||
raise McpDataAnomalyError(
|
||||
f"macro get_asset_price missing 'price' for {ticker}",
|
||||
service=self.SERVICE,
|
||||
tool="get_asset_price",
|
||||
)
|
||||
return Decimal(str(price))
|
||||
|
||||
async def eur_usd_rate(self) -> Decimal:
|
||||
"""Return EUR→USD spot rate (i.e. ``EURUSD`` price)."""
|
||||
return await self.get_asset_price("EURUSD")
|
||||
|
||||
async def next_high_severity_within(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -1,92 +1,157 @@
|
||||
"""Wrapper around ``mcp-portfolio``.
|
||||
"""In-process portfolio aggregator.
|
||||
|
||||
Cerbero Bite uses two pieces of information from this service:
|
||||
Each Cerbero Suite bot now manages its own portfolio view: instead of
|
||||
calling a shared ``mcp-portfolio`` service, this client composes the
|
||||
account summaries and open positions from the exchanges the bot
|
||||
actually uses (Deribit options + Hyperliquid perps) and converts them
|
||||
to EUR via the macro service.
|
||||
|
||||
* total portfolio value (EUR) — fed to the sizing engine after FX
|
||||
conversion to USD;
|
||||
* exposure of a specific asset as percentage of the total portfolio —
|
||||
used by entry filter §2.7 (``eth_holdings_pct_max``).
|
||||
Two values are exposed:
|
||||
|
||||
The portfolio service stores everything in EUR. The orchestrator is
|
||||
responsible for the EUR→USD conversion using a live FX rate.
|
||||
* :py:meth:`total_equity_eur` — sum of USDC equity on Deribit and USD
|
||||
equity on Hyperliquid, converted to EUR using the live ``EURUSD``
|
||||
rate from ``mcp-macro``.
|
||||
* :py:meth:`asset_pct_of_portfolio` — fraction (0..1) of total USD
|
||||
equity exposed to a specific ticker via open positions on the two
|
||||
exchanges. Used by entry filter §2.7 (``eth_holdings_pct_max``).
|
||||
|
||||
**Scope note**: this is the bot's own slice. Holdings on other
|
||||
exchanges, in cold storage, or held by other bots in the suite are
|
||||
*not* counted. The §2.7 limit is therefore a per-bot cap, not a
|
||||
suite-wide one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Iterable
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
from cerbero_bite.clients._base import HttpToolClient
|
||||
from cerbero_bite.clients._exceptions import McpDataAnomalyError
|
||||
from cerbero_bite.clients.deribit import DeribitClient
|
||||
from cerbero_bite.clients.hyperliquid import HyperliquidClient
|
||||
from cerbero_bite.clients.macro import MacroClient
|
||||
|
||||
__all__ = ["PortfolioClient"]
|
||||
|
||||
|
||||
class PortfolioClient:
|
||||
SERVICE = "portfolio"
|
||||
def _decimal_or_zero(value: Any) -> Decimal:
|
||||
if value is None:
|
||||
return Decimal(0)
|
||||
try:
|
||||
return Decimal(str(value))
|
||||
except (ValueError, ArithmeticError):
|
||||
return Decimal(0)
|
||||
|
||||
def __init__(self, http: HttpToolClient) -> None:
|
||||
if http.service != self.SERVICE:
|
||||
raise ValueError(
|
||||
f"PortfolioClient requires service '{self.SERVICE}', got '{http.service}'"
|
||||
)
|
||||
self._http = http
|
||||
|
||||
def _position_notional_usd(pos: dict[str, Any]) -> Decimal:
|
||||
"""Best-effort USD notional of an open position.
|
||||
|
||||
Prefers an explicit ``notional_usd`` / ``size_usd`` / ``value_usd``
|
||||
field. Falls back to ``|size × mark_price|`` (or ``index_price`` if
|
||||
mark is missing). Returns 0 on malformed entries.
|
||||
"""
|
||||
for key in ("notional_usd", "size_usd", "value_usd", "position_value"):
|
||||
v = pos.get(key)
|
||||
if v is not None:
|
||||
return abs(_decimal_or_zero(v))
|
||||
size = _decimal_or_zero(pos.get("size") or pos.get("szi"))
|
||||
mark = _decimal_or_zero(
|
||||
pos.get("mark_price")
|
||||
or pos.get("entry_price")
|
||||
or pos.get("index_price")
|
||||
)
|
||||
return abs(size * mark)
|
||||
|
||||
|
||||
def _instrument_label(pos: dict[str, Any]) -> str:
|
||||
for key in ("instrument_name", "instrument", "symbol", "coin", "asset"):
|
||||
v = pos.get(key)
|
||||
if v is not None:
|
||||
return str(v).upper()
|
||||
return ""
|
||||
|
||||
|
||||
class PortfolioClient:
|
||||
"""Aggregates equity + asset exposure across the bot's exchange accounts."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
deribit: DeribitClient,
|
||||
hyperliquid: HyperliquidClient,
|
||||
macro: MacroClient,
|
||||
) -> None:
|
||||
self._deribit = deribit
|
||||
self._hyperliquid = hyperliquid
|
||||
self._macro = macro
|
||||
|
||||
async def _equity_usd_components(self) -> tuple[Decimal, Decimal]:
|
||||
"""Concurrent fetch of (deribit_equity_usd, hyperliquid_equity_usd)."""
|
||||
deribit_summary, hl_summary = await asyncio.gather(
|
||||
self._deribit.get_account_summary(currency="USDC"),
|
||||
self._hyperliquid.get_account_summary(),
|
||||
)
|
||||
deribit_eq = _decimal_or_zero(deribit_summary.get("equity"))
|
||||
hl_eq = _decimal_or_zero(hl_summary.get("equity"))
|
||||
return deribit_eq, hl_eq
|
||||
|
||||
async def total_equity_usd(self) -> Decimal:
|
||||
"""Sum equity USD across the bot's exchange accounts."""
|
||||
deribit_eq, hl_eq = await self._equity_usd_components()
|
||||
return deribit_eq + hl_eq
|
||||
|
||||
async def total_equity_eur(self) -> Decimal:
|
||||
"""Return the aggregate portfolio value in EUR."""
|
||||
raw = await self._http.call(
|
||||
"get_total_portfolio_value", {"currency": "EUR"}
|
||||
)
|
||||
if not isinstance(raw, dict):
|
||||
"""Return aggregate bot equity in EUR.
|
||||
|
||||
Concurrent: account summaries × FX. Raises
|
||||
:class:`McpDataAnomalyError` if the FX rate is non-positive.
|
||||
"""
|
||||
components_t = asyncio.create_task(self._equity_usd_components())
|
||||
fx_t = asyncio.create_task(self._macro.eur_usd_rate())
|
||||
await asyncio.gather(components_t, fx_t)
|
||||
deribit_eq, hl_eq = components_t.result()
|
||||
fx = fx_t.result()
|
||||
if fx <= 0:
|
||||
raise McpDataAnomalyError(
|
||||
f"portfolio total_value_eur unexpected shape: {type(raw).__name__}",
|
||||
service=self.SERVICE,
|
||||
tool="get_total_portfolio_value",
|
||||
f"non-positive EURUSD rate: {fx}",
|
||||
service="macro",
|
||||
tool="get_asset_price",
|
||||
)
|
||||
value = raw.get("total_value_eur")
|
||||
if value is None:
|
||||
raise McpDataAnomalyError(
|
||||
"portfolio response missing 'total_value_eur'",
|
||||
service=self.SERVICE,
|
||||
tool="get_total_portfolio_value",
|
||||
)
|
||||
return Decimal(str(value))
|
||||
usd_total = deribit_eq + hl_eq
|
||||
return usd_total / fx
|
||||
|
||||
async def asset_pct_of_portfolio(self, ticker: str) -> Decimal:
|
||||
"""Return the fraction (0..1) of the portfolio held in ``ticker``.
|
||||
"""Fraction of bot equity (USD) exposed to ``ticker``.
|
||||
|
||||
Iterates the holdings list and aggregates ``current_value_eur``
|
||||
for any holding whose ticker contains ``ticker`` (case-insensitive).
|
||||
Empty portfolio → 0.
|
||||
Sums absolute USD notional of open positions whose instrument
|
||||
label contains ``ticker`` (case-insensitive) on Deribit and
|
||||
Hyperliquid, divided by the bot's total USD equity. Returns 0
|
||||
when there is no equity or no exposure.
|
||||
"""
|
||||
holdings = await self._http.call("get_holdings", {"min_value_eur": 0})
|
||||
if not isinstance(holdings, list):
|
||||
raise McpDataAnomalyError(
|
||||
f"portfolio get_holdings unexpected shape: {type(holdings).__name__}",
|
||||
service=self.SERVICE,
|
||||
tool="get_holdings",
|
||||
)
|
||||
|
||||
target = ticker.upper()
|
||||
matching_value = Decimal("0")
|
||||
total_value = Decimal("0")
|
||||
for entry in holdings:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
value = entry.get("current_value_eur")
|
||||
if value is None:
|
||||
continue
|
||||
value_dec = Decimal(str(value))
|
||||
total_value += value_dec
|
||||
entry_ticker = str(entry.get("ticker") or "").upper()
|
||||
if target in entry_ticker:
|
||||
matching_value += value_dec
|
||||
deribit_pos_t = asyncio.create_task(
|
||||
self._deribit.get_positions(currency="USDC")
|
||||
)
|
||||
hl_pos_t = asyncio.create_task(self._hyperliquid.get_positions())
|
||||
equity_t = asyncio.create_task(self._equity_usd_components())
|
||||
await asyncio.gather(deribit_pos_t, hl_pos_t, equity_t)
|
||||
|
||||
if total_value == 0:
|
||||
return Decimal("0")
|
||||
return matching_value / total_value
|
||||
exposure_usd = Decimal(0)
|
||||
for raw_pos in cast(Iterable[Any], deribit_pos_t.result()):
|
||||
if not isinstance(raw_pos, dict):
|
||||
continue
|
||||
if target in _instrument_label(raw_pos):
|
||||
exposure_usd += _position_notional_usd(raw_pos)
|
||||
for raw_pos in cast(Iterable[Any], hl_pos_t.result()):
|
||||
if not isinstance(raw_pos, dict):
|
||||
continue
|
||||
if target in _instrument_label(raw_pos):
|
||||
exposure_usd += _position_notional_usd(raw_pos)
|
||||
|
||||
async def health(self) -> dict[str, Any]:
|
||||
"""Lightweight call used by ``cerbero-bite ping``."""
|
||||
result: Any = await self._http.call("get_last_update_info", {})
|
||||
return result if isinstance(result, dict) else {}
|
||||
deribit_eq, hl_eq = equity_t.result()
|
||||
total_eq = deribit_eq + hl_eq
|
||||
if total_eq <= 0:
|
||||
return Decimal(0)
|
||||
return exposure_usd / total_eq
|
||||
|
||||
@@ -1,41 +1,115 @@
|
||||
"""Wrapper around ``mcp-telegram`` (notify-only mode).
|
||||
"""Direct Telegram Bot API client (notify-only).
|
||||
|
||||
Cerbero Bite during the testnet phase (and through the soft launch) is
|
||||
fully autonomous: Telegram is used purely to *notify* Adriano of what
|
||||
the engine has done, never to gate execution. As a consequence:
|
||||
Cerbero Bite is fully autonomous: Telegram is used solely to *notify*
|
||||
the operator of what the engine has done — there is no inbound queue
|
||||
and no confirmation logic.
|
||||
|
||||
* No ``send_with_buttons`` and no callback queue.
|
||||
* Confirmation timeouts are handled inside the orchestrator's own
|
||||
state machine, not by waiting on Telegram replies.
|
||||
* All notifications go through one of the typed endpoints
|
||||
(``notify``, ``notify_position_opened``, ``notify_position_closed``,
|
||||
``notify_alert``, ``notify_system_error``) — the formatting lives
|
||||
on the server side.
|
||||
Credentials are read from the environment:
|
||||
|
||||
* ``CERBERO_BITE_TELEGRAM_BOT_TOKEN`` — bot token from BotFather.
|
||||
* ``CERBERO_BITE_TELEGRAM_CHAT_ID`` — destination chat id.
|
||||
|
||||
If either is missing the client runs in **disabled** mode: every
|
||||
``notify_*`` becomes a no-op logged at DEBUG. This keeps unconfigured
|
||||
deployments and the test environment harmless.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from cerbero_bite.clients._base import HttpToolClient
|
||||
import httpx
|
||||
|
||||
__all__ = ["TelegramClient"]
|
||||
__all__ = [
|
||||
"TELEGRAM_BOT_TOKEN_ENV",
|
||||
"TELEGRAM_CHAT_ID_ENV",
|
||||
"TelegramClient",
|
||||
"TelegramError",
|
||||
"load_telegram_credentials",
|
||||
]
|
||||
|
||||
|
||||
def _to_float(value: Decimal | float) -> float:
|
||||
return float(value) if isinstance(value, Decimal) else value
|
||||
TELEGRAM_BOT_TOKEN_ENV = "CERBERO_BITE_TELEGRAM_BOT_TOKEN"
|
||||
TELEGRAM_CHAT_ID_ENV = "CERBERO_BITE_TELEGRAM_CHAT_ID"
|
||||
|
||||
_log = logging.getLogger("cerbero_bite.clients.telegram")
|
||||
|
||||
|
||||
class TelegramError(RuntimeError):
|
||||
"""Raised when the Telegram Bot API rejects a sendMessage call."""
|
||||
|
||||
|
||||
def _to_float(value: Decimal | float | int) -> float:
|
||||
return float(value)
|
||||
|
||||
|
||||
def load_telegram_credentials(
|
||||
env: dict[str, str] | None = None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""Return ``(bot_token, chat_id)`` from env. Empty strings → ``None``."""
|
||||
e = env if env is not None else os.environ
|
||||
token = (e.get(TELEGRAM_BOT_TOKEN_ENV) or "").strip() or None
|
||||
chat = (e.get(TELEGRAM_CHAT_ID_ENV) or "").strip() or None
|
||||
return token, chat
|
||||
|
||||
|
||||
class TelegramClient:
|
||||
SERVICE = "telegram"
|
||||
"""Notify-only client over the public Telegram Bot API."""
|
||||
|
||||
def __init__(self, http: HttpToolClient) -> None:
|
||||
if http.service != self.SERVICE:
|
||||
raise ValueError(
|
||||
f"TelegramClient requires service '{self.SERVICE}', got '{http.service}'"
|
||||
BASE_URL = "https://api.telegram.org"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
bot_token: str | None,
|
||||
chat_id: str | None,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
timeout_s: float = 5.0,
|
||||
parse_mode: str = "HTML",
|
||||
) -> None:
|
||||
self._token = (bot_token or "").strip() or None
|
||||
self._chat_id = (str(chat_id).strip() if chat_id is not None else "") or None
|
||||
self._client = http_client
|
||||
self._timeout = timeout_s
|
||||
self._parse_mode = parse_mode
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return self._token is not None and self._chat_id is not None
|
||||
|
||||
async def _send(self, text: str) -> None:
|
||||
if not self.enabled:
|
||||
_log.debug("telegram disabled, dropping message: %s", text[:120])
|
||||
return
|
||||
url = f"{self.BASE_URL}/bot{self._token}/sendMessage"
|
||||
payload: dict[str, Any] = {
|
||||
"chat_id": self._chat_id,
|
||||
"text": text,
|
||||
"parse_mode": self._parse_mode,
|
||||
"disable_web_page_preview": True,
|
||||
}
|
||||
client = self._client
|
||||
owns = client is None
|
||||
if client is None:
|
||||
client = httpx.AsyncClient(timeout=self._timeout)
|
||||
try:
|
||||
resp = await client.post(url, json=payload, timeout=self._timeout)
|
||||
finally:
|
||||
if owns:
|
||||
await client.aclose()
|
||||
if resp.status_code != 200:
|
||||
raise TelegramError(
|
||||
f"telegram HTTP {resp.status_code}: {resp.text[:200]}"
|
||||
)
|
||||
self._http = http
|
||||
data = resp.json()
|
||||
if not isinstance(data, dict) or not data.get("ok", False):
|
||||
desc = (
|
||||
data.get("description", "?") if isinstance(data, dict) else str(data)
|
||||
)
|
||||
raise TelegramError(f"telegram api error: {desc}")
|
||||
|
||||
async def notify(
|
||||
self,
|
||||
@@ -44,10 +118,10 @@ class TelegramClient:
|
||||
priority: str = "normal",
|
||||
tag: str | None = None,
|
||||
) -> None:
|
||||
body: dict[str, Any] = {"message": message, "priority": priority}
|
||||
if tag is not None:
|
||||
body["tag"] = tag
|
||||
await self._http.call("notify", body)
|
||||
prefix = f"[{priority.upper()}]"
|
||||
if tag:
|
||||
prefix = f"{prefix}[{tag}]"
|
||||
await self._send(f"{prefix} {message}")
|
||||
|
||||
async def notify_position_opened(
|
||||
self,
|
||||
@@ -59,17 +133,19 @@ class TelegramClient:
|
||||
greeks: dict[str, Decimal | float] | None = None,
|
||||
expected_pnl_usd: Decimal | float | None = None,
|
||||
) -> None:
|
||||
body: dict[str, Any] = {
|
||||
"instrument": instrument,
|
||||
"side": side,
|
||||
"size": float(size),
|
||||
"strategy": strategy,
|
||||
}
|
||||
if greeks is not None:
|
||||
body["greeks"] = {k: _to_float(v) for k, v in greeks.items()}
|
||||
lines = [
|
||||
"<b>POSITION OPENED</b>",
|
||||
f"instrument: <code>{instrument}</code>",
|
||||
f"side: {side} | size: {size} | strategy: {strategy}",
|
||||
]
|
||||
if greeks:
|
||||
joined = ", ".join(
|
||||
f"{k}={_to_float(v):+.4f}" for k, v in greeks.items()
|
||||
)
|
||||
lines.append(f"greeks: {joined}")
|
||||
if expected_pnl_usd is not None:
|
||||
body["expected_pnl"] = _to_float(expected_pnl_usd)
|
||||
await self._http.call("notify_position_opened", body)
|
||||
lines.append(f"expected pnl: ${_to_float(expected_pnl_usd):+.2f}")
|
||||
await self._send("\n".join(lines))
|
||||
|
||||
async def notify_position_closed(
|
||||
self,
|
||||
@@ -78,13 +154,12 @@ class TelegramClient:
|
||||
realized_pnl_usd: Decimal | float,
|
||||
reason: str,
|
||||
) -> None:
|
||||
await self._http.call(
|
||||
"notify_position_closed",
|
||||
{
|
||||
"instrument": instrument,
|
||||
"realized_pnl": _to_float(realized_pnl_usd),
|
||||
"reason": reason,
|
||||
},
|
||||
pnl = _to_float(realized_pnl_usd)
|
||||
await self._send(
|
||||
"<b>POSITION CLOSED</b>\n"
|
||||
f"instrument: <code>{instrument}</code>\n"
|
||||
f"realized pnl: ${pnl:+.2f}\n"
|
||||
f"reason: {reason}"
|
||||
)
|
||||
|
||||
async def notify_alert(
|
||||
@@ -94,9 +169,10 @@ class TelegramClient:
|
||||
message: str,
|
||||
priority: str = "high",
|
||||
) -> None:
|
||||
await self._http.call(
|
||||
"notify_alert",
|
||||
{"source": source, "message": message, "priority": priority},
|
||||
await self._send(
|
||||
f"<b>ALERT [{priority.upper()}]</b>\n"
|
||||
f"source: {source}\n"
|
||||
f"{message}"
|
||||
)
|
||||
|
||||
async def notify_system_error(
|
||||
@@ -106,7 +182,8 @@ class TelegramClient:
|
||||
component: str | None = None,
|
||||
priority: str = "critical",
|
||||
) -> None:
|
||||
body: dict[str, Any] = {"message": message, "priority": priority}
|
||||
if component is not None:
|
||||
body["component"] = component
|
||||
await self._http.call("notify_system_error", body)
|
||||
text = f"<b>SYSTEM ERROR [{priority.upper()}]</b>\n"
|
||||
if component:
|
||||
text += f"component: {component}\n"
|
||||
text += message
|
||||
await self._send(text)
|
||||
|
||||
@@ -1,43 +1,55 @@
|
||||
"""Resolve MCP service URLs and the bearer token.
|
||||
"""Resolve MCP service URLs, the bearer token and the bot tag.
|
||||
|
||||
Cerbero Bite runs in its own Docker container that joins the
|
||||
``cerbero-suite`` network: every MCP service is reachable by the
|
||||
container DNS name plus its internal port (``mcp-deribit:9011`` etc.).
|
||||
Cerbero MCP V2 (a single FastAPI image fronting Deribit, Hyperliquid,
|
||||
Macro, Sentiment and friends) is deployed on a dedicated VPS and reached
|
||||
through the public gateway at ``https://cerbero-mcp.tielogic.xyz``. The
|
||||
server decides the upstream environment (testnet vs mainnet) entirely
|
||||
from the bearer token attached to each request — Cerbero Bite does not
|
||||
have to be told which is which: swapping the token in ``.env`` is enough
|
||||
to switch environments.
|
||||
|
||||
The resolver supports two layers of override:
|
||||
The resolver supports the following layers of override:
|
||||
|
||||
1. Per-service environment variables (``CERBERO_BITE_MCP_DERIBIT_URL``,
|
||||
``CERBERO_BITE_MCP_MACRO_URL``…). Useful for dev when running
|
||||
outside Docker — point at ``http://localhost:9011`` etc.
|
||||
2. ``CERBERO_BITE_CORE_TOKEN_FILE`` env var: path to the file that
|
||||
stores the bearer token (default
|
||||
``/run/secrets/core_token``). The file is read at boot, the
|
||||
trailing whitespace is stripped, and the value is *not* logged.
|
||||
1. Per-service URL env vars (``CERBERO_BITE_MCP_DERIBIT_URL``,
|
||||
``CERBERO_BITE_MCP_HYPERLIQUID_URL``, ``CERBERO_BITE_MCP_MACRO_URL``,
|
||||
``CERBERO_BITE_MCP_SENTIMENT_URL``). Useful for local dev when the
|
||||
bot must talk to a same-host MCP server (``http://localhost:9000``)
|
||||
instead of the public gateway.
|
||||
2. ``CERBERO_BITE_MCP_TOKEN`` env var: the bearer token used on every
|
||||
request. The token's value is *never* logged.
|
||||
3. ``CERBERO_BITE_MCP_BOT_TAG`` env var: identifier sent on the
|
||||
``X-Bot-Tag`` header (default ``BOT__CERBERO_BITE``). Must be a
|
||||
non-empty string of at most 64 characters.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from cerbero_bite.clients._base import DEFAULT_BOT_TAG
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_BOT_TAG",
|
||||
"DEFAULT_ENDPOINTS",
|
||||
"MCP_SERVICES",
|
||||
"McpEndpoints",
|
||||
"load_bot_tag",
|
||||
"load_endpoints",
|
||||
"load_token",
|
||||
]
|
||||
|
||||
|
||||
# Service identifier → (default Docker DNS host, default port, env var name)
|
||||
#
|
||||
# Telegram and Portfolio used to be shared MCP services; both are now
|
||||
# in-process per bot (Telegram → public Bot API, Portfolio → aggregator
|
||||
# over Deribit + Hyperliquid + Macro). They are no longer listed here.
|
||||
MCP_SERVICES: dict[str, tuple[str, int, str]] = {
|
||||
"deribit": ("mcp-deribit", 9011, "CERBERO_BITE_MCP_DERIBIT_URL"),
|
||||
"hyperliquid": ("mcp-hyperliquid", 9012, "CERBERO_BITE_MCP_HYPERLIQUID_URL"),
|
||||
"macro": ("mcp-macro", 9013, "CERBERO_BITE_MCP_MACRO_URL"),
|
||||
"sentiment": ("mcp-sentiment", 9014, "CERBERO_BITE_MCP_SENTIMENT_URL"),
|
||||
"telegram": ("mcp-telegram", 9017, "CERBERO_BITE_MCP_TELEGRAM_URL"),
|
||||
"portfolio": ("mcp-portfolio", 9018, "CERBERO_BITE_MCP_PORTFOLIO_URL"),
|
||||
}
|
||||
|
||||
|
||||
@@ -58,8 +70,6 @@ class McpEndpoints:
|
||||
hyperliquid: str
|
||||
macro: str
|
||||
sentiment: str
|
||||
telegram: str
|
||||
portfolio: str
|
||||
|
||||
def for_service(self, name: str) -> str:
|
||||
try:
|
||||
@@ -78,31 +88,58 @@ def load_endpoints(env: dict[str, str] | None = None) -> McpEndpoints:
|
||||
return McpEndpoints(**resolved)
|
||||
|
||||
|
||||
_DEFAULT_TOKEN_FILE = "/run/secrets/core_token"
|
||||
_TOKEN_FILE_ENV = "CERBERO_BITE_CORE_TOKEN_FILE"
|
||||
_TOKEN_ENV = "CERBERO_BITE_MCP_TOKEN"
|
||||
_BOT_TAG_ENV = "CERBERO_BITE_MCP_BOT_TAG"
|
||||
_BOT_TAG_MAX_LEN = 64
|
||||
|
||||
|
||||
def load_token(
|
||||
*,
|
||||
path: str | Path | None = None,
|
||||
value: str | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
"""Read the bearer token from disk and return it stripped.
|
||||
"""Return the MCP bearer token, stripped of surrounding whitespace.
|
||||
|
||||
Resolution order:
|
||||
1. explicit ``path`` argument;
|
||||
2. ``CERBERO_BITE_CORE_TOKEN_FILE`` env var;
|
||||
3. ``/run/secrets/core_token`` (Docker secrets default).
|
||||
1. explicit ``value`` argument (e.g. from a CLI flag);
|
||||
2. ``CERBERO_BITE_MCP_TOKEN`` env var.
|
||||
"""
|
||||
if value is not None:
|
||||
token = value.strip()
|
||||
if not token:
|
||||
raise ValueError("explicit MCP token is empty")
|
||||
return token
|
||||
e = env if env is not None else os.environ
|
||||
target = (
|
||||
Path(path)
|
||||
if path is not None
|
||||
else Path(e.get(_TOKEN_FILE_ENV, _DEFAULT_TOKEN_FILE))
|
||||
)
|
||||
if not target.is_file():
|
||||
raise FileNotFoundError(f"core token file not found: {target}")
|
||||
token = target.read_text(encoding="utf-8").strip()
|
||||
raw = e.get(_TOKEN_ENV, "")
|
||||
token = raw.strip()
|
||||
if not token:
|
||||
raise ValueError(f"core token file is empty: {target}")
|
||||
raise ValueError(
|
||||
f"{_TOKEN_ENV} is unset or empty; set it in .env to the testnet or "
|
||||
"mainnet bearer issued by Cerbero MCP"
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
def load_bot_tag(
|
||||
*,
|
||||
value: str | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
"""Return the ``X-Bot-Tag`` value, with the project default as fallback.
|
||||
|
||||
Resolution order:
|
||||
1. explicit ``value`` argument;
|
||||
2. ``CERBERO_BITE_MCP_BOT_TAG`` env var;
|
||||
3. :data:`DEFAULT_BOT_TAG` (``"BOT__CERBERO_BITE"``).
|
||||
"""
|
||||
raw = value if value is not None else (env if env is not None else os.environ).get(
|
||||
_BOT_TAG_ENV, ""
|
||||
)
|
||||
cleaned = raw.strip() if raw else ""
|
||||
if not cleaned:
|
||||
return DEFAULT_BOT_TAG
|
||||
if len(cleaned) > _BOT_TAG_MAX_LEN:
|
||||
raise ValueError(
|
||||
f"{_BOT_TAG_ENV} exceeds {_BOT_TAG_MAX_LEN} characters: {cleaned!r}"
|
||||
)
|
||||
return cleaned
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Operational mode flags read from the environment.
|
||||
|
||||
Cerbero Bite supports two independent runtime switches:
|
||||
|
||||
* ``CERBERO_BITE_ENABLE_DATA_ANALYSIS`` — when ``true``, the periodic
|
||||
market-snapshot job is scheduled and writes 15-minute snapshots to
|
||||
``market_snapshots``; when ``false``, the bot still pings MCP for
|
||||
health and reconciliation but does not record any market dataset.
|
||||
* ``CERBERO_BITE_ENABLE_STRATEGY`` — when ``true``, the entry and
|
||||
monitor cycles are scheduled and may propose/execute trades; when
|
||||
``false``, no entry or monitor logic runs autonomously (the methods
|
||||
remain callable from the CLI ``dry-run`` and via manual actions, so
|
||||
the operator can still test code paths on demand).
|
||||
|
||||
The default profile is "analysis only": data analysis on, strategy off.
|
||||
This is the mode used during the post-deploy soak window where the
|
||||
team observes data quality before opening any position.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
__all__ = [
|
||||
"DATA_ANALYSIS_ENV",
|
||||
"STRATEGY_ENV",
|
||||
"RuntimeFlags",
|
||||
"load_runtime_flags",
|
||||
]
|
||||
|
||||
DATA_ANALYSIS_ENV = "CERBERO_BITE_ENABLE_DATA_ANALYSIS"
|
||||
STRATEGY_ENV = "CERBERO_BITE_ENABLE_STRATEGY"
|
||||
|
||||
_TRUE_TOKENS = frozenset({"1", "true", "yes", "on", "enabled"})
|
||||
_FALSE_TOKENS = frozenset({"0", "false", "no", "off", "disabled"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeFlags:
|
||||
"""Boolean switches that gate optional cycles.
|
||||
|
||||
Both fields default to the canonical "analysis only" profile.
|
||||
"""
|
||||
|
||||
data_analysis_enabled: bool = True
|
||||
strategy_enabled: bool = False
|
||||
|
||||
|
||||
def _parse_bool(raw: str, *, var: str, default: bool) -> bool:
|
||||
cleaned = raw.strip().lower()
|
||||
if not cleaned:
|
||||
return default
|
||||
if cleaned in _TRUE_TOKENS:
|
||||
return True
|
||||
if cleaned in _FALSE_TOKENS:
|
||||
return False
|
||||
raise ValueError(
|
||||
f"{var}: expected one of "
|
||||
f"{sorted(_TRUE_TOKENS | _FALSE_TOKENS)}, got {raw!r}"
|
||||
)
|
||||
|
||||
|
||||
def load_runtime_flags(env: dict[str, str] | None = None) -> RuntimeFlags:
|
||||
"""Build a :class:`RuntimeFlags` from environment variables."""
|
||||
e = env if env is not None else os.environ
|
||||
return RuntimeFlags(
|
||||
data_analysis_enabled=_parse_bool(
|
||||
e.get(DATA_ANALYSIS_ENV, ""),
|
||||
var=DATA_ANALYSIS_ENV,
|
||||
default=True,
|
||||
),
|
||||
strategy_enabled=_parse_bool(
|
||||
e.get(STRATEGY_ENV, ""),
|
||||
var=STRATEGY_ENV,
|
||||
default=False,
|
||||
),
|
||||
)
|
||||
@@ -47,7 +47,7 @@ class EntryConfig(BaseModel):
|
||||
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
cron: str = "0 14 * * MON"
|
||||
cron: str = "0 14 * * *"
|
||||
skip_holidays_country: str = "IT"
|
||||
|
||||
# access filters (§2)
|
||||
@@ -75,12 +75,50 @@ class EntryConfig(BaseModel):
|
||||
dealer_gamma_filter_enabled: bool = True
|
||||
liquidation_filter_enabled: bool = True
|
||||
|
||||
# IV richness filter (§2.9). `iv_minus_rv_min` è la soglia in
|
||||
# punti vol che la IV implicita 30g deve eccedere la RV30g per
|
||||
# ammettere l'entry. Letteratura short-vol systematic: l'edge
|
||||
# sostenibile esiste solo con un margine misurabile fra IV e RV.
|
||||
# Default disabilitato + soglia 0 per non bloccare l'avvio finché
|
||||
# non si è calibrato sui dati raccolti (vedi `📐 Calibrazione`).
|
||||
iv_minus_rv_min: Decimal = Field(default=Decimal("0"))
|
||||
iv_minus_rv_filter_enabled: bool = False
|
||||
|
||||
# IV richness gate adattivo (Phase 5+). Quando
|
||||
# `iv_minus_rv_adaptive_enabled=True`, la soglia statica
|
||||
# `iv_minus_rv_min` diventa il floor assoluto e la soglia
|
||||
# effettiva è `max(P_q rolling, floor)` calcolata su
|
||||
# `market_snapshots`. Vedi
|
||||
# `docs/superpowers/specs/2026-05-08-iv-rv-adaptive-gate-design.md`.
|
||||
iv_minus_rv_adaptive_enabled: bool = False
|
||||
iv_minus_rv_percentile: Decimal = Field(default=Decimal("0.25"))
|
||||
iv_minus_rv_window_target_days: int = 60
|
||||
iv_minus_rv_window_min_days: int = 30
|
||||
|
||||
# Vol-of-Vol guard (§4-quater roadmap punto 2): blocca entry se
|
||||
# |DVOL_now - DVOL_24h_ago| supera la soglia. Cattura regime
|
||||
# shift bruschi non riflessi nel percentile rolling.
|
||||
vol_of_vol_guard_enabled: bool = False
|
||||
vol_of_vol_threshold_pt: Decimal = Field(default=Decimal("5"))
|
||||
vol_of_vol_lookback_hours: int = 24
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Structure
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class DeltaByDvolBand(BaseModel):
|
||||
"""Banda della step function delta-target per regime DVOL (§3.2 A)."""
|
||||
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
dvol_under: Decimal
|
||||
delta_target: Decimal
|
||||
delta_min: Decimal
|
||||
delta_max: Decimal
|
||||
|
||||
|
||||
class ShortStrikeSpec(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
@@ -90,6 +128,16 @@ class ShortStrikeSpec(BaseModel):
|
||||
distance_otm_pct_min: Decimal = Field(default=Decimal("0.15"))
|
||||
distance_otm_pct_max: Decimal = Field(default=Decimal("0.25"))
|
||||
|
||||
# §3.2 enhancement (A): step function delta-target by DVOL regime.
|
||||
# Empty list = behaviour invariato (delta_target sopra è il singolo
|
||||
# valore). Quando popolato, il combo_builder sceglie la prima
|
||||
# banda ordinata ascending su `dvol_under` con
|
||||
# `dvol_now ≤ dvol_under`. Esempio:
|
||||
# - dvol_under=50 → delta 0.15 (bassa vol → più premio)
|
||||
# - dvol_under=70 → delta 0.12
|
||||
# - dvol_under=90 → delta 0.10 (alta vol → più safety)
|
||||
delta_by_dvol: list[DeltaByDvolBand] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SpreadWidthSpec(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
@@ -153,7 +201,7 @@ class SizingConfig(BaseModel):
|
||||
|
||||
cap_per_trade_eur: Decimal = Field(default=Decimal("200"))
|
||||
cap_aggregate_open_eur: Decimal = Field(default=Decimal("1000"))
|
||||
max_concurrent_positions: int = 1
|
||||
max_concurrent_positions: int = 5
|
||||
max_contracts_per_trade: int = 4
|
||||
|
||||
dvol_adjustment: list[DvolAdjustmentBand] = Field(default_factory=_default_dvol_bands)
|
||||
@@ -165,6 +213,25 @@ class SizingConfig(BaseModel):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PartialProfitLevel(BaseModel):
|
||||
"""Livello della scala di profit-take graduale (§7.1bis C).
|
||||
|
||||
`mark_at_pct_credit`: il livello è triggerato quando
|
||||
`mark_combo ≤ mark_at_pct_credit × credito_iniziale` (es. 0.25 =
|
||||
25% del credito = 75% di profitto sulla porzione chiusa).
|
||||
|
||||
`close_pct_of_initial_contracts`: frazione dei contratti aperti
|
||||
INIZIALMENTE da chiudere a questo livello (es. 0.50 = chiudi metà).
|
||||
Le frazioni sono cumulative; chiudere oltre i contratti residui
|
||||
è no-op.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
mark_at_pct_credit: Decimal
|
||||
close_pct_of_initial_contracts: Decimal
|
||||
|
||||
|
||||
class ExitConfig(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
@@ -176,6 +243,29 @@ class ExitConfig(BaseModel):
|
||||
delta_breach_threshold: Decimal = Field(default=Decimal("0.30"))
|
||||
adverse_move_4h_pct: Decimal = Field(default=Decimal("0.05"))
|
||||
|
||||
# §7.1ter (D): vol-collapse harvest. Esce in profit anche se il
|
||||
# profit-take non è ancora colpito quando DVOL è scesa di tot
|
||||
# punti rispetto all'entry (edge IV-RV catturato, vol attesa già
|
||||
# rientrata). 0 = filtro disabilitato.
|
||||
vol_harvest_dvol_decrease: Decimal = Field(default=Decimal("0"))
|
||||
|
||||
# §7.1bis (C): scala graduata di profit-take. Lista vuota =
|
||||
# comportamento invariato (chiusura atomica al
|
||||
# `profit_take_pct_of_credit`). Quando popolata, l'engine
|
||||
# interpreta come "chiudi N% dei contratti iniziali al livello
|
||||
# di mark M%×credito". Le entry sono ordinate dal mark più alto
|
||||
# (più profit, livello triggerato prima) al più basso. Vedi
|
||||
# `core/exit_decision.py` per la semantica esatta.
|
||||
#
|
||||
# ATTENZIONE: questa funzione richiede il supporto di chiusure
|
||||
# parziali nel runtime (entry_cycle / repository / clients).
|
||||
# Fino al merge della partial-close pipeline, l'engine la mappa
|
||||
# a CLOSE_PROFIT atomico al primo livello triggerato (vedi
|
||||
# commento in `evaluate`). Default vuoto = no-op.
|
||||
profit_take_partial_levels: list[PartialProfitLevel] = Field(
|
||||
default_factory=list
|
||||
)
|
||||
|
||||
monitor_cron: str = "0 2,14 * * *"
|
||||
user_confirmation_timeout_min: int = 30
|
||||
escalate_on_timeout: list[str] = Field(
|
||||
@@ -183,6 +273,36 @@ class ExitConfig(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auto-pause (F): circuit breaker su drawdown rolling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AutoPauseConfig(BaseModel):
|
||||
"""Configurazione del circuit breaker su drawdown.
|
||||
|
||||
Quando abilitato, il rule engine valuta — prima di ogni entry —
|
||||
il P/L cumulato delle ultime `lookback_trades` posizioni chiuse
|
||||
in proporzione al capitale attuale. Se la perdita supera la
|
||||
soglia, l'engine si auto-mette in pausa per `pause_days`
|
||||
giorni (skip-day). La pausa si annulla automaticamente alla
|
||||
scadenza, oppure manualmente via comando dalla GUI.
|
||||
|
||||
Difende da regime change non rilevati dai filtri quant: se i
|
||||
filtri stanno fallendo sistematicamente, vale la pena fermarsi
|
||||
e attendere che le condizioni cambino, invece di continuare a
|
||||
sanguinare. È un'estensione conservativa del kill switch
|
||||
(che oggi reagisce solo a errori tecnici).
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
enabled: bool = False
|
||||
lookback_trades: int = 5
|
||||
max_drawdown_pct: Decimal = Field(default=Decimal("0.10"))
|
||||
pause_days: int = 14
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Kelly recalibration
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -256,6 +376,7 @@ class StrategyConfig(BaseModel):
|
||||
sizing: SizingConfig = Field(default_factory=SizingConfig)
|
||||
exit: ExitConfig = Field(default_factory=ExitConfig)
|
||||
kelly_recalibration: KellyConfig = Field(default_factory=KellyConfig)
|
||||
auto_pause: AutoPauseConfig = Field(default_factory=AutoPauseConfig)
|
||||
|
||||
execution: ExecutionConfig = Field(default_factory=ExecutionConfig)
|
||||
monitoring: MonitoringConfig = Field(default_factory=MonitoringConfig)
|
||||
@@ -286,6 +407,16 @@ class StrategyConfig(BaseModel):
|
||||
if self.entry.dvol_min >= self.entry.dvol_max:
|
||||
raise ValueError("dvol_min must be < dvol_max")
|
||||
|
||||
e = self.entry
|
||||
if e.iv_minus_rv_window_min_days >= e.iv_minus_rv_window_target_days:
|
||||
raise ValueError(
|
||||
"iv_minus_rv_window_min_days must be < iv_minus_rv_window_target_days"
|
||||
)
|
||||
if not (Decimal("0") < e.iv_minus_rv_percentile < Decimal("1")):
|
||||
raise ValueError(
|
||||
"iv_minus_rv_percentile must be in (0, 1)"
|
||||
)
|
||||
|
||||
return self
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Funzione pura per calcolare la soglia adattiva del gate IV-RV.
|
||||
|
||||
Spec: ``docs/superpowers/specs/2026-05-08-iv-rv-adaptive-gate-design.md``.
|
||||
|
||||
Deterministic, no I/O. La selezione della finestra (target_days vs
|
||||
min_days vs intera storia disponibile) è responsabilità del caller, che
|
||||
interroga il repository con i parametri corretti e passa qui sia i
|
||||
valori (``history``) sia il numero di giorni distinti coperti
|
||||
(``n_days``). Questo permette di mischiare cadenze diverse — tick live a
|
||||
15 min e backfill daily — senza assumere un fattore costante
|
||||
``ticks_per_day``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from decimal import Decimal
|
||||
|
||||
__all__ = ["compute_adaptive_threshold"]
|
||||
|
||||
|
||||
def compute_adaptive_threshold(
|
||||
history: Sequence[Decimal],
|
||||
*,
|
||||
n_days: int,
|
||||
percentile: Decimal,
|
||||
absolute_floor: Decimal,
|
||||
) -> Decimal | None:
|
||||
"""Ritorna la soglia adattiva o ``None`` durante il warmup hard.
|
||||
|
||||
Args:
|
||||
history: Sequenza dei valori IV-RV nella finestra scelta dal
|
||||
caller. NULL e tick non riusciti devono essere già stati
|
||||
filtrati upstream. L'ordine non è significativo per il
|
||||
percentile.
|
||||
n_days: Numero di giorni distinti coperti dalla storia
|
||||
disponibile (calcolato dal caller, tipicamente con
|
||||
``COUNT(DISTINCT date(timestamp))``). ``0`` → warmup hard.
|
||||
percentile: Quantile target nella distribuzione (es. ``0.25``).
|
||||
absolute_floor: Floor minimo applicato dopo il calcolo del
|
||||
percentile. La soglia restituita è
|
||||
``max(P_q, absolute_floor)``.
|
||||
|
||||
Returns:
|
||||
``None`` se ``n_days == 0`` o ``history`` è vuota (warmup hard,
|
||||
gate disabilitato), altrimenti il percentile della finestra
|
||||
bounded dal floor.
|
||||
"""
|
||||
if not (Decimal(0) <= percentile <= Decimal(1)):
|
||||
raise ValueError(
|
||||
f"percentile must be in [0, 1], got {percentile}"
|
||||
)
|
||||
if n_days < 0:
|
||||
raise ValueError(f"n_days must be >= 0, got {n_days}")
|
||||
if n_days == 0 or not history:
|
||||
return None
|
||||
return max(_percentile(history, percentile), absolute_floor)
|
||||
|
||||
|
||||
def _percentile(values: Sequence[Decimal], q: Decimal) -> Decimal:
|
||||
"""Linear-interpolated percentile, NumPy-compatible (method='linear').
|
||||
|
||||
Implementato in Decimal puro per evitare dipendenze numpy nel core.
|
||||
"""
|
||||
if not values:
|
||||
raise ValueError("percentile of empty sequence")
|
||||
sorted_v = sorted(values)
|
||||
n = len(sorted_v)
|
||||
k = (Decimal(n) - Decimal(1)) * q
|
||||
f = int(k) # floor
|
||||
c = min(f + 1, n - 1)
|
||||
if f == c:
|
||||
return sorted_v[f]
|
||||
frac = k - Decimal(f)
|
||||
return sorted_v[f] + (sorted_v[c] - sorted_v[f]) * frac
|
||||
@@ -0,0 +1,651 @@
|
||||
"""Stylized backtest engine over ``market_snapshots`` (§13).
|
||||
|
||||
Two layers, both pure functions:
|
||||
|
||||
1. **Entry-filter simulation** — for each daily 14:00 UTC tick in the
|
||||
recorded snapshots, evaluate which §2 gates would have passed,
|
||||
reconstructing :class:`EntryContext` from the snapshot. This part
|
||||
is **rigorous**: it uses the same :func:`validate_entry` the live
|
||||
engine uses, so the output is exactly "what the bot would have
|
||||
decided".
|
||||
|
||||
2. **P/L estimation per accepted entry** — since ``market_snapshots``
|
||||
does NOT record the option chain (we only collect spot, DVOL,
|
||||
funding, etc.), credit and exit P/L are estimated via a stylized
|
||||
Black-Scholes model: given ``spot``, ``DVOL`` (as IV), and the
|
||||
strategy's delta target, we solve for the short strike, the long
|
||||
strike at ``width_pct`` distance, and the combo mid-price. Future
|
||||
ticks are then re-priced under the same model to detect the first
|
||||
exit trigger from §7.
|
||||
|
||||
The stylized layer is **intentionally approximate**: it captures the
|
||||
geometry of the strategy (DVOL band sets credit, ETH path drives
|
||||
exit triggers) but not the second-order effects (chain liquidity,
|
||||
borrow rates, exchange fees beyond the 0.03% notional cap, dealer
|
||||
hedging skew). Numbers are good for ranking and tuning, not for
|
||||
operational P/L promises.
|
||||
|
||||
The engine is deterministic and side-effect-free: it does **not**
|
||||
write to SQLite, does not call MCP, does not place orders. It
|
||||
operates entirely on a list of :class:`MarketSnapshotRecord` rows
|
||||
the caller has already loaded.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from cerbero_bite.config.schema import StrategyConfig
|
||||
from cerbero_bite.core.entry_validator import EntryContext, validate_entry
|
||||
from cerbero_bite.state.models import MarketSnapshotRecord
|
||||
|
||||
__all__ = [
|
||||
"BacktestEntry",
|
||||
"BacktestExit",
|
||||
"BacktestReport",
|
||||
"DailyPick",
|
||||
"bs_put_delta",
|
||||
"bs_put_price",
|
||||
"daily_picks",
|
||||
"estimate_credit_eth",
|
||||
"find_strike_for_delta",
|
||||
"normal_cdf",
|
||||
"run_backtest",
|
||||
"simulate_entry_filters",
|
||||
"simulate_position_outcome",
|
||||
]
|
||||
|
||||
|
||||
_ANNUAL_DAYS = Decimal("365")
|
||||
_DEFAULT_RISK_FREE = Decimal("0")
|
||||
_NUM_SLIPPAGE_PCT_OF_CREDIT = Decimal("0.03")
|
||||
_NUM_FEE_PCT_OF_NOTIONAL = Decimal("0.0003")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Black-Scholes helpers (stdlib-only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def normal_cdf(x: float) -> float:
|
||||
"""Standard normal CDF, no scipy dependency."""
|
||||
return 0.5 * (1.0 + math.erf(x / math.sqrt(2.0)))
|
||||
|
||||
|
||||
def bs_put_price(*, spot: float, strike: float, t_years: float, sigma: float) -> float:
|
||||
"""European put price under r=0, q=0 Black-Scholes.
|
||||
|
||||
Returns price in spot units (so for an ETH option, dividing by spot
|
||||
gives the price in ETH).
|
||||
"""
|
||||
if t_years <= 0 or sigma <= 0 or spot <= 0 or strike <= 0:
|
||||
return max(0.0, strike - spot)
|
||||
sqrt_t = math.sqrt(t_years)
|
||||
d1 = (math.log(spot / strike) + 0.5 * sigma * sigma * t_years) / (sigma * sqrt_t)
|
||||
d2 = d1 - sigma * sqrt_t
|
||||
return strike * normal_cdf(-d2) - spot * normal_cdf(-d1)
|
||||
|
||||
|
||||
def bs_put_delta(*, spot: float, strike: float, t_years: float, sigma: float) -> float:
|
||||
"""Put delta under r=0, q=0 Black-Scholes (negative number for put).
|
||||
|
||||
Returns 0 for expired options.
|
||||
"""
|
||||
if t_years <= 0 or sigma <= 0 or spot <= 0 or strike <= 0:
|
||||
return 0.0
|
||||
sqrt_t = math.sqrt(t_years)
|
||||
d1 = (math.log(spot / strike) + 0.5 * sigma * sigma * t_years) / (sigma * sqrt_t)
|
||||
return normal_cdf(d1) - 1.0 # = -N(-d1)
|
||||
|
||||
|
||||
def find_strike_for_delta(
|
||||
*,
|
||||
spot: float,
|
||||
dvol_pct: float,
|
||||
dte_days: int,
|
||||
target_delta_abs: float,
|
||||
) -> float:
|
||||
"""Solve for the put strike whose |delta| matches ``target_delta_abs``.
|
||||
|
||||
Bisection on a monotone-decreasing |delta(strike)| relationship.
|
||||
Returns the strike in absolute USD terms.
|
||||
"""
|
||||
sigma = max(0.01, dvol_pct / 100.0)
|
||||
t_years = max(1e-6, dte_days / 365.0)
|
||||
# Bracket: from 50% of spot (deep OTM, small |delta|) up to spot
|
||||
# (ATM, |delta| ≈ 0.5).
|
||||
low = max(1.0, spot * 0.30)
|
||||
high = spot
|
||||
for _ in range(64):
|
||||
mid = 0.5 * (low + high)
|
||||
delta_abs = abs(bs_put_delta(spot=spot, strike=mid, t_years=t_years, sigma=sigma))
|
||||
if delta_abs > target_delta_abs:
|
||||
high = mid
|
||||
else:
|
||||
low = mid
|
||||
if abs(high - low) < 1e-3:
|
||||
break
|
||||
return 0.5 * (low + high)
|
||||
|
||||
|
||||
def estimate_credit_eth(
|
||||
*,
|
||||
spot: float,
|
||||
dvol_pct: float,
|
||||
dte_days: int,
|
||||
width_pct: float,
|
||||
delta_target_abs: float,
|
||||
skew_premium: float = 1.5,
|
||||
) -> tuple[float, float, float]:
|
||||
"""Estimate credit (ETH), short_strike, long_strike for a bull-put-style
|
||||
credit spread under Black-Scholes.
|
||||
|
||||
``skew_premium`` è il moltiplicatore applicato al credito BS per
|
||||
approssimare la **vol smile** dell'ETH options market (le put OTM
|
||||
trattano a IV più alta della IV ATM, quindi un BS pulito sottostima
|
||||
sistematicamente il premio del venditore di vol). Il default 1.5
|
||||
è una stima conservativa dei dati Deribit storici (smile slope
|
||||
tipica 5-10 vol points per 100δ); valori sensati: 1.3 (smile
|
||||
blanda) … 1.8 (regime "stress IV"). Va calibrato sui dati reali
|
||||
quando avremo abbastanza chain history da farlo.
|
||||
|
||||
Returns ``(credit_eth, short_strike, long_strike)``. Credit è
|
||||
già moltiplicato per ``skew_premium``.
|
||||
"""
|
||||
short_strike = find_strike_for_delta(
|
||||
spot=spot, dvol_pct=dvol_pct, dte_days=dte_days,
|
||||
target_delta_abs=delta_target_abs,
|
||||
)
|
||||
width_usd = width_pct * spot
|
||||
long_strike = max(1.0, short_strike - width_usd)
|
||||
sigma = max(0.01, dvol_pct / 100.0)
|
||||
t_years = max(1e-6, dte_days / 365.0)
|
||||
short_mid_usd = bs_put_price(
|
||||
spot=spot, strike=short_strike, t_years=t_years, sigma=sigma,
|
||||
)
|
||||
long_mid_usd = bs_put_price(
|
||||
spot=spot, strike=long_strike, t_years=t_years, sigma=sigma,
|
||||
)
|
||||
short_mid_eth = short_mid_usd / spot
|
||||
long_mid_eth = long_mid_usd / spot
|
||||
credit_eth = (short_mid_eth - long_mid_eth) * skew_premium
|
||||
return credit_eth, short_strike, long_strike
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry filter simulation — rigorous (uses validate_entry exactly)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DailyPick:
|
||||
"""Indice di un tick "daily h:00 UTC" nella time-series."""
|
||||
|
||||
timestamp: datetime
|
||||
snapshot: MarketSnapshotRecord
|
||||
|
||||
|
||||
def daily_picks(
|
||||
snapshots: list[MarketSnapshotRecord],
|
||||
*,
|
||||
hour_utc: int = 14,
|
||||
asset: str = "ETH",
|
||||
) -> list[DailyPick]:
|
||||
"""Estrae un tick per giorno all'ora ``hour_utc``.
|
||||
|
||||
Crypto è 24/7, quindi non gateiamo sul giorno della settimana: per
|
||||
ogni giorno di calendario presente in ``snapshots``, prendiamo la
|
||||
riga ETH che cade a ``hour_utc:00``. Giorni senza tick a quell'ora
|
||||
vengono saltati. ``snapshots`` deve essere ordinato per timestamp
|
||||
ascending.
|
||||
"""
|
||||
picks: list[DailyPick] = []
|
||||
seen_dates: set[tuple[int, int, int]] = set() # (year, month, day)
|
||||
for snap in snapshots:
|
||||
if snap.asset.upper() != asset.upper():
|
||||
continue
|
||||
ts = snap.timestamp.astimezone(UTC)
|
||||
if ts.hour != hour_utc:
|
||||
continue
|
||||
key = (ts.year, ts.month, ts.day)
|
||||
if key in seen_dates:
|
||||
continue
|
||||
seen_dates.add(key)
|
||||
picks.append(DailyPick(timestamp=ts, snapshot=snap))
|
||||
return picks
|
||||
|
||||
|
||||
def _entry_context_from_snapshot(
|
||||
snap: MarketSnapshotRecord,
|
||||
*,
|
||||
capital_usd: Decimal,
|
||||
eth_holdings_pct: Decimal = Decimal("0"),
|
||||
) -> EntryContext | None:
|
||||
"""Costruisce :class:`EntryContext` dal tick storico.
|
||||
|
||||
``None`` quando la riga non ha i campi minimi (spot, dvol, funding).
|
||||
Nel filtro questo si traduce in "skip del giorno" — è la stessa
|
||||
logica del live: un tick incompleto è meglio di un'entry al buio.
|
||||
"""
|
||||
if snap.dvol is None or snap.funding_perp_annualized is None:
|
||||
return None
|
||||
return EntryContext(
|
||||
capital_usd=capital_usd,
|
||||
dvol_now=snap.dvol,
|
||||
funding_perp_annualized=snap.funding_perp_annualized,
|
||||
eth_holdings_pct_of_portfolio=eth_holdings_pct,
|
||||
next_macro_event_in_days=snap.macro_days_to_event,
|
||||
has_open_position=False,
|
||||
dealer_net_gamma=snap.dealer_net_gamma,
|
||||
liquidation_squeeze_risk_high=(
|
||||
snap.liquidation_long_risk == "high"
|
||||
or snap.liquidation_short_risk == "high"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EntryFilterResult:
|
||||
"""Esito del check filtri per una singola daily pick."""
|
||||
|
||||
pick: DailyPick
|
||||
accepted: bool
|
||||
reasons: list[str]
|
||||
skipped_for_data: bool # True se il tick non aveva i campi minimi
|
||||
|
||||
|
||||
def simulate_entry_filters(
|
||||
picks: list[DailyPick],
|
||||
cfg: StrategyConfig,
|
||||
*,
|
||||
capital_usd: Decimal,
|
||||
) -> list[EntryFilterResult]:
|
||||
"""Per ogni daily pick, valuta validate_entry come farebbe il live.
|
||||
|
||||
Rigoroso: usa esattamente :func:`validate_entry` e :class:`EntryContext`.
|
||||
Restituisce la lista degli esiti, una entry per pick.
|
||||
"""
|
||||
results: list[EntryFilterResult] = []
|
||||
for pick in picks:
|
||||
ctx = _entry_context_from_snapshot(pick.snapshot, capital_usd=capital_usd)
|
||||
if ctx is None:
|
||||
results.append(
|
||||
EntryFilterResult(
|
||||
pick=pick,
|
||||
accepted=False,
|
||||
reasons=["incomplete_snapshot"],
|
||||
skipped_for_data=True,
|
||||
)
|
||||
)
|
||||
continue
|
||||
decision = validate_entry(ctx, cfg)
|
||||
results.append(
|
||||
EntryFilterResult(
|
||||
pick=pick,
|
||||
accepted=decision.accepted,
|
||||
reasons=list(decision.reasons),
|
||||
skipped_for_data=False,
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Position outcome simulation — stylized (Black-Scholes re-pricing)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class BacktestEntry(BaseModel):
|
||||
"""Trade aperto nel backtest (snapshot al momento dell'entry)."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
timestamp: datetime
|
||||
spread_type: Literal["bull_put"] # MVP: solo bull_put nel backtest
|
||||
spot_at_entry: Decimal
|
||||
dvol_at_entry: Decimal
|
||||
short_strike: Decimal
|
||||
long_strike: Decimal
|
||||
expiry: datetime
|
||||
credit_received_eth: Decimal
|
||||
credit_received_usd: Decimal
|
||||
n_contracts: int
|
||||
|
||||
|
||||
class BacktestExit(BaseModel):
|
||||
"""Esito di un trade nel backtest."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
timestamp: datetime
|
||||
action: Literal[
|
||||
"CLOSE_PROFIT", "CLOSE_STOP", "CLOSE_VOL", "CLOSE_TIME",
|
||||
"CLOSE_DELTA", "CLOSE_AVERSE", "EXPIRED",
|
||||
]
|
||||
reason: str
|
||||
spot_at_exit: Decimal
|
||||
dvol_at_exit: Decimal
|
||||
debit_paid_eth: Decimal
|
||||
pnl_eth: Decimal
|
||||
pnl_usd: Decimal
|
||||
|
||||
|
||||
def _combo_mid_eth(
|
||||
*, spot: float, dvol_pct: float, dte_days: int,
|
||||
short_strike: float, long_strike: float,
|
||||
skew_premium: float = 1.5,
|
||||
) -> float:
|
||||
"""Re-prezza il combo bull-put usando BS sul nuovo spot/dvol/dte."""
|
||||
sigma = max(0.01, dvol_pct / 100.0)
|
||||
t_years = max(1e-6, dte_days / 365.0)
|
||||
short_mid_usd = bs_put_price(
|
||||
spot=spot, strike=short_strike, t_years=t_years, sigma=sigma,
|
||||
)
|
||||
long_mid_usd = bs_put_price(
|
||||
spot=spot, strike=long_strike, t_years=t_years, sigma=sigma,
|
||||
)
|
||||
return (short_mid_usd - long_mid_usd) / spot * skew_premium
|
||||
|
||||
|
||||
def simulate_position_outcome(
|
||||
entry: BacktestEntry,
|
||||
future_snapshots: list[MarketSnapshotRecord],
|
||||
cfg: StrategyConfig,
|
||||
) -> BacktestExit:
|
||||
"""Re-prezza il combo a ogni tick futuro fino al primo exit trigger.
|
||||
|
||||
Triggers in ordine §7:
|
||||
1. profit_take (debit ≤ 0.5×credit)
|
||||
2. stop_loss (debit ≥ 2.5×credit)
|
||||
3. vol_stop (DVOL salita di ≥10 pt rispetto entry)
|
||||
4. time_stop (DTE ≤ 7 e debit > 0.7×credit)
|
||||
5. expiry (uscita per scadenza, P/L = credit − intrinsic)
|
||||
"""
|
||||
ec = cfg.exit
|
||||
credit = float(entry.credit_received_eth)
|
||||
short = float(entry.short_strike)
|
||||
long_ = float(entry.long_strike)
|
||||
|
||||
profit_thresh = float(ec.profit_take_pct_of_credit) * credit
|
||||
stop_thresh = float(ec.stop_loss_mark_x_credit) * credit
|
||||
skip_time_thresh = float(ec.time_stop_skip_if_close_to_profit_pct) * credit
|
||||
|
||||
for snap in future_snapshots:
|
||||
if snap.timestamp <= entry.timestamp:
|
||||
continue
|
||||
if snap.timestamp >= entry.expiry:
|
||||
break
|
||||
if snap.dvol is None or snap.spot is None:
|
||||
continue
|
||||
spot_now = float(snap.spot)
|
||||
dvol_now = float(snap.dvol)
|
||||
dte = max(0, (entry.expiry - snap.timestamp).days)
|
||||
debit = _combo_mid_eth(
|
||||
spot=spot_now, dvol_pct=dvol_now, dte_days=dte,
|
||||
short_strike=short, long_strike=long_,
|
||||
)
|
||||
if debit <= profit_thresh:
|
||||
return _exit(
|
||||
snap, entry, debit,
|
||||
action="CLOSE_PROFIT",
|
||||
reason=f"debit {debit:.4f} ≤ {profit_thresh:.4f}",
|
||||
)
|
||||
if debit >= stop_thresh:
|
||||
return _exit(
|
||||
snap, entry, debit,
|
||||
action="CLOSE_STOP",
|
||||
reason=f"debit {debit:.4f} ≥ {stop_thresh:.4f}",
|
||||
)
|
||||
if dvol_now >= float(entry.dvol_at_entry) + float(ec.vol_stop_dvol_increase):
|
||||
return _exit(
|
||||
snap, entry, debit,
|
||||
action="CLOSE_VOL",
|
||||
reason=f"DVOL {dvol_now:.1f} ≥ entry+{ec.vol_stop_dvol_increase}",
|
||||
)
|
||||
if dte <= ec.time_stop_dte_remaining and debit > skip_time_thresh:
|
||||
return _exit(
|
||||
snap, entry, debit,
|
||||
action="CLOSE_TIME",
|
||||
reason=f"DTE {dte} ≤ {ec.time_stop_dte_remaining}",
|
||||
)
|
||||
|
||||
# Tick passati senza trigger: scadenza naturale.
|
||||
last = future_snapshots[-1] if future_snapshots else None
|
||||
intrinsic = max(0.0, short - float(last.spot if last and last.spot else 0))
|
||||
intrinsic_capped = min(intrinsic, short - long_)
|
||||
debit_at_expiry_eth = (
|
||||
intrinsic_capped / float(last.spot)
|
||||
if last is not None and last.spot is not None and float(last.spot) > 0
|
||||
else 0.0
|
||||
)
|
||||
return _exit(
|
||||
last or _synthetic_expiry_snapshot(entry),
|
||||
entry,
|
||||
debit_at_expiry_eth,
|
||||
action="EXPIRED",
|
||||
reason="held to expiry",
|
||||
)
|
||||
|
||||
|
||||
def _synthetic_expiry_snapshot(entry: BacktestEntry) -> MarketSnapshotRecord:
|
||||
return MarketSnapshotRecord(
|
||||
timestamp=entry.expiry,
|
||||
asset="ETH",
|
||||
spot=entry.spot_at_entry,
|
||||
dvol=entry.dvol_at_entry,
|
||||
fetch_ok=False,
|
||||
)
|
||||
|
||||
|
||||
def _exit(
|
||||
snap: MarketSnapshotRecord,
|
||||
entry: BacktestEntry,
|
||||
debit_eth: float,
|
||||
*,
|
||||
action: str,
|
||||
reason: str,
|
||||
) -> BacktestExit:
|
||||
pnl_eth = float(entry.credit_received_eth) - debit_eth
|
||||
spot = float(snap.spot) if snap.spot is not None else float(entry.spot_at_entry)
|
||||
dvol = float(snap.dvol) if snap.dvol is not None else float(entry.dvol_at_entry)
|
||||
return BacktestExit(
|
||||
timestamp=snap.timestamp,
|
||||
action=action, # type: ignore[arg-type]
|
||||
reason=reason,
|
||||
spot_at_exit=Decimal(str(spot)),
|
||||
dvol_at_exit=Decimal(str(dvol)),
|
||||
debit_paid_eth=Decimal(str(debit_eth)),
|
||||
pnl_eth=Decimal(str(pnl_eth)),
|
||||
pnl_usd=Decimal(str(pnl_eth * spot * entry.n_contracts)),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Full pipeline
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CompletedTrade:
|
||||
entry: BacktestEntry
|
||||
exit: BacktestExit
|
||||
|
||||
|
||||
class BacktestReport(BaseModel):
|
||||
"""Aggregato del backtest. Tutti i numeri sono **stime**."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
n_picks: int
|
||||
n_accepted: int
|
||||
n_skipped_data: int
|
||||
n_completed: int
|
||||
n_winners: int
|
||||
win_rate: Decimal
|
||||
cumulative_pnl_usd: Decimal
|
||||
cumulative_pnl_pct_of_capital: Decimal
|
||||
max_drawdown_usd: Decimal
|
||||
max_drawdown_pct: Decimal
|
||||
sharpe_annualized: Decimal | None
|
||||
skip_reasons: dict[str, int]
|
||||
trades: list[CompletedTrade]
|
||||
|
||||
|
||||
def _build_entry_from_pick(
|
||||
pick: DailyPick,
|
||||
cfg: StrategyConfig,
|
||||
*,
|
||||
capital_usd: Decimal,
|
||||
eur_to_usd: Decimal,
|
||||
) -> BacktestEntry | None:
|
||||
snap = pick.snapshot
|
||||
if snap.spot is None or snap.dvol is None:
|
||||
return None
|
||||
spot = float(snap.spot)
|
||||
dvol = float(snap.dvol)
|
||||
width_pct = float(cfg.structure.spread_width.target_pct_of_spot)
|
||||
delta_target = float(cfg.structure.short_strike.delta_target)
|
||||
dte = cfg.structure.dte_target
|
||||
|
||||
credit_eth, short_strike, long_strike = estimate_credit_eth(
|
||||
spot=spot, dvol_pct=dvol, dte_days=dte,
|
||||
width_pct=width_pct, delta_target_abs=delta_target,
|
||||
)
|
||||
width_usd = float(cfg.structure.spread_width.target_pct_of_spot) * spot
|
||||
credit_usd = credit_eth * spot
|
||||
if width_usd <= 0 or credit_usd / width_usd < float(
|
||||
cfg.structure.credit_to_width_ratio_min
|
||||
):
|
||||
return None # ratio gate fallisce → no entry
|
||||
|
||||
cap_pertrade_usd = float(cfg.sizing.cap_per_trade_eur) * float(eur_to_usd)
|
||||
risk_target = min(float(cfg.sizing.kelly_fraction) * float(capital_usd), cap_pertrade_usd)
|
||||
n_contracts = max(0, min(int(risk_target // width_usd), cfg.sizing.max_contracts_per_trade))
|
||||
if n_contracts == 0:
|
||||
return None
|
||||
|
||||
expiry = pick.timestamp + timedelta(days=dte)
|
||||
return BacktestEntry(
|
||||
timestamp=pick.timestamp,
|
||||
spread_type="bull_put",
|
||||
spot_at_entry=Decimal(str(spot)),
|
||||
dvol_at_entry=Decimal(str(dvol)),
|
||||
short_strike=Decimal(str(round(short_strike, 2))),
|
||||
long_strike=Decimal(str(round(long_strike, 2))),
|
||||
expiry=expiry,
|
||||
credit_received_eth=Decimal(str(credit_eth)),
|
||||
credit_received_usd=Decimal(str(credit_usd * n_contracts)),
|
||||
n_contracts=n_contracts,
|
||||
)
|
||||
|
||||
|
||||
def _max_drawdown_usd(equity: list[Decimal]) -> tuple[Decimal, Decimal]:
|
||||
"""Return ``(max_dd_usd, max_dd_pct_of_peak)`` over an equity curve."""
|
||||
if not equity:
|
||||
return Decimal("0"), Decimal("0")
|
||||
peak = equity[0]
|
||||
max_dd_usd = Decimal("0")
|
||||
max_dd_pct = Decimal("0")
|
||||
for v in equity:
|
||||
if v > peak:
|
||||
peak = v
|
||||
dd = peak - v
|
||||
if dd > max_dd_usd:
|
||||
max_dd_usd = dd
|
||||
if peak > 0 and (dd / peak) > max_dd_pct:
|
||||
max_dd_pct = dd / peak
|
||||
return max_dd_usd, max_dd_pct
|
||||
|
||||
|
||||
def _sharpe_annualized(pnls_usd: list[Decimal], capital_usd: Decimal) -> Decimal | None:
|
||||
"""Annualized Sharpe approximation: ~120 trade/anno (entry daily,
|
||||
crypto 24/7, post-filtri + concurrency cap).
|
||||
|
||||
Restituisce ``None`` se ci sono <5 trade o stdev = 0.
|
||||
"""
|
||||
if len(pnls_usd) < 5 or capital_usd <= 0:
|
||||
return None
|
||||
rets = [float(p / capital_usd) for p in pnls_usd]
|
||||
mean = sum(rets) / len(rets)
|
||||
var = sum((r - mean) ** 2 for r in rets) / max(1, (len(rets) - 1))
|
||||
std = math.sqrt(var)
|
||||
if std == 0:
|
||||
return None
|
||||
sharpe = mean / std * math.sqrt(120)
|
||||
return Decimal(str(round(sharpe, 3)))
|
||||
|
||||
|
||||
def run_backtest(
|
||||
snapshots: list[MarketSnapshotRecord],
|
||||
cfg: StrategyConfig,
|
||||
*,
|
||||
capital_usd: Decimal,
|
||||
eur_to_usd: Decimal = Decimal("1.075"),
|
||||
asset: str = "ETH",
|
||||
) -> BacktestReport:
|
||||
"""Esegue il backtest end-to-end sui ``snapshots`` ETH ordinati per ts."""
|
||||
snapshots = sorted(snapshots, key=lambda s: s.timestamp)
|
||||
eth_snapshots = [s for s in snapshots if s.asset.upper() == asset.upper()]
|
||||
picks = daily_picks(eth_snapshots, asset=asset)
|
||||
filter_results = simulate_entry_filters(picks, cfg, capital_usd=capital_usd)
|
||||
|
||||
# Tally skip reasons
|
||||
skip_reasons: dict[str, int] = {}
|
||||
for r in filter_results:
|
||||
if r.accepted:
|
||||
continue
|
||||
for reason in r.reasons:
|
||||
skip_reasons[reason] = skip_reasons.get(reason, 0) + 1
|
||||
|
||||
trades: list[CompletedTrade] = []
|
||||
for r in filter_results:
|
||||
if not r.accepted:
|
||||
continue
|
||||
entry = _build_entry_from_pick(
|
||||
r.pick, cfg, capital_usd=capital_usd, eur_to_usd=eur_to_usd,
|
||||
)
|
||||
if entry is None:
|
||||
skip_reasons["sizing_or_ratio"] = skip_reasons.get("sizing_or_ratio", 0) + 1
|
||||
continue
|
||||
future = [s for s in eth_snapshots if s.timestamp > r.pick.timestamp]
|
||||
exit_ = simulate_position_outcome(entry, future, cfg)
|
||||
trades.append(CompletedTrade(entry=entry, exit=exit_))
|
||||
|
||||
pnls = [t.exit.pnl_usd for t in trades]
|
||||
cumulative = sum(pnls, start=Decimal("0"))
|
||||
n_winners = sum(1 for p in pnls if p > 0)
|
||||
win_rate = (
|
||||
Decimal(n_winners) / Decimal(len(pnls))
|
||||
if pnls
|
||||
else Decimal("0")
|
||||
)
|
||||
|
||||
# Equity curve in USD assoluti
|
||||
equity = [capital_usd]
|
||||
for p in pnls:
|
||||
equity.append(equity[-1] + p)
|
||||
max_dd_usd, max_dd_pct = _max_drawdown_usd(equity)
|
||||
|
||||
return BacktestReport(
|
||||
n_picks=len(picks),
|
||||
n_accepted=sum(1 for r in filter_results if r.accepted),
|
||||
n_skipped_data=sum(1 for r in filter_results if r.skipped_for_data),
|
||||
n_completed=len(trades),
|
||||
n_winners=n_winners,
|
||||
win_rate=win_rate,
|
||||
cumulative_pnl_usd=cumulative,
|
||||
cumulative_pnl_pct_of_capital=(
|
||||
cumulative / capital_usd if capital_usd > 0 else Decimal("0")
|
||||
),
|
||||
max_drawdown_usd=max_dd_usd,
|
||||
max_drawdown_pct=max_dd_pct,
|
||||
sharpe_annualized=_sharpe_annualized(pnls, capital_usd),
|
||||
skip_reasons=skip_reasons,
|
||||
trades=trades,
|
||||
)
|
||||
@@ -83,26 +83,49 @@ def _pick_expiry(
|
||||
return min(candidates, key=lambda exp: abs(candidates[exp] - sc.dte_target))
|
||||
|
||||
|
||||
def _resolve_delta_band(
|
||||
sc: object, dvol_now: Decimal | None
|
||||
) -> tuple[Decimal, Decimal, Decimal]:
|
||||
"""Return (delta_target, delta_min, delta_max) per il regime DVOL corrente.
|
||||
|
||||
Quando ``sc.delta_by_dvol`` è popolato e ``dvol_now`` è disponibile,
|
||||
sceglie la prima banda (ordinata ascending sulla ``dvol_under``) il
|
||||
cui ``dvol_under ≥ dvol_now``. Altrimenti torna ai valori statici di
|
||||
``sc``.
|
||||
"""
|
||||
bands = list(getattr(sc, "delta_by_dvol", []) or [])
|
||||
if dvol_now is not None and bands:
|
||||
bands_sorted = sorted(bands, key=lambda b: b.dvol_under)
|
||||
for band in bands_sorted:
|
||||
if dvol_now <= band.dvol_under:
|
||||
return band.delta_target, band.delta_min, band.delta_max
|
||||
last = bands_sorted[-1]
|
||||
return last.delta_target, last.delta_min, last.delta_max
|
||||
return sc.delta_target, sc.delta_min, sc.delta_max
|
||||
|
||||
|
||||
def _select_short(
|
||||
quotes: list[OptionQuote],
|
||||
*,
|
||||
spot: Decimal,
|
||||
cfg: StrategyConfig,
|
||||
dvol_now: Decimal | None = None,
|
||||
) -> OptionQuote | None:
|
||||
"""Pick the short-leg quote with delta closest to target inside both bands."""
|
||||
sc = cfg.structure.short_strike
|
||||
delta_target, delta_min, delta_max = _resolve_delta_band(sc, dvol_now)
|
||||
eligible: list[OptionQuote] = []
|
||||
for q in quotes:
|
||||
dist = (q.strike - spot).copy_abs() / spot
|
||||
if not (sc.distance_otm_pct_min <= dist <= sc.distance_otm_pct_max):
|
||||
continue
|
||||
abs_delta = q.delta.copy_abs()
|
||||
if not (sc.delta_min <= abs_delta <= sc.delta_max):
|
||||
if not (delta_min <= abs_delta <= delta_max):
|
||||
continue
|
||||
eligible.append(q)
|
||||
if not eligible:
|
||||
return None
|
||||
return min(eligible, key=lambda q: abs(q.delta.copy_abs() - sc.delta_target))
|
||||
return min(eligible, key=lambda q: abs(q.delta.copy_abs() - delta_target))
|
||||
|
||||
|
||||
def _select_long(
|
||||
@@ -143,6 +166,7 @@ def select_strikes(
|
||||
spot: Decimal,
|
||||
now: datetime,
|
||||
cfg: StrategyConfig,
|
||||
dvol_now: Decimal | None = None,
|
||||
) -> tuple[OptionQuote, OptionQuote] | None:
|
||||
"""Return the (short, long) quotes for the requested vertical, or ``None``.
|
||||
|
||||
@@ -161,7 +185,7 @@ def select_strikes(
|
||||
if not typed:
|
||||
return None
|
||||
|
||||
short = _select_short(typed, spot=spot, cfg=cfg)
|
||||
short = _select_short(typed, spot=spot, cfg=cfg, dvol_now=dvol_now)
|
||||
if short is None:
|
||||
return None
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from decimal import Decimal
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from cerbero_bite.config import SpreadType, StrategyConfig
|
||||
from cerbero_bite.core.adaptive_threshold import compute_adaptive_threshold
|
||||
|
||||
__all__ = [
|
||||
"EntryContext",
|
||||
@@ -44,6 +45,31 @@ class EntryContext(BaseModel):
|
||||
dealer_net_gamma: Decimal | None = None
|
||||
liquidation_squeeze_risk_high: bool | None = None
|
||||
|
||||
# IV richness gate (§2.9). Differenza IV30g − RV30g in punti vol.
|
||||
# Optional, stessa logica best-effort dei filtri quant: ``None``
|
||||
# significa "dato non disponibile" e fa saltare il gate (non
|
||||
# invalida l'entry).
|
||||
iv_minus_rv: Decimal | None = None
|
||||
|
||||
# Valori IV-RV nella finestra rolling già scelta dal caller
|
||||
# (entry_cycle): tutti i record validi su window_days, ASC, NULL e
|
||||
# fetch_ok=0 esclusi. Caricata dal repository quando
|
||||
# `iv_minus_rv_adaptive_enabled` è True. Tuple per coerenza con
|
||||
# frozen=True.
|
||||
iv_rv_history: tuple[Decimal, ...] = ()
|
||||
|
||||
# Numero di giorni di calendario distinti coperti dalla storia
|
||||
# IV-RV disponibile (non solo dalla finestra `iv_rv_history`).
|
||||
# ``0`` = warmup hard, gate disabilitato (fail-open). Calcolato dal
|
||||
# caller via `repository.count_iv_rv_distinct_days`.
|
||||
iv_rv_n_days: int = 0
|
||||
|
||||
# DVOL al tick più vicino a now - vol_of_vol_lookback_hours.
|
||||
# ``None`` = gap nel dato (es. cron mancante 24h fa) → VoV guard
|
||||
# skip. Caricato dal repository in `entry_cycle` quando
|
||||
# `vol_of_vol_guard_enabled` è True.
|
||||
dvol_24h_ago: Decimal | None = None
|
||||
|
||||
|
||||
class EntryDecision(BaseModel):
|
||||
"""Result of :func:`validate_entry`. ``reasons`` holds *all* blocking reasons."""
|
||||
@@ -131,6 +157,47 @@ def validate_entry(ctx: EntryContext, cfg: StrategyConfig) -> EntryDecision:
|
||||
):
|
||||
reasons.append("imminent liquidation squeeze risk")
|
||||
|
||||
# §2.9: IV richness gate. Vendere vol senza un margine misurabile
|
||||
# fra IV e RV è statisticamente neutro: l'edge della strategia
|
||||
# esiste solo quando il premio è "ricco" rispetto a quanto il
|
||||
# mercato si è effettivamente mosso. La modalità adattiva calcola
|
||||
# la soglia come max(P_q rolling, iv_minus_rv_min) sulla storia
|
||||
# disponibile in market_snapshots; altrimenti fallback alla
|
||||
# soglia statica `iv_minus_rv_min`.
|
||||
if entry_cfg.iv_minus_rv_filter_enabled and ctx.iv_minus_rv is not None:
|
||||
if entry_cfg.iv_minus_rv_adaptive_enabled:
|
||||
threshold = compute_adaptive_threshold(
|
||||
history=ctx.iv_rv_history,
|
||||
n_days=ctx.iv_rv_n_days,
|
||||
percentile=entry_cfg.iv_minus_rv_percentile,
|
||||
absolute_floor=entry_cfg.iv_minus_rv_min,
|
||||
)
|
||||
if threshold is not None and ctx.iv_minus_rv < threshold:
|
||||
pct = int(entry_cfg.iv_minus_rv_percentile * 100)
|
||||
reasons.append(
|
||||
f"IV richness below P{pct} rolling "
|
||||
f"(IV-RV={ctx.iv_minus_rv} < {threshold} vol pts)"
|
||||
)
|
||||
elif ctx.iv_minus_rv < entry_cfg.iv_minus_rv_min:
|
||||
reasons.append(
|
||||
f"IV richness below floor "
|
||||
f"(IV-RV={ctx.iv_minus_rv} < {entry_cfg.iv_minus_rv_min} vol pts)"
|
||||
)
|
||||
|
||||
# §4-quater roadmap: vol-of-vol guard. Blocca entry quando il
|
||||
# regime di volatilità sta cambiando bruscamente, anche se IV-RV
|
||||
# è alto. Fail-open su gap dati 24h fa.
|
||||
if (
|
||||
entry_cfg.vol_of_vol_guard_enabled
|
||||
and ctx.dvol_24h_ago is not None
|
||||
):
|
||||
delta = abs(ctx.dvol_now - ctx.dvol_24h_ago)
|
||||
if delta >= entry_cfg.vol_of_vol_threshold_pt:
|
||||
reasons.append(
|
||||
f"DVOL shifted {delta} pt in {entry_cfg.vol_of_vol_lookback_hours}h "
|
||||
f"(threshold {entry_cfg.vol_of_vol_threshold_pt})"
|
||||
)
|
||||
|
||||
return EntryDecision(accepted=not reasons, reasons=reasons)
|
||||
|
||||
|
||||
|
||||
@@ -28,8 +28,10 @@ __all__ = ["ExitAction", "ExitDecisionResult", "PositionSnapshot", "evaluate"]
|
||||
ExitAction = Literal[
|
||||
"HOLD",
|
||||
"CLOSE_PROFIT",
|
||||
"CLOSE_PROFIT_PARTIAL",
|
||||
"CLOSE_STOP",
|
||||
"CLOSE_VOL",
|
||||
"CLOSE_VOL_HARVEST",
|
||||
"CLOSE_TIME",
|
||||
"CLOSE_DELTA",
|
||||
"CLOSE_AVERSE",
|
||||
@@ -115,6 +117,22 @@ def evaluate(snapshot: PositionSnapshot, cfg: StrategyConfig) -> ExitDecisionRes
|
||||
f"mark {debit} ≤ {ec.profit_take_pct_of_credit:.0%} of credit {credit}",
|
||||
)
|
||||
|
||||
# 1bis. Vol-collapse harvest (D): siamo IN profit (debit < credit) e
|
||||
# la DVOL è scesa di tot punti rispetto all'entry. Edge IV-RV già
|
||||
# catturato, non c'è motivo di tenere fino a profit_take. Esce
|
||||
# opportunisticamente quando il regime di vol che giustificava
|
||||
# l'entry non c'è più.
|
||||
if (
|
||||
ec.vol_harvest_dvol_decrease > 0
|
||||
and debit < credit
|
||||
and snapshot.dvol_now <= snapshot.dvol_at_entry - ec.vol_harvest_dvol_decrease
|
||||
):
|
||||
return _result(
|
||||
"CLOSE_VOL_HARVEST",
|
||||
f"DVOL {snapshot.dvol_now} ≤ entry {snapshot.dvol_at_entry} − "
|
||||
f"{ec.vol_harvest_dvol_decrease}, harvest while in profit",
|
||||
)
|
||||
|
||||
# 2. Stop loss
|
||||
if debit >= stop_thresh:
|
||||
return _result(
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 668 KiB |
@@ -0,0 +1,750 @@
|
||||
"""Read-only data access for the Streamlit GUI.
|
||||
|
||||
The GUI MUST NOT import ``runtime/`` modules nor make MCP calls. Every
|
||||
piece of information shown on screen is derived from:
|
||||
|
||||
* SQLite (``data/state.sqlite``) via :class:`Repository`.
|
||||
* The audit log (``data/audit.log``) via the parsing helpers in
|
||||
:mod:`cerbero_bite.safety.audit_log`.
|
||||
|
||||
The module exposes small frozen dataclasses purpose-built for rendering
|
||||
so each Streamlit page can grab a snapshot in one call instead of
|
||||
poking at the repository directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from cerbero_bite.safety.audit_log import (
|
||||
AuditChainError,
|
||||
AuditEntry,
|
||||
iter_entries,
|
||||
verify_chain,
|
||||
)
|
||||
from cerbero_bite.state import Repository, connect, transaction
|
||||
from cerbero_bite.state.models import (
|
||||
DecisionRecord,
|
||||
ManualAction,
|
||||
MarketSnapshotRecord,
|
||||
PositionRecord,
|
||||
SystemStateRecord,
|
||||
)
|
||||
from cerbero_bite.state.repository import _row_to_manual
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_AUDIT_PATH",
|
||||
"DEFAULT_DB_PATH",
|
||||
"AuditChainStatus",
|
||||
"EngineHealth",
|
||||
"EngineSnapshot",
|
||||
"EquityPoint",
|
||||
"MonthlyStats",
|
||||
"PayoffCurve",
|
||||
"PortfolioKpis",
|
||||
"PositionDistanceMetrics",
|
||||
"compute_distance_metrics",
|
||||
"compute_equity_curve",
|
||||
"compute_kpis",
|
||||
"compute_monthly_stats",
|
||||
"compute_payoff_curve",
|
||||
"enqueue_arm_kill",
|
||||
"enqueue_disarm_kill",
|
||||
"enqueue_run_cycle",
|
||||
"load_audit_chain_status",
|
||||
"load_audit_tail",
|
||||
"load_closed_positions",
|
||||
"load_decisions_for_position",
|
||||
"load_engine_snapshot",
|
||||
"load_market_snapshots",
|
||||
"load_open_positions",
|
||||
"load_pending_manual_actions",
|
||||
"load_position_by_id",
|
||||
]
|
||||
|
||||
|
||||
DEFAULT_DB_PATH = Path("data/state.sqlite")
|
||||
DEFAULT_AUDIT_PATH = Path("data/audit.log")
|
||||
|
||||
|
||||
EngineHealth = Literal["running", "degraded", "killed", "stopped", "unknown"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EngineSnapshot:
|
||||
"""One-shot snapshot used by the Status page."""
|
||||
|
||||
health: EngineHealth
|
||||
kill_switch_armed: bool
|
||||
kill_reason: str | None
|
||||
kill_at: datetime | None
|
||||
last_health_check: datetime | None
|
||||
last_health_check_age_s: float | None
|
||||
started_at: datetime | None
|
||||
config_version: str | None
|
||||
last_audit_hash: str | None
|
||||
open_positions: int
|
||||
|
||||
@property
|
||||
def health_label(self) -> str:
|
||||
return {
|
||||
"running": "ATTIVO",
|
||||
"degraded": "DEGRADATO",
|
||||
"killed": "KILL SWITCH ARMATO",
|
||||
"stopped": "FERMO",
|
||||
"unknown": "SCONOSCIUTO",
|
||||
}[self.health]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuditChainStatus:
|
||||
"""Result of calling ``verify_chain`` on the audit log."""
|
||||
|
||||
ok: bool
|
||||
entries_verified: int
|
||||
error: str | None
|
||||
|
||||
|
||||
def load_engine_snapshot(
|
||||
*,
|
||||
db_path: Path | str = DEFAULT_DB_PATH,
|
||||
now: datetime | None = None,
|
||||
stale_after_s: float = 600.0,
|
||||
) -> EngineSnapshot:
|
||||
"""Read system_state + open positions count and derive engine health.
|
||||
|
||||
Health rules:
|
||||
|
||||
* kill switch armed → ``killed``
|
||||
* no system_state row → ``unknown`` (engine never started)
|
||||
* last health check older than ``stale_after_s`` → ``stopped``
|
||||
* last health check older than 2× cycle (10 min) but younger than
|
||||
``stale_after_s`` → ``degraded``
|
||||
* fresh health check → ``running``
|
||||
"""
|
||||
db_path = Path(db_path)
|
||||
if not db_path.exists():
|
||||
return EngineSnapshot(
|
||||
health="unknown",
|
||||
kill_switch_armed=False,
|
||||
kill_reason=None,
|
||||
kill_at=None,
|
||||
last_health_check=None,
|
||||
last_health_check_age_s=None,
|
||||
started_at=None,
|
||||
config_version=None,
|
||||
last_audit_hash=None,
|
||||
open_positions=0,
|
||||
)
|
||||
|
||||
repo = Repository()
|
||||
conn = connect(db_path)
|
||||
try:
|
||||
state: SystemStateRecord | None = repo.get_system_state(conn)
|
||||
open_pos = len(repo.list_open_positions(conn))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if state is None:
|
||||
return EngineSnapshot(
|
||||
health="unknown",
|
||||
kill_switch_armed=False,
|
||||
kill_reason=None,
|
||||
kill_at=None,
|
||||
last_health_check=None,
|
||||
last_health_check_age_s=None,
|
||||
started_at=None,
|
||||
config_version=None,
|
||||
last_audit_hash=None,
|
||||
open_positions=open_pos,
|
||||
)
|
||||
|
||||
reference = (now or datetime.now(UTC)).astimezone(UTC)
|
||||
last_check = state.last_health_check
|
||||
age = (reference - last_check).total_seconds() if last_check else None
|
||||
|
||||
if state.kill_switch:
|
||||
health: EngineHealth = "killed"
|
||||
elif age is None:
|
||||
health = "unknown"
|
||||
elif age > stale_after_s:
|
||||
health = "stopped"
|
||||
elif age > 600: # over 10 minutes since last health probe
|
||||
health = "degraded"
|
||||
else:
|
||||
health = "running"
|
||||
|
||||
return EngineSnapshot(
|
||||
health=health,
|
||||
kill_switch_armed=bool(state.kill_switch),
|
||||
kill_reason=state.kill_reason,
|
||||
kill_at=state.kill_at,
|
||||
last_health_check=last_check,
|
||||
last_health_check_age_s=age,
|
||||
started_at=state.started_at,
|
||||
config_version=state.config_version,
|
||||
last_audit_hash=state.last_audit_hash,
|
||||
open_positions=open_pos,
|
||||
)
|
||||
|
||||
|
||||
def load_open_positions(
|
||||
*, db_path: Path | str = DEFAULT_DB_PATH
|
||||
) -> list[PositionRecord]:
|
||||
db_path = Path(db_path)
|
||||
if not db_path.exists():
|
||||
return []
|
||||
repo = Repository()
|
||||
conn = connect(db_path)
|
||||
try:
|
||||
return repo.list_open_positions(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def load_closed_positions(
|
||||
*,
|
||||
db_path: Path | str = DEFAULT_DB_PATH,
|
||||
start: datetime | None = None,
|
||||
end: datetime | None = None,
|
||||
) -> list[PositionRecord]:
|
||||
"""Return positions with status ``closed`` (sorted oldest → newest).
|
||||
|
||||
The optional ``start`` / ``end`` window filters by ``closed_at``.
|
||||
Positions still in flight (open / awaiting_fill / closing /
|
||||
cancelled) are excluded. ``cancelled`` positions are also excluded
|
||||
since they never had P&L impact.
|
||||
"""
|
||||
db_path = Path(db_path)
|
||||
if not db_path.exists():
|
||||
return []
|
||||
repo = Repository()
|
||||
conn = connect(db_path)
|
||||
try:
|
||||
rows = repo.list_positions(conn, status="closed")
|
||||
finally:
|
||||
conn.close()
|
||||
out: list[PositionRecord] = []
|
||||
for r in rows:
|
||||
if r.closed_at is None:
|
||||
continue
|
||||
if start is not None and r.closed_at < start:
|
||||
continue
|
||||
if end is not None and r.closed_at > end:
|
||||
continue
|
||||
out.append(r)
|
||||
out.sort(key=lambda p: p.closed_at) # type: ignore[arg-type, return-value]
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Analytics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EquityPoint:
|
||||
"""One point on the cumulative-PnL curve."""
|
||||
|
||||
timestamp: datetime
|
||||
realized_pnl_usd: Decimal
|
||||
cumulative_pnl_usd: Decimal
|
||||
drawdown_usd: Decimal
|
||||
drawdown_pct: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MonthlyStats:
|
||||
"""Aggregated stats for a calendar month."""
|
||||
|
||||
year_month: str # "2026-04"
|
||||
n_trades: int
|
||||
n_wins: int
|
||||
win_rate: float
|
||||
pnl_usd: Decimal
|
||||
avg_pnl_usd: Decimal
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PortfolioKpis:
|
||||
"""High-level KPI strip for the History/Equity pages."""
|
||||
|
||||
n_trades: int
|
||||
n_wins: int
|
||||
win_rate: float
|
||||
total_pnl_usd: Decimal
|
||||
avg_win_usd: Decimal
|
||||
avg_loss_usd: Decimal
|
||||
edge_per_trade_usd: Decimal
|
||||
max_drawdown_usd: Decimal
|
||||
max_drawdown_pct: float
|
||||
|
||||
|
||||
def compute_equity_curve(positions: list[PositionRecord]) -> list[EquityPoint]:
|
||||
"""Build a cumulative PnL series from closed positions.
|
||||
|
||||
Drawdown is measured against the running peak of cumulative PnL
|
||||
(so it accounts for past wins). ``drawdown_pct`` is expressed
|
||||
relative to the peak — undefined when peak ≤ 0 (returns 0.0).
|
||||
"""
|
||||
if not positions:
|
||||
return []
|
||||
|
||||
points: list[EquityPoint] = []
|
||||
cumulative = Decimal(0)
|
||||
peak = Decimal(0)
|
||||
for pos in positions:
|
||||
if pos.pnl_usd is None or pos.closed_at is None:
|
||||
continue
|
||||
cumulative += pos.pnl_usd
|
||||
peak = max(peak, cumulative)
|
||||
dd_usd = peak - cumulative
|
||||
dd_pct = float(dd_usd / peak) if peak > 0 else 0.0
|
||||
points.append(
|
||||
EquityPoint(
|
||||
timestamp=pos.closed_at,
|
||||
realized_pnl_usd=pos.pnl_usd,
|
||||
cumulative_pnl_usd=cumulative,
|
||||
drawdown_usd=dd_usd,
|
||||
drawdown_pct=dd_pct,
|
||||
)
|
||||
)
|
||||
return points
|
||||
|
||||
|
||||
def compute_kpis(positions: list[PositionRecord]) -> PortfolioKpis:
|
||||
"""Aggregate KPI strip across the supplied closed positions."""
|
||||
pnls = [p.pnl_usd for p in positions if p.pnl_usd is not None]
|
||||
n = len(pnls)
|
||||
if n == 0:
|
||||
zero = Decimal(0)
|
||||
return PortfolioKpis(
|
||||
n_trades=0,
|
||||
n_wins=0,
|
||||
win_rate=0.0,
|
||||
total_pnl_usd=zero,
|
||||
avg_win_usd=zero,
|
||||
avg_loss_usd=zero,
|
||||
edge_per_trade_usd=zero,
|
||||
max_drawdown_usd=zero,
|
||||
max_drawdown_pct=0.0,
|
||||
)
|
||||
|
||||
wins = [p for p in pnls if p > 0]
|
||||
losses = [p for p in pnls if p < 0]
|
||||
total = sum(pnls, Decimal(0))
|
||||
avg_win = sum(wins, Decimal(0)) / Decimal(len(wins)) if wins else Decimal(0)
|
||||
avg_loss = sum(losses, Decimal(0)) / Decimal(len(losses)) if losses else Decimal(0)
|
||||
|
||||
curve = compute_equity_curve(positions)
|
||||
if curve:
|
||||
max_dd = max((p.drawdown_usd for p in curve), default=Decimal(0))
|
||||
max_dd_pct = max((p.drawdown_pct for p in curve), default=0.0)
|
||||
else: # pragma: no cover — defensive, curve is empty iff pnls empty
|
||||
max_dd = Decimal(0)
|
||||
max_dd_pct = 0.0
|
||||
|
||||
return PortfolioKpis(
|
||||
n_trades=n,
|
||||
n_wins=len(wins),
|
||||
win_rate=len(wins) / n,
|
||||
total_pnl_usd=total,
|
||||
avg_win_usd=avg_win,
|
||||
avg_loss_usd=avg_loss,
|
||||
edge_per_trade_usd=total / Decimal(n),
|
||||
max_drawdown_usd=max_dd,
|
||||
max_drawdown_pct=max_dd_pct,
|
||||
)
|
||||
|
||||
|
||||
def compute_monthly_stats(positions: list[PositionRecord]) -> list[MonthlyStats]:
|
||||
"""Aggregate per calendar month (UTC), oldest → newest."""
|
||||
buckets: dict[str, list[Decimal]] = {}
|
||||
for pos in positions:
|
||||
if pos.pnl_usd is None or pos.closed_at is None:
|
||||
continue
|
||||
key = pos.closed_at.astimezone(UTC).strftime("%Y-%m")
|
||||
buckets.setdefault(key, []).append(pos.pnl_usd)
|
||||
|
||||
out: list[MonthlyStats] = []
|
||||
for key in sorted(buckets):
|
||||
pnls = buckets[key]
|
||||
n = len(pnls)
|
||||
wins = sum(1 for p in pnls if p > 0)
|
||||
total = sum(pnls, Decimal(0))
|
||||
out.append(
|
||||
MonthlyStats(
|
||||
year_month=key,
|
||||
n_trades=n,
|
||||
n_wins=wins,
|
||||
win_rate=wins / n if n else 0.0,
|
||||
pnl_usd=total,
|
||||
avg_pnl_usd=total / Decimal(n) if n else Decimal(0),
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def load_position_by_id(
|
||||
proposal_id: UUID,
|
||||
*,
|
||||
db_path: Path | str = DEFAULT_DB_PATH,
|
||||
) -> PositionRecord | None:
|
||||
db_path = Path(db_path)
|
||||
if not db_path.exists():
|
||||
return None
|
||||
repo = Repository()
|
||||
conn = connect(db_path)
|
||||
try:
|
||||
return repo.get_position(conn, proposal_id)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def load_decisions_for_position(
|
||||
proposal_id: UUID,
|
||||
*,
|
||||
db_path: Path | str = DEFAULT_DB_PATH,
|
||||
limit: int = 200,
|
||||
) -> list[DecisionRecord]:
|
||||
"""Decisions for ``proposal_id`` newest-first."""
|
||||
db_path = Path(db_path)
|
||||
if not db_path.exists():
|
||||
return []
|
||||
repo = Repository()
|
||||
conn = connect(db_path)
|
||||
try:
|
||||
return repo.list_decisions(conn, proposal_id=proposal_id, limit=limit)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Payoff math (pure, no live data)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PayoffCurve:
|
||||
"""At-expiry P&L curve for a credit spread."""
|
||||
|
||||
spreads_type: str # "bull_put" / "bear_call" / "iron_condor"
|
||||
spot_grid: list[float]
|
||||
pnl_grid_usd: list[float]
|
||||
breakeven: float | None
|
||||
max_profit_usd: float
|
||||
max_loss_usd: float
|
||||
short_strike: float
|
||||
long_strike: float
|
||||
spot_at_entry: float
|
||||
|
||||
|
||||
def compute_payoff_curve(
|
||||
position: PositionRecord,
|
||||
*,
|
||||
grid_points: int = 60,
|
||||
margin_pct: float = 0.15,
|
||||
) -> PayoffCurve:
|
||||
"""Build the at-expiry payoff for a credit spread.
|
||||
|
||||
Supported spreads (Cerbero Bite scope):
|
||||
|
||||
* ``bull_put``: short put @ ``short_strike``, long put @
|
||||
``long_strike`` (lower). Max profit = credit. Max loss = width −
|
||||
credit. Breakeven = short_strike − credit_per_contract.
|
||||
* ``bear_call``: short call @ ``short_strike``, long call @
|
||||
``long_strike`` (higher). Symmetric to bull_put around the strikes.
|
||||
* Other types fall back to a flat zero curve to avoid breaking the
|
||||
page if/when iron condors are implemented later.
|
||||
"""
|
||||
short = float(position.short_strike)
|
||||
long_ = float(position.long_strike)
|
||||
n = position.n_contracts
|
||||
width_usd = float(position.spread_width_usd)
|
||||
credit_total_usd = float(position.credit_usd)
|
||||
credit_per_contract = credit_total_usd / n if n > 0 else 0.0
|
||||
spot = float(position.eth_price_at_entry)
|
||||
|
||||
lo = min(short, long_, spot) * (1 - margin_pct)
|
||||
hi = max(short, long_, spot) * (1 + margin_pct)
|
||||
step = (hi - lo) / max(grid_points - 1, 1)
|
||||
grid = [lo + i * step for i in range(grid_points)]
|
||||
|
||||
if position.spread_type == "bull_put":
|
||||
# short put at higher strike, long put at lower strike
|
||||
max_profit = credit_total_usd
|
||||
max_loss = -(width_usd - credit_total_usd) * n # signed (negative)
|
||||
breakeven = short - credit_per_contract
|
||||
pnl = []
|
||||
for s in grid:
|
||||
if s >= short:
|
||||
pnl.append(max_profit)
|
||||
elif s <= long_:
|
||||
pnl.append(max_loss)
|
||||
else:
|
||||
frac = (s - long_) / (short - long_)
|
||||
pnl.append(max_loss + frac * (max_profit - max_loss))
|
||||
elif position.spread_type == "bear_call":
|
||||
# short call at lower strike, long call at higher strike
|
||||
max_profit = credit_total_usd
|
||||
max_loss = -(width_usd - credit_total_usd) * n
|
||||
breakeven = short + credit_per_contract
|
||||
pnl = []
|
||||
for s in grid:
|
||||
if s <= short:
|
||||
pnl.append(max_profit)
|
||||
elif s >= long_:
|
||||
pnl.append(max_loss)
|
||||
else:
|
||||
frac = (s - short) / (long_ - short)
|
||||
pnl.append(max_profit + frac * (max_loss - max_profit))
|
||||
else:
|
||||
max_profit = credit_total_usd
|
||||
max_loss = -(width_usd - credit_total_usd) * n
|
||||
breakeven = None
|
||||
pnl = [0.0 for _ in grid]
|
||||
|
||||
return PayoffCurve(
|
||||
spreads_type=position.spread_type,
|
||||
spot_grid=grid,
|
||||
pnl_grid_usd=pnl,
|
||||
breakeven=breakeven,
|
||||
max_profit_usd=max_profit,
|
||||
max_loss_usd=max_loss,
|
||||
short_strike=short,
|
||||
long_strike=long_,
|
||||
spot_at_entry=spot,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PositionDistanceMetrics:
|
||||
"""Quick distance summary for the position drilldown."""
|
||||
|
||||
short_strike_otm_pct: float | None
|
||||
days_to_expiry: int | None
|
||||
days_held: int | None
|
||||
delta_at_entry: float
|
||||
width_pct_of_spot: float
|
||||
|
||||
|
||||
def compute_distance_metrics(
|
||||
position: PositionRecord,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> PositionDistanceMetrics:
|
||||
spot = float(position.spot_at_entry)
|
||||
short = float(position.short_strike)
|
||||
if spot > 0:
|
||||
if position.spread_type == "bull_put":
|
||||
otm_pct = (spot - short) / spot
|
||||
elif position.spread_type == "bear_call":
|
||||
otm_pct = (short - spot) / spot
|
||||
else:
|
||||
otm_pct = None
|
||||
else:
|
||||
otm_pct = None
|
||||
|
||||
reference = (now or datetime.now(UTC)).astimezone(UTC)
|
||||
days_to_expiry = (
|
||||
(position.expiry - reference).days if position.expiry else None
|
||||
)
|
||||
days_held = (
|
||||
(reference - position.opened_at).days if position.opened_at else None
|
||||
)
|
||||
|
||||
return PositionDistanceMetrics(
|
||||
short_strike_otm_pct=otm_pct,
|
||||
days_to_expiry=days_to_expiry,
|
||||
days_held=days_held,
|
||||
delta_at_entry=float(position.delta_at_entry),
|
||||
width_pct_of_spot=float(position.spread_width_pct),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Manual actions queue (the GUI's only write path)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _enqueue_action(
|
||||
*,
|
||||
db_path: Path | str,
|
||||
kind: str,
|
||||
payload: dict[str, object],
|
||||
proposal_id: UUID | None = None,
|
||||
) -> int:
|
||||
"""Insert a row in ``manual_actions``. The engine consumer applies it."""
|
||||
db_path = Path(db_path)
|
||||
repo = Repository()
|
||||
now = datetime.now(UTC)
|
||||
conn = connect(db_path)
|
||||
try:
|
||||
with transaction(conn):
|
||||
return repo.enqueue_manual_action(
|
||||
conn,
|
||||
ManualAction(
|
||||
kind=kind, # type: ignore[arg-type]
|
||||
proposal_id=proposal_id,
|
||||
payload_json=json.dumps(payload),
|
||||
created_at=now,
|
||||
),
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def enqueue_arm_kill(
|
||||
*, reason: str, db_path: Path | str = DEFAULT_DB_PATH
|
||||
) -> int:
|
||||
"""Queue an ``arm_kill`` action for the engine consumer."""
|
||||
if not reason or not reason.strip():
|
||||
raise ValueError("reason is required")
|
||||
return _enqueue_action(
|
||||
db_path=db_path,
|
||||
kind="arm_kill",
|
||||
payload={"reason": reason.strip()},
|
||||
)
|
||||
|
||||
|
||||
def enqueue_disarm_kill(
|
||||
*, reason: str, db_path: Path | str = DEFAULT_DB_PATH
|
||||
) -> int:
|
||||
"""Queue a ``disarm_kill`` action for the engine consumer."""
|
||||
if not reason or not reason.strip():
|
||||
raise ValueError("reason is required")
|
||||
return _enqueue_action(
|
||||
db_path=db_path,
|
||||
kind="disarm_kill",
|
||||
payload={"reason": reason.strip()},
|
||||
)
|
||||
|
||||
|
||||
def enqueue_run_cycle(
|
||||
*, cycle: str, db_path: Path | str = DEFAULT_DB_PATH
|
||||
) -> int:
|
||||
"""Queue a ``run_cycle`` action — engine must be running.
|
||||
|
||||
``cycle`` must be one of ``entry``, ``monitor``, ``health``. The
|
||||
engine consumer dispatches the corresponding ``Orchestrator.run_*``
|
||||
method on the next minute tick.
|
||||
"""
|
||||
cycle_norm = cycle.strip().lower()
|
||||
if cycle_norm not in {"entry", "monitor", "health", "market_snapshot"}:
|
||||
raise ValueError(
|
||||
f"cycle must be entry|monitor|health|market_snapshot, "
|
||||
f"got '{cycle}'"
|
||||
)
|
||||
return _enqueue_action(
|
||||
db_path=db_path,
|
||||
kind="run_cycle",
|
||||
payload={"cycle": cycle_norm},
|
||||
)
|
||||
|
||||
|
||||
def load_market_snapshots(
|
||||
*,
|
||||
asset: str,
|
||||
db_path: Path | str = DEFAULT_DB_PATH,
|
||||
start: datetime | None = None,
|
||||
end: datetime | None = None,
|
||||
limit: int = 5000,
|
||||
) -> list[MarketSnapshotRecord]:
|
||||
"""Return market_snapshots rows for the asset, newest-first."""
|
||||
db_path = Path(db_path)
|
||||
if not db_path.exists():
|
||||
return []
|
||||
repo = Repository()
|
||||
conn = connect(db_path)
|
||||
try:
|
||||
return repo.list_market_snapshots(
|
||||
conn, asset=asset, start=start, end=end, limit=limit
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def load_pending_manual_actions(
|
||||
*, db_path: Path | str = DEFAULT_DB_PATH
|
||||
) -> list[ManualAction]:
|
||||
"""All unconsumed actions, oldest first (used for the pending strip)."""
|
||||
db_path = Path(db_path)
|
||||
if not db_path.exists():
|
||||
return []
|
||||
conn = connect(db_path)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM manual_actions WHERE consumed_at IS NULL "
|
||||
"ORDER BY created_at ASC"
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return [_row_to_manual(row) for row in rows]
|
||||
|
||||
|
||||
def load_audit_tail(
|
||||
*,
|
||||
audit_path: Path | str = DEFAULT_AUDIT_PATH,
|
||||
limit: int = 100,
|
||||
event_filter: str | None = None,
|
||||
) -> list[AuditEntry]:
|
||||
"""Return the most recent audit entries (newest first).
|
||||
|
||||
For the GUI we walk the entire file (the audit log is append-only and
|
||||
bounded by daily rotation; reading 100 lines stays cheap). The
|
||||
optional ``event_filter`` matches by exact event name.
|
||||
"""
|
||||
audit_path = Path(audit_path)
|
||||
entries: list[AuditEntry] = []
|
||||
if not audit_path.exists():
|
||||
return entries
|
||||
for entry in iter_entries(audit_path):
|
||||
if event_filter and entry.event != event_filter:
|
||||
continue
|
||||
entries.append(entry)
|
||||
entries.reverse() # newest first
|
||||
return entries[:limit]
|
||||
|
||||
|
||||
def load_audit_chain_status(
|
||||
*, audit_path: Path | str = DEFAULT_AUDIT_PATH
|
||||
) -> AuditChainStatus:
|
||||
audit_path = Path(audit_path)
|
||||
try:
|
||||
n = verify_chain(audit_path)
|
||||
except AuditChainError as exc:
|
||||
return AuditChainStatus(ok=False, entries_verified=0, error=str(exc))
|
||||
except Exception as exc: # pragma: no cover — surface unexpected IO errors
|
||||
return AuditChainStatus(ok=False, entries_verified=0, error=str(exc))
|
||||
return AuditChainStatus(ok=True, entries_verified=n, error=None)
|
||||
|
||||
|
||||
def humanize_age(seconds: float | None) -> str:
|
||||
if seconds is None:
|
||||
return "—"
|
||||
if seconds < 60:
|
||||
return f"{int(seconds)}s fa"
|
||||
if seconds < 3600:
|
||||
return f"{int(seconds / 60)}m fa"
|
||||
if seconds < 86400:
|
||||
return f"{seconds / 3600:.1f}h fa"
|
||||
return f"{seconds / 86400:.1f}g fa"
|
||||
|
||||
|
||||
def humanize_dt(value: datetime | None) -> str:
|
||||
if value is None:
|
||||
return "—"
|
||||
return value.astimezone(UTC).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
|
||||
|
||||
def humanize_timedelta(value: timedelta | None) -> str: # pragma: no cover
|
||||
if value is None:
|
||||
return "—"
|
||||
return f"{value.total_seconds() / 3600:.1f}h"
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Live MCP fetch for the GUI (saldi exchange, FX rate).
|
||||
|
||||
The original architecture forbade the GUI from calling MCP services
|
||||
(`docs/11-gui-streamlit.md`). For the "Saldi exchange" panel that
|
||||
constraint is relaxed: the dashboard fetches balances on demand,
|
||||
caches the result with Streamlit's TTL cache, and never holds the
|
||||
async client open between renders. Every fetch is a one-shot:
|
||||
|
||||
* read endpoints + token from env (same path used by the CLI),
|
||||
* spin up a short-lived ``httpx.AsyncClient``,
|
||||
* query Deribit `get_account_summary` for both ``USDC`` and ``USDT``,
|
||||
* query Hyperliquid `get_account_summary` (returns ``spot_usdc``,
|
||||
``perps_equity`` etc.),
|
||||
* query Macro `get_asset_price("EURUSD")` for FX,
|
||||
* close the client and return a frozen dataclass to the page.
|
||||
|
||||
If a single exchange call fails the row is filled with ``error=...``
|
||||
and the others are still rendered.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from cerbero_bite.clients._base import HttpToolClient
|
||||
from cerbero_bite.clients.deribit import DeribitClient
|
||||
from cerbero_bite.clients.hyperliquid import HyperliquidClient
|
||||
from cerbero_bite.clients.macro import MacroClient
|
||||
from cerbero_bite.config.mcp_endpoints import load_endpoints, load_token
|
||||
|
||||
__all__ = [
|
||||
"BalanceRow",
|
||||
"BalancesSnapshot",
|
||||
"fetch_balances_sync",
|
||||
]
|
||||
|
||||
|
||||
_DERIBIT_CURRENCIES = ("USDC", "USDT")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BalanceRow:
|
||||
"""One row of the balances table."""
|
||||
|
||||
exchange: str
|
||||
currency: str
|
||||
equity: Decimal | None
|
||||
available: Decimal | None
|
||||
unrealized_pnl: Decimal | None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BalancesSnapshot:
|
||||
"""Result of one fetch_balances call (rows + meta)."""
|
||||
|
||||
rows: list[BalanceRow]
|
||||
eur_usd_rate: Decimal | None
|
||||
fetched_at: datetime
|
||||
fx_error: str | None = None
|
||||
|
||||
def total_usd(self) -> Decimal:
|
||||
total = Decimal(0)
|
||||
for r in self.rows:
|
||||
if r.equity is not None:
|
||||
total += r.equity
|
||||
return total
|
||||
|
||||
def total_eur(self) -> Decimal | None:
|
||||
if self.eur_usd_rate is None or self.eur_usd_rate <= 0:
|
||||
return None
|
||||
return self.total_usd() / self.eur_usd_rate
|
||||
|
||||
|
||||
def _decimal_or_none(value: Any) -> Decimal | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return Decimal(str(value))
|
||||
except (ValueError, ArithmeticError):
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_token() -> str:
|
||||
"""Read the MCP bearer token from the environment.
|
||||
|
||||
The token is sourced from ``CERBERO_BITE_MCP_TOKEN``; on Cerbero MCP
|
||||
V2 the same single token decides whether the upstream environment
|
||||
is testnet or mainnet.
|
||||
"""
|
||||
return load_token()
|
||||
|
||||
|
||||
async def _fetch_deribit_currency(
|
||||
deribit: DeribitClient, currency: str
|
||||
) -> BalanceRow:
|
||||
try:
|
||||
summary = await deribit.get_account_summary(currency=currency)
|
||||
except Exception as exc:
|
||||
return BalanceRow(
|
||||
exchange="deribit",
|
||||
currency=currency,
|
||||
equity=None,
|
||||
available=None,
|
||||
unrealized_pnl=None,
|
||||
error=f"{type(exc).__name__}: {exc}",
|
||||
)
|
||||
# Cerbero MCP V2 returns HTTP 200 with a soft ``error`` field when
|
||||
# the upstream Deribit call failed (e.g. invalid credentials). Treat
|
||||
# that as a row-level failure so the dashboard surfaces the cause
|
||||
# instead of showing a misleading equity=0.
|
||||
soft_error = summary.get("error")
|
||||
if soft_error:
|
||||
return BalanceRow(
|
||||
exchange="deribit",
|
||||
currency=currency,
|
||||
equity=None,
|
||||
available=None,
|
||||
unrealized_pnl=None,
|
||||
error=str(soft_error),
|
||||
)
|
||||
return BalanceRow(
|
||||
exchange="deribit",
|
||||
currency=currency,
|
||||
equity=_decimal_or_none(summary.get("equity")),
|
||||
available=_decimal_or_none(summary.get("available_funds")),
|
||||
unrealized_pnl=_decimal_or_none(summary.get("unrealized_pnl")),
|
||||
)
|
||||
|
||||
|
||||
async def _fetch_hyperliquid(hl: HyperliquidClient) -> list[BalanceRow]:
|
||||
try:
|
||||
summary = await hl.get_account_summary()
|
||||
except Exception as exc:
|
||||
return [
|
||||
BalanceRow(
|
||||
exchange="hyperliquid",
|
||||
currency="USDC",
|
||||
equity=None,
|
||||
available=None,
|
||||
unrealized_pnl=None,
|
||||
error=f"{type(exc).__name__}: {exc}",
|
||||
)
|
||||
]
|
||||
rows: list[BalanceRow] = [
|
||||
BalanceRow(
|
||||
exchange="hyperliquid",
|
||||
currency="USDC",
|
||||
equity=_decimal_or_none(summary.get("equity")),
|
||||
available=_decimal_or_none(summary.get("available_balance")),
|
||||
unrealized_pnl=_decimal_or_none(summary.get("unrealized_pnl")),
|
||||
)
|
||||
]
|
||||
# Hyperliquid spot may also hold USDT; the MCP server exposes it
|
||||
# under spot_usdt when present. Add a row only if the field is there
|
||||
# so we don't render a confusing "0.00" against an asset the account
|
||||
# never held.
|
||||
spot_usdt = summary.get("spot_usdt")
|
||||
if spot_usdt is not None:
|
||||
rows.append(
|
||||
BalanceRow(
|
||||
exchange="hyperliquid",
|
||||
currency="USDT",
|
||||
equity=_decimal_or_none(spot_usdt),
|
||||
available=_decimal_or_none(spot_usdt),
|
||||
unrealized_pnl=Decimal(0),
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
async def _fetch_balances_async(*, timeout_s: float = 8.0) -> BalancesSnapshot:
|
||||
endpoints = load_endpoints()
|
||||
token = _resolve_token()
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout_s) as http_client:
|
||||
|
||||
def _client(service: str) -> HttpToolClient:
|
||||
return HttpToolClient(
|
||||
service=service,
|
||||
base_url=endpoints.for_service(service),
|
||||
token=token,
|
||||
timeout_s=timeout_s,
|
||||
retry_max=1,
|
||||
client=http_client,
|
||||
)
|
||||
|
||||
deribit = DeribitClient(_client("deribit"))
|
||||
hl = HyperliquidClient(_client("hyperliquid"))
|
||||
macro = MacroClient(_client("macro"))
|
||||
|
||||
deribit_results, hl_rows, (fx_value, fx_error) = await asyncio.gather(
|
||||
asyncio.gather(
|
||||
*(
|
||||
_fetch_deribit_currency(deribit, cur)
|
||||
for cur in _DERIBIT_CURRENCIES
|
||||
)
|
||||
),
|
||||
_fetch_hyperliquid(hl),
|
||||
_fetch_eur_usd(macro),
|
||||
)
|
||||
deribit_rows = list(deribit_results)
|
||||
|
||||
return BalancesSnapshot(
|
||||
rows=[*deribit_rows, *hl_rows],
|
||||
eur_usd_rate=fx_value,
|
||||
fetched_at=datetime.now(UTC),
|
||||
fx_error=fx_error,
|
||||
)
|
||||
|
||||
|
||||
async def _fetch_eur_usd(
|
||||
macro: MacroClient,
|
||||
) -> tuple[Decimal | None, str | None]:
|
||||
try:
|
||||
rate = await macro.eur_usd_rate()
|
||||
except Exception as exc:
|
||||
return None, f"{type(exc).__name__}: {exc}"
|
||||
return rate, None
|
||||
|
||||
|
||||
def fetch_balances_sync(*, timeout_s: float = 8.0) -> BalancesSnapshot:
|
||||
"""Sync wrapper for Streamlit pages (which run in a sync context)."""
|
||||
return asyncio.run(_fetch_balances_async(timeout_s=timeout_s))
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Streamlit entry point for the Cerbero Bite dashboard.
|
||||
|
||||
Launch with::
|
||||
|
||||
cerbero-bite gui
|
||||
|
||||
or directly::
|
||||
|
||||
uv run streamlit run src/cerbero_bite/gui/main.py \
|
||||
--server.address 127.0.0.1 \
|
||||
--server.port 8765 \
|
||||
--server.headless true
|
||||
|
||||
The dashboard is **read-mostly**: it reads SQLite + the audit log and
|
||||
never imports ``runtime/`` modules. Each Streamlit page is in
|
||||
``gui/pages/`` and Streamlit auto-discovers them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import streamlit as st
|
||||
|
||||
from cerbero_bite.gui.data_layer import (
|
||||
DEFAULT_AUDIT_PATH,
|
||||
DEFAULT_DB_PATH,
|
||||
humanize_age,
|
||||
humanize_dt,
|
||||
load_engine_snapshot,
|
||||
)
|
||||
|
||||
PAGE_TITLE = "Cerbero Bite — Cruscotto"
|
||||
PAGE_ICON = str(Path(__file__).parent / "assets" / "cerbero_logo.png")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Path resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _resolve_paths() -> tuple[Path, Path]:
|
||||
"""Read DB / audit paths from env (settable by ``cerbero-bite gui``)."""
|
||||
db_path = Path(os.environ.get("CERBERO_BITE_GUI_DB", DEFAULT_DB_PATH))
|
||||
audit_path = Path(os.environ.get("CERBERO_BITE_GUI_AUDIT", DEFAULT_AUDIT_PATH))
|
||||
return db_path, audit_path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sidebar
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_HEALTH_BADGES: dict[str, tuple[str, str]] = {
|
||||
"running": ("🟢", "ATTIVO"),
|
||||
"degraded": ("🟡", "DEGRADATO"),
|
||||
"killed": ("🔴", "KILL SWITCH"),
|
||||
"stopped": ("⚫", "FERMO"),
|
||||
"unknown": ("⚪", "SCONOSCIUTO"),
|
||||
}
|
||||
|
||||
|
||||
def _render_sidebar(db_path: Path, audit_path: Path) -> None:
|
||||
snap = load_engine_snapshot(db_path=db_path)
|
||||
icon, label = _HEALTH_BADGES.get(snap.health, ("⚪", "SCONOSCIUTO"))
|
||||
|
||||
logo_path = Path(__file__).parent / "assets" / "cerbero_logo.png"
|
||||
if logo_path.is_file():
|
||||
st.sidebar.image(str(logo_path), use_container_width=True)
|
||||
st.sidebar.markdown(f"### {icon} {label}")
|
||||
if snap.kill_switch_armed:
|
||||
st.sidebar.error(
|
||||
f"**Kill switch armato**\n\n"
|
||||
f"motivo: {snap.kill_reason or '—'}\n\n"
|
||||
f"da: {humanize_dt(snap.kill_at)}"
|
||||
)
|
||||
|
||||
st.sidebar.metric(
|
||||
"Ultimo health check",
|
||||
humanize_age(snap.last_health_check_age_s),
|
||||
)
|
||||
st.sidebar.metric("Posizioni aperte", snap.open_positions)
|
||||
st.sidebar.caption(f"config: `{snap.config_version or '—'}`")
|
||||
|
||||
st.sidebar.divider()
|
||||
st.sidebar.caption("Sola lettura • solo localhost")
|
||||
st.sidebar.caption(f"db: `{db_path}`")
|
||||
st.sidebar.caption(f"audit: `{audit_path}`")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Home page
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main() -> None:
|
||||
st.set_page_config(
|
||||
page_title=PAGE_TITLE,
|
||||
page_icon=PAGE_ICON,
|
||||
layout="wide",
|
||||
initial_sidebar_state="expanded",
|
||||
)
|
||||
|
||||
db_path, audit_path = _resolve_paths()
|
||||
_render_sidebar(db_path, audit_path)
|
||||
|
||||
logo_path = Path(__file__).parent / "assets" / "cerbero_logo.png"
|
||||
header_cols = st.columns([1, 6])
|
||||
if logo_path.is_file():
|
||||
header_cols[0].image(str(logo_path), use_container_width=True)
|
||||
header_cols[1].title("Cerbero Bite")
|
||||
st.caption(
|
||||
"Motore rule-based per credit spread su ETH — cruscotto in sola lettura"
|
||||
)
|
||||
|
||||
st.markdown(
|
||||
"""
|
||||
Usa la barra laterale per navigare:
|
||||
|
||||
- **Stato** — salute del motore, kill switch, posizioni aperte, ancora audit
|
||||
- **Audit** — streaming del registro audit + verifica integrità della catena
|
||||
- **Equity** — P&L cumulato, drawdown, distribuzione per chiusura, statistiche mensili
|
||||
- **Storico** — trade chiusi con filtri, KPI, esportazione CSV
|
||||
- **Posizione** — drilldown sulla singola posizione con grafico payoff
|
||||
|
||||
Il cruscotto legge `data/state.sqlite` e `data/audit.log` direttamente;
|
||||
non interroga mai i servizi MCP né il broker. L'unico canale di
|
||||
scrittura è la coda `manual_actions` per arm/disarm del kill switch.
|
||||
"""
|
||||
)
|
||||
|
||||
snap = load_engine_snapshot(db_path=db_path)
|
||||
cols = st.columns(4)
|
||||
cols[0].metric("Salute motore", _HEALTH_BADGES[snap.health][1])
|
||||
cols[1].metric(
|
||||
"Kill switch",
|
||||
"ARMATO" if snap.kill_switch_armed else "DISARMATO",
|
||||
)
|
||||
cols[2].metric("Posizioni aperte", snap.open_positions)
|
||||
cols[3].metric(
|
||||
"Ultimo health check",
|
||||
humanize_age(snap.last_health_check_age_s),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,347 @@
|
||||
"""Status page — engine health at a glance."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import streamlit as st
|
||||
|
||||
from cerbero_bite.gui.data_layer import (
|
||||
DEFAULT_AUDIT_PATH,
|
||||
DEFAULT_DB_PATH,
|
||||
EngineSnapshot,
|
||||
enqueue_arm_kill,
|
||||
enqueue_disarm_kill,
|
||||
enqueue_run_cycle,
|
||||
humanize_age,
|
||||
humanize_dt,
|
||||
load_engine_snapshot,
|
||||
load_open_positions,
|
||||
load_pending_manual_actions,
|
||||
)
|
||||
from cerbero_bite.gui.live_data import BalancesSnapshot, fetch_balances_sync
|
||||
|
||||
|
||||
def _resolve_paths() -> tuple[Path, Path]:
|
||||
db_path = Path(os.environ.get("CERBERO_BITE_GUI_DB", DEFAULT_DB_PATH))
|
||||
audit_path = Path(os.environ.get("CERBERO_BITE_GUI_AUDIT", DEFAULT_AUDIT_PATH))
|
||||
return db_path, audit_path
|
||||
|
||||
|
||||
_HEALTH_COLORS = {
|
||||
"running": ("🟢", "success"),
|
||||
"degraded": ("🟡", "warning"),
|
||||
"killed": ("🔴", "error"),
|
||||
"stopped": ("⚫", "warning"),
|
||||
"unknown": ("⚪", "info"),
|
||||
}
|
||||
|
||||
_TYPED_PHRASE = "confermo"
|
||||
|
||||
|
||||
def _render_force_cycle_panel(db_path: Path) -> None:
|
||||
st.subheader("Forza ciclo")
|
||||
st.caption(
|
||||
"Accoda una richiesta di esecuzione immediata di un ciclo. Funziona "
|
||||
"solo se il motore è in esecuzione (`cerbero-bite start`); il job "
|
||||
"`manual_actions` consuma la coda ogni minuto."
|
||||
)
|
||||
cols = st.columns(4)
|
||||
if cols[0].button(
|
||||
"▶ Forza entry",
|
||||
use_container_width=True,
|
||||
help="Esegue subito una valutazione del ciclo entry.",
|
||||
):
|
||||
aid = enqueue_run_cycle(cycle="entry", db_path=db_path)
|
||||
st.success(
|
||||
f"✅ ciclo entry accodato (id #{aid}). "
|
||||
"Il motore lo eseguirà entro ~1 minuto."
|
||||
)
|
||||
if cols[1].button(
|
||||
"🔍 Forza monitor",
|
||||
use_container_width=True,
|
||||
help="Esegue subito un giro del monitor sulle posizioni aperte.",
|
||||
):
|
||||
aid = enqueue_run_cycle(cycle="monitor", db_path=db_path)
|
||||
st.success(f"✅ ciclo monitor accodato (id #{aid}).")
|
||||
if cols[2].button(
|
||||
"💓 Forza health",
|
||||
use_container_width=True,
|
||||
help="Esegue subito un health check completo.",
|
||||
):
|
||||
aid = enqueue_run_cycle(cycle="health", db_path=db_path)
|
||||
st.success(f"✅ ciclo health accodato (id #{aid}).")
|
||||
if cols[3].button(
|
||||
"📐 Forza snapshot",
|
||||
use_container_width=True,
|
||||
help="Esegue subito una raccolta market_snapshot (alimenta Calibrazione).",
|
||||
):
|
||||
aid = enqueue_run_cycle(cycle="market_snapshot", db_path=db_path)
|
||||
st.success(f"✅ snapshot accodato (id #{aid}).")
|
||||
|
||||
|
||||
@st.cache_data(ttl=60, show_spinner=False)
|
||||
def _cached_balances() -> BalancesSnapshot:
|
||||
"""Fetch balances at most once per minute per Streamlit session."""
|
||||
return fetch_balances_sync(timeout_s=10.0)
|
||||
|
||||
|
||||
def _render_balances_panel() -> None:
|
||||
st.subheader("Saldi exchange")
|
||||
|
||||
refresh = st.button("🔄 Aggiorna saldi", help="Forza un nuovo fetch dagli MCP.")
|
||||
if refresh:
|
||||
_cached_balances.clear()
|
||||
|
||||
try:
|
||||
snap = _cached_balances()
|
||||
except Exception as exc:
|
||||
st.error(
|
||||
f"Impossibile leggere i saldi: {type(exc).__name__}: {exc}"
|
||||
)
|
||||
return
|
||||
|
||||
rows = []
|
||||
for r in snap.rows:
|
||||
rows.append(
|
||||
{
|
||||
"exchange": r.exchange,
|
||||
"valuta": r.currency,
|
||||
"equity": (
|
||||
f"{float(r.equity):,.2f}"
|
||||
if r.equity is not None
|
||||
else "—"
|
||||
),
|
||||
"disponibile": (
|
||||
f"{float(r.available):,.2f}"
|
||||
if r.available is not None
|
||||
else "—"
|
||||
),
|
||||
"P&L non realizzato": (
|
||||
f"{float(r.unrealized_pnl):+.2f}"
|
||||
if r.unrealized_pnl is not None
|
||||
else "—"
|
||||
),
|
||||
"errore": r.error or "",
|
||||
}
|
||||
)
|
||||
st.dataframe(rows, use_container_width=True, hide_index=True)
|
||||
|
||||
cols = st.columns(3)
|
||||
cols[0].metric("Totale USD", f"${float(snap.total_usd()):,.2f}")
|
||||
eur = snap.total_eur()
|
||||
cols[1].metric(
|
||||
"Totale EUR",
|
||||
f"€{float(eur):,.2f}" if eur is not None else "—",
|
||||
)
|
||||
cols[2].metric(
|
||||
"Cambio EUR/USD",
|
||||
f"{float(snap.eur_usd_rate):.4f}"
|
||||
if snap.eur_usd_rate is not None
|
||||
else "—",
|
||||
)
|
||||
|
||||
if snap.fx_error:
|
||||
st.warning(f"FX non disponibile: {snap.fx_error}")
|
||||
age = (
|
||||
f" · letti {humanize_dt(snap.fetched_at)}"
|
||||
if snap.fetched_at is not None
|
||||
else ""
|
||||
)
|
||||
st.caption(
|
||||
f"Cache TTL 60s · saldi letti dal gateway MCP{age}"
|
||||
)
|
||||
|
||||
|
||||
def _render_kill_switch_panel(db_path: Path, snap: EngineSnapshot) -> None:
|
||||
st.subheader("Comandi kill switch")
|
||||
|
||||
if snap.kill_switch_armed:
|
||||
st.warning(
|
||||
"Kill switch **armato**. Disarmandolo viene accodata una "
|
||||
"azione `disarm_kill`; il consumer del motore la applica al "
|
||||
"prossimo tick di un minuto e la transizione viene registrata "
|
||||
"nella catena audit."
|
||||
)
|
||||
with st.form("kill_disarm_form", clear_on_submit=True):
|
||||
reason = st.text_input(
|
||||
"Motivo (obbligatorio)",
|
||||
placeholder="es. finestra macro superata",
|
||||
)
|
||||
confirm = st.text_input(
|
||||
f"Scrivi `{_TYPED_PHRASE}` per confermare",
|
||||
placeholder=_TYPED_PHRASE,
|
||||
)
|
||||
submitted = st.form_submit_button(
|
||||
"🟢 Accoda disarmo",
|
||||
type="primary",
|
||||
use_container_width=True,
|
||||
)
|
||||
if submitted:
|
||||
if confirm.strip() != _TYPED_PHRASE:
|
||||
st.error(
|
||||
f"Scrivi esattamente `{_TYPED_PHRASE}` per confermare."
|
||||
)
|
||||
elif not reason.strip():
|
||||
st.error("Il motivo è obbligatorio.")
|
||||
else:
|
||||
aid = enqueue_disarm_kill(reason=reason, db_path=db_path)
|
||||
st.success(
|
||||
f"✅ disarmo accodato (id #{aid}). "
|
||||
"Il motore lo applicherà entro ~1 minuto."
|
||||
)
|
||||
else:
|
||||
st.info(
|
||||
"Kill switch **disarmato**. Armandolo viene accodata una "
|
||||
"azione `arm_kill`; il consumer del motore la applica al "
|
||||
"prossimo tick di un minuto."
|
||||
)
|
||||
with st.form("kill_arm_form", clear_on_submit=True):
|
||||
reason = st.text_input(
|
||||
"Motivo (obbligatorio)",
|
||||
placeholder="es. shock macro — sospendi trading",
|
||||
)
|
||||
confirm = st.text_input(
|
||||
f"Scrivi `{_TYPED_PHRASE}` per confermare",
|
||||
placeholder=_TYPED_PHRASE,
|
||||
)
|
||||
submitted = st.form_submit_button(
|
||||
"🔴 Accoda armamento",
|
||||
type="secondary",
|
||||
use_container_width=True,
|
||||
)
|
||||
if submitted:
|
||||
if confirm.strip() != _TYPED_PHRASE:
|
||||
st.error(
|
||||
f"Scrivi esattamente `{_TYPED_PHRASE}` per confermare."
|
||||
)
|
||||
elif not reason.strip():
|
||||
st.error("Il motivo è obbligatorio.")
|
||||
else:
|
||||
aid = enqueue_arm_kill(reason=reason, db_path=db_path)
|
||||
st.success(
|
||||
f"✅ armamento accodato (id #{aid}). "
|
||||
"Il motore lo applicherà entro ~1 minuto."
|
||||
)
|
||||
|
||||
|
||||
def render() -> None:
|
||||
st.title("📊 Stato")
|
||||
st.caption(
|
||||
"Salute del motore, kill switch, posizioni aperte e ancora audit."
|
||||
)
|
||||
|
||||
db_path, _ = _resolve_paths()
|
||||
snap = load_engine_snapshot(db_path=db_path)
|
||||
|
||||
icon, level = _HEALTH_COLORS.get(snap.health, ("⚪", "info"))
|
||||
banner = f"{icon} **{snap.health_label}**"
|
||||
if level == "success":
|
||||
st.success(banner)
|
||||
elif level == "warning":
|
||||
st.warning(banner)
|
||||
elif level == "error":
|
||||
st.error(banner)
|
||||
else:
|
||||
st.info(banner)
|
||||
|
||||
if snap.kill_switch_armed:
|
||||
st.error(
|
||||
f"**Kill switch armato** — il motore rifiuterà nuove entrate.\n\n"
|
||||
f"- motivo: `{snap.kill_reason or '—'}`\n"
|
||||
f"- da: `{humanize_dt(snap.kill_at)}`"
|
||||
)
|
||||
|
||||
# Top metrics
|
||||
cols = st.columns(4)
|
||||
cols[0].metric("Posizioni aperte", snap.open_positions)
|
||||
cols[1].metric(
|
||||
"Ultimo health check", humanize_age(snap.last_health_check_age_s)
|
||||
)
|
||||
cols[2].metric("Avviato il", humanize_dt(snap.started_at))
|
||||
cols[3].metric("Versione config", snap.config_version or "—")
|
||||
|
||||
st.divider()
|
||||
|
||||
# Saldi exchange (live MCP fetch, TTL 60s)
|
||||
_render_balances_panel()
|
||||
|
||||
st.divider()
|
||||
|
||||
# Forza ciclo
|
||||
_render_force_cycle_panel(db_path)
|
||||
|
||||
st.divider()
|
||||
|
||||
# Kill switch controls
|
||||
_render_kill_switch_panel(db_path, snap)
|
||||
|
||||
st.divider()
|
||||
|
||||
# Azioni manuali pendenti
|
||||
pending = load_pending_manual_actions(db_path=db_path)
|
||||
if pending:
|
||||
st.subheader("Azioni manuali pendenti")
|
||||
st.caption(
|
||||
"Accodate da questo cruscotto, non ancora consumate. Il motore "
|
||||
"drena la coda ogni minuto tramite il job `manual_actions`."
|
||||
)
|
||||
rows_pending = [
|
||||
{
|
||||
"id": a.id,
|
||||
"tipo": a.kind,
|
||||
"payload": a.payload_json or "",
|
||||
"creata il": humanize_dt(a.created_at),
|
||||
}
|
||||
for a in pending
|
||||
]
|
||||
st.dataframe(rows_pending, use_container_width=True, hide_index=True)
|
||||
st.divider()
|
||||
|
||||
# Ancora audit
|
||||
st.subheader("Ancora audit")
|
||||
if snap.last_audit_hash is None:
|
||||
st.info("Nessuna ancora registrata.")
|
||||
else:
|
||||
short = (
|
||||
f"{snap.last_audit_hash[:12]}…{snap.last_audit_hash[-12:]}"
|
||||
if len(snap.last_audit_hash) > 24
|
||||
else snap.last_audit_hash
|
||||
)
|
||||
st.code(short, language="text")
|
||||
st.caption(
|
||||
"Ultima testa della catena hash persistita in "
|
||||
"`system_state.last_audit_hash`. All'avvio l'orchestrator la "
|
||||
"confronta con la coda del file audit; un mismatch arma il "
|
||||
"kill switch (CRITICAL)."
|
||||
)
|
||||
|
||||
st.divider()
|
||||
|
||||
# Tabella posizioni aperte
|
||||
st.subheader("Posizioni aperte")
|
||||
positions = load_open_positions(db_path=db_path)
|
||||
if not positions:
|
||||
st.info("Nessuna posizione aperta.")
|
||||
else:
|
||||
rows = [
|
||||
{
|
||||
"proposal_id": str(p.proposal_id)[:8],
|
||||
"spread": p.spread_type,
|
||||
"asset": p.asset,
|
||||
"n. contratti": p.n_contracts,
|
||||
"credito (USD)": f"{p.credit_usd:.2f}",
|
||||
"max perdita (USD)": f"{p.max_loss_usd:.2f}",
|
||||
"strike short": f"{p.short_strike}",
|
||||
"strike long": f"{p.long_strike}",
|
||||
"stato": p.status,
|
||||
"aperta il": humanize_dt(p.opened_at),
|
||||
"scadenza": humanize_dt(p.expiry),
|
||||
}
|
||||
for p in positions
|
||||
]
|
||||
st.dataframe(rows, use_container_width=True)
|
||||
|
||||
|
||||
render()
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Audit page — live audit log stream + chain integrity verification."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
import streamlit as st
|
||||
|
||||
from cerbero_bite.gui.data_layer import (
|
||||
DEFAULT_AUDIT_PATH,
|
||||
DEFAULT_DB_PATH,
|
||||
humanize_dt,
|
||||
load_audit_chain_status,
|
||||
load_audit_tail,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_paths() -> tuple[Path, Path]:
|
||||
db_path = Path(os.environ.get("CERBERO_BITE_GUI_DB", DEFAULT_DB_PATH))
|
||||
audit_path = Path(os.environ.get("CERBERO_BITE_GUI_AUDIT", DEFAULT_AUDIT_PATH))
|
||||
return db_path, audit_path
|
||||
|
||||
|
||||
def render() -> None:
|
||||
st.title("🔍 Audit")
|
||||
st.caption(
|
||||
"Registro audit append-only con hash chain "
|
||||
"(`data/audit.log`). La lettura non modifica nulla."
|
||||
)
|
||||
|
||||
_, audit_path = _resolve_paths()
|
||||
|
||||
col_l, col_r = st.columns([1, 2])
|
||||
|
||||
with col_l:
|
||||
st.subheader("Integrità catena")
|
||||
if st.button("Verifica catena", type="primary"):
|
||||
with st.spinner("Sto percorrendo la catena…"):
|
||||
status = load_audit_chain_status(audit_path=audit_path)
|
||||
if status.ok:
|
||||
st.success(
|
||||
f"✅ catena integra fino a {status.entries_verified} eventi"
|
||||
)
|
||||
else:
|
||||
st.error(
|
||||
f"❌ tampering rilevato\n\n```\n{status.error}\n```"
|
||||
)
|
||||
else:
|
||||
st.caption(
|
||||
"Premi per ricalcolare l'hash di ogni riga e verificare il "
|
||||
"collegamento prev-hash. Mismatch → alert CRITICAL in "
|
||||
"produzione."
|
||||
)
|
||||
|
||||
with col_r:
|
||||
st.subheader("Filtri")
|
||||
limit = st.slider(
|
||||
"Ultimi N eventi",
|
||||
min_value=10,
|
||||
max_value=500,
|
||||
value=100,
|
||||
step=10,
|
||||
)
|
||||
# Build event list from the available tail
|
||||
all_recent = load_audit_tail(audit_path=audit_path, limit=limit)
|
||||
events_present = sorted({e.event for e in all_recent})
|
||||
event_filter = st.selectbox(
|
||||
"Filtro per evento",
|
||||
options=["(tutti)", *events_present],
|
||||
index=0,
|
||||
)
|
||||
|
||||
st.divider()
|
||||
|
||||
# Statistics strip
|
||||
counter: Counter[str] = Counter(e.event for e in all_recent)
|
||||
if counter:
|
||||
cols = st.columns(min(len(counter), 6))
|
||||
for col, (event, count) in zip(cols, counter.most_common(6), strict=False):
|
||||
col.metric(event, count)
|
||||
|
||||
st.divider()
|
||||
|
||||
# Tail filtrata
|
||||
filtered = (
|
||||
all_recent
|
||||
if event_filter == "(tutti)"
|
||||
else [e for e in all_recent if e.event == event_filter]
|
||||
)
|
||||
|
||||
st.subheader(f"Ultimi eventi ({len(filtered)} mostrati)")
|
||||
if not filtered:
|
||||
st.info("Nessun evento corrisponde ai filtri.")
|
||||
return
|
||||
|
||||
rows = []
|
||||
for entry in filtered:
|
||||
try:
|
||||
payload_pretty = json.dumps(
|
||||
entry.payload, ensure_ascii=False, sort_keys=True
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
payload_pretty = str(entry.payload)
|
||||
rows.append(
|
||||
{
|
||||
"timestamp": humanize_dt(entry.timestamp),
|
||||
"evento": entry.event,
|
||||
"payload": payload_pretty,
|
||||
"hash": (
|
||||
f"{entry.hash[:8]}…{entry.hash[-8:]}"
|
||||
if len(entry.hash) > 16
|
||||
else entry.hash
|
||||
),
|
||||
}
|
||||
)
|
||||
st.dataframe(rows, use_container_width=True, hide_index=True)
|
||||
|
||||
|
||||
render()
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Equity page — cumulative PnL, drawdown, distributions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections import Counter
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
import plotly.graph_objects as go
|
||||
import streamlit as st
|
||||
|
||||
from cerbero_bite.gui.data_layer import (
|
||||
DEFAULT_DB_PATH,
|
||||
compute_equity_curve,
|
||||
compute_kpis,
|
||||
compute_monthly_stats,
|
||||
load_closed_positions,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_db() -> Path:
|
||||
return Path(os.environ.get("CERBERO_BITE_GUI_DB", DEFAULT_DB_PATH))
|
||||
|
||||
|
||||
def _date_window(label: str) -> tuple[datetime | None, datetime | None]:
|
||||
"""Selettore della finestra temporale per l'analitica."""
|
||||
options = {
|
||||
"Tutto lo storico": (None, None),
|
||||
"Ultimi 30 giorni": (datetime.now(UTC) - timedelta(days=30), None),
|
||||
"Ultimi 90 giorni": (datetime.now(UTC) - timedelta(days=90), None),
|
||||
"Da inizio anno": (
|
||||
datetime(datetime.now(UTC).year, 1, 1, tzinfo=UTC),
|
||||
None,
|
||||
),
|
||||
}
|
||||
pick = st.selectbox(label, list(options.keys()), index=0)
|
||||
return options[pick]
|
||||
|
||||
|
||||
def render() -> None:
|
||||
st.title("📈 Equity")
|
||||
st.caption(
|
||||
"P&L realizzato cumulato, drawdown e distribuzione per trade. "
|
||||
"Calcolato dalle posizioni chiuse in `data/state.sqlite`."
|
||||
)
|
||||
|
||||
start, end = _date_window("Finestra")
|
||||
|
||||
db_path = _resolve_db()
|
||||
positions = load_closed_positions(db_path=db_path, start=start, end=end)
|
||||
|
||||
if not positions:
|
||||
st.info(
|
||||
"Nessuna posizione chiusa nella finestra selezionata. "
|
||||
"La curva equity si popolerà non appena il motore chiuderà "
|
||||
"il primo trade."
|
||||
)
|
||||
return
|
||||
|
||||
# Striscia KPI
|
||||
kpis = compute_kpis(positions)
|
||||
cols = st.columns(5)
|
||||
cols[0].metric("Trade chiusi", kpis.n_trades)
|
||||
cols[1].metric("Win rate", f"{kpis.win_rate:.0%}")
|
||||
cols[2].metric("P&L totale", f"${float(kpis.total_pnl_usd):+.2f}")
|
||||
cols[3].metric("Edge / trade", f"${float(kpis.edge_per_trade_usd):+.2f}")
|
||||
cols[4].metric(
|
||||
"Max drawdown",
|
||||
f"${float(kpis.max_drawdown_usd):.2f}",
|
||||
delta=f"{kpis.max_drawdown_pct:.1%}",
|
||||
delta_color="inverse",
|
||||
)
|
||||
|
||||
st.divider()
|
||||
|
||||
# Equity curve + drawdown
|
||||
curve = compute_equity_curve(positions)
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
"timestamp": [p.timestamp for p in curve],
|
||||
"cumulative_pnl_usd": [float(p.cumulative_pnl_usd) for p in curve],
|
||||
"drawdown_usd": [float(p.drawdown_usd) for p in curve],
|
||||
"realized_pnl_usd": [float(p.realized_pnl_usd) for p in curve],
|
||||
}
|
||||
)
|
||||
|
||||
st.subheader("P&L cumulato (USD)")
|
||||
fig = go.Figure()
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=df["timestamp"],
|
||||
y=df["cumulative_pnl_usd"],
|
||||
mode="lines+markers",
|
||||
name="P&L cumulato",
|
||||
line={"color": "#2ecc71", "width": 2},
|
||||
)
|
||||
)
|
||||
fig.add_hline(y=0, line_dash="dot", line_color="grey", opacity=0.5)
|
||||
fig.update_layout(
|
||||
height=320,
|
||||
margin={"l": 10, "r": 10, "t": 30, "b": 10},
|
||||
xaxis_title=None,
|
||||
yaxis_title="USD",
|
||||
)
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
|
||||
st.subheader("Drawdown (USD)")
|
||||
dd_fig = go.Figure()
|
||||
dd_fig.add_trace(
|
||||
go.Scatter(
|
||||
x=df["timestamp"],
|
||||
y=-df["drawdown_usd"],
|
||||
mode="lines",
|
||||
fill="tozeroy",
|
||||
name="drawdown",
|
||||
line={"color": "#e74c3c", "width": 1.5},
|
||||
)
|
||||
)
|
||||
dd_fig.update_layout(
|
||||
height=220,
|
||||
margin={"l": 10, "r": 10, "t": 30, "b": 10},
|
||||
xaxis_title=None,
|
||||
yaxis_title="USD",
|
||||
)
|
||||
st.plotly_chart(dd_fig, use_container_width=True)
|
||||
|
||||
# Distribuzione P&L
|
||||
st.subheader("Distribuzione P&L per motivo di chiusura")
|
||||
by_reason: dict[str, list[float]] = {}
|
||||
for pos in positions:
|
||||
if pos.pnl_usd is None:
|
||||
continue
|
||||
by_reason.setdefault(pos.close_reason or "(sconosciuto)", []).append(
|
||||
float(pos.pnl_usd)
|
||||
)
|
||||
|
||||
counts = Counter(
|
||||
(pos.close_reason or "(sconosciuto)") for pos in positions
|
||||
)
|
||||
cols = st.columns(min(len(counts), 6) or 1)
|
||||
for col, (reason, count) in zip(cols, counts.most_common(6), strict=False):
|
||||
col.metric(reason, count)
|
||||
|
||||
hist_fig = go.Figure()
|
||||
for reason, pnls in by_reason.items():
|
||||
hist_fig.add_trace(
|
||||
go.Histogram(x=pnls, name=reason, opacity=0.6, nbinsx=30)
|
||||
)
|
||||
hist_fig.update_layout(
|
||||
barmode="overlay",
|
||||
height=320,
|
||||
margin={"l": 10, "r": 10, "t": 30, "b": 10},
|
||||
xaxis_title="P&L (USD)",
|
||||
yaxis_title="numero trade",
|
||||
legend={"orientation": "h", "y": 1.1},
|
||||
)
|
||||
st.plotly_chart(hist_fig, use_container_width=True)
|
||||
|
||||
# Tabella mensile
|
||||
st.subheader("Statistiche mensili")
|
||||
months = compute_monthly_stats(positions)
|
||||
rows = [
|
||||
{
|
||||
"mese": m.year_month,
|
||||
"trade": m.n_trades,
|
||||
"vittorie": m.n_wins,
|
||||
"win rate": f"{m.win_rate:.0%}",
|
||||
"P&L (USD)": f"{float(m.pnl_usd):+.2f}",
|
||||
"media / trade": f"{float(m.avg_pnl_usd):+.2f}",
|
||||
}
|
||||
for m in months
|
||||
]
|
||||
st.dataframe(rows, use_container_width=True, hide_index=True)
|
||||
|
||||
|
||||
render()
|
||||
@@ -0,0 +1,135 @@
|
||||
"""History page — closed-trade table with filters and CSV export."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
import streamlit as st
|
||||
|
||||
from cerbero_bite.gui.data_layer import (
|
||||
DEFAULT_DB_PATH,
|
||||
compute_kpis,
|
||||
humanize_dt,
|
||||
load_closed_positions,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_db() -> Path:
|
||||
return Path(os.environ.get("CERBERO_BITE_GUI_DB", DEFAULT_DB_PATH))
|
||||
|
||||
|
||||
def _date_window() -> tuple[datetime | None, datetime | None]:
|
||||
presets = {
|
||||
"Tutto lo storico": (None, None),
|
||||
"Ultimi 7 giorni": (datetime.now(UTC) - timedelta(days=7), None),
|
||||
"Ultimi 30 giorni": (datetime.now(UTC) - timedelta(days=30), None),
|
||||
"Ultimi 90 giorni": (datetime.now(UTC) - timedelta(days=90), None),
|
||||
"Da inizio anno": (
|
||||
datetime(datetime.now(UTC).year, 1, 1, tzinfo=UTC),
|
||||
None,
|
||||
),
|
||||
}
|
||||
pick = st.selectbox("Finestra", list(presets.keys()), index=0)
|
||||
return presets[pick]
|
||||
|
||||
|
||||
def render() -> None:
|
||||
st.title("📜 Storico")
|
||||
st.caption(
|
||||
"Trade chiusi con filtri, striscia KPI ed esportazione CSV."
|
||||
)
|
||||
|
||||
db_path = _resolve_db()
|
||||
start, end = _date_window()
|
||||
positions = load_closed_positions(db_path=db_path, start=start, end=end)
|
||||
|
||||
# Sotto-filtri per motivo di chiusura e segno P&L
|
||||
reason_options = sorted(
|
||||
{p.close_reason or "(sconosciuto)" for p in positions}
|
||||
)
|
||||
chosen_reasons = st.multiselect(
|
||||
"Motivi di chiusura",
|
||||
options=reason_options,
|
||||
default=reason_options,
|
||||
)
|
||||
pnl_filter = st.radio(
|
||||
"Filtro P&L",
|
||||
options=["tutti", "vincenti", "perdenti"],
|
||||
horizontal=True,
|
||||
index=0,
|
||||
)
|
||||
|
||||
filtered = []
|
||||
for p in positions:
|
||||
reason = p.close_reason or "(sconosciuto)"
|
||||
if reason not in chosen_reasons:
|
||||
continue
|
||||
if pnl_filter == "vincenti" and (p.pnl_usd is None or p.pnl_usd <= 0):
|
||||
continue
|
||||
if pnl_filter == "perdenti" and (p.pnl_usd is None or p.pnl_usd >= 0):
|
||||
continue
|
||||
filtered.append(p)
|
||||
|
||||
# Striscia KPI
|
||||
kpis = compute_kpis(filtered)
|
||||
cols = st.columns(6)
|
||||
cols[0].metric("Trade", kpis.n_trades)
|
||||
cols[1].metric("Win rate", f"{kpis.win_rate:.0%}")
|
||||
cols[2].metric("P&L totale", f"${float(kpis.total_pnl_usd):+.2f}")
|
||||
cols[3].metric("Vittoria media", f"${float(kpis.avg_win_usd):+.2f}")
|
||||
cols[4].metric("Perdita media", f"${float(kpis.avg_loss_usd):+.2f}")
|
||||
cols[5].metric("Edge / trade", f"${float(kpis.edge_per_trade_usd):+.2f}")
|
||||
|
||||
st.divider()
|
||||
|
||||
if not filtered:
|
||||
st.info("Nessun trade corrisponde ai filtri correnti.")
|
||||
return
|
||||
|
||||
# DataFrame per visualizzazione + esportazione
|
||||
rows = []
|
||||
for p in filtered:
|
||||
days_held = (
|
||||
(p.closed_at - p.opened_at).days
|
||||
if p.opened_at and p.closed_at
|
||||
else None
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
"proposal_id": str(p.proposal_id)[:8],
|
||||
"spread": p.spread_type,
|
||||
"asset": p.asset,
|
||||
"n. contratti": p.n_contracts,
|
||||
"strike short": float(p.short_strike),
|
||||
"strike long": float(p.long_strike),
|
||||
"credito (USD)": float(p.credit_usd),
|
||||
"max perdita (USD)": float(p.max_loss_usd),
|
||||
"P&L (USD)": (
|
||||
float(p.pnl_usd) if p.pnl_usd is not None else None
|
||||
),
|
||||
"motivo chiusura": p.close_reason or "(sconosciuto)",
|
||||
"giorni tenuta": days_held,
|
||||
"aperta il": humanize_dt(p.opened_at),
|
||||
"chiusa il": humanize_dt(p.closed_at),
|
||||
"scadenza": humanize_dt(p.expiry),
|
||||
}
|
||||
)
|
||||
df = pd.DataFrame(rows)
|
||||
st.dataframe(df, use_container_width=True, hide_index=True)
|
||||
|
||||
# Esportazione CSV
|
||||
buf = io.StringIO()
|
||||
df.to_csv(buf, index=False)
|
||||
st.download_button(
|
||||
"⬇ Scarica CSV",
|
||||
data=buf.getvalue(),
|
||||
file_name=f"cerbero_bite_storico_{datetime.now(UTC).date()}.csv",
|
||||
mime="text/csv",
|
||||
)
|
||||
|
||||
|
||||
render()
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Position page — drilldown on a single open or recently-closed trade."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
import plotly.graph_objects as go
|
||||
import streamlit as st
|
||||
|
||||
from cerbero_bite.gui.data_layer import (
|
||||
DEFAULT_DB_PATH,
|
||||
compute_distance_metrics,
|
||||
compute_payoff_curve,
|
||||
humanize_dt,
|
||||
load_closed_positions,
|
||||
load_decisions_for_position,
|
||||
load_open_positions,
|
||||
load_position_by_id,
|
||||
)
|
||||
from cerbero_bite.state.models import PositionRecord
|
||||
|
||||
|
||||
def _resolve_db() -> Path:
|
||||
return Path(os.environ.get("CERBERO_BITE_GUI_DB", DEFAULT_DB_PATH))
|
||||
|
||||
|
||||
def _position_label(p: PositionRecord) -> str:
|
||||
short = (
|
||||
f"{int(p.short_strike)}/{int(p.long_strike)}"
|
||||
if p.short_strike and p.long_strike
|
||||
else "—"
|
||||
)
|
||||
return f"{str(p.proposal_id)[:8]} · {p.spread_type} · {short} · {p.status}"
|
||||
|
||||
|
||||
def _render_header(position: PositionRecord) -> None:
|
||||
cols = st.columns(4)
|
||||
cols[0].metric("stato", position.status)
|
||||
cols[1].metric("spread", position.spread_type)
|
||||
cols[2].metric("contratti", position.n_contracts)
|
||||
cols[3].metric("credito (USD)", f"${float(position.credit_usd):+.2f}")
|
||||
st.caption(
|
||||
f"`{position.proposal_id}` · aperta il "
|
||||
f"{humanize_dt(position.opened_at)} · scadenza "
|
||||
f"{humanize_dt(position.expiry)}"
|
||||
)
|
||||
|
||||
|
||||
def _render_legs(position: PositionRecord) -> None:
|
||||
st.subheader("Gambe (snapshot all'entrata)")
|
||||
rows = [
|
||||
{
|
||||
"gamba": "short",
|
||||
"strumento": position.short_instrument,
|
||||
"strike": float(position.short_strike),
|
||||
"lato": "VENDI",
|
||||
"size": position.n_contracts,
|
||||
"delta all'entrata": float(position.delta_at_entry),
|
||||
},
|
||||
{
|
||||
"gamba": "long",
|
||||
"strumento": position.long_instrument,
|
||||
"strike": float(position.long_strike),
|
||||
"lato": "COMPRA",
|
||||
"size": position.n_contracts,
|
||||
"delta all'entrata": "—",
|
||||
},
|
||||
]
|
||||
st.dataframe(rows, use_container_width=True, hide_index=True)
|
||||
st.caption(
|
||||
"Mid e greche live non vengono richiesti agli MCP dal cruscotto. "
|
||||
"Il refresh è demandato al motore: visibile nella pagina Audit."
|
||||
)
|
||||
|
||||
|
||||
def _render_distance(position: PositionRecord) -> None:
|
||||
metrics = compute_distance_metrics(position)
|
||||
cols = st.columns(5)
|
||||
cols[0].metric(
|
||||
"Short OTM %",
|
||||
f"{metrics.short_strike_otm_pct:.1%}"
|
||||
if metrics.short_strike_otm_pct is not None
|
||||
else "—",
|
||||
)
|
||||
cols[1].metric(
|
||||
"Giorni a scadenza",
|
||||
metrics.days_to_expiry if metrics.days_to_expiry is not None else "—",
|
||||
)
|
||||
cols[2].metric(
|
||||
"Giorni in tenuta",
|
||||
metrics.days_held if metrics.days_held is not None else "—",
|
||||
)
|
||||
cols[3].metric("Δ all'entrata", f"{metrics.delta_at_entry:+.3f}")
|
||||
cols[4].metric("Larghezza % spot", f"{metrics.width_pct_of_spot:.1%}")
|
||||
|
||||
|
||||
def _render_payoff(position: PositionRecord) -> None:
|
||||
st.subheader("Payoff a scadenza")
|
||||
curve = compute_payoff_curve(position)
|
||||
|
||||
fig = go.Figure()
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=curve.spot_grid,
|
||||
y=curve.pnl_grid_usd,
|
||||
mode="lines",
|
||||
line={"color": "#3498db", "width": 2.5},
|
||||
name="P&L a scadenza",
|
||||
fill="tozeroy",
|
||||
fillcolor="rgba(52,152,219,0.10)",
|
||||
)
|
||||
)
|
||||
fig.add_hline(y=0, line_dash="dot", line_color="grey", opacity=0.5)
|
||||
fig.add_vline(
|
||||
x=curve.short_strike,
|
||||
line_dash="dash",
|
||||
line_color="#27ae60",
|
||||
opacity=0.7,
|
||||
annotation_text=f"short {curve.short_strike:.0f}",
|
||||
annotation_position="top",
|
||||
)
|
||||
fig.add_vline(
|
||||
x=curve.long_strike,
|
||||
line_dash="dash",
|
||||
line_color="#c0392b",
|
||||
opacity=0.7,
|
||||
annotation_text=f"long {curve.long_strike:.0f}",
|
||||
annotation_position="top",
|
||||
)
|
||||
if curve.breakeven is not None:
|
||||
fig.add_vline(
|
||||
x=curve.breakeven,
|
||||
line_dash="dot",
|
||||
line_color="orange",
|
||||
opacity=0.7,
|
||||
annotation_text=f"BE {curve.breakeven:.2f}",
|
||||
annotation_position="bottom",
|
||||
)
|
||||
fig.add_vline(
|
||||
x=curve.spot_at_entry,
|
||||
line_dash="solid",
|
||||
line_color="#7f8c8d",
|
||||
opacity=0.4,
|
||||
annotation_text=f"spot all'entrata {curve.spot_at_entry:.0f}",
|
||||
annotation_position="bottom",
|
||||
)
|
||||
fig.update_layout(
|
||||
height=380,
|
||||
margin={"l": 10, "r": 10, "t": 30, "b": 10},
|
||||
xaxis_title="ETH spot a scadenza (USD)",
|
||||
yaxis_title="P&L (USD)",
|
||||
legend={"orientation": "h", "y": 1.1},
|
||||
)
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
|
||||
cols = st.columns(3)
|
||||
cols[0].metric("Profitto massimo", f"${curve.max_profit_usd:+.2f}")
|
||||
cols[1].metric("Perdita massima", f"${curve.max_loss_usd:+.2f}")
|
||||
cols[2].metric(
|
||||
"Breakeven",
|
||||
f"{curve.breakeven:.2f}" if curve.breakeven is not None else "—",
|
||||
)
|
||||
|
||||
|
||||
def _render_decisions(position: PositionRecord) -> None:
|
||||
st.subheader("Storico decisioni")
|
||||
decisions = load_decisions_for_position(position.proposal_id)
|
||||
if not decisions:
|
||||
st.info("Nessuna decisione registrata per questa posizione.")
|
||||
return
|
||||
|
||||
rows = []
|
||||
for d in decisions:
|
||||
try:
|
||||
outputs = json.loads(d.outputs_json)
|
||||
except (TypeError, ValueError):
|
||||
outputs = {}
|
||||
rows.append(
|
||||
{
|
||||
"timestamp": humanize_dt(d.timestamp),
|
||||
"tipo decisione": d.decision_type,
|
||||
"azione": d.action_taken or "—",
|
||||
"note": d.notes or "",
|
||||
"output": json.dumps(outputs, sort_keys=True) if outputs else "",
|
||||
}
|
||||
)
|
||||
st.dataframe(rows, use_container_width=True, hide_index=True)
|
||||
|
||||
|
||||
def render() -> None:
|
||||
st.title("💼 Posizione")
|
||||
st.caption(
|
||||
"Drilldown sul trade: gambe, payoff a scadenza, storico decisioni. "
|
||||
"Tutti i dati arrivano da SQLite — nessuna chiamata MCP live."
|
||||
)
|
||||
|
||||
db_path = _resolve_db()
|
||||
|
||||
open_pos = load_open_positions(db_path=db_path)
|
||||
closed_recent = load_closed_positions(db_path=db_path)[-10:]
|
||||
candidates: list[PositionRecord] = list(open_pos) + list(reversed(closed_recent))
|
||||
|
||||
if not candidates:
|
||||
st.info(
|
||||
"Nessuna posizione da mostrare. La pagina si popolerà non "
|
||||
"appena il motore aprirà il primo trade."
|
||||
)
|
||||
return
|
||||
|
||||
labels = {_position_label(p): p for p in candidates}
|
||||
pick = st.selectbox(
|
||||
"Posizione",
|
||||
options=list(labels.keys()),
|
||||
index=0,
|
||||
)
|
||||
position = labels[pick]
|
||||
|
||||
# Deep-link via ?proposal_id=…
|
||||
qp = st.query_params.get("proposal_id")
|
||||
if qp:
|
||||
try:
|
||||
qp_uuid = UUID(qp)
|
||||
override = load_position_by_id(qp_uuid, db_path=db_path)
|
||||
if override is not None:
|
||||
position = override
|
||||
except ValueError:
|
||||
st.warning(f"Parametro proposal_id non valido: {qp}")
|
||||
|
||||
st.divider()
|
||||
_render_header(position)
|
||||
st.divider()
|
||||
_render_distance(position)
|
||||
st.divider()
|
||||
_render_legs(position)
|
||||
st.divider()
|
||||
_render_payoff(position)
|
||||
st.divider()
|
||||
_render_decisions(position)
|
||||
|
||||
|
||||
render()
|
||||
@@ -0,0 +1,451 @@
|
||||
"""Calibrazione page — distribuzioni storiche dei segnali per tarare le soglie.
|
||||
|
||||
Legge dalla tabella ``market_snapshots`` (popolata dal job dedicato cron
|
||||
``*/15``). Per ogni metrica osservabile mostra:
|
||||
|
||||
* istogramma + linea verticale della soglia attuale di config,
|
||||
* percentili P5/P10/P25/P50/P75/P90/P95,
|
||||
* percentuale di tick che la soglia attuale avrebbe filtrato.
|
||||
|
||||
L'idea è scegliere le soglie sui percentili reali del proprio
|
||||
ambiente (testnet o mainnet), invece di valori fissati a istinto.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
import plotly.graph_objects as go
|
||||
import streamlit as st
|
||||
|
||||
from cerbero_bite.config.loader import load_strategy
|
||||
from cerbero_bite.core.adaptive_threshold import compute_adaptive_threshold
|
||||
from cerbero_bite.gui.data_layer import (
|
||||
DEFAULT_DB_PATH,
|
||||
humanize_dt,
|
||||
load_market_snapshots,
|
||||
)
|
||||
from cerbero_bite.state.models import MarketSnapshotRecord
|
||||
|
||||
|
||||
def _resolve_db() -> Path:
|
||||
return Path(os.environ.get("CERBERO_BITE_GUI_DB", DEFAULT_DB_PATH))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MetricSpec:
|
||||
"""Descrittore della metrica da plottare."""
|
||||
|
||||
field: str
|
||||
title: str
|
||||
unit: str
|
||||
threshold_label: str | None
|
||||
threshold_value: float | None
|
||||
threshold_direction: str # "below" o "above" (filtra se valore è X soglia)
|
||||
|
||||
|
||||
def _metric_specs(strategy: object | None) -> list[MetricSpec]:
|
||||
"""Costruisce gli spec leggendo le soglie correnti da strategy.yaml."""
|
||||
funding_max: float | None = None
|
||||
dealer_min: float | None = None
|
||||
dvol_min: float | None = None
|
||||
if strategy is not None:
|
||||
try:
|
||||
funding_max = float(strategy.entry.funding_max_abs_annualized) # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
funding_max = None
|
||||
try:
|
||||
dealer_min = float(strategy.entry.dealer_gamma_min) # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
dealer_min = None
|
||||
try:
|
||||
dvol_min = float(strategy.entry.dvol_min) # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
dvol_min = None
|
||||
|
||||
specs: list[MetricSpec] = [
|
||||
MetricSpec(
|
||||
field="dvol",
|
||||
title="DVOL",
|
||||
unit="%",
|
||||
threshold_label=(
|
||||
f"DVOL min={dvol_min:.0f}" if dvol_min is not None else None
|
||||
),
|
||||
threshold_value=dvol_min,
|
||||
threshold_direction="below",
|
||||
),
|
||||
MetricSpec(
|
||||
field="realized_vol_30d",
|
||||
title="Realized vol 30d",
|
||||
unit="%",
|
||||
threshold_label=None,
|
||||
threshold_value=None,
|
||||
threshold_direction="below",
|
||||
),
|
||||
MetricSpec(
|
||||
field="iv_minus_rv",
|
||||
title="IV − RV (30d)",
|
||||
unit="%",
|
||||
threshold_label=None,
|
||||
threshold_value=None,
|
||||
threshold_direction="below",
|
||||
),
|
||||
MetricSpec(
|
||||
field="funding_perp_annualized",
|
||||
title="Funding perp annualized",
|
||||
unit="frazione",
|
||||
threshold_label=(
|
||||
f"|funding| max={funding_max:.2f}"
|
||||
if funding_max is not None
|
||||
else None
|
||||
),
|
||||
threshold_value=funding_max,
|
||||
threshold_direction="above_abs",
|
||||
),
|
||||
MetricSpec(
|
||||
field="funding_cross_annualized",
|
||||
title="Funding cross median annualized",
|
||||
unit="frazione",
|
||||
threshold_label=None,
|
||||
threshold_value=None,
|
||||
threshold_direction="above_abs",
|
||||
),
|
||||
MetricSpec(
|
||||
field="dealer_net_gamma",
|
||||
title="Dealer net gamma",
|
||||
unit="USD",
|
||||
threshold_label=(
|
||||
f"min={dealer_min:.0f}"
|
||||
if dealer_min is not None
|
||||
else None
|
||||
),
|
||||
threshold_value=dealer_min,
|
||||
threshold_direction="below",
|
||||
),
|
||||
MetricSpec(
|
||||
field="oi_delta_pct_4h",
|
||||
title="OI delta % (4h)",
|
||||
unit="%",
|
||||
threshold_label=None,
|
||||
threshold_value=None,
|
||||
threshold_direction="below",
|
||||
),
|
||||
]
|
||||
return specs
|
||||
|
||||
|
||||
def _series(records: list[MarketSnapshotRecord], field: str) -> pd.Series:
|
||||
values: list[float] = []
|
||||
for r in records:
|
||||
v = getattr(r, field, None)
|
||||
if v is None:
|
||||
continue
|
||||
try:
|
||||
values.append(float(v))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return pd.Series(values, dtype="float64")
|
||||
|
||||
|
||||
def _percent_blocked(s: pd.Series, spec: MetricSpec) -> float | None:
|
||||
if spec.threshold_value is None or s.empty:
|
||||
return None
|
||||
if spec.threshold_direction == "below":
|
||||
return float((s < spec.threshold_value).mean())
|
||||
if spec.threshold_direction == "above_abs":
|
||||
return float((s.abs() > spec.threshold_value).mean())
|
||||
if spec.threshold_direction == "above":
|
||||
return float((s > spec.threshold_value).mean())
|
||||
return None
|
||||
|
||||
|
||||
def _percentiles_strip(s: pd.Series) -> None:
|
||||
if s.empty:
|
||||
st.caption("(nessun dato)")
|
||||
return
|
||||
quantiles = [0.05, 0.10, 0.25, 0.50, 0.75, 0.90, 0.95]
|
||||
# Inline markdown compatto: i valori a 4 cifre significative
|
||||
# cadono regolarmente fuori dalla colonna se renderizzati con
|
||||
# ``st.metric`` (font fisso, label sopra, no shrink). Render
|
||||
# orizzontale con font ridotto evita il troncamento.
|
||||
parts = [
|
||||
f"<b>P{int(q * 100)}</b> {s.quantile(q):.4g}"
|
||||
for q in quantiles
|
||||
]
|
||||
html = (
|
||||
"<div style='font-size:0.85rem;line-height:1.5;"
|
||||
"white-space:nowrap;overflow-x:auto;'>"
|
||||
+ " · ".join(parts)
|
||||
+ "</div>"
|
||||
)
|
||||
st.markdown(html, unsafe_allow_html=True)
|
||||
|
||||
|
||||
def _render_metric(spec: MetricSpec, records: list[MarketSnapshotRecord]) -> None:
|
||||
s = _series(records, spec.field)
|
||||
if s.empty:
|
||||
st.subheader(f"{spec.title}")
|
||||
st.info(
|
||||
f"Nessun valore disponibile per `{spec.field}`. "
|
||||
"Avvia il job `market_snapshot` (engine attivo, cron */15) per "
|
||||
"popolare la tabella."
|
||||
)
|
||||
return
|
||||
|
||||
st.subheader(f"{spec.title} ({spec.unit})")
|
||||
|
||||
pct_blocked = _percent_blocked(s, spec)
|
||||
cols = st.columns(4)
|
||||
cols[0].metric("Tick raccolti", len(s))
|
||||
cols[1].metric("Min", f"{s.min():.4g}")
|
||||
cols[2].metric("Max", f"{s.max():.4g}")
|
||||
cols[3].metric(
|
||||
"% bloccato dalla soglia",
|
||||
f"{pct_blocked:.0%}" if pct_blocked is not None else "—",
|
||||
help=(
|
||||
"Frazione di tick che la soglia di config avrebbe filtrato"
|
||||
f" se applicata a questa serie ({spec.threshold_direction})."
|
||||
),
|
||||
)
|
||||
|
||||
fig = go.Figure()
|
||||
fig.add_trace(go.Histogram(x=s, nbinsx=40, opacity=0.85, name="distrib."))
|
||||
if spec.threshold_value is not None:
|
||||
fig.add_vline(
|
||||
x=spec.threshold_value,
|
||||
line_dash="dash",
|
||||
line_color="red",
|
||||
line_width=2,
|
||||
annotation_text=spec.threshold_label or f"soglia {spec.threshold_value}",
|
||||
annotation_position="top",
|
||||
)
|
||||
if spec.threshold_direction == "above_abs":
|
||||
# Disegna anche il bound negativo per i filtri simmetrici.
|
||||
fig.add_vline(
|
||||
x=-spec.threshold_value,
|
||||
line_dash="dash",
|
||||
line_color="red",
|
||||
line_width=2,
|
||||
annotation_text=None,
|
||||
)
|
||||
fig.update_layout(
|
||||
height=280,
|
||||
margin={"l": 10, "r": 10, "t": 30, "b": 10},
|
||||
xaxis_title=spec.unit,
|
||||
yaxis_title="numero tick",
|
||||
)
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
|
||||
_percentiles_strip(s)
|
||||
|
||||
|
||||
def _render_adaptive_gate_panel(
|
||||
strategy: object | None,
|
||||
records: list[MarketSnapshotRecord],
|
||||
) -> None:
|
||||
"""Pannello informativo sul gate IV-RV adattivo (read-only)."""
|
||||
if strategy is None:
|
||||
return
|
||||
|
||||
try:
|
||||
entry = strategy.entry # type: ignore[attr-defined]
|
||||
except AttributeError:
|
||||
return
|
||||
|
||||
if not getattr(entry, "iv_minus_rv_filter_enabled", False):
|
||||
st.subheader("🎯 Gate IV-RV adattivo")
|
||||
st.info("Gate IV-RV disabilitato nel profilo corrente.")
|
||||
st.divider()
|
||||
return
|
||||
|
||||
st.subheader("🎯 Gate IV-RV adattivo")
|
||||
|
||||
# records DESC (newest first) → history ASC con NULL/fetch_ok=0 esclusi
|
||||
iv_rv_history: list[Decimal] = []
|
||||
distinct_days: set[str] = set()
|
||||
for r in reversed(records):
|
||||
if r.fetch_ok and r.iv_minus_rv is not None:
|
||||
iv_rv_history.append(r.iv_minus_rv)
|
||||
distinct_days.add(r.timestamp.date().isoformat())
|
||||
|
||||
n_ticks = len(iv_rv_history)
|
||||
n_days = len(distinct_days)
|
||||
target = int(getattr(entry, "iv_minus_rv_window_target_days", 60))
|
||||
min_days = int(getattr(entry, "iv_minus_rv_window_min_days", 30))
|
||||
|
||||
if n_days < 1:
|
||||
status = "🟡 Warmup hard (nessun giorno coperto)"
|
||||
elif n_days < min_days:
|
||||
status = f"🟡 Warmup ({n_days}/{min_days}g — finestra crescente)"
|
||||
elif n_days < target:
|
||||
status = f"🟢 Attivo (finestra {min_days}g, target {target}g)"
|
||||
else:
|
||||
status = f"🟢 Attivo (finestra stabile {target}g)"
|
||||
st.markdown(f"**Status:** {status} · {n_ticks} tick complessivi")
|
||||
|
||||
# Latest tick
|
||||
iv_rv_now: Decimal | None = None
|
||||
dvol_now: Decimal | None = None
|
||||
latest_ts: datetime | None = None
|
||||
for r in records: # records DESC
|
||||
if r.fetch_ok:
|
||||
iv_rv_now = r.iv_minus_rv
|
||||
dvol_now = r.dvol
|
||||
latest_ts = r.timestamp
|
||||
break
|
||||
|
||||
adaptive_on = bool(getattr(entry, "iv_minus_rv_adaptive_enabled", False))
|
||||
floor = Decimal(str(getattr(entry, "iv_minus_rv_min", "0")))
|
||||
|
||||
if adaptive_on:
|
||||
percentile = Decimal(
|
||||
str(getattr(entry, "iv_minus_rv_percentile", "0.25"))
|
||||
)
|
||||
try:
|
||||
threshold = compute_adaptive_threshold(
|
||||
history=iv_rv_history,
|
||||
n_days=n_days,
|
||||
percentile=percentile,
|
||||
absolute_floor=floor,
|
||||
)
|
||||
except ValueError as exc:
|
||||
st.warning(f"Configurazione gate non valida: {exc}")
|
||||
threshold = None
|
||||
|
||||
c1, c2, c3 = st.columns(3)
|
||||
pct_label = int(percentile * 100)
|
||||
c1.metric(
|
||||
f"Soglia P{pct_label} rolling",
|
||||
f"{threshold:.2f}" if threshold is not None else "—",
|
||||
help="Soglia adattiva = max(percentile, floor)",
|
||||
)
|
||||
c2.metric(
|
||||
"IV-RV ultimo tick",
|
||||
f"{iv_rv_now:.2f}" if iv_rv_now is not None else "—",
|
||||
)
|
||||
c3.metric("Floor assoluto", f"{floor:.2f}")
|
||||
|
||||
if threshold is not None and iv_rv_now is not None:
|
||||
verdict = "✅ PASS" if iv_rv_now >= threshold else "❌ SKIP"
|
||||
st.markdown(f"**Decisione hypothetical:** {verdict}")
|
||||
else:
|
||||
st.write(f"Modalità statica: floor = {floor} vol pts")
|
||||
|
||||
if bool(getattr(entry, "vol_of_vol_guard_enabled", False)):
|
||||
st.markdown("---")
|
||||
st.markdown("**Vol-of-Vol guard**")
|
||||
threshold_pt = Decimal(
|
||||
str(getattr(entry, "vol_of_vol_threshold_pt", "5"))
|
||||
)
|
||||
lookback_h = int(getattr(entry, "vol_of_vol_lookback_hours", 24))
|
||||
|
||||
# Find tick closest to latest_ts - lookback hours, tolerance 15 min
|
||||
dvol_lookback: Decimal | None = None
|
||||
if latest_ts is not None and dvol_now is not None:
|
||||
target_ts = latest_ts - timedelta(hours=lookback_h)
|
||||
best_delta = timedelta(minutes=15)
|
||||
for r in records:
|
||||
if not r.fetch_ok or r.dvol is None:
|
||||
continue
|
||||
d = abs(r.timestamp - target_ts)
|
||||
if d <= best_delta:
|
||||
best_delta = d
|
||||
dvol_lookback = r.dvol
|
||||
|
||||
if dvol_lookback is not None and dvol_now is not None:
|
||||
delta = abs(dvol_now - dvol_lookback)
|
||||
c1, c2 = st.columns(2)
|
||||
c1.metric(f"|ΔDVOL {lookback_h}h|", f"{delta:.2f}")
|
||||
c2.metric("Soglia VoV", f"{threshold_pt:.2f}")
|
||||
verdict = "✅ PASS" if delta < threshold_pt else "❌ SKIP"
|
||||
st.markdown(f"**Verdict:** {verdict}")
|
||||
else:
|
||||
st.info(f"Lookback {lookback_h}h non disponibile (gap dati).")
|
||||
|
||||
st.divider()
|
||||
|
||||
|
||||
def render() -> None:
|
||||
st.title("📐 Calibrazione")
|
||||
st.caption(
|
||||
"Distribuzioni storiche dei segnali raccolti dal job "
|
||||
"`market_snapshot` (cron */15). Usa i percentili reali per "
|
||||
"tarare le soglie in `strategy.yaml` invece di valori a istinto."
|
||||
)
|
||||
|
||||
db_path = _resolve_db()
|
||||
|
||||
col_a, col_b = st.columns(2)
|
||||
asset = col_a.selectbox("Asset", options=["ETH", "BTC"], index=0)
|
||||
window = col_b.selectbox(
|
||||
"Finestra",
|
||||
options=[
|
||||
"Tutto lo storico",
|
||||
"Ultime 24h",
|
||||
"Ultimi 7 giorni",
|
||||
"Ultimi 30 giorni",
|
||||
],
|
||||
index=0,
|
||||
)
|
||||
|
||||
now = datetime.now(UTC)
|
||||
start: datetime | None = None
|
||||
if window == "Ultime 24h":
|
||||
start = now - timedelta(hours=24)
|
||||
elif window == "Ultimi 7 giorni":
|
||||
start = now - timedelta(days=7)
|
||||
elif window == "Ultimi 30 giorni":
|
||||
start = now - timedelta(days=30)
|
||||
|
||||
records = load_market_snapshots(
|
||||
asset=asset, db_path=db_path, start=start, limit=5000
|
||||
)
|
||||
|
||||
if not records:
|
||||
st.info(
|
||||
"Nessun snapshot disponibile in questa finestra per "
|
||||
f"`{asset}`. Avvia l'engine (`cerbero-bite start`) e attendi "
|
||||
"almeno un tick del job `market_snapshot` (cron */15)."
|
||||
)
|
||||
return
|
||||
|
||||
st.caption(
|
||||
f"{len(records)} snapshot · primo {humanize_dt(records[-1].timestamp)} "
|
||||
f"· ultimo {humanize_dt(records[0].timestamp)}"
|
||||
)
|
||||
|
||||
# Conteggio fetch_ok per qualità delle serie
|
||||
n_ok = sum(1 for r in records if r.fetch_ok)
|
||||
cols = st.columns(3)
|
||||
cols[0].metric("Snapshot totali", len(records))
|
||||
cols[1].metric("fetch_ok = true", n_ok)
|
||||
cols[2].metric(
|
||||
"Tasso ok",
|
||||
f"{n_ok / len(records):.0%}" if records else "—",
|
||||
)
|
||||
st.divider()
|
||||
|
||||
# Carica strategy.yaml per leggere le soglie correnti
|
||||
try:
|
||||
strategy = load_strategy(Path("strategy.yaml"))
|
||||
except Exception as exc:
|
||||
st.warning(
|
||||
f"Impossibile leggere `strategy.yaml`: {type(exc).__name__}: {exc}"
|
||||
)
|
||||
strategy = None
|
||||
|
||||
specs = _metric_specs(strategy)
|
||||
|
||||
_render_adaptive_gate_panel(strategy, records)
|
||||
|
||||
for spec in specs:
|
||||
_render_metric(spec, records)
|
||||
st.divider()
|
||||
|
||||
|
||||
render()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -71,8 +71,11 @@ class AlertManager:
|
||||
return
|
||||
|
||||
if severity == Severity.MEDIUM:
|
||||
# The TelegramClient already prefixes [PRIORITY][tag] in the
|
||||
# rendered text, so we pass the raw message and let the
|
||||
# client compose the final form.
|
||||
await self._telegram.notify(
|
||||
f"[{source}] {message}", priority="high", tag=source
|
||||
message, priority="high", tag=source
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Auto-pause circuit breaker (§7-bis F).
|
||||
|
||||
Pure-function evaluation that consults `system_state.auto_pause_until`
|
||||
and the rolling P/L of the last N closed positions to decide whether
|
||||
the engine should skip an entry cycle.
|
||||
|
||||
Two responsibilities, both deterministic at call time:
|
||||
|
||||
* :func:`is_paused` — returns ``True`` when the persisted
|
||||
``auto_pause_until`` is in the future. Independent from the kill
|
||||
switch, which targets technical errors.
|
||||
* :func:`evaluate_drawdown_breach` — given the last N closed P/Ls and
|
||||
the current capital, returns whether the rolling drawdown breached
|
||||
the configured ``max_drawdown_pct`` threshold. The orchestrator
|
||||
layer is the one that flips the persisted state on breach (this
|
||||
module stays I/O-free for testability).
|
||||
|
||||
The two are separated on purpose: ``is_paused`` is the cheap,
|
||||
read-only gate consulted at the start of every entry cycle; the
|
||||
breach evaluation runs once per cycle right after the entry
|
||||
filtering, before the entry is actually placed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from cerbero_bite.config.schema import AutoPauseConfig
|
||||
from cerbero_bite.state.models import SystemStateRecord
|
||||
|
||||
__all__ = [
|
||||
"AutoPauseDecision",
|
||||
"PauseStatus",
|
||||
"evaluate_drawdown_breach",
|
||||
"is_paused",
|
||||
"pause_until",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PauseStatus:
|
||||
"""Snapshot del flag di auto-pausa al momento della valutazione."""
|
||||
|
||||
paused: bool
|
||||
until: datetime | None
|
||||
reason: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AutoPauseDecision:
|
||||
"""Esito di :func:`evaluate_drawdown_breach`."""
|
||||
|
||||
should_pause: bool
|
||||
cumulative_pnl_usd: Decimal
|
||||
drawdown_pct: Decimal
|
||||
threshold_pct: Decimal
|
||||
reason: str | None
|
||||
|
||||
|
||||
def is_paused(
|
||||
state: SystemStateRecord | None, *, now: datetime
|
||||
) -> PauseStatus:
|
||||
"""Restituisce lo stato della pausa rispetto a ``now``.
|
||||
|
||||
``state == None`` o ``auto_pause_until == None`` o
|
||||
``auto_pause_until <= now`` ⇒ engine attivo.
|
||||
"""
|
||||
if state is None or state.auto_pause_until is None:
|
||||
return PauseStatus(paused=False, until=None, reason=None)
|
||||
until = state.auto_pause_until
|
||||
if until.tzinfo is not None and now.tzinfo is None:
|
||||
# Coerenza: se il valore persistito è tz-aware, normalizziamo.
|
||||
return PauseStatus(
|
||||
paused=until > now.replace(tzinfo=until.tzinfo),
|
||||
until=until,
|
||||
reason=state.auto_pause_reason,
|
||||
)
|
||||
return PauseStatus(
|
||||
paused=until > now,
|
||||
until=until,
|
||||
reason=state.auto_pause_reason,
|
||||
)
|
||||
|
||||
|
||||
def pause_until(now: datetime, days: int) -> datetime:
|
||||
"""Calcola la scadenza della pausa (``now + days``).
|
||||
|
||||
Estratto in funzione separata per facilitare i test e per ricordare
|
||||
che la pausa è espressa in **giorni** (la strategia ha cron
|
||||
giornaliero su crypto 24/7; pause sub-giornaliere non avrebbero
|
||||
modo di evitare un'entry).
|
||||
"""
|
||||
return now + timedelta(days=max(1, days))
|
||||
|
||||
|
||||
def evaluate_drawdown_breach(
|
||||
*,
|
||||
cfg: AutoPauseConfig,
|
||||
recent_pnl_usd: list[Decimal],
|
||||
capital_usd: Decimal,
|
||||
) -> AutoPauseDecision:
|
||||
"""Decide se la pausa va armata ora dato il rolling P/L.
|
||||
|
||||
Regola: se la somma dei P/L delle ultime ``cfg.lookback_trades``
|
||||
posizioni chiuse è negativa e in valore assoluto eccede
|
||||
``cfg.max_drawdown_pct × capital_usd``, ritorna
|
||||
``should_pause=True``. Tutte le altre condizioni → False.
|
||||
|
||||
``cfg.enabled=False`` → ritorna sempre False (filtro disabilitato).
|
||||
Lookback insufficiente → ritorna False (non scattiamo finché non
|
||||
abbiamo abbastanza storia per giudicare).
|
||||
"""
|
||||
threshold_pct = cfg.max_drawdown_pct
|
||||
cumulative = sum((p for p in recent_pnl_usd), start=Decimal("0"))
|
||||
|
||||
if not cfg.enabled:
|
||||
return AutoPauseDecision(
|
||||
should_pause=False,
|
||||
cumulative_pnl_usd=cumulative,
|
||||
drawdown_pct=Decimal("0"),
|
||||
threshold_pct=threshold_pct,
|
||||
reason=None,
|
||||
)
|
||||
|
||||
if len(recent_pnl_usd) < cfg.lookback_trades:
|
||||
return AutoPauseDecision(
|
||||
should_pause=False,
|
||||
cumulative_pnl_usd=cumulative,
|
||||
drawdown_pct=Decimal("0"),
|
||||
threshold_pct=threshold_pct,
|
||||
reason=None,
|
||||
)
|
||||
|
||||
if capital_usd <= 0:
|
||||
return AutoPauseDecision(
|
||||
should_pause=False,
|
||||
cumulative_pnl_usd=cumulative,
|
||||
drawdown_pct=Decimal("0"),
|
||||
threshold_pct=threshold_pct,
|
||||
reason=None,
|
||||
)
|
||||
|
||||
# Solo perdite ci interessano: vincite cumulate non scattano la pausa.
|
||||
if cumulative >= 0:
|
||||
return AutoPauseDecision(
|
||||
should_pause=False,
|
||||
cumulative_pnl_usd=cumulative,
|
||||
drawdown_pct=cumulative / capital_usd,
|
||||
threshold_pct=threshold_pct,
|
||||
reason=None,
|
||||
)
|
||||
|
||||
drawdown_pct = (-cumulative) / capital_usd
|
||||
if drawdown_pct >= threshold_pct:
|
||||
return AutoPauseDecision(
|
||||
should_pause=True,
|
||||
cumulative_pnl_usd=cumulative,
|
||||
drawdown_pct=drawdown_pct,
|
||||
threshold_pct=threshold_pct,
|
||||
reason=(
|
||||
f"rolling DD {drawdown_pct:.2%} ≥ {threshold_pct:.2%} "
|
||||
f"(last {cfg.lookback_trades} trades, "
|
||||
f"cumulative {cumulative} USD)"
|
||||
),
|
||||
)
|
||||
|
||||
return AutoPauseDecision(
|
||||
should_pause=False,
|
||||
cumulative_pnl_usd=cumulative,
|
||||
drawdown_pct=drawdown_pct,
|
||||
threshold_pct=threshold_pct,
|
||||
reason=None,
|
||||
)
|
||||
@@ -16,13 +16,13 @@ from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from cerbero_bite.clients._base import HttpToolClient
|
||||
from cerbero_bite.clients._base import DEFAULT_BOT_TAG, HttpToolClient
|
||||
from cerbero_bite.clients.deribit import DeribitClient
|
||||
from cerbero_bite.clients.hyperliquid import HyperliquidClient
|
||||
from cerbero_bite.clients.macro import MacroClient
|
||||
from cerbero_bite.clients.portfolio import PortfolioClient
|
||||
from cerbero_bite.clients.sentiment import SentimentClient
|
||||
from cerbero_bite.clients.telegram import TelegramClient
|
||||
from cerbero_bite.clients.telegram import TelegramClient, load_telegram_credentials
|
||||
from cerbero_bite.config.mcp_endpoints import McpEndpoints
|
||||
from cerbero_bite.config.schema import StrategyConfig
|
||||
from cerbero_bite.runtime.alert_manager import AlertManager
|
||||
@@ -78,6 +78,7 @@ def build_runtime(
|
||||
token: str,
|
||||
db_path: Path | str,
|
||||
audit_path: Path | str,
|
||||
bot_tag: str = DEFAULT_BOT_TAG,
|
||||
timeout_s: float = 8.0,
|
||||
retry_max: int = 3,
|
||||
clock: Callable[[], datetime] | None = None,
|
||||
@@ -140,16 +141,31 @@ def build_runtime(
|
||||
service=service,
|
||||
base_url=endpoints.for_service(service),
|
||||
token=token,
|
||||
bot_tag=bot_tag,
|
||||
timeout_s=timeout_s,
|
||||
retry_max=retry_max,
|
||||
client=http_client,
|
||||
)
|
||||
|
||||
telegram = TelegramClient(_client("telegram"))
|
||||
bot_token, chat_id = load_telegram_credentials()
|
||||
telegram = TelegramClient(
|
||||
bot_token=bot_token,
|
||||
chat_id=chat_id,
|
||||
http_client=http_client,
|
||||
timeout_s=timeout_s,
|
||||
)
|
||||
alert_manager = AlertManager(
|
||||
telegram=telegram, audit_log=audit_log, kill_switch=kill_switch
|
||||
)
|
||||
|
||||
deribit = DeribitClient(_client("deribit"))
|
||||
macro = MacroClient(_client("macro"))
|
||||
sentiment = SentimentClient(_client("sentiment"))
|
||||
hyperliquid = HyperliquidClient(_client("hyperliquid"))
|
||||
portfolio = PortfolioClient(
|
||||
deribit=deribit, hyperliquid=hyperliquid, macro=macro
|
||||
)
|
||||
|
||||
return RuntimeContext(
|
||||
cfg=cfg,
|
||||
db_path=db_path,
|
||||
@@ -158,11 +174,11 @@ def build_runtime(
|
||||
audit_log=audit_log,
|
||||
kill_switch=kill_switch,
|
||||
alert_manager=alert_manager,
|
||||
deribit=DeribitClient(_client("deribit")),
|
||||
macro=MacroClient(_client("macro")),
|
||||
sentiment=SentimentClient(_client("sentiment")),
|
||||
hyperliquid=HyperliquidClient(_client("hyperliquid")),
|
||||
portfolio=PortfolioClient(_client("portfolio")),
|
||||
deribit=deribit,
|
||||
macro=macro,
|
||||
sentiment=sentiment,
|
||||
hyperliquid=hyperliquid,
|
||||
portfolio=portfolio,
|
||||
telegram=telegram,
|
||||
http_client=http_client,
|
||||
clock=clk,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""Weekly entry decision loop (``docs/06-operational-flow.md`` §2).
|
||||
"""Daily entry decision loop (``docs/06-operational-flow.md`` §2).
|
||||
|
||||
Pure orchestration over the existing core/clients/state primitives.
|
||||
The cycle is auto-execute: when every gate passes, the engine sends
|
||||
the combo order without asking Adriano. Telegram is used only to
|
||||
notify the outcome.
|
||||
Crypto è 24/7: la cadenza di candidatura non è gateata sulla
|
||||
settimana, sono i gate quantitativi a decidere se entrare o saltare
|
||||
il giorno. Pure orchestration over the existing core/clients/state
|
||||
primitives. The cycle is auto-execute: when every gate passes, the
|
||||
engine sends the combo order without asking Adriano. Telegram is
|
||||
used only to notify the outcome.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -28,6 +30,7 @@ from cerbero_bite.clients.macro import MacroClient
|
||||
from cerbero_bite.clients.portfolio import PortfolioClient
|
||||
from cerbero_bite.clients.sentiment import SentimentClient
|
||||
from cerbero_bite.config.schema import StrategyConfig
|
||||
from cerbero_bite.core.adaptive_threshold import compute_adaptive_threshold
|
||||
from cerbero_bite.core.combo_builder import ComboProposal, build, select_strikes
|
||||
from cerbero_bite.core.entry_validator import (
|
||||
EntryContext,
|
||||
@@ -38,6 +41,7 @@ from cerbero_bite.core.entry_validator import (
|
||||
from cerbero_bite.core.liquidity_gate import InstrumentSnapshot, check
|
||||
from cerbero_bite.core.sizing_engine import SizingContext, compute_contracts
|
||||
from cerbero_bite.core.types import OptionQuote
|
||||
from cerbero_bite.runtime import auto_pause as auto_pause_module
|
||||
from cerbero_bite.runtime.alert_manager import AlertManager
|
||||
from cerbero_bite.runtime.dependencies import RuntimeContext
|
||||
from cerbero_bite.state import (
|
||||
@@ -64,6 +68,7 @@ _STATUS_NO_ENTRY = "no_entry"
|
||||
_STATUS_BROKER_REJECT = "broker_reject"
|
||||
_STATUS_KILL_SWITCH = "kill_switch_armed"
|
||||
_STATUS_HAS_OPEN = "has_open_position"
|
||||
_STATUS_AUTO_PAUSED = "auto_paused"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -94,6 +99,7 @@ class _MarketSnapshot:
|
||||
portfolio_eur: Decimal
|
||||
dealer_net_gamma: Decimal | None
|
||||
liquidation_squeeze_risk_high: bool | None
|
||||
iv_minus_rv: Decimal | None
|
||||
|
||||
|
||||
async def _gather_snapshot(
|
||||
@@ -159,6 +165,9 @@ async def _gather_snapshot(
|
||||
liquidation_t: asyncio.Task[bool | None] = asyncio.create_task(
|
||||
_safe_liquidation_squeeze(sentiment)
|
||||
)
|
||||
iv_rv_t: asyncio.Task[Decimal | None] = asyncio.create_task(
|
||||
_safe_iv_minus_rv(deribit)
|
||||
)
|
||||
|
||||
await asyncio.gather(
|
||||
spot_t,
|
||||
@@ -172,6 +181,7 @@ async def _gather_snapshot(
|
||||
portfolio_t,
|
||||
dealer_t,
|
||||
liquidation_t,
|
||||
iv_rv_t,
|
||||
)
|
||||
return _MarketSnapshot(
|
||||
spot_eth_usd=spot_t.result(),
|
||||
@@ -185,6 +195,7 @@ async def _gather_snapshot(
|
||||
portfolio_eur=portfolio_t.result(),
|
||||
dealer_net_gamma=dealer_t.result(),
|
||||
liquidation_squeeze_risk_high=liquidation_t.result(),
|
||||
iv_minus_rv=iv_rv_t.result(),
|
||||
)
|
||||
|
||||
|
||||
@@ -196,6 +207,20 @@ async def _safe_dealer_gamma(deribit: DeribitClient) -> Decimal | None:
|
||||
return snap.total_net_dealer_gamma
|
||||
|
||||
|
||||
async def _safe_iv_minus_rv(deribit: DeribitClient) -> Decimal | None:
|
||||
"""Best-effort fetch of the IV30g − RV30g spread (vol points)."""
|
||||
try:
|
||||
rv = await deribit.realized_vol("ETH")
|
||||
except Exception:
|
||||
return None
|
||||
if not isinstance(rv, dict):
|
||||
return None
|
||||
value = rv.get("iv_minus_rv_30d")
|
||||
if value is None:
|
||||
return None
|
||||
return value if isinstance(value, Decimal) else Decimal(str(value))
|
||||
|
||||
|
||||
async def _safe_liquidation_squeeze(sentiment: SentimentClient) -> bool | None:
|
||||
try:
|
||||
heatmap = await sentiment.liquidation_heatmap("ETH")
|
||||
@@ -291,6 +316,51 @@ async def _build_quotes(
|
||||
return out
|
||||
|
||||
|
||||
def _select_window_days(entry_cfg: object, n_days: int) -> int:
|
||||
"""Sceglie la finestra in giorni per il gate adattivo dato n_days
|
||||
disponibili.
|
||||
|
||||
Spec: warmup hard se ``n_days == 0`` → 0; finestra ``target_days``
|
||||
se ``n_days >= target_days``; ``min_days`` se ``n_days >= min_days``;
|
||||
altrimenti tutta la storia disponibile (capped a ``target_days``).
|
||||
"""
|
||||
target = int(getattr(entry_cfg, "iv_minus_rv_window_target_days", 60))
|
||||
min_days = int(getattr(entry_cfg, "iv_minus_rv_window_min_days", 30))
|
||||
if n_days < 1:
|
||||
return 0
|
||||
if n_days >= target:
|
||||
return target
|
||||
if n_days >= min_days:
|
||||
return min_days
|
||||
return target # storia parziale: query fino a target, repository ne ritorna n_days
|
||||
|
||||
|
||||
def _audit_threshold(
|
||||
entry_cfg: object,
|
||||
iv_rv_history: tuple[Decimal, ...],
|
||||
n_days: int,
|
||||
) -> str | None:
|
||||
"""Soglia P_q rolling effettivamente usata dal gate, per il decisions log."""
|
||||
if not getattr(entry_cfg, "iv_minus_rv_filter_enabled", False):
|
||||
return None
|
||||
if not getattr(entry_cfg, "iv_minus_rv_adaptive_enabled", False):
|
||||
return str(getattr(entry_cfg, "iv_minus_rv_min", Decimal("0")))
|
||||
threshold = compute_adaptive_threshold(
|
||||
history=iv_rv_history,
|
||||
n_days=n_days,
|
||||
percentile=entry_cfg.iv_minus_rv_percentile, # type: ignore[attr-defined]
|
||||
absolute_floor=entry_cfg.iv_minus_rv_min, # type: ignore[attr-defined]
|
||||
)
|
||||
return None if threshold is None else str(threshold)
|
||||
|
||||
|
||||
def _audit_window_days(entry_cfg: object, n_days: int) -> int | None:
|
||||
"""Numero di giorni effettivamente usati dalla finestra rolling."""
|
||||
if not getattr(entry_cfg, "iv_minus_rv_adaptive_enabled", False):
|
||||
return None
|
||||
return _select_window_days(entry_cfg, n_days)
|
||||
|
||||
|
||||
def _max_loss_per_contract_usd(short_strike: Decimal, long_strike: Decimal) -> Decimal:
|
||||
return (short_strike - long_strike).copy_abs()
|
||||
|
||||
@@ -306,7 +376,7 @@ async def run_entry_cycle(
|
||||
eur_to_usd_rate: Decimal,
|
||||
now: datetime | None = None,
|
||||
) -> EntryCycleResult:
|
||||
"""Run one weekly entry evaluation cycle.
|
||||
"""Run one daily entry evaluation cycle.
|
||||
|
||||
The function is idempotent and side-effect aware: it persists the
|
||||
decision in the ``decisions`` table regardless of outcome and only
|
||||
@@ -322,6 +392,28 @@ async def run_entry_cycle(
|
||||
)
|
||||
return EntryCycleResult(status=_STATUS_KILL_SWITCH, reason="kill_switch")
|
||||
|
||||
# §7-bis (F): auto-pause circuit breaker. Read-only consultation
|
||||
# of the persisted state — the breach evaluation runs later, after
|
||||
# capital is known.
|
||||
conn = connect_state(ctx.db_path)
|
||||
try:
|
||||
sys_state = ctx.repository.get_system_state(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
pause_status = auto_pause_module.is_paused(sys_state, now=when)
|
||||
if pause_status.paused:
|
||||
await alert.low(
|
||||
source="entry_cycle",
|
||||
message=(
|
||||
f"auto-paused until {pause_status.until} "
|
||||
f"({pause_status.reason or 'no reason'}) — skipping"
|
||||
),
|
||||
)
|
||||
return EntryCycleResult(
|
||||
status=_STATUS_AUTO_PAUSED,
|
||||
reason=pause_status.reason or "auto_paused",
|
||||
)
|
||||
|
||||
# Has open position?
|
||||
conn = connect_state(ctx.db_path)
|
||||
try:
|
||||
@@ -344,7 +436,83 @@ async def run_entry_cycle(
|
||||
)
|
||||
capital_usd = snap.portfolio_eur * eur_to_usd_rate
|
||||
|
||||
# §7-bis (F): rolling drawdown breach evaluation. Se le ultime N
|
||||
# posizioni chiuse hanno cumulato perdite oltre la soglia, armiamo
|
||||
# la pausa e usciamo subito (l'entry di questo ciclo è saltata).
|
||||
auto_cfg = cfg.auto_pause
|
||||
if auto_cfg.enabled:
|
||||
conn = connect_state(ctx.db_path)
|
||||
try:
|
||||
recent_pnls = ctx.repository.recent_closed_position_pnls_usd(
|
||||
conn, limit=auto_cfg.lookback_trades
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
breach = auto_pause_module.evaluate_drawdown_breach(
|
||||
cfg=auto_cfg,
|
||||
recent_pnl_usd=recent_pnls,
|
||||
capital_usd=capital_usd,
|
||||
)
|
||||
if breach.should_pause:
|
||||
until = auto_pause_module.pause_until(when, auto_cfg.pause_days)
|
||||
conn = connect_state(ctx.db_path)
|
||||
try:
|
||||
with transaction(conn):
|
||||
ctx.repository.set_auto_pause(
|
||||
conn, until=until, reason=breach.reason
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
await alert.high(
|
||||
source="entry_cycle",
|
||||
message=(
|
||||
f"auto-pause armed: {breach.reason} — paused until {until}"
|
||||
),
|
||||
)
|
||||
return EntryCycleResult(
|
||||
status=_STATUS_AUTO_PAUSED,
|
||||
reason=breach.reason or "auto_paused",
|
||||
)
|
||||
|
||||
# 2. Entry filters
|
||||
entry_cfg = cfg.entry
|
||||
asset = cfg.asset.symbol
|
||||
|
||||
iv_rv_history: tuple[Decimal, ...] = ()
|
||||
iv_rv_n_days: int = 0
|
||||
dvol_24h_ago: Decimal | None = None
|
||||
if entry_cfg.iv_minus_rv_filter_enabled and entry_cfg.iv_minus_rv_adaptive_enabled:
|
||||
conn = connect_state(ctx.db_path)
|
||||
try:
|
||||
iv_rv_n_days = ctx.repository.count_iv_rv_distinct_days(
|
||||
conn,
|
||||
asset=asset,
|
||||
max_days=entry_cfg.iv_minus_rv_window_target_days,
|
||||
as_of=when,
|
||||
)
|
||||
window_days = _select_window_days(entry_cfg, iv_rv_n_days)
|
||||
if window_days > 0:
|
||||
iv_rv_history = tuple(
|
||||
ctx.repository.iv_rv_values_for_window(
|
||||
conn,
|
||||
asset=asset,
|
||||
window_days=window_days,
|
||||
as_of=when,
|
||||
)
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
if entry_cfg.vol_of_vol_guard_enabled:
|
||||
conn = connect_state(ctx.db_path)
|
||||
try:
|
||||
dvol_24h_ago = ctx.repository.dvol_lookback(
|
||||
conn,
|
||||
asset=asset,
|
||||
reference=when - timedelta(hours=entry_cfg.vol_of_vol_lookback_hours),
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
entry_ctx = EntryContext(
|
||||
capital_usd=capital_usd,
|
||||
dvol_now=snap.dvol,
|
||||
@@ -353,7 +521,11 @@ async def run_entry_cycle(
|
||||
next_macro_event_in_days=snap.macro_days_to_event,
|
||||
has_open_position=False,
|
||||
dealer_net_gamma=snap.dealer_net_gamma,
|
||||
iv_minus_rv=snap.iv_minus_rv,
|
||||
liquidation_squeeze_risk_high=snap.liquidation_squeeze_risk_high,
|
||||
iv_rv_history=iv_rv_history,
|
||||
iv_rv_n_days=iv_rv_n_days,
|
||||
dvol_24h_ago=dvol_24h_ago,
|
||||
)
|
||||
decision = validate_entry(entry_ctx, cfg)
|
||||
inputs = {
|
||||
@@ -370,6 +542,20 @@ async def run_entry_cycle(
|
||||
"eth_holdings_pct": str(snap.eth_holdings_pct),
|
||||
"portfolio_eur": str(snap.portfolio_eur),
|
||||
"capital_usd": str(capital_usd),
|
||||
"iv_minus_rv": (
|
||||
str(snap.iv_minus_rv) if snap.iv_minus_rv is not None else None
|
||||
),
|
||||
"iv_rv_history_n": len(iv_rv_history),
|
||||
"iv_rv_n_days": iv_rv_n_days,
|
||||
"iv_rv_threshold_used": _audit_threshold(
|
||||
entry_cfg, iv_rv_history, iv_rv_n_days
|
||||
),
|
||||
"iv_rv_window_used_days": _audit_window_days(
|
||||
entry_cfg, iv_rv_n_days
|
||||
),
|
||||
"dvol_24h_ago": (
|
||||
str(dvol_24h_ago) if dvol_24h_ago is not None else None
|
||||
),
|
||||
}
|
||||
}
|
||||
if not decision.accepted:
|
||||
@@ -436,7 +622,12 @@ async def run_entry_cycle(
|
||||
)
|
||||
quotes = await _build_quotes(ctx.deribit, chain_meta)
|
||||
selection = select_strikes(
|
||||
chain=quotes, bias=bias, spot=snap.spot_eth_usd, now=when, cfg=cfg
|
||||
chain=quotes,
|
||||
bias=bias,
|
||||
spot=snap.spot_eth_usd,
|
||||
now=when,
|
||||
cfg=cfg,
|
||||
dvol_now=snap.dvol, # §3.2 (A) — strike picker dipendente dal regime DVOL
|
||||
)
|
||||
if selection is None:
|
||||
await _record_decision(
|
||||
|
||||
@@ -66,7 +66,6 @@ class HealthCheck:
|
||||
_probe("macro", self._ctx.macro.get_calendar(days=1)),
|
||||
_probe("sentiment", self._probe_sentiment()),
|
||||
_probe("hyperliquid", self._ctx.hyperliquid.funding_rate_annualized("ETH")),
|
||||
_probe("portfolio", self._ctx.portfolio.total_equity_eur()),
|
||||
)
|
||||
|
||||
# SQLite health: lightweight transaction.
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Consumer of the ``manual_actions`` queue.
|
||||
|
||||
The GUI (and other out-of-band tooling) records operator intent in the
|
||||
SQLite ``manual_actions`` table; this consumer pulls those rows and
|
||||
dispatches them through the same primitives the engine uses internally
|
||||
(``KillSwitch.arm`` / ``disarm``, ``Orchestrator.run_*``) so the audit
|
||||
chain remains the single source of truth for state transitions.
|
||||
|
||||
Supported kinds:
|
||||
|
||||
* ``arm_kill`` — payload ``{"reason": str}``; arms the kill switch.
|
||||
* ``disarm_kill`` — payload ``{"reason": str}``; disarms it.
|
||||
* ``run_cycle`` — payload ``{"cycle": "entry"|"monitor"|"health"}``;
|
||||
forces an immediate run of the named cycle. Only available when the
|
||||
consumer is invoked with a ``cycle_runners`` mapping (the orchestrator
|
||||
populates it at scheduler-install time).
|
||||
|
||||
Future kinds (``force_close``, ``approve_proposal``,
|
||||
``reject_proposal``) are recognised by the ``ManualAction`` schema but
|
||||
not yet wired up — the consumer marks them as
|
||||
``result="not_supported"`` so they don't sit in the queue forever.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from cerbero_bite.safety.kill_switch import KillSwitchError
|
||||
from cerbero_bite.state import connect, transaction
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cerbero_bite.runtime.dependencies import RuntimeContext
|
||||
|
||||
__all__ = ["CycleRunner", "consume_manual_actions"]
|
||||
|
||||
|
||||
CycleRunner = Callable[[], Awaitable[object]]
|
||||
|
||||
|
||||
_log = logging.getLogger("cerbero_bite.runtime.manual_actions")
|
||||
_CONSUMER_ID = "engine"
|
||||
|
||||
|
||||
def _parse_payload(raw: str | None) -> dict[str, object]:
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except (TypeError, ValueError):
|
||||
return {}
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
|
||||
|
||||
async def consume_manual_actions(
|
||||
ctx: RuntimeContext,
|
||||
*,
|
||||
cycle_runners: dict[str, CycleRunner] | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> int:
|
||||
"""Drain the queue. Return the number of actions processed.
|
||||
|
||||
The function is synchronous at heart (SQLite + KillSwitch), but kept
|
||||
``async def`` so the orchestrator can register it as an APScheduler
|
||||
coroutine without an extra wrapper. Each iteration fetches the next
|
||||
unconsumed row and processes it; the loop terminates when the queue
|
||||
is empty so a single tick can catch up after a long pause.
|
||||
"""
|
||||
reference = (now or datetime.now(UTC)).astimezone(UTC)
|
||||
processed = 0
|
||||
|
||||
while True:
|
||||
conn = connect(ctx.db_path)
|
||||
try:
|
||||
action = ctx.repository.next_unconsumed_action(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
if action is None:
|
||||
break
|
||||
if action.id is None:
|
||||
_log.warning("manual_action without id, skipping")
|
||||
break
|
||||
|
||||
payload = _parse_payload(action.payload_json)
|
||||
result = "ok"
|
||||
|
||||
try:
|
||||
if action.kind == "arm_kill":
|
||||
reason = str(payload.get("reason", "manual via GUI"))
|
||||
ctx.kill_switch.arm(reason=reason, source="manual_gui")
|
||||
elif action.kind == "disarm_kill":
|
||||
reason = str(payload.get("reason", "manual via GUI"))
|
||||
ctx.kill_switch.disarm(reason=reason, source="manual_gui")
|
||||
elif action.kind == "run_cycle":
|
||||
cycle = str(payload.get("cycle", "")).strip().lower()
|
||||
if cycle_runners is None:
|
||||
result = "not_supported"
|
||||
_log.warning(
|
||||
"run_cycle dispatched without cycle_runners; "
|
||||
"falling back to not_supported"
|
||||
)
|
||||
elif cycle not in cycle_runners:
|
||||
result = f"error: unknown cycle '{cycle}'"
|
||||
else:
|
||||
await cycle_runners[cycle]()
|
||||
result = f"ok: ran {cycle}"
|
||||
else:
|
||||
result = "not_supported"
|
||||
_log.warning(
|
||||
"manual_action kind=%s not supported yet", action.kind
|
||||
)
|
||||
except KillSwitchError as exc:
|
||||
_log.exception("kill switch transition failed")
|
||||
result = f"error: {type(exc).__name__}: {exc}"
|
||||
except Exception as exc: # pragma: no cover — defensive
|
||||
_log.exception("manual_action dispatch failed")
|
||||
result = f"error: {type(exc).__name__}: {exc}"
|
||||
|
||||
conn = connect(ctx.db_path)
|
||||
try:
|
||||
with transaction(conn):
|
||||
ctx.repository.mark_action_consumed(
|
||||
conn,
|
||||
action.id,
|
||||
consumed_by=_CONSUMER_ID,
|
||||
result=result,
|
||||
now=reference,
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
processed += 1
|
||||
|
||||
if processed:
|
||||
_log.info("processed %d manual_actions", processed)
|
||||
return processed
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Periodic market-snapshot collector.
|
||||
|
||||
Drives the ``market_snapshots`` table populated by the scheduler job
|
||||
``market_snapshot`` (cron */15 by default). For every traded asset the
|
||||
collector calls the same MCP feeds the entry/monitor cycles consume,
|
||||
but in **best-effort mode**: a single failure leaves the corresponding
|
||||
column NULL and the row is still persisted, with an error map in
|
||||
``fetch_errors_json`` for debugging. This keeps the time series
|
||||
continuous even when one of the feeds is briefly down — the
|
||||
distributions are what matters for threshold calibration, not the
|
||||
real-time correctness of any single tick.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from cerbero_bite.clients._exceptions import McpError
|
||||
from cerbero_bite.state import connect, transaction
|
||||
from cerbero_bite.state.models import DvolSnapshot, MarketSnapshotRecord
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cerbero_bite.runtime.dependencies import RuntimeContext
|
||||
|
||||
__all__ = ["DEFAULT_ASSETS", "collect_market_snapshot"]
|
||||
|
||||
|
||||
_log = logging.getLogger("cerbero_bite.runtime.market_snapshot")
|
||||
|
||||
|
||||
DEFAULT_ASSETS: tuple[str, ...] = ("ETH", "BTC")
|
||||
|
||||
|
||||
async def _safe_call(
|
||||
label: str,
|
||||
factory: Callable[[], Awaitable[Any]],
|
||||
errors: dict[str, str],
|
||||
) -> Any:
|
||||
try:
|
||||
return await factory()
|
||||
except (McpError, Exception) as exc: # pragma: no branch — best-effort
|
||||
errors[label] = f"{type(exc).__name__}: {exc}"
|
||||
return None
|
||||
|
||||
|
||||
def _decimal_or_none(value: Any) -> Decimal | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, Decimal):
|
||||
return value
|
||||
try:
|
||||
return Decimal(str(value))
|
||||
except (ValueError, ArithmeticError):
|
||||
return None
|
||||
|
||||
|
||||
async def _collect_one(
|
||||
ctx: RuntimeContext, asset: str, *, when: datetime
|
||||
) -> MarketSnapshotRecord:
|
||||
errors: dict[str, str] = {}
|
||||
asset_upper = asset.upper()
|
||||
|
||||
spot = await _safe_call(
|
||||
"spot",
|
||||
lambda: ctx.deribit.spot_perp_price(asset_upper),
|
||||
errors,
|
||||
)
|
||||
dvol_value = await _safe_call(
|
||||
"dvol",
|
||||
lambda: ctx.deribit.latest_dvol(currency=asset_upper, now=when),
|
||||
errors,
|
||||
)
|
||||
rv = await _safe_call(
|
||||
"realized_vol",
|
||||
lambda: ctx.deribit.realized_vol(asset_upper),
|
||||
errors,
|
||||
)
|
||||
gamma = await _safe_call(
|
||||
"dealer_gamma",
|
||||
lambda: ctx.deribit.dealer_gamma_profile(asset_upper),
|
||||
errors,
|
||||
)
|
||||
funding_perp = await _safe_call(
|
||||
"funding_perp",
|
||||
lambda: ctx.hyperliquid.funding_rate_annualized(asset_upper),
|
||||
errors,
|
||||
)
|
||||
funding_cross = await _safe_call(
|
||||
"funding_cross",
|
||||
lambda: ctx.sentiment.funding_cross_median_annualized(asset_upper),
|
||||
errors,
|
||||
)
|
||||
heatmap = await _safe_call(
|
||||
"liquidation",
|
||||
lambda: ctx.sentiment.liquidation_heatmap(asset_upper),
|
||||
errors,
|
||||
)
|
||||
macro_days = await _safe_call(
|
||||
"macro",
|
||||
lambda: ctx.macro.next_high_severity_within(
|
||||
days=ctx.cfg.structure.dte_target,
|
||||
countries=list(ctx.cfg.entry.exclude_macro_countries),
|
||||
now=when,
|
||||
),
|
||||
errors,
|
||||
)
|
||||
|
||||
rv_30 = (rv or {}).get("rv_30d") if isinstance(rv, dict) else None
|
||||
iv_minus_rv_30 = (
|
||||
(rv or {}).get("iv_minus_rv_30d") if isinstance(rv, dict) else None
|
||||
)
|
||||
|
||||
return MarketSnapshotRecord(
|
||||
timestamp=when,
|
||||
asset=asset_upper,
|
||||
spot=_decimal_or_none(spot),
|
||||
dvol=_decimal_or_none(dvol_value),
|
||||
realized_vol_30d=_decimal_or_none(rv_30),
|
||||
iv_minus_rv=_decimal_or_none(iv_minus_rv_30),
|
||||
funding_perp_annualized=_decimal_or_none(funding_perp),
|
||||
funding_cross_annualized=_decimal_or_none(funding_cross),
|
||||
dealer_net_gamma=(
|
||||
_decimal_or_none(gamma.total_net_dealer_gamma)
|
||||
if gamma is not None
|
||||
else None
|
||||
),
|
||||
gamma_flip_level=(
|
||||
_decimal_or_none(gamma.gamma_flip_level)
|
||||
if gamma is not None
|
||||
else None
|
||||
),
|
||||
oi_delta_pct_4h=(
|
||||
_decimal_or_none(heatmap.oi_delta_pct_4h)
|
||||
if heatmap is not None
|
||||
else None
|
||||
),
|
||||
liquidation_long_risk=(
|
||||
heatmap.long_squeeze_risk if heatmap is not None else None
|
||||
),
|
||||
liquidation_short_risk=(
|
||||
heatmap.short_squeeze_risk if heatmap is not None else None
|
||||
),
|
||||
macro_days_to_event=(
|
||||
int(macro_days) if isinstance(macro_days, int) else None
|
||||
),
|
||||
fetch_ok=not errors,
|
||||
fetch_errors_json=(json.dumps(errors) if errors else None),
|
||||
)
|
||||
|
||||
|
||||
async def collect_market_snapshot(
|
||||
ctx: RuntimeContext,
|
||||
*,
|
||||
assets: tuple[str, ...] = DEFAULT_ASSETS,
|
||||
now: datetime | None = None,
|
||||
) -> int:
|
||||
"""Collect + persist one snapshot per asset. Returns count persisted.
|
||||
|
||||
The function is sync at heart (sequential per asset to keep MCP
|
||||
load light) but kept ``async def`` so APScheduler can schedule it
|
||||
directly. A single asset failing does not abort the loop — the
|
||||
other assets are still snapshotted.
|
||||
"""
|
||||
when = (now or datetime.now(UTC)).astimezone(UTC)
|
||||
persisted = 0
|
||||
|
||||
for asset in assets:
|
||||
try:
|
||||
record = await _collect_one(ctx, asset, when=when)
|
||||
except Exception: # pragma: no cover — defensive
|
||||
_log.exception("snapshot for %s failed catastrophically", asset)
|
||||
continue
|
||||
|
||||
try:
|
||||
conn = connect(ctx.db_path)
|
||||
try:
|
||||
with transaction(conn):
|
||||
ctx.repository.record_market_snapshot(conn, record)
|
||||
# Mirror ETH spot+DVOL into dvol_history so monitor_cycle's
|
||||
# return_4h lookup has local samples even in data-only mode.
|
||||
if (
|
||||
record.asset == "ETH"
|
||||
and record.spot is not None
|
||||
and record.dvol is not None
|
||||
):
|
||||
ctx.repository.record_dvol_snapshot(
|
||||
conn,
|
||||
DvolSnapshot(
|
||||
timestamp=record.timestamp,
|
||||
dvol=record.dvol,
|
||||
eth_spot=record.spot,
|
||||
),
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
persisted += 1
|
||||
except Exception: # pragma: no cover — defensive
|
||||
_log.exception("persist snapshot for %s failed", asset)
|
||||
|
||||
if persisted:
|
||||
_log.info("market_snapshot persisted %d row(s)", persisted)
|
||||
return persisted
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Periodic option-chain snapshot collector (§13).
|
||||
|
||||
Fetches the Deribit option chain for every strike entro la finestra
|
||||
DTE configurata. Cadenza di default ``*/15 * * * *`` (allineata a
|
||||
``market_snapshot``: crypto è 24/7 e l'accumulo dataset deve essere
|
||||
continuo, non gateato sui rollover TradFi-style). Persiste un quote
|
||||
per ogni strumento in ``option_chain_snapshots`` con un timestamp
|
||||
condiviso, che diventa il dato di base per:
|
||||
|
||||
* il backtest non-stilizzato (vedi ``core/backtest.py``),
|
||||
* la calibrazione empirica dello skew premium e del credit/width
|
||||
ratio sui regimi reali,
|
||||
* l'analisi ex-post degli strike picker.
|
||||
|
||||
Il collector è **best-effort**: se ``get_tickers`` fallisce per un
|
||||
batch, gli altri batch passano comunque; se manca completamente la
|
||||
chain, il job ritorna 0 senza alzare eccezioni e logga il problema.
|
||||
Non chiama l'order book per ogni strike (sarebbe troppo costoso) —
|
||||
``book_depth_top3`` resta NULL nel quote, il liquidity gate del live
|
||||
lo legge al volo solo per gli strike che gli interessano.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from cerbero_bite.state import connect, transaction
|
||||
from cerbero_bite.state.models import OptionChainQuoteRecord
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cerbero_bite.runtime.dependencies import RuntimeContext
|
||||
|
||||
__all__ = ["DEFAULT_BATCH_SIZE", "collect_option_chain_snapshot"]
|
||||
|
||||
|
||||
_log = logging.getLogger("cerbero_bite.runtime.option_chain_snapshot")
|
||||
|
||||
|
||||
DEFAULT_BATCH_SIZE = 20 # Deribit get_ticker_batch limit
|
||||
|
||||
|
||||
def _to_decimal_or_none(value: Any) -> Decimal | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return Decimal(str(value))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def _fetch_tickers_in_batches(
|
||||
ctx: RuntimeContext, names: list[str], *, batch_size: int = DEFAULT_BATCH_SIZE
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Best-effort fetch dei ticker per tutti i nomi richiesti."""
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
for i in range(0, len(names), batch_size):
|
||||
batch = names[i : i + batch_size]
|
||||
try:
|
||||
tickers = await ctx.deribit.get_tickers(batch)
|
||||
except Exception as exc:
|
||||
_log.warning(
|
||||
"get_tickers failed for batch starting %s: %s",
|
||||
batch[0] if batch else "<empty>", exc,
|
||||
)
|
||||
continue
|
||||
for t in tickers:
|
||||
name = t.get("instrument_name") or t.get("instrument")
|
||||
if isinstance(name, str):
|
||||
out[name] = t
|
||||
return out
|
||||
|
||||
|
||||
async def collect_option_chain_snapshot(
|
||||
ctx: RuntimeContext,
|
||||
*,
|
||||
asset: str = "ETH",
|
||||
now: datetime | None = None,
|
||||
batch_size: int = DEFAULT_BATCH_SIZE,
|
||||
) -> int:
|
||||
"""Collect + persist a single chain snapshot for ``asset``. Returns
|
||||
the number of quotes persisted (0 on best-effort failure).
|
||||
|
||||
Filtra le scadenze nella finestra ``[dte_min, dte_max]`` di
|
||||
``cfg.structure`` per non sprecare richieste su scadenze che il
|
||||
rule engine non userebbe mai.
|
||||
"""
|
||||
when = (now or datetime.now(UTC)).astimezone(UTC)
|
||||
cfg = ctx.cfg
|
||||
|
||||
expiry_from = when + timedelta(days=cfg.structure.dte_min)
|
||||
expiry_to = when + timedelta(days=cfg.structure.dte_max)
|
||||
|
||||
try:
|
||||
chain = await ctx.deribit.options_chain(
|
||||
currency=asset.upper(),
|
||||
expiry_from=expiry_from,
|
||||
expiry_to=expiry_to,
|
||||
min_open_interest=int(cfg.liquidity.open_interest_min),
|
||||
)
|
||||
except Exception:
|
||||
_log.exception("option chain fetch failed")
|
||||
return 0
|
||||
|
||||
if not chain:
|
||||
_log.info("option chain empty for %s in window", asset)
|
||||
return 0
|
||||
|
||||
names = [meta.name for meta in chain]
|
||||
tickers = await _fetch_tickers_in_batches(ctx, names, batch_size=batch_size)
|
||||
|
||||
quotes: list[OptionChainQuoteRecord] = []
|
||||
for meta in chain:
|
||||
ticker = tickers.get(meta.name)
|
||||
if ticker is None:
|
||||
# Lasciamo comunque la riga senza quote: utile sapere
|
||||
# che lo strumento esisteva.
|
||||
quotes.append(
|
||||
OptionChainQuoteRecord(
|
||||
timestamp=when,
|
||||
asset=asset.upper(),
|
||||
instrument_name=meta.name,
|
||||
strike=meta.strike,
|
||||
expiry=meta.expiry,
|
||||
option_type=meta.option_type,
|
||||
open_interest=int(meta.open_interest)
|
||||
if meta.open_interest is not None
|
||||
else None,
|
||||
)
|
||||
)
|
||||
continue
|
||||
greeks = ticker.get("greeks") or {}
|
||||
quotes.append(
|
||||
OptionChainQuoteRecord(
|
||||
timestamp=when,
|
||||
asset=asset.upper(),
|
||||
instrument_name=meta.name,
|
||||
strike=meta.strike,
|
||||
expiry=meta.expiry,
|
||||
option_type=meta.option_type,
|
||||
bid=_to_decimal_or_none(ticker.get("bid")),
|
||||
ask=_to_decimal_or_none(ticker.get("ask")),
|
||||
mid=_to_decimal_or_none(ticker.get("mark_price")),
|
||||
iv=_to_decimal_or_none(ticker.get("mark_iv")),
|
||||
delta=_to_decimal_or_none(greeks.get("delta")),
|
||||
gamma=_to_decimal_or_none(greeks.get("gamma")),
|
||||
theta=_to_decimal_or_none(greeks.get("theta")),
|
||||
vega=_to_decimal_or_none(greeks.get("vega")),
|
||||
open_interest=int(meta.open_interest)
|
||||
if meta.open_interest is not None
|
||||
else None,
|
||||
volume_24h=(
|
||||
int(ticker["volume_24h"])
|
||||
if ticker.get("volume_24h") is not None
|
||||
else None
|
||||
),
|
||||
# book_depth_top3: NULL — non lo prendiamo per ogni
|
||||
# strike per non saturare l'API. Il liquidity gate
|
||||
# del live lo chiede on-the-fly per gli strike
|
||||
# candidati al picker.
|
||||
)
|
||||
)
|
||||
|
||||
persisted = 0
|
||||
try:
|
||||
conn = connect(ctx.db_path)
|
||||
try:
|
||||
with transaction(conn):
|
||||
persisted = ctx.repository.record_option_chain_snapshot(
|
||||
conn, quotes
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
_log.exception("persist option chain snapshot failed")
|
||||
return 0
|
||||
|
||||
_log.info("option_chain_snapshot persisted %d quote(s)", persisted)
|
||||
return persisted
|
||||
|
||||
|
||||
# Avoid unused import warning for asyncio in lint when only used as type
|
||||
_ = asyncio
|
||||
@@ -23,11 +23,20 @@ import structlog
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
|
||||
from cerbero_bite.config.mcp_endpoints import McpEndpoints
|
||||
from cerbero_bite.config.runtime_flags import RuntimeFlags
|
||||
from cerbero_bite.config.schema import StrategyConfig
|
||||
from cerbero_bite.runtime.dependencies import RuntimeContext, build_runtime
|
||||
from cerbero_bite.runtime.entry_cycle import EntryCycleResult, run_entry_cycle
|
||||
from cerbero_bite.runtime.health_check import HealthCheck, HealthCheckResult
|
||||
from cerbero_bite.runtime.lockfile import EngineLock
|
||||
from cerbero_bite.runtime.manual_actions_consumer import consume_manual_actions
|
||||
from cerbero_bite.runtime.market_snapshot_cycle import (
|
||||
DEFAULT_ASSETS,
|
||||
collect_market_snapshot,
|
||||
)
|
||||
from cerbero_bite.runtime.option_chain_snapshot_cycle import (
|
||||
collect_option_chain_snapshot,
|
||||
)
|
||||
from cerbero_bite.runtime.monitor_cycle import MonitorCycleResult, run_monitor_cycle
|
||||
from cerbero_bite.runtime.recovery import recover_state
|
||||
from cerbero_bite.runtime.scheduler import JobSpec, build_scheduler
|
||||
@@ -41,10 +50,13 @@ _log = logging.getLogger("cerbero_bite.runtime.orchestrator")
|
||||
Environment = Literal["testnet", "mainnet"]
|
||||
|
||||
# Default cron schedule (matches docs/06-operational-flow.md table).
|
||||
_CRON_ENTRY = "0 14 * * MON"
|
||||
_CRON_ENTRY = "0 14 * * *" # crypto 24/7: candidatura giornaliera; i gate decidono se entrare
|
||||
_CRON_MONITOR = "0 2,14 * * *"
|
||||
_CRON_HEALTH = "*/5 * * * *"
|
||||
_CRON_BACKUP = "0 * * * *"
|
||||
_CRON_MANUAL_ACTIONS = "*/1 * * * *"
|
||||
_CRON_MARKET_SNAPSHOT = "*/15 * * * *"
|
||||
_CRON_OPTION_CHAIN_SNAPSHOT = "*/15 * * * *" # crypto è 24/7: cadenza continua allineata a market_snapshot
|
||||
_BACKUP_RETENTION_DAYS = 30
|
||||
|
||||
|
||||
@@ -63,10 +75,12 @@ class Orchestrator:
|
||||
*,
|
||||
expected_environment: Environment,
|
||||
eur_to_usd: Decimal,
|
||||
flags: RuntimeFlags | None = None,
|
||||
) -> None:
|
||||
self._ctx = ctx
|
||||
self._expected_env = expected_environment
|
||||
self._eur_to_usd = eur_to_usd
|
||||
self._flags = flags or RuntimeFlags()
|
||||
self._health = HealthCheck(ctx, expected_environment=expected_environment)
|
||||
self._scheduler: AsyncIOScheduler | None = None
|
||||
|
||||
@@ -78,6 +92,10 @@ class Orchestrator:
|
||||
def expected_environment(self) -> Environment:
|
||||
return self._expected_env
|
||||
|
||||
@property
|
||||
def flags(self) -> RuntimeFlags:
|
||||
return self._flags
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Boot
|
||||
# ------------------------------------------------------------------
|
||||
@@ -106,9 +124,18 @@ class Orchestrator:
|
||||
"environment": info.environment,
|
||||
"health": health.state,
|
||||
"config_version": self._ctx.cfg.config_version,
|
||||
"data_analysis_enabled": self._flags.data_analysis_enabled,
|
||||
"strategy_enabled": self._flags.strategy_enabled,
|
||||
},
|
||||
now=when,
|
||||
)
|
||||
_log.info(
|
||||
"engine started: env=%s health=%s data_analysis=%s strategy=%s",
|
||||
info.environment,
|
||||
health.state,
|
||||
self._flags.data_analysis_enabled,
|
||||
self._flags.strategy_enabled,
|
||||
)
|
||||
return _BootResult(environment=info.environment, health=health)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -191,6 +218,11 @@ class Orchestrator:
|
||||
monitor_cron: str = _CRON_MONITOR,
|
||||
health_cron: str = _CRON_HEALTH,
|
||||
backup_cron: str = _CRON_BACKUP,
|
||||
manual_actions_cron: str = _CRON_MANUAL_ACTIONS,
|
||||
market_snapshot_cron: str = _CRON_MARKET_SNAPSHOT,
|
||||
market_snapshot_assets: tuple[str, ...] = DEFAULT_ASSETS,
|
||||
option_chain_cron: str = _CRON_OPTION_CHAIN_SNAPSHOT,
|
||||
option_chain_assets: tuple[str, ...] = ("ETH", "BTC"),
|
||||
backup_dir: Path | None = None,
|
||||
backup_retention_days: int = _BACKUP_RETENTION_DAYS,
|
||||
) -> AsyncIOScheduler:
|
||||
@@ -229,14 +261,81 @@ class Orchestrator:
|
||||
|
||||
await _safe("backup", _do)
|
||||
|
||||
self._scheduler = build_scheduler(
|
||||
[
|
||||
JobSpec(name="entry", cron=entry_cron, coro_factory=_entry),
|
||||
JobSpec(name="monitor", cron=monitor_cron, coro_factory=_monitor),
|
||||
JobSpec(name="health", cron=health_cron, coro_factory=_health),
|
||||
JobSpec(name="backup", cron=backup_cron, coro_factory=_backup),
|
||||
]
|
||||
)
|
||||
async def _run_market_snapshot_via_action() -> None:
|
||||
await collect_market_snapshot(
|
||||
self._ctx, assets=market_snapshot_assets
|
||||
)
|
||||
|
||||
async def _manual_actions() -> None:
|
||||
async def _do() -> None:
|
||||
await consume_manual_actions(
|
||||
self._ctx,
|
||||
cycle_runners={
|
||||
"entry": self.run_entry,
|
||||
"monitor": self.run_monitor,
|
||||
"health": self.run_health,
|
||||
"market_snapshot": _run_market_snapshot_via_action,
|
||||
},
|
||||
)
|
||||
|
||||
await _safe("manual_actions", _do)
|
||||
|
||||
async def _market_snapshot() -> None:
|
||||
async def _do() -> None:
|
||||
await collect_market_snapshot(
|
||||
self._ctx, assets=market_snapshot_assets
|
||||
)
|
||||
|
||||
await _safe("market_snapshot", _do)
|
||||
|
||||
async def _option_chain_snapshot() -> None:
|
||||
async def _do() -> None:
|
||||
for asset in option_chain_assets:
|
||||
await collect_option_chain_snapshot(self._ctx, asset=asset)
|
||||
|
||||
await _safe("option_chain_snapshot", _do)
|
||||
|
||||
jobs: list[JobSpec] = [
|
||||
JobSpec(name="health", cron=health_cron, coro_factory=_health),
|
||||
JobSpec(name="backup", cron=backup_cron, coro_factory=_backup),
|
||||
JobSpec(
|
||||
name="manual_actions",
|
||||
cron=manual_actions_cron,
|
||||
coro_factory=_manual_actions,
|
||||
),
|
||||
]
|
||||
if self._flags.strategy_enabled:
|
||||
jobs.append(JobSpec(name="entry", cron=entry_cron, coro_factory=_entry))
|
||||
jobs.append(
|
||||
JobSpec(name="monitor", cron=monitor_cron, coro_factory=_monitor)
|
||||
)
|
||||
else:
|
||||
_log.warning(
|
||||
"strategy disabled (CERBERO_BITE_ENABLE_STRATEGY=false): "
|
||||
"entry and monitor cycles are NOT scheduled"
|
||||
)
|
||||
if self._flags.data_analysis_enabled:
|
||||
jobs.append(
|
||||
JobSpec(
|
||||
name="market_snapshot",
|
||||
cron=market_snapshot_cron,
|
||||
coro_factory=_market_snapshot,
|
||||
)
|
||||
)
|
||||
jobs.append(
|
||||
JobSpec(
|
||||
name="option_chain_snapshot",
|
||||
cron=option_chain_cron,
|
||||
coro_factory=_option_chain_snapshot,
|
||||
)
|
||||
)
|
||||
else:
|
||||
_log.warning(
|
||||
"data analysis disabled (CERBERO_BITE_ENABLE_DATA_ANALYSIS="
|
||||
"false): market_snapshot job is NOT scheduled"
|
||||
)
|
||||
|
||||
self._scheduler = build_scheduler(jobs)
|
||||
return self._scheduler
|
||||
|
||||
async def run_forever(self, *, lock_path: Path | None = None) -> None:
|
||||
@@ -329,17 +428,25 @@ def make_orchestrator(
|
||||
audit_path: Path,
|
||||
expected_environment: Environment,
|
||||
eur_to_usd: Decimal,
|
||||
bot_tag: str | None = None,
|
||||
flags: RuntimeFlags | None = None,
|
||||
clock: Callable[[], datetime] | None = None,
|
||||
) -> Orchestrator:
|
||||
"""Build a fresh :class:`Orchestrator` ready for ``boot``/``run_*``."""
|
||||
ctx = build_runtime(
|
||||
cfg=cfg,
|
||||
endpoints=endpoints,
|
||||
token=token,
|
||||
db_path=db_path,
|
||||
audit_path=audit_path,
|
||||
clock=clock or (lambda: datetime.now(UTC)),
|
||||
)
|
||||
build_kwargs: dict[str, object] = {
|
||||
"cfg": cfg,
|
||||
"endpoints": endpoints,
|
||||
"token": token,
|
||||
"db_path": db_path,
|
||||
"audit_path": audit_path,
|
||||
"clock": clock or (lambda: datetime.now(UTC)),
|
||||
}
|
||||
if bot_tag is not None:
|
||||
build_kwargs["bot_tag"] = bot_tag
|
||||
ctx = build_runtime(**build_kwargs) # type: ignore[arg-type]
|
||||
return Orchestrator(
|
||||
ctx, expected_environment=expected_environment, eur_to_usd=eur_to_usd
|
||||
ctx,
|
||||
expected_environment=expected_environment,
|
||||
eur_to_usd=eur_to_usd,
|
||||
flags=flags,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
-- 0003_market_snapshots.sql — periodic market snapshot table.
|
||||
--
|
||||
-- Populated by the `market_snapshot` scheduler job (cron */15) for
|
||||
-- every asset traded by the engine (ETH primary, BTC as benchmark).
|
||||
-- The table backs the "Calibrazione" GUI page: histograms, percentiles
|
||||
-- and "% of ticks the current threshold would have blocked" let the
|
||||
-- operator pick filter thresholds from observed distributions instead
|
||||
-- of guessing.
|
||||
--
|
||||
-- Every column except (timestamp, asset, fetch_ok) is NULL-able: a
|
||||
-- single MCP call may fail and we still want to keep the row so the
|
||||
-- time series stays continuous. fetch_errors_json carries the per-feed
|
||||
-- error messages for offline debugging.
|
||||
|
||||
CREATE TABLE market_snapshots (
|
||||
timestamp TEXT NOT NULL,
|
||||
asset TEXT NOT NULL,
|
||||
spot NUMERIC,
|
||||
dvol NUMERIC,
|
||||
realized_vol_30d NUMERIC,
|
||||
iv_minus_rv NUMERIC,
|
||||
funding_perp_annualized NUMERIC,
|
||||
funding_cross_annualized NUMERIC,
|
||||
dealer_net_gamma NUMERIC,
|
||||
gamma_flip_level NUMERIC,
|
||||
oi_delta_pct_4h NUMERIC,
|
||||
liquidation_long_risk TEXT,
|
||||
liquidation_short_risk TEXT,
|
||||
macro_days_to_event INTEGER,
|
||||
fetch_ok INTEGER NOT NULL,
|
||||
fetch_errors_json TEXT,
|
||||
PRIMARY KEY (timestamp, asset)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_market_snapshots_asset_ts
|
||||
ON market_snapshots(asset, timestamp DESC);
|
||||
|
||||
PRAGMA user_version = 3;
|
||||
@@ -0,0 +1,14 @@
|
||||
-- 0004_auto_pause.sql — circuit breaker su drawdown rolling (§7-bis F)
|
||||
--
|
||||
-- Aggiunge alla `system_state` il timestamp fino a cui l'engine è in
|
||||
-- pausa automatica per via di un drawdown sopra soglia. NULL = engine
|
||||
-- attivo. Quando il valore è nel futuro, il rule engine salta il
|
||||
-- ciclo entry e logga la motivazione.
|
||||
--
|
||||
-- Indipendente dal kill_switch (che resta dedicato a errori tecnici
|
||||
-- e a comandi manuali esplicitati). Le due tutele coesistono.
|
||||
|
||||
ALTER TABLE system_state ADD COLUMN auto_pause_until TEXT;
|
||||
ALTER TABLE system_state ADD COLUMN auto_pause_reason TEXT;
|
||||
|
||||
PRAGMA user_version = 4;
|
||||
@@ -0,0 +1,42 @@
|
||||
-- 0005_option_chain_snapshots.sql — catena opzioni storica
|
||||
--
|
||||
-- Snapshot della option chain Deribit prelevata ogni 15 minuti (stesso
|
||||
-- scheduler di market_snapshots, cron */15) per ogni strike entro ±30%
|
||||
-- dallo spot e per ogni scadenza nella finestra 14-28 DTE. Dato di
|
||||
-- base per il backtest non-stilizzato e per calibrare empiricamente
|
||||
-- lo skew premium del modello BS.
|
||||
--
|
||||
-- Granularità: una riga per (snapshot_ts, instrument). Lo
|
||||
-- snapshot_ts è il timestamp del cron tick — TUTTI i quote raccolti
|
||||
-- in quello stesso tick condividono il timestamp, così filtrare per
|
||||
-- "lo snapshot del 2026-05-04 alle 13:55" è una semplice
|
||||
-- WHERE timestamp = X.
|
||||
|
||||
CREATE TABLE option_chain_snapshots (
|
||||
timestamp TEXT NOT NULL,
|
||||
asset TEXT NOT NULL,
|
||||
instrument_name TEXT NOT NULL,
|
||||
strike TEXT NOT NULL,
|
||||
expiry TEXT NOT NULL,
|
||||
option_type TEXT NOT NULL CHECK (option_type IN ('C','P')),
|
||||
bid TEXT,
|
||||
ask TEXT,
|
||||
mid TEXT,
|
||||
iv TEXT,
|
||||
delta TEXT,
|
||||
gamma TEXT,
|
||||
theta TEXT,
|
||||
vega TEXT,
|
||||
open_interest INTEGER,
|
||||
volume_24h INTEGER,
|
||||
book_depth_top3 INTEGER,
|
||||
PRIMARY KEY (timestamp, instrument_name)
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE INDEX idx_option_chain_asset_ts
|
||||
ON option_chain_snapshots(asset, timestamp DESC);
|
||||
|
||||
CREATE INDEX idx_option_chain_expiry
|
||||
ON option_chain_snapshots(asset, expiry);
|
||||
|
||||
PRAGMA user_version = 5;
|
||||
@@ -21,6 +21,8 @@ __all__ = [
|
||||
"DvolSnapshot",
|
||||
"InstructionRecord",
|
||||
"ManualAction",
|
||||
"MarketSnapshotRecord",
|
||||
"OptionChainQuoteRecord",
|
||||
"PositionRecord",
|
||||
"PositionStatus",
|
||||
"SystemStateRecord",
|
||||
@@ -118,6 +120,65 @@ class DvolSnapshot(BaseModel):
|
||||
eth_spot: Decimal
|
||||
|
||||
|
||||
class MarketSnapshotRecord(BaseModel):
|
||||
"""Row of the ``market_snapshots`` table.
|
||||
|
||||
Single point in time, single asset. Every numeric field is
|
||||
optional because the ``market_snapshot`` collector is best-effort:
|
||||
a single MCP failure NULLs the affected metric without dropping
|
||||
the row.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
timestamp: datetime
|
||||
asset: str # "ETH", "BTC"
|
||||
spot: Decimal | None = None
|
||||
dvol: Decimal | None = None
|
||||
realized_vol_30d: Decimal | None = None
|
||||
iv_minus_rv: Decimal | None = None
|
||||
funding_perp_annualized: Decimal | None = None
|
||||
funding_cross_annualized: Decimal | None = None
|
||||
dealer_net_gamma: Decimal | None = None
|
||||
gamma_flip_level: Decimal | None = None
|
||||
oi_delta_pct_4h: Decimal | None = None
|
||||
liquidation_long_risk: str | None = None
|
||||
liquidation_short_risk: str | None = None
|
||||
macro_days_to_event: int | None = None
|
||||
fetch_ok: bool
|
||||
fetch_errors_json: str | None = None
|
||||
|
||||
|
||||
class OptionChainQuoteRecord(BaseModel):
|
||||
"""Row of the ``option_chain_snapshots`` table.
|
||||
|
||||
One row per (snapshot_ts, instrument) — the same ``timestamp`` is
|
||||
shared by every quote prelevato nello stesso tick del cron. Tutti
|
||||
i campi numerici sono opzionali perché il collector è
|
||||
best-effort: un ticker mancante non invalida il resto della chain.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
timestamp: datetime
|
||||
asset: str
|
||||
instrument_name: str
|
||||
strike: Decimal
|
||||
expiry: datetime
|
||||
option_type: Literal["C", "P"]
|
||||
bid: Decimal | None = None
|
||||
ask: Decimal | None = None
|
||||
mid: Decimal | None = None
|
||||
iv: Decimal | None = None
|
||||
delta: Decimal | None = None
|
||||
gamma: Decimal | None = None
|
||||
theta: Decimal | None = None
|
||||
vega: Decimal | None = None
|
||||
open_interest: int | None = None
|
||||
volume_24h: int | None = None
|
||||
book_depth_top3: int | None = None
|
||||
|
||||
|
||||
class ManualAction(BaseModel):
|
||||
"""Row of the ``manual_actions`` table."""
|
||||
|
||||
@@ -130,6 +191,7 @@ class ManualAction(BaseModel):
|
||||
"force_close",
|
||||
"arm_kill",
|
||||
"disarm_kill",
|
||||
"run_cycle",
|
||||
]
|
||||
proposal_id: UUID | None = None
|
||||
payload_json: str | None = None
|
||||
@@ -153,3 +215,5 @@ class SystemStateRecord(BaseModel):
|
||||
config_version: str
|
||||
started_at: datetime
|
||||
last_audit_hash: str | None = None
|
||||
auto_pause_until: datetime | None = None
|
||||
auto_pause_reason: str | None = None
|
||||
|
||||
@@ -13,7 +13,7 @@ Decimals are stored as TEXT to preserve precision (see
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
@@ -23,6 +23,8 @@ from cerbero_bite.state.models import (
|
||||
DvolSnapshot,
|
||||
InstructionRecord,
|
||||
ManualAction,
|
||||
MarketSnapshotRecord,
|
||||
OptionChainQuoteRecord,
|
||||
PositionRecord,
|
||||
PositionStatus,
|
||||
SystemStateRecord,
|
||||
@@ -346,6 +348,268 @@ class Repository:
|
||||
),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# market_snapshots
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def record_market_snapshot(
|
||||
self, conn: sqlite3.Connection, snapshot: MarketSnapshotRecord
|
||||
) -> None:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO market_snapshots("
|
||||
"timestamp, asset, spot, dvol, realized_vol_30d, iv_minus_rv, "
|
||||
"funding_perp_annualized, funding_cross_annualized, "
|
||||
"dealer_net_gamma, gamma_flip_level, oi_delta_pct_4h, "
|
||||
"liquidation_long_risk, liquidation_short_risk, "
|
||||
"macro_days_to_event, fetch_ok, fetch_errors_json) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
_enc_dt(snapshot.timestamp),
|
||||
snapshot.asset,
|
||||
_enc_dec(snapshot.spot),
|
||||
_enc_dec(snapshot.dvol),
|
||||
_enc_dec(snapshot.realized_vol_30d),
|
||||
_enc_dec(snapshot.iv_minus_rv),
|
||||
_enc_dec(snapshot.funding_perp_annualized),
|
||||
_enc_dec(snapshot.funding_cross_annualized),
|
||||
_enc_dec(snapshot.dealer_net_gamma),
|
||||
_enc_dec(snapshot.gamma_flip_level),
|
||||
_enc_dec(snapshot.oi_delta_pct_4h),
|
||||
snapshot.liquidation_long_risk,
|
||||
snapshot.liquidation_short_risk,
|
||||
snapshot.macro_days_to_event,
|
||||
1 if snapshot.fetch_ok else 0,
|
||||
snapshot.fetch_errors_json,
|
||||
),
|
||||
)
|
||||
|
||||
def list_market_snapshots(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
asset: str,
|
||||
start: datetime | None = None,
|
||||
end: datetime | None = None,
|
||||
limit: int = 5000,
|
||||
) -> list[MarketSnapshotRecord]:
|
||||
clauses: list[str] = ["asset = ?"]
|
||||
params: list[Any] = [asset]
|
||||
if start is not None:
|
||||
clauses.append("timestamp >= ?")
|
||||
params.append(_enc_dt(start))
|
||||
if end is not None:
|
||||
clauses.append("timestamp <= ?")
|
||||
params.append(_enc_dt(end))
|
||||
params.append(int(limit))
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM market_snapshots WHERE {' AND '.join(clauses)} "
|
||||
f"ORDER BY timestamp DESC LIMIT ?",
|
||||
params,
|
||||
).fetchall()
|
||||
return [_row_to_market_snapshot(r) for r in rows]
|
||||
|
||||
def count_iv_rv_distinct_days(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
asset: str,
|
||||
max_days: int,
|
||||
as_of: datetime | None = None,
|
||||
) -> int:
|
||||
"""Numero di giorni di calendario distinti coperti da IV-RV validi.
|
||||
|
||||
Esclude righe con ``fetch_ok=0`` o ``iv_minus_rv IS NULL``.
|
||||
Usato dal caller del gate adattivo per decidere la finestra
|
||||
(warmup hard / min_days / target_days).
|
||||
|
||||
Args:
|
||||
as_of: Reference time for the rolling window. Defaults to
|
||||
``datetime.now(UTC)``.
|
||||
"""
|
||||
if max_days <= 0:
|
||||
raise ValueError(f"max_days must be positive, got {max_days}")
|
||||
ref = as_of if as_of is not None else datetime.now(UTC)
|
||||
if ref.tzinfo is None:
|
||||
raise ValueError("as_of must be timezone-aware")
|
||||
cutoff = ref - timedelta(days=max_days)
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(DISTINCT substr(timestamp, 1, 10)) AS n "
|
||||
"FROM market_snapshots "
|
||||
"WHERE asset = ? "
|
||||
" AND fetch_ok = 1 "
|
||||
" AND iv_minus_rv IS NOT NULL "
|
||||
" AND timestamp >= ?",
|
||||
(asset, _enc_dt(cutoff)),
|
||||
).fetchone()
|
||||
return int(row["n"]) if row is not None else 0
|
||||
|
||||
def iv_rv_values_for_window(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
asset: str,
|
||||
window_days: int,
|
||||
as_of: datetime | None = None,
|
||||
) -> list[Decimal]:
|
||||
"""Valori IV-RV ordinati ASC su ``[as_of - window_days, as_of]``.
|
||||
|
||||
Esclude righe con ``fetch_ok=0`` o ``iv_minus_rv IS NULL``.
|
||||
Tutti i record validi della finestra concorrono come singoli
|
||||
contributi alla statistica del percentile, indipendentemente
|
||||
dalla cadenza con cui sono stati raccolti (tick live vs backfill
|
||||
daily).
|
||||
"""
|
||||
if window_days <= 0:
|
||||
raise ValueError(f"window_days must be positive, got {window_days}")
|
||||
ref = as_of if as_of is not None else datetime.now(UTC)
|
||||
if ref.tzinfo is None:
|
||||
raise ValueError("as_of must be timezone-aware")
|
||||
cutoff = ref - timedelta(days=window_days)
|
||||
rows = conn.execute(
|
||||
"SELECT iv_minus_rv FROM market_snapshots "
|
||||
"WHERE asset = ? "
|
||||
" AND fetch_ok = 1 "
|
||||
" AND iv_minus_rv IS NOT NULL "
|
||||
" AND timestamp >= ? "
|
||||
"ORDER BY timestamp ASC",
|
||||
(asset, _enc_dt(cutoff)),
|
||||
).fetchall()
|
||||
return [Decimal(str(r["iv_minus_rv"])) for r in rows]
|
||||
|
||||
def dvol_lookback(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
asset: str,
|
||||
reference: datetime,
|
||||
tolerance_minutes: int = 15,
|
||||
) -> Decimal | None:
|
||||
"""DVOL al tick più vicino a `reference`, entro ±tolerance_minutes.
|
||||
|
||||
Ritorna ``None`` se non esiste un tick valido (``fetch_ok=1``,
|
||||
``dvol IS NOT NULL``) entro la tolerance. Usato dal Vol-of-Vol
|
||||
guard per stimare DVOL N ore fa.
|
||||
"""
|
||||
if reference.tzinfo is None:
|
||||
raise ValueError("reference must be timezone-aware")
|
||||
ref_lo = (reference - timedelta(minutes=tolerance_minutes)).astimezone(UTC)
|
||||
ref_hi = (reference + timedelta(minutes=tolerance_minutes)).astimezone(UTC)
|
||||
row = conn.execute(
|
||||
"SELECT dvol, timestamp FROM market_snapshots "
|
||||
"WHERE asset = ? "
|
||||
" AND fetch_ok = 1 "
|
||||
" AND dvol IS NOT NULL "
|
||||
" AND timestamp >= ? "
|
||||
" AND timestamp <= ? "
|
||||
"ORDER BY ABS(julianday(timestamp) - julianday(?)) ASC LIMIT 1",
|
||||
(
|
||||
asset,
|
||||
_enc_dt(ref_lo),
|
||||
_enc_dt(ref_hi),
|
||||
_enc_dt(reference),
|
||||
),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return Decimal(str(row["dvol"]))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# option_chain_snapshots
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def record_option_chain_snapshot(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
quotes: list[OptionChainQuoteRecord],
|
||||
) -> int:
|
||||
"""Bulk-insert dei quote di un singolo tick. Tutti i quote
|
||||
condividono lo stesso ``timestamp``. Idempotente per
|
||||
(timestamp, instrument_name)."""
|
||||
if not quotes:
|
||||
return 0
|
||||
rows = [
|
||||
(
|
||||
_enc_dt(q.timestamp),
|
||||
q.asset,
|
||||
q.instrument_name,
|
||||
_enc_dec(q.strike),
|
||||
_enc_dt(q.expiry),
|
||||
q.option_type,
|
||||
_enc_dec(q.bid),
|
||||
_enc_dec(q.ask),
|
||||
_enc_dec(q.mid),
|
||||
_enc_dec(q.iv),
|
||||
_enc_dec(q.delta),
|
||||
_enc_dec(q.gamma),
|
||||
_enc_dec(q.theta),
|
||||
_enc_dec(q.vega),
|
||||
q.open_interest,
|
||||
q.volume_24h,
|
||||
q.book_depth_top3,
|
||||
)
|
||||
for q in quotes
|
||||
]
|
||||
conn.executemany(
|
||||
"INSERT OR REPLACE INTO option_chain_snapshots("
|
||||
"timestamp, asset, instrument_name, strike, expiry, option_type, "
|
||||
"bid, ask, mid, iv, delta, gamma, theta, vega, "
|
||||
"open_interest, volume_24h, book_depth_top3) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
rows,
|
||||
)
|
||||
return len(rows)
|
||||
|
||||
def list_option_chain_snapshots(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
asset: str,
|
||||
start: datetime | None = None,
|
||||
end: datetime | None = None,
|
||||
expiry_from: datetime | None = None,
|
||||
expiry_to: datetime | None = None,
|
||||
limit: int = 50000,
|
||||
) -> list[OptionChainQuoteRecord]:
|
||||
clauses: list[str] = ["asset = ?"]
|
||||
params: list[Any] = [asset]
|
||||
if start is not None:
|
||||
clauses.append("timestamp >= ?")
|
||||
params.append(_enc_dt(start))
|
||||
if end is not None:
|
||||
clauses.append("timestamp <= ?")
|
||||
params.append(_enc_dt(end))
|
||||
if expiry_from is not None:
|
||||
clauses.append("expiry >= ?")
|
||||
params.append(_enc_dt(expiry_from))
|
||||
if expiry_to is not None:
|
||||
clauses.append("expiry <= ?")
|
||||
params.append(_enc_dt(expiry_to))
|
||||
params.append(int(limit))
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM option_chain_snapshots "
|
||||
f"WHERE {' AND '.join(clauses)} "
|
||||
f"ORDER BY timestamp DESC, instrument_name ASC LIMIT ?",
|
||||
params,
|
||||
).fetchall()
|
||||
return [_row_to_option_chain_quote(r) for r in rows]
|
||||
|
||||
def latest_option_chain_timestamp(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
asset: str,
|
||||
) -> datetime | None:
|
||||
"""Timestamp dell'ultimo snapshot raccolto per ``asset``,
|
||||
utile per stimare la freschezza del dato dalla GUI."""
|
||||
row = conn.execute(
|
||||
"SELECT timestamp FROM option_chain_snapshots "
|
||||
"WHERE asset = ? ORDER BY timestamp DESC LIMIT 1",
|
||||
(asset,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return _dec_dt(row["timestamp"])
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# manual_actions
|
||||
# ------------------------------------------------------------------
|
||||
@@ -427,6 +691,16 @@ class Repository:
|
||||
last_audit_hash=(
|
||||
row["last_audit_hash"] if "last_audit_hash" in keys else None
|
||||
),
|
||||
auto_pause_until=(
|
||||
_dec_dt(row["auto_pause_until"])
|
||||
if "auto_pause_until" in keys
|
||||
else None
|
||||
),
|
||||
auto_pause_reason=(
|
||||
row["auto_pause_reason"]
|
||||
if "auto_pause_reason" in keys
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
def set_last_audit_hash(
|
||||
@@ -465,6 +739,43 @@ class Repository:
|
||||
(_enc_dt(now),),
|
||||
)
|
||||
|
||||
def set_auto_pause(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
until: datetime | None,
|
||||
reason: str | None,
|
||||
) -> None:
|
||||
"""Imposta o azzera la pausa automatica (§7-bis F).
|
||||
|
||||
``until = None`` annulla la pausa (l'engine torna attivo).
|
||||
Il setter è idempotente: chiamarlo con un until già nel passato
|
||||
è equivalente a clear.
|
||||
"""
|
||||
conn.execute(
|
||||
"UPDATE system_state SET auto_pause_until = ?, "
|
||||
"auto_pause_reason = ? WHERE id = 1",
|
||||
(_enc_dt(until) if until is not None else None, reason),
|
||||
)
|
||||
|
||||
def recent_closed_position_pnls_usd(
|
||||
self, conn: sqlite3.Connection, *, limit: int
|
||||
) -> list[Decimal]:
|
||||
"""Ritorna la lista dei pnl_usd delle ultime ``limit`` posizioni chiuse,
|
||||
ordinate dalla più recente alla più vecchia. Posizioni con
|
||||
``pnl_usd`` ``NULL`` (es. chiuse di emergenza senza P/L noto)
|
||||
sono saltate. Usato dal circuit breaker §7-bis F.
|
||||
"""
|
||||
if limit <= 0:
|
||||
return []
|
||||
rows = conn.execute(
|
||||
"SELECT pnl_usd FROM positions "
|
||||
"WHERE closed_at IS NOT NULL AND pnl_usd IS NOT NULL "
|
||||
"ORDER BY closed_at DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return [Decimal(row["pnl_usd"]) for row in rows]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Row → model converters
|
||||
@@ -559,6 +870,63 @@ def _row_to_manual(row: sqlite3.Row) -> ManualAction:
|
||||
)
|
||||
|
||||
|
||||
def _row_to_market_snapshot(row: sqlite3.Row) -> MarketSnapshotRecord:
|
||||
return MarketSnapshotRecord(
|
||||
timestamp=_dec_dt_required(row["timestamp"]),
|
||||
asset=row["asset"],
|
||||
spot=_dec_dec(row["spot"]),
|
||||
dvol=_dec_dec(row["dvol"]),
|
||||
realized_vol_30d=_dec_dec(row["realized_vol_30d"]),
|
||||
iv_minus_rv=_dec_dec(row["iv_minus_rv"]),
|
||||
funding_perp_annualized=_dec_dec(row["funding_perp_annualized"]),
|
||||
funding_cross_annualized=_dec_dec(row["funding_cross_annualized"]),
|
||||
dealer_net_gamma=_dec_dec(row["dealer_net_gamma"]),
|
||||
gamma_flip_level=_dec_dec(row["gamma_flip_level"]),
|
||||
oi_delta_pct_4h=_dec_dec(row["oi_delta_pct_4h"]),
|
||||
liquidation_long_risk=row["liquidation_long_risk"],
|
||||
liquidation_short_risk=row["liquidation_short_risk"],
|
||||
macro_days_to_event=(
|
||||
int(row["macro_days_to_event"])
|
||||
if row["macro_days_to_event"] is not None
|
||||
else None
|
||||
),
|
||||
fetch_ok=bool(int(row["fetch_ok"])),
|
||||
fetch_errors_json=row["fetch_errors_json"],
|
||||
)
|
||||
|
||||
|
||||
def _row_to_option_chain_quote(row: sqlite3.Row) -> OptionChainQuoteRecord:
|
||||
return OptionChainQuoteRecord(
|
||||
timestamp=_dec_dt_required(row["timestamp"]),
|
||||
asset=row["asset"],
|
||||
instrument_name=row["instrument_name"],
|
||||
strike=_dec_dec_required(row["strike"]),
|
||||
expiry=_dec_dt_required(row["expiry"]),
|
||||
option_type=row["option_type"],
|
||||
bid=_dec_dec(row["bid"]),
|
||||
ask=_dec_dec(row["ask"]),
|
||||
mid=_dec_dec(row["mid"]),
|
||||
iv=_dec_dec(row["iv"]),
|
||||
delta=_dec_dec(row["delta"]),
|
||||
gamma=_dec_dec(row["gamma"]),
|
||||
theta=_dec_dec(row["theta"]),
|
||||
vega=_dec_dec(row["vega"]),
|
||||
open_interest=(
|
||||
int(row["open_interest"])
|
||||
if row["open_interest"] is not None
|
||||
else None
|
||||
),
|
||||
volume_24h=(
|
||||
int(row["volume_24h"]) if row["volume_24h"] is not None else None
|
||||
),
|
||||
book_depth_top3=(
|
||||
int(row["book_depth_top3"])
|
||||
if row["book_depth_top3"] is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _dec_dec_required(value: Any) -> Decimal:
|
||||
out = _dec_dec(value)
|
||||
if out is None:
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
# strategy.aggressiva.yaml — Cerbero Bite, profilo AGGRESSIVO
|
||||
#
|
||||
# Profilo "crescita: rendimenti significativi, drawdown a doppia cifra,
|
||||
# complessità più alta". DEROGA esplicitamente alla sezione §11
|
||||
# "Riepilogo soglie" di docs/01-strategy-rules.md (cap_per_trade_eur,
|
||||
# cap_aggregate_open_eur, max_concurrent_positions). NON va deployato
|
||||
# senza:
|
||||
# 1. backtest dedicato sui dati raccolti
|
||||
# 2. paper trading per almeno 4 settimane
|
||||
# 3. autorizzazione esplicita scritta nel commit
|
||||
#
|
||||
# Caratteristiche operative attese (vs. profilo conservativo):
|
||||
# * Cap per trade 800 EUR (~860 USD) → 4× la size
|
||||
# * Cap aggregato 3 200 EUR (~3 440 USD) → 4× il rischio aggregato
|
||||
# * Max 2 posizioni concorrenti (era 1)
|
||||
# * Max 16 contratti per trade (era 4)
|
||||
# * P/L stimato: +5% / +20% APR sul capitale impiegato
|
||||
# * Drawdown atteso: 25–40% del capitale impiegato in streak
|
||||
# * Adatto a: capitale "growth", non parcheggio
|
||||
#
|
||||
# CAVEAT MULTI-ASSET. Il rule engine attuale è single-asset
|
||||
# (`asset.symbol`). Per estendere a ETH+BTC servono modifiche di
|
||||
# codice in:
|
||||
# * cerbero_bite/runtime/entry_cycle.py (loop su lista asset)
|
||||
# * cerbero_bite/state/repository.py (multi-position per asset)
|
||||
# * cerbero_bite/runtime/orchestrator.py (scheduler one-asset → N)
|
||||
# Nel frattempo il file resta single-asset ETH; il moltiplicatore
|
||||
# 2× via "ETH + BTC" indicato in `📚 Strategia` è una **stima ex-ante**
|
||||
# di cosa otterresti DOPO quel lavoro di codice.
|
||||
|
||||
config_version: "1.4.0-aggressiva"
|
||||
config_hash: "7fa9b0be5b56517293421bc19838b700da595725360fe018a1be13b802dea859"
|
||||
last_review: "2026-04-26"
|
||||
last_reviewer: "Adriano"
|
||||
|
||||
asset:
|
||||
symbol: "ETH"
|
||||
exchange: "deribit"
|
||||
|
||||
entry:
|
||||
cron: "0 14 * * *"
|
||||
skip_holidays_country: "IT"
|
||||
|
||||
capital_min_usd: "2880" # 4× del minimo conservativo (720)
|
||||
dvol_min: "35"
|
||||
dvol_max: "90"
|
||||
funding_perp_abs_max_annualized: "0.80"
|
||||
eth_holdings_pct_max: "0.30"
|
||||
no_position_concurrent: false # consenti N posizioni concorrenti
|
||||
exclude_macro_severity: ["high"]
|
||||
exclude_macro_countries: ["US", "EU"]
|
||||
|
||||
trend_window_days: 30
|
||||
trend_bull_threshold_pct: "0.05"
|
||||
trend_bear_threshold_pct: "-0.05"
|
||||
funding_bull_threshold_annualized: "0.20"
|
||||
funding_bear_threshold_annualized: "-0.20"
|
||||
iron_condor_dvol_min: "55"
|
||||
iron_condor_adx_max: "20"
|
||||
iron_condor_trend_neutral_band_pct: "0.05"
|
||||
|
||||
# Filtri quant invariati: l'edge della strategia E' qui, non
|
||||
# serve allentarli per "guadagnare di più" — anzi sarebbe
|
||||
# controproducente.
|
||||
dealer_gamma_min: "0"
|
||||
dealer_gamma_filter_enabled: true
|
||||
liquidation_filter_enabled: true
|
||||
# IV richness gate (§2.9). In Aggressiva il gate è in modalità
|
||||
# adattiva: la soglia è il P25 rolling sui market_snapshots
|
||||
# (warmup: usa la storia disponibile finché < 30g, poi finestra 30g
|
||||
# fino a 60g, poi fissa 60g). `iv_minus_rv_min: 0` = floor zero,
|
||||
# lascia decidere al P25.
|
||||
iv_minus_rv_filter_enabled: true
|
||||
iv_minus_rv_adaptive_enabled: true
|
||||
iv_minus_rv_min: "0"
|
||||
iv_minus_rv_percentile: "0.25"
|
||||
iv_minus_rv_window_target_days: 60
|
||||
iv_minus_rv_window_min_days: 30
|
||||
|
||||
# Vol-of-Vol guard: blocca entry su shift bruschi DVOL.
|
||||
vol_of_vol_guard_enabled: true
|
||||
vol_of_vol_threshold_pt: "5"
|
||||
vol_of_vol_lookback_hours: 24
|
||||
|
||||
|
||||
structure:
|
||||
dte_target: 18
|
||||
dte_min: 14
|
||||
dte_max: 21
|
||||
|
||||
short_strike:
|
||||
delta_target: "0.12"
|
||||
delta_min: "0.10"
|
||||
delta_max: "0.15"
|
||||
distance_otm_pct_min: "0.15"
|
||||
distance_otm_pct_max: "0.25"
|
||||
|
||||
# §3.2 (A): step-function delta-target per regime DVOL.
|
||||
# DVOL bassa (≤50) → più premio; alta (>70) → più safety.
|
||||
delta_by_dvol:
|
||||
- {dvol_under: "50", delta_target: "0.15", delta_min: "0.13", delta_max: "0.17"}
|
||||
- {dvol_under: "70", delta_target: "0.12", delta_min: "0.10", delta_max: "0.15"}
|
||||
- {dvol_under: "90", delta_target: "0.10", delta_min: "0.08", delta_max: "0.12"}
|
||||
|
||||
spread_width:
|
||||
target_pct_of_spot: "0.04"
|
||||
min_pct_of_spot: "0.03"
|
||||
max_pct_of_spot: "0.05"
|
||||
|
||||
credit_to_width_ratio_min: "0.30"
|
||||
|
||||
liquidity:
|
||||
open_interest_min: 100
|
||||
volume_24h_min: 20
|
||||
bid_ask_spread_pct_max: "0.15"
|
||||
book_depth_top3_min: 5
|
||||
slippage_pct_of_credit_max: "0.08"
|
||||
|
||||
sizing:
|
||||
kelly_fraction: "0.13" # disciplina Kelly invariata
|
||||
|
||||
# Le tre leve dominanti:
|
||||
cap_per_trade_eur: "800" # era 200 → 4×
|
||||
cap_aggregate_open_eur: "6400" # 8 posizioni concorrenti × 800 cap_per_trade (entry daily)
|
||||
max_concurrent_positions: 8 # era 2 (entry daily × DTE 14-21 × pass-rate)
|
||||
max_contracts_per_trade: 16 # era 4 → 4×
|
||||
|
||||
dvol_adjustment:
|
||||
- {dvol_under: "45", multiplier: "1.00"}
|
||||
- {dvol_under: "60", multiplier: "0.85"}
|
||||
- {dvol_under: "80", multiplier: "0.65"}
|
||||
dvol_no_entry_threshold: "80"
|
||||
|
||||
exit:
|
||||
profit_take_pct_of_credit: "0.50"
|
||||
stop_loss_mark_x_credit: "2.50"
|
||||
vol_stop_dvol_increase: "10"
|
||||
time_stop_dte_remaining: 7
|
||||
time_stop_skip_if_close_to_profit_pct: "0.70"
|
||||
delta_breach_threshold: "0.30"
|
||||
adverse_move_4h_pct: "0.05"
|
||||
|
||||
# §7-bis (D): vol-harvest abilitato a 15 punti vol di crollo.
|
||||
vol_harvest_dvol_decrease: "15"
|
||||
|
||||
# §7.1bis (C): scala graduata di profit-take. Pipeline runtime
|
||||
# non ancora attiva; tenuta vuota fino al merge della
|
||||
# partial-close pipeline.
|
||||
profit_take_partial_levels: []
|
||||
|
||||
monitor_cron: "0 2,14 * * *"
|
||||
user_confirmation_timeout_min: 30
|
||||
|
||||
escalate_on_timeout:
|
||||
- "CLOSE_STOP"
|
||||
- "CLOSE_VOL"
|
||||
- "CLOSE_DELTA"
|
||||
|
||||
# §7-bis (F): circuit breaker abilitato. Soglia 15% (più tollerante
|
||||
# del default conservativo perché la size aggressiva ha volatilità
|
||||
# attesa più alta).
|
||||
auto_pause:
|
||||
enabled: true
|
||||
lookback_trades: 5
|
||||
max_drawdown_pct: "0.15"
|
||||
pause_days: 14
|
||||
|
||||
execution:
|
||||
environment: "testnet"
|
||||
eur_to_usd: "1.075"
|
||||
combo_only: true
|
||||
initial_limit: "mid"
|
||||
reprice_step_ticks: 1
|
||||
reprice_max_steps: 3
|
||||
reprice_max_steps_urgent: 5
|
||||
order_tif: "GTC"
|
||||
order_expiry_min: 30
|
||||
ack_timeout_s: 300
|
||||
|
||||
monitoring:
|
||||
health_check_interval_s: 300
|
||||
health_failures_before_kill: 3
|
||||
health_failures_before_restart: 5
|
||||
|
||||
daily_digest_cron: "0 8 * * *"
|
||||
monthly_report_cron: "0 12 1 * *"
|
||||
|
||||
storage:
|
||||
sqlite_path: "data/state.sqlite"
|
||||
log_path: "data/log/"
|
||||
log_retention_days: 365
|
||||
backup_path: "data/backups/"
|
||||
backup_retention_days: 30
|
||||
|
||||
mcp:
|
||||
config_file: "~/.config/cerbero-suite/mcp.json"
|
||||
call_timeout_s: 8
|
||||
retry_max: 3
|
||||
retry_base_delay_s: 1
|
||||
|
||||
required_versions:
|
||||
cerbero-deribit: "^2.0.0"
|
||||
cerbero-hyperliquid: "^1.5.0"
|
||||
cerbero-memory: "^4.0.0"
|
||||
cerbero-portfolio: "^1.2.0"
|
||||
cerbero-macro: "^1.0.0"
|
||||
cerbero-sentiment: "^1.0.0"
|
||||
cerbero-telegram: "^1.0.0"
|
||||
cerbero-brain-bridge: "^1.0.0"
|
||||
|
||||
telegram:
|
||||
parse_mode: "MarkdownV2"
|
||||
confirmation_timeout_min: 60
|
||||
exit_confirmation_timeout_min: 30
|
||||
backup_channel_on_critical: true
|
||||
|
||||
kelly_recalibration:
|
||||
lookback_days: 365
|
||||
min_sample_low_confidence: 30
|
||||
min_sample_high_confidence: 100
|
||||
weight_when_medium_confidence: "0.50"
|
||||
@@ -0,0 +1,177 @@
|
||||
# strategy.conservativa.yaml — Cerbero Bite, profilo CONSERVATIVO
|
||||
#
|
||||
# Profilo "premio sopra T-bill, drawdown contenuto, complessità minima".
|
||||
# È identico per regole alla golden config v1.0.0; serve come ancora di
|
||||
# riferimento per il confronto con `strategy.aggressiva.yaml`.
|
||||
#
|
||||
# Caratteristiche operative attese:
|
||||
# * Cap per trade 200 EUR (~215 USD)
|
||||
# * Max 1 posizione concorrente
|
||||
# * P/L stimato: +1.5% / +5% APR sul capitale totale
|
||||
# * Drawdown atteso: 10–20% del capitale impiegato in streak
|
||||
# * Adatto a: parcheggio capitale, premio modesto, niente sorprese
|
||||
#
|
||||
# Ricorda: dopo ogni edit, rigenerare il config_hash con
|
||||
# cerbero-bite config hash --file strategy.conservativa.yaml
|
||||
# e bumpare config_version.
|
||||
|
||||
config_version: "1.4.0-conservativa"
|
||||
config_hash: "b6af7b041508a67846eba5985e27e655526fe89105653f86bc88b8a4a437ac3a"
|
||||
last_review: "2026-04-26"
|
||||
last_reviewer: "Adriano"
|
||||
|
||||
asset:
|
||||
symbol: "ETH"
|
||||
exchange: "deribit"
|
||||
|
||||
entry:
|
||||
cron: "0 14 * * *"
|
||||
skip_holidays_country: "IT"
|
||||
|
||||
capital_min_usd: "720"
|
||||
dvol_min: "35"
|
||||
dvol_max: "90"
|
||||
funding_perp_abs_max_annualized: "0.80"
|
||||
eth_holdings_pct_max: "0.30"
|
||||
no_position_concurrent: true
|
||||
exclude_macro_severity: ["high"]
|
||||
exclude_macro_countries: ["US", "EU"]
|
||||
|
||||
trend_window_days: 30
|
||||
trend_bull_threshold_pct: "0.05"
|
||||
trend_bear_threshold_pct: "-0.05"
|
||||
funding_bull_threshold_annualized: "0.20"
|
||||
funding_bear_threshold_annualized: "-0.20"
|
||||
iron_condor_dvol_min: "55"
|
||||
iron_condor_adx_max: "20"
|
||||
iron_condor_trend_neutral_band_pct: "0.05"
|
||||
|
||||
dealer_gamma_min: "0"
|
||||
dealer_gamma_filter_enabled: true
|
||||
liquidation_filter_enabled: true
|
||||
# IV richness gate (§2.9). Disabilitato di default.
|
||||
iv_minus_rv_min: "0"
|
||||
iv_minus_rv_filter_enabled: false
|
||||
|
||||
|
||||
structure:
|
||||
dte_target: 18
|
||||
dte_min: 14
|
||||
dte_max: 21
|
||||
|
||||
short_strike:
|
||||
delta_target: "0.12"
|
||||
delta_min: "0.10"
|
||||
delta_max: "0.15"
|
||||
distance_otm_pct_min: "0.15"
|
||||
distance_otm_pct_max: "0.25"
|
||||
|
||||
spread_width:
|
||||
target_pct_of_spot: "0.04"
|
||||
min_pct_of_spot: "0.03"
|
||||
max_pct_of_spot: "0.05"
|
||||
|
||||
credit_to_width_ratio_min: "0.30"
|
||||
|
||||
liquidity:
|
||||
open_interest_min: 100
|
||||
volume_24h_min: 20
|
||||
bid_ask_spread_pct_max: "0.15"
|
||||
book_depth_top3_min: 5
|
||||
slippage_pct_of_credit_max: "0.08"
|
||||
|
||||
sizing:
|
||||
kelly_fraction: "0.13"
|
||||
|
||||
cap_per_trade_eur: "200"
|
||||
cap_aggregate_open_eur: "1000" # 3 posizioni × ~200 + headroom (entry daily, profilo conservativo)
|
||||
max_concurrent_positions: 3
|
||||
|
||||
max_contracts_per_trade: 4
|
||||
|
||||
dvol_adjustment:
|
||||
- {dvol_under: "45", multiplier: "1.00"}
|
||||
- {dvol_under: "60", multiplier: "0.85"}
|
||||
- {dvol_under: "80", multiplier: "0.65"}
|
||||
dvol_no_entry_threshold: "80"
|
||||
|
||||
exit:
|
||||
profit_take_pct_of_credit: "0.50"
|
||||
stop_loss_mark_x_credit: "2.50"
|
||||
vol_stop_dvol_increase: "10"
|
||||
time_stop_dte_remaining: 7
|
||||
time_stop_skip_if_close_to_profit_pct: "0.70"
|
||||
delta_breach_threshold: "0.30"
|
||||
adverse_move_4h_pct: "0.05"
|
||||
|
||||
vol_harvest_dvol_decrease: "0"
|
||||
profit_take_partial_levels: []
|
||||
|
||||
monitor_cron: "0 2,14 * * *"
|
||||
user_confirmation_timeout_min: 30
|
||||
|
||||
escalate_on_timeout:
|
||||
- "CLOSE_STOP"
|
||||
- "CLOSE_VOL"
|
||||
- "CLOSE_DELTA"
|
||||
|
||||
auto_pause:
|
||||
enabled: false
|
||||
lookback_trades: 5
|
||||
max_drawdown_pct: "0.10"
|
||||
pause_days: 14
|
||||
|
||||
execution:
|
||||
environment: "testnet"
|
||||
eur_to_usd: "1.075"
|
||||
combo_only: true
|
||||
initial_limit: "mid"
|
||||
reprice_step_ticks: 1
|
||||
reprice_max_steps: 3
|
||||
reprice_max_steps_urgent: 5
|
||||
order_tif: "GTC"
|
||||
order_expiry_min: 30
|
||||
ack_timeout_s: 300
|
||||
|
||||
monitoring:
|
||||
health_check_interval_s: 300
|
||||
health_failures_before_kill: 3
|
||||
health_failures_before_restart: 5
|
||||
|
||||
daily_digest_cron: "0 8 * * *"
|
||||
monthly_report_cron: "0 12 1 * *"
|
||||
|
||||
storage:
|
||||
sqlite_path: "data/state.sqlite"
|
||||
log_path: "data/log/"
|
||||
log_retention_days: 365
|
||||
backup_path: "data/backups/"
|
||||
backup_retention_days: 30
|
||||
|
||||
mcp:
|
||||
config_file: "~/.config/cerbero-suite/mcp.json"
|
||||
call_timeout_s: 8
|
||||
retry_max: 3
|
||||
retry_base_delay_s: 1
|
||||
|
||||
required_versions:
|
||||
cerbero-deribit: "^2.0.0"
|
||||
cerbero-hyperliquid: "^1.5.0"
|
||||
cerbero-memory: "^4.0.0"
|
||||
cerbero-portfolio: "^1.2.0"
|
||||
cerbero-macro: "^1.0.0"
|
||||
cerbero-sentiment: "^1.0.0"
|
||||
cerbero-telegram: "^1.0.0"
|
||||
cerbero-brain-bridge: "^1.0.0"
|
||||
|
||||
telegram:
|
||||
parse_mode: "MarkdownV2"
|
||||
confirmation_timeout_min: 60
|
||||
exit_confirmation_timeout_min: 30
|
||||
backup_channel_on_critical: true
|
||||
|
||||
kelly_recalibration:
|
||||
lookback_days: 365
|
||||
min_sample_low_confidence: 30
|
||||
min_sample_high_confidence: 100
|
||||
weight_when_medium_confidence: "0.50"
|
||||
+23
-4
@@ -6,8 +6,8 @@
|
||||
# config hash), and lands as a separate commit with the motivation in
|
||||
# the commit message.
|
||||
|
||||
config_version: "1.0.0"
|
||||
config_hash: "4c2be4c51c849ed58fa22ec2b302016c453894dd0964b6d05445ab1b723e2d10"
|
||||
config_version: "1.4.0"
|
||||
config_hash: "22182814216190331e0b69b3bc99493e6d69cc813f7ed937394986eecc1f5d11"
|
||||
last_review: "2026-04-26"
|
||||
last_reviewer: "Adriano"
|
||||
|
||||
@@ -16,7 +16,7 @@ asset:
|
||||
exchange: "deribit"
|
||||
|
||||
entry:
|
||||
cron: "0 14 * * MON"
|
||||
cron: "0 14 * * *"
|
||||
skip_holidays_country: "IT"
|
||||
|
||||
capital_min_usd: "720"
|
||||
@@ -45,6 +45,10 @@ entry:
|
||||
dealer_gamma_min: "0"
|
||||
dealer_gamma_filter_enabled: true
|
||||
liquidation_filter_enabled: true
|
||||
# IV richness gate (§2.9). Disabilitato di default.
|
||||
iv_minus_rv_min: "0"
|
||||
iv_minus_rv_filter_enabled: false
|
||||
|
||||
|
||||
structure:
|
||||
dte_target: 18
|
||||
@@ -77,7 +81,7 @@ sizing:
|
||||
|
||||
cap_per_trade_eur: "200"
|
||||
cap_aggregate_open_eur: "1000"
|
||||
max_concurrent_positions: 1
|
||||
max_concurrent_positions: 5
|
||||
|
||||
max_contracts_per_trade: 4
|
||||
|
||||
@@ -96,6 +100,13 @@ exit:
|
||||
delta_breach_threshold: "0.30"
|
||||
adverse_move_4h_pct: "0.05"
|
||||
|
||||
# §7-bis (D): vol-collapse harvest. 0 = disabilitato.
|
||||
vol_harvest_dvol_decrease: "0"
|
||||
|
||||
# §7.1bis (C): scala graduata di profit-take. Vuoto = chiusura
|
||||
# atomica. Pipeline runtime non ancora attiva (hook futuro).
|
||||
profit_take_partial_levels: []
|
||||
|
||||
monitor_cron: "0 2,14 * * *"
|
||||
user_confirmation_timeout_min: 30
|
||||
|
||||
@@ -104,6 +115,14 @@ exit:
|
||||
- "CLOSE_VOL"
|
||||
- "CLOSE_DELTA"
|
||||
|
||||
# §7-bis (F): circuit breaker su drawdown rolling. Disabilitato di
|
||||
# default — abilitarlo solo dopo abbastanza posizioni chiuse.
|
||||
auto_pause:
|
||||
enabled: false
|
||||
lookback_trades: 5
|
||||
max_drawdown_pct: "0.10"
|
||||
pause_days: 14
|
||||
|
||||
execution:
|
||||
environment: "testnet" # testnet|mainnet — kill switch on broker mismatch
|
||||
eur_to_usd: "1.075" # default FX rate for sizing engine; override at boot
|
||||
|
||||
@@ -71,11 +71,6 @@ def _wire_boot_dependencies(httpx_mock: HTTPXMock) -> None:
|
||||
json={"asset": "ETH", "current_funding_rate": 0.0001},
|
||||
is_reusable=True,
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
url="http://mcp-portfolio:9018/tools/get_total_portfolio_value",
|
||||
json={"total_value_eur": 1000.0},
|
||||
is_reusable=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -115,11 +110,5 @@ async def test_boot_detects_audit_truncation(
|
||||
orch = _build(tmp_path)
|
||||
|
||||
_wire_boot_dependencies(httpx_mock)
|
||||
httpx_mock.add_response(
|
||||
url="http://mcp-telegram:9017/tools/notify_system_error",
|
||||
json={"ok": True},
|
||||
is_reusable=True,
|
||||
)
|
||||
|
||||
await orch.boot()
|
||||
assert orch.context.kill_switch.is_armed() is True
|
||||
|
||||
@@ -118,6 +118,16 @@ def _wire_market_snapshot(
|
||||
},
|
||||
is_reusable=True,
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
url="http://mcp-deribit:9011/tools/get_realized_vol",
|
||||
json={
|
||||
"currency": "ETH",
|
||||
"realized_vol_pct": {"14d": 30.0, "30d": 30.0},
|
||||
"iv_current_pct": 38.0,
|
||||
"iv_minus_rv_pct": {"14d": 8.0, "30d": 8.0},
|
||||
},
|
||||
is_reusable=True,
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
url="http://mcp-sentiment:9014/tools/get_liquidation_heatmap",
|
||||
json={
|
||||
@@ -154,18 +164,39 @@ def _wire_market_snapshot(
|
||||
json={"events": macro_events or []},
|
||||
is_reusable=True,
|
||||
)
|
||||
# In-process portfolio aggregator: wire the underlying exchange and
|
||||
# macro endpoints so total_equity_eur and asset_pct_of_portfolio
|
||||
# produce the requested ``portfolio_eur`` and ``eth_pct``.
|
||||
# FX rate fixed at 1.0 → EUR amount equals USD amount in tests.
|
||||
portfolio_eur_f = float(portfolio_eur)
|
||||
httpx_mock.add_response(
|
||||
url="http://mcp-portfolio:9018/tools/get_holdings",
|
||||
url="http://mcp-macro:9013/tools/get_asset_price",
|
||||
json={"ticker": "EURUSD", "price": 1.0},
|
||||
is_reusable=True,
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
url="http://mcp-deribit:9011/tools/get_account_summary",
|
||||
json={"equity": portfolio_eur_f, "currency": "USDC"},
|
||||
is_reusable=True,
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
url="http://mcp-deribit:9011/tools/get_positions",
|
||||
json=[
|
||||
{"ticker": "AAPL", "current_value_eur": portfolio_eur_f * (1 - eth_pct)},
|
||||
{"ticker": "ETH-USD", "current_value_eur": portfolio_eur_f * eth_pct},
|
||||
{
|
||||
"instrument_name": "ETH-15MAY26-2475-P",
|
||||
"notional_usd": portfolio_eur_f * eth_pct,
|
||||
}
|
||||
],
|
||||
is_reusable=True,
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
url="http://mcp-portfolio:9018/tools/get_total_portfolio_value",
|
||||
json={"total_value_eur": portfolio_eur_f},
|
||||
url="http://mcp-hyperliquid:9012/tools/get_account_summary",
|
||||
json={"equity": 0.0},
|
||||
is_reusable=True,
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
url="http://mcp-hyperliquid:9012/tools/get_positions",
|
||||
json=[],
|
||||
is_reusable=True,
|
||||
)
|
||||
|
||||
@@ -262,11 +293,12 @@ def _wire_combo_order(
|
||||
|
||||
|
||||
def _wire_telegram_notify_position_opened(httpx_mock: HTTPXMock) -> None:
|
||||
httpx_mock.add_response(
|
||||
url="http://mcp-telegram:9017/tools/notify_position_opened",
|
||||
json={"ok": True},
|
||||
is_reusable=True,
|
||||
)
|
||||
"""No-op: Telegram is now an in-process client with disabled mode in tests.
|
||||
|
||||
Kept for call-site compatibility; the function used to register an MCP
|
||||
notify mock but post-refactor there is no HTTP endpoint to mock when
|
||||
the bot has no Telegram credentials configured.
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -355,11 +387,6 @@ async def test_below_capital_minimum_returns_no_entry(
|
||||
now: datetime,
|
||||
httpx_mock: HTTPXMock,
|
||||
) -> None:
|
||||
httpx_mock.add_response(
|
||||
url="http://mcp-telegram:9017/tools/notify",
|
||||
json={"ok": True},
|
||||
is_reusable=True,
|
||||
)
|
||||
# 500 EUR × 1.075 = 537 USD < 720 cfg minimum
|
||||
_wire_market_snapshot(httpx_mock, portfolio_eur=500.0)
|
||||
ctx = _ctx(cfg, runtime_paths, now)
|
||||
@@ -377,11 +404,6 @@ async def test_macro_event_within_dte_blocks_entry(
|
||||
now: datetime,
|
||||
httpx_mock: HTTPXMock,
|
||||
) -> None:
|
||||
httpx_mock.add_response(
|
||||
url="http://mcp-telegram:9017/tools/notify",
|
||||
json={"ok": True},
|
||||
is_reusable=True,
|
||||
)
|
||||
macro_events = [
|
||||
{
|
||||
"name": "FOMC",
|
||||
@@ -406,11 +428,6 @@ async def test_no_bias_returns_no_entry(
|
||||
now: datetime,
|
||||
httpx_mock: HTTPXMock,
|
||||
) -> None:
|
||||
httpx_mock.add_response(
|
||||
url="http://mcp-telegram:9017/tools/notify",
|
||||
json={"ok": True},
|
||||
is_reusable=True,
|
||||
)
|
||||
# Funding cross neutral (=0) and DVOL 40 → no IC, no directional;
|
||||
# entry validates clean otherwise.
|
||||
_wire_market_snapshot(
|
||||
@@ -507,11 +524,6 @@ async def test_broker_reject_marks_position_cancelled(
|
||||
},
|
||||
is_reusable=True,
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
url="http://mcp-telegram:9017/tools/notify_alert",
|
||||
json={"ok": True},
|
||||
is_reusable=True,
|
||||
)
|
||||
bull_cfg = golden_config(
|
||||
entry=type(cfg.entry)(
|
||||
**{**cfg.entry.model_dump(), "trend_bull_threshold_pct": Decimal("0")}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""End-to-end test del gate IV-RV adattivo + Vol-of-Vol guard via Repository.
|
||||
|
||||
Verifica che la nuova API distinct-days componga correttamente repository
|
||||
helpers + ``compute_adaptive_threshold``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from cerbero_bite.core.adaptive_threshold import compute_adaptive_threshold
|
||||
from cerbero_bite.state.db import connect, run_migrations
|
||||
from cerbero_bite.state.models import MarketSnapshotRecord
|
||||
from cerbero_bite.state.repository import Repository
|
||||
|
||||
|
||||
def _seed_history(
|
||||
conn,
|
||||
repo: Repository,
|
||||
asset: str,
|
||||
base: datetime,
|
||||
n_ticks: int,
|
||||
iv_rv_value: Decimal,
|
||||
dvol_value: Decimal,
|
||||
) -> None:
|
||||
for i in range(n_ticks):
|
||||
repo.record_market_snapshot(
|
||||
conn,
|
||||
MarketSnapshotRecord(
|
||||
timestamp=base + timedelta(minutes=15 * i),
|
||||
asset=asset,
|
||||
spot=Decimal("2000"),
|
||||
dvol=dvol_value,
|
||||
realized_vol_30d=Decimal("48"),
|
||||
iv_minus_rv=iv_rv_value,
|
||||
funding_perp_annualized=Decimal("0"),
|
||||
funding_cross_annualized=Decimal("0"),
|
||||
dealer_net_gamma=Decimal("0"),
|
||||
gamma_flip_level=None,
|
||||
oi_delta_pct_4h=None,
|
||||
liquidation_long_risk="low",
|
||||
liquidation_short_risk="low",
|
||||
macro_days_to_event=None,
|
||||
fetch_ok=True,
|
||||
fetch_errors_json=None,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_30d(tmp_path):
|
||||
"""30 giorni di storia con IV-RV bimodale: prima metà 1.0, seconda metà 5.0."""
|
||||
db_path = tmp_path / "e2e.sqlite"
|
||||
conn = connect(str(db_path))
|
||||
run_migrations(conn)
|
||||
repo = Repository()
|
||||
base = datetime(2026, 4, 1, 0, 0, tzinfo=UTC)
|
||||
_seed_history(conn, repo, "ETH", base, 1440, Decimal("1.0"), Decimal("50"))
|
||||
_seed_history(
|
||||
conn,
|
||||
repo,
|
||||
"ETH",
|
||||
base + timedelta(days=15),
|
||||
1440,
|
||||
Decimal("5.0"),
|
||||
Decimal("50"),
|
||||
)
|
||||
return conn, repo
|
||||
|
||||
|
||||
def test_distinct_days_count_matches_calendar_days(db_30d) -> None:
|
||||
"""30 giorni di calendario seedati → COUNT DISTINCT = 30."""
|
||||
conn, repo = db_30d
|
||||
n = repo.count_iv_rv_distinct_days(
|
||||
conn,
|
||||
asset="ETH",
|
||||
max_days=60,
|
||||
as_of=datetime(2026, 5, 1, 0, 0, tzinfo=UTC),
|
||||
)
|
||||
assert n == 30
|
||||
|
||||
|
||||
def test_window_values_returned_for_full_history(db_30d) -> None:
|
||||
conn, repo = db_30d
|
||||
values = repo.iv_rv_values_for_window(
|
||||
conn,
|
||||
asset="ETH",
|
||||
window_days=60,
|
||||
as_of=datetime(2026, 5, 1, 0, 0, tzinfo=UTC),
|
||||
)
|
||||
assert len(values) == 2880
|
||||
# Bimodale: 1440 valori 1.0 e 1440 valori 5.0
|
||||
assert sum(1 for v in values if v == Decimal("1.0")) == 1440
|
||||
assert sum(1 for v in values if v == Decimal("5.0")) == 1440
|
||||
|
||||
|
||||
def test_p25_of_bimodal_history_picks_low_regime(db_30d) -> None:
|
||||
"""Comporre repository + adaptive_threshold come fa entry_cycle."""
|
||||
conn, repo = db_30d
|
||||
as_of = datetime(2026, 5, 1, 0, 0, tzinfo=UTC)
|
||||
n_days = repo.count_iv_rv_distinct_days(
|
||||
conn, asset="ETH", max_days=60, as_of=as_of
|
||||
)
|
||||
values = repo.iv_rv_values_for_window(
|
||||
conn, asset="ETH", window_days=60, as_of=as_of
|
||||
)
|
||||
threshold = compute_adaptive_threshold(
|
||||
history=values,
|
||||
n_days=n_days,
|
||||
percentile=Decimal("0.25"),
|
||||
absolute_floor=Decimal("0"),
|
||||
)
|
||||
# P25 di 2880 valori bimodali: 1440 ×1.0, 1440 ×5.0 → soglia = 1.0
|
||||
assert threshold == Decimal("1.0")
|
||||
|
||||
|
||||
def test_dvol_lookback_within_tolerance(db_30d) -> None:
|
||||
conn, repo = db_30d
|
||||
base = datetime(2026, 4, 1, 0, 0, tzinfo=UTC)
|
||||
out = repo.dvol_lookback(conn, asset="ETH", reference=base + timedelta(hours=24))
|
||||
assert out == Decimal("50")
|
||||
|
||||
|
||||
def test_dvol_lookback_returns_none_outside_tolerance(db_30d) -> None:
|
||||
conn, repo = db_30d
|
||||
out = repo.dvol_lookback(
|
||||
conn,
|
||||
asset="ETH",
|
||||
reference=datetime(2025, 1, 1, tzinfo=UTC),
|
||||
tolerance_minutes=15,
|
||||
)
|
||||
assert out is None
|
||||
@@ -60,11 +60,6 @@ def _wire_all_ok(httpx_mock: HTTPXMock) -> None:
|
||||
json={"asset": "ETH", "current_funding_rate": 0.0001},
|
||||
is_reusable=True,
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
url="http://mcp-portfolio:9018/tools/get_total_portfolio_value",
|
||||
json={"total_value_eur": 1000.0},
|
||||
is_reusable=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -112,11 +107,6 @@ async def test_environment_mismatch_counts_as_failure(
|
||||
json={"asset": "ETH", "current_funding_rate": 0.0001},
|
||||
is_reusable=True,
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
url="http://mcp-portfolio:9018/tools/get_total_portfolio_value",
|
||||
json={"total_value_eur": 1000.0},
|
||||
is_reusable=True,
|
||||
)
|
||||
res = await hc.run()
|
||||
assert res.state == "degraded"
|
||||
assert any("environment mismatch" in r for _s, r in res.failures)
|
||||
@@ -149,17 +139,6 @@ async def test_three_consecutive_failures_arm_kill_switch(
|
||||
json={"asset": "ETH", "current_funding_rate": 0.0001},
|
||||
is_reusable=True,
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
url="http://mcp-portfolio:9018/tools/get_total_portfolio_value",
|
||||
json={"total_value_eur": 1000.0},
|
||||
is_reusable=True,
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
url="http://mcp-telegram:9017/tools/notify_alert",
|
||||
json={"ok": True},
|
||||
is_reusable=True,
|
||||
)
|
||||
|
||||
for _ in range(2):
|
||||
await hc.run()
|
||||
assert ctx.kill_switch.is_armed() is False
|
||||
@@ -197,11 +176,6 @@ async def test_recovered_run_resets_counter(
|
||||
json={"asset": "ETH", "current_funding_rate": 0.0001},
|
||||
is_reusable=True,
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
url="http://mcp-portfolio:9018/tools/get_total_portfolio_value",
|
||||
json={"total_value_eur": 1000.0},
|
||||
is_reusable=True,
|
||||
)
|
||||
res = await hc.run()
|
||||
assert res.state == "degraded"
|
||||
assert res.consecutive_failures == 1
|
||||
|
||||
@@ -231,11 +231,6 @@ async def test_monitor_closes_position_on_profit_take(
|
||||
},
|
||||
is_reusable=True,
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
url="http://mcp-telegram:9017/tools/notify_position_closed",
|
||||
json={"ok": True},
|
||||
is_reusable=True,
|
||||
)
|
||||
|
||||
res = await run_monitor_cycle(ctx, now=now)
|
||||
assert len(res.outcomes) == 1
|
||||
@@ -296,11 +291,6 @@ async def test_monitor_uses_dvol_history_for_return_4h(
|
||||
},
|
||||
is_reusable=True,
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
url="http://mcp-telegram:9017/tools/notify_position_closed",
|
||||
json={"ok": True},
|
||||
is_reusable=True,
|
||||
)
|
||||
|
||||
res = await run_monitor_cycle(ctx, now=now)
|
||||
assert res.outcomes[0].action == "CLOSE_AVERSE"
|
||||
|
||||
@@ -11,6 +11,7 @@ from pytest_httpx import HTTPXMock
|
||||
|
||||
from cerbero_bite.config import golden_config
|
||||
from cerbero_bite.config.mcp_endpoints import load_endpoints
|
||||
from cerbero_bite.config.runtime_flags import RuntimeFlags
|
||||
from cerbero_bite.runtime import Orchestrator
|
||||
from cerbero_bite.runtime.dependencies import build_runtime
|
||||
|
||||
@@ -56,14 +57,14 @@ def _wire_health_probes(httpx_mock: HTTPXMock) -> None:
|
||||
json={"asset": "ETH", "current_funding_rate": 0.0001},
|
||||
is_reusable=True,
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
url="http://mcp-portfolio:9018/tools/get_total_portfolio_value",
|
||||
json={"total_value_eur": 1000.0},
|
||||
is_reusable=True,
|
||||
)
|
||||
|
||||
|
||||
def _build_orch(tmp_path: Path, *, expected: str = "testnet") -> Orchestrator:
|
||||
def _build_orch(
|
||||
tmp_path: Path,
|
||||
*,
|
||||
expected: str = "testnet",
|
||||
flags: RuntimeFlags | None = None,
|
||||
) -> Orchestrator:
|
||||
ctx = build_runtime(
|
||||
cfg=golden_config(),
|
||||
endpoints=load_endpoints(env={}),
|
||||
@@ -77,6 +78,8 @@ def _build_orch(tmp_path: Path, *, expected: str = "testnet") -> Orchestrator:
|
||||
ctx,
|
||||
expected_environment=expected, # type: ignore[arg-type]
|
||||
eur_to_usd=Decimal("1.075"),
|
||||
flags=flags
|
||||
or RuntimeFlags(data_analysis_enabled=True, strategy_enabled=True),
|
||||
)
|
||||
|
||||
|
||||
@@ -110,12 +113,6 @@ async def test_boot_arms_kill_switch_on_environment_mismatch(
|
||||
json=[],
|
||||
is_reusable=True,
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
url="http://mcp-telegram:9017/tools/notify_system_error",
|
||||
json={"ok": True},
|
||||
is_reusable=True,
|
||||
)
|
||||
|
||||
orch = _build_orch(tmp_path, expected="testnet")
|
||||
await orch.boot()
|
||||
assert orch.context.kill_switch.is_armed() is True
|
||||
@@ -125,4 +122,50 @@ def test_install_scheduler_registers_canonical_jobs(tmp_path: Path) -> None:
|
||||
orch = _build_orch(tmp_path)
|
||||
sched = orch.install_scheduler()
|
||||
job_ids = {j.id for j in sched.get_jobs()}
|
||||
assert job_ids == {"entry", "monitor", "health", "backup"}
|
||||
assert job_ids == {
|
||||
"entry",
|
||||
"monitor",
|
||||
"health",
|
||||
"backup",
|
||||
"manual_actions",
|
||||
"market_snapshot",
|
||||
"option_chain_snapshot",
|
||||
}
|
||||
|
||||
|
||||
def test_install_scheduler_skips_strategy_jobs_when_disabled(tmp_path: Path) -> None:
|
||||
orch = _build_orch(
|
||||
tmp_path,
|
||||
flags=RuntimeFlags(data_analysis_enabled=True, strategy_enabled=False),
|
||||
)
|
||||
sched = orch.install_scheduler()
|
||||
job_ids = {j.id for j in sched.get_jobs()}
|
||||
assert "entry" not in job_ids
|
||||
assert "monitor" not in job_ids
|
||||
# data analysis stays on, plus the always-on infra jobs.
|
||||
assert {"health", "backup", "manual_actions", "market_snapshot"}.issubset(job_ids)
|
||||
|
||||
|
||||
def test_install_scheduler_skips_market_snapshot_when_data_analysis_off(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
orch = _build_orch(
|
||||
tmp_path,
|
||||
flags=RuntimeFlags(data_analysis_enabled=False, strategy_enabled=True),
|
||||
)
|
||||
sched = orch.install_scheduler()
|
||||
job_ids = {j.id for j in sched.get_jobs()}
|
||||
assert "market_snapshot" not in job_ids
|
||||
assert {"entry", "monitor", "health", "backup", "manual_actions"}.issubset(
|
||||
job_ids
|
||||
)
|
||||
|
||||
|
||||
def test_install_scheduler_analysis_only_default(tmp_path: Path) -> None:
|
||||
"""The default RuntimeFlags profile (analysis only) drops entry/monitor."""
|
||||
orch = _build_orch(tmp_path, flags=RuntimeFlags())
|
||||
sched = orch.install_scheduler()
|
||||
job_ids = {j.id for j in sched.get_jobs()}
|
||||
assert "entry" not in job_ids
|
||||
assert "monitor" not in job_ids
|
||||
assert "market_snapshot" in job_ids
|
||||
|
||||
@@ -115,11 +115,6 @@ async def test_recovery_cancels_awaiting_fill_when_broker_lacks_legs(
|
||||
url="http://mcp-deribit:9011/tools/get_positions",
|
||||
json=[],
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
url="http://mcp-telegram:9017/tools/notify_system_error",
|
||||
json={"ok": True},
|
||||
is_reusable=True,
|
||||
)
|
||||
|
||||
await recover_state(ctx, now=_now())
|
||||
|
||||
@@ -154,11 +149,6 @@ async def test_recovery_alerts_on_open_position_missing_on_broker(
|
||||
url="http://mcp-deribit:9011/tools/get_positions",
|
||||
json=[],
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
url="http://mcp-telegram:9017/tools/notify_system_error",
|
||||
json={"ok": True},
|
||||
is_reusable=True,
|
||||
)
|
||||
|
||||
await recover_state(ctx, now=_now())
|
||||
assert ctx.kill_switch.is_armed() is True
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"""TDD per :mod:`cerbero_bite.core.adaptive_threshold`.
|
||||
|
||||
Spec: ``docs/superpowers/specs/2026-05-08-iv-rv-adaptive-gate-design.md``.
|
||||
|
||||
La funzione è una pura statistica: riceve già la finestra di valori scelta
|
||||
dal caller e il numero di giorni distinti coperti dalla storia disponibile
|
||||
(``n_days``), e restituisce ``max(percentile, floor)`` o ``None`` durante
|
||||
il warmup hard. La selezione della finestra (target_days vs min_days vs
|
||||
intera storia) è responsabilità del caller (repository + entry_cycle).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from cerbero_bite.core.adaptive_threshold import compute_adaptive_threshold
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Warmup hard: nessun giorno disponibile
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_n_days_zero_returns_none() -> None:
|
||||
"""Storia vuota o nessun giorno coperto → warmup hard."""
|
||||
out = compute_adaptive_threshold(
|
||||
history=[],
|
||||
n_days=0,
|
||||
percentile=Decimal("0.25"),
|
||||
absolute_floor=Decimal("0"),
|
||||
)
|
||||
assert out is None
|
||||
|
||||
|
||||
def test_n_days_zero_with_values_still_returns_none() -> None:
|
||||
"""Difensivo: se il caller passa n_days=0 ma valori non vuoti, warmup
|
||||
hard vince comunque (gate disabilitato)."""
|
||||
out = compute_adaptive_threshold(
|
||||
history=[Decimal("3")] * 10,
|
||||
n_days=0,
|
||||
percentile=Decimal("0.25"),
|
||||
absolute_floor=Decimal("0"),
|
||||
)
|
||||
assert out is None
|
||||
|
||||
|
||||
def test_empty_history_with_positive_n_days_returns_none() -> None:
|
||||
"""Difensivo: history vuota anche con n_days>0 → None."""
|
||||
out = compute_adaptive_threshold(
|
||||
history=[],
|
||||
n_days=5,
|
||||
percentile=Decimal("0.25"),
|
||||
absolute_floor=Decimal("0"),
|
||||
)
|
||||
assert out is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Calcolo percentile sulla finestra ricevuta
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_n_days_one_returns_percentile_of_history() -> None:
|
||||
"""Singolo giorno con tick a 15 min (96 valori): P25 standard."""
|
||||
history = [Decimal(i) / Decimal("10") for i in range(96)] # 0.0..9.5
|
||||
out = compute_adaptive_threshold(
|
||||
history=history,
|
||||
n_days=1,
|
||||
percentile=Decimal("0.25"),
|
||||
absolute_floor=Decimal("0"),
|
||||
)
|
||||
# P25 di [0.0..9.5] passo 0.1 con method='linear': k=23.75, val ≈ 2.375
|
||||
assert out is not None
|
||||
assert Decimal("2.3") < out < Decimal("2.5")
|
||||
|
||||
|
||||
def test_window_chosen_by_caller_is_used_verbatim() -> None:
|
||||
"""La funzione NON fa slicing: usa esattamente la history ricevuta."""
|
||||
history = [Decimal(i) for i in range(1, 201)] # 1..200
|
||||
out = compute_adaptive_threshold(
|
||||
history=history,
|
||||
n_days=30,
|
||||
percentile=Decimal("0.5"),
|
||||
absolute_floor=Decimal("0"),
|
||||
)
|
||||
# P50 di [1..200] = (200+1)/2 = 100.5
|
||||
assert out is not None
|
||||
assert Decimal("100") <= out <= Decimal("101")
|
||||
|
||||
|
||||
def test_mixed_cadence_window_no_special_treatment() -> None:
|
||||
"""Mix di valori (es. backfill daily + tick live) trattato come una
|
||||
distribuzione qualunque: il caller ha già scelto la finestra; la
|
||||
funzione calcola il percentile sui valori ricevuti uno-a-uno."""
|
||||
# 30 valori "daily backfill" (uno per giorno) + 96 tick "live"
|
||||
history = [Decimal("5")] * 30 + [Decimal("8")] * 96
|
||||
out = compute_adaptive_threshold(
|
||||
history=history,
|
||||
n_days=31,
|
||||
percentile=Decimal("0.25"),
|
||||
absolute_floor=Decimal("0"),
|
||||
)
|
||||
# Sorted: 30 ×5, 96 ×8. P25 a indice 0.25*125 = 31.25 → tra 5 e 8.
|
||||
# NumPy linear: sorted_v[31]=8, sorted_v[32]=8 → 8.
|
||||
# Verifica solo l'estremo superiore della famiglia di valori sorted.
|
||||
assert out is not None
|
||||
assert out in (Decimal("5"), Decimal("8"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Floor binding
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_floor_binding_overrides_low_percentile() -> None:
|
||||
history = [Decimal("0.5")] * 200
|
||||
out = compute_adaptive_threshold(
|
||||
history=history,
|
||||
n_days=30,
|
||||
percentile=Decimal("0.25"),
|
||||
absolute_floor=Decimal("3"),
|
||||
)
|
||||
assert out == Decimal("3")
|
||||
|
||||
|
||||
def test_floor_not_binding_returns_percentile() -> None:
|
||||
history = [Decimal("5")] * 200
|
||||
out = compute_adaptive_threshold(
|
||||
history=history,
|
||||
n_days=30,
|
||||
percentile=Decimal("0.25"),
|
||||
absolute_floor=Decimal("0"),
|
||||
)
|
||||
assert out == Decimal("5")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Input validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_invalid_percentile_above_one_raises() -> None:
|
||||
with pytest.raises(ValueError, match="percentile must be in"):
|
||||
compute_adaptive_threshold(
|
||||
history=[Decimal("1")] * 200,
|
||||
n_days=10,
|
||||
percentile=Decimal("1.5"),
|
||||
absolute_floor=Decimal("0"),
|
||||
)
|
||||
|
||||
|
||||
def test_invalid_percentile_negative_raises() -> None:
|
||||
with pytest.raises(ValueError, match="percentile must be in"):
|
||||
compute_adaptive_threshold(
|
||||
history=[Decimal("1")] * 200,
|
||||
n_days=10,
|
||||
percentile=Decimal("-0.1"),
|
||||
absolute_floor=Decimal("0"),
|
||||
)
|
||||
|
||||
|
||||
def test_invalid_negative_n_days_raises() -> None:
|
||||
with pytest.raises(ValueError, match="n_days must be >= 0"):
|
||||
compute_adaptive_threshold(
|
||||
history=[Decimal("1")] * 10,
|
||||
n_days=-1,
|
||||
percentile=Decimal("0.25"),
|
||||
absolute_floor=Decimal("0"),
|
||||
)
|
||||
@@ -9,13 +9,14 @@ from pathlib import Path
|
||||
import pytest
|
||||
from pytest_httpx import HTTPXMock
|
||||
|
||||
from cerbero_bite.clients._base import HttpToolClient
|
||||
from cerbero_bite.clients.telegram import TelegramClient
|
||||
from cerbero_bite.runtime.alert_manager import AlertManager, Severity
|
||||
from cerbero_bite.safety import AuditLog, iter_entries
|
||||
from cerbero_bite.safety.kill_switch import KillSwitch
|
||||
from cerbero_bite.state import Repository, connect, run_migrations, transaction
|
||||
|
||||
SEND_URL = "https://api.telegram.org/botTOK/sendMessage"
|
||||
|
||||
|
||||
def _make_alert_manager(tmp_path: Path) -> tuple[AlertManager, Path, Path, KillSwitch]:
|
||||
db_path = tmp_path / "state.sqlite"
|
||||
@@ -39,14 +40,7 @@ def _make_alert_manager(tmp_path: Path) -> tuple[AlertManager, Path, Path, KillS
|
||||
audit_log=audit,
|
||||
clock=lambda: next(times),
|
||||
)
|
||||
telegram = TelegramClient(
|
||||
HttpToolClient(
|
||||
service="telegram",
|
||||
base_url="http://mcp-telegram:9017",
|
||||
token="t",
|
||||
retry_max=1,
|
||||
)
|
||||
)
|
||||
telegram = TelegramClient(bot_token="TOK", chat_id="42")
|
||||
return AlertManager(telegram=telegram, audit_log=audit, kill_switch=ks), audit_path, db_path, ks
|
||||
|
||||
|
||||
@@ -65,17 +59,13 @@ async def test_low_emits_audit_only(tmp_path: Path, httpx_mock: HTTPXMock) -> No
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_medium_calls_telegram_notify(tmp_path: Path, httpx_mock: HTTPXMock) -> None:
|
||||
httpx_mock.add_response(
|
||||
url="http://mcp-telegram:9017/tools/notify", json={"ok": True}
|
||||
)
|
||||
httpx_mock.add_response(url=SEND_URL, json={"ok": True})
|
||||
am, audit_path, _, ks = _make_alert_manager(tmp_path)
|
||||
await am.medium(source="entry_cycle", message="snapshot delayed")
|
||||
requests = httpx_mock.get_requests()
|
||||
assert len(requests) == 1
|
||||
body = json.loads(requests[0].read())
|
||||
assert body["message"] == "[entry_cycle] snapshot delayed"
|
||||
assert body["priority"] == "high"
|
||||
assert body["tag"] == "entry_cycle"
|
||||
assert body["text"] == "[HIGH][entry_cycle] snapshot delayed"
|
||||
assert ks.is_armed() is False
|
||||
assert any(e.payload["severity"] == "medium" for e in iter_entries(audit_path))
|
||||
|
||||
@@ -84,17 +74,13 @@ async def test_medium_calls_telegram_notify(tmp_path: Path, httpx_mock: HTTPXMoc
|
||||
async def test_high_arms_kill_switch_and_calls_notify_alert(
|
||||
tmp_path: Path, httpx_mock: HTTPXMock
|
||||
) -> None:
|
||||
httpx_mock.add_response(
|
||||
url="http://mcp-telegram:9017/tools/notify_alert", json={"ok": True}
|
||||
)
|
||||
httpx_mock.add_response(url=SEND_URL, json={"ok": True})
|
||||
am, _, _, ks = _make_alert_manager(tmp_path)
|
||||
await am.high(source="health", message="3 consecutive MCP failures")
|
||||
body = json.loads(httpx_mock.get_request().read())
|
||||
assert body == {
|
||||
"source": "health",
|
||||
"message": "3 consecutive MCP failures",
|
||||
"priority": "high",
|
||||
}
|
||||
text = body["text"]
|
||||
assert "ALERT [HIGH]" in text
|
||||
assert "health" in text and "3 consecutive MCP failures" in text
|
||||
assert ks.is_armed() is True
|
||||
|
||||
|
||||
@@ -102,9 +88,7 @@ async def test_high_arms_kill_switch_and_calls_notify_alert(
|
||||
async def test_critical_arms_kill_switch_and_calls_notify_system_error(
|
||||
tmp_path: Path, httpx_mock: HTTPXMock
|
||||
) -> None:
|
||||
httpx_mock.add_response(
|
||||
url="http://mcp-telegram:9017/tools/notify_system_error", json={"ok": True}
|
||||
)
|
||||
httpx_mock.add_response(url=SEND_URL, json={"ok": True})
|
||||
am, _, _, ks = _make_alert_manager(tmp_path)
|
||||
await am.critical(
|
||||
source="audit_chain",
|
||||
@@ -112,8 +96,9 @@ async def test_critical_arms_kill_switch_and_calls_notify_system_error(
|
||||
component="safety.audit_log",
|
||||
)
|
||||
body = json.loads(httpx_mock.get_request().read())
|
||||
assert body["component"] == "safety.audit_log"
|
||||
assert body["priority"] == "critical"
|
||||
text = body["text"]
|
||||
assert "SYSTEM ERROR [CRITICAL]" in text
|
||||
assert "safety.audit_log" in text
|
||||
assert ks.is_armed() is True
|
||||
|
||||
|
||||
@@ -121,9 +106,7 @@ async def test_critical_arms_kill_switch_and_calls_notify_system_error(
|
||||
async def test_critical_when_already_armed_is_idempotent(
|
||||
tmp_path: Path, httpx_mock: HTTPXMock
|
||||
) -> None:
|
||||
httpx_mock.add_response(
|
||||
url="http://mcp-telegram:9017/tools/notify_system_error", json={"ok": True}
|
||||
)
|
||||
httpx_mock.add_response(url=SEND_URL, json={"ok": True})
|
||||
am, _, _, ks = _make_alert_manager(tmp_path)
|
||||
ks.arm(reason="prior", source="manual")
|
||||
assert ks.is_armed() is True
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"""TDD per :mod:`cerbero_bite.runtime.auto_pause` (§7-bis F)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from cerbero_bite.config.schema import AutoPauseConfig
|
||||
from cerbero_bite.runtime.auto_pause import (
|
||||
evaluate_drawdown_breach,
|
||||
is_paused,
|
||||
pause_until,
|
||||
)
|
||||
from cerbero_bite.state.models import SystemStateRecord
|
||||
|
||||
|
||||
_NOW = datetime(2026, 5, 1, 14, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def _state(**overrides: object) -> SystemStateRecord:
|
||||
base: dict[str, object] = {
|
||||
"kill_switch": 0,
|
||||
"last_health_check": _NOW,
|
||||
"config_version": "1.0.0",
|
||||
"started_at": _NOW - timedelta(hours=1),
|
||||
}
|
||||
base.update(overrides)
|
||||
return SystemStateRecord(**base) # type: ignore[arg-type]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_paused
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_is_paused_returns_false_when_state_is_none() -> None:
|
||||
status = is_paused(None, now=_NOW)
|
||||
assert status.paused is False
|
||||
|
||||
|
||||
def test_is_paused_returns_false_when_until_is_none() -> None:
|
||||
status = is_paused(_state(), now=_NOW)
|
||||
assert status.paused is False
|
||||
|
||||
|
||||
def test_is_paused_returns_true_when_until_in_future() -> None:
|
||||
status = is_paused(
|
||||
_state(auto_pause_until=_NOW + timedelta(weeks=2),
|
||||
auto_pause_reason="DD breach"),
|
||||
now=_NOW,
|
||||
)
|
||||
assert status.paused is True
|
||||
assert status.reason == "DD breach"
|
||||
|
||||
|
||||
def test_is_paused_returns_false_when_until_in_past() -> None:
|
||||
status = is_paused(
|
||||
_state(auto_pause_until=_NOW - timedelta(seconds=1)),
|
||||
now=_NOW,
|
||||
)
|
||||
assert status.paused is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pause_until
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_pause_until_adds_days() -> None:
|
||||
until = pause_until(_NOW, days=14)
|
||||
assert until == _NOW + timedelta(days=14)
|
||||
|
||||
|
||||
def test_pause_until_clamps_to_one_day_minimum() -> None:
|
||||
# days <= 0 deve cmq dare almeno 1 giorno di pausa, altrimenti
|
||||
# la cron giornaliera potrebbe scattare comunque.
|
||||
assert pause_until(_NOW, days=0) == _NOW + timedelta(days=1)
|
||||
assert pause_until(_NOW, days=-3) == _NOW + timedelta(days=1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# evaluate_drawdown_breach
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _cfg(**overrides: object) -> AutoPauseConfig:
|
||||
base: dict[str, object] = {
|
||||
"enabled": True,
|
||||
"lookback_trades": 5,
|
||||
"max_drawdown_pct": Decimal("0.10"),
|
||||
"pause_days": 14,
|
||||
}
|
||||
base.update(overrides)
|
||||
return AutoPauseConfig(**base) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_drawdown_breach_when_enabled_and_threshold_exceeded() -> None:
|
||||
decision = evaluate_drawdown_breach(
|
||||
cfg=_cfg(),
|
||||
recent_pnl_usd=[Decimal("-50"), Decimal("-60"), Decimal("-40"),
|
||||
Decimal("-30"), Decimal("-20")], # cum −200 USD
|
||||
capital_usd=Decimal("1500"),
|
||||
)
|
||||
# |200| / 1500 = 0.133 > 0.10
|
||||
assert decision.should_pause is True
|
||||
assert decision.reason is not None
|
||||
assert "rolling DD" in decision.reason
|
||||
|
||||
|
||||
def test_no_breach_when_filter_disabled() -> None:
|
||||
decision = evaluate_drawdown_breach(
|
||||
cfg=_cfg(enabled=False),
|
||||
recent_pnl_usd=[Decimal("-200")] * 5, # massacro
|
||||
capital_usd=Decimal("1500"),
|
||||
)
|
||||
assert decision.should_pause is False
|
||||
|
||||
|
||||
def test_no_breach_when_lookback_insufficient() -> None:
|
||||
decision = evaluate_drawdown_breach(
|
||||
cfg=_cfg(lookback_trades=5),
|
||||
recent_pnl_usd=[Decimal("-100")] * 3, # solo 3 trade, serve 5
|
||||
capital_usd=Decimal("1500"),
|
||||
)
|
||||
assert decision.should_pause is False
|
||||
|
||||
|
||||
def test_no_breach_when_cumulative_positive() -> None:
|
||||
# Anche con tante perdite, se la somma è positiva non scattiamo.
|
||||
decision = evaluate_drawdown_breach(
|
||||
cfg=_cfg(),
|
||||
recent_pnl_usd=[Decimal("-100"), Decimal("-50"),
|
||||
Decimal("300"), Decimal("-20"), Decimal("-10")],
|
||||
capital_usd=Decimal("1500"),
|
||||
)
|
||||
assert decision.should_pause is False
|
||||
|
||||
|
||||
def test_no_breach_when_below_threshold() -> None:
|
||||
decision = evaluate_drawdown_breach(
|
||||
cfg=_cfg(),
|
||||
recent_pnl_usd=[Decimal("-30")] * 5, # cum −150 / 1500 = 10% esatto
|
||||
capital_usd=Decimal("1500"),
|
||||
)
|
||||
# esattamente alla soglia (>=) ⇒ pausa armata
|
||||
assert decision.should_pause is True
|
||||
|
||||
|
||||
def test_no_breach_when_capital_zero_or_negative() -> None:
|
||||
decision = evaluate_drawdown_breach(
|
||||
cfg=_cfg(),
|
||||
recent_pnl_usd=[Decimal("-100")] * 5,
|
||||
capital_usd=Decimal("0"),
|
||||
)
|
||||
assert decision.should_pause is False
|
||||
@@ -0,0 +1,176 @@
|
||||
"""TDD per il backfill IV-RV (``scripts/backfill_iv_rv.py``).
|
||||
|
||||
Testa solo la parte pura (compute RV + assemblaggio record). I/O HTTP
|
||||
e SQLite restano nel main del CLI: testati manualmente al deploy.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _load_backfill_module() -> object:
|
||||
"""Load scripts/backfill_iv_rv.py as a module without polluting sys.path."""
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"_cerbero_bite_backfill_iv_rv", REPO_ROOT / "scripts" / "backfill_iv_rv.py"
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError("cannot load backfill_iv_rv module")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def mod():
|
||||
return _load_backfill_module()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# compute_rv30d_annualized
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_constant_prices_yield_zero_rv(mod) -> None:
|
||||
closes = [Decimal("100")] * 31 # 30 returns of log(1)=0
|
||||
rv = mod.compute_rv30d_annualized(closes)
|
||||
assert rv == Decimal("0")
|
||||
|
||||
|
||||
def test_too_few_closes_raises(mod) -> None:
|
||||
with pytest.raises(ValueError, match="need at least 31 closes"):
|
||||
mod.compute_rv30d_annualized([Decimal("100")] * 10)
|
||||
|
||||
|
||||
def test_monotonic_growth_yields_low_rv(mod) -> None:
|
||||
"""Crescita +1% ogni giorno: log returns costanti → stdev = 0 → RV = 0."""
|
||||
closes = [Decimal("100") * (Decimal("1.01") ** i) for i in range(31)]
|
||||
rv = mod.compute_rv30d_annualized(closes)
|
||||
# Tutti i log returns sono identici (log 1.01) → stdev zero
|
||||
assert rv == Decimal("0")
|
||||
|
||||
|
||||
def test_alternating_returns_yield_known_rv(mod) -> None:
|
||||
"""Returns alternati ±2% ogni giorno: stdev nota."""
|
||||
# closes: 100, 102, 100, 102, ... (ricorda: returns = log(c[i]/c[i-1]))
|
||||
closes = [Decimal("100")] + [
|
||||
Decimal("102") if i % 2 == 0 else Decimal("100") for i in range(30)
|
||||
]
|
||||
rv = mod.compute_rv30d_annualized(closes)
|
||||
# |log return| ~ 0.0198, stdev ≈ 0.0198 (alternano segno con media ≈ 0)
|
||||
# Annualized = 0.0198 * sqrt(365) * 100 ≈ 37.86 vol pts
|
||||
assert Decimal("36") <= rv <= Decimal("40")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_backfill_records
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_records_skips_days_without_30d_history(mod) -> None:
|
||||
"""Per i primi 30 giorni della serie spot, RV30d non è calcolabile."""
|
||||
today = date(2026, 5, 10)
|
||||
days = [today - timedelta(days=i) for i in range(45)]
|
||||
spots = {d.isoformat(): Decimal("100") for d in days}
|
||||
dvols = {d.isoformat(): Decimal("50") for d in days}
|
||||
|
||||
records = mod.build_backfill_records(
|
||||
asset="ETH",
|
||||
spots_by_day=spots,
|
||||
dvols_by_day=dvols,
|
||||
oldest_day=today - timedelta(days=40),
|
||||
)
|
||||
# Per ogni record day, servono 30 giorni precedenti di spot.
|
||||
# Lo spot più vecchio è today-44; quindi il primo giorno computabile
|
||||
# è today-44+30 = today-14. Cap a oldest_day=today-40 → window day-14..day-0.
|
||||
assert len(records) == 15 # day-14..day-0 incluso
|
||||
for r in records:
|
||||
assert r.asset == "ETH"
|
||||
assert r.fetch_ok is True
|
||||
assert r.iv_minus_rv == Decimal("50") # rv=0 con prezzi costanti
|
||||
assert r.timestamp.tzinfo == UTC
|
||||
assert r.timestamp.hour == 12
|
||||
|
||||
|
||||
def test_build_records_filters_to_requested_window(mod) -> None:
|
||||
"""oldest_day applicato come cutoff inferiore inclusivo."""
|
||||
today = date(2026, 5, 10)
|
||||
days = [today - timedelta(days=i) for i in range(45)]
|
||||
spots = {d.isoformat(): Decimal("100") for d in days}
|
||||
dvols = {d.isoformat(): Decimal("50") for d in days}
|
||||
|
||||
records = mod.build_backfill_records(
|
||||
asset="BTC",
|
||||
spots_by_day=spots,
|
||||
dvols_by_day=dvols,
|
||||
oldest_day=today - timedelta(days=5),
|
||||
)
|
||||
# day-5..day-0 → 6 record
|
||||
assert len(records) == 6
|
||||
record_days = {r.timestamp.date() for r in records}
|
||||
assert record_days == {today - timedelta(days=i) for i in range(6)}
|
||||
|
||||
|
||||
def test_build_records_skips_days_missing_dvol(mod) -> None:
|
||||
"""Se manca DVOL per un giorno della finestra, lo si salta (no record)."""
|
||||
today = date(2026, 5, 10)
|
||||
days = [today - timedelta(days=i) for i in range(45)]
|
||||
spots = {d.isoformat(): Decimal("100") for d in days}
|
||||
dvols = {
|
||||
d.isoformat(): Decimal("50")
|
||||
for d in days
|
||||
if d != today - timedelta(days=2)
|
||||
}
|
||||
records = mod.build_backfill_records(
|
||||
asset="ETH",
|
||||
spots_by_day=spots,
|
||||
dvols_by_day=dvols,
|
||||
oldest_day=today - timedelta(days=5),
|
||||
)
|
||||
record_days = {r.timestamp.date() for r in records}
|
||||
assert today - timedelta(days=2) not in record_days
|
||||
assert len(records) == 5
|
||||
|
||||
|
||||
def test_build_records_skips_days_missing_spot(mod) -> None:
|
||||
"""Se manca lo spot del giorno target, no record per quel giorno."""
|
||||
today = date(2026, 5, 10)
|
||||
days = [today - timedelta(days=i) for i in range(45)]
|
||||
spots = {
|
||||
d.isoformat(): Decimal("100")
|
||||
for d in days
|
||||
if d != today - timedelta(days=2)
|
||||
}
|
||||
dvols = {d.isoformat(): Decimal("50") for d in days}
|
||||
records = mod.build_backfill_records(
|
||||
asset="ETH",
|
||||
spots_by_day=spots,
|
||||
dvols_by_day=dvols,
|
||||
oldest_day=today - timedelta(days=5),
|
||||
)
|
||||
record_days = {r.timestamp.date() for r in records}
|
||||
assert today - timedelta(days=2) not in record_days
|
||||
|
||||
|
||||
def test_build_records_uses_noon_utc_timestamp(mod) -> None:
|
||||
today = date(2026, 5, 10)
|
||||
days = [today - timedelta(days=i) for i in range(35)]
|
||||
spots = {d.isoformat(): Decimal("100") for d in days}
|
||||
dvols = {d.isoformat(): Decimal("50") for d in days}
|
||||
records = mod.build_backfill_records(
|
||||
asset="ETH",
|
||||
spots_by_day=spots,
|
||||
dvols_by_day=dvols,
|
||||
oldest_day=today,
|
||||
)
|
||||
assert len(records) == 1
|
||||
assert records[0].timestamp == datetime(2026, 5, 10, 12, 0, tzinfo=UTC)
|
||||
@@ -0,0 +1,259 @@
|
||||
"""TDD per :mod:`cerbero_bite.core.backtest`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from cerbero_bite.config import StrategyConfig, golden_config
|
||||
from cerbero_bite.core.backtest import (
|
||||
bs_put_delta,
|
||||
bs_put_price,
|
||||
daily_picks,
|
||||
estimate_credit_eth,
|
||||
find_strike_for_delta,
|
||||
normal_cdf,
|
||||
run_backtest,
|
||||
simulate_entry_filters,
|
||||
)
|
||||
from cerbero_bite.state.models import MarketSnapshotRecord
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Black-Scholes helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_normal_cdf_known_values() -> None:
|
||||
assert normal_cdf(0.0) == pytest.approx(0.5, abs=1e-6)
|
||||
assert normal_cdf(1.0) == pytest.approx(0.8413, abs=1e-3)
|
||||
assert normal_cdf(-1.0) == pytest.approx(0.1587, abs=1e-3)
|
||||
assert normal_cdf(2.0) == pytest.approx(0.9772, abs=1e-3)
|
||||
|
||||
|
||||
def test_bs_put_price_atm_positive_and_less_than_strike() -> None:
|
||||
p = bs_put_price(spot=3000, strike=3000, t_years=18 / 365, sigma=0.50)
|
||||
assert p > 0
|
||||
assert p < 3000 # cap
|
||||
|
||||
|
||||
def test_bs_put_price_far_otm_close_to_zero() -> None:
|
||||
p = bs_put_price(spot=3000, strike=1500, t_years=18 / 365, sigma=0.50)
|
||||
assert 0 <= p < 5 # essentially zero
|
||||
|
||||
|
||||
def test_bs_put_delta_atm_around_minus_half() -> None:
|
||||
d = bs_put_delta(spot=3000, strike=3000, t_years=18 / 365, sigma=0.50)
|
||||
assert d == pytest.approx(-0.475, abs=0.05)
|
||||
|
||||
|
||||
def test_bs_put_delta_far_otm_close_to_zero() -> None:
|
||||
d = bs_put_delta(spot=3000, strike=1500, t_years=18 / 365, sigma=0.50)
|
||||
assert -0.05 < d <= 0
|
||||
|
||||
|
||||
def test_find_strike_for_delta_monotone() -> None:
|
||||
spot = 3000.0
|
||||
dvol = 50.0
|
||||
dte = 18
|
||||
s_010 = find_strike_for_delta(
|
||||
spot=spot, dvol_pct=dvol, dte_days=dte, target_delta_abs=0.10,
|
||||
)
|
||||
s_020 = find_strike_for_delta(
|
||||
spot=spot, dvol_pct=dvol, dte_days=dte, target_delta_abs=0.20,
|
||||
)
|
||||
# |Δ|=0.20 (più ITM) ⇒ strike più alto di |Δ|=0.10 (più OTM).
|
||||
assert s_020 > s_010
|
||||
# Verifica che il delta corrisponda a target ± tolleranza.
|
||||
achieved = abs(
|
||||
bs_put_delta(
|
||||
spot=spot, strike=s_020, t_years=dte / 365, sigma=dvol / 100,
|
||||
)
|
||||
)
|
||||
assert achieved == pytest.approx(0.20, abs=0.02)
|
||||
|
||||
|
||||
def test_estimate_credit_returns_positive_credit_in_normal_regime() -> None:
|
||||
credit_eth, short_k, long_k = estimate_credit_eth(
|
||||
spot=3000, dvol_pct=50, dte_days=18, width_pct=0.04, delta_target_abs=0.12,
|
||||
)
|
||||
# Sanity: credit > 0, short_k < spot, long_k = short_k - 4%×spot
|
||||
assert credit_eth > 0
|
||||
assert short_k < 3000
|
||||
assert long_k < short_k
|
||||
assert short_k - long_k == pytest.approx(0.04 * 3000, abs=1.0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Daily picks + entry filter simulation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _snap(
|
||||
*, ts: datetime,
|
||||
spot: float = 3000,
|
||||
dvol: float = 50,
|
||||
funding: float = 0.0,
|
||||
macro_d: int | None = None,
|
||||
asset: str = "ETH",
|
||||
) -> MarketSnapshotRecord:
|
||||
return MarketSnapshotRecord(
|
||||
timestamp=ts,
|
||||
asset=asset,
|
||||
spot=Decimal(str(spot)),
|
||||
dvol=Decimal(str(dvol)),
|
||||
funding_perp_annualized=Decimal(str(funding)),
|
||||
funding_cross_annualized=Decimal("0"),
|
||||
dealer_net_gamma=Decimal("100"),
|
||||
liquidation_long_risk="low",
|
||||
liquidation_short_risk="low",
|
||||
macro_days_to_event=macro_d,
|
||||
fetch_ok=True,
|
||||
)
|
||||
|
||||
|
||||
def test_daily_picks_extracts_one_per_calendar_day() -> None:
|
||||
day1 = datetime(2026, 5, 4, 14, 0, tzinfo=UTC)
|
||||
day2 = datetime(2026, 5, 5, 14, 0, tzinfo=UTC) # Tuesday: PICKED ora (crypto 24/7)
|
||||
snapshots = [
|
||||
_snap(ts=day1),
|
||||
_snap(ts=day1 + timedelta(minutes=15)), # stesso giorno, deduplicato
|
||||
_snap(ts=day2),
|
||||
]
|
||||
picks = daily_picks(snapshots)
|
||||
assert len(picks) == 2
|
||||
assert picks[0].timestamp == day1
|
||||
assert picks[1].timestamp == day2
|
||||
|
||||
|
||||
def test_daily_picks_skips_other_hours() -> None:
|
||||
snapshots = [
|
||||
_snap(ts=datetime(2026, 5, 4, 13, 0, tzinfo=UTC)), # 13:00 → skipped
|
||||
_snap(ts=datetime(2026, 5, 5, 15, 30, tzinfo=UTC)), # 15:30 → skipped
|
||||
]
|
||||
assert daily_picks(snapshots) == []
|
||||
|
||||
|
||||
def test_daily_picks_filters_by_asset() -> None:
|
||||
day = datetime(2026, 5, 4, 14, 0, tzinfo=UTC)
|
||||
snapshots = [
|
||||
_snap(ts=day, asset="BTC"),
|
||||
_snap(ts=day, asset="ETH"),
|
||||
]
|
||||
picks = daily_picks(snapshots, asset="ETH")
|
||||
assert len(picks) == 1
|
||||
assert picks[0].snapshot.asset == "ETH"
|
||||
|
||||
|
||||
def test_simulate_entry_filters_accepts_clean_snapshot(
|
||||
) -> None:
|
||||
cfg: StrategyConfig = golden_config()
|
||||
monday = datetime(2026, 5, 4, 14, 0, tzinfo=UTC)
|
||||
snap = _snap(ts=monday, dvol=50, funding=0.10)
|
||||
picks = [
|
||||
type("MP", (), {"timestamp": monday, "snapshot": snap})() # type: ignore[arg-type]
|
||||
]
|
||||
# Hack: build via real dataclass
|
||||
from cerbero_bite.core.backtest import DailyPick
|
||||
picks = [DailyPick(timestamp=monday, snapshot=snap)]
|
||||
results = simulate_entry_filters(picks, cfg, capital_usd=Decimal("1500"))
|
||||
assert len(results) == 1
|
||||
assert results[0].accepted is True
|
||||
|
||||
|
||||
def test_simulate_entry_filters_rejects_dvol_out_of_band() -> None:
|
||||
cfg = golden_config()
|
||||
monday = datetime(2026, 5, 4, 14, 0, tzinfo=UTC)
|
||||
snap = _snap(ts=monday, dvol=20, funding=0.10) # below 35
|
||||
from cerbero_bite.core.backtest import DailyPick
|
||||
picks = [DailyPick(timestamp=monday, snapshot=snap)]
|
||||
results = simulate_entry_filters(picks, cfg, capital_usd=Decimal("1500"))
|
||||
assert results[0].accepted is False
|
||||
assert any("dvol" in r.lower() for r in results[0].reasons)
|
||||
|
||||
|
||||
def test_simulate_entry_filters_skips_incomplete_snapshot() -> None:
|
||||
cfg = golden_config()
|
||||
monday = datetime(2026, 5, 4, 14, 0, tzinfo=UTC)
|
||||
incomplete = MarketSnapshotRecord(
|
||||
timestamp=monday, asset="ETH", spot=Decimal("3000"),
|
||||
# dvol=None ⇒ skipped
|
||||
fetch_ok=False,
|
||||
)
|
||||
from cerbero_bite.core.backtest import DailyPick
|
||||
picks = [DailyPick(timestamp=monday, snapshot=incomplete)]
|
||||
results = simulate_entry_filters(picks, cfg, capital_usd=Decimal("1500"))
|
||||
assert results[0].accepted is False
|
||||
assert results[0].skipped_for_data is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Full pipeline (sintetico)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _synthetic_year_of_snapshots(
|
||||
*,
|
||||
n_weeks: int = 8,
|
||||
spot: float = 3000,
|
||||
dvol: float = 60, # con skew_premium 1.5 ⇒ credit/width ≈ 35% (sopra soglia 30%)
|
||||
funding: float = 0.10,
|
||||
) -> list[MarketSnapshotRecord]:
|
||||
"""Genera N settimane di snapshot sintetici ETH a 4 tick/settimana."""
|
||||
rows: list[MarketSnapshotRecord] = []
|
||||
monday = datetime(2026, 5, 4, 14, 0, tzinfo=UTC)
|
||||
for week in range(n_weeks):
|
||||
base = monday + timedelta(weeks=week)
|
||||
# Lunedì 14:00 è il pick
|
||||
rows.append(_snap(ts=base, spot=spot, dvol=dvol, funding=funding))
|
||||
# Tick intermedi che NON cadono alle 14:00:
|
||||
# offset +1h (=15:00) così vengono ignorati da `daily_picks`.
|
||||
for d in (2, 8, 14, 19):
|
||||
rows.append(
|
||||
_snap(
|
||||
ts=base + timedelta(days=d, hours=1),
|
||||
spot=spot * (1 + 0.005 * d), # +0.5% al giorno
|
||||
dvol=dvol - 1.5 * d, # vol che scende lentamente
|
||||
funding=funding,
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def test_run_backtest_produces_report_with_trades() -> None:
|
||||
# Per il test scaliamo il credit/width gate al 15%: il modello BS
|
||||
# senza skew completo sottostima i premi OTM rispetto al reale.
|
||||
# Vedi `estimate_credit_eth.skew_premium` docstring per dettagli.
|
||||
from cerbero_bite.config.schema import StructureConfig
|
||||
cfg = golden_config()
|
||||
cfg = cfg.model_copy(
|
||||
update={
|
||||
"structure": StructureConfig(
|
||||
**{
|
||||
**cfg.structure.model_dump(),
|
||||
"credit_to_width_ratio_min": Decimal("0.15"),
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
snapshots = _synthetic_year_of_snapshots(n_weeks=4)
|
||||
report = run_backtest(snapshots, cfg, capital_usd=Decimal("1500"))
|
||||
# Sanity: 4 picks, almeno 1 trade chiuso
|
||||
assert report.n_picks == 4
|
||||
assert report.n_completed >= 1
|
||||
assert report.cumulative_pnl_usd != Decimal("0")
|
||||
# Bull-put + ETH al rialzo + DVOL che scende ⇒ atteso win
|
||||
assert report.n_winners >= 1
|
||||
|
||||
|
||||
def test_run_backtest_handles_no_picks_gracefully() -> None:
|
||||
cfg = golden_config()
|
||||
# Solo tick infrasettimanali, niente Monday 14:00.
|
||||
monday = datetime(2026, 5, 4, 14, 0, tzinfo=UTC)
|
||||
snapshots = [_snap(ts=monday + timedelta(hours=1))]
|
||||
report = run_backtest(snapshots, cfg, capital_usd=Decimal("1500"))
|
||||
assert report.n_picks == 0
|
||||
assert report.n_completed == 0
|
||||
assert report.cumulative_pnl_usd == Decimal("0")
|
||||
+15
-33
@@ -7,25 +7,14 @@ contains the expected statuses.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
from pytest_httpx import HTTPXMock
|
||||
|
||||
from cerbero_bite.cli import main as cli_main
|
||||
|
||||
|
||||
def _seed_token(tmp_path: Path) -> Path:
|
||||
target = tmp_path / "core_token"
|
||||
target.write_text("super-secret\n", encoding="utf-8")
|
||||
return target
|
||||
|
||||
|
||||
def test_ping_reports_each_service(
|
||||
tmp_path: Path, httpx_mock: HTTPXMock
|
||||
) -> None:
|
||||
token_file = _seed_token(tmp_path)
|
||||
|
||||
def test_ping_reports_each_service(httpx_mock: HTTPXMock) -> None:
|
||||
httpx_mock.add_response(
|
||||
url="http://mcp-deribit:9011/tools/environment_info",
|
||||
json={
|
||||
@@ -49,29 +38,24 @@ def test_ping_reports_each_service(
|
||||
url="http://mcp-sentiment:9014/tools/get_cross_exchange_funding",
|
||||
json={"snapshot": {"ETH": {"binance": 0.0001}}},
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
url="http://mcp-portfolio:9018/tools/get_total_portfolio_value",
|
||||
json={"total_value_eur": 5000.0},
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(
|
||||
cli_main, ["ping", "--token-file", str(token_file), "--timeout", "1.0"]
|
||||
cli_main, ["ping", "--token", "super-secret", "--timeout", "1.0"]
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "deribit" in result.output
|
||||
assert "hyperliquid" in result.output
|
||||
assert "macro" in result.output
|
||||
assert "sentiment" in result.output
|
||||
assert "portfolio" in result.output
|
||||
assert "telegram" in result.output # listed even if skipped
|
||||
# at least 5 OK statuses
|
||||
assert result.output.count("OK") >= 5
|
||||
# Telegram and Portfolio are no longer MCP services and are not
|
||||
# listed by the ping command.
|
||||
assert "portfolio" not in result.output
|
||||
assert "OK" in result.output
|
||||
|
||||
|
||||
def test_ping_reports_failure_when_service_unreachable(
|
||||
tmp_path: Path, httpx_mock: HTTPXMock
|
||||
httpx_mock: HTTPXMock,
|
||||
) -> None:
|
||||
token_file = _seed_token(tmp_path)
|
||||
httpx_mock.add_response(
|
||||
url="http://mcp-deribit:9011/tools/environment_info",
|
||||
status_code=500,
|
||||
@@ -90,21 +74,19 @@ def test_ping_reports_failure_when_service_unreachable(
|
||||
url="http://mcp-sentiment:9014/tools/get_cross_exchange_funding",
|
||||
json={"snapshot": {"ETH": {"binance": 0.0001}}},
|
||||
)
|
||||
httpx_mock.add_response(
|
||||
url="http://mcp-portfolio:9018/tools/get_total_portfolio_value",
|
||||
json={"total_value_eur": 0.0},
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(
|
||||
cli_main, ["ping", "--token-file", str(token_file), "--timeout", "1.0"]
|
||||
cli_main, ["ping", "--token", "super-secret", "--timeout", "1.0"]
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "FAIL" in result.output
|
||||
|
||||
|
||||
def test_ping_token_missing_exits_nonzero(tmp_path: Path) -> None:
|
||||
result = CliRunner().invoke(
|
||||
cli_main, ["ping", "--token-file", str(tmp_path / "nope")]
|
||||
)
|
||||
def test_ping_token_missing_exits_nonzero(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# Ensure no env var leaks into the CLI invocation.
|
||||
monkeypatch.delenv("CERBERO_BITE_MCP_TOKEN", raising=False)
|
||||
result = CliRunner().invoke(cli_main, ["ping"])
|
||||
assert result.exit_code == 1
|
||||
assert "token error" in result.output
|
||||
|
||||
@@ -47,6 +47,28 @@ async def test_call_attaches_bearer_token(httpx_mock: HTTPXMock) -> None:
|
||||
assert request is not None
|
||||
assert request.headers["Authorization"] == "Bearer abc123"
|
||||
assert request.headers["Content-Type"] == "application/json"
|
||||
# Default bot tag is sent on every request.
|
||||
assert request.headers["X-Bot-Tag"] == "BOT__CERBERO_BITE"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_attaches_custom_bot_tag(httpx_mock: HTTPXMock) -> None:
|
||||
httpx_mock.add_response(json={"ok": True})
|
||||
client = _make_client(bot_tag="BOT__SHADOW")
|
||||
await client.call("any")
|
||||
request = httpx_mock.get_request()
|
||||
assert request is not None
|
||||
assert request.headers["X-Bot-Tag"] == "BOT__SHADOW"
|
||||
|
||||
|
||||
def test_init_rejects_blank_bot_tag() -> None:
|
||||
with pytest.raises(ValueError, match="non-empty"):
|
||||
_make_client(bot_tag=" ")
|
||||
|
||||
|
||||
def test_init_rejects_too_long_bot_tag() -> None:
|
||||
with pytest.raises(ValueError, match="64"):
|
||||
_make_client(bot_tag="x" * 65)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -1,95 +1,240 @@
|
||||
"""Tests for PortfolioClient."""
|
||||
"""Tests for in-process PortfolioClient (composes deribit + hyperliquid + macro)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pytest_httpx import HTTPXMock
|
||||
|
||||
from cerbero_bite.clients._base import HttpToolClient
|
||||
from cerbero_bite.clients._exceptions import McpDataAnomalyError
|
||||
from cerbero_bite.clients.portfolio import PortfolioClient
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test doubles
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _client() -> PortfolioClient:
|
||||
http = HttpToolClient(
|
||||
service="portfolio",
|
||||
base_url="http://mcp-portfolio:9018",
|
||||
token="t",
|
||||
retry_max=1,
|
||||
|
||||
class _FakeDeribit:
|
||||
SERVICE = "deribit"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
equity_usd: Decimal | float = Decimal("0"),
|
||||
positions: list[dict[str, Any]] | None = None,
|
||||
) -> None:
|
||||
self._equity = Decimal(str(equity_usd))
|
||||
self._positions = positions or []
|
||||
|
||||
async def get_account_summary(self, currency: str = "USDC") -> dict[str, Any]:
|
||||
assert currency == "USDC"
|
||||
return {"equity": float(self._equity), "currency": "USDC"}
|
||||
|
||||
async def get_positions(self, currency: str = "USDC") -> list[dict[str, Any]]:
|
||||
assert currency == "USDC"
|
||||
return list(self._positions)
|
||||
|
||||
|
||||
class _FakeHyperliquid:
|
||||
SERVICE = "hyperliquid"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
equity_usd: Decimal | float = Decimal("0"),
|
||||
positions: list[dict[str, Any]] | None = None,
|
||||
) -> None:
|
||||
self._equity = Decimal(str(equity_usd))
|
||||
self._positions = positions or []
|
||||
|
||||
async def get_account_summary(self) -> dict[str, Any]:
|
||||
return {"equity": float(self._equity)}
|
||||
|
||||
async def get_positions(self) -> list[dict[str, Any]]:
|
||||
return list(self._positions)
|
||||
|
||||
|
||||
class _FakeMacro:
|
||||
SERVICE = "macro"
|
||||
|
||||
def __init__(self, *, eur_usd: Decimal | float | None = Decimal("1.10")) -> None:
|
||||
self._eur_usd = eur_usd
|
||||
|
||||
async def eur_usd_rate(self) -> Decimal:
|
||||
if self._eur_usd is None:
|
||||
raise McpDataAnomalyError(
|
||||
"missing", service="macro", tool="get_asset_price"
|
||||
)
|
||||
return Decimal(str(self._eur_usd))
|
||||
|
||||
|
||||
def _make(
|
||||
*,
|
||||
deribit_eq: Decimal | float = 0,
|
||||
hl_eq: Decimal | float = 0,
|
||||
deribit_pos: list[dict[str, Any]] | None = None,
|
||||
hl_pos: list[dict[str, Any]] | None = None,
|
||||
eur_usd: Decimal | float | None = Decimal("1.10"),
|
||||
) -> PortfolioClient:
|
||||
return PortfolioClient(
|
||||
deribit=_FakeDeribit(equity_usd=deribit_eq, positions=deribit_pos),
|
||||
hyperliquid=_FakeHyperliquid(equity_usd=hl_eq, positions=hl_pos),
|
||||
macro=_FakeMacro(eur_usd=eur_usd),
|
||||
)
|
||||
return PortfolioClient(http)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# total_equity_usd / total_equity_eur
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_total_equity_eur(httpx_mock: HTTPXMock) -> None:
|
||||
httpx_mock.add_response(
|
||||
url="http://mcp-portfolio:9018/tools/get_total_portfolio_value",
|
||||
json={"total_value_eur": 12345.67},
|
||||
)
|
||||
out = await _client().total_equity_eur()
|
||||
assert out == Decimal("12345.67")
|
||||
async def test_total_equity_usd_sums_both_exchanges() -> None:
|
||||
p = _make(deribit_eq="1500.50", hl_eq="982.50")
|
||||
assert await p.total_equity_usd() == Decimal("2483.00")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_total_equity_anomaly_when_missing(httpx_mock: HTTPXMock) -> None:
|
||||
httpx_mock.add_response(json={})
|
||||
with pytest.raises(McpDataAnomalyError, match="total_value_eur"):
|
||||
await _client().total_equity_eur()
|
||||
async def test_total_equity_eur_converts_with_fx() -> None:
|
||||
p = _make(deribit_eq="1100", hl_eq="0", eur_usd="1.10")
|
||||
# 1100 USD / 1.10 = 1000 EUR
|
||||
assert await p.total_equity_eur() == Decimal("1000")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_total_equity_anomaly_on_unexpected_shape(httpx_mock: HTTPXMock) -> None:
|
||||
httpx_mock.add_response(json=[1, 2, 3])
|
||||
with pytest.raises(McpDataAnomalyError, match="unexpected shape"):
|
||||
await _client().total_equity_eur()
|
||||
async def test_total_equity_eur_zero_when_no_balance() -> None:
|
||||
p = _make(deribit_eq=0, hl_eq=0, eur_usd="1.20")
|
||||
assert await p.total_equity_eur() == Decimal("0")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_asset_pct_aggregates_matching_tickers(httpx_mock: HTTPXMock) -> None:
|
||||
httpx_mock.add_response(
|
||||
url="http://mcp-portfolio:9018/tools/get_holdings",
|
||||
json=[
|
||||
{"ticker": "ETH-USD", "current_value_eur": 3000.0},
|
||||
{"ticker": "ETHE", "current_value_eur": 1000.0}, # ETH ticker variant
|
||||
{"ticker": "AAPL", "current_value_eur": 6000.0},
|
||||
async def test_total_equity_eur_raises_on_non_positive_fx() -> None:
|
||||
p = _make(deribit_eq="100", hl_eq="0", eur_usd="0")
|
||||
with pytest.raises(McpDataAnomalyError, match="non-positive EURUSD"):
|
||||
await p.total_equity_eur()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_total_equity_eur_propagates_macro_anomaly() -> None:
|
||||
p = _make(deribit_eq="100", hl_eq="0", eur_usd=None)
|
||||
with pytest.raises(McpDataAnomalyError):
|
||||
await p.total_equity_eur()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# asset_pct_of_portfolio
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_asset_pct_aggregates_eth_across_both_exchanges() -> None:
|
||||
p = _make(
|
||||
deribit_eq="5000",
|
||||
hl_eq="5000",
|
||||
deribit_pos=[
|
||||
{
|
||||
"instrument_name": "ETH-15MAY26-2475-P",
|
||||
"size": 10,
|
||||
"mark_price": 100,
|
||||
},
|
||||
# BTC position should be ignored when asking for ETH
|
||||
{
|
||||
"instrument_name": "BTC-PERPETUAL",
|
||||
"size": 1,
|
||||
"mark_price": 75000,
|
||||
},
|
||||
],
|
||||
hl_pos=[
|
||||
{"coin": "ETH", "notional_usd": 1000},
|
||||
],
|
||||
)
|
||||
pct = await _client().asset_pct_of_portfolio("ETH")
|
||||
# 4000 / 10000 = 0.4
|
||||
assert pct == Decimal("0.4")
|
||||
# ETH exposure: 10×100 (deribit) + 1000 (hl) = 2000
|
||||
# total equity: 10000
|
||||
pct = await p.asset_pct_of_portfolio("ETH")
|
||||
assert pct == Decimal("0.2")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_asset_pct_returns_zero_for_empty_portfolio(
|
||||
httpx_mock: HTTPXMock,
|
||||
) -> None:
|
||||
httpx_mock.add_response(json=[])
|
||||
assert await _client().asset_pct_of_portfolio("ETH") == Decimal("0")
|
||||
async def test_asset_pct_returns_zero_when_no_positions() -> None:
|
||||
p = _make(deribit_eq="1000", hl_eq="0")
|
||||
assert await p.asset_pct_of_portfolio("ETH") == Decimal("0")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_asset_pct_skips_entries_without_value(httpx_mock: HTTPXMock) -> None:
|
||||
httpx_mock.add_response(
|
||||
json=[
|
||||
{"ticker": "ETH", "current_value_eur": None},
|
||||
{"ticker": "AAPL", "current_value_eur": 1000.0},
|
||||
]
|
||||
async def test_asset_pct_returns_zero_when_no_equity() -> None:
|
||||
p = _make(
|
||||
deribit_eq=0,
|
||||
hl_eq=0,
|
||||
deribit_pos=[
|
||||
{"instrument_name": "ETH-PERP", "notional_usd": 100},
|
||||
],
|
||||
)
|
||||
assert await _client().asset_pct_of_portfolio("ETH") == Decimal("0")
|
||||
assert await p.asset_pct_of_portfolio("ETH") == Decimal("0")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_asset_pct_anomaly_when_response_not_list(httpx_mock: HTTPXMock) -> None:
|
||||
httpx_mock.add_response(json={"holdings": []})
|
||||
with pytest.raises(McpDataAnomalyError, match="unexpected shape"):
|
||||
await _client().asset_pct_of_portfolio("ETH")
|
||||
|
||||
|
||||
def test_portfolio_client_rejects_wrong_service() -> None:
|
||||
bad = HttpToolClient(
|
||||
service="macro", base_url="http://x:1", token="t", retry_max=1
|
||||
async def test_asset_pct_uses_explicit_notional_when_present() -> None:
|
||||
p = _make(
|
||||
deribit_eq="1000",
|
||||
hl_eq=0,
|
||||
deribit_pos=[
|
||||
# explicit notional_usd takes precedence over size×mark
|
||||
{
|
||||
"instrument_name": "ETH-XYZ",
|
||||
"notional_usd": 250,
|
||||
"size": 999,
|
||||
"mark_price": 999,
|
||||
},
|
||||
],
|
||||
)
|
||||
with pytest.raises(ValueError, match="requires service 'portfolio'"):
|
||||
PortfolioClient(bad)
|
||||
assert await p.asset_pct_of_portfolio("ETH") == Decimal("0.25")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_asset_pct_falls_back_to_size_times_mark() -> None:
|
||||
p = _make(
|
||||
deribit_eq="1000",
|
||||
hl_eq=0,
|
||||
deribit_pos=[
|
||||
{"instrument_name": "ETH-XYZ", "size": 5, "mark_price": 40},
|
||||
],
|
||||
)
|
||||
# 5×40 / 1000 = 0.2
|
||||
assert await p.asset_pct_of_portfolio("ETH") == Decimal("0.2")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_asset_pct_takes_absolute_value_for_short_positions() -> None:
|
||||
p = _make(
|
||||
deribit_eq="1000",
|
||||
hl_eq=0,
|
||||
hl_pos=[{"coin": "ETH", "size": -10, "mark_price": 50}],
|
||||
)
|
||||
# |-10×50| / 1000 = 0.5
|
||||
assert await p.asset_pct_of_portfolio("ETH") == Decimal("0.5")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_asset_pct_case_insensitive_match() -> None:
|
||||
p = _make(
|
||||
deribit_eq="1000",
|
||||
hl_eq=0,
|
||||
deribit_pos=[
|
||||
{"instrument_name": "eth-perpetual", "notional_usd": 300},
|
||||
],
|
||||
)
|
||||
assert await p.asset_pct_of_portfolio("eth") == Decimal("0.3")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_asset_pct_skips_non_dict_entries() -> None:
|
||||
p = _make(
|
||||
deribit_eq="1000",
|
||||
hl_eq=0,
|
||||
deribit_pos=[
|
||||
"not a dict", # type: ignore[list-item]
|
||||
{"instrument_name": "ETH", "notional_usd": 100},
|
||||
],
|
||||
)
|
||||
assert await p.asset_pct_of_portfolio("ETH") == Decimal("0.1")
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
"""Tests for TelegramClient (notify-only mode)."""
|
||||
"""Tests for in-process TelegramClient (Bot API, notify-only)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from decimal import Decimal
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from pytest_httpx import HTTPXMock
|
||||
|
||||
from cerbero_bite.clients._base import HttpToolClient
|
||||
from cerbero_bite.clients.telegram import TelegramClient
|
||||
from cerbero_bite.clients.telegram import (
|
||||
TelegramClient,
|
||||
TelegramError,
|
||||
load_telegram_credentials,
|
||||
)
|
||||
|
||||
SEND_URL = "https://api.telegram.org/botTOK/sendMessage"
|
||||
|
||||
|
||||
def _client() -> TelegramClient:
|
||||
http = HttpToolClient(
|
||||
service="telegram",
|
||||
base_url="http://mcp-telegram:9017",
|
||||
token="t",
|
||||
retry_max=1,
|
||||
)
|
||||
return TelegramClient(http)
|
||||
def _client(**kw) -> TelegramClient:
|
||||
defaults = {"bot_token": "TOK", "chat_id": "42"}
|
||||
defaults.update(kw)
|
||||
return TelegramClient(**defaults)
|
||||
|
||||
|
||||
def _request_body(httpx_mock: HTTPXMock) -> dict:
|
||||
@@ -28,34 +30,66 @@ def _request_body(httpx_mock: HTTPXMock) -> dict:
|
||||
return json.loads(request.read())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# enabled / disabled
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_enabled_when_both_token_and_chat_id_present() -> None:
|
||||
assert _client().enabled is True
|
||||
|
||||
|
||||
def test_disabled_when_token_missing() -> None:
|
||||
c = TelegramClient(bot_token=None, chat_id="42")
|
||||
assert c.enabled is False
|
||||
|
||||
|
||||
def test_disabled_when_chat_id_missing() -> None:
|
||||
c = TelegramClient(bot_token="TOK", chat_id=None)
|
||||
assert c.enabled is False
|
||||
|
||||
|
||||
def test_disabled_when_token_blank() -> None:
|
||||
c = TelegramClient(bot_token=" ", chat_id="42")
|
||||
assert c.enabled is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notify_sends_message_with_priority(httpx_mock: HTTPXMock) -> None:
|
||||
httpx_mock.add_response(
|
||||
url="http://mcp-telegram:9017/tools/notify",
|
||||
json={"ok": True},
|
||||
)
|
||||
async def test_disabled_notify_is_noop(httpx_mock: HTTPXMock) -> None:
|
||||
c = TelegramClient(bot_token=None, chat_id=None)
|
||||
await c.notify("hello")
|
||||
assert httpx_mock.get_requests() == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# notify formatting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notify_sends_with_priority_and_tag(httpx_mock: HTTPXMock) -> None:
|
||||
httpx_mock.add_response(url=SEND_URL, json={"ok": True, "result": {}})
|
||||
await _client().notify("hello", priority="high", tag="entry")
|
||||
body = _request_body(httpx_mock)
|
||||
assert body == {"message": "hello", "priority": "high", "tag": "entry"}
|
||||
assert body["chat_id"] == "42"
|
||||
assert body["parse_mode"] == "HTML"
|
||||
assert body["text"] == "[HIGH][entry] hello"
|
||||
assert body["disable_web_page_preview"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notify_default_priority_normal(httpx_mock: HTTPXMock) -> None:
|
||||
httpx_mock.add_response(json={"ok": True})
|
||||
httpx_mock.add_response(url=SEND_URL, json={"ok": True})
|
||||
await _client().notify("plain")
|
||||
body = _request_body(httpx_mock)
|
||||
assert body["priority"] == "normal"
|
||||
assert "tag" not in body
|
||||
assert body["text"] == "[NORMAL] plain"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notify_position_opened_serialises_decimals(
|
||||
async def test_notify_position_opened_formats_decimals(
|
||||
httpx_mock: HTTPXMock,
|
||||
) -> None:
|
||||
httpx_mock.add_response(
|
||||
url="http://mcp-telegram:9017/tools/notify_position_opened",
|
||||
json={"ok": True},
|
||||
)
|
||||
httpx_mock.add_response(url=SEND_URL, json={"ok": True})
|
||||
await _client().notify_position_opened(
|
||||
instrument="ETH-15MAY26-2475-P",
|
||||
side="SELL",
|
||||
@@ -64,59 +98,139 @@ async def test_notify_position_opened_serialises_decimals(
|
||||
greeks={"delta": Decimal("-0.04"), "vega": Decimal("0.20")},
|
||||
expected_pnl_usd=Decimal("45.00"),
|
||||
)
|
||||
body = _request_body(httpx_mock)
|
||||
assert body["instrument"] == "ETH-15MAY26-2475-P"
|
||||
assert body["greeks"] == {"delta": -0.04, "vega": 0.20}
|
||||
assert body["expected_pnl"] == 45.0
|
||||
assert body["size"] == 2.0
|
||||
text = _request_body(httpx_mock)["text"]
|
||||
assert "POSITION OPENED" in text
|
||||
assert "ETH-15MAY26-2475-P" in text
|
||||
assert "SELL" in text and "size: 2" in text and "bull_put" in text
|
||||
assert "delta=-0.0400" in text and "vega=+0.2000" in text
|
||||
assert "$+45.00" in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notify_position_opened_without_greeks(httpx_mock: HTTPXMock) -> None:
|
||||
httpx_mock.add_response(url=SEND_URL, json={"ok": True})
|
||||
await _client().notify_position_opened(
|
||||
instrument="BTC-PERPETUAL", side="BUY", size=1, strategy="hedge"
|
||||
)
|
||||
text = _request_body(httpx_mock)["text"]
|
||||
assert "greeks" not in text
|
||||
assert "expected pnl" not in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notify_position_closed(httpx_mock: HTTPXMock) -> None:
|
||||
httpx_mock.add_response(json={"ok": True})
|
||||
httpx_mock.add_response(url=SEND_URL, json={"ok": True})
|
||||
await _client().notify_position_closed(
|
||||
instrument="ETH-15MAY26-2475-P_2350-P",
|
||||
realized_pnl_usd=Decimal("32.50"),
|
||||
reason="CLOSE_PROFIT",
|
||||
)
|
||||
body = _request_body(httpx_mock)
|
||||
assert body == {
|
||||
"instrument": "ETH-15MAY26-2475-P_2350-P",
|
||||
"realized_pnl": 32.5,
|
||||
"reason": "CLOSE_PROFIT",
|
||||
}
|
||||
text = _request_body(httpx_mock)["text"]
|
||||
assert "POSITION CLOSED" in text
|
||||
assert "ETH-15MAY26-2475-P_2350-P" in text
|
||||
assert "$+32.50" in text
|
||||
assert "CLOSE_PROFIT" in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notify_position_closed_negative_pnl(httpx_mock: HTTPXMock) -> None:
|
||||
httpx_mock.add_response(url=SEND_URL, json={"ok": True})
|
||||
await _client().notify_position_closed(
|
||||
instrument="X", realized_pnl_usd=Decimal("-12.5"), reason="STOP"
|
||||
)
|
||||
text = _request_body(httpx_mock)["text"]
|
||||
assert "$-12.50" in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notify_alert(httpx_mock: HTTPXMock) -> None:
|
||||
httpx_mock.add_response(json={"ok": True})
|
||||
httpx_mock.add_response(url=SEND_URL, json={"ok": True})
|
||||
await _client().notify_alert(
|
||||
source="kill_switch", message="armed manually", priority="critical"
|
||||
)
|
||||
body = _request_body(httpx_mock)
|
||||
assert body == {
|
||||
"source": "kill_switch",
|
||||
"message": "armed manually",
|
||||
"priority": "critical",
|
||||
}
|
||||
text = _request_body(httpx_mock)["text"]
|
||||
assert "ALERT [CRITICAL]" in text
|
||||
assert "kill_switch" in text and "armed manually" in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notify_system_error(httpx_mock: HTTPXMock) -> None:
|
||||
httpx_mock.add_response(json={"ok": True})
|
||||
httpx_mock.add_response(url=SEND_URL, json={"ok": True})
|
||||
await _client().notify_system_error(
|
||||
message="deribit feed anomaly",
|
||||
component="clients.deribit",
|
||||
message="deribit feed anomaly", component="clients.deribit"
|
||||
)
|
||||
body = _request_body(httpx_mock)
|
||||
assert body["message"] == "deribit feed anomaly"
|
||||
assert body["component"] == "clients.deribit"
|
||||
assert body["priority"] == "critical"
|
||||
text = _request_body(httpx_mock)["text"]
|
||||
assert "SYSTEM ERROR [CRITICAL]" in text
|
||||
assert "deribit feed anomaly" in text
|
||||
assert "clients.deribit" in text
|
||||
|
||||
|
||||
def test_telegram_client_rejects_wrong_service() -> None:
|
||||
bad = HttpToolClient(
|
||||
service="macro", base_url="http://x:1", token="t", retry_max=1
|
||||
@pytest.mark.asyncio
|
||||
async def test_notify_system_error_without_component(httpx_mock: HTTPXMock) -> None:
|
||||
httpx_mock.add_response(url=SEND_URL, json={"ok": True})
|
||||
await _client().notify_system_error(message="boom")
|
||||
text = _request_body(httpx_mock)["text"]
|
||||
assert "component" not in text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# error paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_non_200_raises(httpx_mock: HTTPXMock) -> None:
|
||||
httpx_mock.add_response(url=SEND_URL, status_code=500, text="upstream")
|
||||
with pytest.raises(TelegramError, match="HTTP 500"):
|
||||
await _client().notify("x")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_ok_false_raises(httpx_mock: HTTPXMock) -> None:
|
||||
httpx_mock.add_response(
|
||||
url=SEND_URL, json={"ok": False, "description": "chat not found"}
|
||||
)
|
||||
with pytest.raises(ValueError, match="requires service 'telegram'"):
|
||||
TelegramClient(bad)
|
||||
with pytest.raises(TelegramError, match="chat not found"):
|
||||
await _client().notify("x")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# shared httpx client
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uses_shared_http_client(httpx_mock: HTTPXMock) -> None:
|
||||
httpx_mock.add_response(url=SEND_URL, json={"ok": True})
|
||||
shared = httpx.AsyncClient()
|
||||
try:
|
||||
c = _client(http_client=shared)
|
||||
await c.notify("x")
|
||||
finally:
|
||||
await shared.aclose()
|
||||
assert len(httpx_mock.get_requests()) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# env-var loader
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_load_credentials_returns_none_when_unset() -> None:
|
||||
assert load_telegram_credentials(env={}) == (None, None)
|
||||
|
||||
|
||||
def test_load_credentials_strips_whitespace() -> None:
|
||||
env = {
|
||||
"CERBERO_BITE_TELEGRAM_BOT_TOKEN": " abc ",
|
||||
"CERBERO_BITE_TELEGRAM_CHAT_ID": " -100 ",
|
||||
}
|
||||
assert load_telegram_credentials(env=env) == ("abc", "-100")
|
||||
|
||||
|
||||
def test_load_credentials_treats_empty_as_none() -> None:
|
||||
env = {
|
||||
"CERBERO_BITE_TELEGRAM_BOT_TOKEN": "",
|
||||
"CERBERO_BITE_TELEGRAM_CHAT_ID": " ",
|
||||
}
|
||||
assert load_telegram_credentials(env=env) == (None, None)
|
||||
|
||||
@@ -329,3 +329,146 @@ def test_build_bear_call_breakeven_above_short_strike(
|
||||
# breakeven = 3525 + 15 = 3540
|
||||
assert proposal.breakeven == Decimal("3540")
|
||||
assert proposal.spread_type == "bear_call"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# §3.2 (A): dynamic delta target by DVOL regime
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _cfg_with_delta_bands(cfg: StrategyConfig) -> StrategyConfig:
|
||||
"""Profilo con step-function delta su DVOL.
|
||||
|
||||
Vol bassa (≤50) → delta 0.15 (più premio), vol media (≤70) →
|
||||
0.12 (default), vol alta (≤90) → 0.10 (più safety distance).
|
||||
"""
|
||||
from cerbero_bite.config.schema import (
|
||||
DeltaByDvolBand,
|
||||
ShortStrikeSpec,
|
||||
StructureConfig,
|
||||
)
|
||||
bands = [
|
||||
DeltaByDvolBand(
|
||||
dvol_under=Decimal("50"),
|
||||
delta_target=Decimal("0.15"),
|
||||
delta_min=Decimal("0.13"),
|
||||
delta_max=Decimal("0.17"),
|
||||
),
|
||||
DeltaByDvolBand(
|
||||
dvol_under=Decimal("70"),
|
||||
delta_target=Decimal("0.12"),
|
||||
delta_min=Decimal("0.10"),
|
||||
delta_max=Decimal("0.15"),
|
||||
),
|
||||
DeltaByDvolBand(
|
||||
dvol_under=Decimal("90"),
|
||||
delta_target=Decimal("0.10"),
|
||||
delta_min=Decimal("0.08"),
|
||||
delta_max=Decimal("0.12"),
|
||||
),
|
||||
]
|
||||
new_short = ShortStrikeSpec(
|
||||
**{**cfg.structure.short_strike.model_dump(), "delta_by_dvol": bands}
|
||||
)
|
||||
return cfg.model_copy(
|
||||
update={
|
||||
"structure": StructureConfig(
|
||||
**{**cfg.structure.model_dump(exclude={"short_strike"}),
|
||||
"short_strike": new_short}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _bull_put_chain_wide(now_dt: datetime) -> list[OptionQuote]:
|
||||
"""Chain con shorts e longs per delta 0.10, 0.12, 0.15.
|
||||
|
||||
I mid sono tarati per superare il credit/width ≥ 30% per ogni
|
||||
accoppiamento short→long testato (vedi commento §3.4).
|
||||
"""
|
||||
return [
|
||||
# Shorts a delta 0.10 / 0.12 / 0.15 in OTM range [15-25%].
|
||||
_quote(strike="2535", delta="-0.15", mid="0.026", now_dt=now_dt),
|
||||
_quote(strike="2475", delta="-0.12", mid="0.020", now_dt=now_dt),
|
||||
_quote(strike="2400", delta="-0.10", mid="0.015", now_dt=now_dt),
|
||||
# Long candidati ~4% sotto ciascuno short.
|
||||
_quote(strike="2415", delta="-0.10", mid="0.012", now_dt=now_dt),
|
||||
_quote(strike="2355", delta="-0.08", mid="0.006", now_dt=now_dt),
|
||||
_quote(strike="2280", delta="-0.06", mid="0.002", now_dt=now_dt),
|
||||
]
|
||||
|
||||
|
||||
def test_dynamic_delta_low_dvol_picks_higher_delta(
|
||||
cfg: StrategyConfig, now: datetime
|
||||
) -> None:
|
||||
"""DVOL=40 → banda con delta_target=0.15."""
|
||||
cfg_dyn = _cfg_with_delta_bands(cfg)
|
||||
chain = _bull_put_chain_wide(now)
|
||||
res = select_strikes(
|
||||
chain=chain,
|
||||
bias="bull_put",
|
||||
spot=Decimal("3000"),
|
||||
now=now,
|
||||
cfg=cfg_dyn,
|
||||
dvol_now=Decimal("40"),
|
||||
)
|
||||
assert res is not None
|
||||
short, _ = res
|
||||
assert short.delta == Decimal("-0.15")
|
||||
|
||||
|
||||
def test_dynamic_delta_mid_dvol_picks_default_delta(
|
||||
cfg: StrategyConfig, now: datetime
|
||||
) -> None:
|
||||
"""DVOL=60 → banda con delta_target=0.12."""
|
||||
cfg_dyn = _cfg_with_delta_bands(cfg)
|
||||
chain = _bull_put_chain_wide(now)
|
||||
res = select_strikes(
|
||||
chain=chain,
|
||||
bias="bull_put",
|
||||
spot=Decimal("3000"),
|
||||
now=now,
|
||||
cfg=cfg_dyn,
|
||||
dvol_now=Decimal("60"),
|
||||
)
|
||||
assert res is not None
|
||||
short, _ = res
|
||||
assert short.delta == Decimal("-0.12")
|
||||
|
||||
|
||||
def test_dynamic_delta_high_dvol_picks_lower_delta(
|
||||
cfg: StrategyConfig, now: datetime
|
||||
) -> None:
|
||||
"""DVOL=85 → banda con delta_target=0.10 (più safety distance)."""
|
||||
cfg_dyn = _cfg_with_delta_bands(cfg)
|
||||
chain = _bull_put_chain_wide(now)
|
||||
res = select_strikes(
|
||||
chain=chain,
|
||||
bias="bull_put",
|
||||
spot=Decimal("3000"),
|
||||
now=now,
|
||||
cfg=cfg_dyn,
|
||||
dvol_now=Decimal("85"),
|
||||
)
|
||||
assert res is not None
|
||||
short, _ = res
|
||||
assert short.delta == Decimal("-0.10")
|
||||
|
||||
|
||||
def test_dynamic_delta_disabled_default_uses_static_delta(
|
||||
cfg: StrategyConfig, now: datetime
|
||||
) -> None:
|
||||
"""delta_by_dvol vuoto (default) → comportamento invariato."""
|
||||
chain = _bull_put_chain_wide(now)
|
||||
res = select_strikes(
|
||||
chain=chain,
|
||||
bias="bull_put",
|
||||
spot=Decimal("3000"),
|
||||
now=now,
|
||||
cfg=cfg, # golden config: delta_by_dvol=[]
|
||||
dvol_now=Decimal("40"),
|
||||
)
|
||||
assert res is not None
|
||||
short, _ = res
|
||||
# Delta target statico = 0.12, quindi torna lo strike a -0.12.
|
||||
assert short.delta == Decimal("-0.12")
|
||||
|
||||
@@ -68,7 +68,7 @@ def test_compute_hash_is_independent_of_recorded_hash_value(tmp_path: Path) -> N
|
||||
def test_load_repo_strategy_yaml(tmp_path: Path) -> None:
|
||||
"""The committed strategy.yaml validates with the recorded hash."""
|
||||
result = load_strategy(REPO_ROOT / "strategy.yaml")
|
||||
assert result.config.config_version == "1.0.0"
|
||||
assert result.config.config_version == "1.4.0"
|
||||
assert result.config.sizing.kelly_fraction == Decimal("0.13")
|
||||
assert result.computed_hash == result.config.config_hash
|
||||
|
||||
|
||||
@@ -194,6 +194,62 @@ def test_dealer_gamma_filter_disabled_in_config(cfg: StrategyConfig) -> None:
|
||||
assert decision.accepted is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IV richness gate (§2.9)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _strict_iv_rv_cfg(
|
||||
cfg: StrategyConfig, *, threshold: Decimal = Decimal("5")
|
||||
) -> StrategyConfig:
|
||||
return golden_config(
|
||||
entry=EntryConfig(
|
||||
**{
|
||||
**cfg.entry.model_dump(),
|
||||
"iv_minus_rv_filter_enabled": True,
|
||||
"iv_minus_rv_min": threshold,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_iv_richness_gate_disabled_by_default_lets_thin_premium_pass(
|
||||
cfg: StrategyConfig,
|
||||
) -> None:
|
||||
# Default config: filter disabled. Anche con IV-RV negativa (RV>IV)
|
||||
# l'entry deve passare per non rompere setup pre-calibrazione.
|
||||
decision = validate_entry(_good_ctx(iv_minus_rv=Decimal("-2")), cfg)
|
||||
assert decision.accepted is True
|
||||
|
||||
|
||||
def test_iv_richness_gate_blocks_when_below_floor(cfg: StrategyConfig) -> None:
|
||||
strict = _strict_iv_rv_cfg(cfg, threshold=Decimal("5"))
|
||||
decision = validate_entry(_good_ctx(iv_minus_rv=Decimal("3")), strict)
|
||||
assert decision.accepted is False
|
||||
assert any("IV richness" in r for r in decision.reasons)
|
||||
|
||||
|
||||
def test_iv_richness_gate_passes_when_above_floor(cfg: StrategyConfig) -> None:
|
||||
strict = _strict_iv_rv_cfg(cfg, threshold=Decimal("5"))
|
||||
decision = validate_entry(_good_ctx(iv_minus_rv=Decimal("6")), strict)
|
||||
assert decision.accepted is True
|
||||
|
||||
|
||||
def test_iv_richness_gate_passes_at_exact_threshold(cfg: StrategyConfig) -> None:
|
||||
# Soglia inclusiva: IV-RV == soglia → accettato (gate è "<", non "<=").
|
||||
strict = _strict_iv_rv_cfg(cfg, threshold=Decimal("5"))
|
||||
decision = validate_entry(_good_ctx(iv_minus_rv=Decimal("5")), strict)
|
||||
assert decision.accepted is True
|
||||
|
||||
|
||||
def test_iv_richness_gate_skipped_when_data_missing(cfg: StrategyConfig) -> None:
|
||||
# MCP irraggiungibile: best-effort skip, non bloccare l'entry per
|
||||
# un problema di infrastruttura.
|
||||
strict = _strict_iv_rv_cfg(cfg, threshold=Decimal("5"))
|
||||
decision = validate_entry(_good_ctx(iv_minus_rv=None), strict)
|
||||
assert decision.accepted is True
|
||||
|
||||
|
||||
def test_validate_entry_accumulates_all_reasons(cfg: StrategyConfig) -> None:
|
||||
decision = validate_entry(
|
||||
_good_ctx(
|
||||
@@ -295,3 +351,188 @@ def test_bias_zero_division_safe(cfg: StrategyConfig) -> None:
|
||||
# eth_30d_ago == 0 must not crash; treat as no-bias (neutral)
|
||||
ctx = _trend(eth_now="3000", eth_30d_ago="0", funding_cross="0", dvol="60", adx="15")
|
||||
assert compute_bias(ctx, cfg) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IV-RV adaptive gate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _adaptive_cfg(**entry_overrides: object) -> StrategyConfig:
|
||||
"""Golden config con gate adattivo abilitato di default per test."""
|
||||
base_entry: dict[str, object] = {
|
||||
"iv_minus_rv_filter_enabled": True,
|
||||
"iv_minus_rv_adaptive_enabled": True,
|
||||
"iv_minus_rv_min": Decimal("0"),
|
||||
"iv_minus_rv_percentile": Decimal("0.25"),
|
||||
"iv_minus_rv_window_target_days": 60,
|
||||
"iv_minus_rv_window_min_days": 30,
|
||||
}
|
||||
base_entry.update(entry_overrides)
|
||||
return golden_config(entry=base_entry)
|
||||
|
||||
|
||||
def test_adaptive_pass_when_iv_rv_above_p25() -> None:
|
||||
cfg = _adaptive_cfg()
|
||||
history = tuple(Decimal(i) for i in range(1, 201))
|
||||
decision = validate_entry(
|
||||
_good_ctx(
|
||||
iv_minus_rv=Decimal("80"),
|
||||
iv_rv_history=history,
|
||||
iv_rv_n_days=30,
|
||||
),
|
||||
cfg,
|
||||
)
|
||||
assert decision.accepted is True
|
||||
assert not any("IV richness" in r for r in decision.reasons)
|
||||
|
||||
|
||||
def test_adaptive_blocks_when_iv_rv_below_p25() -> None:
|
||||
cfg = _adaptive_cfg()
|
||||
history = tuple(Decimal(i) for i in range(1, 201))
|
||||
decision = validate_entry(
|
||||
_good_ctx(
|
||||
iv_minus_rv=Decimal("20"),
|
||||
iv_rv_history=history,
|
||||
iv_rv_n_days=30,
|
||||
),
|
||||
cfg,
|
||||
)
|
||||
assert decision.accepted is False
|
||||
assert any("IV richness" in r and "rolling" in r for r in decision.reasons)
|
||||
|
||||
|
||||
def test_adaptive_with_n_days_zero_passes_warmup() -> None:
|
||||
"""Warmup hard: nessun giorno coperto → gate skip (fail-open)."""
|
||||
cfg = _adaptive_cfg()
|
||||
decision = validate_entry(
|
||||
_good_ctx(
|
||||
iv_minus_rv=Decimal("0.1"),
|
||||
iv_rv_history=(),
|
||||
iv_rv_n_days=0,
|
||||
),
|
||||
cfg,
|
||||
)
|
||||
assert decision.accepted is True
|
||||
|
||||
|
||||
def test_adaptive_with_floor_floor_binds_when_p25_low() -> None:
|
||||
cfg = _adaptive_cfg(iv_minus_rv_min=Decimal("3"))
|
||||
history = tuple(Decimal("0.5") for _ in range(200))
|
||||
decision = validate_entry(
|
||||
_good_ctx(
|
||||
iv_minus_rv=Decimal("1"),
|
||||
iv_rv_history=history,
|
||||
iv_rv_n_days=30,
|
||||
),
|
||||
cfg,
|
||||
)
|
||||
assert decision.accepted is False
|
||||
assert any("IV richness" in r for r in decision.reasons)
|
||||
|
||||
|
||||
def test_legacy_static_gate_still_works_when_adaptive_disabled() -> None:
|
||||
cfg = golden_config(entry={
|
||||
"iv_minus_rv_filter_enabled": True,
|
||||
"iv_minus_rv_adaptive_enabled": False,
|
||||
"iv_minus_rv_min": Decimal("3"),
|
||||
})
|
||||
decision = validate_entry(
|
||||
_good_ctx(iv_minus_rv=Decimal("2"), iv_rv_history=(), iv_rv_n_days=0),
|
||||
cfg,
|
||||
)
|
||||
assert decision.accepted is False
|
||||
assert any("IV richness below floor" in r for r in decision.reasons)
|
||||
|
||||
|
||||
def test_iv_minus_rv_none_skips_gate_in_both_modes() -> None:
|
||||
cfg = _adaptive_cfg()
|
||||
decision = validate_entry(
|
||||
_good_ctx(
|
||||
iv_minus_rv=None,
|
||||
iv_rv_history=tuple(Decimal(i) for i in range(1, 201)),
|
||||
iv_rv_n_days=30,
|
||||
),
|
||||
cfg,
|
||||
)
|
||||
assert decision.accepted is True
|
||||
|
||||
|
||||
def test_adaptive_with_n_days_one_uses_history_for_percentile() -> None:
|
||||
"""Singolo giorno disponibile (cadenza qualunque): gate attivo,
|
||||
soglia = P25 della finestra ricevuta. Dimostra che il warmup hard
|
||||
finisce a n_days=1 (non 30 come nella vecchia implementazione)."""
|
||||
cfg = _adaptive_cfg()
|
||||
history = tuple(Decimal(i) for i in range(1, 101)) # 1..100, P25 = 25.75
|
||||
# IV-RV sopra P25 → pass
|
||||
pass_decision = validate_entry(
|
||||
_good_ctx(
|
||||
iv_minus_rv=Decimal("30"),
|
||||
iv_rv_history=history,
|
||||
iv_rv_n_days=1,
|
||||
),
|
||||
cfg,
|
||||
)
|
||||
assert pass_decision.accepted is True
|
||||
# IV-RV sotto P25 → block
|
||||
block_decision = validate_entry(
|
||||
_good_ctx(
|
||||
iv_minus_rv=Decimal("10"),
|
||||
iv_rv_history=history,
|
||||
iv_rv_n_days=1,
|
||||
),
|
||||
cfg,
|
||||
)
|
||||
assert block_decision.accepted is False
|
||||
assert any("IV richness" in r and "rolling" in r for r in block_decision.reasons)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Vol-of-Vol guard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _vov_cfg(threshold: Decimal = Decimal("5")) -> StrategyConfig:
|
||||
return golden_config(entry={
|
||||
"vol_of_vol_guard_enabled": True,
|
||||
"vol_of_vol_threshold_pt": threshold,
|
||||
"vol_of_vol_lookback_hours": 24,
|
||||
})
|
||||
|
||||
|
||||
def test_vov_guard_blocks_on_large_dvol_shift() -> None:
|
||||
cfg = _vov_cfg()
|
||||
decision = validate_entry(
|
||||
_good_ctx(dvol_now=Decimal("56"), dvol_24h_ago=Decimal("50")), cfg
|
||||
)
|
||||
assert decision.accepted is False
|
||||
assert any("DVOL shifted" in r for r in decision.reasons)
|
||||
|
||||
|
||||
def test_vov_guard_passes_on_small_dvol_shift() -> None:
|
||||
cfg = _vov_cfg()
|
||||
decision = validate_entry(
|
||||
_good_ctx(dvol_now=Decimal("52"), dvol_24h_ago=Decimal("50")), cfg
|
||||
)
|
||||
assert decision.accepted is True
|
||||
|
||||
|
||||
def test_vov_guard_passes_when_lookback_missing() -> None:
|
||||
"""fail-open su gap dati: se dvol_24h_ago=None il guard non scatta."""
|
||||
cfg = _vov_cfg()
|
||||
decision = validate_entry(
|
||||
_good_ctx(dvol_now=Decimal("99"), dvol_24h_ago=None), cfg
|
||||
)
|
||||
# dvol_now=99 sarebbe oltre dvol_max=90; testiamo solo l'effetto VoV
|
||||
# consultando le reasons (dvol_now potrebbe avere altre reason ma non
|
||||
# quella VoV).
|
||||
assert not any("DVOL shifted" in r for r in decision.reasons)
|
||||
|
||||
|
||||
def test_vov_guard_disabled_does_nothing() -> None:
|
||||
cfg = golden_config(entry={"vol_of_vol_guard_enabled": False})
|
||||
decision = validate_entry(
|
||||
_good_ctx(dvol_now=Decimal("55"), dvol_24h_ago=Decimal("50")), cfg
|
||||
)
|
||||
# Nessuna reason VoV (il delta=5 sarebbe oltre soglia se attivo)
|
||||
assert not any("DVOL shifted" in r for r in decision.reasons)
|
||||
|
||||
@@ -271,3 +271,91 @@ def test_iron_condor_adverse_move_either_direction(cfg: StrategyConfig) -> None:
|
||||
)
|
||||
res = evaluate(snap, cfg)
|
||||
assert res.action == "CLOSE_AVERSE"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# §7-bis (D): vol-collapse harvest
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _harvest_cfg(
|
||||
cfg: StrategyConfig, *, threshold: str = "15"
|
||||
) -> StrategyConfig:
|
||||
"""Clona la golden config con la soglia di vol-harvest abilitata."""
|
||||
from cerbero_bite.config import ExitConfig
|
||||
return cfg.model_copy(
|
||||
update={
|
||||
"exit": ExitConfig(
|
||||
**{
|
||||
**cfg.exit.model_dump(),
|
||||
"vol_harvest_dvol_decrease": Decimal(threshold),
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_vol_harvest_disabled_by_default_does_not_fire(cfg: StrategyConfig) -> None:
|
||||
# Default: vol_harvest_dvol_decrease = 0 ⇒ filtro disabilitato.
|
||||
snap = _snapshot(
|
||||
credit_received_eth="0.030",
|
||||
mark_combo_now_eth="0.022", # in profit (debit < credit)
|
||||
dvol_at_entry="60",
|
||||
dvol_now="40", # crollato di 20 punti
|
||||
)
|
||||
res = evaluate(snap, cfg)
|
||||
assert res.action == "HOLD"
|
||||
|
||||
|
||||
def test_vol_harvest_fires_when_dvol_collapsed_in_profit(
|
||||
cfg: StrategyConfig,
|
||||
) -> None:
|
||||
harvest = _harvest_cfg(cfg, threshold="15")
|
||||
snap = _snapshot(
|
||||
credit_received_eth="0.030",
|
||||
mark_combo_now_eth="0.022", # in profit ma sopra profit_take 50%
|
||||
dvol_at_entry="60",
|
||||
dvol_now="42", # −18, supera la soglia 15
|
||||
)
|
||||
res = evaluate(snap, harvest)
|
||||
assert res.action == "CLOSE_VOL_HARVEST"
|
||||
assert "harvest" in res.reason
|
||||
|
||||
|
||||
def test_vol_harvest_does_not_fire_when_in_loss(cfg: StrategyConfig) -> None:
|
||||
# Anche se DVOL crolla, se siamo in perdita non vogliamo harvest:
|
||||
# è una funzione di "esci con il profitto in mano", non un panico.
|
||||
harvest = _harvest_cfg(cfg, threshold="15")
|
||||
snap = _snapshot(
|
||||
credit_received_eth="0.030",
|
||||
mark_combo_now_eth="0.040", # debit > credit ⇒ in perdita
|
||||
dvol_at_entry="60",
|
||||
dvol_now="42",
|
||||
)
|
||||
res = evaluate(snap, harvest)
|
||||
assert res.action != "CLOSE_VOL_HARVEST"
|
||||
|
||||
|
||||
def test_vol_harvest_does_not_fire_below_threshold(cfg: StrategyConfig) -> None:
|
||||
harvest = _harvest_cfg(cfg, threshold="15")
|
||||
snap = _snapshot(
|
||||
credit_received_eth="0.030",
|
||||
mark_combo_now_eth="0.022",
|
||||
dvol_at_entry="60",
|
||||
dvol_now="50", # −10, sotto la soglia 15
|
||||
)
|
||||
res = evaluate(snap, harvest)
|
||||
assert res.action == "HOLD"
|
||||
|
||||
|
||||
def test_profit_take_wins_over_vol_harvest(cfg: StrategyConfig) -> None:
|
||||
# Quando il profit-take è già colpito, non passiamo per vol-harvest.
|
||||
harvest = _harvest_cfg(cfg, threshold="15")
|
||||
snap = _snapshot(
|
||||
credit_received_eth="0.030",
|
||||
mark_combo_now_eth="0.014", # ≤ 50% credit ⇒ profit-take
|
||||
dvol_at_entry="60",
|
||||
dvol_now="42",
|
||||
)
|
||||
res = evaluate(snap, harvest)
|
||||
assert res.action == "CLOSE_PROFIT"
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Tests for the GUI live-balances fetcher (soft-error handling)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from cerbero_bite.clients.deribit import DeribitClient
|
||||
from cerbero_bite.gui.live_data import _fetch_deribit_currency
|
||||
|
||||
|
||||
class _FakeDeribit:
|
||||
def __init__(self, payload: dict[str, Any] | Exception) -> None:
|
||||
self._payload = payload
|
||||
|
||||
async def get_account_summary(self, currency: str) -> dict[str, Any]:
|
||||
del currency # not used by the fake; kept for signature parity
|
||||
if isinstance(self._payload, Exception):
|
||||
raise self._payload
|
||||
return self._payload
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_soft_error_payload_becomes_row_error() -> None:
|
||||
"""MCP V2 returns 200 + ``error`` field when upstream auth fails."""
|
||||
fake = _FakeDeribit(
|
||||
{
|
||||
"equity": 0,
|
||||
"balance": 0,
|
||||
"available_funds": 0,
|
||||
"unrealized_pnl": 0,
|
||||
"error": "Deribit auth failed (code=13004): invalid_credentials",
|
||||
}
|
||||
)
|
||||
row = await _fetch_deribit_currency(
|
||||
deribit=fake, # type: ignore[arg-type]
|
||||
currency="USDC",
|
||||
)
|
||||
assert row.exchange == "deribit"
|
||||
assert row.currency == "USDC"
|
||||
assert row.equity is None
|
||||
assert row.available is None
|
||||
assert row.unrealized_pnl is None
|
||||
assert row.error is not None
|
||||
assert "invalid_credentials" in row.error
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clean_payload_populates_balance_fields() -> None:
|
||||
fake = _FakeDeribit(
|
||||
{
|
||||
"equity": "12.5",
|
||||
"available_funds": "10.0",
|
||||
"unrealized_pnl": "-0.25",
|
||||
}
|
||||
)
|
||||
row = await _fetch_deribit_currency(
|
||||
deribit=fake, # type: ignore[arg-type]
|
||||
currency="USDC",
|
||||
)
|
||||
assert row.error is None
|
||||
assert row.equity == Decimal("12.5")
|
||||
assert row.available == Decimal("10.0")
|
||||
assert row.unrealized_pnl == Decimal("-0.25")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exception_becomes_row_error() -> None:
|
||||
fake = _FakeDeribit(RuntimeError("boom"))
|
||||
row = await _fetch_deribit_currency(
|
||||
deribit=fake, # type: ignore[arg-type]
|
||||
currency="USDC",
|
||||
)
|
||||
assert row.equity is None
|
||||
assert row.error is not None
|
||||
assert "RuntimeError" in row.error
|
||||
assert "boom" in row.error
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_blank_error_field_is_ignored() -> None:
|
||||
"""An ``error`` field that is empty/None must not trigger the soft-error path."""
|
||||
fake = _FakeDeribit(
|
||||
{"equity": "1.0", "available_funds": "1.0", "unrealized_pnl": "0.0", "error": None}
|
||||
)
|
||||
row = await _fetch_deribit_currency(
|
||||
deribit=fake, # type: ignore[arg-type]
|
||||
currency="USDC",
|
||||
)
|
||||
assert row.error is None
|
||||
assert row.equity == Decimal("1.0")
|
||||
|
||||
|
||||
# Sanity-check: the production class signature is what we expect to be drop-in
|
||||
# replaceable by ``_FakeDeribit``.
|
||||
def test_fake_matches_production_signature() -> None:
|
||||
assert hasattr(DeribitClient, "get_account_summary")
|
||||
@@ -0,0 +1,205 @@
|
||||
"""Tests for runtime.manual_actions_consumer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from cerbero_bite.runtime.manual_actions_consumer import consume_manual_actions
|
||||
from cerbero_bite.safety.audit_log import AuditLog
|
||||
from cerbero_bite.safety.kill_switch import KillSwitch, KillSwitchError
|
||||
from cerbero_bite.state import Repository, connect, run_migrations, transaction
|
||||
from cerbero_bite.state.models import ManualAction
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime(2026, 4, 30, 12, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def _ctx(tmp_path: Path):
|
||||
db_path = tmp_path / "state.sqlite"
|
||||
audit_path = tmp_path / "audit.log"
|
||||
|
||||
repo = Repository()
|
||||
conn = connect(db_path)
|
||||
run_migrations(conn)
|
||||
with transaction(conn):
|
||||
repo.init_system_state(conn, config_version="1.0.0", now=_now())
|
||||
conn.close()
|
||||
|
||||
audit = AuditLog(audit_path)
|
||||
ks = KillSwitch(
|
||||
connection_factory=lambda: connect(db_path),
|
||||
repository=repo,
|
||||
audit_log=audit,
|
||||
clock=_now,
|
||||
)
|
||||
|
||||
ctx = MagicMock()
|
||||
ctx.db_path = db_path
|
||||
ctx.repository = repo
|
||||
ctx.kill_switch = ks
|
||||
ctx.audit_log = audit
|
||||
return ctx
|
||||
|
||||
|
||||
def _enqueue(ctx, kind: str, payload: dict[str, object]) -> int:
|
||||
conn = connect(ctx.db_path)
|
||||
try:
|
||||
with transaction(conn):
|
||||
return ctx.repository.enqueue_manual_action(
|
||||
conn,
|
||||
ManualAction(
|
||||
kind=kind, # type: ignore[arg-type]
|
||||
payload_json=json.dumps(payload),
|
||||
created_at=_now(),
|
||||
),
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _fetch_action(ctx, action_id: int):
|
||||
conn = connect(ctx.db_path)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT consumed_at, consumed_by, result FROM manual_actions WHERE id = ?",
|
||||
(action_id,),
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
return row
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_arm_kill_arms_kill_switch(tmp_path: Path) -> None:
|
||||
ctx = _ctx(tmp_path)
|
||||
aid = _enqueue(ctx, "arm_kill", {"reason": "GUI typed yes"})
|
||||
assert ctx.kill_switch.is_armed() is False
|
||||
|
||||
n = await consume_manual_actions(ctx, now=_now())
|
||||
assert n == 1
|
||||
assert ctx.kill_switch.is_armed() is True
|
||||
|
||||
row = _fetch_action(ctx, aid)
|
||||
assert row["consumed_by"] == "engine"
|
||||
assert row["result"] == "ok"
|
||||
assert row["consumed_at"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disarm_kill_disarms_kill_switch(tmp_path: Path) -> None:
|
||||
ctx = _ctx(tmp_path)
|
||||
ctx.kill_switch.arm(reason="prior", source="manual")
|
||||
assert ctx.kill_switch.is_armed() is True
|
||||
|
||||
aid = _enqueue(ctx, "disarm_kill", {"reason": "operator override"})
|
||||
n = await consume_manual_actions(ctx, now=_now())
|
||||
assert n == 1
|
||||
assert ctx.kill_switch.is_armed() is False
|
||||
row = _fetch_action(ctx, aid)
|
||||
assert row["result"] == "ok"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_consumer_drains_queue(tmp_path: Path) -> None:
|
||||
ctx = _ctx(tmp_path)
|
||||
_enqueue(ctx, "arm_kill", {"reason": "first"})
|
||||
_enqueue(ctx, "disarm_kill", {"reason": "second"})
|
||||
_enqueue(ctx, "arm_kill", {"reason": "third"})
|
||||
|
||||
n = await consume_manual_actions(ctx, now=_now())
|
||||
assert n == 3
|
||||
assert ctx.kill_switch.is_armed() is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsupported_kind_marked_not_supported(tmp_path: Path) -> None:
|
||||
ctx = _ctx(tmp_path)
|
||||
aid = _enqueue(ctx, "force_close", {"proposal_id": "abc"})
|
||||
n = await consume_manual_actions(ctx, now=_now())
|
||||
assert n == 1
|
||||
row = _fetch_action(ctx, aid)
|
||||
assert row["result"] == "not_supported"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_payload_uses_default_reason(tmp_path: Path) -> None:
|
||||
ctx = _ctx(tmp_path)
|
||||
_enqueue(ctx, "arm_kill", {})
|
||||
n = await consume_manual_actions(ctx, now=_now())
|
||||
assert n == 1
|
||||
assert ctx.kill_switch.is_armed() is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kill_switch_error_caught_and_recorded(tmp_path: Path) -> None:
|
||||
ctx = _ctx(tmp_path)
|
||||
# Replace the kill switch with one whose arm raises.
|
||||
bad_ks = MagicMock()
|
||||
bad_ks.arm.side_effect = KillSwitchError("simulated")
|
||||
bad_ks.is_armed.return_value = False
|
||||
ctx.kill_switch = bad_ks
|
||||
|
||||
aid = _enqueue(ctx, "arm_kill", {"reason": "x"})
|
||||
n = await consume_manual_actions(ctx, now=_now())
|
||||
assert n == 1
|
||||
row = _fetch_action(ctx, aid)
|
||||
assert "KillSwitchError" in (row["result"] or "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_queue_returns_zero(tmp_path: Path) -> None:
|
||||
ctx = _ctx(tmp_path)
|
||||
n = await consume_manual_actions(ctx, now=_now())
|
||||
assert n == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_cycle_dispatches_to_runner(tmp_path: Path) -> None:
|
||||
ctx = _ctx(tmp_path)
|
||||
calls: list[str] = []
|
||||
|
||||
async def _entry() -> None:
|
||||
calls.append("entry")
|
||||
|
||||
aid = _enqueue(ctx, "run_cycle", {"cycle": "entry"})
|
||||
n = await consume_manual_actions(
|
||||
ctx, cycle_runners={"entry": _entry}, now=_now()
|
||||
)
|
||||
assert n == 1
|
||||
assert calls == ["entry"]
|
||||
row = _fetch_action(ctx, aid)
|
||||
assert row["result"] == "ok: ran entry"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_cycle_unknown_marked_error(tmp_path: Path) -> None:
|
||||
ctx = _ctx(tmp_path)
|
||||
|
||||
async def _entry() -> None:
|
||||
raise AssertionError("should not run")
|
||||
|
||||
aid = _enqueue(ctx, "run_cycle", {"cycle": "monitor"})
|
||||
n = await consume_manual_actions(
|
||||
ctx, cycle_runners={"entry": _entry}, now=_now()
|
||||
)
|
||||
assert n == 1
|
||||
row = _fetch_action(ctx, aid)
|
||||
assert "unknown cycle" in (row["result"] or "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_cycle_without_runners_marks_not_supported(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
ctx = _ctx(tmp_path)
|
||||
aid = _enqueue(ctx, "run_cycle", {"cycle": "entry"})
|
||||
n = await consume_manual_actions(ctx, now=_now())
|
||||
assert n == 1
|
||||
row = _fetch_action(ctx, aid)
|
||||
assert row["result"] == "not_supported"
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Tests for runtime.market_snapshot_cycle (best-effort collector)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from cerbero_bite.clients._exceptions import McpDataAnomalyError
|
||||
from cerbero_bite.clients.deribit import DealerGammaSnapshot
|
||||
from cerbero_bite.clients.sentiment import LiquidationHeatmap
|
||||
from cerbero_bite.config import golden_config
|
||||
from cerbero_bite.runtime.market_snapshot_cycle import collect_market_snapshot
|
||||
from cerbero_bite.state import Repository, connect, run_migrations, transaction
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime(2026, 4, 30, 12, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def _ctx(tmp_path: Path) -> MagicMock:
|
||||
db_path = tmp_path / "state.sqlite"
|
||||
repo = Repository()
|
||||
conn = connect(db_path)
|
||||
run_migrations(conn)
|
||||
with transaction(conn):
|
||||
repo.init_system_state(conn, config_version="1.0.0", now=_now())
|
||||
conn.close()
|
||||
|
||||
ctx = MagicMock()
|
||||
ctx.db_path = db_path
|
||||
ctx.repository = repo
|
||||
ctx.cfg = golden_config()
|
||||
|
||||
# Default: every feed succeeds with sane mock values.
|
||||
ctx.deribit = MagicMock()
|
||||
ctx.deribit.spot_perp_price = AsyncMock(return_value=Decimal("3000"))
|
||||
ctx.deribit.latest_dvol = AsyncMock(return_value=Decimal("55"))
|
||||
ctx.deribit.realized_vol = AsyncMock(
|
||||
return_value={
|
||||
"rv_14d": Decimal("28"),
|
||||
"rv_30d": Decimal("35"),
|
||||
"iv_minus_rv_30d": Decimal("20"),
|
||||
}
|
||||
)
|
||||
ctx.deribit.dealer_gamma_profile = AsyncMock(
|
||||
return_value=DealerGammaSnapshot(
|
||||
spot_price=Decimal("3000"),
|
||||
total_net_dealer_gamma=Decimal("-66000000"),
|
||||
gamma_flip_level=Decimal("2900"),
|
||||
strikes_analyzed=42,
|
||||
)
|
||||
)
|
||||
|
||||
ctx.hyperliquid = MagicMock()
|
||||
ctx.hyperliquid.funding_rate_annualized = AsyncMock(
|
||||
return_value=Decimal("0.45")
|
||||
)
|
||||
|
||||
ctx.sentiment = MagicMock()
|
||||
ctx.sentiment.funding_cross_median_annualized = AsyncMock(
|
||||
return_value=Decimal("0.30")
|
||||
)
|
||||
ctx.sentiment.liquidation_heatmap = AsyncMock(
|
||||
return_value=LiquidationHeatmap(
|
||||
asset="ETH",
|
||||
avg_funding_rate=Decimal("0.0003"),
|
||||
oi_delta_pct_4h=Decimal("1.2"),
|
||||
oi_delta_pct_24h=None,
|
||||
long_squeeze_risk="low",
|
||||
short_squeeze_risk="low",
|
||||
)
|
||||
)
|
||||
|
||||
ctx.macro = MagicMock()
|
||||
ctx.macro.next_high_severity_within = AsyncMock(return_value=3)
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
def _read_snapshots(ctx: MagicMock, asset: str) -> list[dict]:
|
||||
import sqlite3
|
||||
|
||||
conn = connect(ctx.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM market_snapshots WHERE asset = ? ORDER BY timestamp",
|
||||
(asset,),
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_happy_path_persists_one_row_per_asset(tmp_path: Path) -> None:
|
||||
ctx = _ctx(tmp_path)
|
||||
n = await collect_market_snapshot(ctx, assets=("ETH", "BTC"), now=_now())
|
||||
assert n == 2
|
||||
eth_rows = _read_snapshots(ctx, "ETH")
|
||||
btc_rows = _read_snapshots(ctx, "BTC")
|
||||
assert len(eth_rows) == 1
|
||||
assert len(btc_rows) == 1
|
||||
eth = eth_rows[0]
|
||||
assert eth["fetch_ok"] == 1
|
||||
assert eth["fetch_errors_json"] is None
|
||||
assert Decimal(str(eth["spot"])) == Decimal("3000")
|
||||
assert Decimal(str(eth["dealer_net_gamma"])) == Decimal("-66000000")
|
||||
assert eth["macro_days_to_event"] == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failure_in_one_metric_keeps_row_with_error(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
ctx = _ctx(tmp_path)
|
||||
ctx.deribit.dealer_gamma_profile = AsyncMock(
|
||||
side_effect=McpDataAnomalyError(
|
||||
"boom", service="deribit", tool="get_dealer_gamma_profile"
|
||||
)
|
||||
)
|
||||
n = await collect_market_snapshot(ctx, assets=("ETH",), now=_now())
|
||||
assert n == 1
|
||||
rows = _read_snapshots(ctx, "ETH")
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["fetch_ok"] == 0
|
||||
errors = json.loads(rows[0]["fetch_errors_json"])
|
||||
assert "dealer_gamma" in errors
|
||||
assert rows[0]["dealer_net_gamma"] is None
|
||||
# Other metrics still populated.
|
||||
assert Decimal(str(rows[0]["spot"])) == Decimal("3000")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_btc_uses_btc_in_calls(tmp_path: Path) -> None:
|
||||
ctx = _ctx(tmp_path)
|
||||
await collect_market_snapshot(ctx, assets=("BTC",), now=_now())
|
||||
ctx.deribit.spot_perp_price.assert_awaited_with("BTC")
|
||||
ctx.hyperliquid.funding_rate_annualized.assert_awaited_with("BTC")
|
||||
ctx.sentiment.liquidation_heatmap.assert_awaited_with("BTC")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_macro_failure_only_nulls_macro(tmp_path: Path) -> None:
|
||||
ctx = _ctx(tmp_path)
|
||||
ctx.macro.next_high_severity_within = AsyncMock(
|
||||
side_effect=RuntimeError("calendar down")
|
||||
)
|
||||
await collect_market_snapshot(ctx, assets=("ETH",), now=_now())
|
||||
rows = _read_snapshots(ctx, "ETH")
|
||||
assert rows[0]["macro_days_to_event"] is None
|
||||
assert rows[0]["fetch_ok"] == 0
|
||||
errors = json.loads(rows[0]["fetch_errors_json"])
|
||||
assert "macro" in errors
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_zero_for_empty_assets(tmp_path: Path) -> None:
|
||||
ctx = _ctx(tmp_path)
|
||||
n = await collect_market_snapshot(ctx, assets=(), now=_now())
|
||||
assert n == 0
|
||||
|
||||
|
||||
def _read_dvol_history(ctx: MagicMock) -> list[dict]:
|
||||
import sqlite3
|
||||
|
||||
conn = connect(ctx.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM dvol_history ORDER BY timestamp"
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_eth_snapshot_mirrors_into_dvol_history(tmp_path: Path) -> None:
|
||||
ctx = _ctx(tmp_path)
|
||||
await collect_market_snapshot(ctx, assets=("ETH", "BTC"), now=_now())
|
||||
rows = _read_dvol_history(ctx)
|
||||
assert len(rows) == 1
|
||||
assert Decimal(str(rows[0]["dvol"])) == Decimal("55")
|
||||
assert Decimal(str(rows[0]["eth_spot"])) == Decimal("3000")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_btc_only_snapshot_does_not_touch_dvol_history(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
ctx = _ctx(tmp_path)
|
||||
await collect_market_snapshot(ctx, assets=("BTC",), now=_now())
|
||||
assert _read_dvol_history(ctx) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_eth_snapshot_skips_dvol_history_when_dvol_missing(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
ctx = _ctx(tmp_path)
|
||||
ctx.deribit.latest_dvol = AsyncMock(side_effect=RuntimeError("no dvol"))
|
||||
await collect_market_snapshot(ctx, assets=("ETH",), now=_now())
|
||||
# market_snapshots row still persisted, but dvol_history must stay empty
|
||||
# because its schema enforces NOT NULL on dvol/eth_spot.
|
||||
assert _read_dvol_history(ctx) == []
|
||||
@@ -1,14 +1,14 @@
|
||||
"""Tests for the MCP endpoint and token resolver."""
|
||||
"""Tests for the MCP endpoint, token and bot-tag resolver."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from cerbero_bite.config.mcp_endpoints import (
|
||||
DEFAULT_BOT_TAG,
|
||||
DEFAULT_ENDPOINTS,
|
||||
MCP_SERVICES,
|
||||
load_bot_tag,
|
||||
load_endpoints,
|
||||
load_token,
|
||||
)
|
||||
@@ -16,7 +16,7 @@ from cerbero_bite.config.mcp_endpoints import (
|
||||
|
||||
def test_defaults_match_known_docker_dns() -> None:
|
||||
assert DEFAULT_ENDPOINTS["deribit"] == "http://mcp-deribit:9011"
|
||||
assert DEFAULT_ENDPOINTS["telegram"] == "http://mcp-telegram:9017"
|
||||
assert DEFAULT_ENDPOINTS["sentiment"] == "http://mcp-sentiment:9014"
|
||||
|
||||
|
||||
def test_load_endpoints_uses_defaults_when_env_empty() -> None:
|
||||
@@ -46,31 +46,70 @@ def test_for_service_unknown_raises_key_error() -> None:
|
||||
endpoints.for_service("nope")
|
||||
|
||||
|
||||
def test_load_token_uses_explicit_path(tmp_path: Path) -> None:
|
||||
target = tmp_path / "core.token"
|
||||
target.write_text("abcdef\n", encoding="utf-8")
|
||||
assert load_token(path=target) == "abcdef"
|
||||
def test_load_token_uses_explicit_value() -> None:
|
||||
assert load_token(value="abcdef") == "abcdef"
|
||||
|
||||
|
||||
def test_load_token_uses_env_var(tmp_path: Path) -> None:
|
||||
target = tmp_path / "core.token"
|
||||
target.write_text("xyz", encoding="utf-8")
|
||||
token = load_token(env={"CERBERO_BITE_CORE_TOKEN_FILE": str(target)})
|
||||
def test_load_token_strips_whitespace_in_explicit_value() -> None:
|
||||
assert load_token(value=" abcdef\n") == "abcdef"
|
||||
|
||||
|
||||
def test_load_token_uses_env_var() -> None:
|
||||
token = load_token(env={"CERBERO_BITE_MCP_TOKEN": "xyz"})
|
||||
assert token == "xyz"
|
||||
|
||||
|
||||
def test_load_token_raises_when_file_missing(tmp_path: Path) -> None:
|
||||
with pytest.raises(FileNotFoundError):
|
||||
load_token(path=tmp_path / "missing")
|
||||
def test_load_token_strips_whitespace_in_env_var() -> None:
|
||||
token = load_token(env={"CERBERO_BITE_MCP_TOKEN": " xyz\n"})
|
||||
assert token == "xyz"
|
||||
|
||||
|
||||
def test_load_token_raises_when_file_empty(tmp_path: Path) -> None:
|
||||
target = tmp_path / "empty"
|
||||
target.write_text("", encoding="utf-8")
|
||||
def test_load_token_raises_when_missing() -> None:
|
||||
with pytest.raises(ValueError, match="CERBERO_BITE_MCP_TOKEN"):
|
||||
load_token(env={})
|
||||
|
||||
|
||||
def test_load_token_raises_when_empty() -> None:
|
||||
with pytest.raises(ValueError, match="CERBERO_BITE_MCP_TOKEN"):
|
||||
load_token(env={"CERBERO_BITE_MCP_TOKEN": " "})
|
||||
|
||||
|
||||
def test_load_token_raises_when_explicit_value_blank() -> None:
|
||||
with pytest.raises(ValueError, match="empty"):
|
||||
load_token(path=target)
|
||||
load_token(value=" ")
|
||||
|
||||
|
||||
def test_load_bot_tag_default_when_unset() -> None:
|
||||
assert load_bot_tag(env={}) == DEFAULT_BOT_TAG
|
||||
|
||||
|
||||
def test_load_bot_tag_explicit_value_overrides_env() -> None:
|
||||
tag = load_bot_tag(value="BOT__CUSTOM", env={"CERBERO_BITE_MCP_BOT_TAG": "x"})
|
||||
assert tag == "BOT__CUSTOM"
|
||||
|
||||
|
||||
def test_load_bot_tag_uses_env_when_set() -> None:
|
||||
tag = load_bot_tag(env={"CERBERO_BITE_MCP_BOT_TAG": "BOT__SHADOW"})
|
||||
assert tag == "BOT__SHADOW"
|
||||
|
||||
|
||||
def test_load_bot_tag_strips_whitespace() -> None:
|
||||
tag = load_bot_tag(env={"CERBERO_BITE_MCP_BOT_TAG": " BOT__X\n"})
|
||||
assert tag == "BOT__X"
|
||||
|
||||
|
||||
def test_load_bot_tag_falls_back_to_default_when_blank_env() -> None:
|
||||
tag = load_bot_tag(env={"CERBERO_BITE_MCP_BOT_TAG": " "})
|
||||
assert tag == DEFAULT_BOT_TAG
|
||||
|
||||
|
||||
def test_load_bot_tag_rejects_too_long() -> None:
|
||||
with pytest.raises(ValueError, match="exceeds 64"):
|
||||
load_bot_tag(value="x" * 65)
|
||||
|
||||
|
||||
def test_mcp_services_table_is_complete() -> None:
|
||||
expected = {"deribit", "hyperliquid", "macro", "sentiment", "telegram", "portfolio"}
|
||||
# Telegram and Portfolio are now in-process and must NOT be listed
|
||||
# as shared MCP services.
|
||||
expected = {"deribit", "hyperliquid", "macro", "sentiment"}
|
||||
assert set(MCP_SERVICES) == expected
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""TDD per :mod:`cerbero_bite.runtime.option_chain_snapshot_cycle`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from cerbero_bite.clients.deribit import InstrumentMeta
|
||||
from cerbero_bite.runtime.option_chain_snapshot_cycle import (
|
||||
collect_option_chain_snapshot,
|
||||
)
|
||||
from cerbero_bite.state.models import OptionChainQuoteRecord
|
||||
|
||||
|
||||
_NOW = datetime(2026, 5, 4, 13, 55, tzinfo=UTC)
|
||||
|
||||
|
||||
def _meta(name: str, strike: int, expiry_dte: int = 18) -> InstrumentMeta:
|
||||
expiry = _NOW.replace(hour=8, minute=0, second=0)
|
||||
expiry = expiry.replace(day=expiry.day) + (
|
||||
# add days
|
||||
__import__("datetime").timedelta(days=expiry_dte)
|
||||
)
|
||||
return InstrumentMeta(
|
||||
name=name,
|
||||
strike=Decimal(str(strike)),
|
||||
expiry=expiry,
|
||||
option_type="P",
|
||||
open_interest=Decimal("100"),
|
||||
tick_size=Decimal("0.0005"),
|
||||
min_trade_amount=Decimal("1"),
|
||||
)
|
||||
|
||||
|
||||
def _ticker(name: str, *, mark: float = 0.020, bid: float = 0.018,
|
||||
ask: float = 0.022, delta: float = -0.12) -> dict:
|
||||
return {
|
||||
"instrument_name": name,
|
||||
"bid": bid,
|
||||
"ask": ask,
|
||||
"mark_price": mark,
|
||||
"mark_iv": 60.0,
|
||||
"volume_24h": 50,
|
||||
"greeks": {
|
||||
"delta": delta,
|
||||
"gamma": 0.001,
|
||||
"theta": -0.0005,
|
||||
"vega": 0.10,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cfg() -> object:
|
||||
from cerbero_bite.config import golden_config
|
||||
return golden_config()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_ctx(cfg: object) -> MagicMock:
|
||||
"""Mock minimal RuntimeContext."""
|
||||
ctx = MagicMock()
|
||||
ctx.cfg = cfg
|
||||
ctx.db_path = ":memory:"
|
||||
return ctx
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collector_persists_one_quote_per_instrument(
|
||||
fake_ctx: MagicMock,
|
||||
) -> None:
|
||||
metas = [_meta("ETH-21MAY26-2475-P", 2475), _meta("ETH-21MAY26-2400-P", 2400)]
|
||||
fake_ctx.deribit.options_chain = AsyncMock(return_value=metas)
|
||||
fake_ctx.deribit.get_tickers = AsyncMock(
|
||||
return_value=[_ticker(m.name) for m in metas]
|
||||
)
|
||||
persisted: list[list[OptionChainQuoteRecord]] = []
|
||||
|
||||
def _record(_conn: object, qs: list[OptionChainQuoteRecord]) -> int:
|
||||
persisted.append(qs)
|
||||
return len(qs)
|
||||
|
||||
fake_ctx.repository.record_option_chain_snapshot = _record
|
||||
|
||||
n = await collect_option_chain_snapshot(fake_ctx, asset="ETH", now=_NOW)
|
||||
assert n == 2
|
||||
assert len(persisted) == 1
|
||||
assert {q.instrument_name for q in persisted[0]} == {
|
||||
"ETH-21MAY26-2475-P", "ETH-21MAY26-2400-P",
|
||||
}
|
||||
# Tutti i quote condividono il timestamp del cron tick.
|
||||
assert all(q.timestamp == _NOW for q in persisted[0])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collector_handles_missing_tickers_with_null_fields(
|
||||
fake_ctx: MagicMock,
|
||||
) -> None:
|
||||
metas = [_meta("ETH-21MAY26-2475-P", 2475)]
|
||||
fake_ctx.deribit.options_chain = AsyncMock(return_value=metas)
|
||||
fake_ctx.deribit.get_tickers = AsyncMock(return_value=[]) # vuoto
|
||||
persisted: list[list[OptionChainQuoteRecord]] = []
|
||||
|
||||
def _record(_conn: object, qs: list[OptionChainQuoteRecord]) -> int:
|
||||
persisted.append(qs)
|
||||
return len(qs)
|
||||
|
||||
fake_ctx.repository.record_option_chain_snapshot = _record
|
||||
|
||||
n = await collect_option_chain_snapshot(fake_ctx, now=_NOW)
|
||||
assert n == 1
|
||||
assert persisted[0][0].mid is None # ticker mancante ⇒ campi NULL
|
||||
assert persisted[0][0].instrument_name == "ETH-21MAY26-2475-P"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collector_returns_zero_when_chain_empty(
|
||||
fake_ctx: MagicMock,
|
||||
) -> None:
|
||||
fake_ctx.deribit.options_chain = AsyncMock(return_value=[])
|
||||
n = await collect_option_chain_snapshot(fake_ctx, now=_NOW)
|
||||
assert n == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collector_swallows_chain_fetch_failure(
|
||||
fake_ctx: MagicMock,
|
||||
) -> None:
|
||||
fake_ctx.deribit.options_chain = AsyncMock(side_effect=RuntimeError("boom"))
|
||||
n = await collect_option_chain_snapshot(fake_ctx, now=_NOW)
|
||||
assert n == 0 # best-effort: non rilancia
|
||||
@@ -0,0 +1,395 @@
|
||||
"""TDD per i nuovi helper repository del gate IV-RV adattivo.
|
||||
|
||||
Spec: distinct-days policy — il caller (entry_cycle) interroga il
|
||||
numero di giorni coperti separatamente dai valori della finestra,
|
||||
così che cadenze miste (tick live 15min + backfill daily) restino
|
||||
statisticamente coerenti.
|
||||
|
||||
Helpers:
|
||||
* ``count_iv_rv_distinct_days(asset, max_days, as_of) -> int``
|
||||
* ``iv_rv_values_for_window(asset, window_days, as_of) -> list[Decimal]``
|
||||
* ``dvol_lookback`` (invariato, riusato dal Vol-of-Vol guard)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from cerbero_bite.state.db import connect, run_migrations
|
||||
from cerbero_bite.state.models import MarketSnapshotRecord
|
||||
from cerbero_bite.state.repository import Repository
|
||||
|
||||
|
||||
def _snap(
|
||||
*,
|
||||
ts: datetime,
|
||||
asset: str = "ETH",
|
||||
iv_minus_rv: Decimal | None = Decimal("2"),
|
||||
fetch_ok: bool = True,
|
||||
dvol: Decimal = Decimal("50"),
|
||||
) -> MarketSnapshotRecord:
|
||||
return MarketSnapshotRecord(
|
||||
timestamp=ts,
|
||||
asset=asset,
|
||||
spot=Decimal("2000"),
|
||||
dvol=dvol,
|
||||
realized_vol_30d=Decimal("48"),
|
||||
iv_minus_rv=iv_minus_rv,
|
||||
funding_perp_annualized=Decimal("0"),
|
||||
funding_cross_annualized=Decimal("0"),
|
||||
dealer_net_gamma=Decimal("0"),
|
||||
gamma_flip_level=None,
|
||||
oi_delta_pct_4h=None,
|
||||
liquidation_long_risk="low",
|
||||
liquidation_short_risk="low",
|
||||
macro_days_to_event=None,
|
||||
fetch_ok=fetch_ok,
|
||||
fetch_errors_json=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_one_day(tmp_path) -> sqlite3.Connection:
|
||||
"""SQLite temp con 96 tick ETH a 15min (1 giorno) e fetch_ok=1."""
|
||||
conn = connect(str(tmp_path / "test.sqlite"))
|
||||
run_migrations(conn)
|
||||
repo = Repository()
|
||||
base = datetime(2026, 5, 1, 0, 0, tzinfo=UTC)
|
||||
for i in range(96):
|
||||
repo.record_market_snapshot(
|
||||
conn,
|
||||
_snap(
|
||||
ts=base + timedelta(minutes=15 * i),
|
||||
iv_minus_rv=Decimal("2") + Decimal(i) / Decimal("100"),
|
||||
dvol=Decimal("50") + Decimal(i) / Decimal("10"),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return conn
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_three_days_mixed(tmp_path) -> sqlite3.Connection:
|
||||
"""SQLite temp con 3 giorni ETH:
|
||||
- day1 (2026-05-01): 96 tick @ 15min, valori 1..96
|
||||
- day2 (2026-05-02): 1 record daily a 12:00, valore 100 (backfill style)
|
||||
- day3 (2026-05-03): 4 tick orari, valori 200, 201, 202, 203
|
||||
Più 1 giorno BTC isolato (per cross-asset isolation).
|
||||
"""
|
||||
conn = connect(str(tmp_path / "test.sqlite"))
|
||||
run_migrations(conn)
|
||||
repo = Repository()
|
||||
|
||||
day1 = datetime(2026, 5, 1, 0, 0, tzinfo=UTC)
|
||||
for i in range(96):
|
||||
repo.record_market_snapshot(
|
||||
conn,
|
||||
_snap(
|
||||
ts=day1 + timedelta(minutes=15 * i),
|
||||
iv_minus_rv=Decimal(i + 1),
|
||||
),
|
||||
)
|
||||
repo.record_market_snapshot(
|
||||
conn,
|
||||
_snap(ts=datetime(2026, 5, 2, 12, 0, tzinfo=UTC), iv_minus_rv=Decimal("100")),
|
||||
)
|
||||
day3 = datetime(2026, 5, 3, 0, 0, tzinfo=UTC)
|
||||
for i in range(4):
|
||||
repo.record_market_snapshot(
|
||||
conn,
|
||||
_snap(
|
||||
ts=day3 + timedelta(hours=i),
|
||||
iv_minus_rv=Decimal(200 + i),
|
||||
),
|
||||
)
|
||||
repo.record_market_snapshot(
|
||||
conn,
|
||||
_snap(
|
||||
ts=datetime(2026, 4, 30, 0, 0, tzinfo=UTC),
|
||||
asset="BTC",
|
||||
iv_minus_rv=Decimal("999"),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return conn
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# count_iv_rv_distinct_days
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_count_distinct_days_returns_one_for_single_day_history(db_one_day) -> None:
|
||||
repo = Repository()
|
||||
n = repo.count_iv_rv_distinct_days(
|
||||
db_one_day,
|
||||
asset="ETH",
|
||||
max_days=60,
|
||||
as_of=datetime(2026, 5, 2, 0, 0, tzinfo=UTC),
|
||||
)
|
||||
assert n == 1
|
||||
|
||||
|
||||
def test_count_distinct_days_returns_zero_for_other_asset(db_one_day) -> None:
|
||||
repo = Repository()
|
||||
n = repo.count_iv_rv_distinct_days(
|
||||
db_one_day,
|
||||
asset="BTC",
|
||||
max_days=60,
|
||||
as_of=datetime(2026, 5, 2, 0, 0, tzinfo=UTC),
|
||||
)
|
||||
assert n == 0
|
||||
|
||||
|
||||
def test_count_distinct_days_counts_unique_calendar_days(
|
||||
db_three_days_mixed,
|
||||
) -> None:
|
||||
repo = Repository()
|
||||
n = repo.count_iv_rv_distinct_days(
|
||||
db_three_days_mixed,
|
||||
asset="ETH",
|
||||
max_days=60,
|
||||
as_of=datetime(2026, 5, 4, 0, 0, tzinfo=UTC),
|
||||
)
|
||||
assert n == 3
|
||||
|
||||
|
||||
def test_count_distinct_days_excludes_other_assets(
|
||||
db_three_days_mixed,
|
||||
) -> None:
|
||||
repo = Repository()
|
||||
n_btc = repo.count_iv_rv_distinct_days(
|
||||
db_three_days_mixed,
|
||||
asset="BTC",
|
||||
max_days=60,
|
||||
as_of=datetime(2026, 5, 4, 0, 0, tzinfo=UTC),
|
||||
)
|
||||
assert n_btc == 1
|
||||
|
||||
|
||||
def test_count_distinct_days_respects_window_cutoff(
|
||||
db_three_days_mixed,
|
||||
) -> None:
|
||||
"""max_days=1 da as_of=2026-05-04 → cutoff=2026-05-03 → solo day3."""
|
||||
repo = Repository()
|
||||
n = repo.count_iv_rv_distinct_days(
|
||||
db_three_days_mixed,
|
||||
asset="ETH",
|
||||
max_days=1,
|
||||
as_of=datetime(2026, 5, 4, 0, 0, tzinfo=UTC),
|
||||
)
|
||||
assert n == 1
|
||||
|
||||
|
||||
def test_count_distinct_days_excludes_null_iv_rv(tmp_path) -> None:
|
||||
conn = connect(str(tmp_path / "test.sqlite"))
|
||||
run_migrations(conn)
|
||||
repo = Repository()
|
||||
repo.record_market_snapshot(
|
||||
conn,
|
||||
_snap(ts=datetime(2026, 5, 1, 12, 0, tzinfo=UTC), iv_minus_rv=None),
|
||||
)
|
||||
conn.commit()
|
||||
n = repo.count_iv_rv_distinct_days(
|
||||
conn,
|
||||
asset="ETH",
|
||||
max_days=60,
|
||||
as_of=datetime(2026, 5, 2, 0, 0, tzinfo=UTC),
|
||||
)
|
||||
assert n == 0
|
||||
|
||||
|
||||
def test_count_distinct_days_excludes_fetch_failed(tmp_path) -> None:
|
||||
conn = connect(str(tmp_path / "test.sqlite"))
|
||||
run_migrations(conn)
|
||||
repo = Repository()
|
||||
repo.record_market_snapshot(
|
||||
conn,
|
||||
_snap(
|
||||
ts=datetime(2026, 5, 1, 12, 0, tzinfo=UTC),
|
||||
iv_minus_rv=Decimal("99"),
|
||||
fetch_ok=False,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
n = repo.count_iv_rv_distinct_days(
|
||||
conn,
|
||||
asset="ETH",
|
||||
max_days=60,
|
||||
as_of=datetime(2026, 5, 2, 0, 0, tzinfo=UTC),
|
||||
)
|
||||
assert n == 0
|
||||
|
||||
|
||||
def test_count_distinct_days_rejects_naive_as_of(db_one_day) -> None:
|
||||
repo = Repository()
|
||||
with pytest.raises(ValueError, match="timezone-aware"):
|
||||
repo.count_iv_rv_distinct_days(
|
||||
db_one_day,
|
||||
asset="ETH",
|
||||
max_days=60,
|
||||
as_of=datetime(2026, 5, 2, 0, 0), # naive
|
||||
)
|
||||
|
||||
|
||||
def test_count_distinct_days_rejects_non_positive_max_days(db_one_day) -> None:
|
||||
repo = Repository()
|
||||
with pytest.raises(ValueError, match="max_days must be positive"):
|
||||
repo.count_iv_rv_distinct_days(
|
||||
db_one_day,
|
||||
asset="ETH",
|
||||
max_days=0,
|
||||
as_of=datetime(2026, 5, 2, 0, 0, tzinfo=UTC),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# iv_rv_values_for_window
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_values_for_window_returns_ordered_asc(db_one_day) -> None:
|
||||
repo = Repository()
|
||||
values = repo.iv_rv_values_for_window(
|
||||
db_one_day,
|
||||
asset="ETH",
|
||||
window_days=60,
|
||||
as_of=datetime(2026, 5, 2, 0, 0, tzinfo=UTC),
|
||||
)
|
||||
assert len(values) == 96
|
||||
assert values == sorted(values)
|
||||
assert values[0] == Decimal("2.00")
|
||||
|
||||
|
||||
def test_values_for_window_filters_other_asset(db_one_day) -> None:
|
||||
repo = Repository()
|
||||
values = repo.iv_rv_values_for_window(
|
||||
db_one_day,
|
||||
asset="BTC",
|
||||
window_days=60,
|
||||
as_of=datetime(2026, 5, 2, 0, 0, tzinfo=UTC),
|
||||
)
|
||||
assert values == []
|
||||
|
||||
|
||||
def test_values_for_window_skips_null(db_one_day) -> None:
|
||||
repo = Repository()
|
||||
repo.record_market_snapshot(
|
||||
db_one_day,
|
||||
_snap(ts=datetime(2026, 5, 2, 0, 0, tzinfo=UTC), iv_minus_rv=None),
|
||||
)
|
||||
db_one_day.commit()
|
||||
values = repo.iv_rv_values_for_window(
|
||||
db_one_day,
|
||||
asset="ETH",
|
||||
window_days=60,
|
||||
as_of=datetime(2026, 5, 3, 0, 0, tzinfo=UTC),
|
||||
)
|
||||
assert len(values) == 96
|
||||
|
||||
|
||||
def test_values_for_window_skips_fetch_failed(db_one_day) -> None:
|
||||
repo = Repository()
|
||||
repo.record_market_snapshot(
|
||||
db_one_day,
|
||||
_snap(
|
||||
ts=datetime(2026, 5, 3, 0, 0, tzinfo=UTC),
|
||||
iv_minus_rv=Decimal("99"),
|
||||
fetch_ok=False,
|
||||
),
|
||||
)
|
||||
db_one_day.commit()
|
||||
values = repo.iv_rv_values_for_window(
|
||||
db_one_day,
|
||||
asset="ETH",
|
||||
window_days=60,
|
||||
as_of=datetime(2026, 5, 4, 0, 0, tzinfo=UTC),
|
||||
)
|
||||
assert Decimal("99") not in values
|
||||
|
||||
|
||||
def test_values_for_window_respects_window_cutoff(
|
||||
db_three_days_mixed,
|
||||
) -> None:
|
||||
"""window_days=1 da as_of=2026-05-04 → solo day3 (4 valori 200..203)."""
|
||||
repo = Repository()
|
||||
values = repo.iv_rv_values_for_window(
|
||||
db_three_days_mixed,
|
||||
asset="ETH",
|
||||
window_days=1,
|
||||
as_of=datetime(2026, 5, 4, 0, 0, tzinfo=UTC),
|
||||
)
|
||||
assert values == [Decimal(200 + i) for i in range(4)]
|
||||
|
||||
|
||||
def test_values_for_window_full_window(db_three_days_mixed) -> None:
|
||||
"""window_days=60: tutti i valori dei 3 giorni (96 + 1 + 4 = 101)."""
|
||||
repo = Repository()
|
||||
values = repo.iv_rv_values_for_window(
|
||||
db_three_days_mixed,
|
||||
asset="ETH",
|
||||
window_days=60,
|
||||
as_of=datetime(2026, 5, 4, 0, 0, tzinfo=UTC),
|
||||
)
|
||||
assert len(values) == 101
|
||||
|
||||
|
||||
def test_values_for_window_rejects_naive_as_of(db_one_day) -> None:
|
||||
repo = Repository()
|
||||
with pytest.raises(ValueError, match="timezone-aware"):
|
||||
repo.iv_rv_values_for_window(
|
||||
db_one_day,
|
||||
asset="ETH",
|
||||
window_days=60,
|
||||
as_of=datetime(2026, 5, 2, 0, 0),
|
||||
)
|
||||
|
||||
|
||||
def test_values_for_window_rejects_non_positive_window(db_one_day) -> None:
|
||||
repo = Repository()
|
||||
with pytest.raises(ValueError, match="window_days must be positive"):
|
||||
repo.iv_rv_values_for_window(
|
||||
db_one_day,
|
||||
asset="ETH",
|
||||
window_days=0,
|
||||
as_of=datetime(2026, 5, 2, 0, 0, tzinfo=UTC),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# dvol_lookback (regression — invariato dopo refactor)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_dvol_lookback_returns_closest_tick(db_one_day) -> None:
|
||||
repo = Repository()
|
||||
base = datetime(2026, 5, 1, 0, 0, tzinfo=UTC)
|
||||
target = base + timedelta(hours=12)
|
||||
out = repo.dvol_lookback(
|
||||
db_one_day, asset="ETH", reference=target, tolerance_minutes=15
|
||||
)
|
||||
# i=48 → dvol = 50 + 4.8 = 54.8
|
||||
assert out == Decimal("54.8")
|
||||
|
||||
|
||||
def test_dvol_lookback_returns_none_when_gap(db_one_day) -> None:
|
||||
repo = Repository()
|
||||
target = datetime(2025, 1, 1, 0, 0, tzinfo=UTC)
|
||||
out = repo.dvol_lookback(
|
||||
db_one_day, asset="ETH", reference=target, tolerance_minutes=15
|
||||
)
|
||||
assert out is None
|
||||
|
||||
|
||||
def test_dvol_lookback_rejects_naive_reference(db_one_day) -> None:
|
||||
repo = Repository()
|
||||
with pytest.raises(ValueError, match="timezone-aware"):
|
||||
repo.dvol_lookback(
|
||||
db_one_day,
|
||||
asset="ETH",
|
||||
reference=datetime(2026, 5, 1, 12, 0),
|
||||
)
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from cerbero_bite.clients.portfolio import PortfolioClient
|
||||
from cerbero_bite.config import golden_config
|
||||
from cerbero_bite.config.mcp_endpoints import load_endpoints
|
||||
from cerbero_bite.runtime import build_runtime
|
||||
@@ -51,5 +52,8 @@ def test_build_runtime_clients_pinned_to_endpoints(tmp_path: Path) -> None:
|
||||
assert ctx.macro.SERVICE == "macro"
|
||||
assert ctx.sentiment.SERVICE == "sentiment"
|
||||
assert ctx.hyperliquid.SERVICE == "hyperliquid"
|
||||
assert ctx.portfolio.SERVICE == "portfolio"
|
||||
assert ctx.telegram.SERVICE == "telegram"
|
||||
# Portfolio is now an in-process aggregator over deribit/hyperliquid/macro;
|
||||
# it has no SERVICE attribute. Telegram is also in-process and disabled
|
||||
# when env vars are unset.
|
||||
assert isinstance(ctx.portfolio, PortfolioClient)
|
||||
assert ctx.telegram.enabled is False
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Tests for the runtime flag loader."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from cerbero_bite.config.runtime_flags import (
|
||||
DATA_ANALYSIS_ENV,
|
||||
STRATEGY_ENV,
|
||||
RuntimeFlags,
|
||||
load_runtime_flags,
|
||||
)
|
||||
|
||||
|
||||
def test_default_profile_is_analysis_only() -> None:
|
||||
flags = load_runtime_flags(env={})
|
||||
assert flags == RuntimeFlags(
|
||||
data_analysis_enabled=True, strategy_enabled=False
|
||||
)
|
||||
|
||||
|
||||
def test_strategy_can_be_explicitly_enabled() -> None:
|
||||
flags = load_runtime_flags(env={STRATEGY_ENV: "true"})
|
||||
assert flags.strategy_enabled is True
|
||||
assert flags.data_analysis_enabled is True
|
||||
|
||||
|
||||
def test_data_analysis_can_be_disabled() -> None:
|
||||
flags = load_runtime_flags(env={DATA_ANALYSIS_ENV: "false"})
|
||||
assert flags.data_analysis_enabled is False
|
||||
assert flags.strategy_enabled is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw,expected",
|
||||
[
|
||||
("1", True),
|
||||
("0", False),
|
||||
("yes", True),
|
||||
("no", False),
|
||||
("on", True),
|
||||
("OFF", False),
|
||||
("ENABLED", True),
|
||||
("Disabled", False),
|
||||
("True", True),
|
||||
("False", False),
|
||||
(" true ", True),
|
||||
],
|
||||
)
|
||||
def test_parses_common_truthy_falsy_tokens(raw: str, expected: bool) -> None:
|
||||
flags = load_runtime_flags(env={STRATEGY_ENV: raw})
|
||||
assert flags.strategy_enabled is expected
|
||||
|
||||
|
||||
def test_blank_value_falls_back_to_default() -> None:
|
||||
flags = load_runtime_flags(env={DATA_ANALYSIS_ENV: " ", STRATEGY_ENV: ""})
|
||||
assert flags.data_analysis_enabled is True
|
||||
assert flags.strategy_enabled is False
|
||||
|
||||
|
||||
def test_unknown_token_raises() -> None:
|
||||
with pytest.raises(ValueError, match=DATA_ANALYSIS_ENV):
|
||||
load_runtime_flags(env={DATA_ANALYSIS_ENV: "maybe"})
|
||||
@@ -144,7 +144,8 @@ def test_aggregate_cap_at_975_reduces_to_one(cfg: StrategyConfig) -> None:
|
||||
|
||||
|
||||
def test_concurrent_positions_at_cap_returns_zero(cfg: StrategyConfig) -> None:
|
||||
res = compute_contracts(_ctx(capital_usd="1500", other_open_positions=1), cfg)
|
||||
# Default cap = 5 (entry daily). other_open_positions=5 ⇒ cap raggiunto.
|
||||
res = compute_contracts(_ctx(capital_usd="1500", other_open_positions=5), cfg)
|
||||
assert res.n_contracts == 0
|
||||
assert res.reason_if_zero is not None
|
||||
assert "position" in res.reason_if_zero.lower()
|
||||
|
||||
@@ -111,6 +111,7 @@ dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "rich" },
|
||||
{ name = "sqlalchemy" },
|
||||
@@ -161,6 +162,7 @@ requires-dist = [
|
||||
{ name = "pydantic", specifier = ">=2.9" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.5" },
|
||||
{ name = "python-dateutil", specifier = ">=2.9" },
|
||||
{ name = "python-dotenv", specifier = ">=1.2.2" },
|
||||
{ name = "pyyaml", specifier = ">=6.0" },
|
||||
{ name = "rich", specifier = ">=13.9" },
|
||||
{ name = "scipy", marker = "extra == 'backtest'", specifier = ">=1.14" },
|
||||
|
||||
Reference in New Issue
Block a user