9 Commits

Author SHA1 Message Date
Adriano Dal Pastro 3b60289cbd fix(gitignore): commento su riga propria per data/funds_watch.json
Il commento inline rendeva il pattern non-matchante (gitignore non
supporta i commenti a fine riga).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 21:16:13 +00:00
Adriano Dal Pastro e328836614 feat(live): switch a MAINNET — micro-test fade-only €500 + funds-watch
Conversione testnet -> mainnet Deribit (soldi veri). Vedi
docs/specs/mainnet-microtest-plan.md, Fase 1.

- portfolios.yml: total_capital 500, leva 3, weighting equal; eseguono
  REALE solo i 7 single-leg (6 fade MR01/02/07 x BTC/ETH 15m + DIP01 1h);
  pairs/SH01/multi-asset -> paper (rumore arrotondamento a €500); real_truth.
- docker-compose.yml: portfolio+dashboard caricano anche .env.mainnet
  (token mainnet, prevale su .env; .env.mainnet resta gitignored).
- reconcile_account.py: watermark FONDI (compute_funds_change) — rileva
  aumenti di capitale (deposito/top-up) e cali anomali (prelievo) sul
  balance USDC, alert Telegram FUNDS_INCREASE/FUNDS_DECREASE; soglia
  max(€25, 5%). Stato in data/funds_watch.json (host-writable).
- .gitignore: ignora data/funds_watch.json (stato runtime).

Ledger testnet archiviato in data/_reset_backup/pre_mainnet_*.tgz e azzerato.
Crons host ri-puntati al conto reale (sourcing .env + .env.mainnet).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 21:15:55 +00:00
Adriano Dal Pastro a0842a61cc feat(analysis): trades_status — PnL live per posizione aperta (entry reale vs mark USDC)
Riusa la convenzione della dashboard (get_ticker_batch USDC perp, real_entry vs mark;
pairs a 2 gambe). Mostra unrealized per trade + Σ REALE, e segnala che il ledger
unrealized e' sul feed sim-decisione testnet DISLOCATO (lo scarto e' dislocazione,
non soldi). Fix sys.path: import src.* anche eseguito come script file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:56:22 +00:00
Adriano Dal Pastro 3a66907f43 feat(analysis): trades_status.py — report stato trades con POOL reale vs PAPER-STATS separati
La dir e' la fonte di verita': portfolio_paper/ = pool (ledger/equity), portfolio_paper_stats/
= TR01/ROT02/TSM01/XS01 solo statistica. Niente piu' glob su portfolio_paper* (matchava
entrambi -> +8.15 XS01 sim attribuiti per sbaglio all'equity il 2026-06-17). Mostra realizzato
POOL + unrealized aperti e riconcilia con Δequity del ledger. Caveat + comando in CLAUDE.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:17:50 +00:00
Adriano Dal Pastro aee53161c3 docs: Fase 0 smoke mainnet VERDE (ETH->USDC limit) + aggiorna piano micro-test
Catena d'esecuzione validata su mainnet con soldi veri a costo ~0: LIMIT SELL ETH_USDC
0.0138 @ floor 1776.3 -> filled@1778.1 (=bid), fee 0, USDC 25->49.54. Consolidato l'ETH
stranded in margine USDC. Funding parziale ~$49.54 (sotto il min EUR500) -> Fase 1 in
attesa di top-up a ~EUR1000. Diario + STATO/checklist del piano aggiornati.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:48:38 +00:00
Adriano Dal Pastro 7b3e554424 release: v1.1.33 2026-06-16 14:16:22 +00:00
Adriano Dal Pastro b576ee66ac feat(worker): guard TP-invertito — sopprime l'entry quando il TP e' gia' sfondato (wick-print)
Un wick transitorio fa calcolare un tp dal lato sbagliato dell'entry (donchian: segnale
su barra wickata, entry al prezzo recuperato oltre il proprio tp) -> l'exit intrabar
scatta a bars_held=0 in perdita (16-06: 8 giri MR02_BTC 15m, sim -17.9 / reale -2.3).
TP_PHANTOM non lo prende (niente resting oracle, prezzo oltre il livello). Gate
zero-parametri in StrategyWorker._open_position, solo path live. Test + diario + CLAUDE.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:16:13 +00:00
Adriano Dal Pastro ef797f1ff0 release: v1.1.32 2026-06-15 08:44:42 +00:00
Adriano Dal Pastro 4c184d5cbc feat(runner): gate feed CONGELATO — sospende gli sleeve ETH-leg finche' il feed non si sblocca
Audit "stato ordini": il feed ETH-PERPETUAL congelato a 1661.95 (36h+) generava
perdite REALI (SH01_ETH -2.83 reali vs -0.09 sim su un close + riapertura; 4 pairs
con gamba ETH entrati su z-score spuri -3/-5/+5.6 = artefatto del log-ratio con ETH
pinnato mentre gli alt si muovono).

_frozen_assets + _feed_gated_sids: quando il feed di decisione 1h di un asset e'
congelato, gli sleeve concentrati (single/ml/pairs) che ne dipendono saltano il tick
(entry+exit) finche' non si sblocca (come outage; disaster-SL on-book = coda).
Auto-guarente: rilascio alla prima barra COMPLETA non-flat (NON l'entry-guard
post-flat bocciata). Detector guasto-vs-illiquido: conta la run di close INVARIATE
(ETH/BNB/DOGE run 40-64, 1-4 val/48h = morti; SOL/LTC/ADA run <=12, 5-31 val = vivi).
Soglia feed_freeze_gate_bars=24 -> gatea le 9 gambe ETH esatte, PR_BTCLTC e i
multi-asset restano attivi. Alert Telegram FEED_FROZEN_GATE GATED/RIPRESO.

Test test_freeze_gate.py (6, detector+scope+rilascio). Suite portfolio 140/140.
Cerotto testnet: il fix vero e' mainnet. Diario 2026-06-15-frozen-feed-gate.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 08:44:39 +00:00
15 changed files with 887 additions and 67 deletions
+2
View File
@@ -20,6 +20,8 @@ data/paper_trades/
data/portfolio_paper/ data/portfolio_paper/
data/portfolio_paper_stats/ data/portfolio_paper_stats/
data/portfolios/ data/portfolios/
# watermark fondi del reconciler (stato runtime, contiene il balance)
data/funds_watch.json
# stato locale di tooling (non condiviso) # stato locale di tooling (non condiviso)
.claude/ .claude/
+40
View File
@@ -98,6 +98,7 @@ uv run python scripts/analysis/regime_fetcher.py # fetch DVOL+funding
uv run python scripts/analysis/exit_lab.py # (ri)costruisci cache segnali exit-lab + parity check uv run python scripts/analysis/exit_lab.py # (ri)costruisci cache segnali exit-lab + parity check
uv run python -m src.live.dashboard --port 8787 # dashboard web (servizio compose 'dashboard', porta 8787) uv run python -m src.live.dashboard --port 8787 # dashboard web (servizio compose 'dashboard', porta 8787)
uv run python scripts/analysis/ledger_vs_backtest.py --since 2026-06-13 # report fuga di esecuzione (reale vs sim) uv run python scripts/analysis/ledger_vs_backtest.py --since 2026-06-13 # report fuga di esecuzione (reale vs sim)
uv run python scripts/analysis/trades_status.py [--since ISO] # report "stato trades": POOL reale vs PAPER-STATS, realizzato + unrealized
./scripts/deploy.sh [patch|minor|major] # DEPLOY: bump versione + commit + rebuild Docker ./scripts/deploy.sh [patch|minor|major] # DEPLOY: bump versione + commit + rebuild Docker
uv run pytest # test uv run pytest # test
``` ```
@@ -502,6 +503,19 @@ queste fade, ma va confermato col paper trader live prima di rischiare capitale
Fill (anche parziale) o prezzo oltre il livello o worker non eseguito → comportamento Fill (anche parziale) o prezzo oltre il livello o worker non eseguito → comportamento
storico. Fail-open su errori di rete. Log `TP_PHANTOM` dedup per barra + alert Telegram. storico. Fail-open su errori di rete. Log `TP_PHANTOM` dedup per barra + alert Telegram.
Test `tests/portfolio/test_tp_phantom.py`. Test `tests/portfolio/test_tp_phantom.py`.
- **INVERTED_TP_SKIP — l'entry con TP dal lato sbagliato va soppressa (v1.1.33, 2026-06-16).**
Un wick transitorio sul feed puo' far calcolare alla strategia un `tp` dal lato SBAGLIATO
dell'entry (es. donchian: segnale su barra wickata in basso → tp=centro canale, entry al
prezzo RECUPERATO sopra il proprio tp). L'exit intrabar `bar_high>=tp` (long)/`bar_low<=tp`
(short) scatta a `bars_held=0` in PERDITA, con churn di fee e TP reduce-only respinti
(16-06: 8 giri MR02_BTC 15m, sim 17.9 / reale 2.3 grazie al real-truth; TP_PHANTOM NON
lo prende — niente resting oracle, il prezzo HA superato il livello). Gate in
`StrategyWorker._open_position` (zero parametri, verita' d'esecuzione): se `tp<=entry`
(long) / `tp>=entry` (short) → **entry soppressa** (niente ordine reale/disaster-SL), log
`INVERTED_TP_SKIP` dedup per-barra + alert Telegram. Solo path live (il backtest entra al
close del bar del segnale → mai invertito, resta non filtrato → live meglio del backtest).
Cerotto testnet (il fix vero e' mainnet). Test `tests/portfolio/test_inverted_tp.py`,
diario `docs/diary/2026-06-16-inverted-tp-guard.md`.
- **TP reale = LIMIT reduce-only AL LIVELLO (2026-06-04).** Misurati +235 bps di slippage medio sulle uscite take-profit market-on-poll (sim esce al livello intrabar, il reale chiudeva al poll post-rimbalzo: sim +11.85 vs reale +0.62 USD sui primi 7 close). Fix: a ogni `REAL_OPEN` il worker piazza un **limit reduce-only al TP** (`ExecutionClient.place_tp_limit`, prezzo quantizzato al tick, SOLA quota del worker) → `REAL_TP_RESTING`; a ogni chiusura sim `_real_close` **cancella il resting → riconcilia i fill (anche parziali) via `get_trade_history` per order_id → market reduce-only solo del residuo** → ledger su prezzo combinato. `real_tp_order_id` persistito in `status.json` (resume-safe). Lo **SL resta market-on-poll** (deliberato: i trigger Deribit generano un nuovo order_id al trigger → fill non verificabile per order_id; e sul SL il rimbalzo lavora a favore). Fill da resting = fee **maker ~0%**. Smoke: `live_shadow_smoke.py` (2 scenari, testnet). Diario `docs/diary/2026-06-04-shadow-divergence.md`. - **TP reale = LIMIT reduce-only AL LIVELLO (2026-06-04).** Misurati +235 bps di slippage medio sulle uscite take-profit market-on-poll (sim esce al livello intrabar, il reale chiudeva al poll post-rimbalzo: sim +11.85 vs reale +0.62 USD sui primi 7 close). Fix: a ogni `REAL_OPEN` il worker piazza un **limit reduce-only al TP** (`ExecutionClient.place_tp_limit`, prezzo quantizzato al tick, SOLA quota del worker) → `REAL_TP_RESTING`; a ogni chiusura sim `_real_close` **cancella il resting → riconcilia i fill (anche parziali) via `get_trade_history` per order_id → market reduce-only solo del residuo** → ledger su prezzo combinato. `real_tp_order_id` persistito in `status.json` (resume-safe). Lo **SL resta market-on-poll** (deliberato: i trigger Deribit generano un nuovo order_id al trigger → fill non verificabile per order_id; e sul SL il rimbalzo lavora a favore). Fill da resting = fee **maker ~0%**. Smoke: `live_shadow_smoke.py` (2 scenari, testnet). Diario `docs/diary/2026-06-04-shadow-divergence.md`.
- **position_size per-famiglia (2026-06-07).** `portfolios.yml` accetta `position_size_family` - **position_size per-famiglia (2026-06-07).** `portfolios.yml` accetta `position_size_family`
(chiave = `weighting.family_of`); plumbing `runner.pos_for_spec`. **PAIRS a 0.20** (esposizione (chiave = `weighting.family_of`); plumbing `runner.pos_for_spec`. **PAIRS a 0.20** (esposizione
@@ -581,6 +595,14 @@ long A / short B sullo z-score del log-ratio, fee su 2 gambe).
- **Fee:** Deribit perp reale = taker ~0.05%/lato (**0.10% round-trip**), maker ~0%. Usare 0.10% RT come baseline (lo 0.20% storico era pessimista 2x). Includere SEMPRE nel backtest: sono vincolo di prim'ordine, molte operazioni = morte per fee. Il worker usa `strategy.fee_rt` (MR01 = 0.001). - **Fee:** Deribit perp reale = taker ~0.05%/lato (**0.10% round-trip**), maker ~0%. Usare 0.10% RT come baseline (lo 0.20% storico era pessimista 2x). Includere SEMPRE nel backtest: sono vincolo di prim'ordine, molte operazioni = morte per fee. Il worker usa `strategy.fee_rt` (MR01 = 0.001).
- **Leva:** testato con 3x. Aumentare a 5x migliora i rendimenti ma raddoppia il drawdown. - **Leva:** testato con 3x. Aumentare a 5x migliora i rendimenti ma raddoppia il drawdown.
- **GBM:** GradientBoostingClassifier di scikit-learn. Ensemble di alberi decisionali sequenziali. Walk-forward per evitare leakage temporale. - **GBM:** GradientBoostingClassifier di scikit-learn. Ensemble di alberi decisionali sequenziali. Walk-forward per evitare leakage temporale.
- **Report "stato trades" — separare SEMPRE pool reale e paper-stats.** La directory è la
fonte di verità: `data/portfolio_paper/` = sleeve nel POOL (capitale reale, muovono
equity/conto); `data/portfolio_paper_stats/` = TR01/ROT02/TSM01/**XS01** in SOLA statistica
(PnL a precisione frazionaria, MAI nel ledger). **NON** fare glob su `portfolio_paper*`
(matcha entrambi → gli utili sim dei paper-stats inquinano il realizzato: errore reale del
2026-06-17, +8.15 XS01 attribuiti per sbaglio all'equity). Verità aggregata = ledger PORT06
(`equity`, `total_capital`; unrealized = equitytotal_capital). Usare
`scripts/analysis/trades_status.py` (riconcilia: realizzato POOL + Δunrealized ≈ Δequity).
- **Cerbero `get_historical` (fix 2026-05-28):** `end_date` come data nuda è inclusivo dell'intera giornata fino all'ultima candela chiusa (es. `end=oggi` arriva fino ad ora, non più a mezzanotte); accettati anche timestamp con orario (`...T14:00:00`, naive=UTC); nessun cap a ~5000 righe (paginazione interna). Il client passa già `end=oggi`, ora corretto. Prima del fix il paper trader restava a zero trade perché il feed era fermo a mezzanotte. - **Cerbero `get_historical` (fix 2026-05-28):** `end_date` come data nuda è inclusivo dell'intera giornata fino all'ultima candela chiusa (es. `end=oggi` arriva fino ad ora, non più a mezzanotte); accettati anche timestamp con orario (`...T14:00:00`, naive=UTC); nessun cap a ~5000 righe (paginazione interna). Il client passa già `end=oggi`, ora corretto. Prima del fix il paper trader restava a zero trade perché il feed era fermo a mezzanotte.
- **Dati ETH Deribit 15m:** 14-30%/anno di candele *flat* (O=H=L=C, volume 0, run fino a ~54h) per bassa liquidità del perpetuo. Verificato (2026-05-28): escluderle NON cambia i backtest (Δacc ≤0.5pp) → edge robusto. Resta un caveat operativo (slippage/fill in trading reale, irrilevante per paper). BTC pulito eccetto picco ~8% nel 2024. - **Dati ETH Deribit 15m:** 14-30%/anno di candele *flat* (O=H=L=C, volume 0, run fino a ~54h) per bassa liquidità del perpetuo. Verificato (2026-05-28): escluderle NON cambia i backtest (Δacc ≤0.5pp) → edge robusto. Resta un caveat operativo (slippage/fill in trading reale, irrilevante per paper). BTC pulito eccetto picco ~8% nel 2024.
- **FEED TESTNET INAFFIDABILE — `ETH-PERPETUAL` inverse CONGELATO (2026-06-13).** Il feed di - **FEED TESTNET INAFFIDABILE — `ETH-PERPETUAL` inverse CONGELATO (2026-06-13).** Il feed di
@@ -598,6 +620,24 @@ long A / short B sullo z-score del log-ratio, fee su 2 gambe).
ogni formato (l'endpoint legacy `get_historical` è 404, rimosso). La voce `"30m"` in ogni formato (l'endpoint legacy `get_historical` è 404, rimosso). La voce `"30m"` in
`runner._SUBHOURLY` era **speculativa, mai testata live** → qualsiasi TF sub-orario oltre 5m/15m `runner._SUBHOURLY` era **speculativa, mai testata live** → qualsiasi TF sub-orario oltre 5m/15m
va DERIVATO resamplando nel runner (come 4h/1d dal 1h), non fetchato diretto. va DERIVATO resamplando nel runner (come 4h/1d dal 1h), non fetchato diretto.
- **GATE FEED CONGELATO — sleeve ETH-leg sospesi (v1.1.32, 2026-06-15, scelta utente "congela
gli sleeve ETH-leg finché il feed non si sblocca").** Audit "stato ordini": il feed ETH congelato
stava generando **perdite REALI** (SH01_ETH 2.83 reali vs 0.09 sim su un close, poi riapertura
della stessa trappola; 4 pairs con gamba ETH entrati su z-score spuri 3/5/+5.6 = artefatto del
log-ratio con ETH pinnato). Fix: `runner._frozen_assets` + `_feed_gated_sids` — quando il feed di
decisione 1h di un asset è CONGELATO, gli sleeve CONCENTRATI (single/ml/pairs) che ne dipendono
**saltano il tick** (entry E exit) finché non si sblocca (come un outage; disaster-SL on-book =
protezione di coda). **Auto-guarente**: si rilascia alla prima barra COMPLETA non-flat (NON è
l'entry-guard post-flat bocciata: quella salta la candela-gap di ripresa, questo no). **Detector
guasto-vs-illiquido**: conta la run di close **INVARIATE** (prezzo mai cambiato), NON le barre flat
— sul feed reale ETH/BNB/DOGE hanno run 40-64 con 1-4 valori distinti/48h (MORTI) mentre
SOL/LTC/ADA sono flat ma VIVI (run ≤12, 5-31 valori/48h: si muovono ogni ~10 barre). Soglia
`feed_freeze_gate_bars=24` (1 giorno di prezzo immobile, configurabile, 0=off): gatea le **9 gambe
ETH esatte** (3 fade ETH 15m + SH_ETH + 5 pairs con gamba ETH) lasciando attivi BTC-only,
**PR_BTCLTC** (BTC vivo + LTC solo illiquido) e i multi-asset PAPER. Le posizioni ETH già aperte
NON vengono flattate (freeze=pausa: un close forzato passerebbe per lo stesso feed morto). Alert
Telegram `FEED_FROZEN_GATE` GATED/RIPRESO. Test `tests/portfolio/test_freeze_gate.py`, diario
`docs/diary/2026-06-15-frozen-feed-gate.md`. **Resta un cerotto testnet: il fix vero è mainnet.**
- **TIMING SWEEP pairs & honest (2026-06-14, NESSUN deploy).** Domanda utente: pairs/honest - **TIMING SWEEP pairs & honest (2026-06-14, NESSUN deploy).** Domanda utente: pairs/honest
beneficiano di TF più veloci come le fade (swap 1h→15m)? Esito: **no, niente deployato.** Vincolo beneficiano di TF più veloci come le fade (swap 1h→15m)? Esito: **no, niente deployato.** Vincolo
dati (alt 1h-only) → sweepabili solo **pairs ETH/BTC** e **DIP01 (BTC)**; TR01/ROT02 fuori scope dati (alt 1h-only) → sweepabili solo **pairs ETH/BTC** e **DIP01 (BTC)**; TR01/ROT02 fuori scope
+1 -1
View File
@@ -1 +1 @@
1.1.31 1.1.33
+2
View File
@@ -9,6 +9,7 @@ services:
- ./portfolios.yml:/app/portfolios.yml:ro - ./portfolios.yml:/app/portfolios.yml:ro
env_file: env_file:
- .env - .env
- .env.mainnet # token MAINNET (soldi veri); prevale su .env (vedi micro-test plan)
environment: environment:
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
healthcheck: healthcheck:
@@ -36,6 +37,7 @@ services:
- ./docs/report:/app/docs/report:ro # scheda strategie_attive.html (modal "scheda dettagliata") - ./docs/report:/app/docs/report:ro # scheda strategie_attive.html (modal "scheda dettagliata")
env_file: env_file:
- .env - .env
- .env.mainnet # token MAINNET (soldi veri); prevale su .env (vedi micro-test plan)
environment: environment:
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
healthcheck: healthcheck:
+85
View File
@@ -0,0 +1,85 @@
# 2026-06-15 — Gate del feed CONGELATO (freeze gate)
## Problema (da "stato ordini")
Audit degli ordini live: 5 sleeve in posizione reale, **4-5 contaminati dal feed ETH
congelato**. `ETH-PERPETUAL` (inverse, il feed di DECISIONE del runner) è fermo a
**1661.95 da 36h+** (100% barre flat, 57/57 nel windows 2g, **1 solo valore distinto**),
mentre il book reale dove si ESEGUE (`ETH_USDC-PERPETUAL`, lineare) si muove ~1717-1722.
Effetto misurato sui soldi reali (log container):
- **SH01_ETH** ha aperto short a sim 1661.95 / real 1717.10 (slippage 332 bps), chiuso a
time_limit con `sim_exit 1661.95` ma `real_fill 1719.21`**2.83 USD reali vs 0.09 sim**
→ e ha **riaperto subito** lo stesso short sulla trappola.
- I 4 pairs con gamba ETH (ETH/BTC 1h+15m, ETH/SOL, LTC/ETH, ADA/ETH) sono entrati con
z-score **estremi** (3.10, 2.96, +5.58, 2.56): artefatto MECCANICO del log-ratio con
ETH pinnato a 1661.95 mentre gli alt si muovono → falsa divergenza → carico di spread long-ETH.
È un GUASTO testnet, non di strategia: in mainnet l'arbitraggio tiene inverse ≈ lineare ≈ realtà.
## Decisione utente
> "congela gli sleeve ETH-leg finché il feed non si sblocca"
## Soluzione: gate del feed congelato nel runner (auto-guarente)
`src/portfolio/runner.py`: quando il feed di decisione 1h di un asset è **congelato**, gli
sleeve CONCENTRATI (single/ml/pairs) che ne dipendono **saltano il tick** (entry E exit)
finché il feed non si sblocca. Come un outage (i worker non valutano gli exit, protezione =
disaster-SL on-book). Si rilascia DA SOLO alla prima barra completa non-flat.
### Distinguere il GUASTO dall'ILLIQUIDITÀ (il punto critico)
Un detector flat-bar ingenuo sarebbe SBAGLIATO: sul feed reale (2026-06-15) gli alt sono
flat ma VIVI. Firma misurata (run finale di close invariate / valori distinti in 48h):
| Asset | run | distinti/48h | natura |
|-------|-----|--------------|--------|
| ETH | 64 | **1** | MORTO (guasto) |
| BNB | 64 | 2 | MORTO (testnet, solo paper) |
| DOGE | 42 | 4 | MORTO (testnet, solo paper) |
| XRP | 12 | 3 | illiquido |
| ADA | 11 | 5 | illiquido ma VIVO |
| LTC | 10 | 5 | illiquido ma VIVO |
| SOL | 1 | 31 | VIVO |
→ il detector conta la **run di close INVARIATE** (prezzo che non cambia MAI), non le barre
flat. Soglia **24** (`feed_freeze_gate_bars`, configurabile, 0=off): un giorno intero di
prezzo immobile separa nettamente le due popolazioni. ETH/BNB/DOGE (run 40-64) gateati,
ADA/LTC/XRP/SOL (run ≤12) no. **PR_BTCLTC resta attivo** (BTC vivo + LTC solo illiquido NON
deve sospendere il pair: si perderebbero segnali reali sulla gamba BTC).
### NON è l'entry-guard post-flat (BOCCIATA)
Si gatea **DURANTE** il freeze (ultima barra completa ferma). La barra di RIPRESA è non-flat
→ la run si azzera → il tick riprende SU di essa. NON si salta la candela-gap di ripresa
(quella è l'overshoot che la fade fada con profitto — l'entry-guard post-flat è bocciata,
CLAUDE.md / 2026-06-05). Rilascio solo su barra **completata** non-flat (la forming non basta,
coerente con EXIT-16).
### Scope
Solo sleeve `single`/`ml`/`pairs`. I multi-asset (basket/rotation/tsmom/xsec, tutti PAPER)
NON sono gateati: diversificati su 8 asset, un feed fermo non li compromette (e ROT02 ha ETH
nell'universo ma resta attivo). Osservabilità: log `FEED_FROZEN_GATE` + alert Telegram
GATED/RIPRESO, dedup per episodio.
## Esito (smoke feed reale, soglia 24)
- CONGELATI: ETH, BNB, DOGE.
- **GATEATI: le 9 gambe ETH esatte** — MR01/MR02/MR07_ETH, SH_ETH, PR_ETHBTC, PR_ETHBTC_15M,
PR_LTCETH, PR_ADAETH, PR_ETHSOL. BTC-only, PR_BTCLTC e i multi-asset restano attivi.
Le 5 posizioni ETH già aperte NON vengono flattate (freeze = pausa, non liquidazione): un
close forzato passerebbe per lo stesso feed congelato (= il close 2.83 di SH01). Restano
gestite alla ripresa del feed; protezione di coda = disaster-SL on-book a 30%.
Test: `tests/portfolio/test_freeze_gate.py` (detector guasto-vs-illiquido, rilascio su barra
completata, scope sui veri sleeve PORT06). Suite portfolio: 140/140 verdi.
## Lezione
Tre livelli di osservabilità del feed ora distinti: STALE_FEED (≥2 barre flat, alert umano),
FEED_BOOK_GAP (feed≠book d'esecuzione, alert), **FEED_FROZEN_GATE (≥24 barre invariate, AGISCE)**.
La firma che separa guasto da illiquidità è la **run di prezzo invariato**, non la barra flat:
gli alt illiquidi stampano barre flat ma il prezzo si muove ogni ~10 barre.
@@ -0,0 +1,66 @@
# 2026-06-16 — Guard TP-invertito (churn MR02_BTC 15m)
## Sintomo
Burst di **8 trade in 15 minuti** su `MR02_donchian_fade__BTC__15m` (09:0009:15 UTC),
tutti `long`, tutti chiusi `take_profit` a `bars_held=0-1` **in perdita**, accuracy
crollata 60%→27%. TP **fisso** a 66189.25, **sotto** ogni entry (6670066834):
```
09:00 OPEN long 66780.5 tp=66189.25 sl=64218.21 ← tp E sl ENTRAMBI sotto l'entry
09:02 CLOSE take_profit exit=66189.25 held=0 sim=-2.63 real=-0.12
... ×8 ... +7× REAL_TP_FAIL: invalid_reduce_only_order
```
I trade MR02 sani prima del cluster hanno il TP dal lato giusto (long → tp>entry:
65033>63414, 64148>63775, 66079>65717). Il cluster delle 09:00 è l'unico **invertito**.
## Causa radice — segnale da wick transitorio (NON feed congelato, NON spike d'ingresso)
BTC alle 09:00 era **davvero** ~66780 (MR01 short@66919, MR07 short@66780.5 nello stesso
istante, slippage reale 0.72.5 bps → feed sano). Il difetto è a monte:
1. Una barra con **wick verso il basso** (~64218 = il livello SL) fa scattare il donchian
"rottura canale basso" → segnale **LONG**, con `tp`=centro canale (66189) e `sl`=64218
calcolati **dal contesto wickato**.
2. Il wick rientra; il worker entra `long` al prezzo **recuperato** 66780 — cioè **sopra
il proprio TP=66189**.
3. L'exit intrabar (`bar_high >= tp`) è banalmente vera all'istante → chiude "take_profit"
a 66189 = **perdita garantita a held=0**. Si ripete 8× perché il livello donchian
wickato resta nel lookback.
Differenza chiave da TP_PHANTOM: lì il wick è sull'**exit** (tocco fantasma del TP);
qui il wick produce un **tp dal lato sbagliato dell'entry** → la posizione non doveva
proprio aprirsi.
## Perché i guard esistenti non l'hanno preso
- **TP_PHANTOM** richiede un limit reduce-only resting come oracolo. Qui il limit è
**respinto** (`invalid_reduce_only_order`: una sell reduce-only a 66189 sotto il mercato
è invalida) → niente `real_tp_order_id` → fail-open → l'exit passa. E il prezzo corrente
(66780) ha **realmente** superato il livello → non è un tocco fantasma.
- **Freeze-gate (v1.1.32)**: BTC non è congelato (prezzi che si muovono) → non scatta.
## Danno reale: contenuto (il real-truth ledger ha funzionato)
- Σ **reale** del cluster ≈ **−€2.29** (solo slippage+fee: il fill reale è al prezzo vero
~66700-66730, non al TP fantasma).
- Σ **sim** sarebbe stato ≈ **−€17.9** → il real-truth ledger ha evitato che ~**€15.6** di
perdita fantasma colpisse l'equity.
- Costi collaterali: 8 round-trip di fee, 7 TP reduce-only respinti, 8 disaster-SL da
piazzare/cancellare, sim accuracy/report inquinati.
- Auto-esaurito alle 09:15 (sleeve poi flat).
## Fix — guard "TP invertito" (v1.1.33, zero parametri)
`StrategyWorker._open_position`: **prima** di aprire, se il TP è già sfondato all'ingresso
(`long & tp<=entry` oppure `short & tp>=entry`) il segnale è malformato (artefatto wick)
**NON si apre** (niente ordine reale, niente disaster-SL). Verità d'esecuzione, non un
filtro di strategia (stessa filosofia di TP_PHANTOM). Log `INVERTED_TP_SKIP` (dedup
per-barra) + alert Telegram una-tantum per processo. Agisce **solo sul path live**: il
backtest canonico entra al close del bar del segnale (entry == base di tp/sl → mai
invertito) e resta non filtrato → il live farà meglio del backtest, come loss-guard /
trend / TP_PHANTOM.
Test: `tests/portfolio/test_inverted_tp.py` (long/short invertiti skippati, controlli
positivi long/short validi aprono, no-tp apre, dedup per-barra). Suite portfolio 146/146.
## Lezione
Cerotto testnet (il feed wicka): il fix vero è **mainnet**, dove l'arbitraggio elimina i
wick-print. Ma il guard è corretto in assoluto — un segnale con TP dal lato sbagliato
dell'entry non va mai eseguito, su qualsiasi venue.
@@ -0,0 +1,57 @@
# 2026-06-16 — Fase 0 smoke mainnet VERDE (ETH→USDC, limit non market)
## Contesto
Il conto Deribit mainnet, fino al 2026-06-14 **vuoto** (€0, unico blocco al micro-test), ha
ricevuto un deposito iniziale di **~$50**: 25.00 USDC + 0.013878 ETH. Occasione per eseguire la
**Fase 0** del piano (`docs/specs/mainnet-microtest-plan.md`): validare la catena d'esecuzione su
soldi VERI con rischio ~0, abbinandola a un'operazione utile — **consolidare l'ETH stranded in
margine USDC**.
Perché consolidare: noi eseguiamo sui perp **lineari USDC** (`*_USDC-PERPETUAL`), marginati in
**USDC**. I 0.0138 ETH stavano nel wallet ETH (collateral dell'inverse, che NON usiamo per la
matematica lineare del backtest) → margine effettivo solo i $25 USDC. Convertendo, l'intero ~$50
diventa USDC utilizzabile.
## Ordine (LIMIT, non market — istruzione utente)
Check capability read-only prima: spot `ETH_USDC` esiste (bid 1780.3/ask 1780.9, `testnet:false`,
price tick 0.1). Il listing strumenti NON espone lo spot (kind=spot vuoto, endpoint v1 404) ma il
ticker basta. `place_order` supporta `order_type="limit"` + `price`.
- **LIMIT SELL `ETH_USDC` 0.0138 ETH @ floor 1776.3** (~0.1% sotto il bid) — *marketable limit con
cap di slippage*: crossa e filla al bid corrente, ma con un floor sotto cui NON può fillare.
Soddisfa "non a market" (prezzo protetto, niente slippage incontrollato) garantendo il fill.
- Step 0.0001 ok al primo colpo (niente fallback 0.013); fallback previsti 0.013/0.012.
### Esito
```
order_state = filled filled_amount = 0.0138
average_price = 1778.1 (= bid al momento dell'invio; il bid era sceso 1780.3->1778.1 tra
quote e invio, ma > floor 1776.3 -> zero slippage negativo)
fee = 0.0 (maker, costo nullo)
order_id = ETH_USDC-8488088107
```
Riconciliazione conto (before→after):
| Wallet | Prima | Dopo |
|---|---|---|
| USDC | 25.00 | **49.538** (+24.538) |
| ETH | 0.013878 | 0.000078 (dust ~$0.14, sotto il min → non convertibile) |
| BTC | 0 | 0 |
| posizioni / ordini resting | — | nessuna / nessuno |
## Cosa è dimostrato
Catena end-to-end su **mainnet** (`testnet:false`), a costo ~0: routing live + auth + `place_order`
limit + fill + verifica via `get_trade_history` + riconciliazione `get_account_summary`. La
meccanica d'esecuzione reale FUNZIONA su soldi veri. **Fase 0 ✅.**
## Limite residuo (resta il blocco vero)
~$49.54 USDC è **sotto il minimo €500** del piano: a questo margine il rumore di arrotondamento
BTC (step lineare ~$6.6) soffoca il segnale → NON si può ancora testare l'**edge**. Per la **Fase 1**
(fade-only, €1000, 2-4 settimane, verdetto ledger-vs-backtest) serve un **top-up a ~€500-1000 USDC**.
Stato piano aggiornato: token+smoke ✅, funding 🟡 parziale, Fase 1 in attesa di capitale.
## Note operative
- Operazione MANUALE one-off (non il runner): `.env.mainnet` sourcato a mano, client puntato a
mainnet solo per questa sessione. Il runner di produzione resta su **testnet** (`.env` condiviso
invariato) → nessun rischio di flip accidentale.
- La marketable-limit con floor è il pattern giusto per conversioni one-shot a prezzo controllato
(vs il TP resting/limit-al-livello degli sleeve): fill immediato + cap di slippage, fee maker.
+16 -7
View File
@@ -14,16 +14,25 @@ testnet era ancora congelato a 1661.95 da 36h+ → conferma definitiva: testnet
--- ---
## STATO (2026-06-14) ## STATO (2026-06-16)
-**Token mainnet wired e VERIFICATO.** `MAINNET_TOKEN` (da cerbero-mcp, che ha già le chiavi -**Token mainnet wired e VERIFICATO.** `MAINNET_TOKEN` (da cerbero-mcp, che ha già le chiavi
Deribit LIVE) copiato in **`.env.mainnet`** (chmod 600, gitignored), SEPARATO dal `.env` Deribit LIVE) copiato in **`.env.mainnet`** (chmod 600, gitignored), SEPARATO dal `.env`
condiviso. Verifica read-only: `is_mainnet()=True`, auth OK su `get_account_summary` (nessun condiviso. Verifica read-only: `is_mainnet()=True`, auth OK su `get_account_summary` (nessun
ordine). Il `.env` condiviso resta testnet → il runner attuale NON flippa a mainnet a un riavvio. ordine). Il `.env` condiviso resta testnet → il runner attuale NON flippa a mainnet a un riavvio.
- 🔴 **BLOCCO: conto Deribit mainnet VUOTO** (equity 0.0 USDC/BTC/ETH). Serve il deposito (~€1000 - **FASE 0 SMOKE VERDE (2026-06-16).** Catena d'esecuzione validata su mainnet con soldi VERI a
USDC) prima di qualsiasi smoke/trade. costo ~0: LIMIT SELL `ETH_USDC` spot 0.0138 ETH @ floor 1776.3 (marketable, NON market) →
- ⏭️ Appena finanziato: avviare il micro-test come **servizio separato** (env_file `.env.mainnet`, `order_state=filled`, average_price **1778.1** (= bid, zero slippage negativo), **fee 0.0**.
dir dati pulita, `portfolios.yml` fade-only) → Fase 0 smoke → Fase 1. Riconciliazione conto: USDC 25.00→**49.54** (+24.54), ETH→0.000078 dust, nessun ordine resting.
Provato end-to-end: routing live + auth + place_order limit + fill + verifica trade + balance.
(Doppio scopo: consolidare l'ETH stranded in margine USDC + smoke.) Diario
`docs/diary/2026-06-16-mainnet-fase0-smoke.md`.
- 🟡 **FINANZIAMENTO PARZIALE: ~$49.54 USDC** (deposito iniziale ~$50: 25 USDC + 0.0138 ETH, ora
consolidati). **Sotto il minimo €500** del piano → basta per la meccanica (Fase 0 ✅) ma NON per
testare l'edge (a $50 di margine il rumore di arrotondamento BTC soffoca il segnale). **Serve
top-up a ~€500-1000 USDC** prima della Fase 1.
- ⏭️ Appena finanziato a ~€1000: avviare il micro-test come **servizio separato** (env_file
`.env.mainnet`, dir dati pulita, `portfolios.yml` fade-only) → Fase 1.
--- ---
@@ -164,9 +173,9 @@ feed congelato). Criterio (lo stesso del report, soglie già nel verdetto 🟢/
- [x] MAINNET_TOKEN Cerbero abilitato + verificato (auth OK, is_mainnet=True) — 2026-06-14 - [x] MAINNET_TOKEN Cerbero abilitato + verificato (auth OK, is_mainnet=True) — 2026-06-14
- [x] Token in `.env.mainnet` (separato, gitignored, chmod 600) + `CERBERO_BOT_TAG=pythagoras-mainnet` - [x] Token in `.env.mainnet` (separato, gitignored, chmod 600) + `CERBERO_BOT_TAG=pythagoras-mainnet`
- [ ] **Conto Deribit mainnet KYC + finanziato ~€1000 USDC** ← BLOCCO ATTUALE (conto a €0) - [x] Fase 0 smoke mainnet VERDE (LIMIT SELL ETH_USDC filled, fill@bid, fee 0, balance riconciliato) — 2026-06-16
- [~] Conto Deribit mainnet finanziato: **~$49.54 USDC** (parziale, sotto il min €500) ← **TOP-UP a ~€1000 = BLOCCO Fase 1**
- [ ] Servizio micro-test separato (env_file `.env.mainnet`, dir dati pulita) - [ ] Servizio micro-test separato (env_file `.env.mainnet`, dir dati pulita)
- [ ] Fase 0 smoke mainnet verde (ordine→verifica→fee)
- [ ] `portfolios.yml` micro-test (fade-only, €1000, exec sleeves MR01/02/07) - [ ] `portfolios.yml` micro-test (fade-only, €1000, exec sleeves MR01/02/07)
- [ ] runner mainnet + reconciler + report ledger-vs-backtest schedulati - [ ] runner mainnet + reconciler + report ledger-vs-backtest schedulati
- [ ] dopo ≥1020 trade: leggere il verdetto → scala / aspetta / stop - [ ] dopo ≥1020 trade: leggere il verdetto → scala / aspetta / stop
+41 -55
View File
@@ -1,69 +1,55 @@
# Config LIVE del paper trader a portafoglio. Seleziona UN portafoglio attivo # Config LIVE del trader a portafoglio. Seleziona UN portafoglio attivo
# (definito in scripts/portfolios/_defs.py) e ne fa l'override dei parametri operativi. # (definito in scripts/portfolios/_defs.py) e ne fa l'override dei parametri operativi.
active: PORT06 # default raccomandato: master + shape #
# ============ MICRO-TEST MAINNET — soldi VERI (Fase 1, 2026-06-17) ============
# Conversione da testnet a mainnet Deribit (vedi docs/specs/mainnet-microtest-plan.md).
# Capitale REALE €500 (periodo di prova; poi si scala col verdetto ledger-vs-backtest).
# Eseguono reale SOLO le 6 fade (MR01/MR02/MR07 x BTC/ETH, 15m) + DIP01 (BTC 1h);
# pairs/SH01/multi-asset -> PAPER (sola statistica, fuori dal pool/conto). Il token
# mainnet arriva da .env.mainnet (env_file del servizio). Ledger ripartito da ZERO:
# lo storico testnet e' archiviato in data/_reset_backup/pre_mainnet_20260617-205943.tgz.
active: PORT06
overrides: overrides:
total_capital: 2000 # CAPITALE REALE del micro-test (era 2000 su testnet). €500 = minimo del piano
weighting: cap # equal | cap | inverse_vol | cluster_rp | manual # (rumore arrotondamento BTC ~5-6%); si sale dopo il verdetto ledger-vs-backtest.
# NB: questo dict SOSTITUISCE interamente i caps di _defs.py (setattr in total_capital: 500
# base.py:load_active_portfolio) → va ridichiarato COMPLETO. Il cap SHAPE # equal-weight: nel pool REALE restano solo FADE (6) + DIP01 (1) -> 1/7 ciascuno.
# 0.0588 (mitigazione coda SH01, 2026-06-05) era stato perso per questo. # I cap PAIRS/SHAPE non servono piu' (quelle famiglie sono PAPER, fuori dal pool).
caps: {PAIRS: 0.33, SHAPE: 0.0588} weighting: equal
# Leva 3x (2026-06-12, scelta utente, da frontiera ACCEL50: OOS CAGR 111->206%, # Leva 3x (scelta utente 2026-06-17 per il micro-test = config che vogliamo deployare,
# FULL DD 3.5->5.2% nel modello lineare; vedi scripts/analysis/accel50_research.py # frontiera ACCEL50). NB su soldi veri al capitale minimo: DD pieno (alt prudente 2x).
# e docs/diary/2026-06-12-accel50.md). Era 2 ("sobrio") dal primo deploy live.
leverage: 3 leverage: 3
rebalance: 1D rebalance: 1D
poll_seconds: 60 poll_seconds: 60
# SLEEVE PAPER (2026-06-08): fuori dal pool/pesi/ledger — i €2000 si dividono SOLO # Gate feed CONGELATO: su mainnet l'arbitraggio tiene il feed vivo (niente freeze come
# tra i 14 sleeve REALI (fade+DIP01+pairs+SH01). I multi-asset (no esecuzione reale, # sul testnet), lo lascio attivo come rete di sicurezza (non scattera' su BTC/ETH
# bloccati dal capitale) girano in data/portfolio_paper_stats/ con capitale nozionale # liquidi). 0 = disattivo.
# fisso, SOLO per statistica in vista di future implementazioni reali. NB: il portafoglio feed_freeze_gate_bars: 24
# live diverge ora dal PORT06 canonico (17 sleeve) -> DD reale ~5.35% vs 3.96% validato: # SLEEVE PAPER (fuori dal pool/pesi/ledger): i €500 si dividono SOLO tra i 7 sleeve
# il prezzo di vedere il risultato reale puro (scelta utente). # REALI (6 fade + DIP01). Pairs (PR01, 2 gambe) e SH01 fuori dalla Fase 1: a €500 il
paper_sleeves: [TR01, ROT02, TSM01, XS01] # rumore di arrotondamento li soffoca (pairs ~30%/gamba; servono ~€5-8k) e aggiungono
# Frazione di capitale-sleeve per posizione (canonico backtest = 0.15). # superfici d'errore (leg-risk). Multi-asset (TR01/ROT02/TSM01/XS01) paper come sempre.
# 0.5 con leva 2x = 100% della fetta impegnata quando in posizione (max impiego paper_sleeves: [TR01, ROT02, TSM01, XS01, PR01, SH01]
# dei 2K senza debito di margine). NB: il DD scala ~lineare (~×3.3 vs validato). # Frazione di capitale-sleeve per posizione (0.5 con leva 3 = 1.5x la fetta impegnata).
position_size: 0.5 position_size: 0.5
# Override per-famiglia (chiave = weighting.family_of). PAIRS: famiglia SENZA # Override per-famiglia: irrilevante per il conto reale (i pairs sono PAPER), tenuto
# stop, validata a esposizione 0.45 (pos 0.15 lev 3) — il gate 2026-06-07 # solo perche' i worker pairs in sola-statistica dimensionino come da gate storico.
# (pairspos_port06_impact.py) la fisso' a 0.40 di esposizione (0.20 × lev 2):
# PORT06 OOS DD 3.40→1.26%, costo OOS Sharpe 9.05→8.43. Col passaggio a lev 3
# (2026-06-12) il pos scende a 0.13 per CONSERVARE la stessa esposizione
# (0.13×3≈0.40): la leva accelera le famiglie con stop, non i pairs no-stop.
position_size_family: {PAIRS: 0.13} position_size_family: {PAIRS: 0.13}
# Esecuzione REALE su Deribit testnet, in SHADOW (sim + reale in parallelo). # Esecuzione REALE su Deribit MAINNET. Solo i 7 single-leg con TP/SL in metadata:
# I 7 single-leg con TP/SL in metadata: 6 fade (MR01/MR02/MR07 x BTC/ETH) + # 6 fade (MR01/MR02/MR07 x BTC/ETH 15m) + DIP01 (BTC 1h). Ordini sui LINEARI USDC
# DIP01 BTC (attivato 2026-06-04: stesso wiring StrategyWorker, TP limit resting # (payoff lineare = matematica del backtest; fee/PnL in USDC).
# incluso). Ordini sui LINEARI USDC (payoff lineare = matematica del backtest;
# fee/PnL in USDC). Gli altri sleeve (pairs/rotation/tsmom/shape) restano
# simulati: pairs richiede executor a 2 gambe, shape non ha TP (orizzonte puro).
execution: execution:
enabled: true enabled: true
# SH01 aggiunto 2026-06-08: e' il diversificatore piu' decorrelato (corr 0.07, # SOLO fade + DIP01 in Fase 1 (SH01 e pairs -> paper, vedi paper_sleeves).
# senza di lui il DD del portafoglio sale 3.96->5.35%). Single-leg, esce a sleeves: [MR01, MR02, MR07, DIP01]
# orizzonte H=12 (no TP/SL) -> _place_real_tp no-op, _real_close market reduce-only;
# disaster-bracket on-book = unica protezione di coda durante outage.
sleeves: [MR01, MR02, MR07, DIP01, SH01]
instruments: instruments:
BTC: BTC_USDC-PERPETUAL BTC: BTC_USDC-PERPETUAL
ETH: ETH_USDC-PERPETUAL ETH: ETH_USDC-PERPETUAL
LTC: LTC_USDC-PERPETUAL # niente esecuzione a 2 gambe in Fase 1 (pairs sono paper).
ADA: ADA_USDC-PERPETUAL pairs_enabled: false
SOL: SOL_USDC-PERPETUAL # Disaster-bracket on-book (~-30%) a ogni apertura: assicurazione per gli outage
# Esecuzione REALE a 2 gambe per i pairs (PairsExecutionClient, 2026-06-08). # del runner. In operativita' normale non scatta mai -> 0 costo. 0 = disattivo.
# Long A / short B sui lineari USDC, con unwind se una gamba sola filla (leg-risk).
# ATTIVATO 2026-06-08 a conto flat (smoke testnet end-to-end verificato).
pairs_enabled: true
# Disaster-bracket on-book (2026-06-07): STOP_MARKET reduce-only a ~-30%
# dall'ingresso, piazzato a ogni REAL_OPEN e cancellato alla chiusura.
# Assicurazione per gli outage (runner fermo = exit non valutati); in
# operativita' normale non scatta mai -> 0 costo Sharpe. 0 = disattivo.
disaster_sl_pct: 0.30 disaster_sl_pct: 0.30
# REAL-TRUTH (2026-06-10, scelta utente): il ledger `capital` degli sleeve # REAL-TRUTH: equity/ribilanci/sizing derivano dai FILL REALI (fee reali incluse),
# eseguiti si aggiorna col PnL dei FILL REALI (fee reali incluse) — il sim # non dal sim. Il sim resta solo diagnostica nel log CLOSE.
# resta solo diagnostica nel log CLOSE (pnl_source/sim_pnl/real_pnl).
# Fallback al sim SOLO se il trade reale non e' mai esistito/fillato
# (REAL_OPEN_FAIL): flag pnl_source=sim_fallback nel log. Cosi' equity,
# ribilanci e sizing derivano dai soldi veri sul conto, non dal sim.
real_truth: true real_truth: true
+79
View File
@@ -23,6 +23,7 @@ from __future__ import annotations
import json import json
import sys import sys
import time import time
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2] PROJECT_ROOT = Path(__file__).resolve().parents[2]
@@ -39,6 +40,14 @@ TOL_STEPS = 1.5 # tolleranza = 1.5 × step contratto
STALE_REAL_MIN = 15 # un worker vivo aggiorna status.json ~ogni poll (60s); STALE_REAL_MIN = 15 # un worker vivo aggiorna status.json ~ogni poll (60s);
# real_in_position con mtime oltre questo = NON gestito # real_in_position con mtime oltre questo = NON gestito
# --- Watermark FONDI (2026-06-17): rileva aumenti di capitale (depositi/top-up) e
# cali anomali (prelievi) confrontando il balance USDC col precedente osservato.
# Serve a sapere quando un versamento e' arrivato (per scalare total_capital). ---
FUNDS_STATE = PROJECT_ROOT / "data" / "funds_watch.json" # data/ e' scrivibile dal
# cron host (adriano); data/portfolios e' root (container)
FUNDS_DELTA_MIN = 25.0 # USDC: sotto questo Δbalance e' rumore di trading (PnL/fee orarie)
FUNDS_DELTA_PCT = 0.05 # ...o 5% del balance precedente (robusto al crescere del conto)
def compute_drift(client: CerberoClient | None = None) -> list[dict]: def compute_drift(client: CerberoClient | None = None) -> list[dict]:
client = client or CerberoClient() client = client or CerberoClient()
@@ -139,11 +148,55 @@ def compute_stale_real_positions(max_age_min: int = STALE_REAL_MIN) -> list[dict
return out return out
def compute_funds_change(client: CerberoClient | None = None,
currency: str = "USDC") -> dict:
"""Watermark dei FONDI: confronta il `balance` USDC corrente con l'ultimo
osservato (persistito in FUNDS_STATE) e segnala un AUMENTO (probabile deposito/
top-up di capitale) o un CALO anomalo (probabile prelievo). Read-only; aggiorna
SEMPRE il watermark (-> l'alert scatta UNA volta sul gradino, non si ripete).
Si traccia `balance` (cassa realizzata: cambia su deposito/prelievo/PnL chiuso/fee),
NON `equity` (che ondeggia con l'unrealized -> falsi positivi). Soglia robusta =
max(FUNDS_DELTA_MIN, FUNDS_DELTA_PCT × balance_prec): il PnL realizzato di trading
in un'ora resta sotto, un versamento la supera nettamente. Primo run = solo seed."""
client = client or CerberoClient()
s = client.get_account_summary(currency)
cur_bal = float(s.get("balance", s.get("equity", 0.0)) or 0.0)
cur_eq = float(s.get("equity", cur_bal) or 0.0)
iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
out = dict(currency=currency, balance=cur_bal, equity=cur_eq, ts=iso,
prev_balance=None, delta=0.0, thr=0.0, kind="seed")
prev = None
if FUNDS_STATE.exists():
try:
prev = json.loads(FUNDS_STATE.read_text())
except Exception:
prev = None
if prev is not None and "balance" in prev:
pb = float(prev["balance"])
delta = cur_bal - pb
thr = max(FUNDS_DELTA_MIN, FUNDS_DELTA_PCT * abs(pb))
out.update(prev_balance=pb, prev_ts=prev.get("ts"), delta=delta, thr=thr,
kind="increase" if delta >= thr else
"decrease" if delta <= -thr else "flat")
try: # persisti il nuovo watermark
FUNDS_STATE.parent.mkdir(parents=True, exist_ok=True)
FUNDS_STATE.write_text(json.dumps(
{"ts": iso, "balance": cur_bal, "equity": cur_eq, "currency": currency}))
except Exception as e:
out["persist_error"] = str(e)
return out
def main(): def main():
client = CerberoClient() client = CerberoClient()
rows = compute_drift(client) rows = compute_drift(client)
resting = compute_resting_drift(client) resting = compute_resting_drift(client)
stale = compute_stale_real_positions() stale = compute_stale_real_positions()
try:
funds = compute_funds_change(client)
except Exception as e:
funds = {"kind": "error", "error": str(e)}
bad = [r for r in rows if not r["ok"]] bad = [r for r in rows if not r["ok"]]
bad_rest = [r for r in resting if r["status"] != "OK"] bad_rest = [r for r in resting if r["status"] != "OK"]
if bad or bad_rest: if bad or bad_rest:
@@ -176,6 +229,17 @@ def main():
if not stale: if not stale:
print("(nessuna posizione reale stantia)") print("(nessuna posizione reale stantia)")
print(f"\n{'FONDI conto (' + funds.get('currency', 'USDC') + ')':<30}{'prec':>12}{'attuale':>12}{'Δ':>12}")
if funds["kind"] == "error":
print(f" ⚠️ lettura fondi fallita: {funds.get('error', '')[:120]}")
elif funds["kind"] == "seed":
print(f" baseline registrata: balance={funds['balance']:.2f} (primo run, nessun confronto)")
else:
tag = {"increase": "⬆️ AUMENTO (deposito?)", "decrease": "⬇️ CALO (prelievo?)",
"flat": "= invariato"}[funds["kind"]]
print(f" {'balance':<27}{funds['prev_balance']:>12.2f}{funds['balance']:>12.2f}"
f"{funds['delta']:>+12.2f} {tag} (soglia ±{funds['thr']:.2f})")
print("\nESITO:", "OK — conto allineato ai libri" if not (bad or bad_rest or stale) print("\nESITO:", "OK — conto allineato ai libri" if not (bad or bad_rest or stale)
else f"⚠️ ANOMALIE (drift_pos={len(bad)} resting={len(bad_rest)} stale={len(stale)})") else f"⚠️ ANOMALIE (drift_pos={len(bad)} resting={len(bad_rest)} stale={len(stale)})")
@@ -209,6 +273,21 @@ def main():
"(caso MR02_BTC 2026-06-12); MISSING = atteso ma non in book; " "(caso MR02_BTC 2026-06-12); MISSING = atteso ma non in book; "
"STALE = in book senza libro corrispondente")}) "STALE = in book senza libro corrispondente")})
print("[telegram] alert RESTING_DRIFT inviato") print("[telegram] alert RESTING_DRIFT inviato")
if funds.get("kind") in ("increase", "decrease") and "--telegram" in sys.argv:
from src.live.telegram_notifier import notify_event
ev = "FUNDS_INCREASE" if funds["kind"] == "increase" else "FUNDS_DECREASE"
notify_event(ev, {
"valuta": funds["currency"],
"balance_prec": round(funds["prev_balance"], 2),
"balance_attuale": round(funds["balance"], 2),
"delta": round(funds["delta"], 2),
"equity": round(funds["equity"], 2),
"note": ("AUMENTO fondi (probabile DEPOSITO/top-up): per usarli, scala "
"total_capital in portfolios.yml e riavvia il runner "
"(docker compose up -d)" if funds["kind"] == "increase"
else "CALO fondi non spiegato dal trading (prelievo? verifica il conto)")})
print(f"[telegram] alert {ev} inviato")
return bool(bad or bad_rest or stale) return bool(bad or bad_rest or stale)
+234
View File
@@ -0,0 +1,234 @@
"""Report "stato trades" — separa SEMPRE pool reale e paper-stats, e mostra sia
realizzato che unrealized. Verita' aggregata = ledger PORT06; il dettaglio per-trade
viene dai worker dir.
Fonte unica della separazione (NON la memoria — qui ho gia' sbagliato una volta
includendo XS01 nell'equity):
data/portfolio_paper/ -> POOL REALE (nel ledger, muove l'equity/il conto)
data/portfolio_paper_stats/ -> PAPER-STATS (solo statistica, MAI nel ledger)
Uso:
uv run python scripts/analysis/trades_status.py # ultime 24h
uv run python scripts/analysis/trades_status.py --since 2026-06-16T14:00
"""
import argparse
import datetime as dt
import glob
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2])) # project root -> import src.*
DATA = Path("data")
POOL_DIR = DATA / "portfolio_paper"
STATS_DIR = DATA / "portfolio_paper_stats"
LEDGER = DATA / "portfolios" / "PORT06" / "status.json"
EQUITY = DATA / "portfolios" / "PORT06" / "equity.jsonl"
def _closes(worker_dir: Path, since: dt.datetime):
"""CLOSE events dei worker in worker_dir dal momento `since`."""
out = []
for f in sorted(glob.glob(str(worker_dir / "*" / "trades.jsonl"))):
name = Path(f).parent.name
for line in open(f):
try:
d = json.loads(line)
except json.JSONDecodeError:
continue
if d.get("event") != "CLOSE":
continue
try:
t = dt.datetime.fromisoformat(d.get("ts", ""))
except ValueError:
continue
if t < since:
continue
# PnL che conta: reale se eseguito (real_truth), altrimenti il sim/pnl
real = d.get("real_pnl")
real = real if real is not None else d.get("pnl", 0.0)
out.append({
"ts": d.get("ts", "")[:19], "name": name, "reason": d.get("reason"),
"held": d.get("bars_held"), "pnl": d.get("pnl"),
"real": d.get("real_pnl"), "sim": d.get("sim_pnl"), "eff": real or 0.0,
})
return sorted(out, key=lambda r: r["ts"])
def _open_positions(worker_dir: Path):
"""Posizioni aperte con i campi per il PnL non realizzato (entry REALE di
esecuzione quando eseguito; il feed di decisione sim e' dislocato)."""
out = []
for f in sorted(glob.glob(str(worker_dir / "*" / "status.json"))):
d = json.load(open(f))
if not d.get("in_position"):
continue
wid = Path(f).parent.name
is_pair = "entry_a" in d
executed = bool(d.get("real_in_position"))
row = {"name": wid, "pair": is_pair, "real": executed,
"dir": d.get("direction", 0), "held": d.get("bars_held"),
"max_bars": d.get("max_bars"), "tp": d.get("tp", 0.0),
"unreal": None}
if is_pair:
a_, b_ = wid.split("__")[1].split("_")
row.update({
"assets": [a_, b_], "leg_a": a_, "leg_b": b_,
"entry_a": d.get("real_entry_a") if executed else d.get("entry_a"),
"entry_b": d.get("real_entry_b") if executed else d.get("entry_b"),
"notional_a": d.get("real_notional_a") or 0.0,
"notional_b": d.get("real_notional_b") or 0.0,
})
else:
asset = wid.split("__")[1]
entry = (d.get("real_entry_price") if executed else None) or d.get("entry_price") or 0.0
row.update({
"assets": [asset], "asset": asset, "entry": entry,
"notional": d.get("real_entry_notional") or round(d.get("capital", 0.0) * 0.5 * 3, 1),
})
out.append(row)
return out
def _marks(assets: set) -> dict:
"""Mark correnti USDC perp (best-effort). {} se Cerbero non risponde."""
if not assets:
return {}
try:
from src.live.cerbero_client import CerberoClient
insts = [f"{a}_USDC-PERPETUAL" for a in assets]
data = CerberoClient().get_ticker_batch(insts)
return {t["instrument_name"].split("_")[0].replace("-PERPETUAL", ""): t.get("mark_price")
for t in data.get("tickers", [])}
except Exception as e:
print(f"[trades_status] mark fetch fallita ({e!r}) -> PnL aperti n/d", file=sys.stderr)
return {}
def _annotate_pnl(positions, marks):
"""PnL non realizzato live, convenzione identica alla dashboard (entry reale vs mark USDC)."""
for a in positions:
if a["pair"]:
ma, mb = marks.get(a["leg_a"]), marks.get(a["leg_b"])
if ma and mb and a.get("entry_a") and a.get("entry_b"):
s = 1 if a["dir"] > 0 else -1 # +1: long A / short B
ga = a["notional_a"] * s * (ma - a["entry_a"]) / a["entry_a"]
gb = a["notional_b"] * (-s) * (mb - a["entry_b"]) / a["entry_b"]
a["unreal"] = ga + gb
a["mark"], a["mark_b"] = ma, mb
else:
mk = marks.get(a["asset"])
if mk and a.get("entry"):
sign = 1 if a["dir"] > 0 else -1
pct = sign * (mk - a["entry"]) / a["entry"]
a["unreal"] = a["notional"] * pct
a["unreal_pct"] = pct * 100
a["mark"] = mk
return positions
def _equity_at(since: dt.datetime):
"""Equity piu' vicino (e <=) a `since`, per il Δ della finestra."""
base = None
if not EQUITY.exists():
return None
for line in open(EQUITY):
try:
d = json.loads(line)
t = dt.datetime.fromisoformat(d["ts"])
except (json.JSONDecodeError, KeyError, ValueError):
continue
if t <= since:
base = d["equity"]
else:
break
return base
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--since", default=None,
help="ISO (es. 2026-06-16T14:00). Default: 24h fa.")
args = ap.parse_args()
now = dt.datetime.now(dt.timezone.utc)
if args.since:
since = dt.datetime.fromisoformat(args.since)
if since.tzinfo is None:
since = since.replace(tzinfo=dt.timezone.utc)
else:
since = now - dt.timedelta(hours=24)
led = json.load(open(LEDGER))
equity = led["equity"]
cap = led["total_capital"]
unreal = equity - cap
eq_base = _equity_at(since)
ver = Path("VERSION").read_text().strip() if Path("VERSION").exists() else "?"
print(f"STATO TRADES — v{ver}{now:%Y-%m-%d %H:%M} UTC (finestra dal {since:%Y-%m-%d %H:%M})\n")
# ---- aggregato (ledger = verita') ----
print("LEDGER PORT06 (verita' aggregata)")
print(f" equity = {equity:.2f}")
print(f" capitale realizz.= {cap:.2f}")
print(f" unrealized aperti= {unreal:+.2f} (equity - capitale; pos. aperte mark-to-market)")
print(f" peak {led.get('peak'):.2f} maxDD {led.get('max_dd')}%")
if eq_base is not None:
print(f" Δ equity finestra= {equity - eq_base:+.2f} ({eq_base:.2f} -> {equity:.2f})")
# ---- POOL REALE ----
pool = _closes(POOL_DIR, since)
s_real = sum(r["eff"] for r in pool)
wins = sum(1 for r in pool if r["eff"] > 0)
print(f"\n=== POOL REALE (muove l'equity) — {len(pool)} chiusure, {wins}W/{len(pool)-wins}L ===")
for r in pool:
tag = "LOSS" if r["eff"] < 0 else "win "
print(f" {r['ts']} {tag} {r['name']:36} {str(r['reason']):11} "
f"real={r['real']} sim={r['sim']}")
print(f" Σ REALIZZATO POOL = {s_real:+.2f}")
op = _open_positions(POOL_DIR)
if op:
assets = set().union(*[set(p["assets"]) for p in op])
marks = _marks(assets)
_annotate_pnl(op, marks)
s_unreal = sum(p["unreal"] for p in op if p["unreal"] is not None)
n_marked = sum(1 for p in op if p["unreal"] is not None)
print(f"\n posizioni aperte ({len(op)}) — PnL non realizzato live (entry reale vs mark USDC):")
for p in sorted(op, key=lambda x: (x["unreal"] is None, x.get("unreal") or 0)):
d = {1: "LONG", -1: "SHORT"}.get(p["dir"], "?")
if p["unreal"] is not None:
pct = f"{p['unreal_pct']:+.2f}%" if not p["pair"] and "unreal_pct" in p else ""
mark = f"mark={p['mark']}" if not p["pair"] else f"mk {p['mark']}/{p.get('mark_b')}"
pnl = f"{p['unreal']:+.2f}"
else:
pct = mark = ""; pnl = "n/d (no mark)"
ent = f"entry={p['entry']}" if not p["pair"] else f"a={p.get('entry_a')} b={p.get('entry_b')}"
print(f" {p['name']:36} {d:5} h={p['held']}/{p['max_bars']} {ent} {mark} "
f"PnL {pnl} {pct}")
print(f" Σ UNREALIZED REALE = {s_unreal:+.2f} ({n_marked}/{len(op)} con mark live) "
f"<- verita' del conto (entry reale vs mark USDC)")
print(f" ledger unrealized = {unreal:+.2f} (feed sim-decisione testnet DISLOCATO); "
f"lo scarto {s_unreal - unreal:+.2f} e' la dislocazione del feed, non soldi.")
# ---- PAPER-STATS ----
stats = _closes(STATS_DIR, since)
s_stats = sum(r["eff"] for r in stats)
print(f"\n=== PAPER-STATS (solo statistica — NON tocca equity/conto) — {len(stats)} chiusure ===")
for r in stats:
print(f" {r['ts']} {r['name']:36} pnl={r['pnl']}")
print(f" Σ PAPER-STATS = {s_stats:+.2f} <- ignorare per l'equity")
# ---- riconciliazione ----
print("\nRICONCILIAZIONE")
print(f" realizzato POOL {s_real:+.2f} + Δunrealized aperti ≈ Δ equity finestra")
if eq_base is not None:
residuo = (equity - eq_base) - s_real
print(f" Δequity {equity - eq_base:+.2f} - realizzato {s_real:+.2f} = "
f"{residuo:+.2f} (= Δunrealized + ribilanci/timing)")
print(f" paper-stats {s_stats:+.2f} NON incluso (book in sola simulazione).")
if __name__ == "__main__":
main()
+32 -4
View File
@@ -80,6 +80,8 @@ class StrategyWorker:
self._tp_phantom_ts = 0 # dedup log TP_PHANTOM per barra (non persistito) self._tp_phantom_ts = 0 # dedup log TP_PHANTOM per barra (non persistito)
self._tp_phantom_notified = False # alert Telegram una tantum per processo self._tp_phantom_notified = False # alert Telegram una tantum per processo
self._tp_phantom_cache = (0, 0.0) # (bar_ts, monotonic): TTL del verdetto phantom self._tp_phantom_cache = (0, 0.0) # (bar_ts, monotonic): TTL del verdetto phantom
self._inverted_tp_ts = 0 # dedup log INVERTED_TP_SKIP per barra
self._inverted_tp_notified = False # alert Telegram una tantum per processo
self.worker_id = f"{strategy.name}__{asset}__{tf}" self.worker_id = f"{strategy.name}__{asset}__{tf}"
self.work_dir = data_dir / self.worker_id self.work_dir = data_dir / self.worker_id
@@ -236,7 +238,34 @@ class StrategyWorker:
enriched = {"worker": self.worker_id, **(data or {})} enriched = {"worker": self.worker_id, **(data or {})}
notify_event(event, enriched) notify_event(event, enriched)
def _open_position(self, signal: Signal, current_price: float): def _open_position(self, signal: Signal, current_price: float, current_ts: int = 0):
meta = signal.metadata or {}
tp = float(meta.get("tp", 0.0) or 0.0)
# GUARD TP-invertito (2026-06-16): un wick transitorio puo' far calcolare alla
# strategia un tp dal lato SBAGLIATO dell'entry (es. donchian: segnale su barra
# wickata, entry al prezzo recuperato gia' oltre il proprio tp) -> l'exit intrabar
# `bar_high>=tp` (long) / `bar_low<=tp` (short) scatta a bars_held=0 in PERDITA,
# con churn di fee e TP reduce-only respinti (16-06: 8 giri MR02_BTC 15m, sim
# -17.9 / reale -2.3). Verita' d'esecuzione, zero parametri: se il TP e' gia'
# sfondato all'ingresso il segnale e' malformato -> NON apriamo. (cerotto testnet:
# il fix vero e' mainnet, dove l'arbitraggio elimina i wick-print.)
if tp and ((signal.direction == 1 and tp <= current_price) or
(signal.direction == -1 and tp >= current_price)):
data = {
"direction": "long" if signal.direction == 1 else "short",
"price": round(current_price, 2),
"tp": round(tp, 2),
"note": "TP gia' sfondato all'ingresso (wick-print) -> entry soppressa",
}
if current_ts != self._inverted_tp_ts:
self._inverted_tp_ts = current_ts
self._log("INVERTED_TP_SKIP", data)
if not self._inverted_tp_notified:
self._inverted_tp_notified = True
self._notify("INVERTED_TP_SKIP", data)
return
notional = self.capital * self.position_size * self.leverage notional = self.capital * self.position_size * self.leverage
size = notional / current_price if current_price > 0 else 0 size = notional / current_price if current_price > 0 else 0
@@ -246,8 +275,7 @@ class StrategyWorker:
self.entry_time = datetime.now(timezone.utc).isoformat() self.entry_time = datetime.now(timezone.utc).isoformat()
self.bars_held = 0 self.bars_held = 0
meta = signal.metadata or {} self.tp = tp
self.tp = float(meta.get("tp", 0.0) or 0.0)
self.sl = float(meta.get("sl", 0.0) or 0.0) self.sl = float(meta.get("sl", 0.0) or 0.0)
self.max_bars = int(meta.get("max_bars", 0) or 0) self.max_bars = int(meta.get("max_bars", 0) or 0)
@@ -729,7 +757,7 @@ class StrategyWorker:
last_idx = len(df) - 1 last_idx = len(df) - 1
if last_signal.idx >= last_idx - 1: if last_signal.idx >= last_idx - 1:
self._open_position(last_signal, current_price) self._open_position(last_signal, current_price, current_ts)
self.last_bar_ts = current_ts self.last_bar_ts = current_ts
self._save_state() self._save_state()
+86
View File
@@ -229,6 +229,68 @@ def _check_stale_feed(asset: str, df: pd.DataFrame, alerted: set[str]):
"gap_pct": round(gap, 2), "prezzo": float(c[i])}) "gap_pct": round(gap, 2), "prezzo": float(c[i])})
# --- Gate feed CONGELATO (2026-06-15) -----------------------------------------
# Distinto da STALE_FEED (osservabilita') e FEED_BOOK_GAP (divergenza esecuzione):
# qui si AGISCE. Quando il feed di DECISIONE 1h di un asset e' CONGELATO (guasto
# testnet: ETH-PERPETUAL inverse fermo a 1661.95 per 36h+, prezzo MAI cambiato) gli
# sleeve CONCENTRATI che ne dipendono (single/ml/pairs) decidono entry/exit su un
# prezzo morto -> segnali spuri (z-score pairs estremi -3/-5, SH01 short a prezzo
# fermo) e perdite REALI (i fill avvengono al book vero ~1717, l'uscita sim al prezzo
# congelato: SH01_ETH ha realizzato -2.83 reali vs -0.09 sim su un solo close). Si
# SALTA il tick (entry E exit) finche' il feed non si sblocca: come un outage (i worker
# non valutano gli exit, protezione = disaster-SL on-book), auto-guarente.
#
# DISTINGUERE guasto da ILLIQUIDITA': SOL/LTC/ADA stampano molte barre flat (O=H=L=C,
# 50-95%) ma il prezzo SI muove ogni poche ore (run di close identiche CORTE, ~2-5). Il
# guasto e' il prezzo che non cambia MAI: run di close INVARIATE >= soglia (il freeze
# reale dura decine di barre). Un detector flat-bar ingenuo gaterebbe gli alt
# illiquidi-ma-VIVI -> si conta la run di close invariate, NON le barre flat.
#
# NB POST-FLAT: si gatea DURANTE il freeze (ultima barra completa ferma). La barra di
# RIPRESA e' non-flat -> la run si azzera -> il tick riprende SU di essa. NON e'
# l'entry-guard post-flat (BOCCIATA: la candela-gap e' l'overshoot che la fade fada con
# profitto, CLAUDE.md / 2026-06-05): quello salta la barra di ripresa, questo no.
#
# SOGLIA (misurata sul feed reale 2026-06-15): le due popolazioni sono ben separate ->
# MORTO ETH run 64 (1 val distinto/48h), BNB 64, DOGE 42 (feed pinnato a un valore)
# ILLIQUIDO LTC run 10, ADA 11, XRP 12, SOL 1 (5-31 val distinti/48h: SI muovono)
# A 24 (un giorno intero di prezzo immobile) il guasto multi-giorno e' preso con ampio
# margine senza gateare gli alt illiquidi-ma-VIVI (importante per PR_BTCLTC: BTC vivo +
# LTC solo illiquido NON deve sospendere il pair). ETH (run 64) gatea comunque subito.
_FREEZE_GATE_BARS = 24 # run di close INVARIATE sull'ultima barra completa = freeze
def _frozen_assets(raw1h: dict[str, pd.DataFrame], threshold: int) -> set[str]:
"""Asset col feed di decisione 1h CONGELATO: ultima barra completa flat (O=H=L=C)
E close invariata da >= `threshold` barre complete consecutive. 0 = gate disattivo."""
from src.live.bars import last_settled_idx
frozen: set[str] = set()
if threshold <= 0:
return frozen
for asset, df in raw1h.items():
o, h, l, c = (df[k].values for k in ("open", "high", "low", "close"))
i = len(c) + last_settled_idx(df["timestamp"].values)
if i < 1 or not (o[i] == h[i] == l[i] == c[i]):
continue # ultima barra completa non flat -> vivo
run = 1
while i - run >= 0 and c[i - run] == c[i]:
run += 1
if run >= threshold:
frozen.add(asset)
return frozen
def _feed_gated_sids(live_specs, frozen: set[str]) -> set[str]:
"""sid degli sleeve CONCENTRATI (single/ml/pairs) che dipendono da un asset col feed
congelato. I multi-asset (basket/rotation/tsmom/xsec, tutti PAPER) NON sono gateati:
diversificati su 8 asset, un singolo feed fermo non li compromette."""
if not frozen:
return set()
return {s.sid for s in live_specs
if s.kind in ("single", "ml", "pairs")
and any(a in frozen for a in _spec_assets_tf(s)[0])}
_GAP_BPS_DEFAULT = 150.0 # |close feed - mark book| oltre cui il feed non e' affidabile _GAP_BPS_DEFAULT = 150.0 # |close feed - mark book| oltre cui il feed non e' affidabile
@@ -431,6 +493,11 @@ def run(config_path: str = "portfolios.yml"):
# guard feed-vs-book (2026-06-12): soglia bps in overrides.feed_book_gap_bps (0 = off) # guard feed-vs-book (2026-06-12): soglia bps in overrides.feed_book_gap_bps (0 = off)
gap_bps = float(_ov.get("feed_book_gap_bps", _GAP_BPS_DEFAULT)) gap_bps = float(_ov.get("feed_book_gap_bps", _GAP_BPS_DEFAULT))
gap_alerted: set[str] = set() gap_alerted: set[str] = set()
# gate feed CONGELATO (2026-06-15): salta gli sleeve concentrati su un asset col feed
# fermo. Soglia (barre 1h di close invariata) in overrides.feed_freeze_gate_bars (0 = off).
freeze_gate_bars = int(_ov.get("feed_freeze_gate_bars", _FREEZE_GATE_BARS))
spec_by_id = {s.sid: s for s in live_specs}
frozen_gated: set[str] = set() # sleeve gateati ora (dedup alert + recovery)
# Osservabilita' outage (improvement-sweep 2026-06-06): il poll-loop intero e' in un # Osservabilita' outage (improvement-sweep 2026-06-06): il poll-loop intero e' in un
# try/except → durante un outage i worker NON valutano gli exit. Alert Telegram dopo # try/except → durante un outage i worker NON valutano gli exit. Alert Telegram dopo
# _OUTAGE_POLLS poll falliti/DEGRADATI consecutivi + notifica di ripresa con durata. # _OUTAGE_POLLS poll falliti/DEGRADATI consecutivi + notifica di ripresa con durata.
@@ -486,6 +553,23 @@ def run(config_path: str = "portfolios.yml"):
if exec_enabled and gap_bps > 0: if exec_enabled and gap_bps > 0:
_check_feed_book_gap(client, raw1h, exec_instr, gap_bps, gap_alerted) _check_feed_book_gap(client, raw1h, exec_instr, gap_bps, gap_alerted)
# gate feed CONGELATO: sleeve concentrati su un asset col feed fermo -> tick saltato
frozen = _frozen_assets(raw1h, freeze_gate_bars)
now_gated = _feed_gated_sids(live_specs, frozen)
if now_gated != frozen_gated:
from src.live.telegram_notifier import notify_event
for sid in sorted(now_gated - frozen_gated):
fa = sorted(set(_spec_assets_tf(spec_by_id[sid])[0]) & frozen)
print(f"[runner] FEED_FROZEN_GATE {sid}: asset congelati {fa} -> tick sospeso")
notify_event("FEED_FROZEN_GATE", {
"sleeve": sid, "asset_congelati": ", ".join(fa), "stato": "GATED",
"nota": "feed di decisione fermo: entry+exit sospesi finche' non si "
"sblocca (disaster-SL on-book protegge le posizioni reali)"})
for sid in sorted(frozen_gated - now_gated):
print(f"[runner] FEED_FROZEN_GATE {sid}: feed ripreso -> tick riattivato")
notify_event("FEED_FROZEN_GATE", {"sleeve": sid, "stato": "RIPRESO"})
frozen_gated = now_gated
# fetch DIRETTO dei timeframe sub-orari (15m...) per (asset, tf) # fetch DIRETTO dei timeframe sub-orari (15m...) per (asset, tf)
raw_sub: dict[tuple[str, str], pd.DataFrame] = {} raw_sub: dict[tuple[str, str], pd.DataFrame] = {}
for (asset, tf), days in subhourly_needs.items(): for (asset, tf), days in subhourly_needs.items():
@@ -553,6 +637,8 @@ def run(config_path: str = "portfolios.yml"):
"nota": "exit NON valutati per questo worker"}) "nota": "exit NON valutati per questo worker"})
for s in live_specs: for s in live_specs:
if s.sid in now_gated:
continue # feed di decisione congelato -> entry+exit sospesi
_tick_safe(s, workers[s.sid]) _tick_safe(s, workers[s.sid])
# PAPER: ticcati per statistica, MAI nel ledger del portafoglio # PAPER: ticcati per statistica, MAI nel ledger del portafoglio
for s in paper_specs: for s in paper_specs:
+70
View File
@@ -0,0 +1,70 @@
"""Gate feed CONGELATO (2026-06-15): salta entry+exit degli sleeve concentrati
(single/ml/pairs) il cui feed di DECISIONE 1h e' fermo (guasto, es. ETH-PERPETUAL
a 1661.95 per 36h+). Distingue il GUASTO (close mai cambiata per decine di barre)
dall'ILLIQUIDITA' (SOL/LTC/ADA: molte barre flat ma il prezzo si muove -> run corte).
Auto-guarente: la barra di ripresa e' non-flat -> il gate si rilascia da solo."""
import pandas as pd
from src.portfolio.runner import _frozen_assets, _feed_gated_sids
def _df(completed, forming):
"""Serie 1h: barre complete (flat = O=H=L=C; non-flat quando il prezzo cambia)
+ una candela in formazione (ultima riga, esclusa dal detector)."""
end_ms = int(pd.Timestamp.now(tz="UTC").floor("h").timestamp() * 1000)
vals = list(completed) + [forming]
n = len(vals)
rows = []
for j, v in enumerate(vals):
ts = end_ms - (n - 1 - j) * 3_600_000
rows.append({"timestamp": ts, "open": v, "high": v, "low": v, "close": v, "volume": 0.0})
for j in range(1, n): # bar NON-flat quando il prezzo si muove
if rows[j]["close"] != rows[j - 1]["close"]:
rows[j]["high"] = rows[j]["close"] * 1.001
return pd.DataFrame(rows)
def test_frozen_feed_detected():
raw = {"ETH": _df([1661.95] * 40, 1661.95)} # prezzo MAI cambiato (guasto)
assert _frozen_assets(raw, 12) == {"ETH"}
def test_live_feed_not_frozen():
raw = {"BTC": _df([65000 + i * 5 for i in range(40)], 65200.0)} # sempre in movimento
assert _frozen_assets(raw, 12) == set()
def test_illiquid_but_alive_not_frozen():
# ultima barra completa FLAT ma il prezzo si e' mosso da poche barre (run < soglia):
# alt illiquido (SOL/LTC/ADA), NON un guasto -> non gateato.
raw = {"LTC": _df([44.0] * 30 + [45.0] * 5, 45.0)}
assert _frozen_assets(raw, 12) == set()
def test_resume_releases_gate():
# rilascio solo su una barra COMPLETATA non-flat (la forming non basta -> coerente
# con la valutazione su barre settled, EXIT-16). NON e' l'entry-guard post-flat: la
# barra di ripresa NON viene saltata, il tick riprende su di essa.
raw = {"ETH": _df([1661.95] * 40 + [1700.0], 1700.0)}
assert _frozen_assets(raw, 12) == set()
# finche' la ripresa e' solo nella candela in formazione, resta congelato
assert _frozen_assets({"ETH": _df([1661.95] * 40, 1700.0)}, 12) == {"ETH"}
def test_threshold_zero_disables():
raw = {"ETH": _df([1661.95] * 40, 1661.95)}
assert _frozen_assets(raw, 0) == set()
def test_gated_scope_matches_eth_legs():
"""Sui veri sleeve PORT06: con ETH congelato si gatano TUTTE e sole le gambe ETH
(3 fade ETH + SH_ETH + 5 pairs con gamba ETH). BTC-only, BTC/LTC e i multi-asset
(anche ROT02 che ha ETH nell'universo) restano attivi."""
from scripts.portfolios._defs import FADE, PAIRS, SHAPE, HONEST
specs = FADE + PAIRS + SHAPE + HONEST
g = _feed_gated_sids(specs, {"ETH"})
assert {"MR01_ETH", "MR02_ETH", "MR07_ETH", "SH_ETH",
"PR_ETHBTC", "PR_ETHBTC_15M", "PR_LTCETH", "PR_ADAETH", "PR_ETHSOL"} <= g
assert g.isdisjoint({"MR01_BTC", "MR02_BTC", "MR07_BTC", "SH_BTC",
"DIP01_BTC", "PR_BTCLTC"})
assert g.isdisjoint({"TR01_basket", "ROT02_rot"}) # multi-asset mai gateati
assert _feed_gated_sids(specs, set()) == set() # nessun congelato -> nessun gate
+76
View File
@@ -0,0 +1,76 @@
"""INVERTED_TP_SKIP (2026-06-16): un wick transitorio puo' far calcolare alla strategia
un tp dal lato SBAGLIATO dell'entry (es. donchian: segnale su barra wickata, entry al
prezzo recuperato gia' oltre il proprio tp). L'exit intrabar `bar_high>=tp` (long) /
`bar_low<=tp` (short) scatterebbe a bars_held=0 in PERDITA, con churn di fee e TP
reduce-only respinti. Verita' d'esecuzione, zero parametri: se il TP e' gia' sfondato
all'ingresso il segnale e' malformato -> NON si apre. TP dal lato giusto (o assente)
-> comportamento storico."""
from src.live.strategy_worker import StrategyWorker
from src.live.strategy_loader import load_strategy
from src.strategies.base import Signal
def _worker(tmp):
w = StrategyWorker(strategy=load_strategy("MR02_donchian_fade"), asset="BTC", tf="15m",
capital=1000.0, data_dir=tmp)
w._notify = lambda *a, **k: None
return w
def test_long_inverted_tp_skipped(tmp_path):
# long con tp SOTTO l'entry (caso reale 16-06: entry 66780, tp 66189) -> no entry
w = _worker(tmp_path)
sig = Signal(idx=119, direction=1, entry_price=66780.0,
metadata={"tp": 66189.25, "sl": 64218.21, "max_bars": 24})
w._open_position(sig, 66780.0, current_ts=1)
assert not w.in_position
assert w.tp == 0.0
def test_short_inverted_tp_skipped(tmp_path):
# short con tp SOPRA l'entry -> il TP intrabar `bar_low<=tp` scatterebbe subito -> no entry
w = _worker(tmp_path)
sig = Signal(idx=119, direction=-1, entry_price=100.0,
metadata={"tp": 105.0, "sl": 110.0, "max_bars": 24})
w._open_position(sig, 100.0, current_ts=1)
assert not w.in_position
def test_long_valid_tp_opens(tmp_path):
# controllo positivo: long con tp SOPRA l'entry -> apre normalmente
w = _worker(tmp_path)
sig = Signal(idx=119, direction=1, entry_price=100.0,
metadata={"tp": 103.0, "sl": 98.0, "max_bars": 24})
w._open_position(sig, 100.0, current_ts=1)
assert w.in_position
assert w.direction == 1
assert w.tp == 103.0
def test_short_valid_tp_opens(tmp_path):
# controllo positivo: short con tp SOTTO l'entry -> apre normalmente
w = _worker(tmp_path)
sig = Signal(idx=119, direction=-1, entry_price=100.0,
metadata={"tp": 97.0, "sl": 102.0, "max_bars": 24})
w._open_position(sig, 100.0, current_ts=1)
assert w.in_position
assert w.direction == -1
def test_no_tp_opens(tmp_path):
# segnale senza tp (strategie a orizzonte puro, es. SH01) -> il guard non si applica
w = _worker(tmp_path)
sig = Signal(idx=119, direction=1, entry_price=100.0, metadata={"max_bars": 12})
w._open_position(sig, 100.0, current_ts=1)
assert w.in_position
def test_inverted_skip_deduped_per_bar(tmp_path):
# stesso bar_ts -> un solo log INVERTED_TP_SKIP; non blocca un bar successivo valido
w = _worker(tmp_path)
sig = Signal(idx=119, direction=1, entry_price=66780.0,
metadata={"tp": 66189.25, "max_bars": 24})
w._open_position(sig, 66780.0, current_ts=1)
w._open_position(sig, 66780.0, current_ts=1)
assert not w.in_position
assert w._inverted_tp_ts == 1