Compare commits
7 Commits
2f701b8469
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| bb40e87d13 | |||
| 9f55652d99 | |||
| 95424f0755 | |||
| de83909db9 | |||
| 1279ea605a | |||
| 835e0c8666 | |||
| 861cc7fc27 |
@@ -1,423 +1,153 @@
|
||||
# PythagorasGoal
|
||||
|
||||
Sistema di riconoscimento pattern frattali e predizione per il trading di criptovalute (BTC, ETH), ispirato al framework teorico di Serleto & Malanga (*Pythagoras Trading Prediction*).
|
||||
Ricerca e esecuzione di strategie algoritmiche su BTC/ETH, con **un libro che gira con soldi veri**
|
||||
su Deribit mainnet dal 20 giugno 2026.
|
||||
|
||||
## Obiettivo
|
||||
> 🚨 **v2.0.0 — RESET del 2026-06-19. Tutto ciò che questo README diceva prima è archiviato in
|
||||
> `Old/` e non è fidato.** L'intera libreria di strategie "validata out-of-sample" (le famiglie
|
||||
> FADE/HONEST/PAIRS/TSMOM/SHAPE, i portafogli PORT01-06, gli Sharpe fra 6 e 10) era un **artefatto
|
||||
> di uno storico contaminato**: print fantasma di un feed *testnet* più storico Binance/USDT.
|
||||
> Ri-testate sul feed reale ricostruito da Deribit mainnet, **perdono ogni anno**. Documento di
|
||||
> fondazione: `docs/diary/2026-06-19-deribit-history.md`.
|
||||
>
|
||||
> Questo README descrive il progetto **dopo** il reset. È stato riscritto il 2026-09-02, dopo
|
||||
> essere rimasto fermo al 4 giugno — quindici giorni prima del reset — mentre pubblicava i numeri
|
||||
> che il progetto aveva già dichiarato falsi.
|
||||
|
||||
Partendo da un capitale iniziale di €1.000, raggiungere un profitto medio di €50 al giorno entro 6–8 mesi, tramite un portafoglio di strategie algoritmiche poco correlate fra loro — mean-reversion, trend/rotazione e spread market-neutral — validate out-of-sample e fee-aware.
|
||||
**L'autorità sui numeri e sulle decisioni è `CLAUDE.md`**, e il racconto completo sta in
|
||||
`docs/memory/`. Questo file è l'ingresso, non la fonte.
|
||||
|
||||
## Risultati
|
||||
---
|
||||
|
||||
> ⚠️ **Revisione 2026-05-28.** La famiglia squeeze-breakout (SQ/MT/ML/AD/CM/PD, con
|
||||
> accuracy storiche dichiarate 76-82%) è stata **scartata**: quei numeri erano un
|
||||
> **artefatto di look-ahead**. I backtest decidevano la direzione dalla candela di
|
||||
> breakout `close[i]` ma entravano a `close[i-1]` — impossibile dal vivo. Sotto
|
||||
> ingresso onesto (`close[i]`) e fee reali, l'edge sparisce e tutte perdono, anche
|
||||
> a fee zero. Dettagli e prove: `scripts/analysis/oos_validation.py`.
|
||||
## Cos'è vivo, adesso
|
||||
|
||||
Dopo una validazione **out-of-sample, fee-aware** di molte famiglie di strategie,
|
||||
emergono cinque famiglie con edge netto reale, tutte radicate nella stessa lezione
|
||||
(in cripto la **mean-reversion** funziona, la continuazione no) o nella diversificazione:
|
||||
| | |
|
||||
|---|---|
|
||||
| libro live | **TP01 + SKH01 a 75/25**, nettati in software su una sola posizione per asset (50/50 BTC/ETH) |
|
||||
| venue | Deribit mainnet, perpetual lineari USDC. Esecuzione **armata** dal 2026-06-20 |
|
||||
| capitale | ~$2.050 (USDC + USDE), non i €2.000 nominali di nessun paper trader |
|
||||
| cadenza | **oraria**, minuto `:47` (`scripts/cron_book.sh`) |
|
||||
| leva | lorda ~0,28x su un tetto di 1,00x. Il cap non ha mai morso: **il vincolo è il segnale** |
|
||||
| protezione on-book | un solo disaster-SL rotolante al −30% sulla posizione netta |
|
||||
|
||||
| Famiglia | Meccanismo | Strategie | Profilo (netto OOS) |
|
||||
|----------|-----------|-----------|---------------------|
|
||||
| **FADE** | mean-reversion intraday 1h (long/short, BTC/ETH) | MR01 Bollinger, MR02 Donchian, MR07 Return-reversal | Acc 52-55%, DD 18-34% |
|
||||
| **HONEST** | long-only multi-regime multi-crypto | DIP01 dip-buy, TR01 EMA-trend, ROT02 dual-momentum | CAGR 31-56%, DD 15-27% |
|
||||
| **PAIRS** | spread reversion *market-neutral* (2 gambe) | PR01 ETH/BTC, LTC/ETH, ADA/ETH, BTC/LTC, ETH/SOL | Sharpe 2.0-4.4, corr col mercato ~0.05 |
|
||||
| **TSMOM** | time-series momentum multi-orizzonte | TSM01 (3/6/12m + risk-off) | diversificatore, DD 15-22% |
|
||||
| **SHAPE** | ML walk-forward su feature di *forma* del prezzo | SH01 (LogisticRegression, orizzonte 12 barre) | diversificatore, corr +0.08 col resto |
|
||||
**Non** sono nel libro live: XS01, VRP01, GTAA01, XSR01. Vivono nel portafoglio di **ricerca**
|
||||
(paper, 5 sleeve), che è una serie diversa da quella che gira — quasi tutti i numeri di portafoglio
|
||||
del progetto sono su quella, non su questa.
|
||||
|
||||
Tutti i numeri sono **netti** dopo fee realistiche (Deribit 0.10% RT single-leg, 0.20%
|
||||
RT/coppia sui pairs), leva 3x, su finestra held-out. Le strategie sono robuste su griglia
|
||||
parametri, sweep fee 0.00-0.20% RT e — per i pairs — validate con **walk-forward** e
|
||||
config universale (niente cherry-picking).
|
||||
## I numeri, con la loro lente
|
||||
|
||||
### Portafoglio combinato (la vera leva anti-drawdown)
|
||||
Ogni numero va scritto con la lente e la banda che gli appartengono. La tabella completa
|
||||
("non citare" ↔ "citare") è in `CLAUDE.md` §2; qui i tre che contano.
|
||||
|
||||
Le famiglie sono **quasi scorrelate fra loro** (~0.05). Combinandole in un unico
|
||||
portafoglio equipesato il drawdown crolla sotto quello di ogni singola sleeve:
|
||||
| grandezza | valore onesto |
|
||||
|---|---|
|
||||
| rendimento del libro live | **TWR +10,6%** dall'armamento, spezzato sul versamento del 25/08: +11,61% prima, −0,9% dopo |
|
||||
| crescita di trading | **+$50** in 71 giorni, su 30 round-trip chiusi e $0,87 di fee totali |
|
||||
| Sharpe del portafoglio di ricerca | **1,95** [1,81 – 2,12] full, **1,54** [1,11 – 1,91] hold-out |
|
||||
|
||||
| Portafoglio | CAGR | Max DD | Sharpe |
|
||||
|-------------|------|--------|--------|
|
||||
| FADE (6 sleeve) | ~46% | 8% | 3.9 |
|
||||
| HONEST (3 sleeve) | ~46% | 13% | 2.2 |
|
||||
| **MASTER** (FADE + HONEST, 9) | ~47% | **5%** | 4.2 |
|
||||
| **MASTER + PAIRS + TSM01** (15) | ~67% | ~5% | ~6 |
|
||||
| **PORT06 live** (17 sleeve, cap pairs 33%, leva 2×, config EXIT-16) | ~79% | **2.6%** | 7.8 (FULL) / 10.1 (OOS) |
|
||||
⚠️ Il rendimento dell'equity **non** è la performance: il 96% della crescita del conto è un
|
||||
bonifico. Chi legge una percentuale su questo progetto deve sapere se è un TWR o un rapporto fra
|
||||
due saldi — è stato un difetto reale, riparato il 2026-09-02.
|
||||
|
||||
> 🔎 **Numeri sobri (anti-overfit).** L'OOS singolo cade nel regime favorevole 2024-25:
|
||||
> i valori di Sharpe/DD sopra sono ottimistici di circa il 50%. Da pianificare per le
|
||||
> decisioni: **Sharpe atteso ~5**, **worst-drawdown su 90 giorni ~6%**, profilo che regge
|
||||
> a leva 2x con slippage raddoppiato. Configurazione raccomandata: equal-weight, leva 2x,
|
||||
> con un cap sull'allocazione ai pairs (~30-35%, poiché concentrano ~57% del rischio).
|
||||
> Tutto resta da confermare nel paper trading live.
|
||||
## La riga che ordina tutto il resto
|
||||
|
||||
## Come funziona
|
||||
> La ricerca ha smesso di essere il vincolo il 2026-07-26, e **sei ondate successive lo hanno
|
||||
> confermato invece che ribaltarlo**. I vincoli sono **il capitale che entra** e **il conto che non
|
||||
> sparisce**.
|
||||
|
||||
### MR01 — Bollinger Fade (mean-reversion)
|
||||
Misurato: il miglior candidato nuovo vale **+0,046 €/giorno**; versare €500/mese invece di €250
|
||||
porta la probabilità di arrivare al traguardo in 20 anni **dal 14% all'85%**. E €100/mese in più
|
||||
equivalgono a **+4,07%/anno di drift**, cioè più di tutta la leva autorizzabile.
|
||||
|
||||
La strategia attiva sfrutta il fatto, emerso dai dati, che su BTC/ETH a 1h gli estremi
|
||||
di prezzo **rientrano verso la media** più di quanto proseguano:
|
||||
## Metodo — cosa deve superare una strategia nuova
|
||||
|
||||
1. **Bollinger Bands** (window `n`, `k` deviazioni standard) sul close.
|
||||
2. **Entry** — quando il close esce *sotto* la banda inferiore → **long** (o *sopra* la superiore → **short**). Ingresso a `close[i]`, eseguibile dal vivo.
|
||||
3. **Take-profit** alla media mobile (il rientro atteso).
|
||||
4. **Stop-loss** a `sl_atr × ATR` oltre l'estremo; **time-limit** a `max_bars`.
|
||||
Sei requisiti, nessuno negoziabile (`CLAUDE.md` §8, gate in `scripts/research/alt/altlib.py`):
|
||||
|
||||
Nessun look-ahead: direzione e livelli sono calcolati con dati fino a `close[i]`.
|
||||
1. **ingresso eseguibile** — direzione e prezzo da dati fino a `close[i]`, mai l'estremo di una candela;
|
||||
2. **backtest netto** dopo fee Deribit realistiche, più la leva;
|
||||
3. **out-of-sample** held-out, robustezza su griglia, sweep fee;
|
||||
4. **liquidità e plausibilità** — un edge su un book fermo o su wick fantasma non è un edge;
|
||||
5. i **gate**: `marginal_vs_tp01` (Sharpe marginale, non assoluto), `study_family_honest` con
|
||||
deflated-Sharpe ≥ 0,95, `day_boundary_robust`, `anchor_luck_band`, `weights_tilt_null`;
|
||||
6. codice, test e diario.
|
||||
|
||||
### Le altre famiglie
|
||||
Il progetto ha **73 regole di prim'ordine** (`CLAUDE.md` §6) — sul dato, sul metodo, sui costi,
|
||||
sulla produzione e sul piano — ognuna pagata almeno una volta. La più
|
||||
ricorrente, cinque occorrenze: *un sorvegliante deve derivare il proprio bersaglio dal codice
|
||||
sorvegliato, mai ridichiararlo — un controllo puntato su una configurazione diversa da quella che
|
||||
gira passa sempre, e non sta controllando niente.*
|
||||
|
||||
- **FADE** (oltre MR01): MR02 fada la rottura del canale Donchian verso il centro;
|
||||
MR07 fada il movimento di barra estremo misurato in deviazioni standard dei
|
||||
rendimenti. Stessa logica di reversione, indicatori indipendenti.
|
||||
- **HONEST** (long-only, multi-crypto): DIP01 compra i dip estremi e rivende al
|
||||
recupero; TR01 segue il trend con incrocio di EMA su un paniere; ROT02 ruota ogni
|
||||
giorno sui tre asset col momentum più forte, andando in cash quando BTC è sotto la
|
||||
sua media (risk-off). Coprono i regimi di trend e rotazione, complementari alle fade.
|
||||
- **PAIRS** (market-neutral): scommette sul rientro verso la media del log-ratio fra
|
||||
due cripto (z-score). Long su una, short sull'altra: l'esposizione netta al mercato è
|
||||
quasi nulla (correlazione ~0.02), il che la rende un diversificatore eccellente.
|
||||
- **TSMOM**: tiene gli asset con momentum positivo persistente su più orizzonti
|
||||
(3/6/12 mesi), con overlay risk-off. Rende meno ma è poco correlato, utile in ensemble.
|
||||
## Il dato
|
||||
|
||||
### Perché lo squeeze breakout è stato abbandonato
|
||||
- **La verità è Deribit mainnet**, perché è dove si esegue. Binance è un audit indipendente, mai
|
||||
un'ancora per "ripulire": è USDT, ~10 bps fuori, fino al 3% sotto depeg.
|
||||
- Universo certificato: **solo BTC/ETH**, ogni timeframe. Gli alt sono esclusi.
|
||||
- Lo storico si aggiorna **solo** con `rebuild_history.py` e si certifica **sempre** con
|
||||
`certify_feed.py`. Il vecchio downloader è la causa del reset.
|
||||
- La catena opzioni si **raccoglie** ogni ora (`collect_chain.py`, minuto `:25`): un'ora non
|
||||
raccolta è persa per sempre.
|
||||
|
||||
L'ipotesi originale era opposta — *continuazione* dopo la compressione di volatilità
|
||||
(Bollinger dentro Keltner → breakout direzionale). Su dati storici sembrava dare
|
||||
76-82% di accuracy, ma era un **artefatto di look-ahead**: il backtest entrava a
|
||||
`close[i-1]` con direzione decisa da `close[i]`. Replicando l'esecuzione reale
|
||||
(ingresso a `close[i]`) l'edge collassa al ~47% (lancio di moneta) e i costi fanno
|
||||
il resto. Il test sui breakout intra-barra a 5m conferma che il movimento *rientra*
|
||||
subito (mean-reversion), giustificando MR01. Tutta la famiglia squeeze è in `scripts/waste/`.
|
||||
|
||||
### Lezione metodologica
|
||||
|
||||
Ogni nuova strategia deve passare: (1) **ingresso eseguibile** senza look-ahead,
|
||||
(2) backtest **netto** dopo fee realistiche (0.10% RT Deribit), (3) validazione
|
||||
**out-of-sample** + robustezza su griglia parametri + sweep fee. Strumenti in
|
||||
`scripts/analysis/` (`strategy_research.py`, `oos_validation.py`, `intrabar_test.py`).
|
||||
|
||||
## Struttura progetto
|
||||
## Struttura
|
||||
|
||||
```
|
||||
PythagorasGoal/
|
||||
├── src/
|
||||
│ ├── data/ # Download e gestione dati (Cerbero MCP + Binance)
|
||||
│ ├── fractal/ # Indicatori frattali: Hurst, Higuchi FD, self-similarity
|
||||
│ ├── backtest/ # Motore di backtesting con fee e metriche
|
||||
│ ├── strategies/ # Classe base Strategy ABC + indicatori condivisi
|
||||
│ │ ├── base.py # Strategy, Signal, BacktestResult, YearlyStats
|
||||
│ │ └── indicators.py # keltner_ratio, detect_squeezes, ema, atr, rv, corr
|
||||
│ ├── live/ # Paper trading live su Deribit testnet
|
||||
│ │ ├── multi_runner.py # Orchestratore multi-strategia (strategie + pairs)
|
||||
│ │ ├── strategy_worker.py # Worker single-leg con stato persistente
|
||||
│ │ ├── pairs_worker.py # Worker a 2 gambe per i pairs (market-neutral)
|
||||
│ │ ├── strategy_loader.py # Import dinamico classi Strategy
|
||||
│ │ ├── cerbero_client.py # Client HTTP per Cerbero MCP
|
||||
│ │ ├── signal_engine.py # Squeeze + ML real-time (legacy) + validazione OOS
|
||||
│ │ └── telegram_notifier.py
|
||||
│ └── portfolio/ # Portafogli di prima classe (capitale condiviso, backtest + live)
|
||||
│ ├── base.py # SleeveSpec, Portfolio (.backtest), load_active_portfolio
|
||||
│ ├── weighting.py # Schemi di ponderazione: equal, cap, inverse_vol, cluster_rp, manual
|
||||
│ ├── sleeves.py # Builder unificato equity-per-sleeve (fonte unica, parità report)
|
||||
│ ├── ledger.py # PortfolioLedger: PnL/DD aggregati, persistenza e resume
|
||||
│ └── runner.py # PortfolioRunner live (Cerbero v2, sizing, ribilancio giornaliero)
|
||||
├── scripts/
|
||||
│ ├── strategies/ # Strategie con edge validato OOS (FADE, HONEST, PAIRS, TSMOM + portafogli)
|
||||
│ ├── portfolios/ # Definizioni PORT01-06 e report run() dei portafogli di prima classe
|
||||
│ ├── waste/ # Strategie scartate (squeeze SQ/MT/ML/AD/CM/PD, MR03, ROT01, W01-W28)
|
||||
│ └── analysis/ # Ricerca/validazione OOS fee-aware, gestione rischio, report
|
||||
├── strategies.yml # Config multi-strategy paper trader
|
||||
├── data/
|
||||
│ ├── raw/ # Parquet OHLCV (gitignored, ~70 MB)
|
||||
│ └── regime/ # DVOL + funding (Deribit mainnet) + cache feature regime (gitignored)
|
||||
├── VERSION # versione semver (cotta nell'immagine, mostrata nei msg Telegram)
|
||||
├── docs/
|
||||
│ ├── diary/ # Diario di ricerca giornaliero
|
||||
│ └── specs/ # Specifiche di design
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
└── pyproject.toml
|
||||
src/
|
||||
data/downloader.py load_data(asset, tf) sui parquet certificati
|
||||
strategies/ trend_portfolio.py (TP01) · skyhook.py (SKH01) · base · indicators
|
||||
portfolio/ portfolio.py (N sleeve + weights_tilt_null) · sleeves.py · gtaa.py
|
||||
backtest/harness.py backtest onesto, senza look-ahead
|
||||
live/ book.py (esecutore netto) · deribit · livefeed · usde
|
||||
venue_watch · venue_probe · venue_news · monitor_health · scale_watch
|
||||
tradesdb · journal · analista · notifier · cli
|
||||
scripts/
|
||||
live/ book_execute · trades_db · journal · analista · balance_watch
|
||||
paper_* (forward-monitor) · usde_watch · usde_convert · fee_watch
|
||||
research/ r<data>_*.py — un file per esperimento, harness in alt/altlib.py
|
||||
analysis/ rebuild_history · certify_feed · audit_feed · multi_source_check
|
||||
cron_{book,daily,chain,balance,usde,opt_snapshot,vol_term}.sh
|
||||
docs/
|
||||
memory/ LA MEMORIA — 6 file, indicizzati in testa a CLAUDE.md
|
||||
research/ RESULTS-0822 (§1-77, un registro per filone) · BRIEF-0822 · SPEC-scale-key
|
||||
diary/ una voce per esperimento (144)
|
||||
journal/ libro di bordo, una voce al giorno, 4 livelli per provenienza
|
||||
tests/ 1008, tutti verdi
|
||||
Old/ archivio pre-reset — consultabile, non fidato
|
||||
```
|
||||
|
||||
## Strategie attive
|
||||
|
||||
Le strategie single-asset estendono `src.strategies.base.Strategy`
|
||||
(`generate_signals() → backtest()`); i pairs hanno un worker dedicato a 2 gambe.
|
||||
|
||||
| Codice | Script | Famiglia | Descrizione |
|
||||
|--------|--------|----------|-------------|
|
||||
| **MR01** | `MR01_bollinger_fade.py` | FADE | Fada la banda di Bollinger, TP alla media, SL ad ATR |
|
||||
| **MR02** | `MR02_donchian_fade.py` | FADE | Fada la rottura del canale Donchian, TP al centro |
|
||||
| **MR07** | `MR07_return_reversal.py` | FADE | Fada il movimento di barra estremo (z dei rendimenti) |
|
||||
| **DIP01** | `DIP01_dip_reversion.py` | HONEST | Dip-buy long-only su z-score estremo |
|
||||
| **TR01** | `TR01_ema_trend.py` | HONEST | EMA 20/100 trend-following su paniere cripto (4h) |
|
||||
| **ROT02** | `ROT02_dual_momentum.py` | HONEST | Rotazione cross-sectional top-3 + risk-off (1d) |
|
||||
| **PR01** | `PR01_pairs_reversion.py` | PAIRS | Spread reversion market-neutral su 5 coppie |
|
||||
| **TSM01** | `tsmom_research.py` | TSMOM | Time-series momentum multi-orizzonte + risk-off |
|
||||
| **SH01** | `SH01_shape_ml.py` | SHAPE | LogisticRegression walk-forward su 17 feature di forma, orizzonte 12 barre (diversificatore) |
|
||||
|
||||
Le fade applicano tre protezioni live: un **filtro trend** (`trend_max`/`ema_long`,
|
||||
salta i segnali col prezzo troppo esteso rispetto alla EMA200), un **loss-guard Hurst**
|
||||
(`hurst_max=0.55`, salta i segnali in regime persistente/trending dove si concentrano gli stop-loss
|
||||
— dimezza il drawdown del portafoglio, calcolato dalle sole close) e l'**EXIT-16 close-confirm SL**
|
||||
(`sl_confirm_atr=0.5`, 2026-06-04: lo stop scatta solo se la barra *chiude* oltre `sl ∓ 0.5·ATR14` —
|
||||
gli stop intrabar da wick erano falsi negativi, l'overshoot che buca lo stop è proprio il movimento
|
||||
che la fade fada; a livello PORT06 porta l'OOS Sharpe da 8.82 a 10.06). Più un filtro `min_tp_frac`
|
||||
che scarta i micro-scalp col take-profit entro il costo delle fee. Le tre protezioni sono
|
||||
complementari: Hurst toglie il regime tossico, il trend-filter gli ingressi sovra-estesi, il
|
||||
close-confirm i falsi stop. Portafogli pronti: `PORT01`
|
||||
(honest), `PORT02` (fade), `PORT03` (master fade+honest), **`PORT06`** (master esteso, default live).
|
||||
|
||||
**Scartate** (in `scripts/waste/`): la famiglia squeeze (SQ01-04, ML01, MT01, PD01,
|
||||
CM01, AD01 — artefatto di look-ahead), MR03 Keltner (debole/ridondante con MR01) e
|
||||
ROT01 (dominata da ROT02).
|
||||
|
||||
### Comandi utili
|
||||
## Comandi
|
||||
|
||||
```bash
|
||||
# Backtest di una strategia
|
||||
uv run python scripts/strategies/MR01_bollinger_fade.py
|
||||
uv run python scripts/strategies/PR01_pairs_reversion.py
|
||||
|
||||
# Ricerca e validazione fee-aware out-of-sample
|
||||
uv run python scripts/analysis/strategy_research.py # screening famiglie + deep-dive fade
|
||||
uv run python scripts/analysis/strategy_research_v2.py # MR02 / MR03 / MR07
|
||||
uv run python scripts/analysis/oos_validation.py # perche' la famiglia squeeze e' scartata
|
||||
uv run python scripts/analysis/pairs_research.py # ricerca + verifica no-look-ahead dei pairs
|
||||
|
||||
# Gestione rischio, combinazione, report
|
||||
uv run python scripts/analysis/risk_management.py # filtro trend + portafoglio fade
|
||||
uv run python scripts/analysis/combine_portfolio.py # combinare fade + honest
|
||||
uv run python scripts/analysis/combine_v2.py # master esteso con pairs + TSM01
|
||||
uv run python scripts/analysis/report_families.py # report per anno di tutte le famiglie
|
||||
|
||||
# Validazione dei worker live (replay == backtest)
|
||||
uv run python scripts/analysis/validate_worker_mr01.py # worker single-leg su MR01
|
||||
uv run python scripts/analysis/validate_worker_pairs.py # worker a 2 gambe sui pairs
|
||||
uv run python scripts/analysis/live_smoke_pairs.py # smoke test feed live reale dei pairs
|
||||
uv sync # dipendenze
|
||||
uv run python scripts/analysis/rebuild_history.py --asset BTC ETH # storico da Deribit mainnet
|
||||
uv run python scripts/analysis/certify_feed.py # certifica i feed
|
||||
uv run python scripts/live/trades_db.py --report # stato del libro + TWR
|
||||
uv run python scripts/live/journal.py # voce del giorno
|
||||
uv run python scripts/live/analista.py --secco # analisi del giorno, senza salvare
|
||||
uv run python scripts/portfolio/run_portfolio.py # portafoglio di ricerca
|
||||
uv run pytest # 1008 test
|
||||
```
|
||||
|
||||
## Paper Trading Live
|
||||
Ogni script di `scripts/live/` accetta `--help` e **rifiuta un flag che non conosce** (esce 2): fino
|
||||
al 2026-09-02 un flag sbagliato eseguiva l'azione di default, e su tre script quell'azione scriveva.
|
||||
|
||||
Il multi-strategy runner esegue N strategie in parallelo su dati live da Cerbero MCP,
|
||||
ognuna con €1000 USDC virtuali indipendenti. Gestisce due tipi di worker:
|
||||
## Gate aperti
|
||||
|
||||
- **Single-leg** (`strategy_worker.py`): per le strategie direzionali. Se un `Signal`
|
||||
porta `tp`/`sl`/`max_bars` in `metadata` (come le fade), chiude su take-profit /
|
||||
stop-loss / time-limit; altrimenti usa il fallback `hold_bars`/stop -2%.
|
||||
- **Due gambe** (`pairs_worker.py`): per i pairs market-neutral. Apre long su una gamba
|
||||
e short sull'altra, esce sul rientro dello z-score o per time-limit, conta le fee su
|
||||
entrambe le gambe. Validato: il replay storico coincide *esattamente* col backtest.
|
||||
Un gate si decide **alla data**, coi criteri scritti **prima**. Elenco completo in `CLAUDE.md` §4.
|
||||
|
||||
### Avvio
|
||||
| gate | data | dove punta oggi |
|
||||
|---|---|---|
|
||||
| STATARB | 2026-09-27 | ritiro (Sharpe −1,61 sulla serie vera) |
|
||||
| SCALA-01 — leva 1,00 → 1,25 | non prima del 2026-10-01 | chiave costruita e **inerte**; 9 condizioni, 2 fatte |
|
||||
| XSR01 | 2026-10-23 | sotto la lente rendita il meccanismo vale ~0 |
|
||||
| DVOLSPREAD | kill 2026-10-24 | serie −4,41 |
|
||||
| GTAA01 tenere/bloccare | al book a $15k | contributo reale +0,10/+0,12 di Sharpe, non incassabile sotto la soglia |
|
||||
|
||||
```bash
|
||||
# Locale
|
||||
uv run python -m src.live.multi_runner
|
||||
## Obiettivo, e cosa lo rende difficile
|
||||
|
||||
# Docker
|
||||
docker compose up -d
|
||||
```
|
||||
Il target dichiarato è **€50/giorno partendo da €1.000**. Non è raggiungibile a questo capitale:
|
||||
servono ~$313k (banda [$187k – $1,14M]) o **€1.733/mese per dieci anni** per averlo al 90% di
|
||||
probabilità. La leva non è la scorciatoia — quella difendibile vale quanto €100-150/mese di
|
||||
versamento. La via è target-vol, capitale e tempo.
|
||||
|
||||
### Configurazione
|
||||
|
||||
Le strategie attive sono definite in `strategies.yml`:
|
||||
|
||||
```yaml
|
||||
defaults:
|
||||
capital: 1000
|
||||
position_size: 0.15
|
||||
leverage: 3
|
||||
|
||||
strategies: # strategie single-leg
|
||||
- name: MR01_bollinger_fade
|
||||
asset: BTC
|
||||
tf: 1h
|
||||
enabled: true
|
||||
params: { bb_window: 50, k: 2.5, sl_atr: 2.0, max_bars: 24, trend_max: 3.0, ema_long: 200 }
|
||||
|
||||
pairs: # strategie a 2 gambe (market-neutral)
|
||||
- name: PR01_pairs_reversion
|
||||
a: ETH
|
||||
b: BTC
|
||||
tf: 1h
|
||||
enabled: true
|
||||
params: { n: 50, z_in: 2.0, z_exit: 0.75, max_bars: 72, jump_max: 0.08 }
|
||||
```
|
||||
|
||||
Per aggiungere una strategia: nuova riga in `strategies.yml` (sezione `strategies` o
|
||||
`pairs`), poi `docker compose restart`. Lo storico delle strategie esistenti rimane intatto.
|
||||
|
||||
### Persistenza
|
||||
|
||||
Ogni strategia ha la sua directory in `data/paper_trades/`:
|
||||
|
||||
```
|
||||
data/paper_trades/
|
||||
MR01_bollinger_fade__BTC__1h/
|
||||
trades.jsonl # Storico trade append-only
|
||||
status.json # Stato corrente (resume al restart, include tp/sl/max_bars)
|
||||
```
|
||||
|
||||
Notifiche Telegram per ogni trade (richiede `TELEGRAM_BOT_TOKEN` e `TELEGRAM_CHAT_ID` in `.env`).
|
||||
|
||||
## Paper Trading a Portafoglio
|
||||
|
||||
Accanto al multi-strategy runner originale — in cui ogni strategia gestisce autonomamente il proprio conto virtuale da €1.000 — il progetto dispone ora di un **paper trader a portafoglio** (`src/portfolio/`) che tratta l'insieme delle strategie come un unico organismo con un capitale condiviso.
|
||||
|
||||
### Come funziona
|
||||
|
||||
La definizione di un portafoglio (`SleeveSpec` + schema di peso) ha due facce sulla stessa sorgente dati:
|
||||
|
||||
- **Backtest** (`.backtest()`): ricostruisce le equity-curve di ogni sleeve tramite il builder unificato in `sleeves.py`, le pondera secondo lo schema scelto e calcola le metriche aggregate (CAGR, Sharpe, max DD). La parità con i report prodotti da `report_families.py` è garantita dalla fonte unica.
|
||||
- **Live** (`PortfolioRunner`): ogni ora il runner scarica le candele aggiornate via Cerbero v2, calcola i pesi correnti, avvia i worker appropriati per ogni sleeve attiva e registra il PnL aggregato nel ledger (`data/portfolios/{code}/`). Il ledger persiste tra i riavvii.
|
||||
|
||||
### Schemi di ponderazione
|
||||
|
||||
Il modulo `weighting.py` mette a disposizione cinque schemi: `equal` (default), `cap` (tetto per famiglia — p.es. `pairs: 0.33` per limitare la concentrazione), `inverse_vol` (pesi inversamente proporzionali alla volatilità storica), `cluster_rp` (equal tra cluster naturali poi inverse-vol all'interno del cluster) e `manual` (pesi liberi). Lo schema si specifica in `portfolios.yml` insieme al codice portafoglio e alla leva.
|
||||
|
||||
### Portafoglio di default: PORT06
|
||||
|
||||
La configurazione raccomandata è **PORT06** (`scripts/portfolios/PORT06_master_shape.py`): portafoglio master esteso che include tutte e sei le famiglie (FADE, HONEST, PAIRS, TSMOM, SHAPE), con schema `cap` che limita i pairs al 33% del capitale per moderare la loro concentrazione di rischio. Backtest canonico (dati al 2026-05-28): Sharpe 6.47 (FULL) / 8.82 (OOS), drawdown massimo 4.10% (FULL) / 1.30% (OOS), leva 2×; **con la config live attuale (EXIT-16 close-confirm): Sharpe 7.84 / 10.06, DD 2.60% / 1.15%**.
|
||||
|
||||
### Scope live
|
||||
|
||||
Il runner esegue **tutti e 17 gli sleeve** di PORT06: **fade** (MR01, MR02, MR07 × BTC/ETH),
|
||||
**honest** (DIP01, TR01-basket 4h, ROT02-rotation 1d), **pairs** (PR01, cinque coppie),
|
||||
**TSMOM** (TSM01 1d) e **shape** (SH01 × BTC/ETH). Worker dedicati: `StrategyWorker` (single-leg, fade/
|
||||
dip/**shape**), `PairsWorker` (2 gambe), `BasketTrendWorker`, `RotationWorker`, `TsmomWorker`. Il runner
|
||||
fetcha 1h da Cerbero v2 e resampla a 4h/1d; il pool di capitale, il ribilancio giornaliero e il ledger
|
||||
sono validati == backtest.
|
||||
|
||||
> **SH01 (2026-06-01):** gira come `StrategyWorker` normale (il walk-forward è interno a
|
||||
> `generate_signals`). Il vecchio `MLWorkerWrapper` usava il `SignalEngine` **squeeze scartato** —
|
||||
> rimosso. **Loss-guard Hurst (2026-06-02):** le fade saltano i segnali in regime persistente
|
||||
> (rolling-Hurst ≥ 0.55), dove si concentrano gli stop-loss — dimezza il drawdown del portafoglio
|
||||
> (FULL 4.1%→2.4%; stop-loss fade −67% in numero, perdite totali −68%). Calcolato dalle sole close,
|
||||
> attivo live (`hurst_max` nei params). Il report orario su Telegram **monitora lo stop-rate fade
|
||||
> prima/dopo l'attivazione** e dà il verdetto automatico quando il campione è sufficiente.
|
||||
|
||||
### Esecuzione reale (shadow, Deribit testnet)
|
||||
|
||||
Sette sleeve single-leg — le **6 fade** (MR01/MR02/MR07 × BTC/ETH) e **DIP01** (dal 2026-06-04) —
|
||||
eseguono ordini **reali su Deribit testnet** accanto al fill simulato (*shadow*: il sim resta la
|
||||
verità che guida le decisioni; il reale misura la fattibilità). Punti chiave:
|
||||
|
||||
- **Strumenti lineari USDC** (`BTC_USDC`/`ETH_USDC-PERPETUAL`): payoff lineare = matematica del
|
||||
backtest; fee e PnL in USDC. Quantizzazione `Decimal` di amount (step) e prezzi (tick).
|
||||
- **Take-profit reale = limit reduce-only AL livello** (v1.0.7): piazzato all'apertura, copre la
|
||||
sola quota del worker (gli strumenti sono condivisi fra worker e nettati per conto); alla
|
||||
chiusura il worker cancella il resting, riconcilia i fill dal trade history per `order_id` e
|
||||
chiude a market solo il residuo. Fix della divergenza misurata: il market-on-poll usciva
|
||||
+235 bps oltre il livello TP. Fill da resting = fee maker (~0%).
|
||||
- **Stop-loss close-confirm** (v1.1.0): uscita al close che sfonda il livello → market
|
||||
reduce-only al poll (nessun ordine stop sul book, per scelta: i trigger Deribit generano un
|
||||
nuovo order_id allo scatto, non verificabile, e i wick non devono stoppare).
|
||||
- **Verifica sul trade** (order_id in `get_trade_history`), fee reali dai `trades[]`, ledger
|
||||
reale parallelo persistito (`real_capital`), eventi `REAL_OPEN`/`REAL_TP_RESTING`/`REAL_CLOSE`
|
||||
nel log + alert Telegram (`REAL_EXEC_LIVE`, `REAL_OPEN_FAIL`).
|
||||
- Config in `portfolios.yml` → `overrides.execution {enabled, sleeves, instruments}`.
|
||||
**Pairs/rotation/TSMOM/shape restano simulati**: i pairs richiedono un executor a 2 gambe
|
||||
(leg-risk), i multi-asset un rebalance-to-target; roadmap nel diario.
|
||||
|
||||
### Versione & deploy
|
||||
|
||||
Ogni deploy ha una **versione** (file `VERSION`, semver) che compare nei messaggi Telegram (notifiche
|
||||
trade + report orario), così correli ogni messaggio al codice che l'ha generato. Il sorgente è **cotto
|
||||
nell'immagine** → per aggiornare il live serve un **rebuild**, non un semplice restart:
|
||||
|
||||
```bash
|
||||
./scripts/deploy.sh # bump patch (1.0.0 → 1.0.1) + commit + rebuild + ricrea container
|
||||
./scripts/deploy.sh minor # 1.0.x → 1.1.0
|
||||
```
|
||||
|
||||
Il volume `data/` persiste tra i deploy → i worker fanno RESUME dello stato (capitale, posizioni aperte).
|
||||
|
||||
### Avvio del paper trader a portafoglio
|
||||
|
||||
```bash
|
||||
# Backtest del portafoglio di default (PORT06)
|
||||
uv run python scripts/portfolios/PORT06_master_shape.py
|
||||
|
||||
# Paper trading live a portafoglio
|
||||
uv run python -m src.portfolio.runner
|
||||
|
||||
# Report orario su Telegram (stato + stop-rate fade prima/dopo loss-guard) — via cron
|
||||
uv run python scripts/portfolios/hourly_report.py
|
||||
|
||||
# Smoke test del data layer Cerbero v2
|
||||
uv run python scripts/analysis/smoke_portfolio.py
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
# Clona e installa
|
||||
git clone <repo-url> && cd PythagorasGoal
|
||||
uv sync
|
||||
|
||||
# Scarica dati storici (~70 MB)
|
||||
uv run python -m src.data.downloader
|
||||
|
||||
# Backtest strategia attiva
|
||||
uv run python scripts/strategies/MR01_bollinger_fade.py
|
||||
|
||||
# Paper trading live
|
||||
uv run python -m src.live.multi_runner
|
||||
```
|
||||
|
||||
### Requisiti
|
||||
|
||||
- Python ≥ 3.11
|
||||
- [uv](https://docs.astral.sh/uv/) come package manager
|
||||
- Accesso a Cerbero MCP (`cerbero-mcp.tielogic.xyz`) per dati Deribit live
|
||||
- Docker (opzionale, per deploy su VPS)
|
||||
|
||||
## Dati
|
||||
|
||||
| Asset | Timeframe | Copertura |
|
||||
|-------|-----------|-----------|
|
||||
| BTC, ETH | 5m / 15m / 1h | 2018-01 → oggi |
|
||||
| SOL, LTC, ADA, XRP, BNB, DOGE | 15m / 1h | 2019-2022 → oggi (variabile per asset) |
|
||||
|
||||
Fonte primaria: perpetual Deribit via Cerbero MCP. Fallback: Binance spot via ccxt.
|
||||
Formato: Apache Parquet (in `data/raw/`, gitignored).
|
||||
|
||||
> **Nota sul naming Deribit (per il feed live).** I major sono perpetui *inverse*
|
||||
> (`BTC-PERPETUAL`, `ETH-PERPETUAL`); gli altcoin sono perpetui *lineari USDC*
|
||||
> (`SOL_USDC-PERPETUAL`, `LTC_USDC-PERPETUAL`, …) con storia dal 2022. Attenzione:
|
||||
> `LTC-PERPETUAL`/`ADA-PERPETUAL` non esistono e `SOL-PERPETUAL` restituisce dati
|
||||
> errati — per gli altcoin usare sempre la forma `_USDC-PERPETUAL`.
|
||||
|
||||
### Discovery & validazione strumenti
|
||||
|
||||
`src/data/instruments.py` scopre e **valida** gli strumenti disponibili sugli
|
||||
exchange implementati — **Deribit** e **Hyperliquid** (esclusi Alpaca/stocks e
|
||||
**Bybit**, feed testnet inaffidabile). Ogni perpetuo viene testato sui dati
|
||||
storici realmente raccoglibili: esistenza, congruenza OHLC, contratto non-morto,
|
||||
liquidità e **congruenza prezzo cross-exchange** (mediana per base-coin, tolleranza
|
||||
5%) — così feed farlocchi e contratti sbagliati (es. `SOL-PERPETUAL`=9.6) vengono
|
||||
scartati. Il risultato è `data/instruments_registry.json` (strumenti validi +
|
||||
timeframe + data d'inizio).
|
||||
|
||||
**Solo gli strumenti validati possono essere scaricati**: il downloader ha un gate
|
||||
(`_download_cerbero_range`) che rifiuta quelli non nel registry. Rigenera con:
|
||||
|
||||
```bash
|
||||
uv run python -m src.data.instruments
|
||||
```
|
||||
|
||||
Simboli Deribit: BTC/ETH = `<COIN>-PERPETUAL` (inverse); altcoin =
|
||||
`<COIN>_USDC-PERPETUAL` (lineari USDC). Registry attuale (testnet): Deribit 18/106
|
||||
validi (major liquidi, BTC dal 2018), Hyperliquid 66/74.
|
||||
|
||||
## Riferimenti
|
||||
|
||||
- Serleto, L. & Malanga, C. — *Pythagoras Trading Prediction* (2024)
|
||||
- Serleto, L. & Malanga, C. — *Libro dei Frattali* (2024)
|
||||
|
||||
## Licenza
|
||||
|
||||
Uso privato. Non destinato alla distribuzione.
|
||||
**Onestà prima di tutto**: nessun numero va creduto finché non è netto fee, out-of-sample, robusto
|
||||
su griglia, e su dati certificati, liquidi ed eseguibili. Il resto di questo repo esiste per rendere
|
||||
quella frase verificabile invece che dichiarata.
|
||||
|
||||
+4
-4
@@ -10,14 +10,14 @@
|
||||
"max_data_age_days": 2,
|
||||
"_nota_skh_feed": "Freschezza del feed 5m usato per il segnale SKH01 (2026-07-26). fresh_5m ricade sul feed certificato IN SILENZIO se il fetch pubblico Deribit fallisce, e il certificato si rigenera 1x/giorno: senza controllo la latenza d'uscita di SKH01 passa da ~1h a ~1 giorno senza segnalazione. Sopra soglia book_execute ALLERTA e NON blocca (bloccare fermerebbe anche TP01, nettato sullo stesso strumento, per un guasto di rete). Diario 2026-07-26-t1-esecuzione-skh-live.md.",
|
||||
"skh_feed_max_age_min": 30,
|
||||
"_nota_usde": "Collaterale USDE a rendimento (test di eligibilita' 2026-08-26: 500 USDE, verdetto entro il 29/08 — diario 2026-08-26-usde-analisi). L'autorita' che LEGGE questa sezione e' src/live/usde.py; la usano shadow._collaterale_usde (equity oraria del book) e scripts/live/usde_watch.py (sorveglianza giornaliera 12:35 UTC). Criteri delle soglie, dichiarati (P6): depeg_warn 0.99 = fuori dalla banda operativa dello spot (~3 bps) e oltre il clamp +-0.5% per fonte dell'indice usde_usdc — a quel prezzo non e' rumore di book; depeg_crit 0.95 = meta' del buffer di haircut (10%) consumata. quota_target 0.70 = la quota DECISA dall'operatore il 2026-08-30 (la decisione di quota che il gate USDE-01 apriva; anticipata di un giorno sul rinvio al 31/08, su richiesta esplicita dell'operatore \"porta in usde tutto il capitale che non viene usato\"). Il 70% NON e' un argmax (M8): e' il massimo compatibile col CUSCINO DI REGOLAMENTO, cioe' il vincolo che r0830_usde_quota non aveva guardato — il P&L e il funding dei perp USDC-lineari si regolano in USDC, non nel collaterale, quindi a quota alta il saldo USDC va negativo alla prima perdita del libro e Deribit lo finanzia a interesse. Il cuscino richiesto e' il disaster-SL sulla massima esposizione lorda: n_asset x frac x disaster_sl_pct = 2 x 0.5 x 0.30 = 30% dell'equity, che lascia esattamente il 70%. quota_max_frac 0.50 = tetto di ALLERTA sulla quota USDE/equity totale (N4: la quota e' l'unica leva contro il rischio emittente, -100% = 25 anni di resa, non recuperabile). Alzato a 0.85 il 2026-08-30 in previsione della quota al 70% e RIMESSO A 0.50 lo stesso giorno: il 70% NON e' raggiungibile. 🚨 venue_cap_frac 0.318 = TETTO DEL VENUE sull'USDE, misurato e NON documentato da Deribit (ne' 'Cross collateral specifications' ne' 'Yield-generating collateral' prevedono un limite sulle quantita' detenibili; il Cap ETHENA che esiste diluisce il TASSO a livello di exchange, non limita gli acquisti). Ogni acquisto oltre il tetto e' rifiutato con `not_enough_funds_in_currency` pur avendo $1.400 disponibili: messaggio FUORVIANTE. E' una FRAZIONE dell'equity, non un livello: provato il 31/08 lasciando scendere l'equity di $8, il tetto e' sceso con lei. ⚠️ DIPENDE DAL MODELLO DI MARGINE, ma pochissimo — misurato a saldo neutro sotto entrambi: SEGREGATO S:SM [643,18-644,18) con equity $2.055,56 = 31,29-31,34%; CROSS X:SM [654,18-655,18) con equity $2.054,90 = 31,84-31,88%. Il passaggio a cross ha comprato +0,55pp = **+11 USDE (~$11)**: il modello entra nel tetto, ma non lo spiega. ⇒ la quota resta ~31-32% sotto qualunque configurazione e il 70% di quota_target NON e' raggiungibile (manca un fattore ~2,2x). Il valore 0.318 e' il bordo BASSO del bracket CROSS, che e' il modello attivo: fa fallire il piano PRIMA dell'ordine. Se si torna a S:SM va rimesso a 0.312. haircut 0.05 — ✅ DIVERGENZA CHIUSA il 2026-08-31 (era 0.10, SBAGLIATO). La pagina margini del conto non espone l'haircut come numero: si RICAVA per differenza fra le due righe, perche' il modello CROSS conta l'USDE scontato e il SEGREGATO non lo conta affatto. Contributo dell'USDE al cross $610,81 su $643,05 di valore -> 5,0137%: l'ipotesi 5% torna a $0,09, la 10% sbaglia di $32,06. Riproducibile: `scripts/research/r0831_margini_conto.py` (N11). Il 26/08 il 10% fu registrato come 'verificato sul venue' senza lasciare traccia di come, e non lo era. 🚨 SCOPERTO NELLA STESSA LETTURA, e vale piu' dell'haircut: il modello di margine ATTIVO e' **Segregated: Standard Margin (S:SM)**, NON cross-collateral. Nella tabella del modello attivo l'USDE **non compare**: non fa margine per i perp USDC-settled del book. Percio' l'haircut oggi non si applica affatto — ed e' esattamente il motivo per cui la misura del 31/08 trovava $0,0000 accantonati. Passare a X:SM aggiungerebbe $610,87 di margine utilizzabile: e' una decisione dell'operatore, non un refactor, e porta con se' la meccanica cross (collateral fee 0,05%/giorno sul saldo negativo, ribilanciamento automatico).",
|
||||
"_nota_usde": "Collaterale USDE a rendimento (test di eligibilita' 2026-08-26: 500 USDE, verdetto entro il 29/08 — diario 2026-08-26-usde-analisi). L'autorita' che LEGGE questa sezione e' src/live/usde.py; la usano shadow._collaterale_usde (equity oraria del book) e scripts/live/usde_watch.py (sorveglianza giornaliera 12:35 UTC). Criteri delle soglie, dichiarati (P6): depeg_warn 0.99 = fuori dalla banda operativa dello spot (~3 bps) e oltre il clamp +-0.5% per fonte dell'indice usde_usdc — a quel prezzo non e' rumore di book; depeg_crit 0.95 = meta' del buffer di haircut (10%) consumata. quota_target 0.70 = la quota DECISA dall'operatore il 2026-08-30 (la decisione di quota che il gate USDE-01 apriva; anticipata di un giorno sul rinvio al 31/08, su richiesta esplicita dell'operatore \"porta in usde tutto il capitale che non viene usato\"). Il 70% NON e' un argmax (M8): e' il massimo compatibile col CUSCINO DI REGOLAMENTO, cioe' il vincolo che r0830_usde_quota non aveva guardato — il P&L e il funding dei perp USDC-lineari si regolano in USDC, non nel collaterale, quindi a quota alta il saldo USDC va negativo alla prima perdita del libro e Deribit lo finanzia a interesse. Il cuscino richiesto e' il disaster-SL sulla massima esposizione lorda: n_asset x frac x disaster_sl_pct = 2 x 0.5 x 0.30 = 30% dell'equity, che lascia esattamente il 70%. quota_max_frac 0.50 = tetto di ALLERTA sulla quota USDE/equity totale (N4: la quota e' l'unica leva contro il rischio emittente, -100% = 25 anni di resa, non recuperabile). Alzato a 0.85 il 2026-08-30 in previsione della quota al 70% e RIMESSO A 0.50 lo stesso giorno perche' il 70% pareva irraggiungibile; RIMESSO A 0.85 il 2026-09-06, quando la quota e' arrivata al 68,7% su autorizzazione dell'operatore e la sonda non ha trovato tetto. 🚨 venue_cap_frac: il 06/09 il tetto NON C'ERA (null, vedi venue_cap_misurato) — quanto segue e' la storia del 30-31/08, tenuta perche' il tetto puo' tornare: venue_cap_frac 0.318 = TETTO DEL VENUE sull'USDE, misurato e NON documentato da Deribit (ne' 'Cross collateral specifications' ne' 'Yield-generating collateral' prevedono un limite sulle quantita' detenibili; il Cap ETHENA che esiste diluisce il TASSO a livello di exchange, non limita gli acquisti). Ogni acquisto oltre il tetto e' rifiutato con `not_enough_funds_in_currency` pur avendo $1.400 disponibili: messaggio FUORVIANTE. E' una FRAZIONE dell'equity, non un livello: provato il 31/08 lasciando scendere l'equity di $8, il tetto e' sceso con lei. ⚠️ DIPENDE DAL MODELLO DI MARGINE, ma pochissimo — misurato a saldo neutro sotto entrambi: SEGREGATO S:SM [643,18-644,18) con equity $2.055,56 = 31,29-31,34%; CROSS X:SM [654,18-655,18) con equity $2.054,90 = 31,84-31,88%. Il passaggio a cross ha comprato +0,55pp = **+11 USDE (~$11)**: il modello entra nel tetto, ma non lo spiega. ⇒ la quota resta ~31-32% sotto qualunque configurazione e il 70% di quota_target NON e' raggiungibile (manca un fattore ~2,2x). Il valore 0.318 e' il bordo BASSO del bracket CROSS, che e' il modello attivo: fa fallire il piano PRIMA dell'ordine. Se si torna a S:SM va rimesso a 0.312. haircut 0.05 — ✅ DIVERGENZA CHIUSA il 2026-08-31 (era 0.10, SBAGLIATO). La pagina margini del conto non espone l'haircut come numero: si RICAVA per differenza fra le due righe, perche' il modello CROSS conta l'USDE scontato e il SEGREGATO non lo conta affatto. Contributo dell'USDE al cross $610,81 su $643,05 di valore -> 5,0137%: l'ipotesi 5% torna a $0,09, la 10% sbaglia di $32,06. Riproducibile: `scripts/research/r0831_margini_conto.py` (N11). Il 26/08 il 10% fu registrato come 'verificato sul venue' senza lasciare traccia di come, e non lo era. 🚨 SCOPERTO NELLA STESSA LETTURA, e vale piu' dell'haircut: il modello di margine ATTIVO e' **Segregated: Standard Margin (S:SM)**, NON cross-collateral. Nella tabella del modello attivo l'USDE **non compare**: non fa margine per i perp USDC-settled del book. Percio' l'haircut oggi non si applica affatto — ed e' esattamente il motivo per cui la misura del 31/08 trovava $0,0000 accantonati. Passare a X:SM aggiungerebbe $610,87 di margine utilizzabile: e' una decisione dell'operatore, non un refactor, e porta con se' la meccanica cross (collateral fee 0,05%/giorno sul saldo negativo, ribilanciamento automatico).",
|
||||
"usde": {
|
||||
"index_name": "usde_usdc",
|
||||
"haircut": 0.05,
|
||||
"quota_target": 0.7,
|
||||
"venue_cap_frac": 0.318,
|
||||
"venue_cap_misurato": "2026-08-31 (X:SM)",
|
||||
"quota_max_frac": 0.5,
|
||||
"venue_cap_frac": null,
|
||||
"venue_cap_misurato": "2026-09-06: NESSUN tetto fino al 68,7% (fermata dal cuscino di regolamento al 70%, non dal venue): +2.434 USDE in 39 ordini a saldo crescente, zero rifiuti (r0906_usde_tetto_sonda.py). Il 30-31/08 il tetto c'era: [643-644) e [654-655) USDE su ~$2.055 = 31,3-31,9%. Non spiegato: e' SPARITO, non e' scalato. null = nessun tetto noto; usde_convert si affida al chunk+backoff sui rifiuti",
|
||||
"quota_max_frac": 0.85,
|
||||
"depeg_warn": 0.99,
|
||||
"depeg_crit": 0.95
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# 2026-09-02 — Debito 14: il report stampa il TWR, non il bonifico
|
||||
|
||||
*Scritto il 2026-09-02 fra le 14:16Z e le 14:30Z. Numeri dalla serie `equity` di `trades.db`
|
||||
(1.678 letture) e dal report rilanciato dopo la riparazione.*
|
||||
*Scritto il 2026-09-02 fra le 14:16Z e le 14:22Z (commit `2f701b8`, 14:22:50Z; la prima stesura diceva «fino alle 14:30Z» senza averlo letto — corretto in revisione). Numeri dalla lettura di equity delle 13:47:01Z (1.678 letture) e dal report rilanciato dopo la riparazione.*
|
||||
|
||||
## Cosa c'era
|
||||
|
||||
@@ -51,3 +50,27 @@ nel test, nel CLAUDE.md e qui.
|
||||
dollari e la sua pagina ha una forma testata. Aggiungerlo è una riga, ma non era il debito.
|
||||
- Non ho scomposto lo scarto (~$25) fra round-trip + non realizzato e il trading al netto: era già
|
||||
fuori scope il 01/09 e lo resta.
|
||||
|
||||
## Revisione del codice, prima tornata (15:15Z, commit `835e0c8` 15:17:05Z) — tre cose trovate, tutte riparate
|
||||
|
||||
| trovato | vero? | riparazione |
|
||||
|---|---|---|
|
||||
| l'intervallo che contiene un movimento certo esce intero dal rendimento: il suo P&L di mercato finisce in `certi` e fuori da `trading`, e nessun documento lo diceva | sì, per costruzione. Errore massimo = metà del movimento riconosciuto (margine del classificatore). Il 25/08: ~$263 lordi a ±0,2% → ~$0,5 | dichiarato nel docstring, nel report, in CLAUDE.md §5.14 e in memoria 40. Non si stima (P12) |
|
||||
| a base di equity zero `trading` era un numero mentre `twr` era None, e sbagliato: il salto 0→X è invisibile al classificatore, `trading` valeva l'intero conto sotto l'etichetta "al netto dei versamenti" | sì. Non raggiungibile dal cron (non scrive mai 0), ma raggiungibile da un backfill | tre stati veri: `twr` e `trading` entrambi None con motivo; il report stampa n/d. Test in `test_journal.py` e `test_trades_report.py` |
|
||||
| movimento nel primo intervallo o due consecutivi → segmento `+0.00% [x -> x]` | sì, cosmetico | i segmenti a lunghezza zero non si producono; nessun tempo a mercato → TWR 0,0 dichiarato numero. Test |
|
||||
|
||||
Il numero pubblicato non cambia: +10,61% alle 14:02Z, +10,56% alle 14:47Z per la marcatura.
|
||||
|
||||
## Seconda tornata (15:30Z) — la piu' importante di tutte
|
||||
|
||||
| trovato | vero? | riparazione |
|
||||
|---|---|---|
|
||||
| il classificatore dei movimenti era **cieco ~23 ore al giorno**: il feed 1h si ferma alle 00:00, `asof` dava la stessa barra alle due letture, mercato «fermo» = 0, qualunque calo ≥10% del giorno diventava «movimento». Riprodotto dalla revisione su copia del DB: −12% appeso alle 14:47 → `certi` 1.399 → 1.153, TWR invariato invece di −2,71% | si'. Verificato: ultima barra 00:00, adesso 15:26 | nuovo stato **«mercato non misurabile»** (feed fermo, assente, o barra mancante) ⇒ `ambiguo` con la ragione; `twr` e `trading` None con motivo |
|
||||
| con `data/raw` assente (host ripristinato prima del rebuild) tutto era «ambiguo», `certi` 0, e il report stampava **+243% sotto l'etichetta TWR** | si' | stesso stato: n/d, mai il grezzo |
|
||||
| «`rendimento_twr` e' la funzione che entrambi chiamano» era **falso**: `pnl_giorno` rifaceva `cum − certi`, e la testata Telegram mandava il cumulato grezzo | si' | `pnl_giorno` chiama `rendimento_twr`; la pagina stampa il TWR accanto al cumulato; la testata dice «di cui versati → trading» |
|
||||
| `leva_tetto` del classificatore ridichiarava `frac × n` ignorando `book_scale_k` e `LEVA_LORDA_MAX` (P1) | si' | `min(frac × n × scala, LEVA_LORDA_MAX)`; fallback = tetto di codice |
|
||||
| il «limite ereditato: +10% di trading a mercato fermo» scritto la mattina era un **artefatto della fixture piatta** (a mercato misurato il trading non supera 2·max_mkt) | si' | cancellato ovunque; il limite vero e' quello sopra |
|
||||
| il bound di mercato guarda solo BTC/ETH, ma il 31% dell'equity e' USDE: un depeg pieno sarebbe un «prelievo» | si' | **non riparato**: l'indice USDE e' registrato una volta al giorno. Debito **§5.15** |
|
||||
| l'ora del report scritta in §2 (14:02Z) era l'ora di esecuzione, non quella della lettura (13:47:01Z); i tempi «scritto» dei diari erano posteriori ai commit | si' | corretti coi tempi veri |
|
||||
|
||||
Il numero pubblicato: TWR +10,61% alla lettura delle 13:47Z; +10,56% alle 14:47Z.
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# 2026-09-02 — Debito 7: il docstring diceva 230 minuti, il cron gira ogni ora
|
||||
|
||||
*Scritto il 2026-09-02 fra le 14:36Z e le 14:38Z (commit `861cc7f`, 14:38:53Z; la prima stesura diceva «alle 14:45Z» senza averlo letto — corretto in revisione). Cadenza misurata dal log (`r0823_sl_anchor.py`: 1.443 giri in
|
||||
1.442 ore) e dalla crontab installata, letta dal test.*
|
||||
|
||||
## Il difetto
|
||||
|
||||
| fonte | cosa diceva | dal |
|
||||
|---|---|---|
|
||||
| `scripts/live/book_execute.py`, docstring | «va lanciato ogni ~230 minuti» | 20/06 |
|
||||
| `scripts/cron_book.sh`, intestazione | cadenza ORARIA, `47 * * * *` | 25/08 (prima `:07`, sempre orario) |
|
||||
| crontab della VPS | `47 * * * *` | 25/08 |
|
||||
|
||||
Era il **docstring** a sbagliare. E non era una svista innocua: sulla riga 4h (la più vicina ai 230
|
||||
minuti) BTC **raddoppia** gli scatti del disaster-SL rotolante (2 contro 1; a 24h ETH arriva a 5, con
|
||||
2 in 30 giorni). Il rischio era un lettore diligente che "allineasse" il cron al docstring.
|
||||
|
||||
## La riparazione
|
||||
|
||||
1. **Docstring riscritto**: `CADENZA: ORARIA`, la riga di crontab citata, la ragione (giro
|
||||
idempotente → girare più fitto della griglia non costa ordini; latenza ≤1h sugli ingressi/uscite
|
||||
software di SKH01; ri-ancoraggio orario del rotolante) e il divieto esplicito, con la data.
|
||||
2. **`tests/test_book_cadenza.py`, 10 test.** P1: non ridichiara «60 minuti», **deriva** le tre
|
||||
fonti e le confronta fra loro:
|
||||
- il docstring non contiene più «ogni ~230» e dichiara ORARIA;
|
||||
- la riga di crontab citata nel docstring è quella dichiarata in `cron_book.sh`;
|
||||
- la riga dichiarata è oraria (parser a 5 campi, solo casi regolari, rifiuta il resto),
|
||||
sotto i 230 della griglia e lontana dai 240 della riga peggiore, e fuori dal minuto `:00`;
|
||||
- **la crontab installata** (`crontab -l`) coincide con la dichiarata. Dove la crontab non è
|
||||
leggibile il test è **saltato**, non verde (P5). Sulla VPS oggi gira e passa.
|
||||
|
||||
## Numeri
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| test nuovi | 10, tutti verdi; suite dei moduli che toccano `book_execute`: 231 verdi |
|
||||
| cadenza dichiarata / installata | 60 min / 60 min |
|
||||
| griglia SKH01 | 230 min (la riga da non usare) |
|
||||
| riga peggiore misurata | 240 min (BTC 2 scatti contro 1) |
|
||||
|
||||
## Cosa NON ho fatto
|
||||
|
||||
- Non ho toccato il cron né `cron_book.sh`: la configurazione che gira era quella giusta.
|
||||
- Non ho aggiunto un sorvegliante a runtime sulla cadenza (contare i giri per ora nel log): il
|
||||
giornale già stampa «giri mancanti» ogni giorno, e il test copre la deriva di configurazione.
|
||||
|
||||
## Revisione del codice, prima tornata (15:15Z, commit `835e0c8`) — due cose trovate, tutte riparate
|
||||
|
||||
| trovato | vero? | riparazione |
|
||||
|---|---|---|
|
||||
| il docstring nuovo diceva «il disaster-SL rotolante si ri-ancora ogni ora»: falso. `ensure_disaster_sl` lascia il bracket com'è finché lo stop voluto è entro il 5% da quello piazzato e la taglia entro il 10%; il giro orario **controlla**, ri-ancora oltre la tolleranza (mark +5,263% / −4,762%). I «BTC 0 / ETH 1» di `r0823` sono misurati **con** questa isteresi | sì, verificato in `execution.py:233-235`. Propagato anche a CLAUDE.md §5.7 e memoria 40 | riscritto in tutti e tre i posti: «controllato ogni ora, ri-ancorato solo oltre la tolleranza; lo stop siede fra −26,3% e −33,3% dal mark corrente». Frase sbagliata su un meccanismo di sicurezza vivo: era la segnalazione più importante |
|
||||
| la guardia del test cercava due letterali («ogni ~230»): «ogni quattro ore» sarebbe passato | sì | la guardia estrae **ogni** prescrizione «ogni/every N unità» e pretende 60 minuti; controllo positivo (M15) su cinque frasi. Limite dichiarato (P13): una prosa che prescrive senza «ogni» passa |
|
||||
|
||||
## Seconda tornata (15:30Z)
|
||||
|
||||
| trovato | vero? | riparazione |
|
||||
|---|---|---|
|
||||
| la frase nuova di CLAUDE.md §5.7 era **invertita** («chi lo avesse corretto nel verso del cron»): correggere il docstring verso il cron e' proprio la riparazione; il pericolo e' correggere il CRON verso il docstring | si' | riscritta nel verso giusto |
|
||||
| la banda dello stop era sbagliata: con isteresi 5% sullo stop (0,70·mark) lo stop siede a 0,665–0,735 × mark, cioe' **−33,5% / −26,5%**, non −26,3/−33,3 | si' | corretta in docstring, CLAUDE.md, memoria |
|
||||
| «BTC raddoppia gli scatti» leggeva male `r0823`: il «2 contro 1» e' rotolante contro pavimento **dentro la riga 4h**; a 1h il rotolante ne fa 0, e la riga peggiore misurata e' **24h** (ETH 5) | si' (la revisione ha eseguito `r0823.simula`) | riscritto ovunque coi conteggi; nel test `RIGA_4H_MIN`, non «peggiore»; `LTF_MIN` importato da `skyhook` (P1) |
|
||||
| «girare piu' fitto non costa ordini» era falso: 22 dei 48 ordini live sono ri-taglie |Δ|≤$10 a segnale invariato; il deadband e' in valuta assoluta (C2) | si' (contati) | riscritto: «costa solo micro-ordini di ri-taglia», con la regola C2 |
|
||||
| «latenza ≤1h» senza condizione: vale solo con la feed 5m fresca; `fresh_5m` ripiega in silenzio sul giornaliero e `skh_feed_max_age_min` allerta e basta | si' | condizione scritta |
|
||||
| `_cron_installato` leggeva la **prima** riga: una seconda riga attiva sotto (`17 * * * *`, dentro lo slot di release) era invisibile | si' (simulato) | si contano **tutte** le righe attive, e devono essere una |
|
||||
| lo skip collassava tre stati: crontab illeggibile, leggibile senza il progetto, leggibile col progetto ma senza `cron_book.sh` attivo (= libro non schedulato) | si' | tre stati: SALTATO, SALTATO, **ROSSO** |
|
||||
| solo il vincolo 1 del `:47` era testato (minuto ≠ 00); il vincolo 2 (fuori dallo slot di release del martedi') no | si' | test con `venue_probe.in_release_window`, il :07 come controllo positivo |
|
||||
| `_cron_dichiarato` accettava solo `N * * * *` e falliva come «assente» su `*/30`; `cadenza_minuti` accettava `*/45`, `0 */5`, `*/0` | si' | regex a 5 campi qualunque, regolarita' e passo > 0 imposti, parametrizzati |
|
||||
| `r0823_sl_anchor.py` si rifiutava di girare nella finestra del **vecchio** `:07` e stampava a runtime che il docstring «prescrive ~230 min»; `cron_chain.sh` giustificava il :25 «fuori dal :07» | si' | guardia sul :47, prosa al passato, commento aggiornato |
|
||||
@@ -0,0 +1,103 @@
|
||||
# 2026-09-02 — Un flag sconosciuto non è l'azione di default
|
||||
|
||||
*Scritto fra le 15:45Z e le 16:05Z. Il difetto è stato misurato, non ipotizzato: la revisione del
|
||||
codice di questa sessione ha eseguito `trades_db.py --help` come sonda di tempi e lo ha visto
|
||||
sincronizzare il DB.*
|
||||
|
||||
## 1. Il difetto
|
||||
|
||||
Nessuno dei 18 script di `scripts/live/` usava argparse. Leggevano gli argomenti così:
|
||||
|
||||
```python
|
||||
if "--reconcile" in args: ...
|
||||
elif "--report" in args: ...
|
||||
else: sync() # <- qui finiva QUALUNQUE cosa non riconosciuta
|
||||
```
|
||||
|
||||
Un flag sbagliato non era un errore: era il ramo finale. Costo osservato e costi possibili:
|
||||
|
||||
| script | `--help` faceva | gravità |
|
||||
|---|---|---|
|
||||
| `trades_db.py` | `sync()`: riscriveva `meta.ultimo_sync` | osservato oggi, danno nullo |
|
||||
| `journal.py` | scriveva la pagina di oggi e una riga nel DB | non osservato, ma scrive |
|
||||
| `analista.py` | spendeva una chiamata al modello e mandava un Telegram | non osservato, ma spende |
|
||||
|
||||
## 2. La riparazione, e perché non è argparse
|
||||
|
||||
`src/live/cli.valida(nome, uso, flag=..., con_valore=...)`, chiamata come **prima istruzione** di
|
||||
ogni blocco `__main__` — prima di `connect()`, prima del sync, prima della rete.
|
||||
|
||||
- `--help` / `-h` → stampa l'uso, esce **0**;
|
||||
- flag non dichiarato, o valore mancante, o posizionale → stderr con **cosa** e **cosa si poteva**
|
||||
(P4), esce **2** (errore d'uso, distinto dall'1 con cui gli script segnalano un esito negativo);
|
||||
- tutto il resto invariato.
|
||||
|
||||
**Argparse no.** Cambierebbe messaggi d'errore, codici d'uscita e il comportamento di `--help` su
|
||||
18 script che il cron già invoca: romperebbe ciò che gira per riparare ciò che non gira mai. Qui
|
||||
serviva l'opposto — lasciare intatti i flag esistenti e rifiutare solo l'ignoto.
|
||||
|
||||
## 3. Il test guarda entrambe le metà del contratto
|
||||
|
||||
`tests/test_cli_flag.py`, 30 test:
|
||||
|
||||
- **l'elenco degli script si deriva dalla cartella** (P1): uno script nuovo che legge `sys.argv` a
|
||||
mano e non chiama `valida` fa fallire il test, non passa inosservato perché nessuno ha aggiornato
|
||||
una lista;
|
||||
- `valida` dev'essere la **prima istruzione** di `__main__` (uscire dopo l'effetto non è uscire);
|
||||
- l'uso deve **nominare** i flag che lo script accetta;
|
||||
- **i flag che il cron usa davvero devono restare accettati** (P15/P16, letti dai `cron_*.sh`): una
|
||||
guardia che ferma il libro di bordo la notte stessa sarebbe peggio del difetto;
|
||||
- end-to-end sui tre script che scrivono: `--help` esce 0 e **non tocca `trades.db`**; un flag
|
||||
ignoto esce 2 e non lo tocca (M15: il caso che ha causato il difetto, non un caso vicino).
|
||||
|
||||
Verificato a mano anche il contrario: `monitor_health.py --quiet`, `trades_db.py --sync --quiet`
|
||||
e `book_execute.py` in dry-run escono 0 come prima.
|
||||
|
||||
## 4. Il resto della sessione
|
||||
|
||||
**Indice USDE orario** (debito §5.15, primo passo). Il classificatore dei movimenti di capitale
|
||||
confronta due letture di equity a un'ora di distanza e il suo bound guarda solo BTC/ETH, mentre il
|
||||
31% dell'equity è USDE: un depeg sarebbe scorporato come un prelievo. `usde_watch` registra
|
||||
l'indice una volta al giorno — troppo rado. Ora `balance_watch` (orario, sola lettura) lo registra
|
||||
a ogni campione, `None` con la ragione se non leggibile, mai 1,0. Il cablaggio nel bound viene
|
||||
quando la serie ha storia: prima serve il dato, poi la regola.
|
||||
|
||||
**Pulizia lasciata dalla revisione.** `tests/helpers.carica_script` sostituisce la dodicesima copia
|
||||
del caricatore `importlib` (15 in giro, con nomi di modulo diversi per lo stesso file e differenze
|
||||
silenziose su `sys.modules`); il fill di prova si scrive con `upsert_fills` invece che con un
|
||||
`INSERT` a mano che lasciava `verified` NULL, una forma che la produzione non produce; le
|
||||
asserzioni sul testo del report non sono più ancorate al padding; `movimenti_capitale` accetta le
|
||||
righe già lette, così il report non fa due SELECT sulla stessa tabella a un'ora del cron — due
|
||||
letture ai due lati di una scrittura descriverebbero due istanti diversi.
|
||||
|
||||
## 5. Il README era fermo a quindici giorni prima del reset
|
||||
|
||||
Chiesto un aggiornamento della documentazione, ho aperto il README e ho trovato un documento del
|
||||
**2026-06-04**. Il reset v2.0.0 è del **19 giugno**. Per 75 giorni la prima pagina del repository ha
|
||||
descritto la libreria pre-reset come se fosse il presente:
|
||||
|
||||
| cosa diceva | stato vero |
|
||||
|---|---|
|
||||
| famiglie FADE / HONEST / PAIRS / TSMOM / SHAPE, strategie MR01…SH01 | archiviate in `Old/`, artefatto di feed contaminato |
|
||||
| PORT06: Sharpe **7,84 / 10,06**, DD 2,60%, CAGR ~79% | numeri che `CLAUDE.md` §2 elenca sotto «non citare» |
|
||||
| paper trader su `strategies.yml`, portafogli in `portfolios.yml` | **file inesistenti** |
|
||||
| `scripts/waste/`, `scripts/portfolios/`, `src/live/multi_runner.py` | **inesistenti** |
|
||||
| esecuzione shadow su Deribit **testnet** | il testnet è *la causa del reset*; oggi si esegue su mainnet con soldi veri |
|
||||
|
||||
Riscritto contro lo stato vero: cosa gira adesso, i numeri nella lente di §2 (TWR +10,6%, non la
|
||||
crescita del conto), il metodo e i sei requisiti, la struttura verificata file per file, i gate con
|
||||
le loro date, l'obiettivo con la sua onestà. Ogni percorso citato è stato controllato: esiste.
|
||||
Da 423 righe a 152.
|
||||
|
||||
**La lezione che ho registrato non è «aggiornare il README».** È che un reset invalida anche i
|
||||
documenti che nessuno rilegge, e l'inventario di cosa cita numeri morti va fatto il giorno del
|
||||
reset — non 75 giorni dopo, per caso, mentre si fa altro.
|
||||
|
||||
## 6. Cosa NON ho fatto
|
||||
|
||||
- I loader `importlib` dei test sulla **ricerca** (moduli in `scripts/research/`) restano come
|
||||
sono: sono a livello di modulo, con radici diverse, e toccarli non ripara niente.
|
||||
- Il bound USDE nel classificatore: manca la storia, non il codice.
|
||||
|
||||
- Non ho cercato altri documenti fermi al pre-reset fuori da `README.md` e `docs/`: l'inventario
|
||||
completo (ogni file che cita un numero morto) resta da fare, ed è la vera coda di questa scoperta.
|
||||
@@ -0,0 +1,91 @@
|
||||
# 2026-09-06 — USDE: dal 14,6% al 68,7%. Il tetto del venue del 30-31/08 non c'era piu'; il versamento di prova del 25/08 dichiarato
|
||||
|
||||
**Ore:** 20:56-21:16Z. **Autorizzazioni dell'operatore, in sessione:** *«usa piu' USDE se ne hai bisogno»*,
|
||||
poi *«cerca % massima raggiungibile in USDE»*, sulla decisione gia' presa il 30/08 (quota bersaglio 70%,
|
||||
`usde.quota_target`). E, a meta' sessione: *«i 25 euro erano un versamento di prova»*.
|
||||
|
||||
## 1. Il versamento del 04/09 aveva dimezzato la quota
|
||||
|
||||
Il versamento di **$2.410,14 del 04/09 14:47Z** (riconosciuto dal classificatore: mercato max 0,56% a leva
|
||||
piena contro un salto del +116,8%) ha portato l'equity a **~$4.495** e la quota USDE da 31,3% a **14,6%**.
|
||||
Nessun automatismo la riportava su.
|
||||
|
||||
## 2. Primo passo: al tetto di config (31,8%) — e la lettura SBAGLIATA che ne ho dato
|
||||
|
||||
`usde_convert.py --quota 0.318 --esegui`: 774 USDE in 8 ordini @ 1,0005 medio, zero rifiuti, quota 31,79%.
|
||||
Ho scritto in CLAUDE.md «il tetto ha tenuto la stessa frazione a equity 2,2× maggiore: terza conferma».
|
||||
**Falso**: nessun rifiuto fino al 31,8% dimostra che il tetto e' **≥** 31,8%, non che e' **=** 31,8%. Il
|
||||
dato non conteneva la conferma; l'ho letta perche' me l'aspettavo. Corretto venti minuti dopo, quando
|
||||
l'operatore ha chiesto la % massima e la sonda ha mostrato che la conferma non c'era.
|
||||
|
||||
## 3. La sonda: nessun tetto fino al 68,7%
|
||||
|
||||
`scripts/research/r0906_usde_tetto_sonda.py` — a saldo crescente, passi piccoli, stesso metodo dei diari
|
||||
30-31/08 (un probe unico di taglia sbagliata produce un falso negativo): sale finche' il venue non rifiuta,
|
||||
ripete il rifiuto dopo aver riletto il book (il messaggio `not_enough_funds_in_currency` e' lo stesso di un
|
||||
limite che non incrocia), scende di passo a ogni doppio rifiuto, e conferma il muro a saldo neutro
|
||||
(SELL 1 → BUY 1 ok → BUY 1 rifiutato). Guardie: prezzo dal book (ask+5 tick, tetto 1,0010), quota mai oltre
|
||||
il 70% e **USDC mai sotto il cuscino di regolamento** derivato da config (`usde_convert.cuscino_richiesto_usd`).
|
||||
|
||||
| corsa | passi | da → a (USDE) | quota | rifiuti |
|
||||
|---|---|---|---|---|
|
||||
| 21:02-21:09 | 30 × 2 | 1.428,7 → 1.488,7 | 31,8% → 33,1% | **0** (fermata dal mio limite +60) |
|
||||
| 21:11-21:15 | 16 × 100 | 1.488,7 → **3.088,7** | 33,1% → **68,7%** | **0** (fermata dal cuscino: +100 avrebbe superato il 70%) |
|
||||
|
||||
**Totale del giorno: 2.434 USDE in 39 ordini @ 1,0005, ~$1,2 di spread.** Conto finale: USDC **$1.407,40** +
|
||||
USDE **3.088,72** = $4.496; cuscino richiesto $1.349 → slack **+$58**. Registro: `data/live/usde_convert.jsonl`
|
||||
(due record `sonda_tetto`, con ogni ordine).
|
||||
|
||||
## 4. Cosa dice il risultato — e cosa non dice
|
||||
|
||||
1. **Il tetto del 30-31/08 era vero allora ed e' sparito ora.** Tre bracket riproducibili a 1 USDE (31,21-31,26% ·
|
||||
31,29-31,34% · 31,84-31,88%) contro 2.434 USDE accettati oggi senza un rifiuto. **Non e' scalato, e'
|
||||
sparito**: la frase del 31/08 «il 70% non e' raggiungibile ne' ora ne' mai» e' falsificata.
|
||||
2. **Non spiegato** (D5). Ipotesi non verificabili da qui (il gateway non espone il transaction log ne' i
|
||||
parametri del cap): un'allocazione per-conto del Cap ETHENA che varia col saldo aggregato dell'exchange;
|
||||
un vincolo sui fondi depositati di recente (ma l'USDC del 04/09 e' stato convertibile a 2 giorni); un
|
||||
cambio di policy. **Il tetto puo' tornare**: quando tornera', bloccherebbe gli ACQUISTI, mai le vendite
|
||||
(tutte le SELL sono sempre passate). `venue_cap_frac` → **null** con la misura in `venue_cap_misurato`;
|
||||
`usde_convert` si affida al chunk+backoff sui rifiuti, che e' cio' che ha sempre fatto.
|
||||
3. **Il limite che morde e' NOSTRO: il cuscino di regolamento.** P&L e funding dei perp si regolano in USDC;
|
||||
a 68,7% restano $58 sopra il cuscino. ⚠️ **Nessuno sorveglia il cuscino**: `usde_watch` allerta sulla
|
||||
quota (`quota_max_frac`, riportata a **0,85**, il valore che l'operatore aveva scelto il 30/08 «in previsione
|
||||
della quota al 70%»), non sull'USDC. Una perdita del libro fa salire la quota da sola: a −$58 il cuscino e'
|
||||
scoperto, e Deribit finanzia un saldo USDC negativo allo 0,05%/giorno (18%/anno). Debito nuovo, §5.17.
|
||||
4. **Rischio emittente**: a 24,2% l'evento Ethena valeva 3,1× il maxDD del libro (30/08); a 68,7% vale **~8,8×**.
|
||||
E' la decisione del 30/08 presa con l'informazione completa (r0830_usde_quota), oggi eseguita. Resa attesa
|
||||
~4,1% × $3.089 ≈ **$127/anno** (era $26): ancora meno di un mese di versamento.
|
||||
|
||||
## 5. Il versamento di prova del 25/08: dichiarato, scorporato, e il TWR scende di 4 punti
|
||||
|
||||
Trovato stamattina per differenza (equity 07:07→08:07 **+$21,11** con le posizioni a **−$3,3/−4,3**) e
|
||||
confermato dall'operatore: **€25 di prova** prima del bonifico da $1.399,39 delle 11:47. Il rilevatore non
|
||||
poteva vederlo (+3,3% contro soglia 10%) e abbassare la soglia farebbe di ogni ora di mercato un candidato:
|
||||
**l'unica fonte che sa l'importo e' l'operatore**. Costruito il canale:
|
||||
|
||||
- `data/live/movimenti_dichiarati.jsonl` (append-only, nel perimetro di backup — P11), letto da
|
||||
`journal.movimenti_dichiarati()`; `movimenti_capitale` lo fonde coi rilevati: fonte `dichiarato`, importo
|
||||
dell'operatore (non il salto), `dopo = prima + importo` cosi' il mercato dell'ora **resta** nel rendimento;
|
||||
se coincide con un salto rilevato l'importo dichiarato vince (`dichiarato+rilevato`); fuori dalle letture →
|
||||
`avvisi`, non applicato; riga rotta → `avvisi`, le altre restano (P3).
|
||||
- Importo registrato **$24,90 ± 0,50**: stimato per differenza (salto +21,11, mercato −3,3/−4,3 dal log del
|
||||
book, fee 0,03) — il gateway non espone `get_deposits`, quindi il numero USDC esatto non e' leggibile; la
|
||||
banda sta nel record e nel report (P12).
|
||||
- `tests/test_journal.py`: +6 (scorporo sotto soglia · dichiarato+rilevato · fuori letture · riga rotta ·
|
||||
senza file niente cambia · la nota di giornale lo dice); `conftest` isola il file nei test.
|
||||
|
||||
| | prima (20:47Z) | dopo (21:14Z) |
|
||||
|---|---|---|
|
||||
| movimenti certi | $3.809,53 | **$3.834,43** |
|
||||
| trading da arming | +$87,35 | **+$62,45** |
|
||||
| TWR | +11,98% | **+7,83%** = +8,14% × −0,62% × −0,12% × +0,46% |
|
||||
|
||||
**Un terzo del «trading» era un bonifico da 25 euro.** Regola nuova (CLAUDE.md §2): ogni versamento, anche
|
||||
di prova, si dichiara nel file il giorno stesso.
|
||||
|
||||
## 6. File toccati
|
||||
|
||||
`config/live.json` (usde: venue_cap_frac null, quota_max_frac 0.85, nota) · `src/live/journal.py` ·
|
||||
`scripts/live/trades_db.py` · `tests/conftest.py` · `tests/test_journal.py` (+6) ·
|
||||
`scripts/research/r0906_usde_tetto_sonda.py` (nuovo) · `data/live/movimenti_dichiarati.jsonl` (nuovo) ·
|
||||
`CLAUDE.md` §1 §2 §4 §5.
|
||||
@@ -0,0 +1,219 @@
|
||||
# 2026-09-09 — Guadagnare nei crolli: revisione dei sistemi, il crollo catturato, piu' monete su Deribit
|
||||
|
||||
**Richiesta dell'operatore:** *"fai analisi e revisione dei sistemi adottati e trova soluzione per
|
||||
guadagnare anche nei crolli (usa le opzioni se serve), anche + monete ma sempre in deribit"*.
|
||||
Analisi e revisione col modello `fable` (revisore fresco, agente separato), test con Opus.
|
||||
|
||||
**Esito in una riga:** nel **giorno** del crollo il libro live **perde** (−0,48%/g, positivo nel 18%
|
||||
dei giorni ≤ −5%); per **finestra** e' immune-o-positivo e **dal 2022 chiude in utile 8/8 peggiori
|
||||
finestre**, tutte per la gamba SHORT di SKH01 marcata all'uscita; la sola leva che aumenterebbe quel
|
||||
guadagno e' il peso di SKH01, che il gate del progetto boccia su una condizione in-sample; le
|
||||
opzioni, misurate per la prima volta su un **crollo catturato a quote vere**, danno un rapporto di
|
||||
credito identico al rally (f_net 0,74 vs 0,714) — **non** il `f` di stress che la regola del 19/06
|
||||
aspettava (crollo a vol bassa, gate di VRP01 chiuso, famiglia inverse); XRP, l'unica "altra
|
||||
moneta" liquida e non misurata di Deribit, **diluisce** esattamente come SOL. **Libro, pesi,
|
||||
config, cron INVARIATI. Nessun ordine.**
|
||||
|
||||
## 0. Prima di misurare: cosa era gia' morto (memoria letta, non riaperta)
|
||||
|
||||
| meccanismo "guadagnare nei crolli" | esito | perche' e' morto (chi riapre deve battere questo) |
|
||||
|---|---|---|
|
||||
| gamba short del trend a 1d (§42) | SCARTATO | −1,91%/anno di drift in 0/24 ancore; il 2022 lo tiene a galla a meta' |
|
||||
| put deep-OTM statica (§46) | REFUTATO 0/162 | beta del libro +0,076: non si assicura un libro gia' piatto |
|
||||
| collar ≤15g (§71) · pavimento come licenza (§72) | REFUTATI | il DD lo riduce il TETTO (troncatura, C9); la put sola peggiora il DD 16/16 |
|
||||
| gamma scalping long-vol | SCARTATO | paga il VRP ogni anno, ogni frequenza |
|
||||
| DVOL direzionale · gate macro · skew de-risk | HEDGE / RIDONDANTI | TP01 e' gia' flat nei crolli (Δ 0,00) |
|
||||
| SOL terza gamba (22/08) | ESCLUSO dall'operatore | hold-out −0,166 in 0/24; un anno solo (2023) |
|
||||
| XS01 su Deribit (§50) | MORTO per ampiezza | 11 gambe con ≥1 anno: 1,265 → 0,116 |
|
||||
|
||||
Condizione di riapertura scritta tre volte (§3 short-vol, §71, §72): **un `f` di stress reale su
|
||||
un crash catturato**. E' la cosa che oggi si poteva misurare e non era stata misurata.
|
||||
|
||||
## 1. Il libro nei crolli — `r0909_libro_nei_crolli.py`
|
||||
|
||||
Lente: sleeve di ricerca, ancora canonica, fee 0,10% RT, funding fuori; pesi importati da
|
||||
`src/live/book` (P1). ⚠️ **SKH01 e' marcato all'USCITA del trade** (`harness.backtest_signals`):
|
||||
la serie giornaliera e' zero nell'87,7% dei giorni, e uno short aperto nella finestra e chiuso
|
||||
fuori e' accreditato fuori — per questo la tabella per GIORNO mostra SKH01 ≈ 0 e quella per
|
||||
FINESTRA il guadagno. Indice 50/50 BTC/ETH; giorno di crollo ≤ −5%; episodio = 12 peggiori
|
||||
finestre di 20 giorni non sovrapposte + 9 episodi nominati prima. Classi ESCLUSIVE nell'ordine
|
||||
dichiarato: immune (|libro| < |idx|/10, col segno) → POSITIVO → PERDE.
|
||||
|
||||
| misura | valore |
|
||||
|---|---|
|
||||
| beta del libro all'indice | **+0,0769 sulla finestra di §46** (2021-03-24+; memoria +0,076: riprodotto al millesimo) · +0,086 su tutta la storia · +0,002 nei giorni ≤ −3% |
|
||||
| 160 giorni ≤ −5% (indice −7,76%/g) | libro **−0,48%/g**: TP01 −0,63, SKH01 +0,15; **libro > 0 nel 18%** dei giorni; somme per anno: 2024 −11,9%, 2025 −7,3%, 2022 +8,2%, 2026 +4,7% |
|
||||
| 24 giorni ≤ −10% (indice −14,4%/g) | libro −0,51%/g: TP01 −0,93, SKH01 **+0,42** |
|
||||
| 12 peggiori finestre 20g | **4 POSITIVO / 6 immune (4 col segno +) / 2 PERDE**; libro > 0 in 8/12; **dal 2022 in 8/8** (mediana +3,3% contro indice −29%); le 2 perdite sono 2019 e maggio 2021 (TP01 ancora long: il trend non si era girato) |
|
||||
| 9 episodi nominati | libro > 0 in LUNA (+4,3%), giugno 2022 (+3,1%), FTX (+0,7%), agosto 2024 (+0,4%), 5/02/2026 (+4,4%), 1-5/06/2026 (+3,3%); COVID −0,04%, feb 2025 −1,5%, maggio 2021 −3,4% |
|
||||
| da dove viene il guadagno | la gamba **SHORT di SKH01 = 108%** dei guadagni negli episodi con libro > 0 (scomposizione ESATTA: long + short == intera su 2735/2735 giorni, verificato con assert; TP01 contribuisce in negativo); per anno al peso 0,25: 2022 **+10,7%**, 2026 **+7,7%** |
|
||||
| peggior giorno del libro (lente canonica) | 2025-10-10 −3,38%, tutto TP01 con esposizione **0,505**, indice −9,7%: **e' un giorno di crollo con TP01 mezzo long** (lo squeeze di §33 sta sulla lente hourly e qui non si riproduce: dichiarato nel docstring) |
|
||||
| riga informativa 37,5/62,5 (§26) | batte il 75/25 in 12/12 episodi, **ma** Sharpe 1,81 vs 1,82 e maxDD 12,1% vs 9,4%: paga i crolli con gli squeeze |
|
||||
|
||||
🚨 **Errore catturato in revisione (fable):** la prima stesura provava "GUADAGNA" prima di
|
||||
"immune" e stampava **8/12 GUADAGNA**; con le classi nell'ordine dichiarato sono **4 POSITIVO +
|
||||
6 immune**. *L'ordine delle regole era il verdetto.* Ora il verdetto e' tre affermazioni separate
|
||||
(giorno / finestra / provenienza) e il conteggio nudo libro > 0 e' stampato accanto.
|
||||
|
||||
📌 **Il meccanismo "guadagnare nei crolli" esiste ed e' in produzione: e' SKH01 short** — ma
|
||||
guadagna **dopo** il giorno del crollo (marcato all'uscita), non dentro. Nel senso stretto della
|
||||
domanda dell'operatore il libro **perde nel giorno** e recupera nella finestra. Le due perdite
|
||||
storiche per finestra sono di TP01 nei crolli che arrivano *dentro* un trend rialzista (2019,
|
||||
2021): e' il costo del long-flat (§42 ha misurato che la short del trend lo aggrava).
|
||||
|
||||
**La leva che aumenterebbe il guadagno e' il peso di SKH01, e il gate la boccia:**
|
||||
`r0726_reeval_live_weight.py` rilanciato: argmax 0,35, plateau [0,25-0,50] (il live e' dentro),
|
||||
`weights_tilt_null` 0,25→0,35: `delta_insample −0,0026`, `pctl_hold 31,2`, **`gate_pass False`**.
|
||||
⚠️ Precisazione della revisione: lo script legge la cache `data/_cache/skh_sigtab/*` **ferma al
|
||||
2026-07-25** — sono le stesse cifre del diario 26/07, non un ri-esame coi segnali di oggi. La
|
||||
condizione che fallisce e' pero' **in-sample (pre-2025)**, che 46 giorni di dati nuovi non possono
|
||||
cambiare: la decisione 75/25 resta vincolante e la sua condizione di riapertura resta non
|
||||
soddisfatta, ma la data da citare e' il 26/07.
|
||||
|
||||
## 2. Il crollo catturato — `r0909_crash_catturato.py`
|
||||
|
||||
**Fatto nuovo:** `bite_archive.parquet` copre con bid E ask il crollo del **1-5 giugno 2026**
|
||||
(BTC −25% / ETH −30% dal 31/05 al minimo; ETH −11,1% il 05/06) su una scadenza (19/06, 14-21
|
||||
DTE). Il campione del 22/08 (§11) partiva **dopo**, dal fondo. Limiti dichiarati prima: n = 1
|
||||
crollo, 1 scadenza, tenor 14-21 g (VRP01 e' a 7), spot dal feed 1h certificato, regolamento allo
|
||||
spot dell'ora di scadenza, **famiglia INVERSE** (premi in ETH: VRP01 opererebbe sulla USDC-lineare,
|
||||
che il conto margina e che NON e' nell'archivio). Ancora d'ingresso = ogni snapshot 30/05-04/06;
|
||||
mediana e banda.
|
||||
|
||||
⚠️ **Non e' un crollo a vol alta:** DVOL pre 37 (BTC) / 49 (ETH) all'IV-rank 0,05 / 0,10; il
|
||||
**picco** del crollo (49 / 69) sta al **29° / 46° percentile storico** — sotto la mediana. Il gate
|
||||
IV-rank ≥ 0,30 di VRP01 e' **chiuso in tutte le ore** e il modello del sleeve segna 0,0 su tutte le
|
||||
settimane di giugno: VRP01 non sarebbe stato a mercato.
|
||||
|
||||
| ETH, ingressi PRE-crollo (286 snapshot = **4 strutture** di strike, δ −0,27/−0,09, larghezza 200) | reale | modello (BS su DVOL) | f |
|
||||
|---|---|---|---|
|
||||
| credito del put credit spread | | | **0,74** [0,65-0,82] — 22/08 in rally: 0,714 |
|
||||
| P&L a scadenza per unita' | **−$174** | −$168 | 1,04 — **tautologico nell'83%** dei trade: entrambe le gambe ITM ⇒ payoff = larghezza per reale e modello, resta solo f_net |
|
||||
| perdita sul rischio (fee incluse) | 101% [65-102] | | = perdita **MASSIMA** dello spread (+ fee); 65% per la 1900/1600 |
|
||||
| peggior MTM, chiusura **tagliata alla larghezza** | **−$172** | −$161 | **1,08** [1,07-1,31]; a quell'ora IV corta 68 = DVOL 68 |
|
||||
| put δ−0,10 comprata all'ask: costo | | | **1,92×** il modello (§46 a δ−0,10 su BTC_USDC: 1,27 — punto NUOVO e piu' alto, ETH inverse) |
|
||||
| put δ−0,10 a scadenza (ITM di $4: K 1700, ST 1694) | −$6 | ≈ $0 | 0,96 sul solo sottoinsieme con payoff modellato ≥ $5 |
|
||||
| put δ−0,10 al picco del percorso (look-ahead, limite sup.) | +$163 | +$173 | 0,94 |
|
||||
| debit spread −0,28/−0,10 a scadenza | +$169 | +$163 | 1,04 (stessa tautologia, specchio) |
|
||||
| ingressi DURANTE il crollo (02-04/06, 283 snapshot) | mediana +$5, **perde nel 46%**, p10 −$133 / p90 +$26 | | bimodale: la mediana nasconde le perdite |
|
||||
|
||||
BTC: **non misurabile pre-crollo** — la griglia di strike dell'archivio il 30/05-01/06 non
|
||||
contiene la δ−0,10 (78 snapshot fuori geometria, 0 in geometria prima del 02/06). Durante:
|
||||
247 snapshot, f_net 0,79, perde nel 37% (p10 −$1.718 / p90 +$818 su larghezza 5000).
|
||||
|
||||
🚨 **Tre correzioni della revisione, applicate:**
|
||||
1. *"Il percorso costa 2,6× il modello (f_mtm 2,58), la IV della gamba corta e' esplosa a 108 per lo
|
||||
skew"* — **artefatto di quote.** Il costo di chiusura `(ask corta − bid lunga)` superava la
|
||||
**larghezza** dello spread (una quota 0,208/0,376 ETH su un intrinseco di $296: 158% di spread
|
||||
relativo, a T→0) e `min()` pescava li'; uno spread a rischio definito non costa mai piu' della
|
||||
larghezza per chiudere. Tagliato: **f_mtm 1,08**. E la IV 108 e' del **18/06 alle 17:00** (deep
|
||||
ITM, T→0), non del crollo: all'ora del peggior MTM la IV e' 68 = DVOL.
|
||||
2. *"f a scadenza 1,04: il modello prezza bene la perdita"* — **tautologia**: con entrambe le gambe
|
||||
ITM il payoff e' la larghezza per entrambi; f_exp e' una funzione di f_net. E la "banda su 286
|
||||
snapshot" sono 4 strutture (N_eff = 1 crollo × 4).
|
||||
3. `S.asof(ts)` sul feed 1h **guardava un'ora avanti**: la barra e' etichettata all'apertura (la
|
||||
20:00 chiude col 5m delle 20:55). Corretto con `spot_causale` (indice +1h). ⚠️ **`cblib.spot_series`
|
||||
ha lo stesso difetto** → il f 0,714 di §11 e ogni numero di `cblib` con `asof` sullo spot; qui
|
||||
innocuo (ambo le gambe ITM per ogni ST < 1700) e nel MTM giocava contro l'autore. **Debito nuovo,
|
||||
registrato in CLAUDE.md §5.** Non toccato: cambierebbe numeri pubblicati.
|
||||
|
||||
📌 **Cosa dice davvero il crollo catturato (n = 1):** il **rapporto di credito reale/modello non
|
||||
cambia col regime** (0,74 nel crollo contro 0,714 nel rally); la put comprata paga nel crollo
|
||||
cio' che il modello dice (0,94-1,04) ma **costa 1,9× il modello all'ingresso** — il bleed e' dove
|
||||
il modello sbaglia, il payoff no (e' il motivo di §46/§72 visto dal lato del sinistro); il gate di
|
||||
VRP01 lo ha tenuto fuori. **La condizione di riapertura di §3 e' soddisfatta nella forma** (un
|
||||
archivio bite con bid/ask su un crollo) **e non nella sostanza**: crollo a vol bassa, sotto il gate,
|
||||
tenor 14-21, famiglia inverse, e l'unico f non tautologico (MTM) vale 1,08 su 4 strutture. **Non si
|
||||
riapre.** Primo punto di una distribuzione che `collect_chain` riempira' da solo; il secondo punto
|
||||
utile e' un crollo **con IV-rank > 0,30**.
|
||||
|
||||
## 3. Piu' monete, sempre in Deribit — `r0909_deribit_universo.py` · `r0909_xrp_terza_gamba.py`
|
||||
|
||||
**Il venue letto oggi (15:05Z):** 32 perpetual USDC-lineari aperti (+6 non aperti, fra cui **APT
|
||||
inactive**: e' il mancante rispetto al 23/08). Volume 24h ≥ $10M solo per **XRP ($38M, il piu'
|
||||
scambiato del venue, spread 4 bps, listato 2022-03-16), ETH, BTC, SOL ($13M)**; tutto il resto fra
|
||||
$0,01M e $7M (TAO, HYPE, ADA, AVAX, NEAR…) con spread 2-20 bps. XS01: **13/19** gambe — mancano
|
||||
ARB, OP, APT, INJ, TIA, SEI → §50 resta: morto per ampiezza. Opzioni USDC-lineari su 7 sottostanti
|
||||
(SOL 676, HYPE 442, XRP 442, TRX, AVAX) con la liquidita' misurata il 22/08 (SOL: 9% delle put a
|
||||
due lati). Trovati dalla revisione, registrati perche' il controllo costa €0 e nessuno li riapra:
|
||||
**`BTCDVOL_USDC-30SEP26`** (l'unico strumento long-vol diretto del venue: volume 0, OI 132, bid/ask
|
||||
39,1/43,9 = spread 12% → non negoziabile, e in contango sanguina come la put) e **PAXG perpetual**
|
||||
(oro, dal 2024-12: $0,18M/g, OI $8,3M — non misurato, non morto, stessi muri di SOL/XRP: 9 mesi di
|
||||
storia contro i 3 anni della decisione 22/08).
|
||||
|
||||
⇒ **L'unica "altra moneta" liquida su Deribit mai misurata come gamba direzionale e' XRP.** Misurata
|
||||
col harness di `r0822_sol_leg` (TP01 e SKH01 **congelati**, 24 ancore, differenze appaiate,
|
||||
de-levering, per anno), ipotesi a priori dichiarata: diluisce.
|
||||
|
||||
Certificazione (Coinbase dal rilisting 2023-07, Bitstamp prima — Coinbase aveva delistato XRP per
|
||||
la causa SEC): >1% dal riferimento 0,06% (2022) · **0,57% (2023)** · 0,01-0,02% (2024+); mediana
|
||||
5-6 bps. ⇒ **L-PULITA dal 2024** (stessa soglia e stesso anno sporco di SOL). ⚠️ Barre 5m flat
|
||||
**27-61%** (BTC/ETH 2021+: 0,1-2%; SOL 21%): il 5m su cui gira SKH01 e' 20-100× piu' flat.
|
||||
|
||||
| | L-FULL (2022-03+) | L-PULITA (2024+) |
|
||||
|---|---|---|
|
||||
| dSharpe FULL (mediana, 24 ancore) | **−0,206** [−0,26, −0,16], >0 in 0% | −0,066, >0 in 12% |
|
||||
| dSharpe hold-out | **−0,169** [−0,32, −0,15], **>0 in 0%** | −0,169, 0% |
|
||||
| dCAGR | −2,27 pp, 0% | −1,32 pp, 4% |
|
||||
| null del de-levering | Sh 3g 1,23 vs 2g×0,974 **1,44** → de-levering | 1,54 vs 1,53 → pari |
|
||||
| per anno (dSh) | 2022 −0,55 · 2023 −0,66 · **2024 +0,54** · 2025 −0,44 · 2026 −0,25 → **1/5** | |
|
||||
| corr(gamba XRP, book) | +0,35 / +0,37 (SOL 0,40-0,56) | |
|
||||
|
||||
**XRP DILUISCE** — lo stesso −0,17 di hold-out di SOL, in 0/24 ancore, con **un anno buono** (2024).
|
||||
TP01 XRP da solo: Sharpe −0,01, hold-out −0,49; SKH01 XRP maxDD 40,1% (il criterio di selezione di
|
||||
SKH01-V2-DD, maxDD<30%, fallisce anche qui, come SOL). Eseguibilita' non e' il vincolo (min 1 XRP =
|
||||
$1,42). Non misurato: **SKH01 solo-short su una terza moneta** (la domanda riguarda i crolli, dove
|
||||
paga lo short) — ma il filtro di direzione e' una famiglia nuova (M20) e il 5m di XRP e' peggio di
|
||||
SOL: il muro e' lo stesso. **Nessuna proposta.** Il dato vive in `data/raw/alt_xrp_*` (namespace di
|
||||
ricerca, non rinfrescato dal cron, `load_data("XRP")` fallisce; `tests/test_sol_leg.py` lo presidia
|
||||
via `DERIBIT_INSTR`). La cache del riferimento in `data/_cache` e' gitignored e fuori backup: la
|
||||
certificazione si riproduce **con rete** (N11 vale con rete).
|
||||
|
||||
## 4. Cosa cambia una decisione — niente; cosa si registra
|
||||
|
||||
| voce | prima | oggi |
|
||||
|---|---|---|
|
||||
| il libro nei crolli | "immune, beta 0,076" | perde nel GIORNO (−0,48%/g, 18% positivi), immune-o-positivo per finestra, **> 0 in 8/8 dal 2022** per SKH01 short marcato all'uscita; beta 0,0769 riprodotto |
|
||||
| f di stress delle opzioni | non misurato ("serve un crash catturato") | crollo catturato **a vol bassa, sotto il gate, inverse**: f_net 0,74 = rally; f_mtm 1,08; ali 1,92×. **Condizione non soddisfatta nella sostanza** |
|
||||
| peso SKH01 | gate fallisce (26/07) | invariato: la condizione che fallisce e' in-sample; cache segnali ferma al 25/07 |
|
||||
| XRP | mai misurato | diluisce come SOL (−0,169 hold-out, 0/24) |
|
||||
| universo Deribit | 14/19 (23/08) | 13/19 (APT inactive); liquidi solo BTC/ETH/XRP/SOL; BTCDVOL future non negoziabile; PAXG non misurato |
|
||||
| debito nuovo | — | `cblib.spot_series` + `asof` guarda un'ora avanti (feed 1h etichettato all'apertura) |
|
||||
|
||||
**Cosa riaprirebbe qualcosa:** (a) un secondo crollo catturato **a IV-rank > 0,30** (VRP01 a
|
||||
mercato), sulla famiglia USDC-lineare, per il f di stress; (b) `weights_tilt_null` che passa — si
|
||||
rigenera `skh_sigtab` e si rilancia `r0726_reeval_live_weight`, non si ridiscute; (c) per le
|
||||
"altre monete", un meccanismo che non sia TP01/SKH01 congelati, o 3 anni di dato 5m liquido.
|
||||
|
||||
## 5. Errori catturati in sessione (prima della revisione)
|
||||
|
||||
- Percorso ora per ora in un ciclo `groupby` per ancora: >10 minuti; riscritto su una tabella
|
||||
ora×strumento (`Quote`), ~3 minuti.
|
||||
- BTC "in geometria" senza guardia: la griglia rada faceva scegliere a `pick_legs` una δ−0,02 al
|
||||
posto della δ−0,10 (K72000/55000). Guardia `TOL_D` (±0,08/±0,06), scritta dopo aver visto BTC ma
|
||||
che per ETH scarta 2/288 e non cambia nessun verdetto (verificato dal revisore).
|
||||
- Coinbase restituiva un batch vuoto per XRP 2022 e il ciclo si fermava: certificazione vuota.
|
||||
Riscritto con avanzamento su batch vuoto e riferimento Bitstamp dove Coinbase manca.
|
||||
|
||||
## 6. Revisione (`fable`, agente fresco) — 21 segnalazioni, tutte verificate
|
||||
|
||||
Applicate: #1 artefatto del MTM (taglio alla larghezza; IV 108 e' a T→0) · #2 f_exp tautologico
|
||||
(quota stampata, N_eff = strutture) · #3 "101%" = perdita massima + fee · #4 `asof` un'ora avanti
|
||||
(`spot_causale`; debito su `cblib`) · #5 famiglia inverse dichiarata · #8 "durante" bimodale (p10/p90,
|
||||
quota che perde) · #9 ordine delle classi (immune → POSITIVO → PERDE, tre affermazioni nel verdetto)
|
||||
· #10 peggior giorno = crollo con TP01 0,505, docstring (c) corretto · #11 beta sulla finestra di §46
|
||||
(0,0769, tolleranza 0,01) · #12 scomposizione esatta con assert · #13 lente "marcata all'uscita"
|
||||
dichiarata · #14 confronto bit-exact con `_skyhook_returns` · #15 cache `skh_sigtab` ferma al 25/07
|
||||
· #16 1,92 e' un punto nuovo, non "gia' noto" · #17 il crollo e' sotto la mediana del DVOL: verdetto
|
||||
riscritto · #18-19 BTCDVOL future e PAXG registrati · #21 APT inactive, cache con rete.
|
||||
Non applicate: nessuna. Il revisore ha trovato **due conclusioni che non reggevano** (il 2,6× e
|
||||
l'"8/12 guadagna") e le ha smontate con numeri riprodotti: la regola *"chi rivede non e' chi ha
|
||||
scritto"* ha pagato in una sessione.
|
||||
|
||||
**Test (Opus, agente separato): `tests/test_r0909_*.py`, 92 test, suite intera 1008/1008 verdi**
|
||||
(erano 916). Coprono: ordine delle classi e riproduzione verbatim dei verdetti, `spot_causale` col
|
||||
controllo positivo sul difetto che ripara, taglio alla larghezza, geometria `in_geom` derivata da
|
||||
`VRP_CFG`, regola L-PULITA (anno pulito isolato ignorato), `_fetch_1h` su batch vuoti, P1 per
|
||||
identita' (`W_TP01`, `XS_UNIVERSE`), zero rete e zero scritture in `data/`. **Due difetti trovati da
|
||||
Opus nel verdetto di XRP, corretti:** un campione vuoto stampava "DILUISCE" (ora "NON MISURABILE",
|
||||
P5) e il criterio per anno era cablato al 2024 invece che derivato dall'anno della lente (P1).
|
||||
@@ -0,0 +1,60 @@
|
||||
# Giornale di bordo — 2026-09-02
|
||||
|
||||
*Scritto 2026-09-03T00:36:49+00:00. Numeri misurati; nessuna interpretazione automatica.*
|
||||
|
||||
## Mercato
|
||||
|
||||
| | chiusura | 1g | 7g | 30g | RV30 ann. | TSMOM 30/90/180 | DVOL |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| **BTC** | $77,312.50 | -0.15% | -2.17% | +21.78% | 42.9% | ↑ ↑ ↑ (3/3 su) | 37.2 (16° pctl 1a) |
|
||||
| **ETH** | $2,391.55 | -1.11% | -4.63% | +28.63% | 68.5% | ↑ ↑ ↑ (3/3 su) | 50.9 (17° pctl 1a) |
|
||||
|
||||
## Libro
|
||||
|
||||
Equity **$2,045.82** · nozionale lordo $560 · leva lorda 0.27x · barra dati 2026-09-02 · 24 giri
|
||||
|
||||
| | TP01 | SKH01 | target | posizione | azione |
|
||||
|---|---|---|---|---|---|
|
||||
| **BTC** | +0.454 | flat | $+348 | $+347 | HOLD (a target) |
|
||||
| **ETH** | +0.276 | flat | $+212 | $+213 | HOLD (a target) |
|
||||
|
||||
## P&L
|
||||
|
||||
- giorno: **$-4.95** di equity (24 letture)
|
||||
- realizzato: -0.35 netto su 1 round-trip chiusi · 2 fill · fee 0.0045
|
||||
- cumulato dall'arming: **$+1,447.76** di equity — di cui $+1,399.39 versati/prelevati -> trading **$+48.37** · TWR **+10.47%**
|
||||
|
||||
## Salute
|
||||
|
||||
- giri di `book_execute`: 24/24
|
||||
- eta' feed SKH all'ultimo giro: 0 min
|
||||
|
||||
## Lettura
|
||||
|
||||
*Generata da regole dichiarate in `src/live/journal.py` — combina i numeri qui sopra e nient'altro. L'id fra parentesi quadre dice quale regola ha parlato. Non e' un giudizio: quello sta nella Nota.*
|
||||
|
||||
- `[stato]` A mercato su BTC $+347 (TP01 +0.454, SKH01 flat) e ETH $+213 (TP01 +0.276, SKH01 flat).
|
||||
- `[leva]` Leva lorda **0.27x** su un tetto di 1.00x (`max_notional_per_asset_frac` 0.50 x 2 asset, letto da `config/live.json`): margine 0.73x.
|
||||
- `[pnl]` Equity $-4.95 con 2 fill e 1 round-trip chiusi (realizzato $-0.35 netto).
|
||||
- `[drawdown]` 📌 Equity **-1.34%** sotto il picco ($2,073.59). ⚠️ e' un DD su letture ORARIE, non sul minimo intra-giorno.
|
||||
- `[vol]` BTC: implicita 37.2 contro realizzata 30g 42.9 (sotto); DVOL al 16° pctl di un anno; IV-rank espandente 0.06 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
|
||||
- `[vol]` ETH: implicita 50.9 contro realizzata 30g 68.5 (sotto); DVOL al 17° pctl di un anno; IV-rank espandente 0.12 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
|
||||
- `[evidenza]` 📌 Campione a oggi: **71 giorni, 30 round-trip**. A questa taglia il P&L non distingue l'edge dalla fortuna — il criterio di morte del libro (`edge_watch`) lavora su una finestra di 36 mesi.
|
||||
|
||||
## Analisi (agente)
|
||||
|
||||
*Scritta da `claude-opus-5` il 2026-09-03T00:37:49+00:00. Prosa di un modello: puo' sbagliare, e non e' una misura. Controllo sui numeri: **ok**.*
|
||||
|
||||
Il fatto nuovo e' l'equity a $2,045.82: sotto ogni chiusura registrata dopo il versamento del 25/08, dove il minimo era $2,047.39 del 30/08. Non viene dal trading — realizzato $-0.35 su un round-trip, fee 0.0045, due fill — ma dal prezzo su due posizioni tenute a target per tutte le 24 letture.
|
||||
|
||||
Il picco da cui si misura il -1.34% ($2,073.59) non compare fra le chiusure giornaliere: e' una lettura oraria, quindi quel drawdown vive su una griglia piu' fine della tabella dei giorni.
|
||||
|
||||
La tensione del giorno e' fra le scale: TSMOM 3/3 su su entrambi gli asset, ma 1g e 7g negativi (BTC -2.17%, ETH -4.63% a 7g) contro 30g ancora +21.78% e +28.63%. Il libro resta long perche' la gamba lunga tiene; una lettura altrettanto compatibile e' che i 30g stiano semplicemente restituendo il rialzo accumulato. Quale delle due, non e' misurabile qui.
|
||||
|
||||
Insolito il pacchetto volatilita': implicita sotto la realizzata su entrambi (37.2 contro 42.9, 50.9 contro 68.5) con DVOL al 16° e 17° percentile dell'anno, mentre il gate di VRP01 legge l'IV-rank (0.06 e 0.12) e resta fermo — guarda una cosa diversa da quello scarto.
|
||||
|
||||
Meriterebbe un occhio umano il perche' il $-4.95 arrivi con salute perfetta, 24/24 giri e feed a 0 min. Il campione resta 71 giorni e 30 round-trip, con $+48.37 di trading sui $+1,447.76 cumulati.
|
||||
|
||||
## Nota
|
||||
|
||||
*(vuota — campo dell'operatore)*
|
||||
@@ -0,0 +1,58 @@
|
||||
# Giornale di bordo — 2026-09-03
|
||||
|
||||
*Scritto 2026-09-04T00:37:16+00:00. Numeri misurati; nessuna interpretazione automatica.*
|
||||
|
||||
## Mercato
|
||||
|
||||
| | chiusura | 1g | 7g | 30g | RV30 ann. | TSMOM 30/90/180 | DVOL |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| **BTC** | $81,286.00 | +5.14% | +1.27% | +26.90% | 45.5% | ↑ ↑ ↑ (3/3 su) | 39.8 (38° pctl 1a) |
|
||||
| **ETH** | $2,507.90 | +4.87% | -0.15% | +34.20% | 69.8% | ↑ ↑ ↑ (3/3 su) | 53.3 (27° pctl 1a) |
|
||||
|
||||
## Libro
|
||||
|
||||
Equity **$2,074.42** · nozionale lordo $562 · leva lorda 0.27x · barra dati 2026-09-03 · 24 giri
|
||||
|
||||
| | TP01 | SKH01 | target | posizione | azione |
|
||||
|---|---|---|---|---|---|
|
||||
| **BTC** | +0.453 | flat | $+352 | $+349 | HOLD (a target) |
|
||||
| **ETH** | +0.274 | flat | $+213 | $+213 | HOLD (a target) |
|
||||
|
||||
## P&L
|
||||
|
||||
- giorno: **$+28.60** di equity (24 letture)
|
||||
- realizzato: -0.14 netto su 4 round-trip chiusi · 4 fill · fee 0.0095
|
||||
- cumulato dall'arming: **$+1,476.36** di equity — di cui $+1,399.39 versati/prelevati -> trading **$+76.97** · TWR **+12.02%**
|
||||
|
||||
## Salute
|
||||
|
||||
- giri di `book_execute`: 24/24
|
||||
- eta' feed SKH all'ultimo giro: 0 min
|
||||
|
||||
## Lettura
|
||||
|
||||
*Generata da regole dichiarate in `src/live/journal.py` — combina i numeri qui sopra e nient'altro. L'id fra parentesi quadre dice quale regola ha parlato. Non e' un giudizio: quello sta nella Nota.*
|
||||
|
||||
- `[stato]` A mercato su BTC $+349 (TP01 +0.453, SKH01 flat) e ETH $+213 (TP01 +0.274, SKH01 flat).
|
||||
- `[leva]` Leva lorda **0.27x** su un tetto di 1.00x (`max_notional_per_asset_frac` 0.50 x 2 asset, letto da `config/live.json`): margine 0.73x.
|
||||
- `[pnl]` Equity $+28.60 con 4 fill e 4 round-trip chiusi (realizzato $-0.14 netto).
|
||||
- `[vol]` BTC: implicita 39.8 contro realizzata 30g 45.5 (sotto); DVOL al 38° pctl di un anno; IV-rank espandente 0.14 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
|
||||
- `[vol]` ETH: implicita 53.3 contro realizzata 30g 69.8 (sotto); DVOL al 27° pctl di un anno; IV-rank espandente 0.14 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
|
||||
- `[evento]` 📌 BTC: giornata a +5.14%, **2.2 deviazioni** giornaliere (sd implicita dalla RV30 = 2.38%).
|
||||
- `[evidenza]` 📌 Campione a oggi: **72 giorni, 34 round-trip**. A questa taglia il P&L non distingue l'edge dalla fortuna — il criterio di morte del libro (`edge_watch`) lavora su una finestra di 36 mesi.
|
||||
|
||||
## Analisi (agente)
|
||||
|
||||
*Scritta da `claude-opus-5` il 2026-09-04T00:38:30+00:00. Prosa di un modello: puo' sbagliare, e non e' una misura. Controllo sui numeri: **ok**.*
|
||||
|
||||
Il fatto nuovo non e' il segno, e' la taglia: $+28.60 e un'equity a $2,074.42, la piu' alta delle dieci sedute mostrate, dopo giorni in cui il delta si e' mosso fra −17.87 e +12.06. Arriva da un BTC a +5.14%, 2.2 deviazioni, mentre il libro era gia' a target su entrambe le gambe: con nozionale lordo $562 e leva 0.27x, l'aumento e' rivalutazione di una posizione ferma, non l'esito di una decisione presa oggi. Il realizzato lo conferma — −0.14 netto su 4 round-trip, rumore di fee (0.0095) — e le posizioni restano $+349 e $+213, in linea con le sedute precedenti.
|
||||
|
||||
Insolito il quadro vol: entrambe le implicite sotto la realizzata 30g (39.8 contro 45.5, 53.3 contro 69.8), coi DVOL al 38° e 27° percentile dell'anno, in una giornata a due deviazioni. Non misurabile qui se sia salita la realizzata oggi o se l'implicita non abbia seguito.
|
||||
|
||||
Lettura alternativa, ugualmente compatibile: BTC fa +5.14% in un giorno ma +1.27% in sette, ed ETH −0.15%; buona parte del guadagno e' recupero dentro la settimana, non nuova direzione, benche' TSMOM sia 3/3 su su entrambi.
|
||||
|
||||
Un solo giorno pesa molto su un trading da $+76.97 in 72 giorni e 34 round-trip. Varrebbe un controllo umano sui 4 fill di una giornata chiusa a target: dovrebbero essere ri-taglie.
|
||||
|
||||
## Nota
|
||||
|
||||
*(vuota — campo dell'operatore)*
|
||||
@@ -0,0 +1,58 @@
|
||||
# Giornale di bordo — 2026-09-04
|
||||
|
||||
*Scritto 2026-09-05T00:36:37+00:00. Numeri misurati; nessuna interpretazione automatica.*
|
||||
|
||||
## Mercato
|
||||
|
||||
| | chiusura | 1g | 7g | 30g | RV30 ann. | TSMOM 30/90/180 | DVOL |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| **BTC** | $79,660.50 | -2.00% | +2.33% | +23.30% | 46.6% | ↑ ↑ ↑ (3/3 su) | 38.0 (24° pctl 1a) |
|
||||
| **ETH** | $2,457.05 | -2.03% | +0.56% | +28.81% | 70.5% | ↑ ↑ ↑ (3/3 su) | 51.1 (18° pctl 1a) |
|
||||
|
||||
## Libro
|
||||
|
||||
Equity **$4,483.73** · nozionale lordo $1,173 · leva lorda 0.26x · barra dati 2026-09-04 · 24 giri
|
||||
|
||||
| | TP01 | SKH01 | target | posizione | azione |
|
||||
|---|---|---|---|---|---|
|
||||
| **BTC** | +0.428 | flat | $+719 | $+717 | HOLD (a target) |
|
||||
| **ETH** | +0.270 | flat | $+455 | $+456 | HOLD (a target) |
|
||||
|
||||
## P&L
|
||||
|
||||
- giorno: **$+2,409.31** di equity (24 letture) — di cui **$+2,410.14 movimenti di capitale** -> trading **$-0.83**
|
||||
- realizzato: -0.02 netto su 2 round-trip chiusi · 5 fill · fee 0.2328
|
||||
- cumulato dall'arming: **$+3,885.67** di equity — di cui $+3,809.53 versati/prelevati -> trading **$+76.14** · TWR **+11.70%**
|
||||
|
||||
## Salute
|
||||
|
||||
- giri di `book_execute`: 24/24
|
||||
- eta' feed SKH all'ultimo giro: 0 min
|
||||
|
||||
## Lettura
|
||||
|
||||
*Generata da regole dichiarate in `src/live/journal.py` — combina i numeri qui sopra e nient'altro. L'id fra parentesi quadre dice quale regola ha parlato. Non e' un giudizio: quello sta nella Nota.*
|
||||
|
||||
- `[movimento]` 📌 **Movimento di capitale $+2,410.14** fra le 13:47 e le 14:47 UTC: salto +116.8% contro un massimo spiegabile dal mercato di ±0.6% al tetto di leva. Scorporato dal P&L di trading qui sotto.
|
||||
- `[stato]` A mercato su BTC $+717 (TP01 +0.428, SKH01 flat) e ETH $+456 (TP01 +0.270, SKH01 flat).
|
||||
- `[leva]` Leva lorda **0.26x** su un tetto di 1.00x (`max_notional_per_asset_frac` 0.50 x 2 asset, letto da `config/live.json`): margine 0.74x.
|
||||
- `[pnl]` P&L di trading $-0.83 (equity $+2409.31 al lordo del movimento di capitale) con 5 fill e 2 round-trip chiusi (realizzato $-0.02 netto).
|
||||
- `[vol]` BTC: implicita 38.0 contro realizzata 30g 46.6 (sotto); DVOL al 24° pctl di un anno; IV-rank espandente 0.08 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
|
||||
- `[vol]` ETH: implicita 51.1 contro realizzata 30g 70.5 (sotto); DVOL al 18° pctl di un anno; IV-rank espandente 0.12 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
|
||||
- `[evidenza]` 📌 Campione a oggi: **73 giorni, 36 round-trip**. A questa taglia il P&L non distingue l'edge dalla fortuna — il criterio di morte del libro (`edge_watch`) lavora su una finestra di 36 mesi.
|
||||
|
||||
## Analisi (agente)
|
||||
|
||||
*Scritta da `claude-opus-5` il 2026-09-05T00:37:58+00:00. Prosa di un modello: puo' sbagliare, e non e' una misura. Controllo sui numeri: **ok**.*
|
||||
|
||||
Il fatto della giornata non è il mercato ma la cassa: l'equity passa da $2,074.42 a $4,483.73, e il salto di $+2,410.14 fra le 13:47 e le 14:47 UTC è il secondo dopo il delta +1,414.57 del 25/08. Il libro l'ha assorbito subito: le posizioni, ferme per dieci giorni fra $+344 e $+364 su BTC e fra $+205 e $+218 su ETH, oggi valgono $+717 e $+456, entrambe a target, a leva lorda 0.26x su un tetto di 1.00x. Nessuna scelta discrezionale: TP01 +0.428 e +0.270, SKH01 flat.
|
||||
|
||||
Il numero da maneggiare con cura è il $-0.83 di trading. BTC e ETH chiudono a -2.00% e -2.03% con $1,173 di nozionale lordo lungo: l'intervallo che contiene il versamento esce intero dal conteggio, quindi quel $-0.83 non copre la giornata piena. Il realizzato, $-0.02 netto su 2 round-trip e 0.2328 di fee, è compatibile sia con una giornata quasi neutra sia con una perdita di mercato finita fuori dal segmento — qui non si separa.
|
||||
|
||||
Insolito il quadro vol: implicita sotto la realizzata su entrambi (38.0 contro 46.6; 51.1 contro 70.5), DVOL al 24° e 18° percentile; VRP01 resta fermo per l'IV-rank (0.08 e 0.12), non per questo.
|
||||
|
||||
Salute piena, 24/24 giri e feed a 0 min; campione 73 giorni e 36 round-trip. Varrebbe che l'operatore confermi a mano importo e ora del versamento: la Nota è vuota.
|
||||
|
||||
## Nota
|
||||
|
||||
*(vuota — campo dell'operatore)*
|
||||
@@ -0,0 +1,57 @@
|
||||
# Giornale di bordo — 2026-09-05
|
||||
|
||||
*Scritto 2026-09-06T00:37:28+00:00. Numeri misurati; nessuna interpretazione automatica.*
|
||||
|
||||
## Mercato
|
||||
|
||||
| | chiusura | 1g | 7g | 30g | RV30 ann. | TSMOM 30/90/180 | DVOL |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| **BTC** | $79,835.00 | +0.22% | +2.03% | +24.19% | 46.4% | ↑ ↑ ↑ (3/3 su) | 38.7 (29° pctl 1a) |
|
||||
| **ETH** | $2,481.10 | +0.98% | +0.93% | +30.40% | 70.4% | ↑ ↑ ↑ (3/3 su) | 51.7 (21° pctl 1a) |
|
||||
|
||||
## Libro
|
||||
|
||||
Equity **$4,490.66** · nozionale lordo $1,161 · leva lorda 0.26x · barra dati 2026-09-05 · 24 giri
|
||||
|
||||
| | TP01 | SKH01 | target | posizione | azione |
|
||||
|---|---|---|---|---|---|
|
||||
| **BTC** | +0.420 | flat | $+708 | $+711 | HOLD (a target) |
|
||||
| **ETH** | +0.268 | flat | $+451 | $+450 | HOLD (a target) |
|
||||
|
||||
## P&L
|
||||
|
||||
- giorno: **$+6.93** di equity (24 letture)
|
||||
- realizzato: -0.16 netto su 3 round-trip chiusi · 3 fill · fee 0.0068
|
||||
- cumulato dall'arming: **$+3,892.60** di equity — di cui $+3,809.53 versati/prelevati -> trading **$+83.07** · TWR **+11.87%**
|
||||
|
||||
## Salute
|
||||
|
||||
- giri di `book_execute`: 24/24
|
||||
- eta' feed SKH all'ultimo giro: 0 min
|
||||
|
||||
## Lettura
|
||||
|
||||
*Generata da regole dichiarate in `src/live/journal.py` — combina i numeri qui sopra e nient'altro. L'id fra parentesi quadre dice quale regola ha parlato. Non e' un giudizio: quello sta nella Nota.*
|
||||
|
||||
- `[stato]` A mercato su BTC $+711 (TP01 +0.420, SKH01 flat) e ETH $+450 (TP01 +0.268, SKH01 flat).
|
||||
- `[leva]` Leva lorda **0.26x** su un tetto di 1.00x (`max_notional_per_asset_frac` 0.50 x 2 asset, letto da `config/live.json`): margine 0.74x.
|
||||
- `[pnl]` Equity $+6.93 con 3 fill e 3 round-trip chiusi (realizzato $-0.16 netto).
|
||||
- `[vol]` BTC: implicita 38.7 contro realizzata 30g 46.4 (sotto); DVOL al 29° pctl di un anno; IV-rank espandente 0.10 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
|
||||
- `[vol]` ETH: implicita 51.7 contro realizzata 30g 70.4 (sotto); DVOL al 21° pctl di un anno; IV-rank espandente 0.13 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
|
||||
- `[evidenza]` 📌 Campione a oggi: **74 giorni, 39 round-trip**. A questa taglia il P&L non distingue l'edge dalla fortuna — il criterio di morte del libro (`edge_watch`) lavora su una finestra di 36 mesi.
|
||||
|
||||
## Analisi (agente)
|
||||
|
||||
*Scritta da `claude-opus-5` il 2026-09-06T00:38:25+00:00. Prosa di un modello: puo' sbagliare, e non e' una misura. Controllo sui numeri: **ok**.*
|
||||
|
||||
Il fatto della giornata non è di oggi: il 04/09 l'equity è passata da $2,074.42 a $4,483.73, +$2,409.31 in un giorno, e questa è la prima pagina intera scritta sul libro nuovo. La forma non è cambiata — leva lorda 0.26x, HOLD a target su entrambe le gambe, TP01 +0.420 e +0.268, SKH01 flat su tutte e due — ma le posizioni sì: $+711 e $+450 contro i $+347 e $+213 del 02/09. È cambiata la taglia, non il comportamento; e il $+6.93 di oggi va letto su una base doppia, dove la stessa cifra in dollari pesa meno di quanto pesava a fine agosto.
|
||||
|
||||
Il cumulato lo dice più chiaramente: $+3,892.60, di cui $+3,809.53 versati o prelevati e $+83.07 di trading in 74 giorni e 39 round-trip. La cifra citabile resta il TWR, +11.87%.
|
||||
|
||||
Insolito, e non spiegabile da questa pagina: implicita sotto realizzata su entrambi (38.7 contro 46.4, 51.7 contro 70.4) con DVOL al 29° e 21° percentile, mentre i 30 giorni segnano +24.19% e +30.40%. Lettura alternativa altrettanto compatibile col +$6.93: il mark-to-market di due long in una giornata salita (+0.22% e +0.98%), non un merito di selezione — con 3 fill e realizzato $-0.16 netto, qui non è distinguibile.
|
||||
|
||||
Un controllo umano lo merita il movimento del 04/09: che abbia la sua riga di giornale, non ricostruita dopo.
|
||||
|
||||
## Nota
|
||||
|
||||
*(vuota — campo dell'operatore)*
|
||||
@@ -0,0 +1,57 @@
|
||||
# Giornale di bordo — 2026-09-06
|
||||
|
||||
*Scritto 2026-09-07T00:36:43+00:00. Numeri misurati; nessuna interpretazione automatica.*
|
||||
|
||||
## Mercato
|
||||
|
||||
| | chiusura | 1g | 7g | 30g | RV30 ann. | TSMOM 30/90/180 | DVOL |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| **BTC** | $80,376.00 | +0.68% | +3.44% | +23.88% | 46.4% | ↑ ↑ ↑ (3/3 su) | 39.3 (35° pctl 1a) |
|
||||
| **ETH** | $2,515.10 | +1.37% | +4.04% | +31.44% | 70.4% | ↑ ↑ ↑ (3/3 su) | 53.2 (27° pctl 1a) |
|
||||
|
||||
## Libro
|
||||
|
||||
Equity **$4,502.93** · nozionale lordo $1,175 · leva lorda 0.26x · barra dati 2026-09-06 · 24 giri
|
||||
|
||||
| | TP01 | SKH01 | target | posizione | azione |
|
||||
|---|---|---|---|---|---|
|
||||
| **BTC** | +0.420 | flat | $+710 | $+717 | SELL $-7 |
|
||||
| **ETH** | +0.268 | flat | $+453 | $+458 | HOLD (a target) |
|
||||
|
||||
## P&L
|
||||
|
||||
- giorno: **$+12.27** di equity (24 letture)
|
||||
- realizzato: +0.13 netto su 1 round-trip chiusi · 1 fill · fee 0.0028
|
||||
- cumulato dall'arming: **$+3,904.87** di equity — di cui $+3,834.43 versati/prelevati -> trading **$+70.44** · TWR **+8.02%**
|
||||
|
||||
## Salute
|
||||
|
||||
- giri di `book_execute`: 24/24
|
||||
- eta' feed SKH all'ultimo giro: 0 min
|
||||
|
||||
## Lettura
|
||||
|
||||
*Generata da regole dichiarate in `src/live/journal.py` — combina i numeri qui sopra e nient'altro. L'id fra parentesi quadre dice quale regola ha parlato. Non e' un giudizio: quello sta nella Nota.*
|
||||
|
||||
- `[stato]` A mercato su BTC $+717 (TP01 +0.420, SKH01 flat) e ETH $+458 (TP01 +0.268, SKH01 flat).
|
||||
- `[leva]` Leva lorda **0.26x** su un tetto di 1.00x (`max_notional_per_asset_frac` 0.50 x 2 asset, letto da `config/live.json`): margine 0.74x.
|
||||
- `[pnl]` Equity $+12.27 con 1 fill e 1 round-trip chiusi (realizzato $+0.13 netto).
|
||||
- `[vol]` BTC: implicita 39.3 contro realizzata 30g 46.4 (sotto); DVOL al 35° pctl di un anno; IV-rank espandente 0.12 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
|
||||
- `[vol]` ETH: implicita 53.2 contro realizzata 30g 70.4 (sotto); DVOL al 27° pctl di un anno; IV-rank espandente 0.14 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
|
||||
- `[evidenza]` 📌 Campione a oggi: **75 giorni, 40 round-trip**. A questa taglia il P&L non distingue l'edge dalla fortuna — il criterio di morte del libro (`edge_watch`) lavora su una finestra di 36 mesi.
|
||||
|
||||
## Analisi (agente)
|
||||
|
||||
*Scritta da `claude-opus-5` il 2026-09-07T00:37:44+00:00. Prosa di un modello: puo' sbagliare, e non e' una misura. Controllo sui numeri: **ok**.*
|
||||
|
||||
Il fatto del giorno non e' il $+12.27: e' che dopo il salto del 04/09 il libro ha ripreso la sua forma abituale a una taglia nuova. Le posizioni sono cresciute con l'equity ($+717 e $+458 contro $+349 e $+213 del 03/09), non col segnale: TP01 sta a 0.420 e 0.268, parziale su entrambe le gambe, e la leva lorda resta 0.26x su un tetto di 1.00x. SKH01 e' flat su BTC e su ETH — il quarto del libro che fa breakout oggi non porta nulla, e da quanti giorni sia cosi' non e' misurabile qui.
|
||||
|
||||
L'attivita' e' scesa: 1 fill e 1 round-trip contro i 3-5 dei giorni scorsi, e l'unica azione e' un SELL $-7 su una posizione da $+717. Vale un'occhiata umana, senza allarme: le soglie d'ordine sono in dollari assoluti e l'equity ha cambiato scala.
|
||||
|
||||
Cumulato $+3,904.87, di cui $+3,834.43 versati; il trading vale $+70.44 su 75 giorni e 40 round-trip — abbastanza poco che un movimento di capitale scorporato male se lo mangerebbe intero.
|
||||
|
||||
Una lettura alternativa dello stesso dato: implicite sotto le realizzate su entrambi (39.3 contro 46.4, 53.2 contro 70.4) e DVOL al 35° e 27° percentile dopo trenta giorni a +23.88% e +31.44%. Le regole ci leggono il gate VRP01 chiuso; e' altrettanto compatibile un rialzo che il mercato delle opzioni non sta pagando.
|
||||
|
||||
## Nota
|
||||
|
||||
*(vuota — campo dell'operatore)*
|
||||
@@ -0,0 +1,57 @@
|
||||
# Giornale di bordo — 2026-09-07
|
||||
|
||||
*Scritto 2026-09-08T00:36:58+00:00. Numeri misurati; nessuna interpretazione automatica.*
|
||||
|
||||
## Mercato
|
||||
|
||||
| | chiusura | 1g | 7g | 30g | RV30 ann. | TSMOM 30/90/180 | DVOL |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| **BTC** | $79,100.00 | -1.59% | +0.68% | +21.85% | 47.1% | ↑ ↑ ↑ (3/3 su) | 38.8 (31° pctl 1a) |
|
||||
| **ETH** | $2,490.35 | -0.98% | +0.91% | +29.98% | 70.6% | ↑ ↑ ↑ (3/3 su) | 52.6 (25° pctl 1a) |
|
||||
|
||||
## Libro
|
||||
|
||||
Equity **$4,484.18** · nozionale lordo $1,156 · leva lorda 0.26x · barra dati 2026-09-07 · 24 giri
|
||||
|
||||
| | TP01 | SKH01 | target | posizione | azione |
|
||||
|---|---|---|---|---|---|
|
||||
| **BTC** | +0.420 | flat | $+707 | $+704 | HOLD (a target) |
|
||||
| **ETH** | +0.268 | flat | $+451 | $+452 | HOLD (a target) |
|
||||
|
||||
## P&L
|
||||
|
||||
- giorno: **$-18.75** di equity (24 letture)
|
||||
- realizzato: +0.10 netto su 1 round-trip chiusi · 3 fill · fee 0.0066
|
||||
- cumulato dall'arming: **$+3,886.12** di equity — di cui $+3,834.43 versati/prelevati -> trading **$+51.69** · TWR **+7.57%**
|
||||
|
||||
## Salute
|
||||
|
||||
- giri di `book_execute`: 24/24
|
||||
- eta' feed SKH all'ultimo giro: 0 min
|
||||
|
||||
## Lettura
|
||||
|
||||
*Generata da regole dichiarate in `src/live/journal.py` — combina i numeri qui sopra e nient'altro. L'id fra parentesi quadre dice quale regola ha parlato. Non e' un giudizio: quello sta nella Nota.*
|
||||
|
||||
- `[stato]` A mercato su BTC $+704 (TP01 +0.420, SKH01 flat) e ETH $+452 (TP01 +0.268, SKH01 flat).
|
||||
- `[leva]` Leva lorda **0.26x** su un tetto di 1.00x (`max_notional_per_asset_frac` 0.50 x 2 asset, letto da `config/live.json`): margine 0.74x.
|
||||
- `[pnl]` Equity $-18.75 con 3 fill e 1 round-trip chiusi (realizzato $+0.10 netto).
|
||||
- `[vol]` BTC: implicita 38.8 contro realizzata 30g 47.1 (sotto); DVOL al 31° pctl di un anno; IV-rank espandente 0.11 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
|
||||
- `[vol]` ETH: implicita 52.6 contro realizzata 30g 70.6 (sotto); DVOL al 25° pctl di un anno; IV-rank espandente 0.13 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
|
||||
- `[evidenza]` 📌 Campione a oggi: **76 giorni, 41 round-trip**. A questa taglia il P&L non distingue l'edge dalla fortuna — il criterio di morte del libro (`edge_watch`) lavora su una finestra di 36 mesi.
|
||||
|
||||
## Analisi (agente)
|
||||
|
||||
*Scritta da `claude-opus-5` il 2026-09-08T00:37:50+00:00. Prosa di un modello: puo' sbagliare, e non e' una misura. Controllo sui numeri: **ok**.*
|
||||
|
||||
La giornata rompe tre chiusure positive di fila (+28.60, +6.93, +12.27) con $-18.75, la perdita giornaliera più grande della serie qui riportata. È quasi tutta valutazione, non attività: 3 fill, 1 round-trip, realizzato $+0.10 netto, con BTC -1.59% e ETH -0.98% e il libro long su entrambi. Il segno torna; la taglia non è verificabile qui, perché la pagina non scompone il P&L per asset.
|
||||
|
||||
Ciò che è cambiato davvero risale al salto del 04/09: le posizioni sono passate dai $+357 / $+214 di fine agosto ai $+704 / $+452 di oggi e ci restano, mentre la leva lorda resta 0.26x. A parità di rischio relativo la stessa percentuale di mercato si scrive con un numero di dollari più grande: le prossime giornate rosse sembreranno peggiori senza esserlo.
|
||||
|
||||
Insolito il quadro della volatilità: implicita sotto la realizzata su entrambi (38.8 contro 47.1; 52.6 contro 70.6) con DVOL al 31° e al 25° percentile di un anno. Il gate di VRP01 legge l'IV-rank (0.11 e 0.13) e resta chiuso, ma una lettura altrettanto compatibile è che il mercato tratti il movimento a 30 giorni (+21.85%, +29.98%) come già avvenuto.
|
||||
|
||||
TP01 porta segnali parziali (0.420, 0.268) e SKH01 è flat malgrado TSMOM 3/3 su: c'è tendenza, non breakout. Il cumulato $+3,886.12 è quasi tutto versato ($+3,834.43); resta $+51.69 di trading su 76 giorni e 41 round-trip, TWR +7.57% — campione che non separa edge e fortuna. Salute piena, 24/24 giri e feed a 0 min: niente da controllare a mano oggi.
|
||||
|
||||
## Nota
|
||||
|
||||
*(vuota — campo dell'operatore)*
|
||||
@@ -0,0 +1,58 @@
|
||||
# Giornale di bordo — 2026-09-08
|
||||
|
||||
*Scritto 2026-09-09T00:36:48+00:00. Numeri misurati; nessuna interpretazione automatica.*
|
||||
|
||||
## Mercato
|
||||
|
||||
| | chiusura | 1g | 7g | 30g | RV30 ann. | TSMOM 30/90/180 | DVOL |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| **BTC** | $78,470.50 | -0.80% | +1.34% | +20.97% | 47.3% | ↑ ↑ ↑ (3/3 su) | 39.9 (40° pctl 1a) |
|
||||
| **ETH** | $2,485.45 | -0.20% | +2.77% | +30.16% | 70.6% | ↑ ↑ ↑ (3/3 su) | 53.9 (33° pctl 1a) |
|
||||
|
||||
## Libro
|
||||
|
||||
Equity **$4,477.46** · nozionale lordo $1,149 · leva lorda 0.26x · barra dati 2026-09-08 · 24 giri
|
||||
|
||||
| | TP01 | SKH01 | target | posizione | azione |
|
||||
|---|---|---|---|---|---|
|
||||
| **BTC** | +0.415 | flat | $+697 | $+698 | HOLD (a target) |
|
||||
| **ETH** | +0.268 | flat | $+449 | $+451 | HOLD (a target) |
|
||||
|
||||
## P&L
|
||||
|
||||
- giorno: **$-6.72** di equity (24 letture)
|
||||
- realizzato: +0.00 netto su 1 round-trip chiusi · 2 fill · fee 0.0055
|
||||
- cumulato dall'arming: **$+3,879.40** di equity — di cui $+3,834.43 versati/prelevati -> trading **$+44.97** · TWR **+7.41%**
|
||||
|
||||
## Salute
|
||||
|
||||
- giri di `book_execute`: 24/24
|
||||
- eta' feed SKH all'ultimo giro: 0 min
|
||||
|
||||
## Lettura
|
||||
|
||||
*Generata da regole dichiarate in `src/live/journal.py` — combina i numeri qui sopra e nient'altro. L'id fra parentesi quadre dice quale regola ha parlato. Non e' un giudizio: quello sta nella Nota.*
|
||||
|
||||
- `[stato]` A mercato su BTC $+698 (TP01 +0.415, SKH01 flat) e ETH $+451 (TP01 +0.268, SKH01 flat).
|
||||
- `[leva]` Leva lorda **0.26x** su un tetto di 1.00x (`max_notional_per_asset_frac` 0.50 x 2 asset, letto da `config/live.json`): margine 0.74x.
|
||||
- `[pnl]` Equity $-6.72 con 2 fill e 1 round-trip chiusi (realizzato $+0.00 netto).
|
||||
- `[drawdown]` 📌 Equity **-0.57%** sotto il picco ($4,502.93). ⚠️ e' un DD su letture ORARIE, non sul minimo intra-giorno.
|
||||
- `[vol]` BTC: implicita 39.9 contro realizzata 30g 47.3 (sotto); DVOL al 40° pctl di un anno; IV-rank espandente 0.14 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
|
||||
- `[vol]` ETH: implicita 53.9 contro realizzata 30g 70.6 (sotto); DVOL al 33° pctl di un anno; IV-rank espandente 0.15 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
|
||||
- `[evidenza]` 📌 Campione a oggi: **77 giorni, 42 round-trip**. A questa taglia il P&L non distingue l'edge dalla fortuna — il criterio di morte del libro (`edge_watch`) lavora su una finestra di 36 mesi.
|
||||
|
||||
## Analisi (agente)
|
||||
|
||||
*Scritta da `claude-opus-5` il 2026-09-09T00:37:27+00:00. Prosa di un modello: puo' sbagliare, e non e' una misura. Controllo sui numeri: **ok**.*
|
||||
|
||||
Terzo giorno sotto il picco del 06/09 ($4,502.93), e secondo consecutivo in rosso dopo i $-18.75 del 07/09: oggi $-6.72. La differenza rispetto alla settimana precedente al versamento non e' il segno ma la scala — le posizioni sono passate da BTC $+347/ETH $+213 a BTC $+698/ETH $+451, quindi oscillazioni di equity dello stesso ordine in dollari valgono ora meno in percentuale. Con realizzato $+0.00 su 1 round-trip e HOLD a target su entrambe le gambe, la perdita del giorno e' rivalutazione delle posizioni aperte contro BTC -0.80% ed ETH -0.20%, non attivita' di trading.
|
||||
|
||||
L'elemento insolito e' altrove: TSMOM 3/3 su su entrambi gli asset, BTC +20.97% e ETH +30.16% a 30 giorni, e nonostante questo TP01 sta a +0.415 e +0.268 con SKH01 flat — leva lorda 0.26x su un tetto di 1.00x. Il libro e' poco investito dentro il trend che dovrebbe essere il suo caso favorevole; se questo sia disciplina di sizing o segnale debole non e' misurabile qui.
|
||||
|
||||
Lettura alternativa altrettanto compatibile: a 0.26x di esposizione il P&L giornaliero e' quasi interamente beta di mercato, e i 77 giorni / 42 round-trip non permettono di distinguerlo dall'edge.
|
||||
|
||||
Merita un'occhiata umana il round-trip chiuso con realizzato esattamente $+0.00 a fronte di fee 0.0055.
|
||||
|
||||
## Nota
|
||||
|
||||
*(vuota — campo dell'operatore)*
|
||||
@@ -1521,3 +1521,30 @@ una maschera a caso della stessa frequenza**.
|
||||
**Gated sull'IV sottile** (la copertura dinamica che §46 non aveva provato): migliora, perde.
|
||||
**Cosa lo riapre:** scadenza lunga che copra il grind (fuori dal vincolo ≤15g dell'operatore),
|
||||
o un premio di varianza negativo su BTC. Non delta, tenor ≤15, gate IV: misurati.
|
||||
|
||||
- **CROLLI / CROLLO CATTURATO / XRP — 0 candidati, 1 debito, 2 conclusioni smontate in revisione (2026-09-09)** —
|
||||
`r0909_{libro_nei_crolli,crash_catturato,xrp_terza_gamba,deribit_universo}.py`, diario
|
||||
`2026-09-09-crolli-opzioni-monete.md`. Domanda dell'operatore: *"guadagnare anche nei crolli (opzioni se
|
||||
serve), piu' monete ma sempre in Deribit"*. **(1) Il libro nei crolli**: perde nel GIORNO (−0,48%/g su 160
|
||||
giorni ≤ −5%, positivo nel 18%), immune-o-positivo per finestra (4 POSITIVO / 6 immune / 2 PERDE su 12), **> 0
|
||||
in 8/8 peggiori finestre dal 2022** per la gamba short di SKH01 (108% dei guadagni, scomposizione esatta,
|
||||
marcata all'uscita). Beta 0,0769 sulla finestra di §46 (riprodotto). Peggior giorno canonico 2025-10-10
|
||||
−3,38%: crollo con TP01 a 0,505 (lo squeeze di §33 e' sulla lente hourly). 37,5/62,5 batte 12/12 episodi
|
||||
ma maxDD 12,1% vs 9,4%; gate fallisce in-sample (cache `skh_sigtab` ferma al 25/07). **(2) Crollo
|
||||
catturato**: l'archivio bite copre con bid/ask il 1-5/06/2026 (BTC −25%, ETH −30%), scadenza 19/06, inverse.
|
||||
ETH pre-crollo (4 strutture): f_net **0,74 = 0,714 del rally**; f a scadenza 1,04 ma **tautologico** (ambo le
|
||||
gambe ITM ⇒ solo f_net); peggior MTM **1,08** dopo il taglio alla larghezza — la prima stesura diceva 2,58 con
|
||||
"IV corta esplosa a 108 per lo skew": **artefatto** (quote 158% di spread a T→0; la IV 108 e' del 18/06, a T→0);
|
||||
put δ−0,10 costa **1,92×** il modello (punto nuovo, sopra l'1,27 di §46) e paga come il modello (0,94-1,04);
|
||||
ingressi durante il crollo bimodali (perde il 46%). **Crollo a vol BASSA** (picco DVOL 29°/46° pctl), gate
|
||||
IV-rank di VRP01 chiuso: **la condizione di §3 e' soddisfatta nella forma, non nella sostanza → non si
|
||||
riapre**. BTC pre-crollo non misurabile (griglia strike rada). **(3) XRP terza gamba** (harness di
|
||||
`r0822_sol_leg`, congelato): **diluisce come SOL** — hold-out −0,169 in 0/24, FULL −0,206 (L-FULL) /
|
||||
−0,066 (2024+), un anno buono (2024), de-levering; L-PULITA dal 2024 (2023 0,57% >1% dal riferimento;
|
||||
Coinbase delisto' XRP 2021-01→2023-07, riferimento Bitstamp prima); 5m flat 27-61%. Dato in
|
||||
`data/raw/alt_xrp_*`. **(4) Venue**: 32 perp USDC, liquidi ≥$10M/g solo XRP/ETH/BTC/SOL; XS01 13/19 (APT
|
||||
inactive); `BTCDVOL_USDC` future esiste ma vol 0 / spread 12%; PAXG perp non misurato (9 mesi, $0,18M/g).
|
||||
**Debito nuovo (§5.18)**: `cblib.spot_series` + `asof` guarda un'ora avanti (feed 1h etichettato
|
||||
all'apertura). **Errori dell'autore smontati dal revisore `fable`**: il 2,6× (artefatto) e "8/12 GUADAGNA"
|
||||
(l'ordine delle classi era il verdetto: immune → POSITIVO da' 4+6). Nessun cambio a libro/pesi/config.
|
||||
|
||||
|
||||
@@ -703,9 +703,16 @@ e i vincoli di deploy (PRIIPs/UCITS/broker).
|
||||
movimento certo e moltiplica i segmenti (gli ambigui non spezzano: restano nel rendimento,
|
||||
dichiarati accanto — P12). Il report stampa TWR, segmenti con le date, movimenti elencati,
|
||||
trading al netto, e il delta $ grezzo solo etichettato «movimenti INCLUSI». Verifica M23: la
|
||||
macchina riproduce il +10,80% del diario 01/09 sui suoi quattro punti. **Limite ereditato:** un
|
||||
+10% di trading fra due letture orarie consecutive, a mercato fermo, sarebbe classificato
|
||||
movimento — e' il limite dichiarato del rilevatore live (D5), non uno nuovo.
|
||||
macchina riproduce il +10,80% del diario 01/09 sui suoi quattro punti. **Due limiti trovati in revisione (02/09):** l'intervallo che contiene un movimento certo esce INTERO dal rendimento (il suo P&L di mercato va in `certi`; errore massimo META' del movimento, per costruzione; il 25/08 ~$0,5) e a base di equity zero `twr` e `trading` sono entrambi None, perche' il salto 0→X del primo versamento e' invisibile al classificatore. I segmenti a lunghezza zero (movimento nel primo intervallo, o due consecutivi) non si stampano. **Seconda tornata di revisione, stesso giorno:** il classificatore era **cieco ~23 ore al giorno** — il
|
||||
feed 1h si ferma alle 00:00, `asof` dava la stessa barra alle due letture, mercato «fermo» = 0, e
|
||||
qualunque calo ≥10% del giorno sarebbe stato un movimento (un crash stampato come prelievo, dal
|
||||
25/08); con feed assente tutto era «ambiguo» e il report stampava `e1/e0-1` sotto l'etichetta TWR.
|
||||
Ora «mercato non misurabile» è uno stato: `ambiguo` con la ragione, `twr`/`trading` None con motivo.
|
||||
Il «limite +10% a mercato fermo» scritto la mattina era un artefatto della fixture piatta, cancellato.
|
||||
`pnl_giorno` chiama `rendimento_twr` (la frase «entrambi la chiamano» è vera solo da questa tornata),
|
||||
la pagina stampa il TWR, la testata Telegram qualifica il cumulato «di cui versati», e il bound di
|
||||
leva include la scala e il tetto di codice. Resta aperto (§5.15): il bound guarda solo BTC/ETH, e il
|
||||
31% dell'equity è USDE — un depeg pieno sarebbe scorporato come prelievo.
|
||||
**Le tre fonti si INCROCIANO e non si sovrascrivono:** log (ora vera) x jsonl (i fill) x venue
|
||||
(autorevole ma **TRONCA** — 1 trade su BTC, 0 su ETH). `reconcile()` riporta le divergenze e
|
||||
**non ripara niente da solo**: fra due fonti che non concordano, una riparazione silenziosa e'
|
||||
@@ -915,6 +922,56 @@ Sulle 4 finestre osservate 3 sono rientrate entro l'ora → il `:47` ne avrebbe
|
||||
**Se al prossimo martedì il `:47` becca comunque la manutenzione, la previsione è sbagliata** e lo
|
||||
slot non è quello descritto in `venue_probe.RELEASE_*`: rileggerlo prima di spostare ancora.
|
||||
|
||||
### La cadenza è ORARIA, e ora lo dicono tutte e tre le fonti (debito §5.7, chiuso il 2026-09-02)
|
||||
|
||||
Il docstring di `book_execute.py` prescriveva «ogni ~230 minuti» (la griglia di SKH01) mentre il cron
|
||||
gira ogni ora. Non era cosmetico: `r0823_sl_anchor.py` misura che l'accumulo di scatti del disaster-SL
|
||||
rotolante è funzione della **cadenza del cron** (1h: BTC 0 / ETH 1 in 7-8 anni · 4h: BTC 2 · 24h: ETH 5
|
||||
con 2 in 30 giorni), quindi chi avesse "corretto" il CRON verso il docstring avrebbe spostato il libro
|
||||
dalla riga 1h (rotolante: BTC 0 / ETH 1) alla riga 4h (BTC 2 contro 1 del pavimento; la peggiore misurata è 24h, ETH 5). La riparazione è il docstring stesso — cadenza ORARIA, la ragione (giro
|
||||
idempotente: girare più fitto della griglia non costa ordini, e compra latenza ≤1h per gli
|
||||
ingressi/uscite software di SKH01 e un **controllo** orario del rotolante — che si ri-ancora solo oltre la tolleranza di `ensure_disaster_sl`, mark +5,263%/−4,762% o taglia >10%, quindi lo stop siede fra −33,5% e −26,5% dal mark corrente: la prima stesura diceva «si ri-ancora ogni ora» ed era sbagliata, corretta in revisione lo stesso giorno) e il divieto scritto —
|
||||
più `tests/test_book_cadenza.py`, che **deriva** (P1) le tre dichiarazioni e le confronta: la riga
|
||||
citata nel docstring, quella dichiarata nell'intestazione di `cron_book.sh` (`47 * * * *`) e la
|
||||
crontab **installata** (`crontab -l`; se non è leggibile il test è SALTATO, non verde — P5). Il
|
||||
parser accetta solo espressioni regolari e rifiuta il resto. Vincolo del minuto tondo verificato
|
||||
(≠ `:00`). La regola generale è la stessa del `:47`: **una cadenza si difende nella sua ragione, e
|
||||
un commento che dichiara una cadenza diversa da quella che gira si paga quando qualcuno lo "corregge"
|
||||
nel verso sbagliato.**
|
||||
|
||||
### Il README ha pubblicato per 75 giorni la libreria che il progetto aveva dichiarato falsa
|
||||
|
||||
Ultimo commit del README: **2026-06-04**. Reset v2.0.0: **2026-06-19**. In mezzo, quindici giorni; dopo,
|
||||
75 in cui la prima pagina del repository descriveva FADE/HONEST/PAIRS/TSMOM/SHAPE, i portafogli PORT01-06,
|
||||
il paper trader su `strategies.yml` e l'esecuzione shadow su testnet — con Sharpe 7,84/10,06 e CAGR ~79%
|
||||
presentati come risultati correnti. Metà dei file citati non esisteva più (`strategies.yml`,
|
||||
`portfolios.yml`, `scripts/waste/`, `scripts/portfolios/`, `src/live/multi_runner.py`); i numeri erano
|
||||
esattamente quelli che `CLAUDE.md` §2 elenca sotto «non citare», e provenivano dalla libreria che il
|
||||
reset aveva dichiarato artefatto di un feed contaminato.
|
||||
|
||||
Nessun danno sui soldi — il README non decide niente. Il danno è di **citazione**, ed è la forma peggiore:
|
||||
un lettore esterno (o un agente nuovo) parte da lì. **La lezione non è "tenere aggiornato il README"**,
|
||||
che nessuno fa più di quanto già non faccia: è che **un reset invalida anche i documenti che nessuno
|
||||
rilegge**, e l'inventario di cosa cita numeri morti va fatto il giorno del reset, quando si sa che sono
|
||||
morti — non 75 giorni dopo, quando qualcuno ci inciampa per caso. Riscritto il 2026-09-02 contro lo stato
|
||||
vero, con ogni riferimento a file verificato e i numeri nella lente di §2.
|
||||
|
||||
### Un flag sconosciuto non è l'azione di default (2026-09-02)
|
||||
|
||||
Nessuno dei 18 script di `scripts/live/` usava argparse: leggevano `sys.argv` con `in` e `index`,
|
||||
quindi un flag sbagliato non era un errore — era il ramo `else`. Il costo si è misurato lo stesso
|
||||
giorno, dentro una revisione: `trades_db.py --help` è caduto in `sync()` e ha riscritto
|
||||
`meta.ultimo_sync`. Sui due script che scrivono davvero sarebbe stato peggio: la pagina di giornale
|
||||
e una riga di DB, oppure una chiamata al modello più un Telegram. **La riparazione non è argparse** —
|
||||
cambierebbe messaggi d'errore, codici d'uscita e `--help` di script che il cron già chiama, cioè
|
||||
romperebbe ciò che gira per riparare ciò che non gira mai. È una funzione di venti righe
|
||||
(`src/live/cli.valida`) che rifiuta ciò che non è dichiarato ed esce **prima di qualunque effetto**:
|
||||
`--help` → 0, flag ignoto → 2 con l'elenco dei previsti (P4). Il test deriva l'elenco degli script
|
||||
dalla cartella (P1: un file nuovo senza guardia fallisce, non passa inosservato), controlla che
|
||||
`valida` sia la prima istruzione di `__main__` (uscire dopo `connect()` sarebbe uscire dopo
|
||||
l'effetto) e — l'altra metà del contratto, P15/P16 — che **i flag che il cron usa davvero restino
|
||||
accettati**: una guardia che ferma il libro di bordo la notte stessa sarebbe peggio del difetto.
|
||||
|
||||
### Cosa resta scoperto
|
||||
|
||||
1. **La sonda dice di chi è il guasto, non lo aggira.** Col gateway giù il libro continua ad
|
||||
|
||||
@@ -157,3 +157,20 @@
|
||||
`fresh_5m`). **REGOLA: un guasto IN CORSO si misura al GIORNO PEGGIORE, non in media** — la prima
|
||||
stesura confrontava "tutto" con "ultimi 7g" e diluiva un guasto di 2 giorni al 13.5%, commettendo
|
||||
a 7 giorni lo stesso errore che dichiarava di evitare a 3 mesi. Diario `2026-07-30-vrp-quote-reali.md`.
|
||||
|
||||
- 🚨 **IL FEED 1h E' ETICHETTATO ALL'APERTURA, e `asof(ts)` guarda un'ora avanti (trovato in revisione
|
||||
il 2026-09-09).** La barra 1h etichettata 20:00 chiude col 5m delle 20:55 (verificato: close 1h 20:00 =
|
||||
close 5m 20:55 = 1603,5, mentre il 5m 19:55 fa 1572,95). Quindi `S.asof(ts)` su una serie indicizzata
|
||||
con quell'etichetta restituisce la chiusura di **ts+1h**: un look-ahead di un'ora senza eccezioni e
|
||||
senza NaN (D6, quarta occorrenza). Colpisce `cblib.spot_series` — cioe' il f 0,714 di §11 e ogni numero
|
||||
di `cblib` con lo spot, compreso `ST = S.asof(exp)` che e' il prezzo di un'ora DOPO la scadenza.
|
||||
`r0909_crash_catturato.spot_causale` sposta l'indice di +1h (chiusura = tempo in cui e' nota); `cblib`
|
||||
**non e' riparato** (cambierebbe numeri pubblicati: va fatto con la rimisura di §11). Debito CLAUDE.md §5.18.
|
||||
- **XRP** (`XRP/USDC:USDC`, dal 2022-03-16) ricostruito il 2026-09-09 con `rebuild_history --asset XRP` e
|
||||
spostato in **`data/raw/alt_xrp_*.parquet`** (namespace di ricerca come SOL: fuori dal feed attivo, non
|
||||
rinfrescato dal cron, `load_data("XRP")` fallisce, presidiato da `test_sol_leg` via `DERIBIT_INSTR`).
|
||||
Certificazione per anno vs riferimento USD: **Coinbase non ha XRP fra 2021-01 e 2023-07** (delisting per
|
||||
la causa SEC) → riferimento **Bitstamp** prima, Coinbase dopo; >1% 0,06% (2022) · 0,57% (2023) · ≤0,02%
|
||||
(2024+); mediana 5-6 bps; **5m flat 27-61%** (BTC/ETH 0,1-2%, SOL 21%): il 5m e' illiquido. Cache del
|
||||
riferimento in `data/_cache/` (gitignored, fuori backup: si riproduce con rete).
|
||||
|
||||
|
||||
@@ -3969,3 +3969,71 @@ quota > 0**: l'ultimo migliaio compra ~€100/anno e butta via l'assicurazione);
|
||||
a banda larga, non un tasso (libro a mercato il 22% dei giorni, TWR +10,8% in 70g, hold-out TP01
|
||||
~+0,05, fisco −30% a 10a); fondo d'emergenza da dichiarare. Operativamente nulla: cap dinamico,
|
||||
rilevatore di salti collaudato il 25/08. **In attesa di importo e data.**
|
||||
|
||||
---
|
||||
|
||||
## 74 — LIBRO-NEI-CROLLI (cosa fanno TP01 e SKH01 nei crolli, misurato invece che ricordato)
|
||||
|
||||
`scripts/research/r0909_libro_nei_crolli.py`, diario `2026-09-09-crolli-opzioni-monete.md`. **Libro,
|
||||
pesi, config, cron INVARIATI.** Lente: sleeve di ricerca, ancora canonica, pesi importati da
|
||||
`src/live/book` (P1); ⚠️ SKH01 marcato all'USCITA del trade (zero nell'87,7% dei giorni). Indice 50/50;
|
||||
giorno di crollo ≤ −5%; 12 peggiori finestre di 20 g non sovrapposte + 9 episodi nominati prima.
|
||||
|
||||
**Nel GIORNO del crollo il libro PERDE** (−0,48%/g su 160 giorni, positivo nel 18%; ≤ −10%: −0,51%/g con
|
||||
SKH01 +0,42). **Per finestra**: 4 POSITIVO / 6 immune / 2 PERDE; libro > 0 in 8/12 e **in 8/8 dal 2022**
|
||||
(mediana +3,3% contro indice −29%); le due perdite (2019, maggio 2021) sono TP01 long dentro un trend
|
||||
non girato. **La gamba short di SKH01 fa il 108% dei guadagni** (scomposizione ESATTA long+short==intera,
|
||||
assert). Beta **+0,0769** sulla finestra di §46 (riprodotto al millesimo). Peggior giorno canonico
|
||||
2025-10-10 −3,38%: un crollo con TP01 a 0,505 (lo squeeze di §33 sta sulla lente hourly, non si riproduce
|
||||
qui). 37,5/62,5 (§26) batte 12/12 episodi, Sharpe 1,81 vs 1,82, maxDD 12,1% vs 9,4%;
|
||||
`r0726_reeval_live_weight` da' `gate_pass False` su `delta_insample −0,0026` (cache segnali ferma al
|
||||
25/07: stesse cifre del 26/07, e la condizione che fallisce e' in-sample).
|
||||
🚨 **Errore dell'autore smontato in revisione:** la prima stesura provava GUADAGNA prima di immune e
|
||||
stampava **8/12 GUADAGNA**; nell'ordine dichiarato sono 4+6. *L'ordine delle regole era il verdetto.*
|
||||
**VERDETTO: `PERDE NEL GIORNO, POSITIVO PER FINESTRA DAL 2022, TUTTO SKH01 SHORT`** — nessun meccanismo
|
||||
nuovo: quello che guadagna nei crolli e' gia' in produzione, e la leva che lo aumenta (il peso) e' chiusa dal gate.
|
||||
|
||||
## 75 — CROLLO-CATTURATO (il f delle opzioni su un crollo a quote vere)
|
||||
|
||||
`scripts/research/r0909_crash_catturato.py`. **Nessun ordine.** L'archivio bite copre con bid/ask il crollo
|
||||
1-5/06/2026 (BTC −25%, ETH −30%; scadenza 19/06, 14-21 DTE, famiglia INVERSE); il campione di §11 partiva
|
||||
dal fondo. ETH, ingressi pre-crollo (286 snapshot = **4 strutture**): f_net **0,74 = 0,714 del rally**;
|
||||
f a scadenza 1,04 ma **tautologico** nell'83% (ambo le gambe ITM ⇒ solo f_net); peggior MTM **1,08**
|
||||
dopo il taglio alla larghezza; put δ−0,10 costa **1,92×** il modello (punto nuovo sopra l'1,27 di §46)
|
||||
e paga come il modello (0,94-1,04); ingressi durante il crollo bimodali (perde il 46%). BTC pre-crollo
|
||||
non misurabile (griglia strike rada). **Crollo a vol BASSA**: picco DVOL al 29°/46° percentile, gate
|
||||
IV-rank di VRP01 CHIUSO in tutte le ore. ⇒ la condizione di §3 e' soddisfatta nella forma, **non nella
|
||||
sostanza: non si riapre**. Servono un crollo con IV-rank > 0,30 e la famiglia USDC-lineare.
|
||||
🚨 **Due conclusioni dell'autore smontate in revisione:** «MTM 2,58× per lo skew (IV 108)» era un
|
||||
artefatto (quote con 158% di spread a T→0 oltre la larghezza; la IV 108 e' del 18/06 a T→0); «f a
|
||||
scadenza 1,04 = il modello prezza bene» era una tautologia. **Debito nuovo (CLAUDE.md §5.18):**
|
||||
`cblib.spot_series` + `asof` guarda un'ora avanti (feed 1h etichettato all'apertura); qui corretto con
|
||||
`spot_causale`, in `cblib` no (toccherebbe il 0,714 di §11).
|
||||
**VERDETTO: `CATTURATO, MA A VOL BASSA E FUORI DAL GATE: f_net NON CAMBIA COL REGIME`** — nessuna riapertura.
|
||||
|
||||
## 76 — XRP-TERZA-GAMBA (l'unica altra moneta liquida di Deribit, coi meccanismi congelati)
|
||||
|
||||
`scripts/research/r0909_xrp_terza_gamba.py` (harness IMPORTATO da `r0822_sol_leg`). Dato in
|
||||
`data/raw/alt_xrp_*` (namespace di ricerca, non rinfrescato dal cron). Certificazione: >1% dal
|
||||
riferimento 0,06% (2022) · **0,57% (2023)** · 0,01-0,02% (2024+) — Coinbase dal rilisting 2023-07
|
||||
(delisting SEC), Bitstamp prima; **L-PULITA dal 2024**, come SOL; 5m flat **27-61%**.
|
||||
dSharpe hold-out **−0,169 in 0/24 ancore** (SOL −0,166); FULL −0,206 (L-FULL) / −0,066 (2024+); dCAGR
|
||||
−2,27 / −1,32 pp; de-levering: L-FULL 1,23 vs 2g×0,974 **1,44**, 2024+ 1,54 vs 1,53 pari; per anno 2022
|
||||
−0,55 · 2023 −0,66 · **2024 +0,54** · 2025 −0,44 · 2026 −0,25 (**1/5**). TP01 XRP Sharpe −0,01 /
|
||||
hold-out −0,49; SKH01 XRP maxDD 40,1% (il criterio di selezione di SKH01-V2-DD fallisce, come SOL);
|
||||
corr gamba-book +0,35/+0,37. Eseguibilita' non e' il vincolo (min 1 XRP = $1,42).
|
||||
⚠️ Due difetti del verdetto trovati dai test (Opus) e corretti: campione vuoto stampava «DILUISCE»
|
||||
(ora NON MISURABILE, P5); criterio per anno cablato al 2024 invece che derivato dalla lente (P1).
|
||||
**VERDETTO: `XRP DILUISCE COME SOL — UN ANNO BUONO, HOLD-OUT 0/24`** — nessuna proposta.
|
||||
|
||||
## 77 — UNIVERSO-DERIBIT (lettura del venue, €0)
|
||||
|
||||
`scripts/research/r0909_deribit_universo.py` (API pubblica, JSON con ora in `data/_cache`, `--fresh`).
|
||||
Lettura 2026-09-09 15:05Z: **32 perpetual USDC** aperti (+6 non aperti, fra cui **APT inactive**);
|
||||
liquidi ≥ $10M/g **solo XRP ($38M, spread 4 bps), ETH, BTC, SOL ($13M)**; tutto il resto $0,01-7M
|
||||
(TAO, HYPE, ADA, AVAX, NEAR…). XS01 **13/19** (mancano ARB, OP, APT, INJ, TIA, SEI → §50 resta).
|
||||
Opzioni USDC-lineari su 7 sottostanti (SOL 676, HYPE 442, XRP 442, TRX, AVAX). Trovati in revisione:
|
||||
**`BTCDVOL_USDC-30SEP26`** (unico long-vol diretto del venue: vol 0, OI 132, spread 12% → non
|
||||
negoziabile) e **PAXG perp** (oro, dal 2024-12, $0,18M/g: non misurato, non morto, muri di SOL/XRP).
|
||||
**VERDETTO: `QUATTRO STRUMENTI LIQUIDI, TRE GIA' NEL LIBRO O MISURATI`** — l'universo "piu' monete" di Deribit e' finito.
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
# MINUTO :25, e la scelta NON e' arbitraria:
|
||||
# :00 cerbero-bite sparava la sua spazzata full-chain (~44 chiamate/s) e saturava il rate limit
|
||||
# Deribit per-IP — 12.186 risposte 429 in 26 ore, il 96% nel minuto tondo.
|
||||
# :07 cron_book.sh (esecuzione del book live). Il feed 5m di SKH01 passa da li': e' la cosa che
|
||||
# :47 cron_book.sh (esecuzione del book live; era :07 fino al 25/08). Il feed 5m di SKH01 passa da li': e' la cosa che
|
||||
# NON deve trovare l'IP occupato.
|
||||
# :25 nessun altro job. Il giro dura ~3 minuti a 4 chiamate/s, quindi finisce ben prima del :30.
|
||||
#
|
||||
|
||||
@@ -115,7 +115,21 @@ def scrivi(con, g: date, modello: str, secco: bool = False, verbose: bool = True
|
||||
return stato
|
||||
|
||||
|
||||
USO = """uso: analista.py [--giorno AAAA-MM-GG] [--modello NOME] [--secco] [--no-telegram] [--quiet]
|
||||
|
||||
(nessun flag) scrive l'analisi di OGGI nel campo `analisi` e manda la notifica
|
||||
--giorno G analizza il giorno G
|
||||
--modello M modello da usare (default: src.live.analista.MODELLO_DEFAULT)
|
||||
--secco stampa e NON salva, niente Telegram
|
||||
--no-telegram salva ma non manda la notifica
|
||||
--quiet non stampa
|
||||
|
||||
⚠️ SPENDE una chiamata al modello e MANDA un messaggio Telegram, salvo --secco/--no-telegram."""
|
||||
|
||||
if __name__ == "__main__":
|
||||
from src.live.cli import valida
|
||||
valida("analista.py", USO, flag=("--secco", "--no-telegram", "--quiet"),
|
||||
con_valore=("--giorno", "--modello"))
|
||||
con = T.connect()
|
||||
g = date.fromisoformat(_arg("--giorno", datetime.now(timezone.utc).date().isoformat()))
|
||||
st = scrivi(con, g, modello=_arg("--modello", A.MODELLO_DEFAULT),
|
||||
|
||||
@@ -85,6 +85,17 @@ def campiona(c: DeribitRead) -> dict:
|
||||
rec["n_posizioni"] = sum(1 for p in pos if abs(float(p.get("size") or 0)) > 1)
|
||||
except Exception:
|
||||
rec["nozionale_lordo"], rec["n_posizioni"] = None, None
|
||||
# INDICE usde_usdc, ORARIO (aggiunto 2026-09-02, debito §5.15). `usde_watch` lo registra una
|
||||
# volta al giorno (12:35Z): troppo rado per il classificatore dei movimenti di capitale, che
|
||||
# confronta due letture di equity a un'ora di distanza e oggi guarda solo BTC/ETH — con il
|
||||
# 31% dell'equity in USDE, un depeg sarebbe scorporato come un PRELIEVO. Questa e' la serie
|
||||
# che manca; il cablaggio nel bound viene dopo, quando ci sara' storia. None se non leggibile
|
||||
# (P5: "non vedo" non e' "il peg tiene").
|
||||
try:
|
||||
from src.live.usde import prezzo_indice
|
||||
rec["usde_usdc"] = prezzo_indice()
|
||||
except Exception as e:
|
||||
rec["usde_usdc"], rec["usde_usdc_errore"] = None, f"{type(e).__name__}"
|
||||
return rec
|
||||
|
||||
|
||||
|
||||
@@ -10,9 +10,24 @@ DOPPIO GATE DI SICUREZZA (entrambi necessari per inviare ordini reali):
|
||||
Senza entrambi e' un DRY-RUN (stampa il piano, NON invia). Reconciliation dopo ogni ordine; log in
|
||||
data/live/book_executions.jsonl.
|
||||
|
||||
⚠️ CADENZA: SKH01 decide su griglia 230m -> questo script va lanciato ogni ~230 minuti con la feed
|
||||
fresca all'ultima barra chiusa (NON il cron giornaliero, che mancherebbe gli ingressi). Gli exit di
|
||||
SKH sono SOFTWARE (latenza fino a fine barra 230m); solo il disaster-SL (-30%) e' on-book.
|
||||
⚠️ CADENZA: ORARIA — `47 * * * *` in `scripts/cron_book.sh` (il minuto e' spiegato li'). SKH01
|
||||
decide su griglia 230m, ma il giro e' IDEMPOTENTE (riconcilia al target netto corrente; sotto
|
||||
`min_order_usd` $5 -> HOLD). Girare piu' fitto della griglia costa solo MICRO-ORDINI di ri-taglia:
|
||||
il target e' ricalcolato ogni ora sull'equity marcata, e 22 dei 48 ordini live sono |delta|<=$10
|
||||
a segnale invariato — spiccioli oggi, ma il deadband e' in valuta ASSOLUTA (regola C2: a capitale
|
||||
maggiore un movimento dello 0,15% lo supera ogni ora). Compra due cose misurate: (1) gli
|
||||
ingressi/uscite SOFTWARE di SKH01 arrivano con latenza <=1h — CON LA FEED 5m FRESCA: se e' stantia
|
||||
`livefeed.fresh_5m` ripiega in silenzio sul feed giornaliero (latenza ~1 giorno) e
|
||||
`skh_feed_max_age_min` 30 ALLERTA, non blocca; (2) il disaster-SL rotolante (-30%, l'unico on-book)
|
||||
viene CONTROLLATO ogni ora e ri-ancorato solo oltre la tolleranza di `ensure_disaster_sl` (stop a
|
||||
>5% da quello piazzato, cioe' mark +5,263%/-4,762%, o taglia >10%) — con questa isteresi
|
||||
(`r0823_sl_anchor.py`): a cadenza 1h in 7-8 anni BTC 0 scatti / ETH 1; a 4h BTC 2; a 24h ETH 5,
|
||||
con 2 in 30 giorni. Lo stop siede quindi fra -33,5% e -26,5% dal mark corrente (0,665-0,735 x mark),
|
||||
non a -30% dall'ultima ora: e' rotolante, non un massimo di perdita (CLAUDE.md §1).
|
||||
🚨 NON "correggere" la cadenza a ~230 minuti: fino al 2026-09-02 questo docstring lo prescriveva,
|
||||
ed era il docstring a essere sbagliato, non il cron (debito §5.7). `tests/test_book_cadenza.py`
|
||||
tiene d'accordo docstring, `cron_book.sh` e crontab installata. NON il cron giornaliero: le entrate
|
||||
di SKH01 le mancherebbe.
|
||||
|
||||
uv run python scripts/live/book_execute.py # DRY-RUN (piano, nessun ordine)
|
||||
uv run python scripts/live/book_execute.py --execute # esegue SOLO se execution_enabled=true
|
||||
@@ -340,5 +355,14 @@ def main():
|
||||
raise
|
||||
|
||||
|
||||
USO = """uso: book_execute.py [--execute]
|
||||
|
||||
uv run python scripts/live/book_execute.py # DRY-RUN (piano, nessun ordine)
|
||||
uv run python scripts/live/book_execute.py --execute # esegue SOLO se execution_enabled=true
|
||||
|
||||
Dettaglio nel docstring in testa al file."""
|
||||
|
||||
if __name__ == "__main__":
|
||||
from src.live.cli import valida
|
||||
valida("book_execute.py", USO, flag=("--execute",), con_valore=())
|
||||
main()
|
||||
|
||||
@@ -93,5 +93,13 @@ def main() -> None:
|
||||
print(" regime QUIET: carry non raccoglibile (coerente con luglio 2026).")
|
||||
|
||||
|
||||
USO = """uso: cc01_regime_watch.py
|
||||
|
||||
(nessun flag) sorveglia il regime di CC01 e stampa lo stato.
|
||||
|
||||
Dettaglio nel docstring in testa al file."""
|
||||
|
||||
if __name__ == "__main__":
|
||||
from src.live.cli import valida
|
||||
valida("cc01_regime_watch.py", USO)
|
||||
main()
|
||||
|
||||
@@ -144,5 +144,15 @@ def main() -> int:
|
||||
return 0
|
||||
|
||||
|
||||
USO = """uso: edge_watch.py [--quiet]
|
||||
|
||||
edge_watch.py — sorveglianza dell'EDGE del book live. Riporta e allerta; non tocca nulla.
|
||||
uv run python scripts/live/edge_watch.py # report
|
||||
uv run python scripts/live/edge_watch.py --quiet # solo se qualcosa scatta (cron)
|
||||
|
||||
Dettaglio nel docstring in testa al file."""
|
||||
|
||||
if __name__ == "__main__":
|
||||
from src.live.cli import valida
|
||||
valida("edge_watch.py", USO, flag=("--quiet",), con_valore=())
|
||||
raise SystemExit(main())
|
||||
|
||||
@@ -252,5 +252,15 @@ def main() -> int:
|
||||
return 2 if r["level"] == "AZIONE" else 0
|
||||
|
||||
|
||||
USO = """uso: fee_watch.py [--quiet]
|
||||
|
||||
fee_watch.py — sorveglia lo schema fee di Deribit e applica la regola DECISA IN ANTICIPO.
|
||||
uv run python scripts/live/fee_watch.py # report
|
||||
uv run python scripts/live/fee_watch.py --quiet # stampa/allerta solo se qualcosa cambia
|
||||
|
||||
Dettaglio nel docstring in testa al file."""
|
||||
|
||||
if __name__ == "__main__":
|
||||
from src.live.cli import valida
|
||||
valida("fee_watch.py", USO, flag=("--quiet",), con_valore=())
|
||||
raise SystemExit(main())
|
||||
|
||||
@@ -41,7 +41,19 @@ def una(con, g: date, nota: str | None = None, verbose: bool = True) -> Path:
|
||||
return f
|
||||
|
||||
|
||||
USO = """uso: journal.py [--giorno AAAA-MM-GG] [--backfill N] [--nota "testo"] [--quiet]
|
||||
|
||||
(nessun flag) scrive la voce di OGGI (pagina marcata PARZIALE: il giorno non e' chiuso)
|
||||
--giorno G scrive la voce del giorno G
|
||||
--backfill N ricostruisce gli ultimi N giorni, dal piu' vecchio
|
||||
--nota "..." annota il giorno (campo dell'operatore, mai riscritto da un ricalcolo)
|
||||
--quiet non stampa la riga di riepilogo
|
||||
|
||||
⚠️ SCRIVE: una riga nel DB e una pagina in docs/journal/. Sola lettura su feed, log e venue."""
|
||||
|
||||
if __name__ == "__main__":
|
||||
from src.live.cli import valida
|
||||
valida("journal.py", USO, flag=("--quiet",), con_valore=("--giorno", "--backfill", "--nota"))
|
||||
# sincronizzare PRIMA: altrimenti la sezione 'Libro' (letta dal log) e la sezione 'P&L'
|
||||
# (letta dal DB) descrivono due istanti diversi e la pagina si contraddice da sola.
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "live"))
|
||||
|
||||
@@ -137,5 +137,14 @@ def main():
|
||||
raise
|
||||
|
||||
|
||||
USO = """uso: live_execute.py [--execute]
|
||||
|
||||
uv run python scripts/live/live_execute.py # DRY-RUN (piano, nessun ordine)
|
||||
uv run python scripts/live/live_execute.py --execute # esegue SOLO se execution_enabled=true
|
||||
|
||||
Dettaglio nel docstring in testa al file."""
|
||||
|
||||
if __name__ == "__main__":
|
||||
from src.live.cli import valida
|
||||
valida("live_execute.py", USO, flag=("--execute",), con_valore=())
|
||||
main()
|
||||
|
||||
@@ -74,5 +74,15 @@ def main():
|
||||
(f"{len(r['orders'])} ordine/i costruito/i sopra." if r["orders"] else "Target flat: 0 ordini."))
|
||||
|
||||
|
||||
USO = """uso: live_trend.py [--equity V] [--no-net]
|
||||
|
||||
uv run python scripts/live/live_trend.py # shadow su mainnet reale
|
||||
uv run python scripts/live/live_trend.py --equity 2000 # forza la base di sizing
|
||||
uv run python scripts/live/live_trend.py --no-net # offline: solo matematica + parita'
|
||||
|
||||
Dettaglio nel docstring in testa al file."""
|
||||
|
||||
if __name__ == "__main__":
|
||||
from src.live.cli import valida
|
||||
valida("live_trend.py", USO, flag=("--no-net",), con_valore=("--equity",))
|
||||
main()
|
||||
|
||||
@@ -88,5 +88,14 @@ def main():
|
||||
print(" Validato: invio ordine reale, fill, fee reali, reconciliation, ritorno a flat.")
|
||||
|
||||
|
||||
USO = """uso: microtest.py [--live]
|
||||
|
||||
uv run python scripts/live/microtest.py # DRY-RUN: nessun ordine inviato
|
||||
uv run python scripts/live/microtest.py --live # invia il round-trip REALE
|
||||
|
||||
Dettaglio nel docstring in testa al file."""
|
||||
|
||||
if __name__ == "__main__":
|
||||
from src.live.cli import valida
|
||||
valida("microtest.py", USO, flag=("--live",), con_valore=())
|
||||
main()
|
||||
|
||||
@@ -55,5 +55,15 @@ def main() -> int:
|
||||
return 0
|
||||
|
||||
|
||||
USO = """uso: monitor_health.py [--quiet]
|
||||
|
||||
monitor_health.py — i forward-monitor stanno registrando? Riporta e allerta, non tocca nulla.
|
||||
uv run python scripts/live/monitor_health.py # report
|
||||
uv run python scripts/live/monitor_health.py --quiet # stampa/allerta solo se scatta
|
||||
|
||||
Dettaglio nel docstring in testa al file."""
|
||||
|
||||
if __name__ == "__main__":
|
||||
from src.live.cli import valida
|
||||
valida("monitor_health.py", USO, flag=("--quiet",), con_valore=())
|
||||
raise SystemExit(main())
|
||||
|
||||
@@ -118,5 +118,13 @@ def main():
|
||||
print(f" GTAA (IB, asof {asof}): " + ", ".join(f"{k} {v:.0%}" for k, v in gw.items() if v) + f" | cash {cash:.0%}")
|
||||
|
||||
|
||||
USO = """uso: paper_combo.py [--reset] [--status]
|
||||
|
||||
uv run python scripts/live/paper_combo.py [--status|--reset]
|
||||
|
||||
Dettaglio nel docstring in testa al file."""
|
||||
|
||||
if __name__ == "__main__":
|
||||
from src.live.cli import valida
|
||||
valida("paper_combo.py", USO, flag=("--status", "--reset"), con_valore=())
|
||||
main()
|
||||
|
||||
@@ -87,5 +87,15 @@ def main():
|
||||
print(f" posizioni correnti: {pf.current_positions()}")
|
||||
|
||||
|
||||
USO = """uso: paper_portfolio.py [--reset] [--status]
|
||||
|
||||
uv run python scripts/live/paper_portfolio.py # avanza (init al 1o run)
|
||||
uv run python scripts/live/paper_portfolio.py --status # solo stato
|
||||
uv run python scripts/live/paper_portfolio.py --reset # azzera (riparte da ora)
|
||||
|
||||
Dettaglio nel docstring in testa al file."""
|
||||
|
||||
if __name__ == "__main__":
|
||||
from src.live.cli import valida
|
||||
valida("paper_portfolio.py", USO, flag=("--status", "--reset"), con_valore=())
|
||||
main()
|
||||
|
||||
@@ -224,5 +224,15 @@ def main() -> None:
|
||||
print("\n fatto. I file *.%s.* sono l'evidenza del difetto e restano nel backup." % SUFFISSO)
|
||||
|
||||
|
||||
USO = """uso: paper_regen.py [--solo V]
|
||||
|
||||
paper_regen.py — RIGENERA le serie dei forward-monitor rotti da `advance()` (§5.1).
|
||||
uv run python scripts/live/paper_regen.py # tutti e 4
|
||||
uv run python scripts/live/paper_regen.py --solo paper_statarb
|
||||
|
||||
Dettaglio nel docstring in testa al file."""
|
||||
|
||||
if __name__ == "__main__":
|
||||
from src.live.cli import valida
|
||||
valida("paper_regen.py", USO, flag=(), con_valore=("--solo",))
|
||||
main()
|
||||
|
||||
@@ -58,5 +58,15 @@ def main() -> None:
|
||||
print(f"\n invio: {rep['invio']}")
|
||||
|
||||
|
||||
USO = """uso: scale_watch.py [--quiet] [--secco]
|
||||
|
||||
uv run python scripts/live/scale_watch.py # osserva e stampa, invia se ALLARME
|
||||
uv run python scripts/live/scale_watch.py --secco # osserva e stampa, NON invia nulla
|
||||
uv run python scripts/live/scale_watch.py --quiet # una riga sola (per il cron)
|
||||
|
||||
Dettaglio nel docstring in testa al file."""
|
||||
|
||||
if __name__ == "__main__":
|
||||
from src.live.cli import valida
|
||||
valida("scale_watch.py", USO, flag=("--quiet", "--secco"), con_valore=())
|
||||
main()
|
||||
|
||||
@@ -146,5 +146,14 @@ def main() -> None:
|
||||
print("inviato" if send(txt) else "NON inviato (config Telegram assente o rete KO)")
|
||||
|
||||
|
||||
USO = """uso: telegram_daily.py [--dry-run]
|
||||
|
||||
uv run python scripts/live/telegram_daily.py # calcola e invia
|
||||
uv run python scripts/live/telegram_daily.py --dry-run # stampa e basta, non invia
|
||||
|
||||
Dettaglio nel docstring in testa al file."""
|
||||
|
||||
if __name__ == "__main__":
|
||||
from src.live.cli import valida
|
||||
valida("telegram_daily.py", USO, flag=("--dry-run",), con_valore=())
|
||||
main()
|
||||
|
||||
@@ -91,8 +91,22 @@ def report() -> None:
|
||||
print(f" movimenti capitale : {r['certi']:+,.2f} certi | {r['ambigui']:+,.2f} ambiguo/i"
|
||||
f" (restano nel P&L, dichiarati)")
|
||||
for ev in r["eventi"]:
|
||||
print(f" {ev['ts_dopo'][:16]} {ev['delta']:+,.2f} [{ev['classe']}]")
|
||||
print(f" trading da arming : {r['trading']:+,.2f} (equity al netto dei movimenti certi)")
|
||||
mk = ev.get("mercato_max_pct")
|
||||
if ev.get("dichiarazione"):
|
||||
dettaglio = ev["dichiarazione"]
|
||||
elif mk is None:
|
||||
dettaglio = ev.get("mercato_nota") or "mercato non misurabile"
|
||||
else:
|
||||
dettaglio = f"mercato max {100 * mk:.2f}% a leva piena, salto {100 * ev['pct']:+.1f}%"
|
||||
print(f" {ev['ts_dopo'][:16]} {ev['delta']:+,.2f} [{ev['classe']}: {dettaglio}]")
|
||||
for av in r.get("avvisi") or []:
|
||||
print(f" ⚠️ {av}")
|
||||
if r["trading"] is None:
|
||||
print(f" trading da arming : n/d ({r['motivo']})")
|
||||
else:
|
||||
print(f" trading da arming : {r['trading']:+,.2f} (equity al netto dei movimenti "
|
||||
f"certi; l'ora di un movimento RILEVATO esce intera, quella di un DICHIARATO "
|
||||
f"resta — vedi journal.rendimento_twr)")
|
||||
if r["twr"] is None:
|
||||
print(f" TWR : n/d ({r['motivo']})")
|
||||
else:
|
||||
@@ -118,8 +132,16 @@ def report() -> None:
|
||||
con.close()
|
||||
|
||||
|
||||
USO = """uso: trades_db.py [--sync] [--report] [--reconcile] [--quiet]
|
||||
|
||||
--sync (default) legge log e jsonl e aggiorna data/live/trades.db — idempotente
|
||||
--report stato del libro di bordo + P&L (TWR, non e1/e0)
|
||||
--reconcile incrocio delle tre fonti sui fill (log x jsonl x venue)
|
||||
--quiet con --sync: non stampa il riepilogo"""
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = sys.argv[1:]
|
||||
from src.live.cli import valida
|
||||
args = valida("trades_db.py", USO, flag=("--sync", "--report", "--reconcile", "--quiet"))
|
||||
if "--reconcile" in args:
|
||||
reconcile()
|
||||
elif "--report" in args:
|
||||
|
||||
@@ -330,5 +330,15 @@ def main() -> int:
|
||||
return 0
|
||||
|
||||
|
||||
USO = """uso: usde_watch.py [--quiet]
|
||||
|
||||
usde_watch.py — sorveglianza giornaliera del collaterale USDE: reward, depeg, quota. SOLA LETTURA.
|
||||
uv run python scripts/live/usde_watch.py # report completo
|
||||
uv run python scripts/live/usde_watch.py --quiet # stampa/allerta solo transizioni
|
||||
|
||||
Dettaglio nel docstring in testa al file."""
|
||||
|
||||
if __name__ == "__main__":
|
||||
from src.live.cli import valida
|
||||
valida("usde_watch.py", USO, flag=("--quiet",), con_valore=())
|
||||
sys.exit(main())
|
||||
|
||||
@@ -98,5 +98,15 @@ def main() -> int:
|
||||
return 0
|
||||
|
||||
|
||||
USO = """uso: venue_watch.py [--quiet]
|
||||
|
||||
venue_watch.py — runner ORARIO del tripwire di venue. Allerta su Telegram, NON blocca nulla.
|
||||
uv run python scripts/live/venue_watch.py # un giro, stampa il report
|
||||
uv run python scripts/live/venue_watch.py --quiet # solo in caso di allarme (per il cron)
|
||||
|
||||
Dettaglio nel docstring in testa al file."""
|
||||
|
||||
if __name__ == "__main__":
|
||||
from src.live.cli import valida
|
||||
valida("venue_watch.py", USO, flag=("--quiet",), con_valore=())
|
||||
raise SystemExit(main())
|
||||
|
||||
@@ -17,7 +17,7 @@ SOLA LETTURA. Questo script:
|
||||
- non importa nulla che invii ordini se non per ISPEZIONARLO (la sottoclasse di §1 ha `_post` che
|
||||
SOLLEVA: se il codice di produzione toccasse la rete, il test fallirebbe rumorosamente);
|
||||
- tocca il venue solo con `--venue`, solo `get_open_orders` (lettura), 2 richieste, e si rifiuta
|
||||
di partire nei minuti :05-:10 (cron_book) e :24-:30 (cron_chain).
|
||||
di partire nei minuti :45-:50 (cron_book, al :47 dal 25/08) e :24-:30 (cron_chain).
|
||||
|
||||
uv run python scripts/research/r0823_sl_anchor.py # attesa + log + simulazione
|
||||
uv run python scripts/research/r0823_sl_anchor.py --venue # + riscontro sul conto (2 letture)
|
||||
@@ -283,8 +283,8 @@ def riscontro_venue(runs_info: dict):
|
||||
diritte di Deribit appartengono a un ALTRO progetto: usarle sarebbe inventare un percorso, e la
|
||||
domanda non ne ha bisogno. Si usa quindi cio' che il percorso sanzionato offre: `get_open_orders`.""")
|
||||
m = datetime.now(timezone.utc).minute
|
||||
if 5 <= m <= 10 or 24 <= m <= 30:
|
||||
print(f"\n ⏸ minuto :{m:02d} — finestra di cron_book (:07) / cron_chain (:25). Non interrogo.")
|
||||
if 45 <= m <= 50 or 24 <= m <= 30: # cron_book al :47 dal 2026-08-25 (era :07), cron_chain :25
|
||||
print(f"\n ⏸ minuto :{m:02d} — finestra di cron_book (:47) / cron_chain (:25). Non interrogo.")
|
||||
return
|
||||
from src.live.deribit import DeribitRead
|
||||
r = DeribitRead()
|
||||
@@ -395,10 +395,11 @@ def conseguenza():
|
||||
alta dopo una salita, lo stop siede vicino sotto il mercato, il crollo lo prende, si rientra e
|
||||
la gamba successiva lo riprende. **Il rischio di accumulo e' funzione della CADENZA DEL CRON,
|
||||
non di k**: il gradino di leva non lo crea e non lo amplifica.
|
||||
· ⚠️ E qui c'e' un aggancio operativo: il docstring di `scripts/live/book_execute.py` prescrive
|
||||
«ogni ~230 minuti» (la griglia di SKH01) mentre il cron gira **ogni ora** (misurato sopra: 1443
|
||||
giri in 1442 ore). Chi "correggesse" la cadenza verso il docstring sposterebbe il libro dalla
|
||||
riga 1h alla riga 4h — quella in cui BTC raddoppia gli scatti.
|
||||
· ⚠️ Aggancio operativo (RIPARATO il 2026-09-02, `tests/test_book_cadenza.py`): fino a quel
|
||||
giorno il docstring di `scripts/live/book_execute.py` prescriveva «ogni ~230 minuti» (la
|
||||
griglia di SKH01) mentre il cron gira **ogni ora** (misurato sopra: 1443 giri in 1442 ore).
|
||||
Chi avesse "corretto" il CRON verso il docstring avrebbe spostato il libro dalla riga 1h
|
||||
(rotolante: 0 scatti) alla riga 4h (2 scatti del rotolante contro 1 del pavimento).
|
||||
|
||||
(*) ⚠️ CAVEAT sulla colonna, e sul suo artefatto: in questa lente la posizione e' SEMPRE aperta
|
||||
dal 2018, quindi in modo `roll` l'"ingresso" e' un prezzo di anni prima e il -48%/-61% e' il
|
||||
@@ -501,9 +502,9 @@ def costo(out: dict):
|
||||
|
||||
⚠️ E c'e' un accoppiamento nuovo da registrare: l'accumulo di episodi e' funzione della CADENZA
|
||||
DEL CRON (1h: no · 4h: BTC 2 · 24h: ETH 5 con 2 in 30 giorni). Oggi la cadenza e' misurata sana,
|
||||
ma nessun sorvegliante la controlla contro questa conseguenza, e il docstring di `book_execute`
|
||||
ne prescrive una (~230 min) che cade nella riga peggiore. Non e' un'azione richiesta qui: e' il
|
||||
parametro da rileggere prima del gradino, insieme a G2.""")
|
||||
e dal 2026-09-02 `tests/test_book_cadenza.py` tiene d'accordo docstring, cron_book.sh e crontab
|
||||
(il docstring ne prescriveva una, ~230 min, nella riga 4h). Resta il parametro da rileggere prima
|
||||
del gradino, insieme a G2.""")
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env python
|
||||
"""r0906_usde_tetto_sonda.py — sonda il TETTO del venue sull'USDE a SALDO NEUTRO, passi da 1-2 USDE.
|
||||
INVIA ORDINI VERI (spot USDE_USDC, fee 0, ~$0,001 di spread per ordine).
|
||||
|
||||
Metodo (diari 30-31/08, sezione «la lezione di metodo»): un probe unico di taglia sbagliata produce un
|
||||
falso negativo che si legge come risultato. Qui si sale a passi piccoli fino al primo rifiuto, si
|
||||
ripete il rifiuto dopo aver riletto il book (per escludere l'ask che si e' mosso: il messaggio del
|
||||
venue e' lo stesso `not_enough_funds_in_currency`), poi si CONFERMA a saldo neutro:
|
||||
SELL 1 -> BUY 1 (deve passare) -> BUY 1 (deve essere rifiutato). Il bracket e' [L, L+1) USDE, con
|
||||
l'equity totale letta nello stesso istante, cosi' la frazione ha la sua banda.
|
||||
|
||||
Guardie: prezzo dal BOOK (ask+5 tick, tetto 1,0010 / bid-5 tick, pavimento 0,9990), banda
|
||||
[0,995, 1,005]; al massimo MAX_ADD USDE in piu' (quota mai oltre QUOTA_STOP); pausa fra ordini.
|
||||
|
||||
uv run python scripts/research/r0906_usde_tetto_sonda.py # dry-run: stato e piano
|
||||
uv run python scripts/research/r0906_usde_tetto_sonda.py --esegui # sonda davvero
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "live"))
|
||||
|
||||
from src.live import usde as U # noqa: E402
|
||||
from src.live.deribit import DeribitRead # noqa: E402
|
||||
import usde_convert as C # noqa: E402 (book_top, leggi_stato: stessi attrezzi)
|
||||
|
||||
LOG = ROOT / "data" / "live" / "usde_convert.jsonl"
|
||||
PX_MIN, PX_MAX = C.PX_MIN, C.PX_MAX
|
||||
TICK, MARGINE, CAP_BUY, CAP_SELL = 0.0001, 5, 1.0010, 0.9990
|
||||
MAX_ADD = 60 # USDE in piu' al massimo (--max-add N): oltre, «tetto non trovato entro il limite»
|
||||
QUOTA_HARD = 0.70 # mai oltre: e' la quota decisa dall'operatore il 30/08 (`usde.quota_target`), e coincide
|
||||
# col CUSCINO DI REGOLAMENTO derivato da config (usde_convert.cuscino_richiesto_usd),
|
||||
# che qui e' la guardia che morde davvero: si legge a ogni passo, non si ridichiara.
|
||||
SCALA = [100.0, 20.0, 4.0, 1.0] # passi: si scende di uno a ogni doppio rifiuto (M13: risoluzione dell'effetto)
|
||||
PAUSA, PAUSA_RIFIUTO = 12.0, 20.0
|
||||
|
||||
|
||||
def stato(c: DeribitRead) -> dict:
|
||||
eq_usdc, eq_usde, px, fonte = C.leggi_stato(c)
|
||||
val, _ = U.valuta(eq_usde, px)
|
||||
return {"usdc": eq_usdc, "usde": eq_usde, "px": px, "fonte": fonte, "val": val, "tot": eq_usdc + val,
|
||||
"quota": val / (eq_usdc + val) if (eq_usdc + val) > 0 else None}
|
||||
|
||||
|
||||
def prezzo_ordine(side: str) -> float | None:
|
||||
bid, ask = C.book_top()
|
||||
if side == "buy":
|
||||
return None if ask is None else round(min(ask + MARGINE * TICK, CAP_BUY), 4)
|
||||
return None if bid is None else round(max(bid - MARGINE * TICK, CAP_SELL), 4)
|
||||
|
||||
|
||||
def ordine(c: DeribitRead, side: str, amt: float, px: float) -> tuple[bool, str]:
|
||||
resp = c._unwrap(c._post("/mcp-deribit/tools/place_order",
|
||||
{"instrument_name": U.SPOT, "side": side, "amount": float(amt),
|
||||
"type": "limit", "price": px, "label": "usde-sonda-0906"})) or {}
|
||||
if not isinstance(resp, dict) or resp.get("error") or "order" not in resp:
|
||||
err = resp.get("error", resp) if isinstance(resp, dict) else resp
|
||||
return False, str(err)[:120]
|
||||
o = resp["order"]
|
||||
return True, f"{o.get('order_state')} filled {o.get('filled_amount')}"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
esegui = "--esegui" in sys.argv[1:]
|
||||
max_add = float(sys.argv[sys.argv.index("--max-add") + 1]) if "--max-add" in sys.argv else MAX_ADD
|
||||
c = DeribitRead()
|
||||
s0 = stato(c)
|
||||
if s0["px"] is None:
|
||||
print(" ✗ prezzo USDE non leggibile — non si sonda al buio (P5)"); return 2
|
||||
print("=" * 78)
|
||||
print(f" SONDA TETTO USDE — {datetime.now(timezone.utc):%Y-%m-%dT%H:%M:%SZ} "
|
||||
f"{'ESECUZIONE' if esegui else 'DRY-RUN'}")
|
||||
print("=" * 78)
|
||||
cap = U.cfg().get("venue_cap_frac")
|
||||
print(f" conto : USDC ${s0['usdc']:,.2f} + USDE {s0['usde']:,.4f} (${s0['val']:,.2f}) = ${s0['tot']:,.2f}"
|
||||
f" quota {s0['quota']:.2%} · tetto in config {cap:.1%} = {cap * s0['tot']:,.1f} USDE")
|
||||
cusc, come = C.cuscino_richiesto_usd(s0["tot"])
|
||||
print(f" piano : BUY a passi {'→'.join(f'{x:.0f}' for x in SCALA)} fino al rifiuto (max +{max_add:.0f} USDE;"
|
||||
f" quota ≤{QUOTA_HARD:.0%} e USDC ≥ cuscino ${cusc:,.2f} = {come}), conferma SELL 1 → BUY 1 ok → BUY 1 rifiutato")
|
||||
if not esegui:
|
||||
print(" → dry-run. Rilancia con --esegui."); return 0
|
||||
|
||||
eventi: list[dict] = []
|
||||
|
||||
def fai(side: str, amt: float) -> bool:
|
||||
px = prezzo_ordine(side)
|
||||
if px is None or not (PX_MIN <= px <= PX_MAX):
|
||||
print(f" ✗ prezzo d'ordine {px} non leggibile/fuori banda — mi fermo"); raise SystemExit(3)
|
||||
ok, nota = ordine(c, side, amt, px)
|
||||
time.sleep(3.0)
|
||||
s = stato(c)
|
||||
ev = {"ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "side": side, "amount": amt,
|
||||
"px": px, "ok": ok, "nota": nota, "usde": s["usde"], "tot": s["tot"], "quota": s["quota"]}
|
||||
eventi.append(ev)
|
||||
print(f" · {side.upper():4} {amt:>3.0f} @ {px:.4f} -> {'OK ' if ok else 'RIFIUTATO'} "
|
||||
f"USDE {s['usde']:,.4f} eq ${s['tot']:,.2f} quota {s['quota']:.3%} [{nota}]")
|
||||
return ok
|
||||
|
||||
# --- salita a passi piccoli -------------------------------------------------------------
|
||||
i_step, aggiunti, muro, limite = 0, 0.0, None, None
|
||||
while True:
|
||||
step = SCALA[i_step]
|
||||
s = stato(c)
|
||||
cusc, _ = C.cuscino_richiesto_usd(s["tot"])
|
||||
if aggiunti + step > max_add:
|
||||
limite = f"+{aggiunti:.0f} USDE raggiunti (max-add {max_add:.0f})"; break
|
||||
if (s["val"] + step * s["px"]) / s["tot"] > QUOTA_HARD:
|
||||
limite = f"quota {s['quota']:.2%} + passo supererebbe {QUOTA_HARD:.0%}"; break
|
||||
if s["usdc"] - step * s["px"] < cusc:
|
||||
limite = f"USDC ${s['usdc']:,.2f} − passo scenderebbe sotto il cuscino ${cusc:,.2f}"; break
|
||||
if fai("buy", step):
|
||||
aggiunti += step; time.sleep(PAUSA); continue
|
||||
time.sleep(PAUSA_RIFIUTO) # rifiuto: rileggo il book e riprovo UGUALE
|
||||
if fai("buy", step):
|
||||
aggiunti += step; time.sleep(PAUSA); continue
|
||||
if i_step < len(SCALA) - 1:
|
||||
i_step += 1; time.sleep(PAUSA); continue # doppio rifiuto: passo piu' fine
|
||||
muro = stato(c); break
|
||||
if limite:
|
||||
s = stato(c)
|
||||
print(f" ✗ limite della sonda: {limite} — tetto del venue NON trovato entro il limite;"
|
||||
f" quota {s['quota']:.2%}, USDE {s['usde']:,.4f}")
|
||||
|
||||
if muro is None:
|
||||
s = stato(c)
|
||||
esito = {"verdetto": "NON_TROVATO", "limite": limite, "usde": s["usde"], "equity_tot": s["tot"],
|
||||
"quota": s["quota"], "aggiunti": aggiunti, "eventi": eventi}
|
||||
else:
|
||||
# --- conferma a saldo neutro ---------------------------------------------------------
|
||||
time.sleep(PAUSA)
|
||||
ok_sell = fai("sell", 1); time.sleep(PAUSA)
|
||||
ok_buy1 = fai("buy", 1) if ok_sell else False; time.sleep(PAUSA)
|
||||
ok_buy2 = fai("buy", 1) if ok_buy1 else None; time.sleep(3.0)
|
||||
fin = stato(c)
|
||||
confermato = bool(ok_sell and ok_buy1 and ok_buy2 is False)
|
||||
L = fin["usde"]
|
||||
lo, hi = L / fin["tot"], (L + 1) / fin["tot"]
|
||||
esito = {"verdetto": "CONFERMATO" if confermato else "NON_CONFERMATO",
|
||||
"bracket_usde": [L, L + 1], "equity_tot": fin["tot"], "frazione": [lo, hi],
|
||||
"aggiunti": aggiunti, "eventi": eventi}
|
||||
print()
|
||||
print(f" TETTO : [{L:,.4f} · {L + 1:,.4f}) USDE con equity ${fin['tot']:,.2f}"
|
||||
f" ⇒ frazione {lo:.2%} – {hi:.2%} ({esito['verdetto']}: "
|
||||
f"sell1 {ok_sell} / buy1 {ok_buy1} / buy1 {ok_buy2})")
|
||||
print(f" config : venue_cap_frac {cap:.3f} → bordo basso misurato oggi {lo:.4f}"
|
||||
+ (" (config sotto il misurato: conservativa, ok)" if cap <= lo + 1e-9 else " ⚠️ config SOPRA il misurato"))
|
||||
print(f" conto : USDC ${fin['usdc']:,.2f} + USDE {fin['usde']:,.4f} = ${fin['tot']:,.2f} quota {fin['quota']:.2%}")
|
||||
with LOG.open("a") as f:
|
||||
f.write(json.dumps({"ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"sonda_tetto": esito}) + "\n")
|
||||
print(f" registrato : {LOG.relative_to(ROOT)}")
|
||||
return 0 if esito["verdetto"] == "CONFERMATO" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,385 @@
|
||||
"""r0909 — IL CROLLO CATTURATO: il `f` di stress delle opzioni, misurato su quote VERE.
|
||||
|
||||
PERCHE' ESISTE. La decisione vincolante del 19/06 ("niente short-vol da modello in deploy", e la
|
||||
gemella "niente long-vol scalp") dichiara UNA condizione che la riapre: *un `f` di stress reale
|
||||
misurato su un crash catturato*. COLLAR01 (§71) e PAVIMENTO-LEVA (§72) chiudono con la stessa
|
||||
condizione. Il 22/08 (§11) il campione con quote vere era "la strategia mentre STA FERMA":
|
||||
19 settimane di rally, "l'unico episodio ad alta vol del campione e' un rally, non un crash".
|
||||
|
||||
FATTO NUOVO (2026-09-09): l'archivio ereditato `bite_archive.parquet` copre con bid E ask
|
||||
il crollo del 1-5 giugno 2026 — BTC −25% e ETH −30% in 5 giorni (peggior giorno ETH −10,6%
|
||||
il 05/06), su UNA scadenza (19 giugno, 14-21 DTE agli ingressi). Il campione del 22/08
|
||||
partiva DOPO, dal fondo. Nessuno lo aveva usato: e' il crash catturato che la regola aspettava.
|
||||
|
||||
COSA MISURA (a ogni ora d'ingresso dal 30/05 al 04/06, BTC e ETH — l'ora e' un'ancora, si
|
||||
riportano mediana e banda, mai il massimo):
|
||||
A. la geometria di VRP01 (vendi put δ−0,28, compra put δ−0,10, stessa scadenza) a quote REALI:
|
||||
credito al bid/ask, mark-to-market ora per ora dalle standing quotes, regolamento a scadenza
|
||||
sullo spot certificato. Contro lo STESSO trade prezzato dal modello del sleeve (BS su DVOL,
|
||||
`sleeves._bs_put`). f_stress = perdita reale / perdita modellata, a scadenza e al peggior MTM.
|
||||
B. lo specchio LONG (compra la put δ−0,10 all'ask; e il debit spread −0,28/−0,10): quanto del
|
||||
payoff modellato si incassa davvero al bid — a scadenza (uscita passiva) e al miglior punto
|
||||
del percorso (limite superiore con look-ahead, dichiarato).
|
||||
C. il contesto: DVOL, RV realizzata, IV-rank causale (il gate di VRP01 era aperto o chiuso?),
|
||||
e cosa dice il modello del sleeve (`_vrp_weekly_asset`) per le settimane di giugno.
|
||||
|
||||
LIMITI DICHIARATI PRIMA DEI NUMERI: n = 1 crollo, 1 scadenza per asset, tenor 14-21 giorni
|
||||
(VRP01 e' a 7), 2-5 mila righe/giorno (40-125 strumenti/ora), `underlying_price` assente
|
||||
nell'archivio (spot dal feed 1h certificato, D1), regolamento approssimato allo spot dell'ora
|
||||
di scadenza (Deribit usa una TWAP di 30 min). La famiglia dell'archivio e' INVERSE (premi in
|
||||
BTC/ETH; `ETH-19JUN26-1900-P`): VRP01 opererebbe sulla USDC-lineare, che il conto puo' marginare
|
||||
e che NON e' nell'archivio (§11) — il f qui misurato e' della famiglia che il conto non negozia.
|
||||
Un episodio e' un episodio: da' la TAGLIA di f nello stress, non la sua distribuzione.
|
||||
|
||||
DUE CORREZIONI DELLA REVISIONE (fable, 09/09), applicate:
|
||||
* il feed 1h e' etichettato all'APERTURA (la barra 20:00 chiude alle 21:00): `S.asof(ts)`
|
||||
guardava un'ora AVANTI. Qui lo spot e' `spot_causale` = chiusura dell'ultima barra chiusa a ts.
|
||||
⚠️ `cblib.spot_series` ha lo stesso difetto (e quindi il f 0,714 di §11): registrato, non toccato.
|
||||
* il costo di chiusura `(ask corta − bid lunga)·spot` puo' superare la LARGHEZZA dello spread
|
||||
(una quota da 158% di spread relativo a T→0): uno spread a rischio definito non costa mai piu'
|
||||
della larghezza per chiudere (si lascia regolare). Il MTM e' tagliato alla larghezza e le ore
|
||||
tagliate sono contate. Senza il taglio il "peggior MTM" era un artefatto di quote (2,6×).
|
||||
* `f_exp` e' TAUTOLOGICO quando entrambe le gambe finiscono ITM (payoff = larghezza per reale
|
||||
e modello): si riduce a una funzione di f_net. Si stampa la quota di trade in cui succede.
|
||||
|
||||
Nessun file di produzione toccato. Nessun ordine.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||
|
||||
import cblib as CB # noqa: E402
|
||||
from src.portfolio.sleeves import VRP_CFG, _vrp_weekly_asset # noqa: E402
|
||||
|
||||
ARCHIVIO = ROOT / "data" / "raw" / "cb_chain" / "bite_archive.parquet"
|
||||
ASSETS = ("BTC", "ETH")
|
||||
EXP = pd.Timestamp("2026-06-19 08:00", tz="UTC")
|
||||
INGRESSI = (pd.Timestamp("2026-05-30", tz="UTC"), pd.Timestamp("2026-06-05", tz="UTC")) # [da, a)
|
||||
CROLLO = (pd.Timestamp("2026-06-01", tz="UTC"), pd.Timestamp("2026-06-06", tz="UTC"))
|
||||
PRE = pd.Timestamp("2026-06-02", tz="UTC") # ingressi "prima del crollo" = prima del primo giorno a −7%/−8% (il 01/06 BTC fece −3,1%: e' DENTRO "pre", dichiarato)
|
||||
TOL_D = (0.08, 0.06) # geometria VRP01: |δ corta + 0,28| ≤ 0,08 e |δ lunga + 0,10| ≤ 0,06, altrimenti "fuori geometria"
|
||||
SHORT_D, LONG_D = VRP_CFG["short_delta"], VRP_CFG["long_delta"] # P1: dal sleeve, non ridichiarati
|
||||
FEE_FRAC = VRP_CFG["fee_frac"]
|
||||
# listino Deribit opzioni (stesse costanti di r0822_vrp_real_quotes, verificate al venue il 22/08)
|
||||
FEE_UNDERLYING, FEE_PREMIUM_CAP, FEE_DELIVERY = 0.0003, 0.125, 0.00015
|
||||
|
||||
|
||||
def leg_fee(prem_base: float, spot: float) -> float:
|
||||
return min(FEE_UNDERLYING * spot, FEE_PREMIUM_CAP * prem_base * spot)
|
||||
|
||||
|
||||
def spot_causale(S: pd.Series) -> pd.Series:
|
||||
"""Il feed 1h e' etichettato all'apertura: la chiusura della barra T e' nota a T+1h. Spostando
|
||||
l'indice di +1h, `asof(ts)` restituisce l'ultima chiusura CONOSCIUTA a ts, mai una futura."""
|
||||
out = S.copy(); out.index = out.index + pd.Timedelta(hours=1)
|
||||
return out
|
||||
|
||||
|
||||
def carica() -> pd.DataFrame:
|
||||
a = pd.read_parquet(ARCHIVIO)
|
||||
a["ts"] = pd.to_datetime(a["ts"], utc=True)
|
||||
a["exp"] = pd.to_datetime(a["exp"], utc=True)
|
||||
a = a[(a["option_type"] == "P") & (a["exp"] == EXP) & (a["ts"] >= INGRESSI[0]) & (a["ts"] <= EXP)]
|
||||
a = a.drop_duplicates(subset=["ts", "instrument_name"], keep="last")
|
||||
a["dte"] = (a["exp"] - a["ts"]).dt.total_seconds() / 86400.0
|
||||
return a.reset_index(drop=True)
|
||||
|
||||
|
||||
def bs_spread(S: float, Ks: float, Kl: float, T: float, sig: float) -> float:
|
||||
return CB.bs_put(S, Ks, T, sig) - CB.bs_put(S, Kl, T, sig)
|
||||
|
||||
|
||||
def bs_put_vec(S, K, T, sig):
|
||||
"""`cblib.bs_put` vettorizzata (stessa formula, r=0); T≤0 → intrinseco."""
|
||||
from scipy.stats import norm
|
||||
S = np.asarray(S, float); T = np.asarray(T, float); sig = np.asarray(sig, float)
|
||||
out = np.maximum(K - S, 0.0)
|
||||
ok = (T > 0) & (sig > 0)
|
||||
d1 = (np.log(S[ok] / K) + 0.5 * sig[ok] ** 2 * T[ok]) / (sig[ok] * np.sqrt(T[ok]))
|
||||
out[ok] = K * norm.cdf(-(d1 - sig[ok] * np.sqrt(T[ok]))) - S[ok] * norm.cdf(-d1)
|
||||
return out
|
||||
|
||||
|
||||
class Quote:
|
||||
"""Tabella ora × strumento (bid, ask, iv) costruita UNA volta per asset, con spot e DVOL per ora."""
|
||||
|
||||
def __init__(self, seg: pd.DataFrame, S: pd.Series, V: pd.Series):
|
||||
q = seg.dropna(subset=["bid", "ask"]); q = q[(q["bid"] > 0) & (q["ask"] > 0)]
|
||||
self.bid = q.pivot_table(index="ts", columns="instrument_name", values="bid")
|
||||
self.ask = q.pivot_table(index="ts", columns="instrument_name", values="ask")
|
||||
self.iv = q.pivot_table(index="ts", columns="instrument_name", values="iv")
|
||||
idx = self.bid.index
|
||||
self.spot = pd.Series([float(S.asof(t)) for t in idx], index=idx)
|
||||
self.dvol = pd.Series([float(V.asof(t)) for t in idx], index=idx) / 100.0
|
||||
self.T = pd.Series(np.maximum((EXP - idx).total_seconds().values / 86400.0, 0.0) / 365.25, index=idx)
|
||||
|
||||
|
||||
def percorso(Q: Quote, dopo: pd.Timestamp, inst_s: str, inst_l: str, Ks: float, Kl: float) -> pd.DataFrame:
|
||||
"""Ora per ora dopo l'ingresso: costo REALE di chiudere lo short spread (ricompra la corta
|
||||
all'ask, rivende la lunga al bid), valore REALE di liquidare il debit spread (vende la corta
|
||||
al bid, ricompra la lunga all'ask) e la put lunga (bid); piu' il valore MODELLATO (BS a DVOL_t)."""
|
||||
if inst_s not in Q.bid.columns or inst_l not in Q.bid.columns:
|
||||
return pd.DataFrame()
|
||||
m = (Q.bid.index > dopo) & Q.bid[inst_s].notna() & Q.bid[inst_l].notna()
|
||||
if not m.any():
|
||||
return pd.DataFrame()
|
||||
idx = Q.bid.index[m]
|
||||
sp = Q.spot.loc[idx].values; dv = Q.dvol.loc[idx].values; T = Q.T.loc[idx].values
|
||||
bs_, as_ = Q.bid.loc[idx, inst_s].values, Q.ask.loc[idx, inst_s].values
|
||||
bl, al = Q.bid.loc[idx, inst_l].values, Q.ask.loc[idx, inst_l].values
|
||||
mod_s, mod_l = bs_put_vec(sp, Ks, T, dv), bs_put_vec(sp, Kl, T, dv)
|
||||
grezzo = (as_ - bl) * sp
|
||||
return pd.DataFrame(dict(ts=idx, spot=sp, dvol=dv,
|
||||
chiudi_short=np.minimum(grezzo, Ks - Kl), chiudi_grezzo=grezzo,
|
||||
liquida_long=np.clip((bs_ - al) * sp, 0.0, Ks - Kl), liquida_put=bl * sp,
|
||||
mod_spread=mod_s - mod_l, mod_put=mod_l,
|
||||
iv_s=Q.iv.loc[idx, inst_s].values, iv_l=Q.iv.loc[idx, inst_l].values))
|
||||
|
||||
|
||||
def trade(asset: str, ts: pd.Timestamp, snap: pd.DataFrame, Q: Quote,
|
||||
S: pd.Series, V: pd.Series) -> dict | None:
|
||||
legs = CB.pick_legs(snap, SHORT_D, LONG_D)
|
||||
if legs is None:
|
||||
return None
|
||||
spot = float(S.asof(ts)); dvol = float(V.asof(ts)); T = (EXP - ts).total_seconds() / 86400.0 / 365.25
|
||||
hist = V[V.index < ts]; ivr = float((hist < dvol).mean()) if len(hist) else np.nan
|
||||
Ks, Kl = legs["k_short"], legs["k_long"]
|
||||
# --- A. short spread (VRP01) — reale al bid/ask, modello a DVOL
|
||||
cred = (legs["bid_short"] - legs["ask_long"]) * spot
|
||||
if cred <= 0:
|
||||
return None
|
||||
cred_mod = bs_spread(spot, Ks, Kl, T, dvol / 100.0)
|
||||
ST = float(S.asof(EXP))
|
||||
payoff = max(Ks - ST, 0.0) - max(Kl - ST, 0.0)
|
||||
fee_in = leg_fee(legs["bid_short"], spot) + leg_fee(legs["ask_long"], spot)
|
||||
fee_out = sum(min(FEE_DELIVERY * ST, FEE_PREMIUM_CAP * max(K - ST, 0.0)) for K in (Ks, Kl) if K > ST)
|
||||
pnl_real = cred - payoff - fee_in - fee_out
|
||||
pnl_mod = cred_mod - payoff - FEE_FRAC * cred_mod
|
||||
p = percorso(Q, ts, legs["inst_short"], legs["inst_long"], Ks, Kl)
|
||||
if p.empty:
|
||||
return None
|
||||
mtm_real = cred - p["chiudi_short"] # P&L aperto se si chiude ORA a quote vere (tagliato alla larghezza)
|
||||
mtm_mod = cred_mod - p["mod_spread"] # lo stesso trade riprezzato a BS/DVOL_t (costruzione nostra: il sleeve tiene a scadenza)
|
||||
i_min = int(mtm_real.idxmin())
|
||||
n_tagli = int((p["chiudi_grezzo"] > Ks - Kl).sum())
|
||||
# --- B. specchio long: put δ−0,10 comprata all'ask; debit spread −0,28/−0,10
|
||||
costo_put = legs["ask_long"] * spot
|
||||
mod_put = CB.bs_put(spot, Kl, T, dvol / 100.0)
|
||||
costo_deb = (legs["ask_short"] - legs["bid_long"]) * spot
|
||||
mod_deb = cred_mod
|
||||
pay_put_exp = max(Kl - ST, 0.0)
|
||||
pay_deb_exp = payoff
|
||||
# il picco del percorso e' un limite SUPERIORE con look-ahead: si dichiara, non si vende
|
||||
i_put = int(p["liquida_put"].idxmax()); i_deb = int(p["liquida_long"].idxmax())
|
||||
risk = (Ks - Kl) - cred
|
||||
in_geom = abs(legs["d_short"] - SHORT_D) <= TOL_D[0] and abs(legs["d_long"] - LONG_D) <= TOL_D[1]
|
||||
return dict(
|
||||
in_geom=bool(in_geom),
|
||||
asset=asset, ts=ts, dte=(EXP - ts).total_seconds() / 86400.0, spot=spot, dvol=dvol, ivrank=ivr,
|
||||
k_short=Ks, k_long=Kl, d_short=legs["d_short"], d_long=legs["d_long"], width=Ks - Kl,
|
||||
iv_s0=legs["iv_short"], iv_l0=legs["iv_long"], ST=ST, und_ret=ST / spot - 1.0,
|
||||
cred_real=cred, cred_mod=cred_mod, f_net=cred / cred_mod if cred_mod > 0 else np.nan,
|
||||
risk=risk, pnl_real=pnl_real, pnl_mod=pnl_mod,
|
||||
perdita_su_risk=-pnl_real / risk, perdita_su_coll=-pnl_real / Ks,
|
||||
f_exp=(-pnl_real / -pnl_mod) if pnl_mod < 0 and pnl_real < 0 else np.nan,
|
||||
mtm_real_min=float(mtm_real.min()), mtm_mod_min=float(mtm_mod.min()),
|
||||
mtm_min_ts=p["ts"].iloc[i_min], n_tagli=n_tagli, n_ore=int(len(p)),
|
||||
iv_s_at_min=float(p["iv_s"].iloc[i_min]), iv_l_at_min=float(p["iv_l"].iloc[i_min]), dvol_at_min=float(p["dvol"].iloc[i_min]) * 100,
|
||||
itm_pieno=bool(ST <= Kl), # entrambe le gambe ITM: payoff = larghezza, f_exp si riduce a f_net
|
||||
f_mtm=(mtm_real.min() / mtm_mod.min()) if mtm_mod.min() < 0 and mtm_real.min() < 0 else np.nan,
|
||||
spot_min=float(p["spot"].min()), iv_s_max=float(p["iv_s"].max()), iv_l_max=float(p["iv_l"].max()),
|
||||
n_ore_percorso=int(len(p)),
|
||||
# long
|
||||
costo_put=costo_put, mod_put=mod_put, f_put_in=costo_put / mod_put if mod_put > 0 else np.nan,
|
||||
put_exp=pay_put_exp - costo_put, put_exp_mod=pay_put_exp - mod_put,
|
||||
put_best=float(p["liquida_put"].iloc[i_put]) - costo_put, put_best_mod=float(p["mod_put"].iloc[i_put]) - mod_put,
|
||||
put_best_ts=p["ts"].iloc[i_put], bleed_put=costo_put / spot,
|
||||
costo_deb=costo_deb, mod_deb=mod_deb,
|
||||
deb_exp=pay_deb_exp - costo_deb, deb_exp_mod=pay_deb_exp - mod_deb,
|
||||
deb_best=float(p["liquida_long"].iloc[i_deb]) - costo_deb, deb_best_mod=float(p["mod_spread"].iloc[i_deb]) - mod_deb,
|
||||
)
|
||||
|
||||
|
||||
def misura() -> dict:
|
||||
a = carica()
|
||||
out = {"trades": {}, "contesto": {}, "sleeve": {}}
|
||||
for asset in ASSETS:
|
||||
seg = a[a["asset"] == asset]
|
||||
S, V = spot_causale(CB.spot_series(asset)), CB.dvol_series(asset)
|
||||
Q = Quote(seg, S, V)
|
||||
rows = []
|
||||
for ts in sorted(seg["ts"].unique()):
|
||||
ts = pd.Timestamp(ts)
|
||||
if not (INGRESSI[0] <= ts < INGRESSI[1]):
|
||||
continue
|
||||
t = trade(asset, ts, seg[seg["ts"] == ts], Q, S, V)
|
||||
if t:
|
||||
rows.append(t)
|
||||
out["trades"][asset] = pd.DataFrame(rows)
|
||||
# contesto: DVOL, RV5 e RV1 sul crollo
|
||||
px = S[(S.index >= pd.Timestamp("2026-05-20", tz="UTC")) & (S.index <= EXP)]
|
||||
pd_ = px.resample("1D").last().dropna() # chiusura del giorno = ultima chiusura nota entro le 24:00
|
||||
hist_all = V[V.index < CROLLO[0]]
|
||||
lr = np.log(pd_).diff()
|
||||
rv5 = lr.rolling(5).std() * np.sqrt(365.25) * 100
|
||||
dv = V[(V.index >= pd.Timestamp("2026-05-20", tz="UTC")) & (V.index <= EXP)]
|
||||
hist = V[V.index < CROLLO[0]]
|
||||
out["contesto"][asset] = dict(
|
||||
spot=pd_, ret=lr, rv5=rv5, dvol=dv,
|
||||
dvol_max_crollo=float(dv[(dv.index >= CROLLO[0]) & (dv.index <= CROLLO[1])].max()),
|
||||
dvol_pre=float(V.asof(CROLLO[0])),
|
||||
pctl_picco=float((hist_all < V[(V.index >= CROLLO[0]) & (V.index <= CROLLO[1])].max()).mean()),
|
||||
ivr_pre=float((hist < V.asof(CROLLO[0])).mean()),
|
||||
rv5_max=float(rv5[(rv5.index >= CROLLO[0]) & (rv5.index <= CROLLO[1] + pd.Timedelta(days=3))].max()),
|
||||
caduta=float(pd_[CROLLO[0]:CROLLO[1]].min() / pd_[:CROLLO[0]].iloc[-1] - 1.0),
|
||||
)
|
||||
wk = _vrp_weekly_asset(asset)
|
||||
out["sleeve"][asset] = wk[(wk.index >= pd.Timestamp("2026-05-10", tz="UTC")) & (wk.index <= pd.Timestamp("2026-07-05", tz="UTC"))]
|
||||
return out
|
||||
|
||||
|
||||
def sintesi(m: dict) -> dict:
|
||||
s = {}
|
||||
for asset in ASSETS:
|
||||
t = m["trades"][asset]
|
||||
if t.empty:
|
||||
s[asset] = None; continue
|
||||
n_fuori = int((~t["in_geom"]).sum())
|
||||
t = t[t["in_geom"]] # la griglia di strike dell'archivio e' rada: fuori geometria = altra struttura
|
||||
if t.empty:
|
||||
s[asset] = dict(n=0, n_fuori=n_fuori); continue
|
||||
pre = t[t["ts"] < PRE] # ingressi PRIMA del crollo: il caso della regola
|
||||
dur = t[(t["ts"] >= PRE) & (t["ts"] < CROLLO[1] - pd.Timedelta(days=1))] # DURANTE (02-04/06): lente secondaria
|
||||
med = lambda c, d=t: float(np.nanmedian(d[c])) if len(d) else np.nan
|
||||
b = lambda c, d=t: (float(np.nanpercentile(d[c], 10)), float(np.nanpercentile(d[c], 90))) if len(d) else (np.nan, np.nan)
|
||||
s[asset] = dict(
|
||||
n=int(len(t)), n_pre=int(len(pre)), n_fuori=n_fuori,
|
||||
d_short=med("d_short", pre), d_long=med("d_long", pre), width=med("width", pre), ST=float(t["ST"].iloc[0]),
|
||||
spot_pre=med("spot", pre),
|
||||
dte=(float(t["dte"].min()), float(t["dte"].max())),
|
||||
f_net=med("f_net"), f_net_b=b("f_net"),
|
||||
ivrank=(float(t["ivrank"].min()), float(t["ivrank"].max())),
|
||||
gate_aperto=float((t["ivrank"] >= VRP_CFG["gate_ivr"]).mean()),
|
||||
perdita_su_risk=med("perdita_su_risk", pre), perdita_su_risk_b=b("perdita_su_risk", pre),
|
||||
perdita_su_coll=med("perdita_su_coll", pre),
|
||||
pnl_real=med("pnl_real", pre), pnl_mod=med("pnl_mod", pre),
|
||||
f_exp=med("f_exp", pre), f_exp_b=b("f_exp", pre), n_f_exp=int(pre["f_exp"].notna().sum()),
|
||||
f_mtm=med("f_mtm", pre), f_mtm_b=b("f_mtm", pre),
|
||||
mtm_real_min=med("mtm_real_min", pre), mtm_mod_min=med("mtm_mod_min", pre),
|
||||
n_tagli=int(pre["n_tagli"].sum()) if len(pre) else 0, n_ore=int(pre["n_ore"].sum()) if len(pre) else 0,
|
||||
iv_s_at_min=med("iv_s_at_min", pre), dvol_at_min=med("dvol_at_min", pre),
|
||||
q_itm_pieno=float(pre["itm_pieno"].mean()) if len(pre) else np.nan,
|
||||
n_strutture=int(pre.groupby(["k_short", "k_long"]).ngroups) if len(pre) else 0,
|
||||
f_put_in=med("f_put_in", pre), bleed_put=med("bleed_put", pre),
|
||||
put_exp=med("put_exp", pre), put_exp_mod=med("put_exp_mod", pre),
|
||||
put_best=med("put_best", pre), put_best_mod=med("put_best_mod", pre),
|
||||
f_put_exp=med_ratio(pre, "put_exp", "put_exp_mod"), f_put_best=med_ratio(pre, "put_best", "put_best_mod"),
|
||||
deb_exp=med("deb_exp", pre), deb_exp_mod=med("deb_exp_mod", pre),
|
||||
deb_best=med("deb_best", pre), deb_best_mod=med("deb_best_mod", pre),
|
||||
f_deb_exp=med_ratio(pre, "deb_exp", "deb_exp_mod"), f_deb_best=med_ratio(pre, "deb_best", "deb_best_mod"),
|
||||
iv_s0=med("iv_s0", pre), iv_s_max=med("iv_s_max", pre), iv_l0=med("iv_l0", pre), iv_l_max=med("iv_l_max", pre),
|
||||
und_ret=med("und_ret", pre), spot_min=med("spot_min", pre),
|
||||
durante=dict(n=int(len(dur)), f_exp=med("f_exp", dur), f_mtm=med("f_mtm", dur),
|
||||
perdita_su_risk=med("perdita_su_risk", dur), f_net=med("f_net", dur),
|
||||
q_perde=float((dur["pnl_real"] < 0).mean()) if len(dur) else np.nan,
|
||||
pnl_p10=float(np.nanpercentile(dur["pnl_real"], 10)) if len(dur) else np.nan,
|
||||
pnl_p90=float(np.nanpercentile(dur["pnl_real"], 90)) if len(dur) else np.nan,
|
||||
d_short=med("d_short", dur), d_long=med("d_long", dur), width=med("width", dur),
|
||||
pnl_real=med("pnl_real", dur), pnl_mod=med("pnl_mod", dur)),
|
||||
)
|
||||
return s
|
||||
|
||||
|
||||
def med_ratio(d: pd.DataFrame, a: str, b: str, minimo: float = 5.0) -> float:
|
||||
"""Mediana del rapporto reale/modello sulle righe in cui il modello vale almeno `minimo` $
|
||||
(un payoff modellato ≈ 0 non da' un rapporto, da' un'altra domanda: si stampano i dollari)."""
|
||||
x = d[(d[b] >= minimo)]
|
||||
return float(np.nanmedian(x[a] / x[b])) if len(x) else np.nan
|
||||
|
||||
|
||||
def verdetto(s: dict) -> str:
|
||||
parti = []
|
||||
for asset in ASSETS:
|
||||
r = s.get(asset)
|
||||
if not r or r.get("n", 0) == 0:
|
||||
parti.append(f"{asset}: NON misurabile — {r.get('n_fuori', 0) if r else 0} snapshot tutti FUORI geometria (griglia di strike rada)"); continue
|
||||
if not np.isfinite(r["f_exp"]):
|
||||
parti.append(f"{asset}: nessun ingresso pre-crollo in geometria (solo DURANTE: f_net {r['durante']['f_net']:.2f}, perde il {r['durante']['q_perde']*100:.0f}%)"); continue
|
||||
parti.append(f"{asset}: f_net {r['f_net']:.2f} (22/08 in rally 0,714); a scadenza f {r['f_exp']:.2f} ma TAUTOLOGICO nel "
|
||||
f"{r['q_itm_pieno']*100:.0f}% dei trade (entrambe le gambe ITM ⇒ dipende solo da f_net; {r['n_strutture']} strutture, non {r['n_pre']} osservazioni); "
|
||||
f"peggior MTM tagliato alla larghezza f {r['f_mtm']:.2f} (quote oltre la larghezza nel {r['n_tagli']/max(1, r['n_ore'])*100:.1f}% delle ore); "
|
||||
f"gate IV-rank {'APERTO' if r['gate_aperto'] > 0.5 else 'CHIUSO'} nel {r['gate_aperto']*100:.0f}% delle ore")
|
||||
esito = ("IL CROLLO E' CATTURATO MA A VOL BASSA E FUORI DAL GATE DI VRP01: il f di stress che la regola del 19/06 aspettava "
|
||||
"NON e' misurato nella sostanza — cio' che e' misurato e' che il rapporto di credito f_net non cambia col regime")
|
||||
return esito + " — " + "; ".join(parti) + " — n = 1 crollo, 1 scadenza per asset, famiglia INVERSE"
|
||||
|
||||
|
||||
def pct(x): return f"{x*100:+.1f}%"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("=" * 104)
|
||||
print(" r0909 — IL CROLLO CATTURATO (1-5 giugno 2026): f di stress su quote VERE, scadenza 19/06")
|
||||
print("=" * 104)
|
||||
m = misura(); s = sintesi(m)
|
||||
for asset in ASSETS:
|
||||
c = m["contesto"][asset]; r = s[asset]
|
||||
print(f"\n [{asset}] CONTESTO — caduta {pct(c['caduta'])} dal 31/05 al minimo di 1-5/06; "
|
||||
f"DVOL pre {c['dvol_pre']:.1f} (IV-rank causale {c['ivr_pre']:.2f}) → max {c['dvol_max_crollo']:.1f} "
|
||||
f"(= {c['pctl_picco']*100:.0f}° percentile storico: il PICCO del crollo sta sotto la mediana); RV5 max {c['rv5_max']:.0f}")
|
||||
print(" giorno spot ret DVOL RV5")
|
||||
for d in pd.date_range("2026-05-28", "2026-06-08", tz="UTC"):
|
||||
if d in c["spot"].index:
|
||||
dv = c["dvol"].asof(d)
|
||||
print(f" {d.date()} {c['spot'][d]:>8.0f} {pct(c['ret'].get(d, np.nan)):>7} {dv:>5.1f} {c['rv5'].get(d, np.nan):>5.0f}")
|
||||
if r is None or r.get("n", 0) == 0:
|
||||
print(f" nessun trade in geometria VRP01: {r.get('n_fuori', 0) if r else 0} snapshot fuori geometria "
|
||||
f"(la griglia di strike dell'archivio in quei giorni non ha le δ−0,28/−0,10)"); continue
|
||||
t = m["trades"][asset]; t = t[t["in_geom"]]
|
||||
print(f"\n [{asset}] A. VRP01 a quote vere — {r['n']} snapshot d'ingresso in geometria ({r['n_pre']} prima del {PRE.date()}; "
|
||||
f"{r['n_fuori']} scartati fuori geometria), DTE {r['dte'][0]:.0f}-{r['dte'][1]:.0f}, IV-rank {r['ivrank'][0]:.2f}-{r['ivrank'][1]:.2f}")
|
||||
print(f" geometria mediana pre-crollo: δ corta {r['d_short']:+.2f} / δ lunga {r['d_long']:+.2f}, larghezza {r['width']:.0f}, "
|
||||
f"spot d'ingresso {r['spot_pre']:.0f}, regolamento {EXP.date()} a {r['ST']:.0f}")
|
||||
print(f" credito reale/modello f_net {r['f_net']:.2f} [{r['f_net_b'][0]:.2f}, {r['f_net_b'][1]:.2f}] "
|
||||
f"(22/08: 0,714 in rally)")
|
||||
print(f" ingressi PRE-crollo, mediana: P&L reale ${r['pnl_real']:+.0f} vs modello ${r['pnl_mod']:+.0f} per unita' "
|
||||
f"→ perdita {r['perdita_su_risk']*100:.0f}% del rischio [{r['perdita_su_risk_b'][0]*100:.0f}, {r['perdita_su_risk_b'][1]*100:.0f}] "
|
||||
f"(100% = perdita MASSIMA dello spread; oltre 100 sono le fee), "
|
||||
f"{r['perdita_su_coll']*100:.1f}% del collaterale")
|
||||
print(f" f a SCADENZA {r['f_exp']:.2f} [{r['f_exp_b'][0]:.2f}, {r['f_exp_b'][1]:.2f}] su {r['n_f_exp']} snapshot = {r['n_strutture']} strutture "
|
||||
f"— TAUTOLOGICO nel {r['q_itm_pieno']*100:.0f}% (entrambe le gambe ITM: payoff = larghezza per reale e modello, resta solo f_net)")
|
||||
print(f" peggior MTM (chiusura tagliata alla larghezza; quote oltre la larghezza nel {r['n_tagli']/max(1, r['n_ore'])*100:.1f}% delle ore-percorso): "
|
||||
f"f {r['f_mtm']:.2f} [{r['f_mtm_b'][0]:.2f}, {r['f_mtm_b'][1]:.2f}] (reale ${r['mtm_real_min']:+.0f} / modello ${r['mtm_mod_min']:+.0f}); "
|
||||
f"a quell'ora IV corta {r['iv_s_at_min']:.0f} vs DVOL {r['dvol_at_min']:.0f}")
|
||||
print(f" IV gamba corta {r['iv_s0']:.0f} → max {r['iv_s_max']:.0f} (il max e' a T→0, non nel crollo); gamba lunga {r['iv_l0']:.0f} → max {r['iv_l_max']:.0f}; "
|
||||
f"sottostante a scadenza {pct(r['und_ret'])}, minimo {r['spot_min']:.0f}")
|
||||
du = r["durante"]
|
||||
print(f" lente secondaria — ingressi DURANTE il crollo (02-04/06, {du['n']} snapshot): δ {du['d_short']:+.2f}/{du['d_long']:+.2f}, "
|
||||
f"larghezza {du['width']:.0f}, f_net {du['f_net']:.2f}, P&L reale ${du['pnl_real']:+.0f} vs modello ${du['pnl_mod']:+.0f}, "
|
||||
f"perde nel {du['q_perde']*100:.0f}% (p10 ${du['pnl_p10']:+.0f} / p90 ${du['pnl_p90']:+.0f}: bimodale, la mediana nasconde le perdite), "
|
||||
f"f scadenza {du['f_exp']:.2f} / peggior MTM {du['f_mtm']:.2f}")
|
||||
print(f"\n [{asset}] B. LO SPECCHIO LONG (ingressi PRE-crollo, mediana per unita')")
|
||||
print(f" put δ−0,10 all'ask: costo reale/modello {r['f_put_in']:.2f}, bleed {r['bleed_put']*100:.2f}% dello spot")
|
||||
print(f" a scadenza: reale ${r['put_exp']:+.0f} vs modello ${r['put_exp_mod']:+.0f} → f_payoff {r['f_put_exp']:.2f}")
|
||||
print(f" al PICCO del percorso (look-ahead, limite sup.): reale ${r['put_best']:+.0f} vs modello ${r['put_best_mod']:+.0f} → f {r['f_put_best']:.2f}")
|
||||
print(f" debit spread −0,28/−0,10: a scadenza reale ${r['deb_exp']:+.0f} vs modello ${r['deb_exp_mod']:+.0f} → f {r['f_deb_exp']:.2f}; "
|
||||
f"al picco ${r['deb_best']:+.0f} vs ${r['deb_best_mod']:+.0f} → f {r['f_deb_best']:.2f}")
|
||||
print(f"\n [{asset}] C. IL MODELLO DEL SLEEVE (`_vrp_weekly_asset`, 0.0 = settimana saltata dai gate) — rendimento settimanale su collaterale:")
|
||||
print(" " + " ".join(f"{d.date()} {v*100:+.2f}%" for d, v in m["sleeve"][asset].items()))
|
||||
print(f"\n snapshot d'ingresso (primi 6): " + " | ".join(
|
||||
f"{row.ts.strftime('%m-%d %H')}h K{row.k_short:.0f}/{row.k_long:.0f} cred ${row.cred_real:.0f} pnl ${row.pnl_real:+.0f} mtm ${row.mtm_real_min:+.0f}"
|
||||
for row in t.head(6).itertuples()))
|
||||
print("\n VERDETTO: " + verdetto(s))
|
||||
print("=" * 104)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,125 @@
|
||||
"""r0909 — L'UNIVERSO DERIBIT LETTO OGGI: quali "altre monete" esistono davvero sul venue.
|
||||
|
||||
Domanda dell'operatore: "anche piu' monete, ma sempre in Deribit". Prima di misurare una
|
||||
moneta si legge il venue (N10: la verifica esterna a €0 si fa PRIMA, e sul venue). Questo
|
||||
script interroga l'API PUBBLICA Deribit (nessuna credenziale, nessun ordine) e stampa:
|
||||
1. i perpetual USDC-lineari aperti, con data di listing, lotto minimo in $, volume 24h,
|
||||
spread top-of-book, open interest e funding — ordinati per volume;
|
||||
2. quante delle 19 gambe di XS01 sono quotate (il 23/08 erano 14/19, 11 con ≥1 anno);
|
||||
3. le famiglie di opzioni (inverse BTC/ETH; USDC-lineari per sottostante).
|
||||
Una lettura e' UN istante (come r0822_alt_options): il file salva il JSON grezzo in
|
||||
`data/_cache/` con l'ora della lettura, cosi' il numero stampato ha la sua fonte.
|
||||
|
||||
Cosa NON fa: nessun backtest, nessuna proposta. Dice solo quali strumenti esistono e quanto
|
||||
sono negoziabili oggi — il filtro che decide cosa vale la pena misurare (C6).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
from collections import Counter
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
from src.portfolio.sleeves import XS_UNIVERSE # noqa: E402 (P1: l'universo si importa)
|
||||
|
||||
API = "https://www.deribit.com/api/v2/public/"
|
||||
CACHE = ROOT / "data" / "_cache" / "r0909_deribit_universo.json"
|
||||
SOGLIA_LIQUIDO_USD = 10e6 # volume 24h da cui una gamba e' "liquida" per un libro da $4-5k
|
||||
|
||||
|
||||
def get(path: str) -> dict:
|
||||
req = urllib.request.Request(API + path, headers={"User-Agent": "PythagorasGoal-research"})
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
return json.load(r)["result"]
|
||||
|
||||
|
||||
def leggi(usa_cache: bool = True) -> dict:
|
||||
if usa_cache and CACHE.exists():
|
||||
return json.loads(CACHE.read_text())
|
||||
d = dict(letto_a=datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
fut=get("get_instruments?currency=USDC&kind=future"),
|
||||
bs=get("get_book_summary_by_currency?currency=USDC&kind=future"),
|
||||
opt_usdc=get("get_instruments?currency=USDC&kind=option"),
|
||||
opt_inv={c: len(get(f"get_instruments?currency={c}&kind=option")) for c in ("BTC", "ETH")})
|
||||
CACHE.parent.mkdir(parents=True, exist_ok=True)
|
||||
CACHE.write_text(json.dumps(d))
|
||||
return d
|
||||
|
||||
|
||||
def tabella(d: dict) -> list[dict]:
|
||||
perps = {i["base_currency"]: i for i in d["fut"] if i["instrument_name"].endswith("PERPETUAL") and i["state"] == "open"}
|
||||
bs = {b["instrument_name"]: b for b in d["bs"]}
|
||||
rows = []
|
||||
for c, i in perps.items():
|
||||
b = bs.get(i["instrument_name"], {})
|
||||
bid, ask, mark = b.get("bid_price"), b.get("ask_price"), b.get("mark_price") or 0.0
|
||||
rows.append(dict(
|
||||
coin=c, listato=time.strftime("%Y-%m-%d", time.gmtime(i["creation_timestamp"] / 1000)),
|
||||
min_amt=i["min_trade_amount"], min_usd=i["min_trade_amount"] * mark,
|
||||
vol_usd=b.get("volume_usd") or 0.0,
|
||||
spread_bps=((ask - bid) / ((ask + bid) / 2) * 1e4) if bid and ask else float("nan"),
|
||||
oi_usd=(b.get("open_interest") or 0.0) * mark, funding_8h=b.get("funding_8h"),
|
||||
xs01=c in XS_UNIVERSE))
|
||||
return sorted(rows, key=lambda r: -r["vol_usd"])
|
||||
|
||||
|
||||
def sintesi(rows: list[dict], d: dict) -> dict:
|
||||
listati = {r["coin"] for r in rows}
|
||||
liquidi = [r["coin"] for r in rows if r["vol_usd"] >= SOGLIA_LIQUIDO_USD]
|
||||
bs = {b["instrument_name"]: b for b in d["bs"]}
|
||||
inattivi = sorted(i["instrument_name"] for i in d["fut"] if i["instrument_name"].endswith("PERPETUAL") and i["state"] != "open")
|
||||
datati = [i for i in d["fut"] if not i["instrument_name"].endswith("PERPETUAL")]
|
||||
dvol_fut = []
|
||||
for i in datati:
|
||||
if "DVOL" not in i["instrument_name"]:
|
||||
continue
|
||||
b = bs.get(i["instrument_name"], {})
|
||||
dvol_fut.append(dict(nome=i["instrument_name"], stato=i["state"], min_amt=i["min_trade_amount"],
|
||||
vol_usd=b.get("volume_usd") or 0.0, oi=b.get("open_interest") or 0.0,
|
||||
bid=b.get("bid_price"), ask=b.get("ask_price")))
|
||||
return dict(
|
||||
n_perp=len(rows), liquidi=liquidi, inattivi=inattivi, n_datati=len(datati), dvol_fut=dvol_fut,
|
||||
xs01_listate=sorted(c for c in XS_UNIVERSE if c in listati),
|
||||
xs01_mancanti=[c for c in XS_UNIVERSE if c not in listati],
|
||||
opt_inv=d["opt_inv"], opt_usdc=Counter(i["base_currency"] for i in d["opt_usdc"]).most_common(),
|
||||
letto_a=d["letto_a"])
|
||||
|
||||
|
||||
def verdetto(s: dict) -> str:
|
||||
return (f"{s['n_perp']} perpetual USDC; LIQUIDI (≥${SOGLIA_LIQUIDO_USD/1e6:.0f}M/g): {', '.join(s['liquidi'])}; "
|
||||
f"XS01 {len(s['xs01_listate'])}/19 quotate, mancano {', '.join(s['xs01_mancanti'])}; "
|
||||
f"opzioni USDC-lineari su {len(s['opt_usdc'])} sottostanti — lettura del {s['letto_a']}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
d = leggi(usa_cache="--fresh" not in sys.argv)
|
||||
rows = tabella(d); s = sintesi(rows, d)
|
||||
print("=" * 110)
|
||||
print(f" r0909 — UNIVERSO DERIBIT (API pubblica, lettura {s['letto_a']}; --fresh per rileggere)")
|
||||
print("=" * 110)
|
||||
print(f"\n [1] PERPETUAL USDC-LINEARI APERTI: {s['n_perp']}")
|
||||
print(f" {'coin':<9}{'listato':>11}{'min $':>8}{'vol 24h M$':>12}{'spread bps':>12}{'OI M$':>8}{'funding 8h':>12} XS01")
|
||||
for r in rows:
|
||||
print(f" {r['coin']:<9}{r['listato']:>11}{r['min_usd']:>8.2f}{r['vol_usd']/1e6:>12.2f}{r['spread_bps']:>12.1f}"
|
||||
f"{r['oi_usd']/1e6:>8.2f}{(r['funding_8h'] or 0)*1e4:>+11.2f}bp {'<--' if r['xs01'] else ''}")
|
||||
print(f" perpetual NON aperti (stato ≠ open): {s['inattivi'] or 'nessuno'}")
|
||||
print(f"\n [1bis] FUTURE USDC DATATI: {s['n_datati']} — fra cui il future sul DVOL, l'unico strumento long-vol DIRETTO del venue "
|
||||
f"(visto dalla revisione del 09/09: non in memoria):")
|
||||
for f in s["dvol_fut"]:
|
||||
spr = (f["ask"] - f["bid"]) / ((f["ask"] + f["bid"]) / 2) * 100 if f["bid"] and f["ask"] else float("nan")
|
||||
print(f" {f['nome']:<28} {f['stato']:<8} min {f['min_amt']} vol24h ${f['vol_usd']/1e6:.2f}M OI {f['oi']:.0f} "
|
||||
f"bid/ask {f['bid']}/{f['ask']} (spread {spr:.0f}%) → non negoziabile a questi numeri; e in contango sanguina come la put")
|
||||
print(f"\n [2] XS01: {len(s['xs01_listate'])}/19 gambe quotate; mancano {s['xs01_mancanti']}")
|
||||
print(f" (23/08, §50: 14/19 quotate, 11 con ≥1 anno; sulle 11 il meccanismo collassa 1,265 → 0,116 — breadth, non capitale)")
|
||||
print(f"\n [3] OPZIONI: inverse {s['opt_inv']}; USDC-lineari per sottostante: {s['opt_usdc']}")
|
||||
print("\n VERDETTO: " + verdetto(s))
|
||||
print("=" * 110)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,338 @@
|
||||
"""r0909 — IL LIBRO NEI CROLLI: revisione dei sistemi adottati, misurata invece che ricordata.
|
||||
|
||||
DOMANDA DELL'OPERATORE (2026-09-09): "trova una soluzione per guadagnare anche nei crolli".
|
||||
Prima di cercare un meccanismo nuovo, questo script misura cosa fanno OGGI i due sleeve live
|
||||
(TP01 long-flat 75% + SKH01 long/short 25%) nei crolli — giorno per giorno e per episodio —
|
||||
e da dove viene, se c'e', il guadagno. La memoria dice tre cose che qui vengono RIPRODOTTE
|
||||
(M23: far riprodurre alla macchina il numero vecchio prima di pubblicarne uno nuovo):
|
||||
(a) beta del libro al sottostante +0,076 (§46) → "non si assicura un libro gia' piatto";
|
||||
(b) nel sinistro TP01 va flat e SKH01 si gira short e guadagna (§26);
|
||||
(c) il peggior giorno del libro: §33 lo trova in uno short squeeze di SKH01 (−15,77% di sleeve,
|
||||
2020-03-13) sulla lente HOURLY — su questa lente (canonica, sleeve giornalieri) NON si
|
||||
riproduce: §33 stesso nota che la canonica da' il 2025-10-10. Qui si stampa e si dichiara.
|
||||
|
||||
LENTE, dichiarata: sleeve di ricerca `sleeves._tp01_returns` / `_skyhook_returns` (ancora
|
||||
canonica 00:00 UTC, fee 0,10% RT, funding NON modellato, come ogni numero del progetto).
|
||||
⚠️ SKH01 e' MARCATO ALL'USCITA del trade (`harness.backtest_signals` muove l'equity alla
|
||||
chiusura): la serie giornaliera e' zero nell'87,7% dei giorni, e uno short aperto in una finestra
|
||||
e chiuso fuori e' accreditato FUORI. E' il motivo per cui la tabella per GIORNO mostra SKH01 ≈ 0
|
||||
e quella per FINESTRA mostra il guadagno. NON e' la serie del libro live (armato dal 2026-06-20): i crolli del 2026 (5 febbraio
|
||||
−14%/−15%, 1-5 giugno −25%/−30%) sono PRIMA dell'arming, quindi qui sono backtest, non fill.
|
||||
|
||||
DEFINIZIONI, scritte prima di guardare i numeri:
|
||||
- indice = 50/50 BTC/ETH, ribilanciato ogni giorno (la stessa geometria del libro);
|
||||
- giorno di crollo = indice ≤ −5% (severo: ≤ −10%);
|
||||
- episodio = le 12 peggiori finestre di 20 giorni NON sovrapposte dell'indice, piu' un elenco
|
||||
NOMINATO di episodi noti (COVID, maggio 2021, LUNA, FTX, agosto 2024, febbraio e giugno 2026);
|
||||
- classi ESCLUSIVE, in quest'ordine: "immune" = |libro| < 1/10 dell'indice (col segno stampato);
|
||||
altrimenti "POSITIVO" se libro > 0, "PERDE" se < 0. Si stampa anche il conteggio nudo libro > 0.
|
||||
(Prima stesura: POSITIVO provato prima di immune → 8/12; con l'ordine dichiarato qui 4/12 + 6
|
||||
immune. L'ordine delle regole ERA il verdetto: corretto in revisione, si stampano entrambi.)
|
||||
- beta al ribasso = pendenza OLS del libro sull'indice nei soli giorni con indice < 0.
|
||||
|
||||
PESI: 75/25 e' decisione vincolante (confermata 3 volte, §3). Il 37,5/62,5 di §26 compare qui
|
||||
SOLO come riga informativa negli stessi episodi — non e' una proposta: la riapre solo
|
||||
`weights_tilt_null`, che finora fallisce.
|
||||
|
||||
Nessun file di produzione toccato. Nessun ordine.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.backtest.harness import backtest_signals # noqa: E402
|
||||
from src.data.downloader import load_data # noqa: E402
|
||||
from src.live.book import W_SKH, W_TP01 # noqa: E402 (i pesi si IMPORTANO, non si ridichiarano: P1)
|
||||
from src.portfolio.sleeves import _skyhook_returns, _tp01_returns # noqa: E402
|
||||
from src.strategies.skyhook import SKH01_V2_DD, build_frames, skyhook_entries # noqa: E402
|
||||
from src.strategies.trend_portfolio import CANONICAL, TrendPortfolio, resample_1d # noqa: E402
|
||||
|
||||
ASSETS = ("BTC", "ETH")
|
||||
SOGLIA_CROLLO = -0.05
|
||||
SOGLIA_SEVERO = -0.10
|
||||
FINESTRA = 20
|
||||
N_EPISODI = 12
|
||||
BETA_MEMORIA = 0.076 # §46, da riprodurre SULLA SUA FINESTRA (DVOL: 2021-03-24+)
|
||||
FINESTRA_46 = pd.Timestamp("2021-03-24", tz="UTC")
|
||||
TOL_BETA = 0.01
|
||||
W_INFO = (0.375, 0.625) # §26: riga informativa, NON proposta
|
||||
|
||||
EPISODI_NOMINATI = {
|
||||
"COVID 2020-03": ("2020-03-01", "2020-03-20"),
|
||||
"maggio 2021": ("2021-05-08", "2021-05-24"),
|
||||
"LUNA 2022-05": ("2022-05-04", "2022-05-14"),
|
||||
"giugno 2022": ("2022-06-06", "2022-06-19"),
|
||||
"FTX 2022-11": ("2022-11-06", "2022-11-22"),
|
||||
"agosto 2024": ("2024-07-28", "2024-08-06"),
|
||||
"febbraio 2025": ("2025-01-31", "2025-03-11"),
|
||||
"5 febbraio 2026": ("2026-01-28", "2026-02-07"),
|
||||
"1-5 giugno 2026": ("2026-05-28", "2026-06-06"),
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ serie
|
||||
|
||||
def indice_5050() -> pd.Series:
|
||||
r = {}
|
||||
for a in ASSETS:
|
||||
d = resample_1d(load_data(a, "1h"))
|
||||
s = pd.Series(d["close"].values.astype(float), index=pd.to_datetime(d["datetime"], utc=True))
|
||||
r[a] = s.pct_change()
|
||||
J = pd.concat(r, axis=1, join="inner").dropna()
|
||||
return 0.5 * J["BTC"] + 0.5 * J["ETH"]
|
||||
|
||||
|
||||
def tp01_esposizione() -> pd.Series:
|
||||
"""Esposizione lorda media (BTC+ETH)/2 tenuta nel giorno (decisa a close[i-1])."""
|
||||
tp = TrendPortfolio(**CANONICAL)
|
||||
e = {}
|
||||
for a in ASSETS:
|
||||
d = resample_1d(load_data(a, "1h"))
|
||||
tgt = tp.target_series(d)
|
||||
held = np.zeros(len(tgt)); held[1:] = tgt[:-1]
|
||||
e[a] = pd.Series(held, index=pd.to_datetime(d["datetime"], utc=True))
|
||||
J = pd.concat(e, axis=1, join="inner")
|
||||
return 0.5 * J["BTC"] + 0.5 * J["ETH"]
|
||||
|
||||
|
||||
def skh_per_direzione() -> dict[str, pd.Series]:
|
||||
"""SKH01-V2-DD 50/50 su griglia giornaliera, in tre versioni: intera, SOLO long, SOLO short.
|
||||
|
||||
Le versioni per direzione filtrano gli entry-dict prima del backtest: stesso motore, stessi
|
||||
stop. In principio filtrare potrebbe cambiare il non-overlap; MISURATO (revisione 09/09):
|
||||
long + short == intera su 2735/2735 giorni (max|diff| 3,5e-18) — la scomposizione e' ESATTA,
|
||||
e `misura()` lo verifica con un assert (P1: la versione "tutto" e' confrontata bit-exact con
|
||||
`sleeves._skyhook_returns`).
|
||||
"""
|
||||
out = {k: {} for k in ("tutto", "long", "short")}
|
||||
for a in ASSETS:
|
||||
ltf, htf = build_frames(load_data(a, "5m"))
|
||||
ent = skyhook_entries(ltf, htf, SKH01_V2_DD)
|
||||
for k, keep in (("tutto", (1, -1)), ("long", (1,)), ("short", (-1,))):
|
||||
e2 = [x if (x is not None and x["dir"] in keep) else None for x in ent]
|
||||
m = backtest_signals(ltf, e2, fee_rt=0.001, leverage=1.0, asset=a, tf="230m")
|
||||
s = pd.Series(m.equity, index=pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True)))
|
||||
out[k][a] = s.resample("1D").last().ffill().pct_change().dropna()
|
||||
res = {}
|
||||
for k in out:
|
||||
J = pd.concat(out[k], axis=1, join="inner").fillna(0.0)
|
||||
res[k] = 0.5 * J["BTC"] + 0.5 * J["ETH"]
|
||||
return res
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ misure
|
||||
|
||||
def sh(s: pd.Series) -> float:
|
||||
s = s.dropna()
|
||||
return float(s.mean() / s.std() * np.sqrt(365.0)) if len(s) > 5 and s.std() > 0 else float("nan")
|
||||
|
||||
|
||||
def maxdd(s: pd.Series) -> float:
|
||||
eq = (1 + s.fillna(0)).cumprod(); return float((eq / eq.cummax() - 1).min())
|
||||
|
||||
|
||||
def cum(s: pd.Series) -> float:
|
||||
return float((1 + s.fillna(0)).prod() - 1)
|
||||
|
||||
|
||||
def finestre_peggiori(idx: pd.Series, n: int = N_EPISODI, w: int = FINESTRA) -> list[tuple]:
|
||||
"""Le n peggiori finestre di w giorni NON sovrapposte dell'indice (greedy dal peggiore)."""
|
||||
roll = (1 + idx).rolling(w).apply(np.prod, raw=True) - 1
|
||||
roll = roll.dropna().sort_values()
|
||||
prese, occupati = [], []
|
||||
for end, v in roll.items():
|
||||
start = end - pd.Timedelta(days=w - 1)
|
||||
if any(not (end < s or start > e) for s, e in occupati):
|
||||
continue
|
||||
prese.append((start, end, float(v))); occupati.append((start, end))
|
||||
if len(prese) >= n:
|
||||
break
|
||||
return sorted(prese)
|
||||
|
||||
|
||||
def classifica(libro: float, indice: float) -> str:
|
||||
"""Classi esclusive nell'ordine dichiarato: immune (col segno) → POSITIVO → PERDE."""
|
||||
if abs(libro) < abs(indice) / 10:
|
||||
return "immune+" if libro > 0 else "immune-"
|
||||
return "POSITIVO" if libro > 0 else "PERDE"
|
||||
|
||||
|
||||
def beta_ribasso(libro: pd.Series, idx: pd.Series, soglia: float = 0.0) -> float:
|
||||
J = pd.concat({"b": libro, "i": idx}, axis=1, join="inner").dropna()
|
||||
J = J[J["i"] < soglia]
|
||||
if len(J) < 20:
|
||||
return float("nan")
|
||||
x, y = J["i"].values, J["b"].values
|
||||
return float(np.cov(x, y)[0, 1] / np.var(x, ddof=1))
|
||||
|
||||
|
||||
def beta_pieno(libro: pd.Series, idx: pd.Series) -> float:
|
||||
J = pd.concat({"b": libro, "i": idx}, axis=1, join="inner").dropna()
|
||||
x, y = J["i"].values, J["b"].values
|
||||
return float(np.cov(x, y)[0, 1] / np.var(x, ddof=1))
|
||||
|
||||
|
||||
def misura() -> dict:
|
||||
idx = indice_5050()
|
||||
tp = _tp01_returns()
|
||||
if tp.index.tz is None:
|
||||
tp.index = tp.index.tz_localize("UTC")
|
||||
skh = skh_per_direzione()
|
||||
esp = tp01_esposizione()
|
||||
J = pd.concat({"idx": idx, "tp": tp, "skh": skh["tutto"], "skl": skh["long"],
|
||||
"sks": skh["short"], "esp": esp}, axis=1, join="inner").dropna()
|
||||
ref = _skyhook_returns()
|
||||
if ref.index.tz is None:
|
||||
ref.index = ref.index.tz_localize("UTC")
|
||||
comune = J.index.intersection(ref.index)
|
||||
assert float((J.loc[comune, "skh"] - ref.loc[comune]).abs().max()) == 0.0, "SKH01 'tutto' non e' bit-exact col sleeve"
|
||||
assert float((J["skl"] + J["sks"] - J["skh"]).abs().max()) < 1e-12, "long + short != intera: la scomposizione non e' esatta"
|
||||
J["libro"] = W_TP01 * J["tp"] + W_SKH * J["skh"]
|
||||
J["info"] = W_INFO[0] * J["tp"] + W_INFO[1] * J["skh"]
|
||||
J["c_tp"] = W_TP01 * J["tp"]
|
||||
J["c_skh"] = W_SKH * J["skh"]
|
||||
J["c_sks"] = W_SKH * J["sks"]
|
||||
|
||||
# giorni di crollo
|
||||
giorni = {}
|
||||
for nome, soglia in (("≤ −5%", SOGLIA_CROLLO), ("≤ −10%", SOGLIA_SEVERO)):
|
||||
g = J[J["idx"] <= soglia]
|
||||
giorni[nome] = dict(n=int(len(g)), idx=float(g["idx"].mean()), libro=float(g["libro"].mean()),
|
||||
tp=float(g["c_tp"].mean()), skh=float(g["c_skh"].mean()),
|
||||
sks=float(g["c_sks"].mean()), esp=float(g["esp"].mean()),
|
||||
libro_pos=float((g["libro"] > 0).mean()),
|
||||
per_anno={int(y): dict(n=int(len(gg)), idx=float(gg["idx"].sum()),
|
||||
libro=float(gg["libro"].sum()),
|
||||
sks=float(gg["c_sks"].sum()))
|
||||
for y, gg in g.groupby(g.index.year)})
|
||||
|
||||
# episodi: finestre peggiori + nominati
|
||||
def riga_ep(nome, s, e):
|
||||
w = J.loc[s:e]
|
||||
if len(w) < 3:
|
||||
return None
|
||||
return dict(nome=nome, da=str(w.index[0].date()), a=str(w.index[-1].date()), n=int(len(w)),
|
||||
idx=cum(w["idx"]), libro=cum(w["libro"]), tp=cum(w["c_tp"]), skh=cum(w["c_skh"]),
|
||||
sks=cum(w["c_sks"]), skl=cum(W_SKH * w["skl"]), esp=float(w["esp"].mean()),
|
||||
info=cum(w["info"]), classe=classifica(cum(w["libro"]), cum(w["idx"])))
|
||||
ep_auto = [r for r in (riga_ep(f"peggiore #{i+1}", s, e)
|
||||
for i, (s, e, _) in enumerate(finestre_peggiori(J["idx"]))) if r]
|
||||
ep_nom = [r for r in (riga_ep(k, pd.Timestamp(a, tz="UTC"), pd.Timestamp(b, tz="UTC"))
|
||||
for k, (a, b) in EPISODI_NOMINATI.items()) if r]
|
||||
|
||||
# peggior giorno del libro (riproduce §33: e' uno squeeze, non un crollo?)
|
||||
peggiori = J.nsmallest(5, "libro")[["idx", "libro", "c_tp", "c_skh"]]
|
||||
|
||||
n_auto = len(ep_auto)
|
||||
guad = sum(r["classe"] == "POSITIVO" for r in ep_auto)
|
||||
imm = sum(r["classe"].startswith("immune") for r in ep_auto)
|
||||
pos = sum(r["libro"] > 0 for r in ep_auto)
|
||||
dal22 = [r for r in ep_auto if r["da"] >= "2022"]
|
||||
J46 = J[J.index >= FINESTRA_46]
|
||||
quota_short = (sum(r["sks"] for r in ep_auto if r["libro"] > 0)
|
||||
/ max(1e-12, sum(r["libro"] for r in ep_auto if r["libro"] > 0)))
|
||||
return dict(
|
||||
finestra=(str(J.index[0].date()), str(J.index[-1].date()), int(len(J))),
|
||||
beta=beta_pieno(J["libro"], J["idx"]), beta_46=beta_pieno(J46["libro"], J46["idx"]),
|
||||
beta_giu=beta_ribasso(J["libro"], J["idx"]),
|
||||
beta_giu3=beta_ribasso(J["libro"], J["idx"], -0.03),
|
||||
sharpe=dict(libro=sh(J["libro"]), info=sh(J["info"]), tp=sh(J["tp"]), skh=sh(J["skh"]),
|
||||
sks=sh(J["sks"]), skl=sh(J["skl"])),
|
||||
maxdd=dict(libro=maxdd(J["libro"]), info=maxdd(J["info"]), idx=maxdd(J["idx"])),
|
||||
giorni=giorni, episodi=ep_auto, nominati=ep_nom,
|
||||
peggiori=[(str(t.date()), float(r.idx), float(r.libro), float(r.c_tp), float(r.c_skh))
|
||||
for t, r in peggiori.iterrows()],
|
||||
sks_anno={int(y): cum(g) for y, g in J["c_sks"].groupby(J.index.year)},
|
||||
sintesi=dict(n_episodi=n_auto, guadagna=guad, immune=imm, perde=n_auto - guad - imm, positivi=pos,
|
||||
dal22=(sum(r["libro"] > 0 for r in dal22), len(dal22)),
|
||||
giorni_pos=giorni["≤ −5%"]["libro_pos"], giorni_libro=giorni["≤ −5%"]["libro"],
|
||||
quota_short_nei_guadagni=float(quota_short),
|
||||
info_batte_live=sum(r["info"] > r["libro"] for r in ep_auto)),
|
||||
)
|
||||
|
||||
|
||||
def verdetto(m: dict) -> str:
|
||||
"""Tre affermazioni separate, perche' rispondono a tre domande diverse:
|
||||
(1) nel GIORNO del crollo (media sui giorni ≤ −5%); (2) per FINESTRA (classi esclusive);
|
||||
(3) da dove viene il guadagno. Un solo aggettivo ("guadagna"/"immune") sarebbe l'ordine delle regole."""
|
||||
s = m["sintesi"]
|
||||
beta_ok = abs(m["beta_46"] - BETA_MEMORIA) < TOL_BETA
|
||||
giorno = ("PERDE" if s["giorni_libro"] < 0 else "GUADAGNA")
|
||||
if s["guadagna"] >= 2 * s["n_episodi"] / 3:
|
||||
finestra = "POSITIVO per finestra"
|
||||
elif s["guadagna"] + s["immune"] >= 2 * s["n_episodi"] / 3:
|
||||
finestra = "IMMUNE-O-POSITIVO per finestra"
|
||||
else:
|
||||
finestra = "PERDE per finestra"
|
||||
return (f"NEL GIORNO DEL CROLLO IL LIBRO {giorno} ({s['giorni_libro']*100:+.2f}%/g, positivo nel {s['giorni_pos']*100:.0f}% dei giorni ≤ −5%); "
|
||||
f"{finestra}: {s['guadagna']} POSITIVO / {s['immune']} immune / {s['perde']} PERDE su {s['n_episodi']} (libro > 0 in {s['positivi']}, "
|
||||
f"dal 2022 in {s['dal22'][0]}/{s['dal22'][1]}); beta {m['beta_46']:+.4f} sulla finestra di §46 "
|
||||
f"({'riproduce' if beta_ok else 'NON riproduce'} il +{BETA_MEMORIA:.3f}); "
|
||||
f"la gamba short di SKH01 (marcata all'uscita) fa il {s['quota_short_nei_guadagni']*100:.0f}% dei guadagni negli episodi con libro > 0")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ stampa
|
||||
|
||||
def pct(x: float) -> str:
|
||||
return f"{x*100:+.2f}%"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("=" * 100)
|
||||
print(" r0909 — IL LIBRO NEI CROLLI (TP01 75 / SKH01 25, lente di ricerca, ancora canonica)")
|
||||
print("=" * 100)
|
||||
m = misura()
|
||||
a, b, n = m["finestra"]
|
||||
print(f" finestra {a} → {b} ({n} giorni) pesi importati da src/live/book: TP01 {W_TP01} / SKH01 {W_SKH}")
|
||||
|
||||
print("\n [1] BETA DEL LIBRO ALL'INDICE 50/50")
|
||||
print(f" pieno {m['beta']:+.3f} · sulla finestra di §46 (2021-03-24+) {m['beta_46']:+.4f} (memoria: +{BETA_MEMORIA:.3f}) "
|
||||
f"al ribasso (idx<0) {m['beta_giu']:+.3f} nei giorni ≤ −3% {m['beta_giu3']:+.3f}")
|
||||
|
||||
print("\n [2] GIORNI DI CROLLO — media per giorno")
|
||||
print(f" {'soglia':<8}{'n':>5}{'indice':>9}{'libro':>9}{'TP01':>9}{'SKH01':>9}{'SKH short':>11}{'esp TP01':>10}{'libro>0':>9}")
|
||||
for k, g in m["giorni"].items():
|
||||
print(f" {k:<8}{g['n']:>5}{pct(g['idx']):>9}{pct(g['libro']):>9}{pct(g['tp']):>9}"
|
||||
f"{pct(g['skh']):>9}{pct(g['sks']):>11}{g['esp']:>10.3f}{g['libro_pos']*100:>8.0f}%")
|
||||
print("\n per anno (giorni ≤ −5%, SOMMA nel gruppo):")
|
||||
print(f" {'anno':<6}{'n':>4}{'indice':>10}{'libro':>10}{'SKH short':>11}")
|
||||
for y, g in m["giorni"]["≤ −5%"]["per_anno"].items():
|
||||
print(f" {y:<6}{g['n']:>4}{pct(g['idx']):>10}{pct(g['libro']):>10}{pct(g['sks']):>11}")
|
||||
|
||||
def tab(rows, titolo):
|
||||
print(f"\n {titolo}")
|
||||
print(f" {'episodio':<18}{'da':>11}{'a':>11}{'indice':>9}{'libro':>9}{'TP01':>8}{'SKH01':>8}"
|
||||
f"{'SKH-S':>8}{'SKH-L':>8}{'esp':>6}{'37/63':>8} classe")
|
||||
for r in rows:
|
||||
print(f" {r['nome']:<18}{r['da']:>11}{r['a']:>11}{pct(r['idx']):>9}{pct(r['libro']):>9}"
|
||||
f"{pct(r['tp']):>8}{pct(r['skh']):>8}{pct(r['sks']):>8}{pct(r['skl']):>8}{r['esp']:>6.2f}"
|
||||
f"{pct(r['info']):>8} {r['classe']}")
|
||||
tab(m["episodi"], f"[3] LE {N_EPISODI} PEGGIORI FINESTRE DI {FINESTRA} GIORNI DELL'INDICE (non sovrapposte)")
|
||||
tab(m["nominati"], "[4] EPISODI NOMINATI (elenco scritto prima di misurare)")
|
||||
|
||||
print("\n [5] I 5 PEGGIORI GIORNI DEL LIBRO (§33 su lente hourly: squeeze SKH01 2020-03-13; qui lente canonica: NON si riproduce, e' un crollo con TP01 mezzo long)")
|
||||
print(f" {'giorno':<12}{'indice':>9}{'libro':>9}{'TP01':>9}{'SKH01':>9}")
|
||||
for t, i, l, tpv, sk in m["peggiori"]:
|
||||
print(f" {t:<12}{pct(i):>9}{pct(l):>9}{pct(tpv):>9}{pct(sk):>9}")
|
||||
|
||||
print("\n [6] LA GAMBA SHORT DI SKH01 (peso 0,25 gia' applicato) — contributo per anno al libro")
|
||||
print(" " + " ".join(f"{y} {pct(v)}" for y, v in m["sks_anno"].items()))
|
||||
s = m["sharpe"]; d = m["maxdd"]
|
||||
print(f"\n Sharpe: libro {s['libro']:.2f} · TP01 {s['tp']:.2f} · SKH01 {s['skh']:.2f} "
|
||||
f"(solo long {s['skl']:.2f} / solo short {s['sks']:.2f}) maxDD libro {d['libro']*100:.1f}% · indice {d['idx']*100:.1f}%")
|
||||
print(f" riga INFORMATIVA 37,5/62,5 (§26, NON proposta): Sharpe {s['info']:.2f}, maxDD {d['info']*100:.1f}%, "
|
||||
f"batte il 75/25 in {m['sintesi']['info_batte_live']}/{m['sintesi']['n_episodi']} episodi")
|
||||
|
||||
print("\n VERDETTO: " + verdetto(m))
|
||||
print("=" * 100)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,242 @@
|
||||
"""r0909 — XRP COME TERZA GAMBA DEL BOOK DERIBIT (meccanismi CONGELATI): la misura che manca.
|
||||
|
||||
PERCHE' ESISTE. L'operatore chiede "piu' monete, ma sempre in Deribit". Letto il venue OGGI
|
||||
(09/09/2026, `public/get_book_summary_by_currency`): dei 32 perpetual USDC-lineari, solo
|
||||
QUATTRO hanno volume a 8 cifre — BTC, ETH, **XRP ($38M/g, il PIU' scambiato del venue, spread
|
||||
4 bps, listato 2022-03-16)** e SOL ($13M/g). Tutto il resto sta fra $0,01M e $7M al giorno.
|
||||
SOL come terza gamba e' stato misurato il 22/08 e ESCLUSO dall'operatore (hold-out −0,166 in
|
||||
0/24 ancore, guadagno di UN anno, il 2023). XRP non e' mai stato misurato come gamba
|
||||
direzionale: e' l'unico candidato "piu' monete" del venue che sia liquido E non misurato.
|
||||
|
||||
IPOTESI A PRIORI, dichiarata prima di misurare: DILUISCE, come SOL — il trend multi-asset del
|
||||
19/06 fu scartato a corr 0,74 con TP01, e SOL ha confermato (corr 0,40-0,56, in salita col dato
|
||||
che migliora). Se XRP aggiunge, il primo sospetto e' l'anno del suo evento idiosincratico (la
|
||||
sentenza SEC dell'estate 2023 e la corsa di fine 2024): M9, "24/24 ancore positive possono
|
||||
essere un anno solo".
|
||||
|
||||
METODO — identico a `r0822_sol_leg.py`, da cui IMPORTA il harness (non riscritto): TP01 e
|
||||
SKH01-V2-DD congelati, book a 2 vs 3 gambe, 24 ancore orarie, mediana delle differenze
|
||||
APPAIATE (M7), null del de-levering a iso-maxDD (M5), scomposizione per anno (M9), due lenti
|
||||
DICHIARATE PRIMA dalla certificazione del dato, non dal risultato:
|
||||
- L-FULL: dal listing (2022-03-16);
|
||||
- L-PULITA: dal primo anno in cui la quota di barre 1h a >1% da Coinbase USD scende sotto
|
||||
lo 0,5% (SOL 2023 aveva 0,6% e fu giudicata sporca: e' il precedente che fissa la soglia).
|
||||
Il dato XRP vive in `data/raw/alt_xrp_*.parquet` (namespace di ricerca, come SOL: fuori dal
|
||||
feed attivo, NON rinfrescato dal cron, `load_data("XRP")` continua a fallire).
|
||||
|
||||
Nessun file di produzione toccato. Nessun ordine. Book/pesi/universo/cron INVARIATI.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||
|
||||
from r0822_sol_leg import ( # noqa: E402 — harness importato, non riscritto
|
||||
DUE, HOLDOUT, W_SKH, W_TP, book, cagr, dati, k_iso_dd, maxdd, sh, skh_leg, taglia, tp01_leg,
|
||||
)
|
||||
|
||||
ASSET = "XRP"
|
||||
TRE = ("BTC", "ETH", ASSET)
|
||||
LISTING = pd.Timestamp("2022-03-16", tz="UTC")
|
||||
SOGLIA_PULITA = 0.005 # quota di barre 1h a >1% da Coinbase: sotto = anno pulito
|
||||
CACHE = ROOT / "data" / "_cache" / "r0909_xrp_riferimento_1h.parquet"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ certificazione (D1: Coinbase = audit)
|
||||
|
||||
def _fetch_1h(ex, symbol: str, start: pd.Timestamp, limit: int) -> pd.Series:
|
||||
"""OHLCV 1h paginato in avanti; un batch vuoto NON ferma il ciclo (Coinbase ha un buco
|
||||
2021-01 → 2023-07 su XRP: delistato dopo la causa SEC, rilistato dopo la sentenza)."""
|
||||
out, since = [], int(start.timestamp() * 1000)
|
||||
end = int(time.time() * 1000)
|
||||
while since < end:
|
||||
r = ex.fetch_ohlcv(symbol, "1h", since=since, limit=limit)
|
||||
r = [x for x in r if x[0] >= since]
|
||||
if not r:
|
||||
since += limit * 3_600_000
|
||||
continue
|
||||
out.extend(r)
|
||||
nxt = r[-1][0] + 3_600_000
|
||||
since = nxt if nxt > since else since + limit * 3_600_000
|
||||
df = pd.DataFrame(out, columns=["ts", "o", "h", "l", "close", "v"]).drop_duplicates("ts")
|
||||
return pd.Series(df["close"].values.astype(float),
|
||||
index=pd.to_datetime(df["ts"], unit="ms", utc=True)).sort_index()
|
||||
|
||||
|
||||
def riferimento_1h(start: pd.Timestamp) -> pd.DataFrame:
|
||||
"""Riferimento indipendente in USD (D1: audit, mai ancora): Coinbase dove c'e' (dal rilisting
|
||||
2023-07), Bitstamp altrove — entrambi gia' usati dal progetto come venue di audit. Con cache."""
|
||||
if CACHE.exists():
|
||||
df = pd.read_parquet(CACHE); df.index = pd.to_datetime(df.index, utc=True)
|
||||
if len(df):
|
||||
return df
|
||||
import ccxt
|
||||
bs = _fetch_1h(ccxt.bitstamp({"enableRateLimit": True}), "XRP/USD", start, 1000)
|
||||
cb = _fetch_1h(ccxt.coinbase({"enableRateLimit": True}), "XRP/USD", start, 300)
|
||||
df = pd.concat({"bitstamp": bs, "coinbase": cb}, axis=1).sort_index()
|
||||
df["close"] = df["coinbase"].where(df["coinbase"].notna(), df["bitstamp"])
|
||||
df["fonte"] = np.where(df["coinbase"].notna(), "CB", np.where(df["bitstamp"].notna(), "BS", ""))
|
||||
CACHE.parent.mkdir(parents=True, exist_ok=True)
|
||||
df.to_parquet(CACHE)
|
||||
return df
|
||||
|
||||
|
||||
def certifica() -> dict:
|
||||
"""Per anno: quota di barre 1h a >1% da Coinbase, mediana in bps, quota di barre flat (1h e 5m)."""
|
||||
d1 = dati(ASSET, "1h")
|
||||
dz = pd.Series(d1["close"].values.astype(float), index=pd.to_datetime(d1["datetime"], utc=True))
|
||||
ref = riferimento_1h(LISTING)
|
||||
J = pd.concat({"d": dz, "c": ref["close"], "f": ref["fonte"]}, axis=1, join="inner").dropna(subset=["d", "c"])
|
||||
dev = (J["d"] - J["c"]).abs() / J["c"]
|
||||
d5 = dati(ASSET, "5m")
|
||||
f5 = pd.Series((d5["high"].values == d5["low"].values), index=pd.to_datetime(d5["datetime"], utc=True))
|
||||
f1 = pd.Series((d1["high"].values == d1["low"].values), index=dz.index)
|
||||
per_anno = {}
|
||||
for y in sorted(set(J.index.year)):
|
||||
m = dev[dev.index.year == y]
|
||||
fy = J["f"][J.index.year == y]
|
||||
per_anno[int(y)] = dict(n=int(len(m)), q_1pct=float((m > 0.01).mean()), med_bps=float(m.median() * 1e4),
|
||||
fonte=f"CB {float((fy == 'CB').mean())*100:.0f}% / BS {float((fy == 'BS').mean())*100:.0f}%",
|
||||
flat_1h=float(f1[f1.index.year == y].mean()), flat_5m=float(f5[f5.index.year == y].mean()))
|
||||
puliti = [y for y, r in per_anno.items() if r["q_1pct"] <= SOGLIA_PULITA]
|
||||
# L-PULITA = dal primo anno pulito in poi SOLO se anche i successivi restano puliti (un anno pulito
|
||||
# isolato non e' un regime); altrimenti dal primo della coda pulita finale.
|
||||
anni = sorted(per_anno)
|
||||
inizio = None
|
||||
for y in anni:
|
||||
if all(per_anno[z]["q_1pct"] <= SOGLIA_PULITA for z in anni if z >= y):
|
||||
inizio = y; break
|
||||
return dict(per_anno=per_anno, puliti=puliti,
|
||||
pulita_da=pd.Timestamp(f"{inizio}-01-01", tz="UTC") if inizio else None)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ misura (stesso schema di r0822_sol_leg)
|
||||
|
||||
def gamba(asset: str) -> pd.Series:
|
||||
return W_TP * tp01_leg(asset, 0).reindex(skh_leg(asset).index).fillna(0.0) + W_SKH * skh_leg(asset)
|
||||
|
||||
|
||||
def differenze(anchors, start) -> dict:
|
||||
d = {"sh": [], "hold": [], "dd": [], "cagr": []}
|
||||
for h in anchors:
|
||||
A, B = book(DUE, h), book(TRE, h)
|
||||
idx = A.index.intersection(B.index)
|
||||
A, B = taglia(A.loc[idx], start), taglia(B.loc[idx], start)
|
||||
if len(A) < 200:
|
||||
continue
|
||||
d["sh"].append(sh(B) - sh(A)); d["hold"].append(sh(taglia(B, HOLDOUT)) - sh(taglia(A, HOLDOUT)))
|
||||
d["dd"].append((maxdd(B) - maxdd(A)) * 100); d["cagr"].append((cagr(B) - cagr(A)) * 100)
|
||||
return {k: np.array(v, float) for k, v in d.items()}
|
||||
|
||||
|
||||
def misura(n_anc: int = 24) -> dict:
|
||||
anchors = list(range(0, 24, max(1, 24 // n_anc)))[:n_anc]
|
||||
cert = certifica()
|
||||
lenti = {"L-FULL": None}
|
||||
if cert["pulita_da"] is not None:
|
||||
lenti[f"L-PULITA ({cert['pulita_da'].year}+)"] = cert["pulita_da"]
|
||||
lenti["2024+ (lente di SOL)"] = pd.Timestamp("2024-01-01", tz="UTC")
|
||||
out = dict(cert=cert, lenti={}, gambe={}, anno={}, delev={}, corr={})
|
||||
for a in TRE:
|
||||
out["gambe"][f"TP01 {a}"] = tp01_leg(a, 0); out["gambe"][f"SKH01 {a}"] = skh_leg(a)
|
||||
g = gamba(ASSET); b2 = book(DUE, 0)
|
||||
com = g.index.intersection(b2.index)
|
||||
for nome, start in lenti.items():
|
||||
c = com if start is None else com[com >= start]
|
||||
out["corr"][nome] = float(np.corrcoef(g.loc[c], b2.loc[c])[0, 1]) if len(c) > 100 else np.nan
|
||||
out["lenti"][nome] = differenze(anchors, start)
|
||||
A, B = book(DUE, 0), book(TRE, 0); idx = A.index.intersection(B.index)
|
||||
A, B = taglia(A.loc[idx], start), taglia(B.loc[idx], start)
|
||||
k = k_iso_dd(A, B)
|
||||
out["delev"][nome] = dict(k=k, sh3=sh(B), sh2k=sh(k * A), cagr3=cagr(B), cagr2k=cagr(k * A))
|
||||
A0, B0 = book(DUE, 0), book(TRE, 0); i0 = A0.index.intersection(B0.index); A0, B0 = A0.loc[i0], B0.loc[i0]
|
||||
for y in sorted(set(A0.index.year)):
|
||||
a_, b_ = A0[A0.index.year == y], B0[B0.index.year == y]
|
||||
if len(a_) >= 60:
|
||||
out["anno"][int(y)] = dict(sh2=sh(a_), sh3=sh(b_), dsh=sh(b_) - sh(a_), dcagr=(cagr(b_) - cagr(a_)) * 100,
|
||||
sh_gamba=sh(g[g.index.year == y]))
|
||||
out["gamba_xrp"] = g
|
||||
out["n_anchors"] = len(anchors)
|
||||
return out
|
||||
|
||||
|
||||
def verdetto(m: dict) -> str:
|
||||
"""AGGIUNGE solo se, nella lente PULITA (o 2024+ se non c'e' una coda pulita): dSharpe hold-out
|
||||
mediano > 0 in ≥ 75% delle ancore, E il null del de-levering passa (Sh 3 gambe > Sh 2 gambe
|
||||
riscalate + 0,02), E il contributo per anno e' positivo in piu' anni di quanti sia negativo —
|
||||
contati sugli anni DENTRO la lente (derivati dal suo nome, non cablati: P1).
|
||||
Un campione vuoto e' "NON MISURABILE", non un verdetto (P5: "non vedo" non e' "va tutto bene")."""
|
||||
nomi = [n for n in m["lenti"] if n.startswith("L-PULITA")] or ["2024+ (lente di SOL)"]
|
||||
n = nomi[0]; d = m["lenti"][n]; dl = m["delev"][n]
|
||||
import re
|
||||
anno0 = int(re.search(r"(\d{4})\+", n).group(1)) # "L-PULITA (2024+)" / "2024+ (lente di SOL)"
|
||||
if not len(d["hold"]) or not np.isfinite(dl["sh3"]) or not np.isfinite(dl["sh2k"]):
|
||||
return f"XRP: NON MISURABILE — lente {n} senza ancore valide o de-levering non calcolabile"
|
||||
c_hold = np.median(d["hold"]) > 0 and np.mean(d["hold"] > 0) >= 0.75
|
||||
c_delev = dl["sh3"] > dl["sh2k"] + 0.02
|
||||
anni = [r["dsh"] for y, r in m["anno"].items() if y >= anno0]
|
||||
c_anni = sum(x > 0 for x in anni) > sum(x < 0 for x in anni)
|
||||
esito = "XRP AGGIUNGE" if (c_hold and c_delev and c_anni) else ("XRP DILUISCE" if (not c_hold and not c_delev) else "XRP: PARI / NON SELEZIONABILE")
|
||||
return (f"{esito} — lente {n}: dSharpe hold-out mediano {np.median(d['hold']):+.3f} "
|
||||
f"(>0 nel {np.mean(d['hold'] > 0) * 100:.0f}% di {len(d['hold'])} ancore); "
|
||||
f"de-levering: Sh 3 gambe {dl['sh3']:.2f} vs 2 gambe×{dl['k']:.3f} {dl['sh2k']:.2f}; "
|
||||
f"anni {anno0}+ con dSh>0: {sum(x > 0 for x in anni)}/{len(anni)}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
n_anc = int(sys.argv[sys.argv.index("--anchors") + 1]) if "--anchors" in sys.argv else 24
|
||||
print("=" * 104)
|
||||
print(" r0909 — XRP COME TERZA GAMBA DEL BOOK DERIBIT (meccanismi CONGELATI; harness di r0822_sol_leg)")
|
||||
print("=" * 104)
|
||||
print(" ipotesi a priori dichiarata: DILUISCE (come SOL; trend multi-asset 19/06 corr 0,74)")
|
||||
m = misura(n_anc)
|
||||
c = m["cert"]
|
||||
print(f"\n [0/5] CERTIFICAZIONE XRP vs Coinbase USD (1h) — soglia anno pulito: quota >1% ≤ {SOGLIA_PULITA*100:.1f}%")
|
||||
print(f" {'anno':<6}{'barre':>7}{'>1%':>8}{'med bps':>9}{'flat 1h':>9}{'flat 5m':>9} riferimento")
|
||||
for y, r in c["per_anno"].items():
|
||||
print(f" {y:<6}{r['n']:>7}{r['q_1pct']*100:>7.2f}%{r['med_bps']:>9.1f}{r['flat_1h']*100:>8.1f}%{r['flat_5m']*100:>8.1f}% {r['fonte']}")
|
||||
print(f" L-PULITA da: {c['pulita_da'].date() if c['pulita_da'] is not None else 'NESSUN anno pulito in coda'}")
|
||||
|
||||
print(f"\n [1/5] LE GAMBE DA SOLE (ancora canonica h=0)")
|
||||
print(f"\n {'gamba':<26}{'barre':>7}{'Sharpe':>9}{'maxDD':>10}{'CAGR':>9}{'Sh hold':>10}")
|
||||
for k, s in m["gambe"].items():
|
||||
print(f" {k:<26}{len(s):>7}{sh(s):>9.2f}{maxdd(s)*100:>9.1f}%{cagr(s)*100:>8.1f}%{sh(taglia(s, HOLDOUT)):>10.2f}")
|
||||
g = m["gamba_xrp"]
|
||||
print(f"\n [2/5] LA GAMBA XRP (75/25) — Sharpe {sh(g):.2f}, maxDD {maxdd(g)*100:.1f}%, CAGR {cagr(g)*100:.1f}%, hold-out {sh(taglia(g, HOLDOUT)):.2f}")
|
||||
for n, v in m["corr"].items():
|
||||
print(f" corr(gamba XRP, book BTC/ETH) {n}: {v:+.3f} (SOL: +0,40 / +0,56; soglia del 19/06: 0,74)")
|
||||
|
||||
print(f"\n [3/5] BOOK A 2 vs 3 GAMBE — differenze APPAIATE per ancora ({m['n_anchors']} ancore)")
|
||||
for n, d in m["lenti"].items():
|
||||
if not len(d["sh"]):
|
||||
print(f"\n {n}: campione insufficiente"); continue
|
||||
print(f"\n {n} ({len(d['sh'])} ancore)")
|
||||
for lab, v, u in (("dSharpe FULL", d["sh"], ""), ("dSharpe hold-out", d["hold"], ""), ("dMaxDD", d["dd"], " pp"), ("dCAGR", d["cagr"], " pp")):
|
||||
print(f" {lab:<18} mediana {np.median(v):+7.3f}{u} banda [{np.percentile(v, 10):+.3f}, {np.percentile(v, 90):+.3f}] >0 nel {np.mean(v > 0)*100:.0f}% delle ancore")
|
||||
|
||||
print(f"\n [3bis] PER ANNO (h=0): {'anno':<6}{'2g Sh':>7}{'3g Sh':>7}{'dSh':>7}{'dCAGR':>9}{'gamba XRP':>11}")
|
||||
for y, r in m["anno"].items():
|
||||
print(f" {'':<19}{y:<6}{r['sh2']:>7.2f}{r['sh3']:>7.2f}{r['dsh']:>+7.2f}{r['dcagr']:>+8.1f}pp{r['sh_gamba']:>+11.2f}")
|
||||
|
||||
print(f"\n [4/5] NULL DEL DE-LEVERING (iso-maxDD): {'lente':<24}{'k':>7}{'Sh 3g':>8}{'Sh 2g×k':>9}{'CAGR 3g':>9}{'CAGR 2g×k':>11}")
|
||||
for n, dl in m["delev"].items():
|
||||
v = "AGGIUNGE" if dl["sh3"] > dl["sh2k"] + 0.02 else ("de-levering" if dl["sh2k"] > dl["sh3"] + 0.02 else "pari")
|
||||
print(f" {'':<39}{n:<24}{dl['k']:>7.3f}{dl['sh3']:>8.2f}{dl['sh2k']:>9.2f}{dl['cagr3']*100:>8.1f}%{dl['cagr2k']*100:>10.1f}% {v}")
|
||||
|
||||
px = float(dati(ASSET, "1h")["close"].iloc[-1])
|
||||
print(f"\n [5/5] ESEGUIBILITA': XRP_USDC-PERPETUAL min 1 XRP = ${px:.2f} < pavimento min_order $5 — non e' il vincolo (come SOL)")
|
||||
print("\n VERDETTO: " + verdetto(m))
|
||||
print("=" * 104)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -205,7 +205,10 @@ def testata(voce: dict) -> str:
|
||||
r = [f"<b>📓 Giornale — {_b(voce['giorno'])}</b>"]
|
||||
r.append(" equity " + (f"<b>${eq:,.2f}</b>" if eq is not None else "n/d")
|
||||
+ ("" if dq is None else f" ({dq:+.2f} oggi)")
|
||||
+ ("" if cum is None else f" · dall'arming {cum:+.2f}"))
|
||||
+ ("" if cum is None else f" · dall'arming {cum:+.2f}"
|
||||
+ (f" (di cui {p['mov_arming']:+.2f} versati -> trading "
|
||||
f"{p['trading_da_arming']:+.2f})"
|
||||
if p.get("mov_arming") and p.get("trading_da_arming") is not None else "")))
|
||||
if "errore" not in lb and lb.get("asset"):
|
||||
pos = " · ".join(f"{a} ${v['posizione']:+,.0f}" for a, v in sorted(lb["asset"].items()))
|
||||
r.append(f" {pos} · leva {lb.get('leva_lorda') or 0:.2f}x")
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Argomenti da riga di comando degli script operativi: un flag ignoto NON e' l'azione di default.
|
||||
|
||||
PERCHE' ESISTE (2026-09-02). Nessuno degli script di `scripts/live/` usava argparse: leggevano
|
||||
`sys.argv` con `in` e `index`, quindi un flag sbagliato non era un errore — era il ramo `else`.
|
||||
Costo misurato lo stesso giorno, durante una revisione: `trades_db.py --help` non ha stampato
|
||||
l'uso, e' caduto in `sync()` e ha riscritto `meta.ultimo_sync`. Gli altri due sono peggio:
|
||||
`journal.py --help` avrebbe scritto la pagina di oggi e una riga nel DB, `analista.py --help`
|
||||
avrebbe speso una chiamata al modello e mandato un messaggio Telegram.
|
||||
|
||||
TRE REGOLE, e nient'altro:
|
||||
1. **Nessun argparse.** Gli script hanno flag posizionali-per-nome (`--report`, `--secco`) e
|
||||
flag con valore (`--giorno 2026-08-21`); argparse cambierebbe i messaggi d'errore, il
|
||||
codice d'uscita e il comportamento di `--help` di 18 script che il cron gia' chiama. Qui
|
||||
serve il contrario: **lasciare intatto cio' che gira** e rifiutare cio' che non e' previsto.
|
||||
2. **Il cron non deve cambiare.** I flag dichiarati sono quelli che gli script gia' accettano:
|
||||
`valida()` non ne aggiunge e non ne toglie (P15: una regola si prova contro il codice che
|
||||
dovrebbe eseguirla). L'unica novita' e' `--help`, che prima non esisteva.
|
||||
3. **Si esce PRIMA di qualunque effetto.** `valida()` va chiamata come prima istruzione del
|
||||
blocco `__main__`, prima di `connect()`, prima del sync, prima della rete.
|
||||
|
||||
Codici d'uscita: 0 per `--help` (l'utente ha chiesto l'uso), 2 per un flag ignoto (errore d'uso,
|
||||
distinto dal 1 con cui gli script segnalano un esito negativo del loro lavoro).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
|
||||
class UsoSbagliato(Exception):
|
||||
"""Flag non previsto da questo script. Portato con l'elenco di cio' che e' previsto (P4)."""
|
||||
|
||||
|
||||
def valida(nome: str, uso: str, *, flag: tuple[str, ...] = (), con_valore: tuple[str, ...] = (),
|
||||
argv: list[str] | None = None, esci: bool = True) -> list[str]:
|
||||
"""Controlla `argv` contro i flag dichiarati. Ritorna gli argomenti; solleva o esce se ignoti.
|
||||
|
||||
`flag` sono i booleani (`--quiet`), `con_valore` quelli che consumano l'argomento seguente
|
||||
(`--giorno 2026-08-21`). `--help`/`-h` stampano `uso` ed escono 0. Con `esci=False` non
|
||||
tocca `sys.exit` e solleva `UsoSbagliato`: e' la forma che i test usano.
|
||||
"""
|
||||
args = list(sys.argv[1:] if argv is None else argv)
|
||||
if "--help" in args or "-h" in args:
|
||||
if not esci:
|
||||
return args
|
||||
print(uso.strip())
|
||||
raise SystemExit(0)
|
||||
noti, ignoti, attesa_valore = set(flag) | set(con_valore), [], False
|
||||
for a in args:
|
||||
if attesa_valore: # e' il valore di un flag noto, non un flag
|
||||
attesa_valore = False
|
||||
continue
|
||||
if not a.startswith("-"):
|
||||
ignoti.append(a); continue # posizionale: nessuno di questi script ne ha
|
||||
if a not in noti:
|
||||
ignoti.append(a); continue
|
||||
attesa_valore = a in con_valore
|
||||
if attesa_valore:
|
||||
ignoti.append(f"{args[-1]} (manca il valore)")
|
||||
if ignoti:
|
||||
msg = (f"{nome}: argomento non previsto: {', '.join(ignoti)}\n"
|
||||
f" previsti: {' '.join(sorted(noti)) or '(nessuno)'}\n\n{uso.strip()}")
|
||||
if not esci:
|
||||
raise UsoSbagliato(msg)
|
||||
print(msg, file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
return args
|
||||
+157
-30
@@ -25,6 +25,12 @@ from src.live import tradesdb as T
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
JOURNAL_DIR = PROJECT_ROOT / "docs" / "journal"
|
||||
# Movimenti di capitale DICHIARATI dall'operatore (append-only, dentro il perimetro di backup —
|
||||
# P11). Servono per i movimenti che il rilevatore non puo' vedere: sotto `EQUITY_JUMP_ALERT`
|
||||
# (il versamento di prova da ~$25 del 2026-08-25 08:00Z, +3,3% su $647, restato nel P&L per
|
||||
# 12 giorni) o dentro un'ora di mercato non misurabile. Una riga per movimento:
|
||||
# {"ts_utc": "...", "delta_usd": 24.9, "banda_usd": 0.5, "nota": "...", "dichiarato_il": "..."}
|
||||
MOVIMENTI_DICHIARATI = PROJECT_ROOT / "data" / "live" / "movimenti_dichiarati.jsonl"
|
||||
ASSETS = ("BTC", "ETH")
|
||||
# Gli orizzonti del segnale TP01 in produzione (trend_portfolio: TSMOM 30/90/180 giorni).
|
||||
ORIZZONTI = (30, 90, 180)
|
||||
@@ -118,7 +124,34 @@ def _px_1h(asset: str) -> pd.Series | None:
|
||||
return None
|
||||
|
||||
|
||||
def movimenti_capitale(con, fino_ts: str) -> dict:
|
||||
def movimenti_dichiarati(path: Path | None = None) -> tuple[list[dict], list[str]]:
|
||||
"""Righe di `MOVIMENTI_DICHIARATI` -> (movimenti validi, errori di riga). Mai solleva.
|
||||
Una riga rotta non azzera le altre e NON sparisce: torna in `errori` (P3 — si registra nel
|
||||
punto in cui si ingoia)."""
|
||||
path = MOVIMENTI_DICHIARATI if path is None else path
|
||||
if not path.exists():
|
||||
return [], []
|
||||
validi, errori = [], []
|
||||
for n, riga in enumerate(path.read_text().splitlines(), 1):
|
||||
if not riga.strip():
|
||||
continue
|
||||
try:
|
||||
d = json.loads(riga)
|
||||
ts = pd.Timestamp(d["ts_utc"])
|
||||
if ts.tzinfo is None:
|
||||
raise ValueError("ts_utc senza fuso")
|
||||
delta = float(d["delta_usd"])
|
||||
if delta == 0:
|
||||
raise ValueError("delta_usd zero")
|
||||
validi.append(dict(ts=ts, delta=delta, banda=float(d.get("banda_usd") or 0.0),
|
||||
nota=str(d.get("nota") or ""), dichiarato_il=d.get("dichiarato_il")))
|
||||
except Exception as e: # noqa: BLE001 — la riga rotta si riporta
|
||||
errori.append(f"{path.name}:{n}: {type(e).__name__}: {e}")
|
||||
validi.sort(key=lambda d: d["ts"])
|
||||
return validi, errori
|
||||
|
||||
|
||||
def movimenti_capitale(con, fino_ts: str, rows=None) -> dict:
|
||||
"""Salti di equity fra letture consecutive: versamenti e prelievi, da scorporare dal P&L.
|
||||
|
||||
PERCHE' ESISTE. Il P&L di giornale e' un delta di equity, quindi un versamento ci finisce
|
||||
@@ -137,45 +170,101 @@ def movimenti_capitale(con, fino_ts: str) -> dict:
|
||||
|
||||
⚠️ LIMITE DICHIARATO (D5): un movimento sotto soglia (es. $150 su un conto da $2.000)
|
||||
non si distingue dal mercato e RESTA nel P&L. E' lo stesso limite del rilevatore live.
|
||||
Il rimedio NON e' abbassare la soglia (ogni ora di mercato diventerebbe un candidato): e'
|
||||
la DICHIARAZIONE dell'operatore in `MOVIMENTI_DICHIARATI` — l'unica fonte che sa l'importo.
|
||||
Un movimento dichiarato e' `movimento` con `fonte="dichiarato"`, l'importo e' quello
|
||||
dichiarato (non il salto di equity), e `dopo` = `prima` + importo: il P&L di mercato di
|
||||
quell'ora resta nel rendimento invece di uscire con il movimento. Se coincide con un salto
|
||||
rilevato, l'importo dichiarato VINCE sul salto (fonte "dichiarato+rilevato"); un movimento
|
||||
dichiarato fuori dalle letture, o dopo `fino_ts`, non entra e finisce in `avvisi`.
|
||||
"""
|
||||
from src.live import book as _book # lazy: prende la soglia viva, anche monkeypatchata
|
||||
rows = con.execute("SELECT ts_utc, equity FROM equity WHERE ts_utc <= ? ORDER BY ts_utc",
|
||||
(fino_ts,)).fetchall()
|
||||
# `rows` si puo' passare gia' letto: due SELECT sulla stessa tabella a un'ora del cron
|
||||
# possono cadere ai due lati di una scrittura e descrivere due istanti diversi.
|
||||
if rows is None:
|
||||
rows = con.execute("SELECT ts_utc, equity FROM equity WHERE ts_utc <= ? ORDER BY ts_utc",
|
||||
(fino_ts,)).fetchall()
|
||||
soglia = float(_book.EQUITY_JUMP_ALERT)
|
||||
candidati = [(a, b) for a, b in zip(rows, rows[1:])
|
||||
if a["equity"] and abs(b["equity"] - a["equity"]) / a["equity"] >= soglia]
|
||||
if not candidati:
|
||||
return dict(eventi=[], certi=0.0, ambigui=0.0, soglia=soglia)
|
||||
dichiarati, avvisi = movimenti_dichiarati()
|
||||
if not candidati and not dichiarati:
|
||||
return dict(eventi=[], certi=0.0, ambigui=0.0, soglia=soglia, avvisi=avvisi)
|
||||
try:
|
||||
cfg = json.loads((PROJECT_ROOT / "config" / "live.json").read_text())
|
||||
leva_tetto = float(cfg["max_notional_per_asset_frac"]) * len(ASSETS)
|
||||
# la leva lorda massima che il libro puo' tenere: frac x n_asset x scala, mai oltre il
|
||||
# tetto di CODICE (P1: derivata da `book`, non ridichiarata — la chiave di scala del
|
||||
# 01/09 entra qui da sola)
|
||||
scala = float(cfg.get("book_scale_k") or 1.0)
|
||||
leva_tetto = min(float(cfg["max_notional_per_asset_frac"]) * len(ASSETS) * scala,
|
||||
float(_book.LEVA_LORDA_MAX))
|
||||
except Exception:
|
||||
leva_tetto = 1.0 # il tetto di progetto: bound conservativo, usato solo qui
|
||||
px = {a: _px_1h(a) for a in ASSETS}
|
||||
leva_tetto = float(_book.LEVA_LORDA_MAX) # config illeggibile: il tetto di codice
|
||||
px = {a: _px_1h(a) for a in ASSETS} if candidati else {}
|
||||
eventi = []
|
||||
for prev, cur in candidati:
|
||||
t0, t1 = pd.Timestamp(prev["ts_utc"]), pd.Timestamp(cur["ts_utc"])
|
||||
delta = float(cur["equity"] - prev["equity"])
|
||||
pct = delta / prev["equity"]
|
||||
mosse = []
|
||||
# Il mercato e' MISURABILE solo se il feed copre entrambe le letture. Il feed 1h
|
||||
# certificato si ferma alle 00:00 (rebuild alle 00:30): per il resto del giorno
|
||||
# `asof` restituirebbe la STESSA barra per t0 e t1, max_mkt 0, e qualunque calo >=10%
|
||||
# diventerebbe un "movimento" — un crash stampato come prelievo (revisione 02/09).
|
||||
# Non misurabile => `ambiguo` con la ragione (P5: "non vedo" non e' "mercato fermo").
|
||||
mosse, non_mis = [], []
|
||||
for a in ASSETS:
|
||||
s = px.get(a)
|
||||
if s is None or s.empty:
|
||||
continue
|
||||
non_mis.append(f"{a}: feed 1h non leggibile"); continue
|
||||
fine = s.index[-1] + pd.Timedelta(hours=1) # l'ultima barra copre un'ora
|
||||
if t0 < s.index[0] or t1 > fine:
|
||||
non_mis.append(f"{a}: feed 1h fermo a {s.index[-1]:%Y-%m-%dT%H:%M}Z"); continue
|
||||
p0, p1 = s.asof(t0), s.asof(t1)
|
||||
if p0 and p1 and not (pd.isna(p0) or pd.isna(p1)):
|
||||
mosse.append(abs(float(p1) / float(p0) - 1.0))
|
||||
max_mkt = max(mosse) * leva_tetto if mosse else None
|
||||
classe = "movimento" if (max_mkt is not None and abs(pct) > 2.0 * max_mkt) else "ambiguo"
|
||||
if pd.isna(p0) or pd.isna(p1) or not p0 or not p1:
|
||||
non_mis.append(f"{a}: barra assente"); continue
|
||||
mosse.append(abs(float(p1) / float(p0) - 1.0))
|
||||
if non_mis:
|
||||
max_mkt, classe, nota = None, "ambiguo", "mercato non misurabile: " + "; ".join(non_mis)
|
||||
else:
|
||||
max_mkt = max(mosse) * leva_tetto
|
||||
classe = "movimento" if abs(pct) > 2.0 * max_mkt else "ambiguo"
|
||||
nota = None
|
||||
eventi.append(dict(ts_prima=prev["ts_utc"], ts_dopo=cur["ts_utc"],
|
||||
prima=float(prev["equity"]), dopo=float(cur["equity"]),
|
||||
delta=delta, pct=float(pct),
|
||||
mercato_max_pct=(None if max_mkt is None else float(max_mkt)),
|
||||
classe=classe))
|
||||
mercato_nota=nota, classe=classe, fonte="rilevato"))
|
||||
# --- dichiarati dall'operatore: l'importo lo sa solo lui ------------------------------
|
||||
ts_rows = [pd.Timestamp(r["ts_utc"]) for r in rows]
|
||||
for d in dichiarati:
|
||||
if not rows or d["ts"] <= ts_rows[0] or d["ts"] > ts_rows[-1]:
|
||||
avvisi.append(f"movimento dichiarato {d['ts']:%Y-%m-%dT%H:%M}Z ${d['delta']:+,.2f} "
|
||||
f"fuori dalle letture [{rows[0]['ts_utc'][:16] if rows else '-'} .. "
|
||||
f"{rows[-1]['ts_utc'][:16] if rows else '-'}]: non applicato")
|
||||
continue
|
||||
i = next(k for k in range(1, len(rows)) if ts_rows[k - 1] < d["ts"] <= ts_rows[k])
|
||||
prev, cur = rows[i - 1], rows[i]
|
||||
nota_d = (f"dichiarato dall'operatore"
|
||||
+ (f" il {d['dichiarato_il']}" if d.get("dichiarato_il") else "")
|
||||
+ (f": {d['nota']}" if d["nota"] else "")
|
||||
+ (f" (banda ±${d['banda']:,.2f})" if d["banda"] else ""))
|
||||
gia = next((e for e in eventi if e["ts_dopo"] == cur["ts_utc"]), None)
|
||||
if gia is not None: # rilevato E dichiarato: l'importo e' quello dichiarato
|
||||
gia.update(delta=d["delta"], pct=d["delta"] / float(prev["equity"]) if prev["equity"] else None,
|
||||
classe="movimento", fonte="dichiarato+rilevato",
|
||||
dopo=float(prev["equity"]) + d["delta"], dichiarazione=nota_d)
|
||||
continue
|
||||
eventi.append(dict(ts_prima=prev["ts_utc"], ts_dopo=cur["ts_utc"],
|
||||
prima=float(prev["equity"]), dopo=float(prev["equity"]) + d["delta"],
|
||||
delta=d["delta"],
|
||||
pct=(d["delta"] / float(prev["equity"]) if prev["equity"] else None),
|
||||
mercato_max_pct=None, mercato_nota=None, classe="movimento",
|
||||
fonte="dichiarato", dichiarazione=nota_d))
|
||||
eventi.sort(key=lambda e: e["ts_dopo"])
|
||||
return dict(eventi=eventi,
|
||||
certi=float(sum(e["delta"] for e in eventi if e["classe"] == "movimento")),
|
||||
ambigui=float(sum(e["delta"] for e in eventi if e["classe"] == "ambiguo")),
|
||||
soglia=soglia, leva_tetto=leva_tetto)
|
||||
soglia=soglia, leva_tetto=leva_tetto, avvisi=avvisi)
|
||||
|
||||
|
||||
def rendimento_twr(con, fino_ts: str) -> dict:
|
||||
@@ -191,35 +280,61 @@ def rendimento_twr(con, fino_ts: str) -> dict:
|
||||
apre uno all'equity DOPO; il TWR e' il prodotto dei rendimenti di segmento, meno uno. Gli
|
||||
eventi `ambiguo` NON spezzano niente (P12: restano nel rendimento, dichiarati accanto).
|
||||
|
||||
TRE STATI: `twr` e' un numero, oppure None con `motivo` (serie vuota, base zero).
|
||||
⚠️ LIMITE DICHIARATO (D5). L'intervallo che CONTIENE un movimento certo esce INTERO dal
|
||||
rendimento: il suo P&L di mercato (marcatura delle posizioni aperte, funding; in un buco di
|
||||
lettura — fino a 10h nella serie vera — l'intero buco) finisce in `certi`, quindi anche fuori
|
||||
da `trading`. E' limitato per costruzione dal margine del classificatore (|salto| >
|
||||
2·max_mkt·leva·equity), percio' l'errore massimo vale max_mkt·leva·equity: fino a META' del
|
||||
movimento riconosciuto. Il 25/08: libro a ~$263 lordi, mercato a ±0,2% -> ~$0,5 su $1.399,39.
|
||||
Non si stima e non si corregge (P12): si dichiara. Un intervallo di sola marcatura fra due
|
||||
movimenti consecutivi, o prima del primo, ha lunghezza zero e NON produce un segmento.
|
||||
|
||||
TRE STATI: `twr` e `trading` sono numeri, oppure None con `motivo`. Mercato NON misurabile
|
||||
per almeno un salto (feed 1h fermo, o assente) => entrambi None: non si conosce la classe
|
||||
del salto, quindi non si conosce il rendimento — e `e1/e0-1` non torna sotto un'etichetta
|
||||
piu' autorevole. Base di equity zero =>
|
||||
entrambi None: il salto 0 -> X del primo versamento e' INVISIBILE a `movimenti_capitale`
|
||||
(una base zero non ha percentuale), quindi `certi` lo perderebbe e `trading` varrebbe
|
||||
l'intero conto — un numero etichettato "al netto dei versamenti" che li contiene tutti.
|
||||
"""
|
||||
rows = con.execute("SELECT ts_utc, equity FROM equity WHERE ts_utc <= ? ORDER BY ts_utc",
|
||||
(fino_ts,)).fetchall()
|
||||
if not rows:
|
||||
return dict(twr=None, motivo="nessuna lettura di equity", segmenti=[], eventi=[],
|
||||
certi=0.0, ambigui=0.0, delta_equity=None, trading=None, letture=0)
|
||||
mv = movimenti_capitale(con, fino_ts)
|
||||
certi = [e for e in mv["eventi"] if e["classe"] == "movimento"]
|
||||
mv = movimenti_capitale(con, fino_ts, rows=rows) # stessa lettura, un solo istante
|
||||
e0, e1 = float(rows[0]["equity"]), float(rows[-1]["equity"])
|
||||
if e0 <= 0:
|
||||
return dict(twr=None, trading=None,
|
||||
motivo="base di equity nulla: il primo versamento non e' classificabile",
|
||||
segmenti=[], eventi=mv["eventi"], certi=mv["certi"], ambigui=mv["ambigui"],
|
||||
delta_equity=e1 - e0, letture=len(rows))
|
||||
non_mis = [e for e in mv["eventi"] if e.get("mercato_nota") and e["classe"] != "movimento"]
|
||||
if non_mis:
|
||||
# un salto oltre soglia che NON si e' potuto confrontare col mercato non e' ne' un
|
||||
# movimento ne' un rendimento: il TWR non si stampa (altrimenti `e1/e0-1` tornerebbe
|
||||
# sotto l'etichetta "TWR", il numero che il debito 14 e' nato per uccidere)
|
||||
return dict(twr=None, trading=None,
|
||||
motivo=(f"{len(non_mis)} salto/i non classificabile/i — "
|
||||
+ non_mis[0]["mercato_nota"]),
|
||||
segmenti=[], eventi=mv["eventi"], certi=mv["certi"], ambigui=mv["ambigui"],
|
||||
delta_equity=e1 - e0, letture=len(rows))
|
||||
certi = [e for e in mv["eventi"] if e["classe"] == "movimento"]
|
||||
segmenti, da_ts, da_eq = [], rows[0]["ts_utc"], e0
|
||||
for ev in certi: # gia' in ordine di tempo
|
||||
if da_eq:
|
||||
if da_eq and ev["ts_prima"] != da_ts: # lunghezza zero = niente segmento
|
||||
segmenti.append(dict(da=da_ts, a=ev["ts_prima"], e_da=da_eq, e_a=ev["prima"],
|
||||
ret=ev["prima"] / da_eq - 1.0))
|
||||
da_ts, da_eq = ev["ts_dopo"], ev["dopo"]
|
||||
if da_eq:
|
||||
if da_eq and rows[-1]["ts_utc"] != da_ts:
|
||||
segmenti.append(dict(da=da_ts, a=rows[-1]["ts_utc"], e_da=da_eq, e_a=e1,
|
||||
ret=e1 / da_eq - 1.0))
|
||||
if not segmenti or e0 <= 0:
|
||||
return dict(twr=None, motivo="base di equity nulla", segmenti=segmenti, eventi=mv["eventi"],
|
||||
certi=mv["certi"], ambigui=mv["ambigui"], delta_equity=e1 - e0,
|
||||
trading=e1 - e0 - mv["certi"], letture=len(rows))
|
||||
twr = 1.0
|
||||
for sg in segmenti:
|
||||
for sg in segmenti: # nessun segmento = nessun tempo a mercato = 0
|
||||
twr *= 1.0 + sg["ret"]
|
||||
return dict(twr=twr - 1.0, motivo=None, segmenti=segmenti, eventi=mv["eventi"],
|
||||
certi=mv["certi"], ambigui=mv["ambigui"], delta_equity=e1 - e0,
|
||||
trading=e1 - e0 - mv["certi"], letture=len(rows))
|
||||
trading=e1 - e0 - mv["certi"], letture=len(rows), avvisi=mv.get("avvisi") or [])
|
||||
|
||||
|
||||
def pnl_giorno(con, giorno: date) -> dict:
|
||||
@@ -240,7 +355,10 @@ def pnl_giorno(con, giorno: date) -> dict:
|
||||
e_arm = con.execute("SELECT equity FROM equity ORDER BY ts_utc LIMIT 1").fetchone()
|
||||
delta = (e_fin - e_ini) if (e_ini is not None and e_fin is not None) else None
|
||||
cum = (e_fin - e_arm["equity"]) if (e_fin is not None and e_arm) else None
|
||||
mv = movimenti_capitale(con, f"{g}T23:59:59+00:00")
|
||||
# il cumulato dall'arming, i movimenti e il trading al netto vengono da rendimento_twr:
|
||||
# UNA funzione per i due lettori (report e giornale), non due aritmetiche (revisione 02/09)
|
||||
r_twr = rendimento_twr(con, f"{g}T23:59:59+00:00")
|
||||
mv = dict(eventi=r_twr["eventi"], certi=r_twr["certi"], ambigui=r_twr["ambigui"])
|
||||
ev_g = [e for e in mv["eventi"] if e["ts_dopo"][:10] == g]
|
||||
mov_g = sum(e["delta"] for e in ev_g if e["classe"] == "movimento")
|
||||
amb_g = sum(e["delta"] for e in ev_g if e["classe"] == "ambiguo")
|
||||
@@ -254,7 +372,8 @@ def pnl_giorno(con, giorno: date) -> dict:
|
||||
movimenti_giorno=ev_g, mov_giorno=float(mov_g), mov_giorno_ambiguo=float(amb_g),
|
||||
trading_giorno=(delta - mov_g) if delta is not None else None,
|
||||
movimenti_arming=mv["eventi"], mov_arming=mv["certi"], mov_arming_ambiguo=mv["ambigui"],
|
||||
trading_da_arming=(cum - mv["certi"]) if cum is not None else None)
|
||||
trading_da_arming=(r_twr["trading"] if cum is not None else None),
|
||||
twr_da_arming=(r_twr["twr"] if cum is not None else None), twr_motivo=r_twr["motivo"])
|
||||
|
||||
|
||||
def salute(con, giorno: date) -> dict:
|
||||
@@ -362,7 +481,11 @@ def lettura(con, voce: dict) -> list[dict]:
|
||||
# --- movimenti di capitale: prima del P&L, perche' lo ridefiniscono ----------------------
|
||||
for e in p.get("movimenti_giorno") or []:
|
||||
h0, h1 = e["ts_prima"][11:16], e["ts_dopo"][11:16]
|
||||
if e["classe"] == "movimento":
|
||||
if e["classe"] == "movimento" and e.get("fonte", "rilevato") != "rilevato":
|
||||
dire("movimento", f"**Movimento di capitale ${e['delta']:+,.2f}** fra le {h0} e le "
|
||||
f"{h1} UTC, {e.get('dichiarazione') or 'dichiarato'}. "
|
||||
"Scorporato dal P&L di trading qui sotto.", "nota")
|
||||
elif e["classe"] == "movimento":
|
||||
dire("movimento", f"**Movimento di capitale ${e['delta']:+,.2f}** fra le {h0} e le "
|
||||
f"{h1} UTC: salto {100*e['pct']:+.1f}% contro un massimo spiegabile "
|
||||
f"dal mercato di ±{100*e['mercato_max_pct']:.1f}% al tetto di leva. "
|
||||
@@ -626,6 +749,10 @@ def rendi_markdown(con, voce: dict) -> str:
|
||||
if p.get("mov_arming"):
|
||||
riga_c += (f" — di cui ${p['mov_arming']:+,.2f} versati/prelevati -> trading "
|
||||
f"**{_n(p.get('trading_da_arming'),'${:+,.2f}')}**")
|
||||
if p.get("twr_da_arming") is not None:
|
||||
riga_c += f" · TWR **{100 * p['twr_da_arming']:+.2f}%**"
|
||||
elif p.get("twr_motivo"):
|
||||
riga_c += f" · TWR n/d ({p['twr_motivo']})"
|
||||
if p.get("mov_arming_ambiguo"):
|
||||
riga_c += f" — ⚠️ ${p['mov_arming_ambiguo']:+,.2f} ambigui non attribuiti"
|
||||
L += ["", "## P&L", "",
|
||||
|
||||
@@ -122,6 +122,11 @@ def _isola_file_operativi_vivi(tmp_path, monkeypatch):
|
||||
# non deve poter sporcare il working tree (P16: il cron gira dal working tree).
|
||||
import src.live.journal as journal
|
||||
monkeypatch.setattr(journal, "JOURNAL_DIR", tmp_path / "journal")
|
||||
# --- data/live/movimenti_dichiarati.jsonl (2026-09-06) ------------------------------------
|
||||
# `journal.movimenti_capitale` LEGGE i movimenti dichiarati dall'operatore: se un test
|
||||
# vedesse quelli veri, un versamento reale entrerebbe in una serie di fixture e il test
|
||||
# passerebbe o fallirebbe per un motivo che non e' suo. Qui il file non esiste.
|
||||
monkeypatch.setattr(journal, "MOVIMENTI_DICHIARATI", tmp_path / "movimenti_dichiarati.jsonl")
|
||||
yield
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Utilita' condivise dei test. `tests/` e' su sys.path (c'e' un conftest), quindi si importa
|
||||
con `from helpers import carica_script`.
|
||||
|
||||
PERCHE' ESISTE (2026-09-02). `scripts/` non e' un package importabile per nome, quindi ogni test
|
||||
che tocca uno script si caricava il modulo a mano con `importlib.util.spec_from_file_location`:
|
||||
**15 copie della stessa dozzina di righe**, con nomi di modulo diversi per lo stesso file e
|
||||
differenze silenziose (chi registra in `sys.modules` e chi no — e chi non lo fa rompe `pickle`,
|
||||
`dataclasses` e i decoratori che risolvono per nome). Una funzione sola, con la registrazione
|
||||
fatta sempre.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def carica_script(rel: str, nome: str | None = None):
|
||||
"""Carica per PERCORSO uno script del repo (es. "scripts/live/trades_db.py").
|
||||
|
||||
`nome` e' il nome sotto cui il modulo viene registrato in `sys.modules`; il default e' il
|
||||
nome del file. Registrare PRIMA di eseguire e' voluto: un modulo che si auto-importa o usa
|
||||
`dataclasses` durante l'esecuzione deve trovarsi.
|
||||
"""
|
||||
p = (ROOT / rel).resolve()
|
||||
assert p.exists(), f"script inesistente: {rel}"
|
||||
nome = nome or p.stem
|
||||
spec = importlib.util.spec_from_file_location(nome, p)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[nome] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
@@ -0,0 +1,207 @@
|
||||
"""La cadenza del libro live e' ORARIA, e le tre dichiarazioni devono restare d'accordo.
|
||||
|
||||
Debito §5.7 (chiuso il 2026-09-02): il docstring di `book_execute.py` prescriveva «ogni ~230
|
||||
minuti» mentre il cron gira ogni ora. Non era cosmetico: `r0823_sl_anchor.py` misura che gli
|
||||
scatti del disaster-SL rotolante dipendono dalla cadenza — 1h: BTC 0 / ETH 1 in 7-8 anni; 4h:
|
||||
BTC 2 (contro 1 del pavimento); 24h: ETH 5 con 2 in 30 giorni. Chi avesse "corretto" il CRON
|
||||
verso il docstring avrebbe spostato il libro dalla riga 1h a quella 4h.
|
||||
|
||||
P1: il bersaglio si DERIVA da cio' che gira. Qui le fonti sono tre — il docstring (cio' che un
|
||||
umano legge), `cron_book.sh` (cio' che dichiara di essere installato) e la crontab (cio' che
|
||||
gira davvero) — e il test le confronta fra loro invece di ridichiarare «60 minuti».
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.live import venue_probe as VP # noqa: E402 lo slot di release, dichiarato li'
|
||||
from src.strategies.skyhook import LTF_MIN # noqa: E402 la griglia di SKH01, NON ridichiarata
|
||||
|
||||
BOOK_EXECUTE = ROOT / "scripts" / "live" / "book_execute.py"
|
||||
CRON_BOOK = ROOT / "scripts" / "cron_book.sh"
|
||||
RIGA_4H_MIN = 240 # la riga in cui il rotolante di BTC passa da 0 a 2 scatti (r0823_sl_anchor.py);
|
||||
# la PEGGIORE misurata e' 24h (ETH 5, 2 in 30 giorni), non questa
|
||||
|
||||
_RX_CRON5 = re.compile(r"`([\d*/,-]+(?: [\d*/,-]+){4})`")
|
||||
|
||||
|
||||
def cadenza_minuti(expr: str) -> int:
|
||||
"""Minuti fra due esecuzioni di un'espressione crontab a 5 campi, SOLO per cadenze regolari
|
||||
(intervalli tutti uguali): `47 * * * *` -> 60, `*/30 * * * *` -> 30, `7 */4 * * *` -> 240.
|
||||
`*/45` (intervalli 45/15), `0 */5` (ultimo intervallo 4h) e `*/0` vengono RIFIUTATI."""
|
||||
campi = expr.split()
|
||||
assert len(campi) == 5, expr
|
||||
m, h, dom, mon, dow = campi
|
||||
assert dom == mon == dow == "*", f"cadenza non regolare: {expr}"
|
||||
if h == "*" and m.isdigit():
|
||||
return 60
|
||||
if h == "*" and m.startswith("*/"):
|
||||
passo = int(m[2:])
|
||||
if passo <= 0 or 60 % passo:
|
||||
raise ValueError(f"passo di minuti non regolare: {expr}")
|
||||
return passo
|
||||
if h.startswith("*/") and m.isdigit():
|
||||
passo = int(h[2:])
|
||||
if passo <= 0 or 24 % passo:
|
||||
raise ValueError(f"passo di ore non regolare: {expr}")
|
||||
return 60 * passo
|
||||
raise ValueError(f"espressione non riconosciuta: {expr}")
|
||||
|
||||
|
||||
def _docstring() -> str:
|
||||
return ast.get_docstring(ast.parse(BOOK_EXECUTE.read_text())) or ""
|
||||
|
||||
|
||||
def _cron_dichiarato() -> str:
|
||||
"""La prima espressione crontab a 5 campi che `cron_book.sh` dichiara fra backtick — di
|
||||
qualunque forma: e' `cadenza_minuti` a giudicarla, non questa regex (P4: un'espressione
|
||||
presente ma non oraria deve fallire come 'non oraria', non come 'assente')."""
|
||||
m = _RX_CRON5.search(CRON_BOOK.read_text())
|
||||
assert m, "cron_book.sh non dichiara piu' la sua riga di crontab fra backtick"
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def _crontab() -> str | None:
|
||||
"""Testo della crontab dell'utente, None se non leggibile (P5: distinto da 'vuota')."""
|
||||
if not shutil.which("crontab"):
|
||||
return None
|
||||
try:
|
||||
out = subprocess.run(["crontab", "-l"], capture_output=True, text=True, timeout=10)
|
||||
except Exception:
|
||||
return None
|
||||
return out.stdout if out.returncode == 0 else None
|
||||
|
||||
|
||||
def _righe_attive(testo: str, marcatore: str) -> list[str]:
|
||||
"""Le espressioni (5 campi) di TUTTE le righe attive che citano `marcatore`: due righe sono
|
||||
due esecuzioni, e la seconda non deve nascondersi dietro la prima."""
|
||||
return [" ".join(r.split()[:5]) for r in testo.splitlines()
|
||||
if marcatore in r and not r.lstrip().startswith("#")]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- regressione sul docstring
|
||||
# La guardia non cerca due letterali («ogni ~230»): estrae OGNI prescrizione di cadenza scritta
|
||||
# come «ogni/every N unita'» e pretende che valga 60 minuti. ⚠️ Limite dichiarato (P13): una
|
||||
# guardia sui numeri non copre il ragionamento — una prosa che prescrive una cadenza senza la
|
||||
# parola «ogni» passa. Il controllo positivo qui sotto dice cosa la guardia VEDE.
|
||||
|
||||
_NUMERI = {"un": 1, "una": 1, "due": 2, "tre": 3, "quattro": 4, "sei": 6, "otto": 8, "dodici": 12}
|
||||
_RX_CADENZA = re.compile(
|
||||
r"\b(?:ogni|every)\s*~?\s*(\d+|un'?|una|due|tre|quattro|sei|otto|dodici)?\s*'?\s*"
|
||||
r"(minuti|minuto|minutes?|min|m|ore|ora|hours?|h)\b", re.I)
|
||||
|
||||
|
||||
def prescrizioni_minuti(testo: str) -> list[int]:
|
||||
"""Ogni «ogni N unita'» del testo, in minuti. «ogni ora» -> 60, «ogni ~230 minuti» -> 230."""
|
||||
out = []
|
||||
for m in _RX_CADENZA.finditer(testo):
|
||||
n_raw, unita = m.group(1), m.group(2).lower()
|
||||
n = 1 if n_raw is None else (int(n_raw) if n_raw.isdigit() else _NUMERI[n_raw.rstrip("'")])
|
||||
out.append(n * (60 if unita.startswith(("or", "h")) else 1))
|
||||
return out
|
||||
|
||||
|
||||
def test_la_guardia_vede_le_prescrizioni_che_deve_vedere():
|
||||
# controllo positivo (M15): i modi in cui il docstring potrebbe ri-prescrivere la riga 4h
|
||||
assert prescrizioni_minuti("va lanciato ogni ~230 minuti con la feed") == [230]
|
||||
assert prescrizioni_minuti("il giro va lanciato ogni quattro ore") == [240]
|
||||
assert prescrizioni_minuti("every 4 hours, then every 230 min") == [240, 230]
|
||||
assert prescrizioni_minuti("controllato ogni ora; ogni 60 minuti") == [60, 60]
|
||||
assert prescrizioni_minuti("fino a 230m di latenza") == [] # non e' una prescrizione
|
||||
|
||||
|
||||
def test_il_docstring_prescrive_solo_la_cadenza_oraria():
|
||||
d = _docstring()
|
||||
p = prescrizioni_minuti(d)
|
||||
assert p, "il docstring deve contenere almeno una prescrizione di cadenza"
|
||||
assert all(x == 60 for x in p), f"prescrizioni non orarie nel docstring: {p}"
|
||||
assert re.search(r"CADENZA:\s*ORARIA", d), "il docstring deve dichiarare la cadenza ORARIA"
|
||||
|
||||
|
||||
def test_il_docstring_dichiara_la_stessa_riga_del_cron():
|
||||
# la riga di crontab citata nel docstring e' quella di cron_book.sh, non un'altra
|
||||
d = _docstring()
|
||||
assert _cron_dichiarato() in d
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- cron_book.sh
|
||||
|
||||
def test_cron_book_e_orario_sotto_la_griglia_skh_e_sotto_la_riga_4h():
|
||||
c = cadenza_minuti(_cron_dichiarato())
|
||||
assert c == 60
|
||||
assert c < LTF_MIN, "la cadenza deve stare SOTTO la griglia di SKH01 (latenza <= 1 barra)"
|
||||
assert c < RIGA_4H_MIN, "la riga 4h e' quella in cui il rotolante di BTC fa 2 scatti"
|
||||
assert "ORARIA" in CRON_BOOK.read_text()
|
||||
|
||||
|
||||
def test_cron_book_evita_il_minuto_tondo():
|
||||
# vincolo 1 del commento di cron_book.sh: fuori dai ~26s del :00 (rate-limit per-IP)
|
||||
minuto = int(_cron_dichiarato().split()[0])
|
||||
assert minuto != 0
|
||||
|
||||
|
||||
def test_cron_book_evita_lo_slot_di_release_deribit():
|
||||
# vincolo 2 del commento di cron_book.sh — il motivo vero del :07 -> :47 (4 giri morti in
|
||||
# 63 giorni): il giro del martedi' alle 09:MM deve cadere FUORI dallo slot dichiarato in
|
||||
# venue_probe (derivato, non riscritto). Il :07 lo violava; se qualcuno tornasse a :15
|
||||
# con docstring e crontab coerenti, gli altri test passerebbero e questo no.
|
||||
minuto = int(_cron_dichiarato().split()[0])
|
||||
base = datetime(2026, 9, 1, tzinfo=timezone.utc) # un lunedi'
|
||||
martedi = base + timedelta(days=(VP.RELEASE_WEEKDAY - base.weekday()) % 7)
|
||||
giro = martedi.replace(hour=VP.RELEASE_START_MIN // 60, minute=minuto)
|
||||
assert not VP.in_release_window(giro), f"il giro :{minuto:02d} cade nello slot di release"
|
||||
assert VP.in_release_window(giro.replace(minute=7)), "controllo positivo: il :07 ci cadeva"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("expr,atteso", [("47 * * * *", 60), ("*/30 * * * *", 30),
|
||||
("7 */4 * * *", 240), ("0 */24 * * *", 1440)])
|
||||
def test_il_parser_della_cadenza(expr, atteso):
|
||||
assert cadenza_minuti(expr) == atteso
|
||||
|
||||
|
||||
@pytest.mark.parametrize("expr", ["47 9 * * 2", "*/45 * * * *", "0 */5 * * *", "*/0 * * * *",
|
||||
"47 * 1 * *"])
|
||||
def test_il_parser_rifiuta_cio_che_non_e_regolare(expr):
|
||||
with pytest.raises((ValueError, AssertionError)):
|
||||
cadenza_minuti(expr)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- la crontab che GIRA
|
||||
|
||||
def test_la_crontab_installata_e_quella_dichiarata_e_una_sola():
|
||||
"""P16: il cron gira dalla macchina, non dal repo. TRE stati, non due (P5):
|
||||
- crontab NON leggibile -> SALTATO (non verificabile qui);
|
||||
- leggibile, e questo progetto non ci compare affatto -> SALTATO (macchina di sviluppo);
|
||||
- leggibile con altre righe del progetto ma SENZA cron_book.sh attivo -> ROSSO: il libro
|
||||
non e' schedulato dove il progetto e' installato (una riga commentata per manutenzione e
|
||||
dimenticata e' esattamente questo caso).
|
||||
E la riga deve essere UNA: due righe attive sono due esecuzioni l'ora."""
|
||||
testo = _crontab()
|
||||
if testo is None:
|
||||
pytest.skip("crontab non leggibile: non verificabile qui")
|
||||
if str(ROOT) not in testo and ROOT.name not in testo:
|
||||
pytest.skip("la crontab non cita questo progetto: non e' la macchina che lo esegue")
|
||||
righe = _righe_attive(testo, "cron_book.sh")
|
||||
assert righe, "questa macchina esegue il progetto ma cron_book.sh NON e' schedulato"
|
||||
assert len(righe) == 1, f"cron_book.sh schedulato {len(righe)} volte: {righe}"
|
||||
assert righe[0] == _cron_dichiarato()
|
||||
assert cadenza_minuti(righe[0]) == 60
|
||||
|
||||
|
||||
def test_due_righe_attive_non_si_nascondono_dietro_la_prima():
|
||||
finta = ("47 * * * * /x/PythagorasGoal/scripts/cron_book.sh\n"
|
||||
"# 7 * * * * /x/PythagorasGoal/scripts/cron_book.sh\n"
|
||||
"17 * * * * /x/PythagorasGoal/scripts/cron_book.sh\n")
|
||||
assert _righe_attive(finta, "cron_book.sh") == ["47 * * * *", "17 * * * *"]
|
||||
assert _righe_attive("# 47 * * * * /x/cron_book.sh\n", "cron_book.sh") == []
|
||||
+9
-15
@@ -192,12 +192,9 @@ def test_gate_requires_both_switches():
|
||||
|
||||
def test_config_default_disarmed(tmp_path, monkeypatch):
|
||||
"""load_config di book_execute mette execution_enabled=False di default (fail-safe)."""
|
||||
import importlib
|
||||
be = importlib.import_module("scripts.live.book_execute") if False else None
|
||||
# carica il modulo via path (scripts/ non e' un package importabile per nome)
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location("book_execute", PROJECT_ROOT / "scripts/live/book_execute.py")
|
||||
mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)
|
||||
# `scripts/` non e' un package importabile per nome: si carica via percorso
|
||||
from helpers import carica_script
|
||||
mod = carica_script("scripts/live/book_execute.py")
|
||||
monkeypatch.setattr(mod, "CONFIG", tmp_path / "nope.json") # config assente
|
||||
assert mod.load_config()["execution_enabled"] is False
|
||||
|
||||
@@ -419,9 +416,8 @@ def test_book_report_flags_skh_feed_error(monkeypatch):
|
||||
|
||||
def test_book_execute_surfaces_skh_error(monkeypatch, capsys):
|
||||
"""Se il report porta skh_error, _run() lo stampa E chiama notify (niente flat silenzioso)."""
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location("book_execute", PROJECT_ROOT / "scripts/live/book_execute.py")
|
||||
mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)
|
||||
from helpers import carica_script
|
||||
mod = carica_script("scripts/live/book_execute.py")
|
||||
|
||||
canned = dict(
|
||||
last_data=_fresh_bar(), online=True, real_equity=600.0, equity=600.0, eq_basis="test",
|
||||
@@ -481,9 +477,8 @@ def test_book_report_propagates_pos_error(monkeypatch):
|
||||
|
||||
def test_book_execute_halts_on_unreadable_position(monkeypatch, capsys):
|
||||
"""ARMATO + --execute + ordine presente: il gate DEVE fermarsi PRIMA di costruire il trader."""
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location("book_execute", PROJECT_ROOT / "scripts/live/book_execute.py")
|
||||
mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)
|
||||
from helpers import carica_script
|
||||
mod = carica_script("scripts/live/book_execute.py")
|
||||
|
||||
canned = dict(
|
||||
last_data=_fresh_bar(), online=True, real_equity=598.0, equity=598.0, eq_basis="mainnet USDC",
|
||||
@@ -542,9 +537,8 @@ def test_book_report_propagates_eq_fallback(monkeypatch):
|
||||
|
||||
def test_book_execute_eq_fallback_warns_but_proceeds(monkeypatch, capsys):
|
||||
"""eq_fallback: avvisa + notify MA PROSEGUE (non e' un halt, a differenza di pos_error)."""
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location("book_execute", PROJECT_ROOT / "scripts/live/book_execute.py")
|
||||
mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)
|
||||
from helpers import carica_script
|
||||
mod = carica_script("scripts/live/book_execute.py")
|
||||
|
||||
canned = dict(
|
||||
last_data=_fresh_bar(), online=True, real_equity=None, equity=2000.0,
|
||||
|
||||
@@ -116,11 +116,8 @@ def test_posizione_flat_non_puo_diventare_naked():
|
||||
# B. Isolamento per asset dentro book_execute._run().
|
||||
# ---------------------------------------------------------------------------
|
||||
def _carica_book_execute():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"book_execute", PROJECT_ROOT / "scripts/live/book_execute.py")
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
from helpers import carica_script
|
||||
return carica_script("scripts/live/book_execute.py")
|
||||
|
||||
|
||||
def _report_due_asset():
|
||||
|
||||
@@ -58,11 +58,8 @@ def test_funzionale_feed_stantio_blocca_lesecuzione_e_allerta(monkeypatch, capsy
|
||||
"""Il test che conta: conto ONLINE, posizione LEGGIBILE, ordine PRESENTE — cioe' la situazione
|
||||
esatta del 2026-07-14 — ma feed vecchio. Non deve partire nessun ordine e deve arrivare l'alert.
|
||||
"""
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"book_execute_iso", ROOT / "scripts" / "live" / "book_execute.py")
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
from helpers import carica_script
|
||||
mod = carica_script("scripts/live/book_execute.py", "book_execute_iso")
|
||||
|
||||
canned = dict(
|
||||
last_data="2026-07-08", online=True, real_equity=598.0, equity=598.0,
|
||||
@@ -94,11 +91,8 @@ def test_funzionale_feed_stantio_blocca_lesecuzione_e_allerta(monkeypatch, capsy
|
||||
def test_config_senza_la_chiave_non_esplode(monkeypatch, capsys):
|
||||
"""Una config priva di max_data_age_days deve ricadere sul default, non sollevare KeyError
|
||||
dentro il percorso con soldi veri."""
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"book_execute_iso2", ROOT / "scripts" / "live" / "book_execute.py")
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
from helpers import carica_script
|
||||
mod = carica_script("scripts/live/book_execute.py", "book_execute_iso2")
|
||||
canned = dict(last_data=str(pd.Timestamp.now(tz="UTC").normalize().date()), online=True,
|
||||
real_equity=598.0, equity=598.0, eq_basis="t", cap_per_asset=300.0,
|
||||
skh_error=None, pos_error=None, assets=[], orders=[])
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Un flag ignoto non e' l'azione di default (2026-09-02).
|
||||
|
||||
Costo misurato durante una revisione dello stesso giorno: `trades_db.py --help` non stampava
|
||||
l'uso, cadeva in `sync()` e riscriveva `meta.ultimo_sync`. Su `journal.py` lo stesso errore
|
||||
avrebbe scritto una pagina e una riga di DB; su `analista.py` avrebbe speso una chiamata al
|
||||
modello e mandato un Telegram.
|
||||
|
||||
Il test guarda anche l'altra meta' del contratto (P15): i flag che il CRON usa devono restare
|
||||
accettati — una guardia che rompe il cron e' peggio del difetto che ripara.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.live.cli import UsoSbagliato, valida # noqa: E402
|
||||
|
||||
# Tutti gli script di scripts/live/ che NON usano argparse: la guardia deve coprirli tutti, e
|
||||
# l'elenco si DERIVA dalla cartella (P1) — un file nuovo senza guardia fa fallire il test, non
|
||||
# passa inosservato perche' nessuno ha aggiornato una lista.
|
||||
LIVE = Path(__file__).resolve().parents[1] / "scripts" / "live"
|
||||
|
||||
|
||||
def _senza_argparse() -> list[str]:
|
||||
fuori = []
|
||||
for f in sorted(LIVE.glob("*.py")):
|
||||
s = f.read_text()
|
||||
if f.name == "__init__.py" or "argparse" in s or "if __name__" not in s:
|
||||
continue
|
||||
fuori.append(f.name)
|
||||
return fuori
|
||||
|
||||
|
||||
SCRIPT_GUARDATI = tuple(_senza_argparse())
|
||||
assert SCRIPT_GUARDATI, "nessuno script da guardare: il modulo non sta controllando niente"
|
||||
SCRIVONO = ("trades_db.py", "journal.py", "analista.py") # i tre che hanno pagato il difetto
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- la funzione
|
||||
|
||||
def _v(argv, **kw):
|
||||
return valida("x.py", "uso: x.py", argv=argv, esci=False, **kw)
|
||||
|
||||
|
||||
def test_un_flag_noto_passa_e_torna_gli_argomenti():
|
||||
assert _v(["--report"], flag=("--report",)) == ["--report"]
|
||||
|
||||
|
||||
def test_un_flag_ignoto_e_un_errore_non_il_default():
|
||||
with pytest.raises(UsoSbagliato) as e:
|
||||
_v(["--help-me"], flag=("--report",))
|
||||
assert "--help-me" in str(e.value) and "--report" in str(e.value) # P4: cosa, e cosa si puo'
|
||||
|
||||
|
||||
def test_il_valore_di_un_flag_non_e_scambiato_per_un_flag():
|
||||
assert _v(["--giorno", "2026-08-21"], con_valore=("--giorno",)) == ["--giorno", "2026-08-21"]
|
||||
with pytest.raises(UsoSbagliato):
|
||||
_v(["--giorno"], con_valore=("--giorno",)) # manca il valore
|
||||
|
||||
|
||||
def test_un_valore_che_sembra_un_flag_resta_un_valore():
|
||||
assert _v(["--nota", "--quiet"], con_valore=("--nota",), flag=("--quiet",))
|
||||
|
||||
|
||||
def test_un_posizionale_e_ignoto():
|
||||
with pytest.raises(UsoSbagliato):
|
||||
_v(["report"], flag=("--report",))
|
||||
|
||||
|
||||
def test_senza_argomenti_non_solleva():
|
||||
assert _v([], flag=("--report",)) == []
|
||||
|
||||
|
||||
def test_help_non_e_un_errore():
|
||||
assert _v(["--help"], flag=("--report",)) == ["--help"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- gli script veri
|
||||
|
||||
def _uso(nome: str) -> str:
|
||||
"""La costante USO dello script, letta dal sorgente senza importarlo."""
|
||||
src = (ROOT / "scripts" / "live" / nome).read_text()
|
||||
for n in ast.parse(src).body:
|
||||
if isinstance(n, ast.Assign) and getattr(n.targets[0], "id", "") == "USO":
|
||||
return n.value.value
|
||||
raise AssertionError(f"{nome} non dichiara USO")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("nome", SCRIPT_GUARDATI)
|
||||
def test_lo_script_chiama_valida_prima_di_ogni_effetto(nome):
|
||||
"""`valida` dev'essere la PRIMA istruzione eseguibile del blocco __main__: uscire dopo aver
|
||||
aperto il DB o sincronizzato sarebbe uscire dopo l'effetto."""
|
||||
src = (ROOT / "scripts" / "live" / nome).read_text()
|
||||
main = [n for n in ast.parse(src).body
|
||||
if isinstance(n, ast.If) and "__main__" in ast.dump(n.test)]
|
||||
assert main, f"{nome}: nessun blocco __main__"
|
||||
corpo = [n for n in main[0].body if not isinstance(n, ast.ImportFrom | ast.Import)]
|
||||
chiamate = [n for n in ast.walk(corpo[0]) if isinstance(n, ast.Call)]
|
||||
assert any(getattr(c.func, "id", "") == "valida" for c in chiamate), \
|
||||
f"{nome}: la prima istruzione di __main__ non e' valida()"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("nome", SCRIPT_GUARDATI)
|
||||
def test_l_uso_elenca_esattamente_i_flag_dichiarati(nome):
|
||||
"""P4 e P1: il testo d'aiuto non e' prosa libera — deve nominare i flag che lo script accetta."""
|
||||
src = (ROOT / "scripts" / "live" / nome).read_text()
|
||||
dichiarati = set(re.findall(r'["\'](--[a-z-]+)["\']', src.split("if __name__")[1]))
|
||||
uso = _uso(nome)
|
||||
mancanti = {f for f in dichiarati if f not in uso}
|
||||
assert not mancanti, f"{nome}: flag accettati ma non documentati in USO: {mancanti}"
|
||||
|
||||
|
||||
# I flag che il cron usa DAVVERO, letti dagli script di cron: se la guardia ne rifiutasse uno,
|
||||
# il libro di bordo si fermerebbe la notte stessa (P16: il cron gira dal working tree).
|
||||
def test_i_flag_del_cron_sono_tutti_accettati():
|
||||
usati: dict[str, set[str]] = {}
|
||||
for sh in (ROOT / "scripts").glob("cron_*.sh"):
|
||||
for riga in sh.read_text().splitlines():
|
||||
for nome in SCRIPT_GUARDATI:
|
||||
if f"scripts/live/{nome}" in riga and not riga.lstrip().startswith("#"):
|
||||
usati.setdefault(nome, set()).update(re.findall(r"(--[a-z-]+)", riga))
|
||||
assert usati, "nessun cron chiama gli script guardati: il test non sta controllando niente"
|
||||
for nome, flag in usati.items():
|
||||
src = (ROOT / "scripts" / "live" / nome).read_text()
|
||||
noti = set(re.findall(r'["\'](--[a-z-]+)["\']', src.split("if __name__")[1]))
|
||||
assert flag <= noti, f"{nome}: il cron usa {flag - noti}, che la guardia rifiuterebbe"
|
||||
|
||||
# controllo positivo (M15): il confronto sa dire di no. Se il cron usasse un flag non
|
||||
# dichiarato, questo test lo direbbe — qui lo si verifica su un caso finto.
|
||||
assert not {"--inventato"} <= {"--quiet"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("nome", SCRIVONO)
|
||||
def test_help_esce_zero_e_non_scrive(nome):
|
||||
"""Controllo end-to-end (M15): il caso che ha causato il difetto, sui tre script veri."""
|
||||
prima = (ROOT / "data" / "live" / "trades.db").stat().st_mtime
|
||||
r = subprocess.run([sys.executable, str(ROOT / "scripts" / "live" / nome), "--help"],
|
||||
capture_output=True, text=True, cwd=ROOT, timeout=120)
|
||||
assert r.returncode == 0, r.stderr[-500:]
|
||||
assert "uso:" in r.stdout
|
||||
assert (ROOT / "data" / "live" / "trades.db").stat().st_mtime == prima
|
||||
|
||||
|
||||
@pytest.mark.parametrize("nome", SCRIVONO)
|
||||
def test_un_flag_ignoto_esce_due_e_non_scrive(nome):
|
||||
prima = (ROOT / "data" / "live" / "trades.db").stat().st_mtime
|
||||
r = subprocess.run([sys.executable, str(ROOT / "scripts" / "live" / nome), "--nonesiste"],
|
||||
capture_output=True, text=True, cwd=ROOT, timeout=120)
|
||||
assert r.returncode == 2, r.stdout[-300:]
|
||||
assert "non previsto" in r.stderr
|
||||
assert (ROOT / "data" / "live" / "trades.db").stat().st_mtime == prima
|
||||
|
||||
|
||||
def test_ogni_script_senza_argparse_ha_la_guardia():
|
||||
"""L'elenco e' derivato dalla cartella: uno script nuovo che legge sys.argv a mano e non
|
||||
chiama `valida` fa fallire questo test invece di ripetere il difetto in silenzio."""
|
||||
senza = [n for n in SCRIPT_GUARDATI
|
||||
if "valida(" not in (LIVE / n).read_text().split("if __name__")[1]]
|
||||
assert not senza, f"script senza guardia sui flag: {senza}"
|
||||
assert len(SCRIPT_GUARDATI) >= 17, f"troppo pochi script guardati: {SCRIPT_GUARDATI}"
|
||||
@@ -568,3 +568,205 @@ def test_twr_riproduce_il_numero_del_diario_01_09(db, monkeypatch):
|
||||
r = J.rendimento_twr(db, "2026-09-01T23:59:59+00:00")
|
||||
assert round(100 * r["twr"], 2) == 10.80
|
||||
assert r["certi"] == pytest.approx(1399.39)
|
||||
|
||||
|
||||
def test_twr_a_base_zero_e_None_anche_sul_trading(db, monkeypatch):
|
||||
# revisione 02/09: a base zero `movimenti_capitale` non vede il salto 0 -> X del primo
|
||||
# versamento, quindi `trading = e1-e0-certi` varrebbe l'intero conto. Tre stati, non due.
|
||||
monkeypatch.setattr(J, "_px_1h", lambda a: _px_piatto())
|
||||
_serie_equity(db, [("2026-08-24T09:47:01+00:00", 0.0),
|
||||
("2026-08-24T10:47:01+00:00", 598.06),
|
||||
("2026-08-25T10:47:01+00:00", 610.0)])
|
||||
r = J.rendimento_twr(db, "2026-08-25T23:59:59+00:00")
|
||||
assert r["twr"] is None and r["trading"] is None and "base" in r["motivo"]
|
||||
|
||||
|
||||
def test_twr_due_movimenti_consecutivi_non_producono_segmenti_a_lunghezza_zero(db, monkeypatch):
|
||||
monkeypatch.setattr(J, "_px_1h", lambda a: _px_piatto())
|
||||
_serie_equity(db, [("2026-08-24T10:47:01+00:00", 600.0),
|
||||
("2026-08-24T11:47:01+00:00", 2000.0), # movimento nel PRIMO intervallo
|
||||
("2026-08-24T12:47:01+00:00", 3400.0), # movimento consecutivo
|
||||
("2026-08-25T10:47:01+00:00", 3434.0)]) # +1% di trading
|
||||
r = J.rendimento_twr(db, "2026-08-25T23:59:59+00:00")
|
||||
assert len(r["segmenti"]) == 1 and r["segmenti"][0]["ret"] == pytest.approx(0.01)
|
||||
assert r["twr"] == pytest.approx(0.01)
|
||||
assert all(s["da"] != s["a"] for s in r["segmenti"])
|
||||
assert r["certi"] == pytest.approx(1400.0 + 1400.0)
|
||||
|
||||
|
||||
def test_twr_senza_tempo_a_mercato_e_zero_non_None(db, monkeypatch):
|
||||
# due letture, la seconda e' un versamento: nessun segmento, rendimento 0, dichiarato numero
|
||||
monkeypatch.setattr(J, "_px_1h", lambda a: _px_piatto())
|
||||
_serie_equity(db, [("2026-08-24T10:47:01+00:00", 600.0),
|
||||
("2026-08-24T11:47:01+00:00", 2000.0)])
|
||||
r = J.rendimento_twr(db, "2026-08-25T23:59:59+00:00")
|
||||
assert r["twr"] == 0.0 and r["segmenti"] == [] and r["trading"] == pytest.approx(0.0)
|
||||
|
||||
|
||||
# ------------------------------------------------- mercato NON misurabile (revisione 02/09)
|
||||
# Il feed 1h certificato si ferma alle 00:00 (rebuild alle 00:30): per il resto del giorno
|
||||
# `asof` restituiva la STESSA barra per le due letture, max_mkt 0, e qualunque calo >=10%
|
||||
# diventava un "movimento" — un crash stampato come prelievo. E con il feed assente tutto
|
||||
# era "ambiguo", certi=0, e il TWR tornava a essere e1/e0-1 sotto l'etichetta sbagliata.
|
||||
|
||||
def test_un_salto_oltre_la_fine_del_feed_e_ambiguo_con_la_ragione(db, monkeypatch):
|
||||
monkeypatch.setattr(J, "_px_1h", lambda a: _px_piatto()) # copre fino al 27/08
|
||||
_serie_equity(db, [("2026-08-28T10:47:01+00:00", 2000.0),
|
||||
("2026-08-28T11:47:01+00:00", 1760.0)]) # -12% dopo la fine
|
||||
p = J.pnl_giorno(db, date(2026, 8, 28))
|
||||
ev = p["movimenti_giorno"]
|
||||
assert ev[0]["classe"] == "ambiguo" and ev[0]["mercato_max_pct"] is None
|
||||
assert "fermo a 2026-08-27" in ev[0]["mercato_nota"]
|
||||
assert p["mov_giorno"] == 0.0 # NON scorporato
|
||||
assert p["trading_da_arming"] is None and p["twr_da_arming"] is None
|
||||
assert "non classificabile" in p["twr_motivo"]
|
||||
|
||||
|
||||
def test_con_il_feed_assente_il_twr_e_None_non_il_grezzo(db, monkeypatch):
|
||||
monkeypatch.setattr(J, "_px_1h", lambda a: None)
|
||||
_serie_equity(db, [("2026-08-25T10:47:01+00:00", 667.0),
|
||||
("2026-08-25T11:47:01+00:00", 2066.0)])
|
||||
r = J.rendimento_twr(db, "2026-08-25T23:59:59+00:00")
|
||||
assert r["twr"] is None and r["trading"] is None
|
||||
assert "non leggibile" in r["motivo"]
|
||||
assert r["eventi"][0]["classe"] == "ambiguo"
|
||||
|
||||
|
||||
def test_il_giornale_e_il_report_usano_la_stessa_funzione(db, monkeypatch):
|
||||
# P1, revisione 02/09: pnl_giorno non rifa' piu' `cum - certi`; prende trading e TWR da
|
||||
# rendimento_twr, e la pagina lo stampa accanto al cumulato
|
||||
monkeypatch.setattr(J, "_px_1h", lambda a: _px_piatto())
|
||||
_serie_equity(db, [("2026-08-24T10:47:01+00:00", 600.0),
|
||||
("2026-08-25T10:47:01+00:00", 630.0),
|
||||
("2026-08-25T11:47:01+00:00", 2030.0),
|
||||
("2026-08-25T12:47:01+00:00", 2009.7)])
|
||||
p = J.pnl_giorno(db, date(2026, 8, 25))
|
||||
r = J.rendimento_twr(db, "2026-08-25T23:59:59+00:00")
|
||||
assert p["trading_da_arming"] == pytest.approx(r["trading"])
|
||||
assert p["twr_da_arming"] == pytest.approx(r["twr"]) == pytest.approx(1.05 * 0.99 - 1)
|
||||
md = J.rendi_markdown(db, _voce(pnl=p, giorno="2026-08-25"))
|
||||
assert "TWR **+3.95%**" in md
|
||||
|
||||
|
||||
def test_la_leva_del_classificatore_include_la_scala_e_il_tetto_di_codice(db, monkeypatch, tmp_path):
|
||||
# P1: la chiave di scala del 01/09 entra nel bound da sola; e il fallback e' il tetto di CODICE
|
||||
from src.live import book as B
|
||||
cfg = tmp_path / "live.json"
|
||||
cfg.write_text(json.dumps({"max_notional_per_asset_frac": 0.5, "book_scale_k": 1.25}))
|
||||
monkeypatch.setattr(J, "PROJECT_ROOT", tmp_path); (tmp_path / "config").mkdir()
|
||||
cfg.rename(tmp_path / "config" / "live.json")
|
||||
monkeypatch.setattr(J, "_px_1h", lambda a: _px_piatto())
|
||||
_serie_equity(db, [("2026-08-25T10:47:01+00:00", 600.0), ("2026-08-25T11:47:01+00:00", 2000.0)])
|
||||
mv = J.movimenti_capitale(db, "2026-08-25T23:59:59+00:00")
|
||||
assert mv["leva_tetto"] == pytest.approx(min(0.5 * 2 * 1.25, B.LEVA_LORDA_MAX))
|
||||
|
||||
|
||||
def test_balance_watch_registra_anche_l_indice_usde(monkeypatch):
|
||||
"""Debito §5.15, primo passo (02/09): serve una serie ORARIA dell'indice usde_usdc — quella
|
||||
di `usde_watch` e' giornaliera, e il classificatore confronta letture a un'ora di distanza.
|
||||
Il campione la porta, e un indice non leggibile e' None con la ragione, mai 1.0 (P5)."""
|
||||
from helpers import carica_script
|
||||
bw = carica_script("scripts/live/balance_watch.py")
|
||||
|
||||
class _C:
|
||||
def account_summary(self, cur): return {"balance": 1.0, "equity": 1.0}
|
||||
def positions(self, cur): return []
|
||||
|
||||
from src.live import usde as U
|
||||
monkeypatch.setattr(U, "prezzo_indice", lambda *a, **k: 0.9997)
|
||||
assert bw.campiona(_C())["usde_usdc"] == 0.9997
|
||||
|
||||
def _rotto(*a, **k): raise RuntimeError("giu'")
|
||||
monkeypatch.setattr(U, "prezzo_indice", _rotto)
|
||||
rec = bw.campiona(_C())
|
||||
assert rec["usde_usdc"] is None and rec["usde_usdc_errore"] == "RuntimeError"
|
||||
|
||||
|
||||
# ------------------------------------------------- movimenti DICHIARATI dall'operatore (06/09)
|
||||
# Il versamento di prova del 2026-08-25 08:00Z (~$25 su $647, +3,3%) e' restato 12 giorni nel P&L:
|
||||
# sotto EQUITY_JUMP_ALERT il rilevatore non puo' vederlo, e abbassare la soglia farebbe di ogni
|
||||
# ora di mercato un candidato. L'unica fonte che sa l'importo e' l'operatore.
|
||||
|
||||
def _dichiara(monkeypatch, tmp_path, righe):
|
||||
f = tmp_path / "mov.jsonl"
|
||||
f.write_text("\n".join(json.dumps(r) if isinstance(r, dict) else r for r in righe) + "\n")
|
||||
monkeypatch.setattr(J, "MOVIMENTI_DICHIARATI", f)
|
||||
return f
|
||||
|
||||
|
||||
def test_un_versamento_dichiarato_sotto_soglia_viene_scorporato(db, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(J, "_px_1h", lambda a: _px_piatto())
|
||||
_serie_equity(db, [("2026-08-25T07:07:01+00:00", 646.77),
|
||||
("2026-08-25T08:07:01+00:00", 667.88), # +3,3%: sotto soglia
|
||||
("2026-08-25T09:07:01+00:00", 667.68)])
|
||||
_dichiara(monkeypatch, tmp_path, [dict(ts_utc="2026-08-25T08:00:00+00:00", delta_usd=24.9,
|
||||
banda_usd=0.5, nota="versamento di prova",
|
||||
dichiarato_il="2026-09-06")])
|
||||
r = J.rendimento_twr(db, "2026-08-25T23:59:59+00:00")
|
||||
ev = r["eventi"]
|
||||
assert len(ev) == 1 and ev[0]["classe"] == "movimento" and ev[0]["fonte"] == "dichiarato"
|
||||
assert ev[0]["delta"] == pytest.approx(24.9) # l'IMPORTO dichiarato, non il salto
|
||||
assert ev[0]["dopo"] == pytest.approx(646.77 + 24.9) # il mercato dell'ora resta nel rendimento
|
||||
assert r["certi"] == pytest.approx(24.9)
|
||||
assert r["trading"] == pytest.approx(667.68 - 646.77 - 24.9)
|
||||
# TWR: segmento 1 di lunghezza zero (una sola lettura prima), segmento 2 da 671,67 a 667,68
|
||||
assert r["twr"] == pytest.approx(667.68 / (646.77 + 24.9) - 1.0)
|
||||
assert "versamento di prova" in ev[0]["dichiarazione"] and "±$0.50" in ev[0]["dichiarazione"]
|
||||
|
||||
|
||||
def test_dichiarato_e_rilevato_insieme_vince_l_importo_dichiarato(db, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(J, "_px_1h", lambda a: _px_piatto())
|
||||
_serie_equity(db, [("2026-08-25T10:47:01+00:00", 667.49),
|
||||
("2026-08-25T11:47:01+00:00", 2066.88),
|
||||
("2026-08-25T12:47:01+00:00", 2064.56)])
|
||||
_dichiara(monkeypatch, tmp_path, [dict(ts_utc="2026-08-25T11:20:00+00:00", delta_usd=1399.39,
|
||||
nota="bonifico")])
|
||||
r = J.rendimento_twr(db, "2026-08-25T23:59:59+00:00")
|
||||
assert len(r["eventi"]) == 1 # UN evento, non due
|
||||
e = r["eventi"][0]
|
||||
assert e["fonte"] == "dichiarato+rilevato" and e["delta"] == pytest.approx(1399.39)
|
||||
assert e["mercato_max_pct"] is not None # la misura di mercato resta
|
||||
assert r["certi"] == pytest.approx(1399.39)
|
||||
|
||||
|
||||
def test_dichiarato_fuori_dalle_letture_non_entra_ma_si_vede(db, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(J, "_px_1h", lambda a: _px_piatto())
|
||||
_serie_equity(db, [("2026-08-25T10:47:01+00:00", 600.0),
|
||||
("2026-08-25T11:47:01+00:00", 601.0)])
|
||||
_dichiara(monkeypatch, tmp_path, [dict(ts_utc="2026-08-20T08:00:00+00:00", delta_usd=25.0)])
|
||||
r = J.rendimento_twr(db, "2026-08-25T23:59:59+00:00")
|
||||
assert r["eventi"] == [] and r["certi"] == 0.0
|
||||
assert r["avvisi"] and "non applicato" in r["avvisi"][0]
|
||||
|
||||
|
||||
def test_una_riga_rotta_non_azzera_le_altre_e_non_sparisce(db, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(J, "_px_1h", lambda a: _px_piatto())
|
||||
_serie_equity(db, [("2026-08-25T07:07:01+00:00", 646.77),
|
||||
("2026-08-25T08:07:01+00:00", 667.88)])
|
||||
_dichiara(monkeypatch, tmp_path, ["{non json", dict(ts_utc="2026-08-25T08:00:00", delta_usd=1.0),
|
||||
dict(ts_utc="2026-08-25T08:00:00+00:00", delta_usd=24.9)])
|
||||
r = J.rendimento_twr(db, "2026-08-25T23:59:59+00:00")
|
||||
assert r["certi"] == pytest.approx(24.9)
|
||||
assert len(r["avvisi"]) == 2 and "senza fuso" in r["avvisi"][1]
|
||||
|
||||
|
||||
def test_senza_file_dei_dichiarati_niente_cambia(db, monkeypatch):
|
||||
monkeypatch.setattr(J, "_px_1h", lambda a: _px_piatto())
|
||||
_serie_equity(db, [("2026-08-25T10:47:01+00:00", 600.0),
|
||||
("2026-08-25T11:47:01+00:00", 612.0)])
|
||||
assert not J.MOVIMENTI_DICHIARATI.exists() # conftest lo isola
|
||||
r = J.rendimento_twr(db, "2026-08-25T23:59:59+00:00")
|
||||
assert r["eventi"] == [] and r["avvisi"] == []
|
||||
|
||||
|
||||
def test_la_nota_di_giornale_dice_che_e_dichiarato(db, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(J, "_px_1h", lambda a: _px_piatto())
|
||||
_serie_equity(db, [("2026-08-25T07:07:01+00:00", 646.77),
|
||||
("2026-08-25T08:07:01+00:00", 667.88)])
|
||||
_dichiara(monkeypatch, tmp_path, [dict(ts_utc="2026-08-25T08:00:00+00:00", delta_usd=24.9,
|
||||
nota="versamento di prova")])
|
||||
p = J.pnl_giorno(db, date(2026, 8, 25))
|
||||
assert p["mov_giorno"] == pytest.approx(24.9)
|
||||
righe = J.lettura(db, _voce(pnl=p, giorno="2026-08-25"))
|
||||
mv = [r for r in righe if r["id"] == "movimento"]
|
||||
assert mv and "dichiarato dall'operatore" in mv[0]["testo"] and "versamento di prova" in mv[0]["testo"]
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
"""Test di `scripts/research/r0909_crash_catturato.py` (2026-09-09) — il `f` di stress su quote vere.
|
||||
|
||||
Questo script porta l'UNICA condizione che riapre la decisione vincolante del 19/06 ("niente
|
||||
short-vol da modello in deploy"): un `f` di stress reale misurato su un crash catturato. Un
|
||||
numero con quel peso ha bisogno di guardie sul MECCANISMO, non solo sull'esito:
|
||||
|
||||
* il prezzatore vettorizzato dev'essere *lo stesso* di `cblib.bs_put`, elemento per elemento e
|
||||
nei casi degeneri (T≤0, sig=0 → intrinseco): se diverge, il denominatore di ogni `f` e' un
|
||||
altro modello e il rapporto non significa piu' niente;
|
||||
* **lo spot dev'essere CAUSALE.** Il feed 1h e' etichettato all'APERTURA: la barra 20:00 chiude
|
||||
alle 21:00, quindi `S.asof(ts)` guardava un'ora AVANTI. `spot_causale` sposta l'indice di
|
||||
+1h. E' un look-ahead di un'ora dentro una misura di stress — piccolo e letale;
|
||||
* **il costo di chiusura si TAGLIA alla larghezza.** Uno spread a rischio definito non costa
|
||||
mai piu' della larghezza per chiudersi: si lascia regolare. Senza il taglio il "peggior MTM"
|
||||
era un artefatto di quote (2,6× il modello; col taglio 1,08);
|
||||
* la geometria fuori tolleranza va MARCATA, non aggiustata: `pick_legs` prende sempre la delta
|
||||
piu' vicina, quindi senza `in_geom` una griglia rada produrrebbe silenziosamente un'altra
|
||||
struttura (e' cio' che ha reso BTC non misurabile);
|
||||
* un `f` fra un reale negativo e un modello positivo NON e' un `f`: dev'essere NaN (P12).
|
||||
|
||||
I test sintetici costruiscono quote a mano, dove ogni numero atteso si calcola a matita. Il test
|
||||
d'integrazione (~11 s) riproduce la misura pubblicata su ETH.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||
|
||||
import cblib as CB # noqa: E402
|
||||
import r0909_crash_catturato as C # noqa: E402
|
||||
|
||||
INS_S = "ETH-19JUN26-1900-P"
|
||||
INS_L = "ETH-19JUN26-1600-P"
|
||||
K_S, K_L = 1900.0, 1600.0
|
||||
LARGHEZZA = K_S - K_L
|
||||
TS0 = pd.Timestamp("2026-06-05 00:00", tz="UTC")
|
||||
ORE = pd.date_range(TS0, periods=4, freq="h")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ impalcature sintetiche
|
||||
|
||||
def _serie_spot(entrata: float = 2000.0, scadenza: float | None = None) -> pd.Series:
|
||||
"""Spot orario dal 01/06 a EXP: `entrata` fino a 12h dopo l'ingresso, poi `scadenza`
|
||||
(cosi' `S.asof(EXP)` e' pilotabile senza toccare le quote della finestra d'ingresso)."""
|
||||
idx = pd.date_range(pd.Timestamp("2026-06-01", tz="UTC"), C.EXP, freq="h")
|
||||
fine = TS0 + pd.Timedelta(hours=12)
|
||||
v = np.where(idx <= fine, entrata, entrata if scadenza is None else scadenza)
|
||||
return pd.Series(v.astype(float), index=idx)
|
||||
|
||||
|
||||
def _serie_dvol(dvol: float = 50.0) -> pd.Series:
|
||||
idx = pd.date_range(pd.Timestamp("2026-06-01", tz="UTC"), C.EXP, freq="h")
|
||||
return pd.Series(float(dvol), index=idx)
|
||||
|
||||
|
||||
def _seg(bid_s=0.030, ask_s=0.032, bid_l=0.010, ask_l=0.012,
|
||||
ore=ORE, salta_lunga=()) -> pd.DataFrame:
|
||||
"""Tabella di quote (ts × strumento). `salta_lunga` toglie la gamba LUNGA a quelle ore:
|
||||
e' il caso "una gamba sola", che non e' meta' struttura ma un'altra strategia."""
|
||||
righe = []
|
||||
for k, t in enumerate(ore):
|
||||
righe.append(dict(ts=t, instrument_name=INS_S, bid=bid_s, ask=ask_s, iv=48.0 + k))
|
||||
if k not in salta_lunga:
|
||||
righe.append(dict(ts=t, instrument_name=INS_L, bid=bid_l, ask=ask_l, iv=57.0 + k))
|
||||
return pd.DataFrame(righe)
|
||||
|
||||
|
||||
def _quote(bid_s=0.030, ask_s=0.032, bid_l=0.010, ask_l=0.012, salta_lunga=()) -> C.Quote:
|
||||
return C.Quote(_seg(bid_s, ask_s, bid_l, ask_l, salta_lunga=salta_lunga),
|
||||
_serie_spot(), _serie_dvol())
|
||||
|
||||
|
||||
def _snap(d_short: float, d_long: float, bid_s=0.030, ask_s=0.032,
|
||||
bid_l=0.010, ask_l=0.012) -> pd.DataFrame:
|
||||
return pd.DataFrame([
|
||||
dict(instrument_name=INS_S, strike=K_S, delta=d_short,
|
||||
bid=bid_s, ask=ask_s, iv=48.0, mid=(bid_s + ask_s) / 2),
|
||||
dict(instrument_name=INS_L, strike=K_L, delta=d_long,
|
||||
bid=bid_l, ask=ask_l, iv=57.0, mid=(bid_l + ask_l) / 2),
|
||||
])
|
||||
|
||||
|
||||
def _trade(d_short: float, d_long: float, bid_s=0.030, ask_s=0.032, bid_l=0.010,
|
||||
ask_l=0.012, spot=2000.0, ST=None) -> dict | None:
|
||||
S, V = _serie_spot(spot, ST), _serie_dvol()
|
||||
Q = C.Quote(_seg(bid_s, ask_s, bid_l, ask_l), S, V)
|
||||
return C.trade("ETH", TS0, _snap(d_short, d_long, bid_s, ask_s, bid_l, ask_l), Q, S, V)
|
||||
|
||||
|
||||
# ===========================================================================================
|
||||
# spot_causale — il look-ahead di un'ora
|
||||
# ===========================================================================================
|
||||
|
||||
def test_spot_causale_restituisce_l_ultima_chiusura_CONOSCIUTA():
|
||||
"""🚨 Il feed 1h e' etichettato all'APERTURA: la barra 01:00 chiude alle 02:00. Senza lo
|
||||
spostamento, `asof(02:00)` restituiva la chiusura delle 02:00 — cioe' un'ora di futuro
|
||||
dentro un ingresso. DEVE FALLIRE SE: qualcuno "semplifica" togliendo lo shift.
|
||||
|
||||
⚠️ `cblib.spot_series` ha ancora il difetto (e quindi il f 0,714 di §11): dichiarato nel
|
||||
docstring dello script, non toccato qui."""
|
||||
idx = pd.date_range("2026-06-01", periods=5, freq="h", tz="UTC")
|
||||
S = pd.Series([10.0, 11.0, 12.0, 13.0, 14.0], index=idx)
|
||||
Sc = C.spot_causale(S)
|
||||
|
||||
assert list(Sc.index) == list(idx + pd.Timedelta(hours=1))
|
||||
assert Sc.asof(idx[2]) == 11.0, "a 02:00 e' nota la chiusura della barra 01:00"
|
||||
assert S.asof(idx[2]) == 12.0, "il difetto che la funzione ripara (controllo positivo)"
|
||||
assert np.isnan(Sc.asof(idx[0])), "prima della prima chiusura nota non c'e' spot, non c'e' 10.0"
|
||||
assert Sc.iloc[0] == 10.0 and S.iloc[0] == 10.0, "i VALORI non si toccano, solo le etichette"
|
||||
|
||||
|
||||
# ===========================================================================================
|
||||
# bs_put_vec == cblib.bs_put, elemento per elemento
|
||||
# ===========================================================================================
|
||||
|
||||
def test_bs_put_vec_e_IDENTICA_a_cblib_bs_put_elemento_per_elemento():
|
||||
"""DEVE FALLIRE SE: la vettorizzata devia dal prezzatore del sleeve — il denominatore di
|
||||
ogni `f` smetterebbe di essere "lo stesso trade nel modello"."""
|
||||
S = np.array([2400.0, 2000.0, 1900.0, 1800.0, 1500.0, 900.0])
|
||||
T = np.array([0.10, 0.0383, 0.0192, 0.0027, 0.0027, 0.20])
|
||||
sig = np.array([0.35, 0.50, 0.65, 1.10, 0.50, 0.05])
|
||||
for K in (K_S, K_L, 2200.0):
|
||||
v = C.bs_put_vec(S, K, T, sig)
|
||||
atteso = [CB.bs_put(float(s), K, float(t), float(g)) for s, t, g in zip(S, T, sig)]
|
||||
assert v == pytest.approx(atteso, rel=0, abs=1e-10), K
|
||||
|
||||
|
||||
def test_bs_put_vec_a_T_o_sigma_nulli_da_l_INTRINSECO():
|
||||
"""I casi degeneri entrano nel conto: all'ora di scadenza T=0 e il valore e' il payoff, non
|
||||
un prezzo. DEVE FALLIRE SE: qualcuno toglie la maschera `ok` e lascia passare log/0 o NaN
|
||||
dentro `mod_spread`."""
|
||||
S = np.array([2400.0, 1900.0, 1500.0, 1000.0])
|
||||
for T, sig in ((np.zeros(4), np.full(4, 0.5)),
|
||||
(np.full(4, -1.0), np.full(4, 0.5)),
|
||||
(np.full(4, 0.05), np.zeros(4))):
|
||||
v = C.bs_put_vec(S, K_S, T, sig)
|
||||
assert v == pytest.approx(np.maximum(K_S - S, 0.0), abs=1e-12)
|
||||
assert np.isfinite(v).all()
|
||||
|
||||
|
||||
# ===========================================================================================
|
||||
# Quote + percorso su quote sintetiche
|
||||
# ===========================================================================================
|
||||
|
||||
def test_percorso_costa_lo_spread_nel_VERSO_GIUSTO():
|
||||
"""chiudi_short = (ASK corta − BID lunga)·spot: si ricompra cio' che si e' venduto e si
|
||||
rivende cio' che si e' comprato, cioe' si attraversa lo spread due volte.
|
||||
liquida_long = (BID corta − ASK lunga)·spot e' lo specchio.
|
||||
DEVE FALLIRE SE: qualcuno "corregge" i lati — a mid il percorso costerebbe meno e il fatto
|
||||
misurato si ammorbidirebbe da solo."""
|
||||
Q = _quote(salta_lunga=(1,))
|
||||
|
||||
assert list(Q.bid.index) == list(ORE)
|
||||
assert Q.spot.iloc[0] == 2000.0
|
||||
assert Q.dvol.iloc[0] == pytest.approx(0.50), "il DVOL entra in frazione, non in punti"
|
||||
|
||||
p = C.percorso(Q, ORE[0] - pd.Timedelta(hours=1), INS_S, INS_L, K_S, K_L)
|
||||
assert len(p) == 3, "l'ora con la sola gamba corta dev'essere ESCLUSA"
|
||||
assert list(p["ts"]) == [ORE[0], ORE[2], ORE[3]]
|
||||
assert p["ts"].is_monotonic_increasing
|
||||
|
||||
assert p["chiudi_grezzo"].tolist() == pytest.approx([(0.032 - 0.010) * 2000.0] * 3)
|
||||
assert p["chiudi_short"].tolist() == pytest.approx([(0.032 - 0.010) * 2000.0] * 3), (
|
||||
"dentro la larghezza il taglio non deve mordere: sarebbe un costo inventato")
|
||||
assert p["liquida_long"].tolist() == pytest.approx([(0.030 - 0.012) * 2000.0] * 3)
|
||||
assert p["liquida_put"].tolist() == pytest.approx([0.010 * 2000.0] * 3)
|
||||
assert p["iv_s"].tolist() == pytest.approx([48.0, 50.0, 51.0])
|
||||
assert p["iv_l"].tolist() == pytest.approx([57.0, 59.0, 60.0])
|
||||
assert p["spot"].tolist() == pytest.approx([2000.0] * 3)
|
||||
|
||||
# il valore MODELLATO e' quello del sleeve, agli stessi strike e alla stessa ora
|
||||
for i, t in enumerate([ORE[0], ORE[2], ORE[3]]):
|
||||
T = max((C.EXP - t).total_seconds(), 0.0) / 86400.0 / 365.25
|
||||
atteso = CB.bs_put(2000.0, K_S, T, 0.5) - CB.bs_put(2000.0, K_L, T, 0.5)
|
||||
assert p["mod_spread"].iloc[i] == pytest.approx(atteso, abs=1e-10)
|
||||
assert p["mod_put"].iloc[i] == pytest.approx(CB.bs_put(2000.0, K_L, T, 0.5), abs=1e-10)
|
||||
|
||||
|
||||
def test_percorso_TAGLIA_il_costo_di_chiusura_alla_LARGHEZZA():
|
||||
"""🚨 Uno spread a rischio definito non puo' costare piu' della larghezza per chiudersi: si
|
||||
lascia regolare. A T→0 le quote arrivano al 158% di spread relativo, e senza il taglio il
|
||||
"peggior MTM" era un artefatto di book (2,6× il modello, corretto in revisione a 1,08).
|
||||
`chiudi_grezzo` resta accanto perche' il taglio va CONTATO, non nascosto (P12)."""
|
||||
Q = _quote(bid_s=0.005, ask_s=0.200, bid_l=0.010, ask_l=0.012)
|
||||
p = C.percorso(Q, ORE[0] - pd.Timedelta(hours=1), INS_S, INS_L, K_S, K_L)
|
||||
|
||||
assert p["chiudi_grezzo"].tolist() == pytest.approx([(0.200 - 0.010) * 2000.0] * 4) # 380
|
||||
assert p["chiudi_short"].tolist() == pytest.approx([LARGHEZZA] * 4) # 300
|
||||
assert (p["chiudi_short"] <= LARGHEZZA + 1e-12).all()
|
||||
assert (p["chiudi_grezzo"] > p["chiudi_short"]).all(), "il grezzo deve restare visibile"
|
||||
# la gamba lunga liquidata non puo' valere meno di zero
|
||||
assert p["liquida_long"].tolist() == pytest.approx([0.0] * 4)
|
||||
|
||||
|
||||
def test_percorso_taglia_liquida_long_anche_in_ALTO():
|
||||
"""L'altro estremo della stessa regola: un debit spread non vale piu' della sua larghezza."""
|
||||
Q = _quote(bid_s=0.200, ask_s=0.210, bid_l=0.005, ask_l=0.010)
|
||||
p = C.percorso(Q, ORE[0] - pd.Timedelta(hours=1), INS_S, INS_L, K_S, K_L)
|
||||
assert p["liquida_long"].tolist() == pytest.approx([LARGHEZZA] * 4)
|
||||
|
||||
|
||||
def test_percorso_parte_DOPO_l_ingresso_e_non_lo_include():
|
||||
"""Il mark-to-market comincia all'ora successiva: includere l'ora d'ingresso metterebbe
|
||||
dentro un P&L noto a priori (il mezzo spread pagato entrando)."""
|
||||
Q = _quote()
|
||||
p = C.percorso(Q, ORE[0], INS_S, INS_L, K_S, K_L)
|
||||
assert list(p["ts"]) == [ORE[1], ORE[2], ORE[3]]
|
||||
assert C.percorso(Q, ORE[-1], INS_S, INS_L, K_S, K_L).empty
|
||||
|
||||
|
||||
def test_percorso_e_vuoto_se_uno_strumento_non_esiste_nella_tabella():
|
||||
"""P5: "non vedo" ha uno stato suo (DataFrame vuoto → `trade` ritorna None), non un valore
|
||||
inventato."""
|
||||
Q = _quote()
|
||||
assert C.percorso(Q, ORE[0], INS_S, "ETH-19JUN26-9999-P", K_S, 9999.0).empty
|
||||
assert C.percorso(Q, ORE[0], "ETH-19JUN26-9999-P", INS_L, 9999.0, K_L).empty
|
||||
|
||||
|
||||
def test_Quote_scarta_le_quote_NON_quotate():
|
||||
"""bid o ask a zero (o assenti) non sono prezzi: entrano come "non quotato", non come 0."""
|
||||
seg = _seg()
|
||||
seg.loc[seg["instrument_name"] == INS_L, "bid"] = 0.0 # gamba lunga senza bid
|
||||
Q = C.Quote(seg, _serie_spot(), _serie_dvol())
|
||||
assert INS_L not in Q.bid.columns
|
||||
assert C.percorso(Q, ORE[0], INS_S, INS_L, K_S, K_L).empty
|
||||
|
||||
|
||||
# ===========================================================================================
|
||||
# la guardia di geometria in_geom (TOL_D)
|
||||
# ===========================================================================================
|
||||
|
||||
def test_in_geom_e_FALSO_quando_le_delta_disponibili_sono_lontane():
|
||||
"""`pick_legs` prende SEMPRE la delta piu' vicina: su una griglia rada restituisce una
|
||||
struttura che non e' VRP01 e non lo dice. `in_geom` e' il pezzo che lo dice.
|
||||
DEVE FALLIRE SE: qualcuno allarga TOL_D o toglie il flag — i 78 snapshot BTC scartati
|
||||
il 09/09 sono esattamente quelli che quel flag ha fermato."""
|
||||
t = _trade(-0.50, -0.01)
|
||||
assert t is not None
|
||||
assert t["in_geom"] is False
|
||||
assert t["d_short"] == -0.50 and t["d_long"] == -0.01
|
||||
|
||||
|
||||
def test_in_geom_e_VERO_sulla_geometria_di_VRP01():
|
||||
"""Controllo POSITIVO (M15): un rilevatore tarato per non segnalare va provato su un caso
|
||||
che DEVE passare, altrimenti "nessun trade fuori geometria" non significa niente."""
|
||||
t = _trade(C.SHORT_D, C.LONG_D)
|
||||
assert t["in_geom"] is True
|
||||
assert t["d_short"] == pytest.approx(C.SHORT_D) and t["d_long"] == pytest.approx(C.LONG_D)
|
||||
|
||||
|
||||
def test_in_geom_il_confine_e_esattamente_TOL_D():
|
||||
"""I due confini si DERIVANO da `TOL_D`/`SHORT_D`/`LONG_D`, non si riscrivono (P1).
|
||||
|
||||
⚠️ Il confine ESATTO non e' asseribile in virgola mobile: `-0.28 - 0.08` vale
|
||||
-0.36000000000000004, quindi la delta "sul confine" cade FUORI. Si prova un micron dentro
|
||||
e un micron fuori — e' il confine che conta, non il suo ultimo bit."""
|
||||
dentro_s, fuori_s = C.SHORT_D - C.TOL_D[0] + 1e-6, C.SHORT_D - C.TOL_D[0] - 1e-6
|
||||
dentro_l, fuori_l = C.LONG_D - C.TOL_D[1] + 1e-6, C.LONG_D - C.TOL_D[1] - 1e-6
|
||||
assert _trade(dentro_s, dentro_l)["in_geom"] is True, "un micron dentro deve passare"
|
||||
assert _trade(fuori_s, dentro_l)["in_geom"] is False, "gamba corta appena fuori"
|
||||
assert _trade(dentro_s, fuori_l)["in_geom"] is False, "gamba lunga appena fuori"
|
||||
|
||||
|
||||
def test_P1_le_delta_bersaglio_vengono_dal_SLEEVE():
|
||||
"""DEVE FALLIRE SE: qualcuno ridichiara −0,28/−0,10 nello script — la misura punterebbe su
|
||||
una geometria diversa da quella del sleeve che dovrebbe descrivere."""
|
||||
from src.portfolio.sleeves import VRP_CFG
|
||||
assert C.SHORT_D is VRP_CFG["short_delta"]
|
||||
assert C.LONG_D is VRP_CFG["long_delta"]
|
||||
assert C.FEE_FRAC is VRP_CFG["fee_frac"]
|
||||
|
||||
|
||||
# ===========================================================================================
|
||||
# trade() — i conti, e cio' che si RIFIUTA di calcolare
|
||||
# ===========================================================================================
|
||||
|
||||
def test_f_exp_e_f_mtm_sono_NAN_a_SEGNI_DISCORDI():
|
||||
"""Un rapporto fra un reale NEGATIVO e un modello POSITIVO non e' un fattore di stress:
|
||||
e' due fatti diversi messi in frazione. Qui il credito reale (0,2 $) sta sotto le fee
|
||||
d'ingresso (1,2 $) mentre il credito modellato e' pieno → pnl_real < 0 < pnl_mod.
|
||||
DEVE FALLIRE SE: qualcuno toglie le condizioni di segno e pubblica un `f` negativo."""
|
||||
t = _trade(C.SHORT_D, C.LONG_D, bid_s=0.0101, ask_s=0.0102, bid_l=0.009, ask_l=0.010)
|
||||
assert t["pnl_real"] < 0 < t["pnl_mod"], (t["pnl_real"], t["pnl_mod"])
|
||||
assert np.isnan(t["f_exp"])
|
||||
assert t["mtm_real_min"] < 0 < t["mtm_mod_min"], (t["mtm_real_min"], t["mtm_mod_min"])
|
||||
assert np.isnan(t["f_mtm"])
|
||||
|
||||
|
||||
def test_f_exp_e_UN_NUMERO_quando_perdono_entrambi():
|
||||
"""Controllo POSITIVO del test qui sopra: con lo spot a 1500 a scadenza perdono sia il
|
||||
reale sia il modello, e allora `f_exp` esiste ed e' esattamente il rapporto delle perdite."""
|
||||
t = _trade(C.SHORT_D, C.LONG_D, bid_s=0.0101, ask_s=0.0102, bid_l=0.009, ask_l=0.010,
|
||||
ST=1500.0)
|
||||
assert t["pnl_real"] < 0 and t["pnl_mod"] < 0
|
||||
assert t["f_exp"] == pytest.approx(t["pnl_real"] / t["pnl_mod"], abs=1e-12)
|
||||
assert np.isfinite(t["f_exp"]) and t["f_exp"] > 0
|
||||
|
||||
|
||||
def test_itm_pieno_dichiara_quando_f_exp_e_TAUTOLOGICO():
|
||||
"""Se ENTRAMBE le gambe finiscono ITM il payoff e' la larghezza per il reale e per il
|
||||
modello: `f_exp` si riduce a una funzione di `f_net` e non e' piu' un'informazione nuova.
|
||||
Il flag esiste per poterlo DIRE (l'83% dei trade ETH del 09/09), non per correggerlo."""
|
||||
assert _trade(C.SHORT_D, C.LONG_D, ST=1500.0)["itm_pieno"] is True # 1500 <= K_L
|
||||
assert _trade(C.SHORT_D, C.LONG_D, ST=2000.0)["itm_pieno"] is False
|
||||
assert _trade(C.SHORT_D, C.LONG_D, ST=K_L)["itm_pieno"] is True # confine incluso
|
||||
assert _trade(C.SHORT_D, C.LONG_D, ST=K_L + 1.0)["itm_pieno"] is False
|
||||
|
||||
|
||||
def test_trade_CONTA_le_ore_tagliate_invece_di_nasconderle():
|
||||
"""P12/D5: una riparazione silenziosa e' un'invenzione. Il taglio alla larghezza cambia il
|
||||
peggior MTM, quindi le ore in cui morde vanno contate e stampate accanto al numero."""
|
||||
normale = _trade(C.SHORT_D, C.LONG_D)
|
||||
assert normale["n_ore"] == 3 and normale["n_tagli"] == 0
|
||||
|
||||
tagliato = _trade(C.SHORT_D, C.LONG_D, ask_s=0.200) # (0,200 − 0,010)·2000 = 380 > 300
|
||||
assert tagliato["n_ore"] == 3 and tagliato["n_tagli"] == 3
|
||||
assert tagliato["mtm_real_min"] == pytest.approx(tagliato["cred_real"] - LARGHEZZA)
|
||||
|
||||
|
||||
def test_f_net_e_il_credito_reale_su_quello_modellato():
|
||||
"""Il credito reale si incassa al BID della corta e si paga all'ASK della lunga: e' la
|
||||
convenzione conservativa di `cblib.f_factors`, e vale 0,74 su ETH nel crollo."""
|
||||
t = _trade(C.SHORT_D, C.LONG_D)
|
||||
assert t["cred_real"] == pytest.approx((0.030 - 0.012) * 2000.0)
|
||||
T = (C.EXP - TS0).total_seconds() / 86400.0 / 365.25
|
||||
assert t["cred_mod"] == pytest.approx(
|
||||
CB.bs_put(2000.0, K_S, T, 0.5) - CB.bs_put(2000.0, K_L, T, 0.5), abs=1e-10)
|
||||
assert t["f_net"] == pytest.approx(t["cred_real"] / t["cred_mod"], abs=1e-12)
|
||||
assert t["risk"] == pytest.approx(LARGHEZZA - t["cred_real"])
|
||||
|
||||
|
||||
def test_trade_e_None_se_il_credito_non_e_positivo():
|
||||
"""Un credit spread a credito ≤ 0 non e' la struttura che si sta studiando."""
|
||||
assert _trade(C.SHORT_D, C.LONG_D, bid_s=0.010, ask_s=0.011, bid_l=0.020, ask_l=0.030) is None
|
||||
|
||||
|
||||
# ===========================================================================================
|
||||
# verdetto() — i rami
|
||||
# ===========================================================================================
|
||||
|
||||
ESITO = ("IL CROLLO E' CATTURATO MA A VOL BASSA E FUORI DAL GATE DI VRP01: il f di stress che "
|
||||
"la regola del 19/06 aspettava NON e' misurato nella sostanza — cio' che e' misurato "
|
||||
"e' che il rapporto di credito f_net non cambia col regime")
|
||||
|
||||
|
||||
def _r(f_exp=1.04, f_mtm=1.08, n=569, n_pre=286, n_fuori=2, f_net=0.74, q_itm=0.83,
|
||||
n_strutture=4, n_tagli=8779, n_ore=213477, gate=0.0,
|
||||
dur_f_net=0.71, dur_q_perde=0.46) -> dict:
|
||||
return dict(n=n, n_pre=n_pre, n_fuori=n_fuori, f_net=f_net, f_exp=f_exp, f_mtm=f_mtm,
|
||||
q_itm_pieno=q_itm, n_strutture=n_strutture, n_tagli=n_tagli, n_ore=n_ore,
|
||||
gate_aperto=gate, durante=dict(f_net=dur_f_net, q_perde=dur_q_perde))
|
||||
|
||||
|
||||
def test_verdetto_l_esito_e_una_CONCLUSIONE_non_un_esito_numerico():
|
||||
"""La conclusione del 09/09 non e' "f vale x": e' che il crollo catturato sta a vol BASSA e
|
||||
fuori dal gate, quindi il `f` di stress che la regola del 19/06 aspetta non e' misurato
|
||||
nella sostanza. DEVE FALLIRE SE: qualcuno rimette un verdetto che dipende dal solo numero —
|
||||
e' la lettura che il numero da solo autorizzava e che la revisione ha tolto."""
|
||||
for s in ({"BTC": _r(), "ETH": _r()},
|
||||
{"BTC": _r(f_exp=3.0), "ETH": _r(f_exp=0.2)},
|
||||
{"BTC": None, "ETH": None}):
|
||||
v = C.verdetto(s)
|
||||
assert v.startswith(ESITO)
|
||||
assert v.endswith("— n = 1 crollo, 1 scadenza per asset, famiglia INVERSE")
|
||||
|
||||
|
||||
def test_verdetto_NON_MISURABILE_quando_un_asset_non_ha_snapshot_in_geometria():
|
||||
"""P5: lo stato "non vedo" ha un ramo suo e nomina la causa (griglia di strike rada)."""
|
||||
v = C.verdetto({"BTC": dict(n=0, n_fuori=78), "ETH": None})
|
||||
assert "BTC: NON misurabile — 78 snapshot tutti FUORI geometria (griglia di strike rada)" in v
|
||||
assert "ETH: NON misurabile — 0 snapshot tutti FUORI geometria" in v
|
||||
|
||||
|
||||
def test_verdetto_distingue_ZERO_SNAPSHOT_da_NESSUN_INGRESSO_PRE_CROLLO():
|
||||
"""Due guasti diversi con la stessa azione (P5): "tutti fuori geometria" e "in geometria ma
|
||||
nessun ingresso PRIMA del crollo" sono cause diverse e vanno dette diverse — e' esattamente
|
||||
il caso di BTC il 09/09, dove resta solo la lente DURANTE."""
|
||||
v = C.verdetto({"BTC": _r(f_exp=float("nan"), n=247, n_pre=0, n_fuori=78,
|
||||
dur_f_net=0.79, dur_q_perde=0.3724696356275304),
|
||||
"ETH": _r()})
|
||||
assert "BTC: nessun ingresso pre-crollo in geometria (solo DURANTE: f_net 0.79, perde il 37%)" in v
|
||||
assert "FUORI geometria" not in v.split("BTC:")[1].split(";")[0]
|
||||
|
||||
|
||||
def test_verdetto_dichiara_che_il_f_a_scadenza_e_TAUTOLOGICO():
|
||||
"""M13/§2: un numero si cita con la sua risoluzione. 286 snapshot su 4 strutture non sono
|
||||
286 osservazioni, e a gambe entrambe ITM il `f` a scadenza e' una funzione di `f_net`.
|
||||
Senza queste due note la riga "f 1,04" verrebbe letta come una misura indipendente."""
|
||||
v = C.verdetto({"BTC": _r(), "ETH": _r()})
|
||||
assert "a scadenza f 1.04 ma TAUTOLOGICO nel 83% dei trade" in v
|
||||
assert "4 strutture, non 286 osservazioni" in v
|
||||
assert "peggior MTM tagliato alla larghezza f 1.08" in v
|
||||
assert "quote oltre la larghezza nel 4.1% delle ore" in v
|
||||
|
||||
|
||||
def test_verdetto_dice_se_il_gate_di_VRP01_era_APERTO():
|
||||
"""E' il fatto che decide se la misura parla del sleeve o di un altro trade: col gate chiuso
|
||||
VRP01 quel trade NON lo avrebbe fatto."""
|
||||
assert "gate IV-rank CHIUSO nel 0% delle ore" in C.verdetto({"BTC": _r(), "ETH": _r()})
|
||||
assert "gate IV-rank APERTO nel 60% delle ore" in C.verdetto(
|
||||
{"BTC": _r(gate=0.6), "ETH": _r(gate=0.6)})
|
||||
# confine: 0,5 esatto non e' "aperto" (il confronto e' `>`)
|
||||
assert "gate IV-rank CHIUSO nel 50% delle ore" in C.verdetto(
|
||||
{"BTC": _r(gate=0.5), "ETH": _r(gate=0.5)})
|
||||
|
||||
|
||||
def test_verdetto_non_divide_per_zero_quando_non_ci_sono_ore():
|
||||
"""Un campione vuoto non deve far esplodere la riga: la quota di ore tagliate va a 0,0%."""
|
||||
v = C.verdetto({"BTC": _r(n_tagli=0, n_ore=0), "ETH": _r()})
|
||||
assert "quote oltre la larghezza nel 0.0% delle ore" in v
|
||||
|
||||
|
||||
def test_verdetto_riproduce_ALLA_LETTERA_la_riga_pubblicata_il_09_09():
|
||||
"""M23: la macchina deve ristampare il numero vecchio. Sono i valori esatti usciti da
|
||||
`sintesi()` il 09/09 — se il testo cambia, cambia anche cio' che il diario cita, e va
|
||||
cambiato nei due posti insieme."""
|
||||
s = {"BTC": _r(f_exp=float("nan"), f_mtm=float("nan"), n=247, n_pre=0, n_fuori=78,
|
||||
f_net=0.7941019699391642, q_itm=float("nan"), n_strutture=0, n_tagli=0,
|
||||
n_ore=0, gate=0.0, dur_f_net=0.7941019699391642,
|
||||
dur_q_perde=0.3724696356275304),
|
||||
"ETH": _r(f_exp=1.0383032287675409, f_mtm=1.0790935015712533, n=569, n_pre=286,
|
||||
n_fuori=2, f_net=0.7405178198702741, q_itm=0.8286713286713286,
|
||||
n_strutture=4, n_tagli=8779, n_ore=213477, gate=0.0)}
|
||||
assert C.verdetto(s) == (
|
||||
ESITO + " — "
|
||||
"BTC: nessun ingresso pre-crollo in geometria (solo DURANTE: f_net 0.79, perde il 37%); "
|
||||
"ETH: f_net 0.74 (22/08 in rally 0,714); a scadenza f 1.04 ma TAUTOLOGICO nel 83% dei "
|
||||
"trade (entrambe le gambe ITM ⇒ dipende solo da f_net; 4 strutture, non 286 "
|
||||
"osservazioni); peggior MTM tagliato alla larghezza f 1.08 (quote oltre la larghezza "
|
||||
"nel 4.1% delle ore); gate IV-rank CHIUSO nel 0% delle ore "
|
||||
"— n = 1 crollo, 1 scadenza per asset, famiglia INVERSE")
|
||||
|
||||
|
||||
# ===========================================================================================
|
||||
# carica() e l'integrazione sull'archivio VERO
|
||||
# ===========================================================================================
|
||||
|
||||
def test_carica_tiene_solo_put_una_scadenza_la_finestra_e_nessun_duplicato():
|
||||
"""L'archivio ereditato copre molte scadenze e molti tipi: se il filtro si allentasse, il
|
||||
`f` verrebbe misurato su una struttura diversa da quella dichiarata nel docstring."""
|
||||
if not C.ARCHIVIO.exists():
|
||||
pytest.skip(f"archivio ereditato assente: {C.ARCHIVIO} (data/raw e' gitignored)")
|
||||
a = C.carica()
|
||||
assert len(a) > 0
|
||||
assert set(pd.unique(a["option_type"])) == {"P"}, "call rimaste dentro"
|
||||
assert (a["exp"] == C.EXP).all(), "piu' di una scadenza"
|
||||
assert a["ts"].min() >= C.INGRESSI[0], "quote prima della finestra dichiarata"
|
||||
assert a["ts"].max() <= C.EXP, "quote dopo la scadenza"
|
||||
assert not a.duplicated(subset=["ts", "instrument_name"]).any()
|
||||
assert (a["dte"] >= 0).all()
|
||||
assert a["dte"].max() <= (C.EXP - C.INGRESSI[0]).total_seconds() / 86400.0 + 1e-9
|
||||
assert {"BTC", "ETH"} <= set(pd.unique(a["asset"]))
|
||||
|
||||
|
||||
def test_integrazione_la_misura_del_09_09_si_riproduce():
|
||||
"""~11 s. Riproduce cio' su cui poggia la (non) riapertura della decisione del 19/06.
|
||||
|
||||
I fatti che decidono, e che il test tiene fermi:
|
||||
* il gate IV-rank di VRP01 e' CHIUSO nel 100% delle ore → il sleeve questo trade non lo
|
||||
avrebbe fatto, quindi il `f` misurato non e' il suo `f` di stress;
|
||||
* il `f` a scadenza (1,04) e' TAUTOLOGICO nella grande maggioranza dei trade;
|
||||
* 286 snapshot stanno su POCHE strutture: la risoluzione non e' 286 (M13);
|
||||
* BTC resta NON misurabile pre-crollo;
|
||||
* col taglio alla larghezza il peggior MTM e' vicino a 1, non 2,6 (che era book, non
|
||||
rischio) — qui si controlla la TAGLIA, non la terza cifra."""
|
||||
if not C.ARCHIVIO.exists():
|
||||
pytest.skip(f"archivio ereditato assente: {C.ARCHIVIO}")
|
||||
s = C.sintesi(C.misura())
|
||||
|
||||
eth = s["ETH"]
|
||||
assert eth["n_pre"] > 200, "il campione pre-crollo si e' svuotato"
|
||||
assert eth["f_net"] == pytest.approx(0.74, abs=0.03)
|
||||
assert eth["f_exp"] == pytest.approx(1.04, abs=0.03)
|
||||
assert eth["q_itm_pieno"] == pytest.approx(0.83, abs=0.10), (
|
||||
"la quota di trade a f_exp tautologico e' cambiata: cambia la lettura del 1,04")
|
||||
assert eth["n_strutture"] <= 10, (
|
||||
f"{eth['n_pre']} snapshot su {eth['n_strutture']} strutture: se le strutture "
|
||||
"diventassero tante, la nota sulla risoluzione andrebbe riscritta")
|
||||
assert eth["gate_aperto"] == pytest.approx(0.0, abs=1e-12), (
|
||||
"il gate IV-rank di VRP01 risulta APERTO: cambia il senso della misura, che oggi dice "
|
||||
"'lo sleeve questo trade non lo avrebbe fatto'")
|
||||
assert eth["perdita_su_risk"] == pytest.approx(1.01, abs=0.05)
|
||||
assert 0.9 < eth["f_mtm"] < 1.5, (
|
||||
f"peggior MTM {eth['f_mtm']:.2f}: col taglio alla larghezza sta attorno a 1; sopra 1,5 "
|
||||
"vuol dire che il taglio non morde piu' e il numero torna a essere book, non rischio")
|
||||
assert 0 < eth["n_tagli"] < eth["n_ore"] * 0.20, (
|
||||
"il taglio deve mordere su una minoranza di ore: se e' zero non serve, se e' la "
|
||||
"maggioranza il campione e' un altro")
|
||||
|
||||
btc = s["BTC"]
|
||||
assert btc["n_pre"] == 0 and np.isnan(btc["f_exp"]), (
|
||||
"BTC era NON misurabile pre-crollo: se ora lo e', il verdetto cambia e va riscritto, "
|
||||
"non aggiustato")
|
||||
assert btc["durante"]["n"] > 0 and 0.0 < btc["durante"]["q_perde"] < 1.0
|
||||
|
||||
v = C.verdetto(s)
|
||||
assert v.startswith(ESITO)
|
||||
assert "BTC: nessun ingresso pre-crollo in geometria" in v
|
||||
assert "gate IV-rank CHIUSO nel 0% delle ore" in v
|
||||
assert v.endswith("famiglia INVERSE")
|
||||
@@ -0,0 +1,324 @@
|
||||
"""Test di `scripts/research/r0909_deribit_universo.py` (2026-09-09) — l'universo Deribit letto oggi.
|
||||
|
||||
Lo script e' il filtro che decide COSA vale la pena misurare (C6): se sbaglia a leggere il venue,
|
||||
sbaglia l'elenco delle monete candidate, e la misura successiva parte dalla domanda sbagliata.
|
||||
Qui si congelano i tre pezzi che possono sbagliare in silenzio:
|
||||
|
||||
* i FILTRI — un perpetual `state != "open"` non e' negoziabile e non deve comparire in una
|
||||
tabella intitolata "aperti"; un future datato non e' un perpetual;
|
||||
* lo SPREAD assente — senza bid o senza ask lo spread e' NaN, non zero (P5: "non vedo" non e'
|
||||
"spread perfetto", e uno zero stampato in quella colonna direbbe il contrario del vero);
|
||||
* l'universo di XS01 — si IMPORTA da `sleeves.XS_UNIVERSE` (P1). Una lista ricopiata qui
|
||||
risponderebbe "13/19 quotate" anche dopo che qualcuno ha cambiato le gambe del sleeve.
|
||||
|
||||
⚠️ NESSUN test tocca la rete e nessuno scrive in `data/`: la cache viene deviata in `tmp_path`
|
||||
e `get()` e' sostituita da una che fallisce se qualcuno la chiama.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||
|
||||
import r0909_deribit_universo as U # noqa: E402
|
||||
|
||||
LETTO_A = "2026-09-09T15:05:43+00:00"
|
||||
|
||||
|
||||
def _ms(giorno: str) -> int:
|
||||
return int(time.mktime(time.strptime(giorno, "%Y-%m-%d")) * 1000) + int(
|
||||
time.timezone * 1000)
|
||||
|
||||
|
||||
def _instr(coin: str, nome: str, stato: str, listato: str, min_amt: float) -> dict:
|
||||
# creation_timestamp e' letto con time.gmtime -> si costruisce in UTC puro
|
||||
import calendar
|
||||
ts = calendar.timegm(time.strptime(listato + " 12:00", "%Y-%m-%d %H:%M")) * 1000
|
||||
return dict(base_currency=coin, instrument_name=nome, state=stato,
|
||||
creation_timestamp=ts, min_trade_amount=min_amt)
|
||||
|
||||
|
||||
def _bs(nome: str, bid, ask, mark, vol, oi=0.0, funding=0.0) -> dict:
|
||||
return dict(instrument_name=nome, bid_price=bid, ask_price=ask, mark_price=mark,
|
||||
volume_usd=vol, open_interest=oi, funding_8h=funding)
|
||||
|
||||
|
||||
def _lettura() -> dict:
|
||||
"""Una lettura sintetica del venue: 4 perpetual aperti, 1 INATTIVO, 2 future datati (di cui
|
||||
uno sul DVOL — l'unico strumento long-vol diretto del venue)."""
|
||||
fut = [
|
||||
_instr("XRP", "XRP_USDC-PERPETUAL", "open", "2022-03-16", 1.0),
|
||||
_instr("ETH", "ETH_USDC-PERPETUAL", "open", "2022-03-14", 0.01),
|
||||
_instr("ADA", "ADA_USDC-PERPETUAL", "open", "2022-03-21", 10.0),
|
||||
_instr("PAXG", "PAXG_USDC-PERPETUAL", "open", "2024-12-02", 0.001),
|
||||
_instr("APT", "APT_USDC-PERPETUAL", "inactive", "2022-11-02", 1.0),
|
||||
_instr("BTC", "BTC_USDC-27JUN26", "open", "2022-03-09", 0.001),
|
||||
_instr("BTCDVOL", "BTCDVOL_USDC-30SEP26", "open", "2026-06-01", 1.0),
|
||||
]
|
||||
bs = [
|
||||
_bs("XRP_USDC-PERPETUAL", 2.0, 2.002, 2.0, 38.13e6, oi=3.0e6, funding=4.0e-5),
|
||||
_bs("ETH_USDC-PERPETUAL", 3000.0, 3000.3, 3000.0, 32.72e6, oi=8.0e3, funding=1.05e-4),
|
||||
_bs("ADA_USDC-PERPETUAL", None, 0.6, 0.6, 3.33e6, oi=1.0e6, funding=None),
|
||||
_bs("PAXG_USDC-PERPETUAL", 0.0, 3600.0, 3600.0, 0.18e6, oi=2.0e3, funding=0.0),
|
||||
_bs("APT_USDC-PERPETUAL", 1.0, 1.1, 1.0, 999e6, oi=1.0, funding=0.0),
|
||||
_bs("BTC_USDC-27JUN26", 100000.0, 100001.0, 100000.0, 500e6, oi=1.0, funding=0.0),
|
||||
_bs("BTCDVOL_USDC-30SEP26", 30.0, 60.0, 45.0, 0.0, oi=12.0, funding=None),
|
||||
]
|
||||
return dict(letto_a=LETTO_A, fut=fut, bs=bs,
|
||||
opt_usdc=[dict(base_currency="SOL")] * 3 + [dict(base_currency="BTC")] * 2,
|
||||
opt_inv={"BTC": 912, "ETH": 790})
|
||||
|
||||
|
||||
# ===========================================================================================
|
||||
# (a) tabella / sintesi / verdetto su una lettura SINTETICA
|
||||
# ===========================================================================================
|
||||
|
||||
def test_tabella_esclude_i_perpetual_NON_APERTI():
|
||||
"""Un perpetual `state != "open"` non e' negoziabile: se entrasse, sarebbe pure il piu'
|
||||
liquido della tabella (999 M$ di volume stantio) e comparirebbe in cima all'elenco dei
|
||||
candidati. DEVE FALLIRE SE: qualcuno toglie il filtro sullo stato."""
|
||||
d = _lettura()
|
||||
rows = U.tabella(d)
|
||||
assert "APT" not in {r["coin"] for r in rows}
|
||||
s = U.sintesi(rows, d)
|
||||
assert s["inattivi"] == ["APT_USDC-PERPETUAL"], (
|
||||
"un perpetual inattivo non va nella tabella, ma va DETTO: e' la differenza fra "
|
||||
"'non esiste' e 'esiste e non si puo' negoziare' (P5)")
|
||||
|
||||
|
||||
def test_un_perpetual_INATTIVO_conta_come_gamba_XS01_MANCANTE():
|
||||
"""APT e' in XS01 ed e' listato sul venue, ma inattivo: la riga "N/19 quotate" deve
|
||||
contarlo fra i mancanti, o il filtro di eseguibilita' direbbe il falso (C6)."""
|
||||
d = _lettura()
|
||||
s = U.sintesi(U.tabella(d), d)
|
||||
assert "APT" in s["xs01_mancanti"] and "APT" not in s["xs01_listate"]
|
||||
|
||||
|
||||
def test_tabella_esclude_i_FUTURE_DATATI_ma_la_sintesi_li_CONTA():
|
||||
"""`kind=future` su Deribit contiene anche i future con scadenza: la domanda della tabella
|
||||
e' sui PERPETUAL, e un future datato non e' una gamba direzionale continua. Restano contati
|
||||
(`n_datati`) perche' e' li' dentro che vive il future sul DVOL."""
|
||||
d = _lettura()
|
||||
rows = U.tabella(d)
|
||||
assert {r["coin"] for r in rows} == {"XRP", "ETH", "ADA", "PAXG"}
|
||||
assert len(rows) == 4
|
||||
assert U.sintesi(rows, d)["n_datati"] == 2
|
||||
|
||||
|
||||
def test_il_future_sul_DVOL_viene_isolato_coi_suoi_numeri_di_negoziabilita():
|
||||
"""C5/C6: e' l'unico strumento long-vol DIRETTO del venue, quindi va guardato — e va
|
||||
guardato con bid/ask e OI accanto, perche' e' li' che si vede che non e' negoziabile
|
||||
(bid 30 / ask 60 = 67% di spread). Un elenco di nomi non basterebbe."""
|
||||
d = _lettura()
|
||||
s = U.sintesi(U.tabella(d), d)
|
||||
assert [f["nome"] for f in s["dvol_fut"]] == ["BTCDVOL_USDC-30SEP26"], (
|
||||
"il future datato SENZA 'DVOL' nel nome non deve entrare qui")
|
||||
f = s["dvol_fut"][0]
|
||||
assert (f["stato"], f["min_amt"], f["bid"], f["ask"], f["oi"]) == ("open", 1.0, 30.0, 60.0, 12.0)
|
||||
assert f["vol_usd"] == 0.0
|
||||
|
||||
|
||||
def test_tabella_e_ORDINATA_per_volume_decrescente():
|
||||
rows = U.tabella(_lettura())
|
||||
vol = [r["vol_usd"] for r in rows]
|
||||
assert vol == sorted(vol, reverse=True)
|
||||
assert [r["coin"] for r in rows] == ["XRP", "ETH", "ADA", "PAXG"]
|
||||
|
||||
|
||||
def test_lo_spread_e_NAN_senza_bid_o_senza_ask():
|
||||
"""P5: senza un lato del book lo spread non e' 0 bps (che vorrebbe dire "perfetto"), e'
|
||||
"non misurabile". DEVE FALLIRE SE: qualcuno mette un `or 0.0` anche su bid/ask."""
|
||||
rows = {r["coin"]: r for r in U.tabella(_lettura())}
|
||||
assert np.isnan(rows["ADA"]["spread_bps"]), "bid assente -> spread non misurabile"
|
||||
assert np.isnan(rows["PAXG"]["spread_bps"]), "bid a zero -> spread non misurabile"
|
||||
assert rows["XRP"]["spread_bps"] == pytest.approx((2.002 - 2.0) / 2.001 * 1e4)
|
||||
assert rows["ETH"]["spread_bps"] == pytest.approx(0.3 / 3000.15 * 1e4)
|
||||
|
||||
|
||||
def test_il_lotto_minimo_e_in_DOLLARI_non_in_unita():
|
||||
"""C3: prima di misurare il rendimento a un capitale dato si misura il lotto minimo del
|
||||
venue — e il confronto col pavimento $5 si fa in dollari, non in unita' della moneta."""
|
||||
rows = {r["coin"]: r for r in U.tabella(_lettura())}
|
||||
assert rows["XRP"]["min_usd"] == pytest.approx(1.0 * 2.0)
|
||||
assert rows["ADA"]["min_usd"] == pytest.approx(10.0 * 0.6)
|
||||
assert rows["ETH"]["min_amt"] == 0.01
|
||||
|
||||
|
||||
def test_la_data_di_listing_e_quella_del_venue():
|
||||
rows = {r["coin"]: r for r in U.tabella(_lettura())}
|
||||
assert rows["XRP"]["listato"] == "2022-03-16"
|
||||
assert rows["PAXG"]["listato"] == "2024-12-02"
|
||||
|
||||
|
||||
def test_P1_le_gambe_di_XS01_si_IMPORTANO_dal_sleeve():
|
||||
"""*Un controllo puntato su una configurazione diversa da quella che gira passa sempre.*
|
||||
Se l'elenco fosse ricopiato nello script, "13/19 quotate" resterebbe vero anche dopo un
|
||||
cambio di universo del sleeve — cioe' smetterebbe di controllare qualcosa."""
|
||||
from src.portfolio import sleeves
|
||||
assert U.XS_UNIVERSE is sleeves.XS_UNIVERSE
|
||||
src = inspect.getsource(U)
|
||||
assert "from src.portfolio.sleeves import XS_UNIVERSE" in src
|
||||
assert "XS_UNIVERSE = [" not in src, "universo XS01 ridichiarato nello script"
|
||||
|
||||
|
||||
def test_le_gambe_XS01_mancanti_si_CALCOLANO_dall_universo():
|
||||
"""La riga "mancano [...]" e' l'elenco delle gambe di XS01 non quotate sul venue: si
|
||||
ricava dall'universo importato, quindi si aggiorna da sola se il sleeve cambia."""
|
||||
from src.portfolio.sleeves import XS_UNIVERSE
|
||||
rows = U.tabella(_lettura())
|
||||
s = U.sintesi(rows, _lettura())
|
||||
quotate = {"XRP", "ETH", "ADA"} # PAXG non e' in XS01
|
||||
assert s["xs01_listate"] == sorted(c for c in XS_UNIVERSE if c in quotate)
|
||||
assert s["xs01_mancanti"] == [c for c in XS_UNIVERSE if c not in quotate]
|
||||
assert set(s["xs01_listate"]) | set(s["xs01_mancanti"]) == set(XS_UNIVERSE)
|
||||
assert {r["coin"] for r in rows if r["xs01"]} == quotate
|
||||
|
||||
|
||||
def test_liquidi_usa_la_soglia_dichiarata_e_il_confine_e_incluso():
|
||||
"""La soglia $10M/giorno e' dichiarata nel modulo: il test la DERIVA, non la ricopia."""
|
||||
d = _lettura()
|
||||
for b in d["bs"]:
|
||||
if b["instrument_name"] == "ADA_USDC-PERPETUAL":
|
||||
b["volume_usd"] = U.SOGLIA_LIQUIDO_USD # esattamente in soglia -> liquido
|
||||
if b["instrument_name"] == "PAXG_USDC-PERPETUAL":
|
||||
b["volume_usd"] = U.SOGLIA_LIQUIDO_USD - 1.0 # un dollaro sotto -> no
|
||||
s = U.sintesi(U.tabella(d), d)
|
||||
assert s["liquidi"] == ["XRP", "ETH", "ADA"]
|
||||
|
||||
|
||||
def test_sintesi_riporta_le_famiglie_di_opzioni_e_l_ora_della_lettura():
|
||||
"""N10/§7: un numero letto dal venue vale con la sua ora. Senza `letto_a` la riga
|
||||
diventerebbe una verita' senza data."""
|
||||
d = _lettura()
|
||||
s = U.sintesi(U.tabella(d), d)
|
||||
assert s["letto_a"] == LETTO_A
|
||||
assert s["opt_inv"] == {"BTC": 912, "ETH": 790}
|
||||
assert s["opt_usdc"] == [("SOL", 3), ("BTC", 2)]
|
||||
assert s["n_perp"] == 4
|
||||
|
||||
|
||||
def test_verdetto_su_una_lettura_sintetica():
|
||||
d = _lettura()
|
||||
s = U.sintesi(U.tabella(d), d)
|
||||
v = U.verdetto(s)
|
||||
assert v.startswith("4 perpetual USDC; LIQUIDI (≥$10M/g): XRP, ETH; ")
|
||||
assert "XS01 3/19 quotate" in v
|
||||
assert "opzioni USDC-lineari su 2 sottostanti" in v
|
||||
assert f"lettura del {LETTO_A}" in v
|
||||
|
||||
|
||||
# ===========================================================================================
|
||||
# (b) leggi() — la cache, e la garanzia che NESSUN test vada in rete
|
||||
# ===========================================================================================
|
||||
|
||||
@pytest.fixture()
|
||||
def cache_in_tmp(tmp_path, monkeypatch):
|
||||
"""Devia la cache fuori da `data/_cache/` (debito §5.12: la suite non scrive nei file vivi)."""
|
||||
p = tmp_path / "r0909_deribit_universo.json"
|
||||
monkeypatch.setattr(U, "CACHE", p)
|
||||
return p
|
||||
|
||||
|
||||
def test_leggi_con_cache_presente_NON_tocca_la_rete(cache_in_tmp, monkeypatch):
|
||||
"""DEVE FALLIRE SE: qualcuno rende la lettura sempre fresca — la suite comincerebbe a
|
||||
dipendere dalla rete e dal venue, e un test rosso non direbbe piu' niente sul codice."""
|
||||
def _vietato(path):
|
||||
raise AssertionError(f"chiamata di rete durante un test: {path}")
|
||||
monkeypatch.setattr(U, "get", _vietato)
|
||||
|
||||
atteso = _lettura()
|
||||
cache_in_tmp.write_text(json.dumps(atteso))
|
||||
d = U.leggi(usa_cache=True)
|
||||
assert d["letto_a"] == LETTO_A
|
||||
assert len(d["fut"]) == len(atteso["fut"])
|
||||
# (i NaN dello spread non sono confrontabili con ==: si confronta il resto)
|
||||
chiave = lambda rows: [(r["coin"], r["listato"], r["vol_usd"], r["min_usd"], r["xs01"])
|
||||
for r in rows]
|
||||
assert chiave(U.tabella(d)) == chiave(U.tabella(atteso))
|
||||
|
||||
|
||||
def test_leggi_senza_cache_interroga_il_venue_UNA_volta_e_la_scrive(cache_in_tmp, monkeypatch):
|
||||
"""La cache non e' un'ottimizzazione: e' la FONTE del numero stampato (una lettura e' un
|
||||
istante). Deve finire su disco con la sua ora, o la riga del diario non ha piu' fonte."""
|
||||
chiamate = []
|
||||
|
||||
def _finto(path):
|
||||
chiamate.append(path)
|
||||
if "get_book_summary" in path:
|
||||
return _lettura()["bs"]
|
||||
if "kind=option" in path and "currency=USDC" in path:
|
||||
return _lettura()["opt_usdc"]
|
||||
if "kind=option" in path:
|
||||
return [0] * 912
|
||||
return _lettura()["fut"]
|
||||
monkeypatch.setattr(U, "get", _finto)
|
||||
|
||||
assert not cache_in_tmp.exists()
|
||||
d = U.leggi(usa_cache=True)
|
||||
assert cache_in_tmp.exists(), "la lettura non e' stata salvata: il numero resta senza fonte"
|
||||
assert json.loads(cache_in_tmp.read_text())["letto_a"] == d["letto_a"]
|
||||
assert d["letto_a"].endswith("+00:00"), "l'ora della lettura dev'essere in UTC dichiarato"
|
||||
assert d["opt_inv"] == {"BTC": 912, "ETH": 912}
|
||||
assert len(chiamate) == 5, "una chiamata per famiglia: fut, book summary, opzioni USDC, BTC, ETH"
|
||||
|
||||
# e una seconda lettura con cache NON ripete le chiamate
|
||||
prima = len(chiamate)
|
||||
U.leggi(usa_cache=True)
|
||||
assert len(chiamate) == prima
|
||||
|
||||
|
||||
def test_fresh_ignora_la_cache(cache_in_tmp, monkeypatch):
|
||||
"""`--fresh` deve rileggere davvero: e' l'unico modo di aggiornare una tabella che per
|
||||
costruzione invecchia (i perpetual del 18/08/2026 non c'erano un mese prima)."""
|
||||
cache_in_tmp.write_text(json.dumps(_lettura()))
|
||||
chiamate = []
|
||||
monkeypatch.setattr(U, "get", lambda path: chiamate.append(path) or [])
|
||||
U.leggi(usa_cache=False)
|
||||
assert chiamate, "con usa_cache=False la cache non va usata"
|
||||
|
||||
|
||||
def test_il_flag_fresh_e_DOCUMENTATO_e_cablato():
|
||||
"""P15: una regola operativa va provata contro il codice che dovrebbe eseguirla. Qui:
|
||||
il flag esiste nel `main`, e' l'unico modo di bypassare la cache, ed e' scritto nell'uso
|
||||
che lo script stampa (chi legge la tabella deve sapere che puo' essere vecchia)."""
|
||||
src = inspect.getsource(U)
|
||||
assert 'leggi(usa_cache="--fresh" not in sys.argv)' in src, "il flag non e' cablato"
|
||||
assert "--fresh per rileggere" in src, (
|
||||
"il flag non e' documentato nell'uso che lo script stampa: chi legge la tabella non "
|
||||
"sa come aggiornarla, e la lettura in cache invecchia in silenzio")
|
||||
|
||||
|
||||
def test_lo_script_NON_ha_credenziali_ne_ordini():
|
||||
"""L'API interrogata e' quella PUBBLICA: nessun token, nessun endpoint privato, nessun
|
||||
ordine. Se un giorno servisse il conto, e' una decisione (debito §5.11), non un refactor."""
|
||||
src = inspect.getsource(U)
|
||||
assert U.API == "https://www.deribit.com/api/v2/public/"
|
||||
for vietato in ("private/", "buy", "sell", "CERBERO", "api_key", "client_id"):
|
||||
assert vietato not in src, f"lo script pubblico contiene '{vietato}'"
|
||||
|
||||
|
||||
# ===========================================================================================
|
||||
# la lettura VERA in cache (se c'e'): il file su cui poggia la riga del 09/09
|
||||
# ===========================================================================================
|
||||
|
||||
def test_la_cache_vera_se_c_e_regge_la_tabella_pubblicata():
|
||||
"""Non e' un test di rete: legge il JSON che lo script ha gia' salvato. Se manca, si salta."""
|
||||
if not U.CACHE.exists():
|
||||
pytest.skip(f"nessuna lettura in cache ({U.CACHE}): il test non va in rete")
|
||||
d = json.loads(U.CACHE.read_text())
|
||||
rows = U.tabella(d)
|
||||
s = U.sintesi(rows, d)
|
||||
assert s["n_perp"] == len(rows) > 0
|
||||
assert [r["vol_usd"] for r in rows] == sorted([r["vol_usd"] for r in rows], reverse=True)
|
||||
assert {"BTC", "ETH"} <= {r["coin"] for r in rows}, "il venue non quota piu' BTC/ETH?"
|
||||
assert set(s["xs01_listate"]) | set(s["xs01_mancanti"]) == set(U.XS_UNIVERSE)
|
||||
assert U.verdetto(s).endswith(f"lettura del {d['letto_a']}")
|
||||
@@ -0,0 +1,385 @@
|
||||
"""Test di `scripts/research/r0909_libro_nei_crolli.py` (2026-09-09) — il libro nei crolli.
|
||||
|
||||
Cosa protegge, in due strati:
|
||||
|
||||
* il MECCANISMO su dati SINTETICI, dove la risposta e' nota per costruzione — le classi
|
||||
ESCLUSIVE col loro ordine, le finestre peggiori non sovrapposte, il beta con pendenza
|
||||
imposta, i rami del verdetto. Qui un test rosso significa "il codice ha cambiato
|
||||
comportamento", mai "il mercato ha cambiato idea".
|
||||
* la RIPRODUZIONE del risultato pubblicato il 09/09 sui dati veri: 4 POSITIVO / 6 immune /
|
||||
2 PERDE su 12 finestre, libro > 0 in 8 (dal 2022: 8/8), e beta +0,0769 sulla finestra di
|
||||
§46 che riproduce il +0,076 della memoria. E' M23: prima di pubblicare un numero nuovo, la
|
||||
macchina deve ristampare quello vecchio.
|
||||
|
||||
🚨 LA REGRESSIONE PIU' IMPORTANTE DI QUESTO FILE. `classifica()` prova **immune PRIMA di
|
||||
POSITIVO**, e l'ordine non e' un dettaglio: con l'ordine opposto lo stesso identico dato dava
|
||||
"8/12 episodi in utile" invece di "4 POSITIVO / 6 immune". *L'ordine delle regole ERA il
|
||||
verdetto* (corretto in revisione il 09/09). Un test lo congela.
|
||||
|
||||
⚠️ P1: i pesi del libro NON si ridichiarano — un test congela l'identita' fra i pesi usati dallo
|
||||
script e `src.live.book.W_TP01` / `W_SKH`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||
|
||||
import r0909_libro_nei_crolli as M # noqa: E402
|
||||
|
||||
RAW = ROOT / "data" / "raw"
|
||||
|
||||
|
||||
def _feed_certificato_presente() -> bool:
|
||||
return all((RAW / f"{a}_{tf}.parquet").exists()
|
||||
for a in ("btc", "eth") for tf in ("1h", "5m"))
|
||||
|
||||
|
||||
def _giorni(n: int, inizio: str = "2021-01-01") -> pd.DatetimeIndex:
|
||||
return pd.date_range(inizio, periods=n, freq="D", tz="UTC")
|
||||
|
||||
|
||||
# ===========================================================================================
|
||||
# classifica() — classi ESCLUSIVE, e l'ORDINE in cui si provano
|
||||
# ===========================================================================================
|
||||
|
||||
def test_classifica_prova_IMMUNE_PRIMA_di_POSITIVO():
|
||||
"""🚨 La regressione del 09/09. Sugli stessi numeri l'ordine "POSITIVO prima" dava 8/12
|
||||
episodi in utile; l'ordine dichiarato (immune prima) da' 4 POSITIVO + 6 immune.
|
||||
DEVE FALLIRE SE: qualcuno rimette il test del segno davanti alla banda di immunita' —
|
||||
non e' un refactor, e' un altro verdetto."""
|
||||
# peggiore #8 del 09/09: +0,21% di libro contro −26,54% di indice. E' dentro un decimo.
|
||||
assert M.classifica(0.0021, -0.2654) == "immune+"
|
||||
# peggiore #5: +4,26% contro −31,61% -> fuori dalla banda, e allora e' POSITIVO
|
||||
assert M.classifica(0.0426, -0.3161) == "POSITIVO"
|
||||
|
||||
|
||||
def test_classifica_immune_porta_il_SEGNO_con_se():
|
||||
""""immune" non e' una classe cieca: +0,21% e −0,52% su un crollo del 26% sono entrambi
|
||||
immunita', ma non sono la stessa notizia. DEVE FALLIRE SE: le due si fondono in una."""
|
||||
assert M.classifica(0.0021, -0.2654) == "immune+"
|
||||
assert M.classifica(-0.0052, -0.2583) == "immune-"
|
||||
assert set(M.classifica(x, -0.30) for x in (0.001, -0.001)) == {"immune+", "immune-"}
|
||||
|
||||
|
||||
def test_classifica_fuori_dalla_banda_resta_il_segno():
|
||||
assert M.classifica(+0.05, -0.30) == "POSITIVO"
|
||||
assert M.classifica(-0.05, -0.30) == "PERDE"
|
||||
assert M.classifica(-0.0637, -0.3411) == "PERDE" # peggiore #1 del 09/09
|
||||
|
||||
|
||||
def test_classifica_il_confine_della_banda_e_STRETTO():
|
||||
"""Esattamente un decimo NON e' immune: il confronto e' `<`, non `<=`.
|
||||
DEVE FALLIRE SE: qualcuno lo rilassa e sposta episodi dentro la banda."""
|
||||
assert M.classifica(-0.010, -0.10) == "PERDE"
|
||||
assert M.classifica(-0.010000001, -0.10) == "PERDE"
|
||||
assert M.classifica(-0.009999999, -0.10) == "immune-"
|
||||
assert M.classifica(+0.010, -0.10) == "POSITIVO"
|
||||
assert M.classifica(+0.009999999, -0.10) == "immune+"
|
||||
|
||||
|
||||
def test_classifica_a_indice_zero_non_esiste_immunita():
|
||||
"""Caso limite DICHIARATO (non un bug): con indice 0 la banda ha ampiezza 0, quindi nulla
|
||||
e' immune. Non morde negli episodi (per costruzione hanno indice molto negativo), ma chi
|
||||
riusa `classifica()` altrove deve saperlo."""
|
||||
assert M.classifica(0.0, 0.0) == "PERDE"
|
||||
assert M.classifica(+0.001, 0.0) == "POSITIVO"
|
||||
|
||||
|
||||
def test_classifica_un_libro_esattamente_fermo_e_immune_col_segno_MENO():
|
||||
"""Zero non e' positivo: `libro > 0` e' falso, quindi la classe e' "immune-".
|
||||
E' una convenzione, e va scritta perche' non e' l'unica possibile."""
|
||||
assert M.classifica(0.0, -0.20) == "immune-"
|
||||
|
||||
|
||||
# ===========================================================================================
|
||||
# finestre_peggiori() — non sovrapposte, ordinate, al piu' N, e la peggiore e' la peggiore
|
||||
# ===========================================================================================
|
||||
|
||||
def _serie_con_crolli(crolli: list[tuple[int, float]], n: int = 300) -> pd.Series:
|
||||
"""Serie giornaliera piatta con crolli piantati a posizioni note: ogni `(i0, r)` mette
|
||||
20 giorni consecutivi a rendimento `r` a partire dalla posizione `i0`."""
|
||||
r = pd.Series(0.0, index=_giorni(n))
|
||||
for i0, val in crolli:
|
||||
r.iloc[i0:i0 + M.FINESTRA] = val
|
||||
return r
|
||||
|
||||
|
||||
CROLLI = [(30, -0.010), (100, -0.005), (180, -0.002)]
|
||||
|
||||
|
||||
def test_finestre_peggiori_non_si_sovrappongono_e_sono_ordinate():
|
||||
"""DEVE FALLIRE SE: il greedy smette di marcare gli intervalli occupati — senza quel filtro
|
||||
le 12 "peggiori finestre" sarebbero 12 traslazioni di UNO stesso episodio, e la riga
|
||||
"4/12" conterebbe 12 volte lo stesso crollo."""
|
||||
idx = _serie_con_crolli(CROLLI)
|
||||
prese = M.finestre_peggiori(idx, n=3)
|
||||
assert len(prese) == 3
|
||||
assert prese == sorted(prese), "l'elenco esce ordinato per data d'inizio"
|
||||
for (s0, e0, _), (s1, _, _) in zip(prese, prese[1:]):
|
||||
assert s1 > e0, f"finestre sovrapposte: {e0} e {s1}"
|
||||
for s, e, _ in prese:
|
||||
assert e - s == pd.Timedelta(days=M.FINESTRA - 1)
|
||||
|
||||
|
||||
def test_finestre_peggiori_trova_DAVVERO_la_peggiore():
|
||||
"""Il primo episodio dev'essere il minimo globale del rolling, non un minimo locale.
|
||||
DEVE FALLIRE SE: qualcuno sostituisce il greedy dal peggiore con una scansione in avanti."""
|
||||
idx = _serie_con_crolli(CROLLI)
|
||||
prese = M.finestre_peggiori(idx, n=3)
|
||||
roll = (1 + idx).rolling(M.FINESTRA).apply(np.prod, raw=True) - 1
|
||||
peggiore = min(prese, key=lambda t: t[2])
|
||||
assert peggiore[2] == pytest.approx(float(roll.min()), abs=1e-12)
|
||||
assert peggiore[2] == pytest.approx((1 - 0.010) ** M.FINESTRA - 1, abs=1e-12)
|
||||
assert peggiore[0] == idx.index[30] and peggiore[1] == idx.index[49]
|
||||
valori = sorted(v for _, _, v in prese)
|
||||
attesi = sorted((1 + r) ** M.FINESTRA - 1 for _, r in CROLLI)
|
||||
assert valori == pytest.approx(attesi, abs=1e-12)
|
||||
|
||||
|
||||
def test_finestre_peggiori_rispetta_il_tetto_n():
|
||||
idx = _serie_con_crolli(CROLLI)
|
||||
assert len(M.finestre_peggiori(idx, n=2)) == 2
|
||||
assert len(M.finestre_peggiori(idx, n=1)) == 1
|
||||
assert len(M.finestre_peggiori(idx, n=100)) <= 100
|
||||
|
||||
|
||||
# ===========================================================================================
|
||||
# beta_pieno / beta_ribasso — pendenza NOTA per costruzione
|
||||
# ===========================================================================================
|
||||
|
||||
def test_beta_pieno_ritrova_una_pendenza_nota():
|
||||
rng = np.random.default_rng(909)
|
||||
i = pd.Series(rng.normal(0.0, 0.03, 400), index=_giorni(400))
|
||||
assert M.beta_pieno(0.30 * i, i) == pytest.approx(0.30, abs=1e-9)
|
||||
assert M.beta_pieno(0.0 * i, i) == pytest.approx(0.0, abs=1e-12)
|
||||
assert M.beta_pieno(-0.75 * i, i) == pytest.approx(-0.75, abs=1e-9)
|
||||
|
||||
|
||||
def test_beta_ribasso_guarda_SOLO_i_giorni_giu():
|
||||
"""Controllo POSITIVO: su una serie asimmetrica (0,50 in salita, 0,10 in discesa) il beta
|
||||
pieno NON puo' rispondere alla domanda del crollo — il beta al ribasso si'.
|
||||
DEVE FALLIRE SE: il filtro `idx < soglia` sparisce e le due misure collassano in una."""
|
||||
rng = np.random.default_rng(46)
|
||||
i = pd.Series(rng.normal(0.0, 0.03, 600), index=_giorni(600))
|
||||
libro = pd.Series(np.where(i.values < 0, 0.10 * i.values, 0.50 * i.values), index=i.index)
|
||||
assert M.beta_ribasso(libro, i) == pytest.approx(0.10, abs=1e-9)
|
||||
assert M.beta_pieno(libro, i) > 0.20, "il beta pieno MEDIA i due regimi: e' un'altra domanda"
|
||||
|
||||
|
||||
def test_beta_ribasso_usa_la_soglia_che_riceve():
|
||||
"""La riga [1] stampa quattro beta (pieno, finestra §46, idx<0, idx≤−3%): sono lenti
|
||||
diverse, non una."""
|
||||
rng = np.random.default_rng(3)
|
||||
i = pd.Series(rng.normal(0.0, 0.03, 800), index=_giorni(800))
|
||||
forte = i.values < -0.03
|
||||
libro = pd.Series(np.where(forte, 0.90 * i.values, 0.10 * i.values), index=i.index)
|
||||
assert (i < -0.03).sum() >= 20, "campione insufficiente: il test non avrebbe potenza"
|
||||
b3 = M.beta_ribasso(libro, i, -0.03)
|
||||
b0 = M.beta_ribasso(libro, i, 0.0)
|
||||
assert b3 == pytest.approx(0.90, abs=1e-9), "sotto −3% la pendenza e' quella imposta"
|
||||
assert abs(b0 - b3) > 0.05, (
|
||||
"la soglia non entra nel calcolo: le lenti darebbero lo stesso numero e la riga [1] "
|
||||
"stamperebbe quattro volte la stessa cosa")
|
||||
|
||||
|
||||
def test_beta_ribasso_e_NAN_sotto_venti_osservazioni():
|
||||
"""P5: "non vedo" non e' "beta zero". DEVE FALLIRE SE: qualcuno toglie la guardia e
|
||||
pubblica una pendenza stimata su tre giorni."""
|
||||
i = pd.Series(np.linspace(-0.05, -0.01, 10), index=_giorni(10))
|
||||
assert np.isnan(M.beta_ribasso(0.3 * i, i)), "10 giorni non fanno una pendenza"
|
||||
i25 = pd.Series(np.linspace(-0.05, -0.01, 25), index=_giorni(25))
|
||||
assert M.beta_ribasso(0.3 * i25, i25) == pytest.approx(0.30, abs=1e-9)
|
||||
|
||||
|
||||
def test_la_finestra_di_46_e_dichiarata_e_non_e_tutta_la_storia():
|
||||
"""§46 e' un numero NATO su una finestra che comincia col DVOL (2021-03-24): confrontarlo
|
||||
col beta di tutta la storia sarebbe P7 al contrario (etichettare un numero con la finestra
|
||||
sbagliata). Lo script tiene le due misure separate — questo test lo congela."""
|
||||
assert M.FINESTRA_46 == pd.Timestamp("2021-03-24", tz="UTC")
|
||||
assert M.BETA_MEMORIA == 0.076 and M.TOL_BETA == 0.01
|
||||
|
||||
|
||||
# ===========================================================================================
|
||||
# verdetto() — TRE affermazioni, tre domande diverse
|
||||
# ===========================================================================================
|
||||
|
||||
def _m(guadagna=4, immune=6, n=12, beta_46=0.0769, giorni_libro=-0.0048,
|
||||
giorni_pos=0.175, positivi=8, dal22=(8, 8), quota=1.08) -> dict:
|
||||
return dict(beta_46=beta_46,
|
||||
sintesi=dict(n_episodi=n, guadagna=guadagna, immune=immune,
|
||||
perde=n - guadagna - immune, positivi=positivi, dal22=dal22,
|
||||
giorni_pos=giorni_pos, giorni_libro=giorni_libro,
|
||||
quota_short_nei_guadagni=quota))
|
||||
|
||||
|
||||
def test_verdetto_separa_il_GIORNO_dalla_FINESTRA():
|
||||
"""Le due domande hanno risposte OPPOSTE sugli stessi dati (nel giorno perde, per finestra
|
||||
e' immune-o-positivo), ed e' il fatto centrale del 09/09: SKH01 e' marcato all'uscita, e
|
||||
uno short aperto nel crollo viene accreditato dopo. Un solo aggettivo mentirebbe."""
|
||||
v = M.verdetto(_m())
|
||||
assert v.startswith("NEL GIORNO DEL CROLLO IL LIBRO PERDE")
|
||||
assert "IMMUNE-O-POSITIVO per finestra" in v
|
||||
assert M.verdetto(_m(giorni_libro=+0.0012)).startswith(
|
||||
"NEL GIORNO DEL CROLLO IL LIBRO GUADAGNA")
|
||||
|
||||
|
||||
def test_verdetto_i_tre_rami_della_FINESTRA():
|
||||
"""Le soglie sono 2/3 sui POSITIVO e 2/3 su POSITIVO+immune.
|
||||
DEVE FALLIRE SE: qualcuno le sposta guardando l'esito (selezione sull'hold-out)."""
|
||||
assert "POSITIVO per finestra" in M.verdetto(_m(guadagna=8, immune=2))
|
||||
assert "IMMUNE-O-POSITIVO per finestra" in M.verdetto(_m(guadagna=4, immune=6))
|
||||
assert "PERDE per finestra" in M.verdetto(_m(guadagna=3, immune=2))
|
||||
|
||||
|
||||
def test_verdetto_i_confini_delle_due_soglie():
|
||||
v8 = M.verdetto(_m(guadagna=8, immune=0)) # 8 >= 8.0
|
||||
assert "POSITIVO per finestra" in v8 and "IMMUNE-O-POSITIVO" not in v8
|
||||
assert "IMMUNE-O-POSITIVO per finestra" in M.verdetto(_m(guadagna=7, immune=1))
|
||||
assert "PERDE per finestra" in M.verdetto(_m(guadagna=7, immune=0))
|
||||
|
||||
|
||||
def test_verdetto_dice_se_il_beta_RIPRODUCE_la_memoria_e_su_QUALE_finestra():
|
||||
"""La tolleranza e' TOL_BETA e il beta confrontato e' quello della finestra di §46, non il
|
||||
beta pieno: due numeri diversi (0,0769 contro 0,086), e solo il primo va confrontato."""
|
||||
# ⚠️ il confine esatto non e' asseribile in virgola mobile: 0,076 + 0,01 vale 0,086 e la
|
||||
# differenza torna 0,009999999999999995, cioe' DENTRO. Si prova un micron di qua e di la'.
|
||||
dentro = M.BETA_MEMORIA + M.TOL_BETA - 1e-6
|
||||
fuori = M.BETA_MEMORIA + M.TOL_BETA + 1e-6
|
||||
assert "(riproduce il" in M.verdetto(_m(beta_46=dentro))
|
||||
assert "(NON riproduce il" not in M.verdetto(_m(beta_46=dentro))
|
||||
assert "(NON riproduce il" in M.verdetto(_m(beta_46=fuori))
|
||||
assert "sulla finestra di §46" in M.verdetto(_m())
|
||||
assert f"+{M.BETA_MEMORIA:.3f}" in M.verdetto(_m())
|
||||
|
||||
|
||||
def test_verdetto_dichiara_che_SKH01_e_marcato_all_uscita():
|
||||
"""P4: l'allerta (qui: la riga) risponde anche al "perche'". Senza quella nota, il lettore
|
||||
vede SKH01 ≈ 0 nella tabella per giorno e il guadagno nella tabella per finestra, e non ha
|
||||
modo di sapere che e' la stessa serie letta in due modi."""
|
||||
assert "marcata all'uscita" in M.verdetto(_m())
|
||||
|
||||
|
||||
def test_verdetto_riproduce_ALLA_LETTERA_la_riga_pubblicata_il_09_09():
|
||||
"""M23: la macchina deve ristampare il numero vecchio. Sono i valori esatti usciti da
|
||||
`misura()` il 09/09 — se il testo cambia, cambia anche cio' che il diario cita, e va
|
||||
cambiato nei due posti insieme."""
|
||||
m = _m(guadagna=4, immune=6, n=12, beta_46=0.07691539529362904,
|
||||
giorni_libro=-0.004841811670794324, giorni_pos=0.175, positivi=8, dal22=(8, 8),
|
||||
quota=1.0793712686517833)
|
||||
assert M.verdetto(m) == (
|
||||
"NEL GIORNO DEL CROLLO IL LIBRO PERDE (-0.48%/g, positivo nel 18% dei giorni ≤ −5%); "
|
||||
"IMMUNE-O-POSITIVO per finestra: 4 POSITIVO / 6 immune / 2 PERDE su 12 "
|
||||
"(libro > 0 in 8, dal 2022 in 8/8); beta +0.0769 sulla finestra di §46 "
|
||||
"(riproduce il +0.076); la gamba short di SKH01 (marcata all'uscita) fa il 108% "
|
||||
"dei guadagni negli episodi con libro > 0")
|
||||
|
||||
|
||||
# ===========================================================================================
|
||||
# P1 — i pesi si IMPORTANO dal book live
|
||||
# ===========================================================================================
|
||||
|
||||
def test_P1_i_pesi_sono_QUELLI_del_book_live_non_una_copia():
|
||||
"""*Un controllo puntato su una configurazione diversa da quella che gira passa sempre.*
|
||||
L'identita' (`is`) vale solo se i nomi sono legati allo STESSO oggetto dell'import: un
|
||||
`W_TP01 = 0.75` riscritto nello script la romperebbe anche a valore uguale."""
|
||||
import src.live.book as B
|
||||
|
||||
assert M.W_TP01 is B.W_TP01, "TP01: peso ridichiarato invece che importato da src/live/book"
|
||||
assert M.W_SKH is B.W_SKH, "SKH01: peso ridichiarato invece che importato da src/live/book"
|
||||
assert M.W_TP01 + M.W_SKH == pytest.approx(1.0)
|
||||
|
||||
src = inspect.getsource(M)
|
||||
assert "from src.live.book import" in src
|
||||
assert not re.search(r"^W_TP01\s*=", src, re.M), "peso TP01 ridichiarato a livello di modulo"
|
||||
assert not re.search(r"^W_SKH\s*=", src, re.M), "peso SKH01 ridichiarato a livello di modulo"
|
||||
|
||||
|
||||
def test_la_riga_informativa_37_5_62_5_NON_e_il_peso_del_libro():
|
||||
"""§26 compare come riga informativa. DEVE FALLIRE SE: qualcuno la promuove a peso di
|
||||
default — il 75/25 e' decisione vincolante, la riapre solo `weights_tilt_null`."""
|
||||
assert M.W_INFO == (0.375, 0.625)
|
||||
assert (M.W_TP01, M.W_SKH) != M.W_INFO
|
||||
|
||||
|
||||
# ===========================================================================================
|
||||
# INTEGRAZIONE sui dati veri
|
||||
# ===========================================================================================
|
||||
|
||||
def test_la_scomposizione_di_SKH01_in_long_e_short_e_ESATTA():
|
||||
"""~2 s. Due guardie, entrambe P1:
|
||||
(a) la versione "tutto" dev'essere BIT-EXACT con `sleeves._skyhook_returns` — se non lo
|
||||
fosse, l'attribuzione descriverebbe un altro sleeve;
|
||||
(b) long + short == intera. In principio filtrare gli entry-dict potrebbe cambiare il
|
||||
non-overlap (un long che non occupa piu' la barra lascia entrare uno short): la
|
||||
misura dice che qui non succede, e il test lo tiene vero."""
|
||||
if not _feed_certificato_presente():
|
||||
pytest.skip("feed certificato BTC/ETH 5m/1h assente in data/raw (gitignored)")
|
||||
from src.portfolio.sleeves import _skyhook_returns
|
||||
|
||||
d = M.skh_per_direzione()
|
||||
assert set(d) == {"tutto", "long", "short"}
|
||||
J = pd.concat(d, axis=1, join="inner")
|
||||
assert float((J["long"] + J["short"] - J["tutto"]).abs().max()) < 1e-12, (
|
||||
"long + short != intera: la scomposizione non e' un'attribuzione, e' un'altra misura")
|
||||
|
||||
ref = _skyhook_returns()
|
||||
if ref.index.tz is None:
|
||||
ref.index = ref.index.tz_localize("UTC")
|
||||
comune = d["tutto"].index.intersection(ref.index)
|
||||
assert len(comune) > 1000
|
||||
assert float((d["tutto"].loc[comune] - ref.loc[comune]).abs().max()) == 0.0, (
|
||||
"la versione 'tutto' non e' piu' bit-exact col sleeve di ricerca")
|
||||
|
||||
|
||||
def test_integrazione_il_risultato_pubblicato_del_09_09_si_riproduce():
|
||||
"""~2 s. Se questo test diventa rosso NON va "aggiornato": va capito se e' cambiato il
|
||||
dato (rebuild del feed) o il codice, e la riga citata nel diario va rifatta.
|
||||
|
||||
I due fatti che decidono: (1) per FINESTRA il libro non perde quasi mai — 4 POSITIVO +
|
||||
6 immune su 12, e libro > 0 in 8/8 episodi dal 2022; (2) NEL GIORNO del crollo perde
|
||||
(−0,48%/g, positivo solo nel 18% dei giorni ≤ −5%). Sono due risposte opposte alla stessa
|
||||
domanda dell'operatore, e stanno insieme perche' SKH01 e' marcato all'uscita."""
|
||||
if not _feed_certificato_presente():
|
||||
pytest.skip("feed certificato BTC/ETH 5m/1h assente in data/raw (gitignored)")
|
||||
|
||||
m = M.misura()
|
||||
|
||||
assert m["beta"] == pytest.approx(0.086, abs=0.03), "beta pieno fuori dal valore pubblicato"
|
||||
assert abs(m["beta_46"] - M.BETA_MEMORIA) < M.TOL_BETA, (
|
||||
f"il beta sulla finestra di §46 ({m['beta_46']:+.4f}) non riproduce piu' il "
|
||||
f"+{M.BETA_MEMORIA:.3f}: e' la riproduzione che giustifica la lente")
|
||||
|
||||
s = m["sintesi"]
|
||||
assert s["n_episodi"] == 12
|
||||
assert (s["guadagna"], s["immune"], s["perde"]) == (4, 6, 2)
|
||||
assert s["guadagna"] + s["immune"] + s["perde"] == s["n_episodi"]
|
||||
assert s["positivi"] == 8, "il conteggio nudo libro > 0 e' l'altra lettura, e va stampata"
|
||||
assert s["dal22"] == (8, 8), "dal 2022 ogni episodio ha il libro in utile"
|
||||
assert s["giorni_libro"] < 0, "nel GIORNO del crollo il libro perde: e' la meta' scomoda"
|
||||
assert s["giorni_pos"] == pytest.approx(0.175, abs=0.05)
|
||||
|
||||
classi = [r["classe"] for r in m["episodi"]]
|
||||
assert classi.count("POSITIVO") == s["guadagna"]
|
||||
assert sum(c.startswith("immune") for c in classi) == s["immune"]
|
||||
assert classi.count("PERDE") == s["perde"]
|
||||
|
||||
dal_2022 = [r for r in m["episodi"] if int(r["da"][:4]) >= 2022]
|
||||
assert len(dal_2022) == 8
|
||||
assert all(r["libro"] > 0 for r in dal_2022), (
|
||||
"un episodio dal 2022 in poi non ha piu' il libro in utile: "
|
||||
+ str([(r["nome"], r["da"], r["libro"]) for r in dal_2022]))
|
||||
|
||||
v = M.verdetto(m)
|
||||
assert v.startswith("NEL GIORNO DEL CROLLO IL LIBRO PERDE")
|
||||
assert "IMMUNE-O-POSITIVO per finestra" in v
|
||||
assert "(riproduce il +0.076)" in v
|
||||
@@ -0,0 +1,351 @@
|
||||
"""Test di `scripts/research/r0909_xrp_terza_gamba.py` (2026-09-09) — XRP come terza gamba.
|
||||
|
||||
Tre cose vanno protette, e nessuna e' il numero finale:
|
||||
|
||||
1. **Il paginatore.** `_fetch_1h` esiste nella forma che ha perche' Coinbase ha un BUCO su XRP
|
||||
(delistato 2021-01, rilistato 2023-07 dopo la sentenza SEC): un batch vuoto NON deve
|
||||
fermare il ciclo. Un paginatore che si ferma al primo vuoto avrebbe certificato XRP su
|
||||
mezzo campione — e la certificazione decide QUALE lente si usa, cioe' decide il risultato.
|
||||
2. **La regola L-PULITA.** "Dal primo anno in cui tutti i successivi restano sotto soglia":
|
||||
un anno pulito ISOLATO non e' un regime. E' la regola che sceglie la finestra, ed e'
|
||||
dichiarata prima di guardare l'esito — quindi va congelata prima che qualcuno la ritocchi.
|
||||
3. **I tre rami del verdetto**, coi loro AND: AGGIUNGE richiede hold-out E de-levering E anni.
|
||||
|
||||
Piu' il guardrail del namespace: XRP vive in `alt_xrp_*`, e `load_data("XRP")` deve continuare
|
||||
a fallire (il "solo dati certi" di CLAUDE.md non e' codice, e' l'assenza del file).
|
||||
|
||||
⚠️ NESSUN test va in rete: il riferimento Coinbase/Bitstamp o e' in cache o il test si salta.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||
|
||||
import r0909_xrp_terza_gamba as M # noqa: E402
|
||||
|
||||
RAW = ROOT / "data" / "raw"
|
||||
ORA_MS = 3_600_000
|
||||
T0 = pd.Timestamp("2026-01-01", tz="UTC")
|
||||
|
||||
|
||||
# ===========================================================================================
|
||||
# (a) _fetch_1h — un batch vuoto NON ferma il ciclo
|
||||
# ===========================================================================================
|
||||
|
||||
class ExFinto:
|
||||
"""Exchange finto e DETERMINISTICO: restituisce un batch vuoto alle chiamate elencate in
|
||||
`vuote` (indici 0-based), altrove `limit` barre orarie a partire da `since`."""
|
||||
|
||||
def __init__(self, vuote=(), righe_prima_di_since=False):
|
||||
self.vuote = set(vuote)
|
||||
self.righe_prima_di_since = righe_prima_di_since
|
||||
self.since_visti: list[int] = []
|
||||
|
||||
def fetch_ohlcv(self, symbol, tf, since=None, limit=None):
|
||||
n = len(self.since_visti)
|
||||
self.since_visti.append(since)
|
||||
assert tf == "1h" and symbol and limit
|
||||
if n in self.vuote:
|
||||
return []
|
||||
base = since - 10 * ORA_MS if self.righe_prima_di_since else since
|
||||
return [[base + i * ORA_MS, 1.0, 1.0, 1.0, 10.0 + n + i, 1.0] for i in range(limit)]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def orologio_fermo(monkeypatch):
|
||||
"""`_fetch_1h` chiude il ciclo su `time.time()`: qui lo si ferma a T0+72h, cosi' il numero
|
||||
di pagine e' un fatto e non il meteo della macchina. (Il patch vive solo dentro il test.)"""
|
||||
fine = T0 + pd.Timedelta(hours=72)
|
||||
monkeypatch.setattr(M.time, "time", lambda: fine.timestamp())
|
||||
return fine
|
||||
|
||||
|
||||
def test_fetch_1h_AVANZA_dopo_un_batch_vuoto(orologio_fermo):
|
||||
"""IL test di questo file. Coinbase su XRP restituisce vuoto per due anni e mezzo: se il
|
||||
ciclo si fermasse (break) o non facesse avanzare `since` (loop infinito), la certificazione
|
||||
girerebbe su un campione mutilato — e la lente L-PULITA la sceglie la certificazione.
|
||||
DEVE FALLIRE SE: qualcuno sostituisce `since += limit*3_600_000; continue` con un `break`."""
|
||||
ex = ExFinto(vuote={0})
|
||||
s = M._fetch_1h(ex, "XRP/USD", T0, 24)
|
||||
|
||||
assert len(ex.since_visti) >= 2, "il ciclo si e' fermato al primo batch vuoto"
|
||||
assert ex.since_visti[1] == ex.since_visti[0] + 24 * ORA_MS, (
|
||||
"dopo un batch vuoto `since` deve avanzare di un'intera pagina")
|
||||
assert len(s) == 48
|
||||
assert s.index.min() == T0 + pd.Timedelta(hours=24), "la prima pagina era il buco"
|
||||
assert s.index.is_monotonic_increasing and s.index.is_unique
|
||||
|
||||
|
||||
def test_fetch_1h_scavalca_un_buco_IN_MEZZO_e_riprende(orologio_fermo):
|
||||
"""Il buco vero di XRP e' in mezzo (2021-01 → 2023-07), non in testa: il dato PRIMA e quello
|
||||
DOPO devono stare tutti e due nella serie, col buco che resta un buco (D2: una riga assente
|
||||
non si inventa)."""
|
||||
ex = ExFinto(vuote={1})
|
||||
s = M._fetch_1h(ex, "XRP/USD", T0, 24)
|
||||
|
||||
assert len(s) == 48
|
||||
assert s.index.min() == T0
|
||||
assert s.index.max() == T0 + pd.Timedelta(hours=71)
|
||||
buco = s[(s.index >= T0 + pd.Timedelta(hours=24)) & (s.index < T0 + pd.Timedelta(hours=48))]
|
||||
assert len(buco) == 0, "il buco e' stato riempito con qualcosa: non c'era niente da riempire"
|
||||
|
||||
|
||||
def test_fetch_1h_scarta_le_righe_PRIMA_di_since_e_avanza_lo_stesso(orologio_fermo):
|
||||
"""Alcuni venue ignorano `since` e ritornano sempre la stessa pagina: il filtro
|
||||
`x[0] >= since` la svuota, e a quel punto vale la stessa regola del batch vuoto."""
|
||||
ex = ExFinto(righe_prima_di_since=True)
|
||||
s = M._fetch_1h(ex, "XRP/USD", T0, 24)
|
||||
# le prime 10 barre di ogni pagina cadono prima di `since` e vengono scartate
|
||||
assert len(ex.since_visti) >= 2
|
||||
assert ex.since_visti[1] > ex.since_visti[0], "il ciclo si e' impantanato"
|
||||
assert (s.index >= T0).all()
|
||||
|
||||
|
||||
def test_fetch_1h_non_si_impianta_su_una_pagina_da_una_riga(monkeypatch):
|
||||
"""La guardia `since = nxt if nxt > since else since + pagina` esiste per non girare a
|
||||
vuoto: qui ogni chiamata rende UNA barra a `since`, e il ciclo deve comunque terminare."""
|
||||
fine = T0 + pd.Timedelta(hours=5)
|
||||
monkeypatch.setattr(M.time, "time", lambda: fine.timestamp())
|
||||
|
||||
class UnaRiga:
|
||||
def __init__(self):
|
||||
self.n = 0
|
||||
|
||||
def fetch_ohlcv(self, symbol, tf, since=None, limit=None):
|
||||
self.n += 1
|
||||
assert self.n < 50, "ciclo infinito"
|
||||
return [[since, 1.0, 1.0, 1.0, 10.0, 1.0]]
|
||||
|
||||
s = M._fetch_1h(UnaRiga(), "XRP/USD", T0, 24)
|
||||
assert len(s) == 5
|
||||
assert list(s.index) == list(pd.date_range(T0, periods=5, freq="h"))
|
||||
|
||||
|
||||
# ===========================================================================================
|
||||
# (b) la regola L-PULITA dentro certifica()
|
||||
# ===========================================================================================
|
||||
|
||||
ANNI = (2022, 2023, 2024, 2025, 2026)
|
||||
|
||||
|
||||
def _finto_feed(sporchi: dict[int, int], n: int = 700):
|
||||
"""Feed sintetico: per ogni anno `n` barre orarie, di cui `sporchi[y]` a +2% dal
|
||||
riferimento (cioe' sopra la soglia >1% che definisce una barra sporca)."""
|
||||
dd, rr = [], []
|
||||
for y in ANNI:
|
||||
idx = pd.date_range(f"{y}-01-01", periods=n, freq="h", tz="UTC")
|
||||
rif = np.full(n, 100.0)
|
||||
mio = rif.copy()
|
||||
mio[:sporchi.get(y, 0)] = 102.0
|
||||
dd.append(pd.DataFrame(dict(datetime=idx, close=mio, high=mio + 1.0, low=mio - 1.0)))
|
||||
rr.append(pd.DataFrame(dict(ts=idx, close=rif, fonte="CB")))
|
||||
D = pd.concat(dd, ignore_index=True)
|
||||
R = pd.concat(rr, ignore_index=True).set_index("ts")
|
||||
return D, R
|
||||
|
||||
|
||||
def _certifica(monkeypatch, sporchi: dict[int, int], n: int = 700) -> dict:
|
||||
D, R = _finto_feed(sporchi, n)
|
||||
monkeypatch.setattr(M, "dati", lambda asset, tf: D)
|
||||
monkeypatch.setattr(M, "riferimento_1h", lambda start: R)
|
||||
return M.certifica()
|
||||
|
||||
|
||||
def test_L_PULITA_parte_dal_primo_anno_con_TUTTI_i_successivi_puliti(monkeypatch):
|
||||
"""Lo scenario vero: 2022 e 2023 sporchi (0,57% nel 2023), 2024-2026 puliti → 2024-01-01."""
|
||||
c = _certifica(monkeypatch, {2022: 8, 2023: 4})
|
||||
assert c["per_anno"][2023]["q_1pct"] == pytest.approx(4 / 700, abs=1e-6)
|
||||
assert c["per_anno"][2022]["q_1pct"] > M.SOGLIA_PULITA
|
||||
assert c["puliti"] == [2024, 2025, 2026]
|
||||
assert c["pulita_da"] == pd.Timestamp("2024-01-01", tz="UTC")
|
||||
|
||||
|
||||
def test_L_PULITA_ignora_un_anno_pulito_ISOLATO(monkeypatch):
|
||||
"""La regressione che la regola esiste per impedire: 2022 pulito, 2023 sporco, 2024+ puliti.
|
||||
Un anno pulito isolato NON e' un regime — la lente parte dalla coda pulita finale (2024),
|
||||
non dal 2022. DEVE FALLIRE SE: qualcuno semplifica in `min(puliti)`."""
|
||||
c = _certifica(monkeypatch, {2023: 8})
|
||||
assert c["puliti"] == [2022, 2024, 2025, 2026], "il 2022 e' pulito, e va detto"
|
||||
assert c["pulita_da"] == pd.Timestamp("2024-01-01", tz="UTC"), (
|
||||
"la lente non puo' partire dal 2022: il 2023 in mezzo e' sporco")
|
||||
|
||||
|
||||
def test_L_PULITA_e_tutta_la_storia_se_non_c_e_niente_di_sporco(monkeypatch):
|
||||
c = _certifica(monkeypatch, {})
|
||||
assert c["puliti"] == list(ANNI)
|
||||
assert c["pulita_da"] == pd.Timestamp("2022-01-01", tz="UTC")
|
||||
|
||||
|
||||
def test_L_PULITA_e_NONE_se_nessun_anno_e_pulito(monkeypatch):
|
||||
"""P5: "non c'e' una finestra pulita" ha uno stato suo. `misura()` allora non aggiunge la
|
||||
lente e `verdetto()` ripiega sul 2024+ dichiarato (la lente di SOL)."""
|
||||
c = _certifica(monkeypatch, {y: 8 for y in ANNI})
|
||||
assert c["puliti"] == []
|
||||
assert c["pulita_da"] is None
|
||||
|
||||
|
||||
def test_la_soglia_di_pulizia_e_INCLUSIVA_e_vale_SOGLIA_PULITA(monkeypatch):
|
||||
"""Il confronto e' `<=`: esattamente in soglia l'anno e' pulito. Con n=1000 barre la
|
||||
soglia 0,5% cade su un intero (5 barre), quindi il confine e' asseribile."""
|
||||
c = _certifica(monkeypatch, {2022: 5, 2023: 5, 2024: 5, 2025: 5, 2026: 5}, n=1000)
|
||||
assert c["per_anno"][2022]["q_1pct"] == pytest.approx(M.SOGLIA_PULITA)
|
||||
assert c["puliti"] == list(ANNI)
|
||||
c2 = _certifica(monkeypatch, {2022: 6, 2023: 5, 2024: 5, 2025: 5, 2026: 5}, n=1000)
|
||||
assert 2022 not in c2["puliti"]
|
||||
assert c2["pulita_da"] == pd.Timestamp("2023-01-01", tz="UTC")
|
||||
|
||||
|
||||
def test_certifica_riporta_per_ogni_anno_le_misure_che_la_tabella_stampa(monkeypatch):
|
||||
c = _certifica(monkeypatch, {2022: 8})
|
||||
for y, r in c["per_anno"].items():
|
||||
assert set(r) == {"n", "q_1pct", "med_bps", "fonte", "flat_1h", "flat_5m"}
|
||||
assert r["n"] == 700
|
||||
assert r["flat_1h"] == 0.0, "high != low per costruzione"
|
||||
|
||||
|
||||
# ===========================================================================================
|
||||
# (c) verdetto() — i tre rami, tutti con AND
|
||||
# ===========================================================================================
|
||||
|
||||
NOME = "L-PULITA (2024+)"
|
||||
|
||||
|
||||
def _m(hold, sh3, sh2k, anni_dsh, nome: str = NOME, k: float = 0.827) -> dict:
|
||||
h = np.asarray(hold, float)
|
||||
z = np.zeros(len(h))
|
||||
return dict(lenti={nome: dict(sh=z, hold=h, dd=z, cagr=z)},
|
||||
delev={nome: dict(k=k, sh3=sh3, sh2k=sh2k, cagr3=0.146, cagr2k=0.134)},
|
||||
anno={2024 + i: dict(dsh=float(v)) for i, v in enumerate(anni_dsh)})
|
||||
|
||||
|
||||
def test_verdetto_AGGIUNGE_solo_se_passano_tutte_e_tre_le_condizioni():
|
||||
v = M.verdetto(_m([0.10] * 24, sh3=1.60, sh2k=1.50, anni_dsh=[0.5, 0.4, -0.1]))
|
||||
assert v.startswith("XRP AGGIUNGE")
|
||||
|
||||
|
||||
def test_verdetto_DILUISCE_quando_cadono_hold_out_E_de_levering():
|
||||
v = M.verdetto(_m([-0.169] * 24, sh3=1.54, sh2k=1.53, anni_dsh=[0.54, -0.44, -0.25]))
|
||||
assert v.startswith("XRP DILUISCE")
|
||||
|
||||
|
||||
def test_verdetto_PARI_quando_le_condizioni_si_contraddicono():
|
||||
"""N5/M8: quando una condizione passa e l'altra no, il verdetto non sceglie la piu' comoda —
|
||||
dichiara che non e' selezionabile."""
|
||||
solo_hold = M.verdetto(_m([0.10] * 24, sh3=1.50, sh2k=1.60, anni_dsh=[0.5, 0.4, -0.1]))
|
||||
assert solo_hold.startswith("XRP: PARI / NON SELEZIONABILE")
|
||||
solo_delev = M.verdetto(_m([-0.10] * 24, sh3=1.60, sh2k=1.50, anni_dsh=[0.5, 0.4, -0.1]))
|
||||
assert solo_delev.startswith("XRP: PARI / NON SELEZIONABILE")
|
||||
|
||||
|
||||
def test_verdetto_chiede_il_75_percento_delle_ancore_non_la_sola_mediana():
|
||||
"""M7/§2: la banda d'ancora NON e' un intervallo di confidenza, ma la robustezza alla scelta
|
||||
dell'ancora e' una condizione a se'. Mediana > 0 con solo il 70% delle ancore positive non
|
||||
basta. DEVE FALLIRE SE: qualcuno lascia solo la mediana."""
|
||||
hold = [0.10] * 16 + [-0.10] * 8 # mediana +0,10, ma >0 solo nel 67% delle ancore
|
||||
v = M.verdetto(_m(hold, sh3=1.60, sh2k=1.50, anni_dsh=[0.5, 0.4, -0.1]))
|
||||
assert not v.startswith("XRP AGGIUNGE")
|
||||
hold_ok = [0.10] * 18 + [-0.10] * 6 # esattamente 75%
|
||||
assert M.verdetto(_m(hold_ok, sh3=1.60, sh2k=1.50,
|
||||
anni_dsh=[0.5, 0.4, -0.1])).startswith("XRP AGGIUNGE")
|
||||
|
||||
|
||||
def test_verdetto_il_de_levering_chiede_un_margine_di_0_02():
|
||||
"""M5: il null del de-levering non si passa "per un pelo" — un vantaggio dentro 0,02 di
|
||||
Sharpe e' de-levering travestito, ed e' esattamente il caso di XRP (1,54 contro 1,53)."""
|
||||
quasi = M.verdetto(_m([0.10] * 24, sh3=1.52, sh2k=1.50, anni_dsh=[0.5, 0.4, -0.1]))
|
||||
assert not quasi.startswith("XRP AGGIUNGE")
|
||||
appena = M.verdetto(_m([0.10] * 24, sh3=1.521, sh2k=1.50, anni_dsh=[0.5, 0.4, -0.1]))
|
||||
assert appena.startswith("XRP AGGIUNGE")
|
||||
|
||||
|
||||
def test_verdetto_ripiega_sulla_lente_di_SOL_se_non_c_e_una_coda_pulita():
|
||||
"""Quando `pulita_da` e' None, `misura()` non crea la lente L-PULITA: il verdetto deve
|
||||
leggere quella dichiarata di riserva, non la prima che capita."""
|
||||
m = _m([-0.10] * 24, sh3=1.50, sh2k=1.60, anni_dsh=[-0.1, -0.2, -0.3],
|
||||
nome="2024+ (lente di SOL)")
|
||||
m["lenti"]["L-FULL"] = dict(sh=np.zeros(24), hold=np.full(24, 9.0),
|
||||
dd=np.zeros(24), cagr=np.zeros(24))
|
||||
m["delev"]["L-FULL"] = dict(k=1.0, sh3=9.0, sh2k=0.0, cagr3=0.0, cagr2k=0.0)
|
||||
v = M.verdetto(m)
|
||||
assert "lente 2024+ (lente di SOL)" in v
|
||||
assert v.startswith("XRP DILUISCE"), "ha letto la L-FULL invece della lente dichiarata"
|
||||
|
||||
|
||||
def test_verdetto_riproduce_ALLA_LETTERA_la_riga_pubblicata_il_09_09():
|
||||
"""M23: i valori sono quelli usciti dalla misura a 24 ancore del 09/09."""
|
||||
m = _m([-0.169] * 24, sh3=1.54, sh2k=1.53, anni_dsh=[0.54, -0.44, -0.25])
|
||||
assert M.verdetto(m) == (
|
||||
"XRP DILUISCE — lente L-PULITA (2024+): dSharpe hold-out mediano -0.169 "
|
||||
"(>0 nel 0% di 24 ancore); de-levering: Sh 3 gambe 1.54 vs 2 gambe×0.827 1.53; "
|
||||
"anni 2024+ con dSh>0: 1/3")
|
||||
|
||||
|
||||
# ===========================================================================================
|
||||
# (d) il GUARDRAIL del namespace
|
||||
# ===========================================================================================
|
||||
|
||||
def test_il_divieto_su_data_raw_xrp_e_gia_DERIVATO_dal_codice():
|
||||
"""`tests/test_sol_leg.py::test_nessun_asset_fuori_universo_nel_namespace_NUDO` deriva gli
|
||||
asset vietati da `rebuild_history.DERIBIT_INSTR`: finche' XRP e' li' dentro, quel test
|
||||
copre gia' `data/raw/xrp_*.parquet` e qui NON si duplica. Questo test congela la premessa
|
||||
— se qualcuno togliesse XRP dal dict, la guardia sparirebbe in silenzio."""
|
||||
from scripts.analysis.rebuild_history import DERIBIT_INSTR
|
||||
assert "XRP" in DERIBIT_INSTR, (
|
||||
"XRP fuori da DERIBIT_INSTR: il divieto su data/raw/xrp_*.parquet non e' piu' derivato "
|
||||
"da nessuna parte — riscrivere la guardia in test_sol_leg.py prima di toglierlo")
|
||||
|
||||
|
||||
def test_load_data_XRP_continua_a_FALLIRE():
|
||||
"""L'altra meta': il dato XRP dello studio vive in `alt_xrp_*` e il feed attivo non lo vede.
|
||||
Se un giorno `load_data("XRP")` funzionasse, il cron (`--asset BTC ETH`) non lo
|
||||
rinfrescherebbe comunque -> dato stantio che sembra attivo."""
|
||||
from src.data.downloader import load_data
|
||||
with pytest.raises(FileNotFoundError):
|
||||
load_data("XRP", "1h")
|
||||
|
||||
|
||||
def test_lo_studio_legge_il_namespace_di_RICERCA():
|
||||
"""`dati()` importata da r0822_sol_leg instrada XRP su `alt_xrp_*`: se non c'e' il file,
|
||||
l'errore deve dire come rigenerarlo, non fingere che l'asset non esista."""
|
||||
assert M.ASSET == "XRP" and M.TRE == ("BTC", "ETH", "XRP")
|
||||
if (RAW / "alt_xrp_1h.parquet").exists():
|
||||
d = M.dati("XRP", "1h")
|
||||
assert {"close", "datetime"} <= set(d.columns) and len(d) > 0
|
||||
|
||||
|
||||
# ===========================================================================================
|
||||
# (e) INTEGRAZIONE (opzionale: salta senza i dati o senza la cache del riferimento)
|
||||
# ===========================================================================================
|
||||
|
||||
def test_integrazione_xrp_diluisce_anche_a_due_ancore(monkeypatch):
|
||||
"""~2 s. Non riproduce i numeri a 24 ancore (2 ancore sono un'altra misura): riproduce la
|
||||
DIREZIONE, che e' la cosa che decide — dSharpe hold-out mediano negativo e verdetto
|
||||
"XRP DILUISCE", cioe' l'ipotesi a priori confermata."""
|
||||
if not all((RAW / f"alt_xrp_{tf}.parquet").exists() for tf in ("1h", "5m")):
|
||||
pytest.skip("namespace di ricerca alt_xrp_* assente (rigenerabile, gitignored)")
|
||||
if not all((RAW / f"{a}_{tf}.parquet").exists() for a in ("btc", "eth") for tf in ("1h", "5m")):
|
||||
pytest.skip("feed certificato BTC/ETH assente")
|
||||
if not M.CACHE.exists():
|
||||
pytest.skip(f"cache del riferimento assente ({M.CACHE}): il test NON va in rete")
|
||||
|
||||
# cintura: anche se la cache sparisse a meta' test, nessuno tocca la rete
|
||||
def _vietato(*a, **k):
|
||||
raise AssertionError("il test ha provato ad andare in rete (_fetch_1h)")
|
||||
monkeypatch.setattr(M, "_fetch_1h", _vietato)
|
||||
|
||||
m = M.misura(2)
|
||||
assert m["n_anchors"] == 2
|
||||
assert m["cert"]["pulita_da"] == pd.Timestamp("2024-01-01", tz="UTC")
|
||||
|
||||
nome = ([n for n in m["lenti"] if n.startswith("L-PULITA")] or ["2024+ (lente di SOL)"])[0]
|
||||
assert np.median(m["lenti"][nome]["hold"]) < 0, "il dSharpe hold-out ha cambiato segno"
|
||||
assert M.verdetto(m).startswith("XRP DILUISCE")
|
||||
assert m["corr"]["L-FULL"] < 0.74, "la correlazione col book supera la soglia del 19/06"
|
||||
+41
-16
@@ -6,25 +6,21 @@ finisce in tmp grazie alla fixture autouse di conftest: qui non si tocca il libr
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from helpers import carica_script # noqa: E402
|
||||
from src.live import journal as J # noqa: E402
|
||||
from src.live import tradesdb as T # noqa: E402
|
||||
|
||||
|
||||
def _carica_script():
|
||||
spec = importlib.util.spec_from_file_location("trades_db_script", ROOT / "scripts/live/trades_db.py")
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
return carica_script("scripts/live/trades_db.py", "trades_db_script")
|
||||
|
||||
|
||||
def _px_piatto(livello=100.0):
|
||||
@@ -33,11 +29,11 @@ def _px_piatto(livello=100.0):
|
||||
|
||||
|
||||
def _prepara(con, equity):
|
||||
con.execute("INSERT INTO fills (fill_id, ts_utc, ts_source, asset, side, qty, price, fee) "
|
||||
"VALUES ('f1','2026-08-24T10:47:15+00:00','test','BTC','buy',0.001,60000.0,0.01)")
|
||||
for ts, eq in equity:
|
||||
con.execute("INSERT INTO equity (ts_utc, equity, src) VALUES (?,?,?)", (ts, eq, "test"))
|
||||
con.commit()
|
||||
# il fill si scrive con `upsert_fills`, non con un INSERT a mano: una riga scritta a mano
|
||||
# aveva `verified` NULL, una forma che la produzione non produce mai
|
||||
T.upsert_fills(con, [T.Fill(ts_utc="2026-08-24T10:47:15+00:00", ts_source="test",
|
||||
asset="BTC", side="buy", qty=0.001, price=60000.0, fee=0.01)])
|
||||
T.upsert_equity(con, [(ts, eq, "test") for ts, eq in equity])
|
||||
|
||||
|
||||
def test_il_report_stampa_il_TWR_e_non_il_grezzo(monkeypatch, capsys):
|
||||
@@ -51,10 +47,10 @@ def test_il_report_stampa_il_TWR_e_non_il_grezzo(monkeypatch, capsys):
|
||||
con.close()
|
||||
_carica_script().report()
|
||||
out = capsys.readouterr().out
|
||||
assert "TWR : +3.95%" in out
|
||||
assert "+234.95%" not in out and "+235" not in out # il grezzo non compare piu'
|
||||
assert "movimenti capitale : +1,400.00 certi" in out
|
||||
assert "trading da arming : +9.70" in out
|
||||
assert "TWR" in out and "+3.95%" in out
|
||||
assert "+234.95%" not in out # il grezzo non compare piu'
|
||||
assert "+1,400.00 certi" in out
|
||||
assert "+9.70" in out
|
||||
assert "movimenti di capitale INCLUSI" in out # il delta $ resta, etichettato
|
||||
|
||||
|
||||
@@ -65,4 +61,33 @@ def test_il_report_senza_movimenti_dice_lo_stesso_numero_di_prima(monkeypatch, c
|
||||
con.close()
|
||||
_carica_script().report()
|
||||
out = capsys.readouterr().out
|
||||
assert "TWR : +5.00%" in out and "movimenti capitale" not in out
|
||||
assert "+5.00%" in out and "movimenti capitale" not in out
|
||||
|
||||
|
||||
def test_il_report_a_base_zero_dice_n_d_e_non_l_intero_conto(monkeypatch, capsys):
|
||||
monkeypatch.setattr(J, "_px_1h", lambda a: _px_piatto())
|
||||
con = T.connect()
|
||||
_prepara(con, [("2026-08-24T09:47:01+00:00", 0.0),
|
||||
("2026-08-24T10:47:01+00:00", 598.06),
|
||||
("2026-08-25T10:47:01+00:00", 610.0)])
|
||||
con.close()
|
||||
_carica_script().report()
|
||||
out = capsys.readouterr().out
|
||||
assert out.count("n/d") >= 2 and "TWR" in out
|
||||
assert "+610.00 di equity" in out and "arming : +610.00" not in out
|
||||
|
||||
|
||||
def test_il_report_con_il_feed_assente_non_stampa_il_grezzo_come_TWR(monkeypatch, capsys):
|
||||
# revisione 02/09: con data/raw assente (host ripristinato prima di rebuild_history) ogni
|
||||
# salto era "ambiguo", certi=0, e il report stampava +243% sotto l'etichetta TWR
|
||||
monkeypatch.setattr(J, "_px_1h", lambda a: None)
|
||||
con = T.connect()
|
||||
_prepara(con, [("2026-08-24T10:47:01+00:00", 600.0),
|
||||
("2026-08-25T11:47:01+00:00", 2000.0),
|
||||
("2026-08-26T10:47:01+00:00", 2020.0)])
|
||||
con.close()
|
||||
_carica_script().report()
|
||||
out = capsys.readouterr().out
|
||||
assert out.count("n/d") >= 2 and "TWR" in out
|
||||
assert "% =" not in out and "+236" not in out
|
||||
assert "[ambiguo: mercato non misurabile" in out
|
||||
|
||||
Reference in New Issue
Block a user