Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b60289cbd | |||
| e328836614 | |||
| a0842a61cc | |||
| 3a66907f43 | |||
| aee53161c3 | |||
| 7b3e554424 | |||
| b576ee66ac | |||
| ef797f1ff0 | |||
| 4c184d5cbc |
@@ -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/
|
||||||
|
|||||||
@@ -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 = equity−total_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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:00–09: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 (66700–66834):
|
||||||
|
|
||||||
|
```
|
||||||
|
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.7–2.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.
|
||||||
@@ -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 ≥10–20 trade: leggere il verdetto → scala / aspetta / stop
|
- [ ] dopo ≥10–20 trade: leggere il verdetto → scala / aspetta / stop
|
||||||
|
|||||||
+41
-55
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user