107 Commits

Author SHA1 Message Date
Adriano Dal Pastro 722837e599 Merge feat/mainnet-fallback: OHLCV da Deribit mainnet quando testnet e' giu'
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 16:58:14 +00:00
Adriano Dal Pastro 8e72d7ad02 feat(live): fallback OHLCV a Deribit MAINNET quando Cerbero/testnet e' giu'
Deribit testnet (test.deribit.com) va giu' periodicamente (502) e Cerbero lo rilancia ->
il runner si bloccava senza dati. Aggiunto CerberoClient.get_historical_mainnet (Deribit MAINNET
public, NO-AUTH, paginato sotto il cap ~5000 candele/chiamata) e fallback nel runner: try Cerbero
-> on fail/empty usa mainnet. Prezzi REALI (meglio del testnet farlocco per il paper). Verificato
durante l'outage: tutti gli 8 strumenti (BTC/ETH + alt _USDC) coperti su mainnet. Log una-tantum
all'attivazione/disattivazione del fallback.

Caveat: testnet e mainnet hanno prezzi diversi (~9%) -> al primo switch le posizioni aperte su
prezzi testnet vanno resettate (transizione pulita).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 16:58:14 +00:00
Adriano Dal Pastro 87b420f459 docs: monitor loss-guard nel report orario + effetto misurato (stop-loss -67%)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 16:17:05 +00:00
Adriano Dal Pastro 6605008f49 feat(report): monitor loss-guard Hurst nel report orario Telegram
Traccia lo stop-rate delle fade (MR01/MR02/MR07) PRIMA/DOPO l'attivazione del loss-guard
(2026-06-02 14:34 UTC). Verdetto automatico quando il campione DOPO >= 30 trade. Conferma gia'
visibile: stop-rate live PRIMA 42% (n=36) == backtest 42.1%. Gira host-side (cron), no rebuild.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:54:37 +00:00
Adriano Dal Pastro b5f644e52d docs: aggiorna CLAUDE.md e README (loss-guard Hurst, versione/deploy, fix SH01, regime infra)
- Loss-guard Hurst sulle fade (hurst_max=0.55, dimezza DD PORT06) — attivo live
- Sistema versione + deploy.sh (versione nei msg Telegram, +1 ad ogni deploy)
- Fix wiring SH01 (StrategyWorker reale, non MLWorkerWrapper squeeze)
- Fix StrategyWorker: exit orizzonte, is_win netto, min_tp_frac
- Infrastruttura regime (regime_lab/regime_fetcher, data/regime/)
- Esiti ricerca: ARGO/GEX no-go, frattali x regime (FR01 robusto ma non deployato)
- Nota deploy = up -d --build (non restart)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:39:59 +00:00
Adriano Dal Pastro 7da9dc36e4 Merge feat/version-tag: versione nei msg Telegram (+1 ad ogni deploy)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:34:18 +00:00
Adriano Dal Pastro 4770a8368f feat(deploy): tag di versione nei msg Telegram, +1 ad ogni deploy
File VERSION (semver, cotto nell'immagine) letto da src/version.py. Compare nelle notifiche trade
(telegram_notifier) e nel report orario -> sai quale codice ha generato quale msg.
scripts/bump_version.py incrementa la patch; scripts/deploy.sh = bump+commit+rebuild (versione
aumenta ad OGNI deploy). v1.0.0 = primo release versionato (include hurst loss-guard).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:34:18 +00:00
Adriano Dal Pastro d624fe74c9 Merge feat/fade-lossguard: loss-guard Hurst sulle fade (attivo live)
Sistema anti-perdite: skip fade in regime persistente (hurst>=0.55). Dimezza il DD del PORT06
(FULL 4.10%->2.39%, Sharpe 6.62->6.76, OOS 8.89->9.15). Solo l'Hurst supera il gate (ADX/vol-expansion
falliscono). Attivo live sulle 6 fade. Diagnostici + workflow 11 agenti + diario.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:17:07 +00:00
Adriano Dal Pastro 29c2ea488f feat(live): attiva loss-guard Hurst sulle fade PORT06 (hurst_max=0.55)
Attivazione live: hurst_max=0.55 nei params delle 6 fade (MR01/MR02/MR07 x BTC/ETH) in _defs.py.
hurst_skip_mask step=6 (5.9s/fade su 10560 barre live, ~35s per le 6 -> OK su poll 60s, e coincide
con la cache di validazione). Calcolato dalle SOLE close -> nessun feed esterno, il worker lo computa
inline.

NB live/backtest: il filtro agisce solo sul path LIVE (spec.params); il backtest canonico
(build_everything/regression-lock, via risk_management) NON e' filtrato -> il live FARA' meglio del
backtest sul DD (FULL 4.10%->2.39% atteso). Divergenza intenzionale (miglioramento). Backtest-parity
aggiornabile in seguito. Suite: 54 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:17:07 +00:00
Adriano Dal Pastro ac6f3766b0 feat(fade): loss-guard Hurst (skip regime persistente) — dimezza il DD del PORT06
GOAL: limitare le perdite delle fade in regime sfavorevole. Diagnosi (3022 trade): le perdite/stop
si concentrano nel regime PERSISTENTE (hurst>0.55: stop-rate 43% vs 21% anti-persistente), NON in
bassa vol (low-vol e' net positivo). Ricerca web + workflow 11 agenti: l'UNICO meccanismo che riduce
DD senza uccidere l'edge e' il filtro Hurst (ADX, vol-expansion, time-stop, ER, vol-target falliscono
il gate FR01). Test esterni ADX/vol-expansion NON si replicano su queste fade crypto.

TEST DECISIVO PORT06 (gate FR01) SUPERATO: Hurst-skip h<0.55 sulle 6 fade ->
FULL Sharpe 6.62->6.76, FULL DD 4.10%->2.39% (quasi dimezzato), OOS Sharpe 8.89->9.15.
Migliora il portafoglio (a differenza di FR01 che diluiva).

Implementazione: hurst_skip_mask in fade_base.py (rolling-Hurst causale dalle SOLE close -> nessun
feed dati esterno, deployabile inline dal worker) + param hurst_max (default None=off) in
MR01/MR02/MR07. Test: test_hurst_lossguard.py. Default off -> zero impatto su backtest/parita'/live
finche' non attivato.

FIX collaterale: regime_fetcher/regime_lab scrivevano DVOL/funding/feature in data/raw/ ->
inquinavano la discovery asset del backtest (rompeva il regression-lock PORT06). Spostati in
data/regime/ (gitignored). Suite: 54 passed (lock incluso).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:08:15 +00:00
Adriano Dal Pastro 4d9f2af0c0 research(fade-lossguard): diagnostici perdite fade per regime + workflow anti-perdite
Diagnosi (3022 trade fade 2021+): perdite/stop concentrati in regime PERSISTENTE (hurst>0.55,
SL-rate 43% vs 21% anti-persistente), NON in bassa vol (low-vol e' net positivo). Ricerca web
conferma: filtro Hurst<0.45 / ADX<20 / vol-expansion ratio>1.5 (prevenne 72% perdite maggiori).
Workflow 11 agenti testa i meccanismi sulle fade reali.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:35:44 +00:00
Adriano Dal Pastro 26de94e57d Merge feat/fractal-argo-search: ricerca 100 agenti frattali x ARGO (record)
Esito: 15 strategie frattale x regime robuste e causali, ma NESSUNA migliora PORT06
(FR01 lo diluisce: OOS Sharpe 8.89->8.72). Prior ARGO 'VRP>0=range' smentito, edge su
VRP<0 + DVOL bassa. RECORD DI RICERCA: non deployato, infrastruttura regime riusabile.

Artefatti: regime_fetcher/regime_lab (dati DVOL+funding Deribit mainnet, feature regime+frattali
causali), fractal_argo_workflow.js (171 agenti), FR01_hurst_calm_fade.py (NON in MODULE_MAP/yml),
analisi per-anno + test MASTER, diario 2026-06-02.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 11:49:28 +00:00
Adriano Dal Pastro 8fd8f64368 test(research): FR01 NON migliora PORT06 (verdetto decisivo)
Contributo marginale al MASTER (equal-weight): OOS Sharpe 8.89->8.72 (diluisce),
OOS ret +175%->+156%. Corr FR01 vs MASTER +0.18/+0.23. Sharpe daily-return standalone
~1.85 (non il 3.73 per-trade) -> troppo basso per un PORT06 a 8.89. Ridondanza robusta.
ESITO: search a 100 agenti rigoroso, ma nessuna strategia migliora PORT06. Deploy abbandonato,
resta record di ricerca sul branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 11:48:02 +00:00
Adriano Dal Pastro 7005763517 feat(research): FR01 Hurst-Calm Fade + analisi per-anno/mercato (ricerca 100 agenti)
Esito ricerca frattali x regime ARGO (171 agenti):
- FR01_hurst_calm_fade.py: vincitore = fade gateato da hurst<0.55 (anti-persistente) +
  dvol_pct<0.4 (DVOL bassa). OOS Sharpe 3.73 BTC, 6/6 anni positivi su BTC+ETH, corr bassa
  coi fade esistenti (MR01 +0.17/MR02 +0.08/MR07 -0.03) -> diversificatore non ridondante.
- fractal_argo_peryear.py: analisi per-anno/regime-mercato dei top candidati.
- diario 2026-06-02: verdetto completo. Finding chiave: prior ARGO 'VRP>0=range=fade' SMENTITO,
  l'edge robusto e' su VRP<0 + DVOL bassa.

Diversificatori, NON spodestano PORT06 (OOS Sharpe 8.19). Branch di ricerca.
Deploy bloccato da: verifica corr sul MASTER intero + wiring DVOL live nel runner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 07:22:10 +00:00
Adriano Dal Pastro 5f229cd66e fix(regime_lab): vrp annualizzato per timeframe + report() safe su 0 entries
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 00:08:33 +00:00
Adriano Dal Pastro 7169614506 feat(research): substrato ricerca frattali x regime ARGO
- regime_fetcher.py: fetch DVOL (2021+) + funding (2019+) BTC/ETH da Deribit mainnet public
- regime_lab.py: API condivisa, allineamento regime<->prezzo CAUSALE no-look-ahead,
  feature regime (dvol_pct/vrp/funding_z/dvol_chg) + frattali (hurst/higuchi/vratio/williams),
  cache feature precalcolate, report()=netto-fee OOS via explore_lab
- fractal_argo_workflow.js: workflow ~100 agenti (84 griglia + 8 wildcard + verifica + sintesi)

Branch di ricerca: nessun impatto su main/live.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 00:05:14 +00:00
Adriano Dal Pastro 69619df4c7 chore: gitignore stato locale tooling (.claude/, .omc/)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 14:35:19 +00:00
Adriano Dal Pastro 056d18a3fd Merge feat/hourly-telegram-report: report orario PORT06 su Telegram
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 14:02:37 +00:00
Adriano Dal Pastro 99efc7a042 feat(report): report orario PORT06 su Telegram
Script standalone che legge lo stato persistito del paper trader a portafoglio
(data/portfolio_paper/*/ + data/portfolios/PORT06/status.json) e invia su
Telegram: (1) trade CHIUSI positivi/negativi NETTO fee con breakdown per motivo
e PnL; (2) tabella trade IN CORSO (posizioni aperte, single e pairs a 2 gambe);
(3) PnL realizzato totale + equity mark-to-market (con non realizzato).

Carica .env da solo (cron non eredita l'env del container), legge file
world-readable scritti dal container, non tocca lo stato del trader. Riusa
send_telegram di telegram_notifier.

Schedulazione: crontab host orario (minuto 0):
  0 * * * * uv run --project /opt/docker/PythagorasGoal python \
            scripts/portfolios/hourly_report.py
(uv run --project + path assoluto: nessun cd necessario.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 14:02:37 +00:00
Adriano Dal Pastro e44a310a3b Merge fix/sh01-live-wiring: SH01 esegue shape-ML reale (non squeeze)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 12:24:14 +00:00
Adriano Dal Pastro 83c4e7a334 fix(live): SH01 esegue shape-ML reale, non il wrapper squeeze scartato
Bug di wiring: runner.py avvolgeva lo sleeve SH01 nel MLWorkerWrapper legacy
di multi_runner, che usa SignalEngine (famiglia squeeze ML01 SCARTATA), apre
con Signal nudo ed esce a hold_bars=3 con tick() propria. Risultato: lo sleeve
"SH01" del portafoglio live NON eseguiva SH01_shape_ml (generate_signals mai
chiamata) e il fix horizon-exit era in un ramo morto -> SH01 continuava a
chiudere a hold_limit/3.

Fix: SH01 (kind="ml") gira come StrategyWorker normale. SH01_shape_ml.
generate_signals fa il walk-forward internamente ad ogni tick ed emette
metadata.max_bars=H=12 -> exit via StrategyWorker.tick (orizzonte H, fix
applicato). Rimosso l'import/uso di MLWorkerWrapper e il blocco train esterno.

ml_wf_entries ha train_min=4000 (>=4000 barre 1h per produrre segnali):
aggiunto _ML_LOOKBACK_DAYS=365 cosi gli asset di sleeve ml fetchano >=365g
(~8760 barre), senza dipendere dal fetch 440g di TSM01/ROT02. generate_signals
su 365g: 0,17-0,24s (logit) -> trascurabile sul poll 60s.

Test: test_build_ml_sh01_is_plain_strategyworker (StrategyWorker + strategy
SH01_shape_ml + niente engine squeeze). Suite: 51 passed.

Stato live: SH01 BTC/ETH flat -> contatori resettati (capitale preservato),
trade squeeze archiviati. Rebuild+recreate: 14 worker RESUME puliti, healthy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 12:24:13 +00:00
Adriano Dal Pastro 56565f6f73 Merge fix/win-net-and-tp-min-edge: win netto-fee + filtro TP edge-minimo
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 11:07:30 +00:00
Adriano Dal Pastro ab4f706057 fix(live): win netto-fee + filtro TP edge-minimo sulle fade
Due correzioni emerse da close live con win=True ma pnl<0.

1) Metrica win lorda -> netta. _close_position contava is_win=trade_return>0
   (lordo), gonfiando l'accuracy: un take-profit colpito con mossa < fee RT
   risultava "win" pur perdendo. 51 close live: 39 win (76,5%) -> 13 falsi win
   -> accuracy netta reale 52,9%. Fix: is_win = net > 0. Capitale/PnL erano
   già corretti (netti). Contatori persistiti riconciliati a parte (MR01/DIP01
   BTC 7->1).

2) Filtro edge-minimo min_tp_frac. I 13 falsi win erano tutti MR01/DIP01 BTC in
   regime piatto: TP (la media) entro il costo round-trip -> perdenti garantiti.
   Aggiunto param min_tp_frac (default 0.0=off) a tutte e 4 le fade (MR01 banda,
   MR02 midpoint, MR07 ATR, DIP01): salta i segnali col TP entro la soglia.
   Non si "allarga" il TP (rischierebbe di perdere di piu'): si evita la trade.
   Cablato live a 0.0015 (1,5x fee) in _defs.py.

Validazione backtest BTC+ETH 1h: neutro su tutte le fade (0-1 trade rimossi,
pnl invariato o +leggero su DIP01). I micro-scalp sotto-fee non esistono nello
storico -> artefatto del regime attuale. Filtro puro-upside.

Test: test_win_net_of_fees.py, test_min_tp_frac.py (monotonia + gap > soglia +
default-off invariato). Suite: 50 passed.

NB deploy: il sorgente e' COPY nell'immagine, non montato -> serve
`docker compose up -d --build`, non un semplice restart (vale anche per il fix
SH01 horizon-exit, andato live solo con questo rebuild). Volume ./data persiste,
14 worker in RESUME puliti.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 11:07:30 +00:00
Adriano Dal Pastro 05ebd6754b Merge fix/sh01-horizon-exit: exit a orizzonte SH01 (H=12, non hold_bars=3)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 09:06:49 +00:00
Adriano Dal Pastro b5d277478c docs(diary): bugfix exit a orizzonte SH01 (3 barre -> H=12)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 09:06:49 +00:00
Adriano Dal Pastro 53e0965c4b fix(live): exit a orizzonte per strategie senza TP/SL (SH01)
Lo StrategyWorker onorava max_bars (orizzonte del Signal) solo nel ramo
`if self.tp and self.sl`. SH01 (shape-ML, H=12) non porta TP/SL, quindi
cadeva sul fallback legacy hold_bars=3 e chiudeva a 3 barre invece delle
12 validate: l'edge (asimmetria sull'orizzonte, non frequenza) non aveva
tempo di realizzarsi -> accuratezza live falsata (33%), tutti exit
"hold_limit" a bars_held=3.

Aggiunto un ramo `elif self.max_bars` che esce a "time_limit" quando
bars_held>=max_bars, prima del fallback hold_bars. Tocca solo le
strategie horizon-only (SH01); le fade con tp+sl+max_bars sono invariate.

Test: tests/portfolio/test_horizon_exit.py (resta in posizione a 3 barre
con max_bars=12; esce a 12 con reason time_limit). Suite: 43 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 09:05:53 +00:00
Adriano Dal Pastro aaf0221957 docs(diary): studio exit fade (scalping / TP dinamico / TP-ATR)
Tre alternative di uscita misurate su MR02 (fee-aware) vs baseline TP=centro canale:
- 15m "scalp": piu' PnL lordo ma fragile a fee/slippage (DD esplode a 0.20%).
- trailing/TP dinamico: win-rate 48%->36%, azzera l'edge (let-it-run = continuazione).
- TP-ATR: TP stretto m=0.5 -> win 77% ma edge ~0 (trappola scalping); nessun
  multiplo ATR batte il centro su avg/trade e Sharpe (FULL+OOS, BTC/ETH).
Verdetto: TP=centro canale e' ottimale (target adattivo alla struttura, gia'
scalato alla vol). Design blindato. Nota: win-rate alto != edge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 13:52:44 +00:00
Adriano Dal Pastro 1177da33ea docs(diary): stato trade live PORT06 (snapshot verificato 2026-05-31)
~43h di paper trading: 10 trade chiusi (9W/1L, +EUR0.40 realizzato), 3 aperti,
equity MtM EUR1000.36, max DD 0.40%. Check: 0 anomalie (net=gross-fee, win
coerente, fee incluse), uscite pairs conformi a z_exit=0.75, riconciliazione
ledger spiegata (timing rebal). Campione a livello rumore ma sistema sano e
fedele al backtest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 13:23:35 +00:00
Adriano Dal Pastro 03e8938a18 docs(diary): record anti-ripetizione strategie scartate (opzioni + TA classiche)
Conclusioni-only (codice testato e scartato, non conservato): perche' non
funzionano, cosi' da non ri-testarle.
- Opzioni: overlay PORT06 / OH01 direzionale / OH02 credit-spread / V5 debit
  spread (Casario) / V4 box. Nessun edge nuovo: trend e MR gia' catturati 50-100x
  meglio dai perp; VRP contro chi compra, code grasse contro chi vende.
- TA: SMA pullback / ORB / weakness rectangle -- tutte continuazione, negative
  anche a fee 0. Flip al fade: 2/3 segno positivo ma = MR01/MR02 inferiori, WR
  rumore. Riconferma: solo mean-reversion paga.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 07:17:10 +00:00
Adriano Dal Pastro c13773e762 test(portfolio): aggiorna regression-lock PORT06 dopo recupero dati BNB/DOGE/XRP
Il recupero dello storico BNB/DOGE/XRP (29 mag) ha ampliato la copertura del
backtest -> metriche migliorate, non una regressione:
  Sharpe FULL 6.07 -> 6.47, Sharpe OOS 8.19 -> 8.82, DD FULL 4.9% -> 4.1%.
Aggiornati i tre valori attesi (tolleranze invariate) + commento col motivo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 07:10:40 +00:00
Adriano Dal Pastro 5a219ca8e5 feat(analysis): proiezione 3 anni capitale PORT06 (live 2x, esclude 2024)
Script di proiezione: partendo da 1000 EUR con capitale che compone e
puntata che cresce, stima guadagno giornaliero e traiettoria a 3 anni.
Riscalata sul sizing LIVE (pos 0.15 x 2x) vs backtest 3x; ROT02/TSM01
usano gross fisso (non riscalano). Esclude il 2024 (anno eccezionale)
e include haircut ~50% per lo scenario sobrio prudente.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 19:29:07 +00:00
Adriano Dal Pastro 2a11728384 feat(docker): deploy PORT06 portfolio runner via docker compose
Wire the Docker image and compose service for the capital-pool portfolio
paper trader (src.portfolio.runner) instead of the single-leg multi_runner:

- Dockerfile: copy full scripts/ (runner imports scripts.analysis.* and
  scripts.portfolios._defs via sleeves.py) and portfolios.yml.
- docker-compose.yml: service "portfolio" / container pythagoras-portfolio,
  command override to src.portfolio.runner, mount portfolios.yml, healthcheck
  on data/portfolios/ status.json.
- .gitignore: ignore portfolio runtime state (data/portfolio_paper, data/portfolios).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 07:30:33 +00:00
Adriano 7b2e0049eb test(live): demo numerica exit intrabar fade -- worker replay ~= backtest
MR01 BTC 1h, 4000 barre, no filtro trend: backtest build_trades +3.5%/73 trade vs
worker replay intrabar +4.5%/78 trade -> gap +1.0pt (allineato). Conferma che il fix
exit intrabar chiude il gap live-vs-backtest delle fade (residuo = bar-timing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 18:43:38 +02:00
Adriano fb65df7861 merge: fix exit intrabar StrategyWorker (fade/DIP01 allineate al backtest)
Lo StrategyWorker esce su TP/SL toccati intrabar (high/low della barra, al livello,
SL prioritario) come il backtest, chiudendo il gap live-vs-backtest delle fade/DIP01.
+4 test. 39 test totali passano.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 18:02:14 +02:00
Adriano 49039ac286 fix(live): StrategyWorker esce intrabar su TP/SL (high/low, al livello) come il backtest
Chiude il gap live-vs-backtest delle fade/DIP01: prima il worker controllava solo il
close, ora controlla high/low della barra ed esce AL LIVELLO tp/sl (SL prioritario),
identico alla semantica intrabar del backtest. +4 test. Pairs/rotation/tsmom invariati.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 18:00:12 +02:00
Adriano 9e91ad6335 merge: fase 2 portafogli - validazione runner + worker live honest/TSM01
A) PortfolioRunner certificato (pool/ribilancio/ledger == backtest).
B) worker live dedicati: DIP01 (Strategy), BasketTrendWorker (TR01), RotationWorker (ROT02),
   TsmomWorker (TSM01) + integrazione runner (resample 1h->4h/1d). PORT06 gira live completo.
Validati vs reference (TSM01 esatto, ROT02 canonico, TR01 stesso ordine). 35 test passano.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 17:52:47 +02:00
Adriano 924ed8eeff docs: fase 2 completata - tutti gli sleeve PORT06 girano live (worker dedicati + validazione) 2026-05-29 17:50:43 +02:00
Adriano fe8c272460 test(portfolio): valida worker honest/TSM01 vs backtest reference
TSM01 esatto (+98%==+98%); ROT02 riproduce il +1303% canonico (reference normalizzata
su finestra piu' corta = +984%); TR01 stesso ordine (+465 vs +591%, differenza di
convenzione capitale-unico-live vs media-equity-report, non un bug). Worker fedeli.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 17:50:08 +02:00
Adriano a7ada9f36c feat(portfolio): integra worker honest/TSM01 nel runner (PORT06 live completo)
build_worker_for gestisce basket/rotation/tsmom + DIP01 via StrategyWorker; run()
fetcha 1h e resampla a 4h/1d, lookback dimensionato sui daily (TSM01 252g); tick
multi-asset per kind. _defs marca TR01/ROT02/TSM01 col kind+universo. Niente piu'
sleeve saltati in PORT06.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 17:45:39 +02:00
Adriano 1e60835612 feat(live): TsmomWorker (TSM01) consenso TSMOM multi-orizzonte risk-gated 2026-05-29 17:42:44 +02:00
Adriano a40315563e feat(live): RotationWorker (ROT02) dual-momentum top-k risk-gated 2026-05-29 17:41:13 +02:00
Adriano e7e8041dae feat(live): BasketTrendWorker (TR01) EMA-cross long/flat multi-asset
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 17:39:11 +02:00
Adriano ce601c4507 feat(live): DIP01 dip-buy come Strategy single-asset (worker via StrategyWorker)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 17:36:32 +02:00
Adriano dc63399cc7 docs(portfolio): piano fase 2-B worker honest/TSM01 dedicati (6 task) 2026-05-29 17:35:10 +02:00
Adriano e374cca103 test(portfolio): valida runner pool+ribilancio+ledger == backtest (identico)
Certifica il livello aggiunto dal PortfolioRunner (capitale pool, ribilancio
giornaliero, ledger aggregato): replay deterministico == port_returns del backtest
(errore 4.4e-08, floating-point). Fedeltà per-worker: pairs esatta, fade approssimata
(exit close live vs intrabar backtest = gap noto dello StrategyWorker), shape a tempo ok.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 17:28:03 +02:00
Adriano 2749553577 merge origin/main (discovery strumenti downloader) in shape+portfolios 2026-05-29 17:22:15 +02:00
Adriano 0bb14d1c6e merge: shape patterns (SH01) + cartella portfolios (PORT01-06, runner pool)
Ricerca pattern-forma (4/5 famiglie rumore, SH01 Shape-ML edge/diversificatore) +
cartella portfolios/ completa (portafogli pool, backtest+live, Cerbero v2, default PORT06).
21 test passano. Live v1 = fade+pairs+shape; honest/TSM01 backtest-only (fase 2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 17:21:51 +02:00
Adriano 04f64c8f89 feat(portfolio): compare_all confronto PORT01-06 in un processo 2026-05-29 16:32:57 +02:00
Adriano 0f582db265 fix(portfolio): runner data_dir dedicata, no resize posizioni aperte, poll da config, +test cap/cluster_rp
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 16:22:33 +02:00
Adriano d02bc10ab5 docs(portfolio): documenta cartella portfolios, comandi, scope live e default PORT06
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 16:13:49 +02:00
Adriano a5547fb3d2 feat(portfolio): PortfolioRunner live (data v2, tick, ribilancio giornaliero, ledger)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 16:05:07 +02:00
Adriano 2b3d3e3ff8 feat(portfolio): build_worker_for (worker esecutori con capitale da alloc pool)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 16:02:35 +02:00
Adriano 169819fe31 feat(portfolio): portfolios.yml + load_active_portfolio (override operativi) 2026-05-29 16:00:54 +02:00
Adriano 7a4bdb74f0 feat(portfolio): PortfolioLedger (alloc, equity/DD, persistenza+resume)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 15:59:57 +02:00
Adriano eaf4800b6d feat(portfolio): definizioni PORT01-06 + report run() (default PORT06)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 15:58:33 +02:00
Adriano 3f6b0ccf91 feat(portfolio): SleeveSpec/Portfolio/backtest con parità verso report_families
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 15:55:29 +02:00
Adriano 9ff469cb8e feat(portfolio): builder unificato equity-per-sleeve (parità con report_families)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 15:51:38 +02:00
Adriano d99c9895bb feat(portfolio): schemi di peso (equal/manual/cap/inverse_vol/cluster_rp) 2026-05-29 15:49:27 +02:00
Adriano ea04dcd9d1 feat(portfolio): metodi Cerbero v2 (get_historical_v2, get_instruments, get_ticker_batch)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 15:47:10 +02:00
Adriano 753d786bb5 docs(portfolios): piano di implementazione TDD (10 task)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 15:29:41 +02:00
Adriano 602c46e5bf docs(portfolios): design spec cartella portfolios (brainstorming)
Portafogli come oggetti di prima classe (pool condiviso, backtest+live unificati,
ribilancio periodico, 4 schemi pesi, data layer Cerbero v2). Default PORT06
(master+shape, cap pairs 33%, leva 2x). Include analisi accorpamento sleeve
(cluster per asset/regime, pairs=47% rischio) e fuori-scope (ledger unico,
Hyperliquid alt, cointegration).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 15:24:17 +02:00
Adriano 9e1be75444 analysis(portfolio): clustering sleeve per correlazione + contributo rischio
I cluster naturali sono per ASSET/REGIME, non per famiglia (BTC-reversion,
ETH-reversion, trend TR01+TSM01, shape, rotation ROT02). Ridondanza lieve
(max corr 0.43). PAIRS = 47% del rischio a equal-weight -> conferma cap 30-35%.
Equal-weight batte inverse-vol/risk-parity in OOS calmo (pairs corrono liberi).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 13:09:27 +02:00
Adriano e002968914 report(shape): integra SH01 (sleeve SHAPE) nel report per-anno e nell'integrazione MASTER
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 12:43:19 +02:00
Adriano 2596687679 feat(shape): SH01 Shape-ML validato come diversificatore + doc
Validazione dura del solo edge sopravvissuto alla ricerca shape (ML walk-forward
LogisticRegression sulle feature di forma). SH01 config W24 H12 th0.58:
- BTC robusto ovunque (expanding +219%/OOS+42% Sharpe2.72 8-9anni; rolling2y
  +166%/+96%; stress leva2x+slippage OK), ETH/ADA solo expanding, LTC/SOL/XRP no.
- Griglia 5/27 robuste su cresta W24/H8-12 -> overfit moderato, config conservativa.
- Free-lunch: corr +0.08 col MASTER, aggiungerlo migliora OOS (Sharpe 4.33->5.10,
  DD 4.7->4.2%). Diversificatore, non motore standalone. Regge fee 0.20% RT.

SH01 come Strategy (in MODULE_MAP) + run() riproducibile. shape_ml_research esteso
con walk-forward rolling (train_window). Live richiede worker con retraining.
Diario 2026-05-29-shape.md, CLAUDE.md famiglia SHAPE-ML.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 12:31:26 +02:00
Adriano 4ac87ab385 research(shape): 5 famiglie di pattern-forma su harness onesto
Harness shape_lab (analog kNN causale, no look-ahead verificato) + 5 ricerche
parallele. 4/5 famiglie = RUMORE (confermano dominanza mean-reversion):
- analog kNN forma grezza: solo BTC-overfit, non robusto >=2 asset
- encoding candele UP/DOWN/DOJI + body/shadow: hit-rate ~50%, muore a fee
- DTW + template geometrici: DTW peggiora euclidea; template overfit
- PIP/pivot/zig-zag: 0/48 config robuste
1/5 = EDGE REALE: ML walk-forward (LogisticRegression) sulle feature di forma.
  BTC logit W24H12 th0.58: FULL +219% / OOS +42% / Sharpe 2.72 / 8-9 anni+ /
  regge fee 0.20% RT (+60/+26). Causalita' verificata. Da validare a fondo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 12:09:28 +02:00
Adriano Dal Pastro 2b01443efe Merge branch 'main' of ssh://git.tielogic.xyz:222/Adriano/PythagorasGoal
# Conflicts:
#	CLAUDE.md
#	README.md
2026-05-29 08:06:19 +00:00
Adriano Dal Pastro 5ac9ebed5b docs: documenta discovery/validazione strumenti e gate del downloader
CLAUDE.md, README.md, API_REFERENCE.md aggiornati per il nuovo layer
src/data/instruments.py: validazione strumenti per exchange (Deribit +
Hyperliquid; esclusi Alpaca e Bybit testnet), congruenza prezzo cross-exchange,
registry come allowlist, gate nel downloader. Aggiunti schemi param
get_instruments/get_markets/get_historical per exchange e convenzione simboli
Deribit (inverse vs USDC lineari). Universo dati esteso con SOL/LTC/ADA 1h.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 07:51:47 +00:00
Adriano Dal Pastro 1561005d41 feat(data): discovery + validazione strumenti con gate nel downloader
src/data/instruments.py: enumera i perpetui per exchange (Deribit, Hyperliquid;
esclusi Alpaca/stocks e Bybit per feed testnet farlocco) e valida ogni strumento
sui DATI STORICI realmente raccoglibili:
  - esistenza, congruenza OHLC, not-flat (scarta contratti morti)
  - liquidita' (volume daily) e congruenza prezzo cross-exchange via mediana
    del base-coin (scarta outlier come Deribit SOL-PERPETUAL=9.6 vs SOL ~82)
Produce data/instruments_registry.json con strumenti validi, timeframe e start-date.

Gate: _download_cerbero_range rifiuta strumenti non validati (override esplicito
allow_unvalidated). La raccolta dati e' possibile solo per strumenti validati.

Registry attuale (testnet): Deribit 18/106 validi (BTC dal 2018, alt dal 2022),
Hyperliquid 66/74. I major liquidi (BTC,ETH,SOL,LTC,ADA,XRP,DOGE,AVAX,BNB,...) passano.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 07:49:57 +00:00
Adriano b9e5176a5b docs: aggiorna README e CLAUDE.md allo stato attuale (4 famiglie + pairs worker)
README riscritto: 4 famiglie (FADE/HONEST/PAIRS/TSMOM) con profili netti OOS,
portafoglio MASTER e numeri sobri anti-overfit, tabella strategie completa, comandi
analisi/validazione, struttura aggiornata (pairs_worker), dati 8 asset + nota naming
Deribit (alt = _USDC-PERPETUAL), paper trading a 2 gambe con sezione pairs in YAML.
CLAUDE.md: struttura/comandi/sezione paper trader aggiornati (niente piu' "solo MR01").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 09:48:39 +02:00
Adriano a60ad30ac0 fix(live): naming Deribit corretto per alt -> tutte le 5 coppie pairs tradabili live
Gli alt su Deribit sono perp LINEARI USDC: "<COIN>_USDC-PERPETUAL" (storia dal 2022),
non "<COIN>-PERPETUAL" (vuoto per LTC/ADA, dati errati per SOL). INSTRUMENT_MAP corretto.
Smoke test live (live_smoke_pairs.py): tutte e 5 le coppie ricevono feed fresco (1448
barre, ultima ~0.4h) e ticcano. Riabilitate tutte le coppie in strategies.yml.
BTC/ETH restano inverse ("<COIN>-PERPETUAL"). CLAUDE.md / docstring PR01 aggiornati.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 09:26:27 +02:00
Adriano bd31a15548 fix(live): smoke test REALE pairs -> live solo ETH/BTC (alt assenti su Deribit)
Test reale (scripts/analysis/live_smoke_pairs.py): fetch live Cerbero + tick vero per
coppia. Scoperta: l'endpoint Deribit serve solo BTC/ETH freschi; LTC/ADA-PERPETUAL sono
VUOTI e SOL ha pochi dati. Il backtest usava i parquet locali (8 asset completi), ma la
pipeline live no -> tradabile live SOLO ETH/BTC.

- strategies.yml: abilitata solo la coppia ETH/BTC; le altre 4 disabilitate con nota
  (valide a backtest, off finche' non si aggiunge un feed live per gli alt).
- live_smoke_pairs.py (nuovo): verifica end-to-end della pipeline live (no ordini reali).
- CLAUDE.md / docstring PR01: distinzione esplicita logica-validata vs live-disponibile.

Onesta': la validazione precedente era backtest-equivalence (ESATTA), NON test live;
il test live ha rivelato il limite del feed alt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 09:16:50 +02:00
Adriano 4dc0e77ee5 feat(live): worker a 2 gambe per i pairs (PR01 market-neutral)
src/live/pairs_worker.py: PairsWorker market-neutral (long A / short B sullo z-score
del log-ratio, exit |z|<=z_exit o max_bars, FEE SU 2 GAMBE = 2*fee_rt*lev, stato
persistente come StrategyWorker). multi_runner: sezione `pairs:` nello YAML, fetch di
entrambe le gambe, tick/status/shutdown; INSTRUMENT_MAP esteso agli alt. strategies.yml:
5 coppie PR01 (config universale n50 z2 zx0.75 mb72).

Validazione (scripts/analysis/validate_worker_pairs.py): replay live bar-per-bar ==
backtest pairs_sim ESATTAMENTE -> ETH/BTC capitale 2.870.429 = 2.870.429, 1754 trade,
win 74.1% identici. Caveat: shortabilita'/liquidita' del perp B sugli alt da verificare
in trading reale. CLAUDE.md / docstring PR01 aggiornati.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 09:12:25 +02:00
Adriano 9a066eb76f feat(analysis): report_families.py - riepilogo strategie/famiglie per anno + integrazione
Report consolidato: (A) Ret%/anno per famiglia (FADE/HONEST/PAIRS/TSM01) e portafogli,
(B) Ret%/anno per ogni strategia singola (15 sleeve), (C) analisi di integrazione delle
nuove famiglie nel MASTER (+pairs/+TSM01/esteso) con correlazioni, (D) numeri sobri e
raccomandazione (leva 2x, cap pairs 30-35%).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 08:20:23 +02:00
Adriano 7226946911 refactor(explore): irrobustimento anti-overfit di pairs/TSM01/master
Giro di validazione scettica (walk-forward, plateau, stress, scomposizione):

- PAIRS: config PER-COPPIA -> config UNIVERSALE (n50 z2 zx0.75 mb72), niente
  cherry-picking. Plateau confermato (heatmap 20/20 Sharpe>1) + walk-forward
  (ETH/BTC 11/12 finestre+, BTC/LTC 9/10). Scartata BNB/ETH (overfit). 5 coppie robuste.
- TSM01: gross 0.45->0.30 (stesso Sharpe, DD 22->15%); corr reale con ROT02 = 0.62
  (non 0.53); diversificatore, non motore. Robusto (36/36 config OOS+).
- Confluenza multi-TF SCARTATA: overfit (taglia 97% trade, ~40 in 8 anni, Sharpe crolla).
- MASTER: numeri sobri onesti -> OOS Sharpe 7.7/DD 2.3% e' regime calmo 2024-25
  (ottimistico ~50%); worst-DD 90g ~6%, Sharpe atteso ~5, regge leva 2x+slippage.
  Config robusta: equal-weight, leva 2x, cap pairs ~30-35% (sono ~57% del rischio).

Quanto trovato regge l'esame anti-overfit; numeri comunicati sobri. Doc/CLAUDE/memoria aggiornati.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 08:11:33 +02:00
Adriano 945c2a2db6 feat(explore): 3a ondata - PAIRS a 6 coppie + verdetti su 4 nuove famiglie
Espansione dei meccanismi provati + 2 nuovi sondaggi (agenti paralleli, honest):
- PAIRS espansa a 6 coppie robuste: + BTC/LTC (robusta 1h E 4h, Sharpe 2.21, DD 24-34%),
  ETH/SOL e BNB/ETH (Sharpe 2.4+, solo 1h). Pattern: alt-liquido vs major.
- Fade su 6 alt: 0 robuste (mean-reversion vive solo su BTC/ETH; DOGE = artefatto).
- Low-vol anomaly: invertita in cripto (vince alta vol), ridondante -> scartata.
- Confluenza multi-TF: dimezza il DD di MR01 (ETH same Sharpe a DD 38% vs 63%) ->
  variante low-DD utile, non edge nuovo.

MASTER + 6 pairs (15 sleeve): CAGR 47->71%, OOS DD 4.7->2.3%, Sharpe OOS 4.33->7.71,
tutti gli anni positivi. Bilancio robusti: famiglia PAIRS (6) + TSM01 = 7 strategie nuove.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 01:13:54 +02:00
Adriano 33e3e2a603 feat(explore): esplora 9 famiglie alternative -> PAIRS (nuovo edge forte) + TSM01
Esplorazione onesta con agenti paralleli su harness condiviso (explore_lab.py):
ingresso close[i], netto fee, OOS, DD basso, attenzione fee. 7 famiglie su 9 sono
rumore (stagionalita' oraria/mensile, cross-sectional reversal, opening-range,
lead-lag BTC->alt, continuation intraday) e l'harness le rifiuta senza falsi positivi.

Due edge reali verificati indipendentemente:
- PR01 Pairs: spread reversion market-neutral su log-ratio z-score (ETH/BTC, LTC/ETH,
  ADA/ETH). ETH/BTC CAGR 144% Sharpe 4.04 OOS DD 17% 8/9 anni, corr mercato ~0.02,
  no-look-ahead verificato, regge fee 0.40%/coppia. Fee su 2 gambe (worker da estendere).
- TSM01: TSMOM multi-orizzonte 3/6/12m + risk-off, distinto da ROT02 (corr 0.53),
  DD 22%/12% OOS, mai un anno negativo, regge fee 0.40%.

Payoff: aggiungere i pairs (quasi scorrelati ~0.05) al MASTER -> CAGR 47->66%,
DD 5.2->3.8% full / 4.7->3.3% OOS, Sharpe OOS 4.33->6.86 (combine_v2.py).

Fix: explore_lab.get_df ora produce timestamp ms reale per 1d/4h (era placeholder).
Diario 2026-05-29-exploration.md + nota CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 01:07:08 +02:00
Adriano 666c906907 feat(analysis): report.py aggiornato con numero trade per anno
Report consolidato: (A) Ret% netto per anno di ogni strategia singola + portafogli
(FADE/HONEST/MASTER eq/5050), (B) numero trade per anno per strategia (ingressi per
fade/DIP01; ribilanciamenti per TR01/ROT02 a posizione continua), (C) riepilogo
portafogli FULL/OOS. Config deployata (MR03/ROT01 in waste, ROT02 top_k=3).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 00:33:00 +02:00
Adriano 9ebdfc7a7a feat(ROT02): riduce il DD diversificando (top_k 2 -> 3)
ROT02 concentrava il book su 2 soli asset (DD 40%). top_k=3 dimezza quasi il DD
(40% -> 26%) e ALZA il ritorno full (+1095 -> +1303%, ret/DD da 27 a 50). Il
vol-target abbassa il DD ma sacrifica ritorno (de-leverage) -> tenuto top_k=3
senza VT. Caveat: OOS di ROT02 cala (+98 -> +68%, DD 12 -> 14%), ma il portafoglio
MASTER migliora lo Sharpe full (3.95 -> 4.23). Applicato a ROT02_dual_momentum.py
e a _rot_daily_equity (usata da PORT01/PORT03). Docstring/CLAUDE/diario aggiornati.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 00:30:55 +02:00
Adriano bcccfde9a0 feat(strategie): portafogli master (PORT02/PORT03) + waste delle peggiori (MR03, ROT01)
Crea gli artefatti accorpati e migliorati:
- PORT02_fade_master: 3 fade (MR01/MR02/MR07) x BTC/ETH = 6 sleeve, filtro trend,
  equal-weight daily. DD 8.2% full / 5.9% OOS, Sharpe 3.95/4.09, CAGR ~46%.
- PORT03_all_master: portafoglio MASTER fade+honest (9 sleeve), varianti equal
  (max Sharpe: DD 5.2%/4.7% OOS, Sharpe 3.95/4.42) e 50/50 (min DD 5.1%/4.3%).

Sposta in scripts/waste/ le due peggiori:
- MR03 keltner_fade: fade piu' debole (BTC Sharpe 1.22), ridondante con MR01, il
  filtro trend la peggiorava; rimuoverla MIGLIORA il portafoglio fade.
- ROT01 xsect_rotation: strettamente dominata da ROT02 (stesso meccanismo, ROT02
  meglio su tutto), non usata da alcun portafoglio.

Sganciata MR03 da strategy_loader, strategies.yml e dal motore portafogli
(risk_management.STRATS). La funzione keltner_fade resta in strategy_research_v2
come record. CLAUDE.md aggiornato.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 00:21:37 +02:00
Adriano 1af4addbdd feat(analysis): studio combinazione strategie fade + honest (diversificazione)
combine_portfolio.py: costruisce l'equity giornaliera di tutte le sleeve (8 fade +
3 honest) su indice comune 2021-2026, misura la correlazione cross-famiglia e
confronta i portafogli FULL/OOS (ret, CAGR, DD, Sharpe).

Risultato: le due famiglie sono quasi scorrelate (corr ~0.05). Combinarle migliora
il rischio/rendimento: equal-weight 11 sleeve -> DD 6.1% full / 4.6% OOS, Sharpe OOS
4.46 (vs honest-only 12% DD / 2.23 e fade-only 8.6% DD / 4.14), CAGR ~43% mantenuta.
Il 50/50 fra famiglie da' il DD piu' basso (5.5% full / 4.0% OOS). Diario 2026-05-29
e nota CLAUDE.md aggiornati.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 00:14:14 +02:00
Adriano 22c6080873 chore(analysis): pulizia e accorpamento script di analisi (25 -> 15 file)
- accorpa risk_improvements.py + risk_portfolio.py -> risk_management.py
  (sezione A screening leve, sezione B filtro trend + portafoglio)
- rimuove 4 script legacy della famiglia squeeze (ormai in waste, non
  referenziati): compare_strategies, best_yearly, final_report, yearly_market_report
- rimuove 5 script honest_* di diagnostica/iterazione superati da honest_matrix
  (consolidato) e non importati: honest_diag, honest_diag2, honest_candidates,
  honest_yearly, honest_yearly2
- mantiene il core honest (lab/improve/improve2/rotation/trend) + canonici
  (final/matrix), tutta la ricerca fade (strategy_research[_v2]), validazione
  (oos_validation, validate_worker_mr01), intrabar_test (lezione squeeze)
- aggiorna riferimento in CLAUDE.md. Import-check: 14/14 moduli OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 00:08:24 +02:00
Adriano fd547748dd merge: strategy_free -> main (fade MR02/MR03/MR07 + filtro trend Acc/DD)
Integra il lavoro su branch strategy_free, indipendente dalle strategie oneste
(DIP/TR/ROT/PORT) gia' su main: nessun file in comune, merge pulito.

- 3 nuove fade mean-reversion validate OOS fee-aware: MR02 Donchian fade,
  MR03 Keltner fade, MR07 Return reversal (+ base condivisa fade_base).
- Filtro trend (trend_max/ema_long) su tutte le fade: alza Acc e riduce DD
  (drastico su ETH), edge OOS confermato; modello portafoglio equipesato.
2026-05-29 00:00:51 +02:00
Adriano a51129acf6 feat(analysis): matrice PnL/anno consolidata (confronto strategie + portafoglio)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 23:53:37 +02:00
Adriano 1b099bb47b feat(analysis): tabella per-anno (PnL/DD) versioni migliorate + portafoglio
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 23:52:25 +02:00
Adriano 783fa5546f feat(analysis): miglioramenti - ROT02 dual-momentum + portafoglio (DD 12%)
Obiettivo: alzare Acc, ridurre DD, migliorare PnL. Leve oneste, no tuning per-anno.

- ROT02: overlay absolute-momentum (cash se BTC<SMA100) su ROT01. Domina su tutte
  le metriche: FULL +679->+1095%, OOS +44->+98%, DD 53->40%.
- DIP01 market-gate (variante low-DD): alza Acc (ETH 52->57, SOL 49->52) e dimezza
  il DD (ETH 53->23), al costo di PnL. De-risking opzionale; su BTC il gate va evitato.
- PORT01: portafoglio equal-weight giornaliero delle 3 sleeve anti-correlate
  (DIP01+TR01+ROT02). DD 12% (sotto ogni sleeve), CAGR 45%, 2022 bear -1% (era -30%).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 23:49:14 +02:00
Adriano cff0d08fca feat(risk): filtro trend per alzare Acc e ridurre DD + modello portafoglio
Filtro opzionale trend_max/ema_long su tutte le fade (MR01/MR02/MR03/MR07):
salta i segnali quando |close-EMA200|/ATR supera la soglia (non fadare un trend
o crollo estremo). Con trend_max=3.0 (default in strategies.yml): accuratezza su
e DD giu' su 7/8 sleeve, drastico su ETH (MR01 71->26%, MR02 42->25%,
MR03 66->34%, MR07 46->21%); edge OOS confermato. MR03 BTC: filtro disattivo
(unico sleeve dove peggiora entrambe).

Scartate come non robuste: vol-target sizing e skip-alta-volatilita' (peggiorano
sia Acc che DD). Aggiunto modello di portafoglio equipesato su sotto-conti
indipendenti: DD aggregato ~14% full / ~10% OOS sul paniere di 8 sleeve, contro
20-70% del singolo -> vera leva anti-drawdown.

Banco di prova: scripts/analysis/risk_improvements.py, risk_portfolio.py.
Helper trend_distance() in fade_base. CLAUDE.md e diario aggiornati.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 23:47:52 +02:00
Adriano ad141f080c feat(analysis): report per-anno (Trade/Acc/DD/PnL) delle 3 strategie
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 23:42:04 +02:00
Adriano 212427ffa1 feat(analysis): 3 strategie oneste validate OOS multi-crypto (DIP/TR/ROT)
Ricerca onesta post-squeeze su 8 crypto (2018-2026), engine fee-aware con
ingresso eseguibile a close[i], uscita TP/SL intrabar, OOS held-out, sweep fee.

Lezione madre: shortare cripto perde OOS sistematicamente (campione net-bull)
-> tutte le strategie robuste sono long-biased.

Tre meccanismi distinti e complementari:
- DIP01  dip-buy z-score reversion (long-only, 1h)  robusto BTC/ETH/SOL
- TR01   EMA 20/100 trend-following (long-only, 4h) robusto su 5/8 asset
- ROT01  rotazione cross-sectional momentum sul paniere (1d) OOS +44%, param-insensitive

Engine e validazione: scripts/analysis/honest_lab.py + honest_final.py
(+ honest_candidates/diag/diag2/trend/rotation). Diario in docs/diary/.

Onesto sull'obiettivo: €50/giorno su €1000 in pochi mesi non e' raggiungibile a
rischio sano (~1825%/anno); edge reali 30-60% OOS pluriennale. Via realistica:
portafoglio delle 3, leva moderata, crescita composta.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 23:28:00 +02:00
Adriano 21d3ba609d feat(strategie): 3 nuove fade mean-reversion validate OOS fee-aware (MR02/MR03/MR07)
Trovate e promosse 3 strategie con edge netto distinto da MR01, stessa
metodologia (ingresso close[i], netto fee 0.10% RT + leva 3x, OOS ultimo 30%,
robustezza su griglia + sweep fee 0.00-0.20%):

- MR02 Donchian Fade: fade rottura canale H/L, TP al centro. BTC +172% OOS.
- MR03 Keltner Fade: canale ATR su EMA (indipendente da Bollinger). BTC +112%.
- MR07 Return Reversal: fade movimento di barra estremo (z dei rendimenti). BTC +105%.

Tutte positive netto OOS su entrambi gli asset e su tutto lo sweep fee, anche
0.20% RT pessimista (validate anche via oos_validation live-path). Scartate
MR04 (= MR01 riparametrizzato), MR05 (ADX non robusto), MR06 (RSI2 ETH neg).

Base condivisa fade_base.FadeStrategy (backtest intrabar TP/SL/max_bars).
Aggiunte a strategy_loader e strategies.yml (BTC+ETH 1h). Ricerca in
strategy_research_v2.py. Diario e CLAUDE.md aggiornati.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 23:26:21 +02:00
Adriano Dal Pastro 48435f6858 feat(live): worker con exit TP/SL/max_bars per MR01 + doc aggiornata
StrategyWorker ora supporta exit guidati dalla strategia via Signal.metadata
(take-profit alla media / stop-loss ad ATR / time-limit), con fallback al
vecchio hold_bars/stop -2% per strategie senza metadata. Usa fee_rt della
strategia (MR01 = 0.10% RT reale Deribit, non piu' 0.20% hardcoded).
Persistenza di tp/sl/max_bars in status.json per resume.

Re-validato col worker reale (replay finestre mobili 1h, fee 0.10%):
  BTC 1h MR01: +196% OOS, ETH 1h: +251% OOS (nov 2023->mag 2026) — coerente col backtest.

README + CLAUDE.md riscritti: squeeze = artefatto di look-ahead -> waste,
MR01 mean-reversion unica attiva, metodologia anti-look-ahead e fee reali 0.10% RT.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 20:46:35 +00:00
Adriano Dal Pastro 9879b46688 refactor(strategie): tieni solo MR01 mean-reversion, squeeze -> waste
L'analisi out-of-sample fee-aware ha dimostrato che l'intera famiglia
squeeze-breakout (SQ01-04, MT01, ML01, AD01, CM01, PD01) non ha edge:
le accuratezze storiche 76-82% erano un artefatto di look-ahead (ingresso
a close[i-1] con direzione decisa da close[i]). Sotto ingresso onesto a
close[i] e fee reali tutte perdono, anche a fee zero.

- nuova MR01_bollinger_fade (mean-reversion): edge netto validato OOS,
  robusto su griglia parametri e fino a 0.20% fee RT. BTC 1h n50 k2.5: +201% OOS, DD 15%
- 9 strategie squeeze spostate in scripts/waste/
- strategy_loader + strategies.yml: solo MR01 (BTC/ETH 1h)
- signal_engine.train: validazione OOS (accuratezza test + signal precision)
- scripts/analysis/strategy_research.py: harness di ricerca fee-aware

NOTA: lo StrategyWorker va aggiornato per usare gli exit TP/SL passati in
metadata prima di tradare MR01 dal vivo (ora esce solo a hold_bars/stop fisso).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 20:22:11 +00:00
Adriano Dal Pastro ca88e62a11 feat(analysis): validazione out-of-sample fee-aware delle strategie
oos_validation.py: backtest OOS fedele al worker live (non-overlap, hold,
stop, fee, leva) su finestra held-out. Mostra che l'edge storico 76-79%
e' un artefatto di look-ahead (ingresso a close[i-1]) e che nessuna regola
di direzione onesta supera il lancio di moneta; le fee sono secondarie
(4/6 config perdono anche a fee zero).

intrabar_test.py: ingresso intra-barra su 5m vs close 15m a parita' di exit.
Lo "scatto" del breakout e' avverso (rientro immediato alla media), quindi
la granularita' piu' fine non recupera edge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:57:15 +00:00
Adriano Dal Pastro 8fd2c16cac fix(live): MT01 usa trend 1h live da Cerbero, non dal parquet statico
Il paper trader restava a zero trade: il feed Cerbero era fermo a
mezzanotte (bug end_date lato cerbero-mcp, poi risolto) e MT01 leggeva
il trend 1h da un parquet statico, di fatto congelandolo (gap ~15h sul
bar corrente). Ora il runner fa fetch 1h live per le strategie MTF e lo
passa a generate_signals via il parametro df_1h (fallback al parquet se
assente). Aggiornati CLAUDE.md, README e diario 2026-05-28.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:30:26 +00:00
Adriano 31be1b43aa docs: aggiorna README e CLAUDE.md con strategie MT01/PD01/CM01/AD01
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 09:50:58 +02:00
Adriano bdcef09057 chore: untrack paper_trades runtime data + report per anno/mercato
- data/paper_trades/ rimosso dal tracking (dati runtime, gitignored)
- scripts/analysis/yearly_market_report.py: accuracy/trades/PnL per anno×mercato

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 09:46:24 +02:00
Adriano d39c75b103 feat(strategy4): PD01 82.5%/DD2.9%, AD01 81.2%, CM01 81.9% — tutte battono SQ02
Nuove strategie che battono SQ02 (79.7% acc, DD 6.5%):
- PD01 price-volume divergence: 82.5% acc, DD 2.9%, worst year 80%
- CM01 cross-market momentum: 81.9% acc, DD 2.7%
- AD01 adaptive squeeze threshold: 81.2% acc, DD 3.4%
- MT01 (già committato): 82.7% acc, DD 5.9%

Tutte testate su BTC e ETH, 15m e 1h, 9 anni, con fee 0.2% RT.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 01:13:17 +02:00
Adriano f42fec9fac feat(strategy4): MT01 squeeze+MTF 82.7% acc — batte SQ02, 6 strategie scartate
Nuova strategia MT01: squeeze 15m + momentum EMA 1h
  BTC 15m: 82.7% acc, 503 trades, DD 5.9%, 9/9 anni, worst 72%
  ETH 15m: 81.2% acc, 404 trades, DD 2.9%, 9/9 anni, worst 73%

Strategie testate e scartate (waste W23-W28):
  IB01 inside bar (58.7%, no edge)
  DC01 donchian (48%, sotto random)
  SB01 retest (52%, no edge)
  MR01 mean reversion RSI (62.9%, DD 29%)
  VO01 volume spike (64.2%, DD 34%)
  HY01 squeeze+MR (64.6%, DD 14.5%)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 00:38:11 +02:00
Adriano 56bad4741e docs: aggiorna README e CLAUDE.md con struttura attuale e multi-strategy runner
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 23:23:07 +02:00
Adriano b79c87e4af feat: multi-strategy paper trader — N strategie in parallelo su testnet
- src/live/multi_runner.py: orchestratore con fetch raggruppato per asset/tf
- src/live/strategy_worker.py: worker indipendente con stato persistente JSONL
- src/live/strategy_loader.py: import dinamico classi Strategy
- strategies.yml: config dichiarativa con defaults e override per strategia
- Docker: container unico, strategies.yml montato come volume read-only
- Supporta hot-add: aggiungi riga YAML + restart, storico intatto
- Ogni strategia: €1000 USDC virtuale, equity tracking, Telegram notify

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 23:12:18 +02:00
Adriano 0e47956f7a refactor: riorganizzazione script — Strategy ABC, folder strategies/waste/analysis
- src/strategies/base.py: Strategy ABC con Signal, BacktestResult, YearlyStats
- src/strategies/indicators.py: keltner_ratio, detect_squeezes, ema, atr, rv, corr
- scripts/strategies/: SQ01-SQ04 (squeeze puro/filtri), ML01 (squeeze+GBM)
- scripts/waste/: W01-W22 script scartati + REF originali
- scripts/analysis/: compare, best_yearly, final_report, paper_status
- CLAUDE.md aggiornato con nuova struttura e tabella strategie

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 23:01:36 +02:00
Adriano fa2d74be77 feat(strategy3): ultimate squeeze — BTC 15m antifake+vol 79.7%, antifake+corr 81.6%
Top results con dati reali:
- BTC 15m antifake+vol: 79.7% acc, 1250 trades, DD 6.5%
- ETH 15m antifake+vol: 78.5% acc, 941 trades, DD 3.4%
- BTC 15m antifake+corr: 81.6% acc, 376 trades (pochi anni)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:25:22 +02:00
Adriano 041db2191c test(strategy3): lead-lag multi-asset — leader-follower fallito, corr-weighted 76.8%
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:22:44 +02:00
Adriano 185ac0d49b feat(strategy3): squeeze migliorato — BTC 15m ALL_FILTERS 79.2% acc
Cross-asset + timing + long_squeeze + dual_tf + anti_fakeout.
Worst year: 2021 76.8%. Tutti gli anni profittevoli.
ETH 15m long_squeeze: 77.9% acc. BTC 1h anti_fakeout: 76.3%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:20:44 +02:00
Adriano 0ab3b5698a docs: confronto migliori strategie S1/S2 per anno, dati reali
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 11:12:47 +02:00
Adriano 7639e5012b Merge branch 'main' of ssh://git.tielogic.xyz:222/Adriano/PythagorasGoal
# Conflicts:
#	uv.lock
2026-05-27 11:09:52 +02:00
Adriano Dal Pastro 2694a4a00c feat: notifiche Telegram dal paper trader via bot
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 08:52:11 +00:00
Adriano Dal Pastro a7b3c3c203 infra: add uv.lock per build Docker riproducibili
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 08:19:15 +00:00
191 changed files with 21989 additions and 407 deletions
+10
View File
@@ -16,3 +16,13 @@ data/processed/
*.pt
*.pth
notebooks/.ipynb_checkpoints/
data/paper_trades/
data/portfolio_paper/
data/portfolios/
# stato locale di tooling (non condiviso)
.claude/
.omc/
# dati regime (DVOL/funding/feature cache, rigenerabili)
data/regime/
+37
View File
@@ -292,3 +292,40 @@ curl -X POST http://localhost:9000/mcp-bybit/tools/get_ticker \
Per lo schema completo dei body di richiesta e risposta:
<http://localhost:9000/apidocs>.
---
## 15. Discovery strumenti — schemi `get_instruments` / `get_markets` / `get_historical`
Schemi dei body verificati sull'OpenAPI live (usati da `src/data/instruments.py`).
### Lista strumenti
| Exchange | Tool | Body | Risposta (campi utili) |
|---|---|---|---|
| Deribit | `get_instruments` | `{currency:"any", kind:"future", offset:int, limit:100}` (paginato, `has_more`) | `instruments[].name` (es. `BTC-PERPETUAL`, `SOL_USDC-PERPETUAL`), `expiry`, `tick_size` |
| Bybit | `get_instruments` | `{category:"linear", symbol?}` | `instruments[]`: `symbol`, `status`, `base_coin`, `quote_coin` |
| Hyperliquid | `get_markets` | `{}` | lista `{asset, mark_price, funding_rate, open_interest, volume_24h, max_leverage}` |
### Storico OHLCV (`get_historical`, chiave `candles` uniforme `{timestamp(ms),open,high,low,close,volume}`)
| Exchange | Body |
|---|---|
| Deribit | `{instrument, start_date:"YYYY-MM-DD", end_date, resolution}` — resolution `1/5/15/60/1D` |
| Bybit | `{symbol, category:"linear", interval:"1/5/15/60/D", start:int_ms, end:int_ms, limit}` |
| Hyperliquid | `{asset|instrument, start_date, end_date, resolution:"1m/5m/15m/1h/1d", limit}` |
### Simboli Deribit
- BTC/ETH → perpetui **inverse**: `BTC-PERPETUAL`, `ETH-PERPETUAL`
- Altcoin → perpetui **lineari USDC**: `<COIN>_USDC-PERPETUAL` (es. `SOL_USDC-PERPETUAL`)
- Trappola: `LTC-PERPETUAL`/`ADA-PERPETUAL` non esistono; `SOL-PERPETUAL` esiste ma è un contratto sbagliato (prezzo ~9.6 vs SOL reale ~82).
### Validazione (lato progetto)
`src/data/instruments.py` valida ogni strumento sui dati storici realmente
raccoglibili — esistenza, congruenza OHLC, not-flat, liquidità (volume daily) e
**congruenza prezzo cross-exchange** (scostamento dalla mediana del base-coin ≤5%).
Solo gli exchange con feed affidabile sono inclusi: **Deribit** e **Hyperliquid**
(esclusi Alpaca/stocks e **Bybit**, il cui feed testnet è farlocco). Output in
`data/instruments_registry.json`; il downloader scarica **solo** strumenti validati.
> **Testnet.** Il token osservatore punta a testnet (`"testnet": true` nei ticker):
> i prezzi possono divergere dal mainnet. La congruenza cross-exchange via mediana
> è il filtro che scarta i feed incongrui prima di usarli per backtest/trading.
+291 -22
View File
@@ -9,31 +9,84 @@ Progetto di ricerca: riconoscimento pattern frattali per trading algoritmico su
- **Linguaggio:** Python 3.11+
- **Package manager:** uv (dipendenze in `pyproject.toml`, lock in `uv.lock`)
- **Dati:** Parquet in `data/raw/` (non committati, ~70 MB)
- **ML:** scikit-learn (GradientBoosting), PyTorch (LSTM)
- **ML:** scikit-learn (GradientBoostingClassifier)
- **Analisi:** numpy, pandas, scipy
- **API dati:** Cerbero MCP su `cerbero-mcp.tielogic.xyz` (Deribit, Bybit, Hyperliquid), ccxt/Binance come fallback
- **Config:** pyyaml per `strategies.yml`
## Struttura
```
src/data/ → download e caricamento dati (downloader.py)
src/fractal/ → indicatori frattali (patterns.py, indicators.py, similarity.py)
src/backtest/ → engine di backtesting (engine.py)
scripts/ → analisi e strategie numerate 0113
docs/diary/ → diario di ricerca giornaliero
data/raw/ → file .parquet OHLCV (gitignored)
data/processed/ → modelli salvati (gitignored)
src/data/ → download e caricamento dati
downloader.py → download/caricamento parquet (gate: solo strumenti validati)
instruments.py → discovery + validazione strumenti per exchange, registry
src/fractal/ indicatori frattali (patterns.py, indicators.py, similarity.py)
src/backtest/ → engine di backtesting (engine.py)
src/strategies/ → classe base Strategy ABC + indicatori condivisi
base.py → Strategy, Signal, BacktestResult, YearlyStats
indicators.py → keltner_ratio, detect_squeezes, ema, atr, rv, correlation
src/live/ → paper trading live multi-strategia
multi_runner.py → orchestratore: carica YAML (strategies + pairs), fetch candele, tick worker
strategy_worker.py → worker single-leg: capital, trade log, stato persistente.
Exit guidati da strategia (TP/SL/max_bars via Signal.metadata),
fallback hold_bars/stop -2%. Usa fee_rt della strategia.
pairs_worker.py → worker a 2 GAMBE per PR01 (market-neutral): long A / short B sullo
z-score del log-ratio, exit |z|<=z_exit o max_bars, fee su 2 gambe.
strategy_loader.py → import dinamico classi Strategy da scripts/strategies/
cerbero_client.py → client HTTP per Cerbero MCP (Deribit testnet)
signal_engine.py → squeeze + ML real-time (legacy ML01, ora in waste) + validazione OOS
telegram_notifier.py → notifiche Telegram per trade
src/portfolio/ → portafogli di prima classe (capitale-pool, backtest+live)
base.py → SleeveSpec, Portfolio (.backtest), load_active_portfolio
weighting.py → schemi pesi: equal/cap/inverse_vol/cluster_rp/manual
sleeves.py → builder unificato equity-per-sleeve (fonte unica, parità report)
ledger.py → PortfolioLedger: capitale/PnL/DD/persistenza+resume
runner.py → PortfolioRunner live (data Cerbero v2, sizing, ribilancio)
src/version.py → APP_VERSION (legge il file VERSION) — mostrato nei msg Telegram
src/strategies/fade_base.py → FadeStrategy + helper: atr, trend_distance, hurst_skip_mask (loss-guard)
scripts/strategies/ → strategie con edge validato OOS: FADE (MR01/MR02/MR07),
HONEST (DIP01/TR01/ROT02), PAIRS (PR01), TSMOM + portafogli (PORT01/02/03);
FR01_hurst_calm_fade.py = record ricerca (robusto ma NON deployato)
scripts/portfolios/ → definizioni PORT01-06 (_defs.py) + report run() + hourly_report.py (Telegram)
scripts/waste/ → strategie scartate (W01-W28 + famiglia squeeze SQ/MT/ML/AD/CM/PD)
scripts/analysis/ → ricerca/validazione OOS fee-aware (strategy_research, oos_validation, ...);
regime_fetcher.py + regime_lab.py (DVOL/funding/feature regime per la ricerca)
scripts/bump_version.py, scripts/deploy.sh → versionamento e deploy (bump+commit+rebuild)
VERSION → versione semver (cotta nell'immagine, +1 ad ogni deploy)
strategies.yml → config multi-strategy paper trader
docs/diary/ → diario di ricerca giornaliero
docs/specs/ → specifiche di design
data/raw/ → file .parquet OHLCV (gitignored) | data/regime/ → DVOL+funding+feature (gitignored)
data/instruments_registry.json → allowlist strumenti validati (gate del downloader)
```
## Comandi
```bash
uv sync # installa dipendenze
uv run python -m src.data.downloader # scarica dati storici
uv run python scripts/13_squeeze_ml_hybrid.py # strategia vincente
uv run pytest # test
uv sync # installa dipendenze
uv run python -m src.data.downloader # scarica dati storici (solo strumenti validati)
uv run python -m src.data.instruments # (ri)costruisci il registry strumenti validati
uv run python scripts/strategies/MR01_bollinger_fade.py # backtest una strategia (es. fade)
uv run python scripts/strategies/PR01_pairs_reversion.py # backtest pairs market-neutral
uv run python scripts/analysis/strategy_research.py # ricerca strategie fee-aware OOS
uv run python scripts/analysis/oos_validation.py # perche' la famiglia squeeze e' scartata
uv run python scripts/analysis/report_families.py # report per anno di tutte le famiglie
uv run python scripts/analysis/validate_worker_pairs.py # replay worker 2 gambe == backtest
uv run python -m src.live.multi_runner # paper trading live multi-strategia (strategie + pairs)
uv run python scripts/portfolios/PORT06_master_shape.py # report backtest portafoglio (default)
uv run python -m src.portfolio.runner # paper trading a PORTAFOGLIO (capitale pool)
uv run python scripts/analysis/smoke_portfolio.py # smoke live data layer Cerbero v2
uv run python scripts/analysis/regime_fetcher.py # fetch DVOL+funding (Deribit mainnet) -> data/regime/
./scripts/deploy.sh [patch|minor|major] # DEPLOY: bump versione + commit + rebuild Docker
uv run pytest # test
```
> **Deploy.** Il sorgente è **COPY nell'immagine Docker** (non montato) → `docker compose restart`
> NON ricarica il codice: serve **`docker compose up -d --build`** (o `./scripts/deploy.sh`, che bumpa
> la versione, committa e rebuilda). Il volume `data/` persiste → i worker fanno RESUME dello stato.
> La **versione** (file `VERSION`, semver, +1 ad ogni deploy via `deploy.sh`) compare nei messaggi
> Telegram (notifiche trade + report orario) → correli ogni msg al codice che l'ha generato.
## Dati storici
Scaricati e salvati localmente in Parquet. Per rigenerarli:
@@ -47,22 +100,235 @@ df = load_data("ETH", "15m") # carica un asset/timeframe
Fonte primaria: Cerbero MCP (endpoint `/mcp-deribit/tools/get_historical`).
Token observer: nel file `secrets/observer.token` del progetto CerberoSuite.
## Strategia vincente
### Strumenti & validazione (gate raccolta dati)
**Squeeze + ML ibrida** (script 13):
`src/data/instruments.py` scopre e **valida** gli strumenti per ogni exchange
implementato — **Deribit** e **Hyperliquid** (esclusi Alpaca/stocks e **Bybit**,
il cui feed testnet è farlocco). Per ogni perpetuo enumera via `get_instruments`
/`get_markets` e verifica sui **dati storici realmente raccoglibili**:
esistenza, congruenza OHLC, not-flat (scarta contratti morti), liquidità (volume
daily) e **congruenza prezzo cross-exchange** (scostamento dalla mediana del
base-coin ≤ 5% → scarta outlier come `SOL-PERPETUAL`=9.6 vs SOL reale ~82).
1. Rileva squeeze di volatilità (Bollinger dentro Keltner)
2. Al rilascio dello squeeze, estrai feature strutturali dalla finestra
3. GradientBoosting predice direzione con walk-forward training
4. Trade solo se modello ha confidenza ≥ 70%
Output: `data/instruments_registry.json` (strumenti validi, timeframe, start-date).
**Gate:** `_download_cerbero_range` rifiuta gli strumenti non validati (override
`allow_unvalidated=True` solo per casi eccezionali). Rigenera con
`python -m src.data.instruments`.
Configurazione migliore: ETH 15m, BBw=14, squeeze threshold=0.8, breakout=3 barre, leva 3x, position 15%.
> **NB testnet.** Il token Cerbero punta a testnet; la congruenza cross-exchange
> è il filtro che distingue i feed realistici (Deribit, Hyperliquid) da quelli
> farlocchi (Bybit). Simboli Deribit: BTC/ETH = `<COIN>-PERPETUAL` (inverse);
> alt = `<COIN>_USDC-PERPETUAL` (lineari USDC). Registry attuale: Deribit 18/106,
> Hyperliquid 66/74 validi (major liquidi: BTC dal 2018, alt dal 2022).
Risultato backtestato: 76.9% accuracy, 118% annuo, 4.2% drawdown, €13.78/giorno da €1.000.
## Strategie attive
> **LEZIONE CRITICA (2026-05-28).** L'intera famiglia squeeze-breakout (SQ01-04,
> MT01, ML01, AD01, CM01, PD01) è stata **scartata in `scripts/waste/`**: le
> accuratezze storiche 76-82% erano un **artefatto di look-ahead**. Quei backtest
> decidono la direzione con `sign(close[i]-close[i-1])` (la candela di breakout `i`)
> ma entrano a `close[i-1]` — cioè comprano *prima* della candela che usano per
> scegliere la direzione. Dal vivo il worker scopre il breakout solo a `close[i]`
> ed entra lì: l'edge sparisce (win-rate ~47%, lancio di moneta). Sotto ingresso
> onesto e fee reali **tutte perdono, anche a fee zero**. Inoltre i breakout
> *rientrano* (mean-reversion > continuation). Vedi `scripts/analysis/oos_validation.py`
> e `intrabar_test.py`.
Tutte le strategie estendono `src.strategies.base.Strategy`
(`generate_signals() → backtest()`). Le strategie mean-reversion condividono
`src.strategies.fade_base.FadeStrategy` (backtest intrabar TP/SL/max_bars).
**Strategie con edge netto validato OOS fee-aware (tutte fade/mean-reversion):**
| Codice | Nome | Meccanismo | Edge OOS netto (1h, fee 0.10% RT) | DD | Note |
|--------|------|-----------|-----------------------------------|----|------|
| **MR01** | Bollinger Fade | banda std attorno a SMA | BTC +201% / ETH +1238% | 15-72% | Fada la banda, TP alla media, SL ad ATR |
| **MR02** | Donchian Fade | estremi canale H/L | BTC +172% / ETH enorme | 30-42% | Fada la rottura del canale, TP al centro |
| **MR07** | Return Reversal | z dei rendimenti di barra | BTC +105% / ETH +195% | 25-46% | Fada il movimento estremo, exit in ATR; esposizione ~8% |
> **MR03 Keltner Fade** spostata in `scripts/waste/`: era la fade più debole
> (BTC Sharpe 1.22, il filtro trend la peggiorava) e ridondante con MR01 (stessa
> idea di banda). Rimuoverla dal portafoglio ne ha *migliorato* le metriche.
> La funzione `keltner_fade` resta in `strategy_research_v2.py` come record.
**Lezione confermata:** l'edge è sempre *mean-reversion* (i breakout rientrano).
Il trend-following (Donchian trend, RSI cross) e gli oscillatori senza filtro
(RSI revert, ADX-filtered fade) perdono netti → restano scartati.
Ogni strategia è robusta su **tutta** la sua griglia parametri (entrambi gli asset
→ tutte positive OOS) e su **tutte** le fee 0.00-0.20% RT (margine ampio).
MR01 validato col worker reale: BTC +196% / ETH +251% OOS (nov 2023→mag 2026).
Ricerca completa: `scripts/analysis/strategy_research.py` (MR01) e
`scripts/analysis/strategy_research_v2.py` (MR02/MR03/MR07).
Validazione live-path: `scripts/analysis/oos_validation.py`.
**Filtro trend (riduzione DD + aumento Acc).** Tutte le fade accettano i parametri
opzionali `trend_max` / `ema_long`: saltano i segnali quando il prezzo è troppo
esteso rispetto al trend di fondo (`|close EMA(ema_long)| / ATR(14) > trend_max`),
cioè quando si starebbe fadando un trend/crollo estremo. Con `trend_max=3.0`,
`ema_long=200` (default in `strategies.yml`): accuratezza su tutti gli sleeve
e DD giù drasticamente su ETH (MR01 71%→26%, MR02 42%→25%, MR03 66%→34%,
MR07 46%→21%), edge OOS confermato (vedi `scripts/analysis/risk_management.py`).
Unica eccezione: MR03 BTC, dove il filtro peggiora entrambe → lasciato disattivo.
Leva non robusta scartate: vol-target sizing e skip-alta-volatilità (peggiorano).
**Loss-guard Hurst (ATTIVO LIVE, 2026-06-02).** Le fade accettano `hurst_max`: saltano i segnali in
regime PERSISTENTE/trending (rolling-Hurst ≥ soglia), dove si concentrano stop-loss e perdite
(diagnosi: stop-rate 43% per hurst>0.55 vs 21% anti-persistente; i peggiori 1% trade hanno hurst
medio 0.61). Helper `src.strategies.fade_base.hurst_skip_mask` (rolling-Hurst causale **dalle sole
close** → nessun feed esterno; step=6 per velocità live). **`hurst_max=0.55` attivo sulle 6 fade in
`_defs.py`**: il test decisivo a livello PORT06 lo conferma — **FULL DD 4.10%→2.39% (quasi dimezzato),
Sharpe 6.62→6.76, OOS Sharpe 8.89→9.15**. È l'UNICO meccanismo anti-perdite che supera il gate (ADX,
vol-expansion/vratio, efficiency-ratio, time-stop, vol-target FALLISCONO: tagliano i winner insieme ai
loser; i claim esterni ADX/ATR-ratio non si replicano su queste fade crypto). NB: il filtro agisce solo
sul path LIVE (`spec.params`); il backtest canonico (`build_everything`/regression-lock) NON è filtrato
→ il live farà MEGLIO del backtest sul DD. Ricerca: `scripts/analysis/fade_lossguard_workflow.js`,
diagnosi `fade_loss_by_regime.py`, diario `docs/diary/2026-06-02-fade-lossguard.md`.
**Effetto misurato (backtest):** stop-loss fade 67% in numero (1881→621), perdite totali 68%, coda
61%→−48% (lo stop-RATE per-trade scende poco, 42→38%: il guard lavora riducendo l'ESPOSIZIONE nel
regime tossico, non rendendo sicuro ogni trade). **Monitor live:** `hourly_report.py` traccia lo
stop-rate fade PRIMA/DOPO l'attivazione (14:34 UTC del 2026-06-02) e dà il verdetto su Telegram quando
il campione DOPO ≥30 (già confermato: stop-rate live PRIMA 42% == backtest 42.1%).
**Portafoglio.** Diversificare su sotto-conti indipendenti equipesati (le 4 strategie
× BTC/ETH, pos 0.15 ciascuno) abbatte il DD aggregato: ~14% full / ~10% OOS sul
paniere di 8 sleeve, contro il 20-70% del singolo. È la vera leva anti-drawdown.
**Combinare le due famiglie (fade + honest).** Le fade (reversione intraday 1h) e le
honest (DIP/TR/ROT trend+rotazione multi-crypto) sono **quasi scorrelate**
(correlazione cross-famiglia ~0.05). Combinarle in un unico portafoglio migliora il
rischio/rendimento rispetto a ciascuna famiglia da sola: equal-weight dei 9 sleeve
→ DD 5.2% full / 4.7% OOS e Sharpe 4.23 full / 4.33 OOS (vs honest-only 12.6% DD /
2.20 Sharpe e fade-only 8.2% DD / 4.09 Sharpe), CAGR ~47% mantenuta. Studio in
`scripts/analysis/combine_portfolio.py`.
**ROT02 — riduzione DD (top_k 2→3).** La rotazione dual-momentum honest concentrava
il book su 2 soli asset (DD 40%). Diversificare su 3 (`top_k=3`) dimezza quasi il DD
(40%→26%) e *alza* pure il ritorno full (+1095%→+1303%, ret/DD da 27 a 50); il
vol-target abbassa il DD ma sacrifica ritorno, quindi si tiene top_k=3 senza VT.
Applicato a `ROT02_dual_momentum.py` e a `_rot_daily_equity` (usata dai portafogli).
**Portafogli pronti (artefatti accorpati e migliorati).** Oltre a `PORT01` (solo
honest), due script in `scripts/strategies/`:
- `PORT02_fade_master.py` — le 3 fade × BTC/ETH accorpate (6 sleeve, filtro trend),
equal-weight daily: DD ~8.2% full / 5.9% OOS, Sharpe 3.95/4.09, CAGR ~46%.
- `PORT03_all_master.py` — portafoglio MASTER (fade + honest, 9 sleeve). Due varianti:
`equal` (massimo Sharpe: DD 5.2%/4.7% OOS, Sharpe 4.23/4.33) e `5050` fra le due
famiglie (minimo DD: 5.0% full / 4.5% OOS). È la configurazione consigliata.
Come `PORT01`, sono meta-portafogli (script `run()` di report), non `Strategy` con
`generate_signals`, quindi non nel `strategy_loader`.
**Esplorazione famiglie alternative (branch `strategy_explore`, 2026-05-29).** Esplorate
9 famiglie nuove con agenti paralleli su harness onesto condiviso
(`scripts/analysis/explore_lab.py`). 7 sono rumore (rifiutate: stagionalità oraria/mensile,
cross-sectional reversal, opening-range breakout, lead-lag BTC→alt, continuation intraday —
quest'ultima riconferma la dominanza mean-reversion). Due edge reali:
- **PR01 Pairs** (`scripts/strategies/PR01_pairs_reversion.py`): spread reversion
market-neutral sul log-ratio z-score, **config UNIVERSALE** `n=50 z_in=2.0 z_exit=0.75
max_bars=72` (anti-overfit, niente tuning per-coppia). **5 coppie robuste**: ETH/BTC
(Sharpe 4.36), LTC/ETH (3.08), ADA/ETH (2.69), BTC/LTC (2.36, robusta anche 4h), ETH/SOL
(1.96, la più debole). Pattern: sempre alt-liquido vs major. Plateau confermato
(heatmap 20/20 Sharpe>1) + walk-forward (ETH/BTC 11/12 finestre+). **BNB/ETH scartata**
(overfit). Corr col mercato ~0.02-0.08. Fee su **2 gambe**: worker live implementato
(`src/live/pairs_worker.py`, sezione `pairs:` in `strategies.yml`). LOGICA validata
(`validate_worker_pairs.py`: replay == backtest ESATTO). LIVE (`live_smoke_pairs.py`,
smoke reale Cerbero): **tutte e 5 le coppie con feed live fresco**. Naming Deribit:
BTC/ETH = `<COIN>-PERPETUAL` (inverse); alt = `<COIN>_USDC-PERPETUAL` (lineari USDC,
storia dal 2022). Trappola: `LTC-PERPETUAL`/`SOL-PERPETUAL` danno vuoto/dati errati →
usare sempre `_USDC-PERPETUAL`. Resta da verificare solo liquidità/fill in esecuzione.
Verifica edge: `pairs_research.py`.
- **TSM01** (`scripts/analysis/tsmom_research.py`): TSMOM multi-orizzonte 3/6/12m + risk-off,
**gross 0.30**, distinto da ROT02 (corr 0.62), DD 15-22%, mai un anno negativo. Robusto
(36/36 config OOS+) ma diversificatore, non motore di ritorno (rende meno di ROT02).
Aggiungere i **5 pairs** al MASTER (quasi scorrelati, ~0.02-0.09) è il free-lunch più
grande (`scripts/analysis/combine_v2.py`). **Numeri sobri onesti** (l'OOS singolo 2024-25
è regime calmo → ottimistico ~50%): worst-DD su 90g rolling **~6%** (non 2.3%), Sharpe
atteso **~5** (mediana semestrale), ogni anno positivo dal 2021, regge **leva 2x +
slippage doppio** (CAGR 36%, Sharpe 5.1). Config robusta raccomandata: **MASTER-esteso
equal-weight, leva 2x, cap pairs ~30-35%** (i pairs sono ~57% del rischio; worker live a
2 gambe implementato, validato e con feed live su tutte e 5 le coppie — resta da
verificare liquidità/fill in esecuzione reale). La confluenza multi-TF è stata SCARTATA (overfit).
**Pattern del segnale per FORMA (branch `shape_patterns`, 2026-05-29).** Esplorate 5 famiglie
di *shape forecasting* con agenti paralleli su harness onesto (`scripts/analysis/shape_lab.py`:
analog kNN causale, no-look-ahead verificato). **4/5 sono RUMORE** (riconfermano la dominanza
mean-reversion): analog kNN sulla forma grezza (solo BTC-overfit), encoding candele
UP/DOWN/DOJI+body/shadow (hit-rate ~50%), DTW+template geometrici (DTW *peggiora* l'euclidea;
template overfit), PIP/pivot/zig-zag (0/48 robuste). Vedi `scripts/analysis/shape_*_research.py`.
- **SH01 Shape-ML** (`scripts/strategies/SH01_shape_ml.py`): UNICO edge. Una LogisticRegression
legge 17 feature di forma (body/shadow, rendimenti, pendenza/curvatura, pos max/min, RSI,
estensione) e predice il segno del rendimento a H barre in **walk-forward** (scaler+modello
solo sul passato, no leakage). Config **W24 H12 th0.58**. A differenza dello squeeze
**regge fee 0.20% RT**. Win-rate ~50% → l'edge è nell'**asimmetria**, non nella frequenza.
Validazione (`scripts/analysis/shape_ml_validate.py`): BTC robusto OVUNQUE (expanding +219%/
OOS +42% Sharpe 2.72 8-9 anni; rolling 2y +166%/+96%; stress 2x+slippage OK), ETH/ADA
robusti solo expanding (secondari), LTC/SOL/XRP scartati. Griglia: **5/27 celle robuste su
cresta stretta W24/H8-12** → overfit moderato, scelta la config conservativa. **Valore vero:
diversificatore** (corr +0.08 col MASTER); aggiungerlo migliora l'OOS del MASTER (Sharpe
4.33→5.10, DD 4.7%→4.2%). NON motore standalone. **LIVE (2026-06-01): gira come StrategyWorker
reale** (vedi fix wiring sotto in SCOPE LIVE). Diario: `docs/diary/2026-05-29-shape.md`.
**ARGO / GEX opzioni (analisi 2026-06-01, SCARTATO).** Valutato ARGO (lettura del gamma-exposure dei
dealer Deribit) come filtro di regime. Esito **NO-GO**: il net-GEX si calcola live (Deribit mainnet
public API, OI reale ~368k contratti, DVOL/funding storici gratis) ma **lo storico per-strike dell'OI
non è gratuito → non backtestabile OOS** (stesso muro delle opzioni W18/19/21). Niente evidenza crypto,
segno fragile, mercato dominato dai perp. Diario `docs/diary/2026-06-01-argo-gex-feasibility.md`.
**Frattali del segnale × Regime ARGO (ricerca 100 agenti, 2026-06-02, RECORD).** Cercata una strategia
che combini un segnale frattale (Hurst/Higuchi/Williams/analog) con un gate di regime (DVOL/VRP/funding).
Infrastruttura riusabile: `scripts/analysis/regime_fetcher.py` (DVOL+funding da Deribit mainnet →
**`data/regime/`**, NON `data/raw/` che è solo OHLCV) e `regime_lab.py` (feature regime+frattali causali,
cache, harness netto-OOS). Esito: **15 strategie robuste e causali, ma NESSUNA batte/migliora il PORT06**
(diversificatori sovrapposti alle fade). **Finding: il prior ARGO "VRP>0=range=fade" è SMENTITO**
l'edge è su **VRP<0 + DVOL bassa**. Il vincitore `FR01_hurst_calm_fade.py` è robusto ma DILUISCE il
PORT06 (OOS Sharpe 8.89→8.72) → **non deployato** (in `scripts/strategies/` ma NON in MODULE_MAP/yml).
Il sottoprodotto utile è stato il **loss-guard Hurst** (vedi sopra), che invece MIGLIORA il PORT06.
Diario `docs/diary/2026-06-02-fractal-argo-search.md`.
**Metodologia obbligatoria per ogni nuova strategia** (per non ripetere l'errore squeeze):
1. Ingresso eseguibile: direzione e prezzo decisi con dati **fino a `close[i]`**, mai `close[i-1]` con direzione da `i`.
2. Backtest **NETTO** dopo fee realistiche Deribit (**0.10% RT** taker, non 0.20%) + leva.
3. Validazione **out-of-sample** (held-out) + robustezza su griglia parametri + sweep fee.
4. Crea script in `scripts/strategies/`, aggiungi a `MODULE_MAP` (`strategy_loader.py`) e a `strategies.yml`.
Strategie scartate storiche in `scripts/waste/` (W01-W28 + la famiglia squeeze).
**Verso €50/giorno.** Con 4 strategie indipendenti (MR01/MR02/MR03/MR07) × 2 asset
(BTC/ETH) su €1000 ciascuna, il PnL medio storico aggregato è ben oltre €50/giorno;
ma quei numeri sono backtest a leva 3x su 8 anni e includono anni eccezionali (es.
ETH 2024). Stima onesta: il target è *plausibile* su un portafoglio diversificato di
queste fade, ma va confermato col paper trader live prima di rischiare capitale reale.
## Portafogli
- Un `Portfolio` è un oggetto di prima classe (`src/portfolio/`) con definizione (sleeve + schema pesi) e due facce sulla **STESSA** definizione: `.backtest()` (riusa il builder unico di `sleeves.py` → parità esatta con `report_families`) e live (`PortfolioRunner`: capitale pool condiviso, sizing per peso, ribilancio giornaliero, ledger aggregato in `data/portfolios/{code}/`).
- **Schemi peso:** `equal` (default), `cap` (tetto per famiglia, es. pairs 33% — config raccomandata), `inverse_vol`, `cluster_rp` (equal fra cluster naturali poi inverse-vol intra-cluster), `manual`. Definiti in `weighting.py`; la chiave cap è la famiglia (PAIRS/FADE/HONEST/SHAPE/TSM).
- **Default `portfolios.yml`:** PORT06 (master+shape), `weighting=cap pairs 0.33`, leva 2x, ribilancio 1D. Backtest PORT06: FULL Sharpe 6.07 / OOS Sharpe 8.19, DD 4.9% full / 2.3% OOS.
- **Data layer Cerbero v2:** `get_historical_v2` unificato + `get_instruments` (naming robusto) + `get_ticker_batch`. Trading su Deribit.
- **SCOPE LIVE (fase 2 completata):** il runner esegue TUTTI gli sleeve di PORT06. Worker: single `StrategyWorker` (fade MR01/02/07, DIP01, **e SH01**), `PairsWorker` (PR01 2 gambe), e i multi-asset dedicati `BasketTrendWorker` (TR01 4h), `RotationWorker` (ROT02 1d), `TsmomWorker` (TSM01 1d). Il runner fetcha 1h da Cerbero v2 e **resampla a 4h/1d** (lookback dimensionato sui daily: TSM01 usa 252g). Validazione: runner pool/ribilancio/ledger == backtest (`validate_portfolio_runner.py`, identico); worker multi-asset == reference (`validate_honest_workers.py`: TSM01 esatto, ROT02 +1303% canonico, TR01 stesso ordine — differenza di convenzione capitale-unico vs media-equity).
- **FIX SH01 wiring (2026-06-01).** SH01 gira come **`StrategyWorker` NORMALE** (NON il vecchio `MLWorkerWrapper` di `multi_runner`, che usava il `SignalEngine` **squeeze SCARTATO**: apriva senza metadata ed usciva a `hold_bars=3`, ignorando del tutto SH01_shape_ml). `SH01_shape_ml.generate_signals` fa il walk-forward (retraining) internamente ad ogni tick ed emette `metadata.max_bars=12` → exit a orizzonte via `StrategyWorker.tick`. Serve ≥4000 barre 1h (`_ML_LOOKBACK_DAYS=365`). Vedi `docs/diary/2026-06-01-sh01-wiring-squeeze-bug.md`.
- **Altri fix StrategyWorker (2026-06-01).** Exit a orizzonte puro per strategie senza TP/SL (`elif self.max_bars`, SH01 esce a H=12 non hold_bars=3); `is_win = net > 0` (win NETTO fee, non lordo); filtro `min_tp_frac` (salta micro-scalp col TP entro le fee); loss-guard `hurst_max=0.55` sulle fade (vedi sopra).
- **Exit intrabar (fase 3, risolto):** lo `StrategyWorker` ora esce sui TP/SL toccati INTRABAR (high/low della barra, al livello, SL prioritario) come il backtest — non più solo sul close. Allinea fade/DIP01 live al backtest intrabar (`tests/portfolio/test_intrabar_exit.py`). Caveat residuo onesto: nel paper trading l'high/low usato è quello della barra in corso al poll; su un fill reale conterebbe il momento del tocco.
- **Limite noto:** al ribilancio le posizioni APERTE restano sul loro notional (non travasate); fedele al backtest daily-rebalanced entro il turnover infragiornaliero.
## Multi-Strategy Paper Trader
Orchestratore che esegue N strategie in parallelo su dati live Cerbero, ognuna con €1000 USDC virtuali indipendenti.
**Config:** `strategies.yml` — due sezioni: `strategies` (single-leg: fade/honest) e
`pairs` (a 2 gambe). Attive: 6 fade (MR01/MR02/MR07 × BTC/ETH) + 5 coppie PR01.
**Due worker:** `strategy_worker.py` (single-leg) e `pairs_worker.py` (2 gambe,
long A / short B sullo z-score del log-ratio, fee su 2 gambe).
**Persistenza:** `data/paper_trades/{worker_id}/` con `trades.jsonl` (append-only) + `status.json` (resume al restart).
**Hot-add:** aggiungi riga YAML → `docker compose restart` → storico intatto.
**Exit strategia:** se un `Signal` porta `tp`/`sl`/`max_bars` in `metadata` (come le fade), il worker esce su take-profit/stop-loss/time-limit; i pairs escono su |z|≤z_exit o max_bars.
**Naming Deribit (feed live):** major = `<COIN>-PERPETUAL` (inverse); alt = `<COIN>_USDC-PERPETUAL` (lineari USDC). Vedi INSTRUMENT_MAP in `multi_runner.py`.
**Notifiche:** Telegram per ogni trade (richiede `.env` con `TELEGRAM_BOT_TOKEN` e `TELEGRAM_CHAT_ID`).
## Convenzioni
- Script numerati progressivamente (`01_`, `02_`, …). Ogni script è autocontenuto.
- Strategie in `scripts/strategies/` con codice univoco (MR01, ...).
- Script scartati in `scripts/waste/` (W01-W28 + famiglia squeeze).
- Diario in `docs/diary/YYYY-MM-DD.md`. Aggiornare dopo ogni esperimento significativo.
- Nessun dato sensibile nei commit (token, chiavi API). Usare `.gitignore`.
- Verificare sempre assenza di data leakage prima di fidarsi dei risultati. In particolare: `returns[i-w : i]` include `close[i]` che è un candle nel futuro — usare `returns[i-w : i-1]`.
@@ -70,5 +336,8 @@ Risultato backtestato: 76.9% accuracy, 118% annuo, 4.2% drawdown, €13.78/giorn
## Attenzione
- **Data leakage:** è stata trovata e corretta nello script 05. Ogni volta che si usano rendimenti logaritmici (`np.diff(np.log(close))`), ricordare che `returns[k]` usa `close[k+1]`. I feature devono fermarsi a `returns[i-2]` se il prezzo corrente è `close[i-1]`.
- **Fee:** sempre 0.1% per lato (0.2% round-trip). Includere nel backtest.
- **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.
- **GBM:** GradientBoostingClassifier di scikit-learn. Ensemble di alberi decisionali sequenziali. Walk-forward per evitare leakage temporale.
- **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.
+5 -1
View File
@@ -8,7 +8,11 @@ COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
COPY src/ src/
COPY scripts/ scripts/
COPY strategies.yml portfolios.yml VERSION ./
VOLUME /app/data
CMD ["uv", "run", "python", "-m", "src.live.paper_trader"]
# Default: paper trader multi-strategia. Il servizio "portfolio" in docker-compose
# sovrascrive il command per il runner a portafoglio (src.portfolio.runner).
CMD ["uv", "run", "python", "-m", "src.live.multi_runner"]
+335 -63
View File
@@ -4,111 +4,383 @@ Sistema di riconoscimento pattern frattali e predizione per il trading di cripto
## Obiettivo
Partendo da un capitale iniziale di €1.000, raggiungere un profitto medio di €50 al giorno entro 68 mesi, tramite strategie algoritmiche che combinano analisi frattale, squeeze di volatilità e machine learning.
Partendo da un capitale iniziale di €1.000, raggiungere un profitto medio di €50 al giorno entro 68 mesi, tramite un portafoglio di strategie algoritmiche poco correlate fra loro — mean-reversion, trend/rotazione e spread market-neutral — validate out-of-sample e fee-aware.
## Risultati
Tredici strategie testate su dati storici 20182026 (BTC e ETH, timeframe 5m / 15m / 1h). Le migliori cinque:
> ⚠️ **Revisione 2026-05-28.** La famiglia squeeze-breakout (SQ/MT/ML/AD/CM/PD, con
> accuracy storiche dichiarate 76-82%) è stata **scartata**: quei numeri erano un
> **artefatto di look-ahead**. I backtest decidevano la direzione dalla candela di
> breakout `close[i]` ma entravano a `close[i-1]` — impossibile dal vivo. Sotto
> ingresso onesto (`close[i]`) e fee reali, l'edge sparisce e tutte perdono, anche
> a fee zero. Dettagli e prove: `scripts/analysis/oos_validation.py`.
| # | Strategia | Accuracy | ROI annuo | Max DD | €/giorno |
|---|-----------|----------|-----------|--------|----------|
| 1 | ETH 15m Squeeze + ML ibrida | 76.9% | 118% | 4.2% | €13.78 |
| 2 | ETH 1h Squeeze + Vol | 83.9% | 22% | 2.0% | €0.71 |
| 3 | BTC 15m Squeeze + ML ibrida | 78.8% | 69% | 7.0% | €5.51 |
| 4 | ETH 1h Squeeze (BBw=30) | 82.8% | 47% | 3.2% | €1.77 |
| 5 | ETH Walk-Forward ML | 57.7% | 38% | 47% | €3.12 |
Dopo una validazione **out-of-sample, fee-aware** di molte famiglie di strategie,
emergono quattro famiglie con edge netto reale, tutte radicate nella stessa lezione
(in cripto la **mean-reversion** funziona, la continuazione no) o nella diversificazione:
La strategia vincente (#1) opera su ETH a 15 minuti con ~1 trade al giorno, leva 3x e drawdown contenuto al 4.2%.
| Famiglia | Meccanismo | Strategie | Profilo (netto OOS) |
|----------|-----------|-----------|---------------------|
| **FADE** | mean-reversion intraday 1h (long/short, BTC/ETH) | MR01 Bollinger, MR02 Donchian, MR07 Return-reversal | Acc 52-55%, DD 18-34% |
| **HONEST** | long-only multi-regime multi-crypto | DIP01 dip-buy, TR01 EMA-trend, ROT02 dual-momentum | CAGR 31-56%, DD 15-27% |
| **PAIRS** | spread reversion *market-neutral* (2 gambe) | PR01 ETH/BTC, LTC/ETH, ADA/ETH, BTC/LTC, ETH/SOL | Sharpe 2.0-4.4, corr col mercato ~0.05 |
| **TSMOM** | time-series momentum multi-orizzonte | TSM01 (3/6/12m + risk-off) | diversificatore, DD 15-22% |
Tutti i numeri sono **netti** dopo fee realistiche (Deribit 0.10% RT single-leg, 0.20%
RT/coppia sui pairs), leva 3x, su finestra held-out. Le strategie sono robuste su griglia
parametri, sweep fee 0.00-0.20% RT e — per i pairs — validate con **walk-forward** e
config universale (niente cherry-picking).
### Portafoglio combinato (la vera leva anti-drawdown)
Le famiglie sono **quasi scorrelate fra loro** (~0.05). Combinandole in un unico
portafoglio equipesato il drawdown crolla sotto quello di ogni singola sleeve:
| Portafoglio | CAGR | Max DD | Sharpe |
|-------------|------|--------|--------|
| FADE (6 sleeve) | ~46% | 8% | 3.9 |
| HONEST (3 sleeve) | ~46% | 13% | 2.2 |
| **MASTER** (FADE + HONEST, 9) | ~47% | **5%** | 4.2 |
| **MASTER + PAIRS + TSM01** (15) | ~67% | ~5% | ~6 |
> 🔎 **Numeri sobri (anti-overfit).** L'OOS singolo cade nel regime favorevole 2024-25:
> i valori di Sharpe/DD sopra sono ottimistici di circa il 50%. Da pianificare per le
> decisioni: **Sharpe atteso ~5**, **worst-drawdown su 90 giorni ~6%**, profilo che regge
> a leva 2x con slippage raddoppiato. Configurazione raccomandata: equal-weight, leva 2x,
> con un cap sull'allocazione ai pairs (~30-35%, poiché concentrano ~57% del rischio).
> Tutto resta da confermare nel paper trading live.
## Come funziona
### Volatility Squeeze Breakout
### MR01 — Bollinger Fade (mean-reversion)
Il meccanismo centrale sfrutta i cicli naturali di compressione ed espansione della volatilità:
La strategia attiva sfrutta il fatto, emerso dai dati, che su BTC/ETH a 1h gli estremi
di prezzo **rientrano verso la media** più di quanto proseguano:
1. **Compressione** — le Bollinger Bands entrano dentro i Keltner Channel (il prezzo si muove sempre meno, accumulando "energia").
2. **Breakout** — le bande escono dal canale. Un impulso direzionale parte.
3. **Conferma ML** — un modello GradientBoosting, addestrato su feature strutturali e frattali della finestra precedente, conferma la direzione e filtra i segnali deboli.
1. **Bollinger Bands** (window `n`, `k` deviazioni standard) sul close.
2. **Entry** — quando il close esce *sotto* la banda inferiore → **long** (o *sopra* la superiore → **short**). Ingresso a `close[i]`, eseguibile dal vivo.
3. **Take-profit** alla media mobile (il rientro atteso).
4. **Stop-loss** a `sl_atr × ATR` oltre l'estremo; **time-limit** a `max_bars`.
### Feature frattali
Nessun look-ahead: direzione e livelli sono calcolati con dati fino a `close[i]`.
- Rapporti body/shadow normalizzati su finestre multiple (12, 24, 48 candele)
- Momentum, volatilità, skewness, kurtosis dei rendimenti logaritmici
- Autocorrelazione lag-1
- Profilo volumetrico e spike detection
- Durata della fase di squeeze e rapporto di espansione Keltner
- Posizione del prezzo rispetto al range recente e ATR normalizzato
### Le altre famiglie
- **FADE** (oltre MR01): MR02 fada la rottura del canale Donchian verso il centro;
MR07 fada il movimento di barra estremo misurato in deviazioni standard dei
rendimenti. Stessa logica di reversione, indicatori indipendenti.
- **HONEST** (long-only, multi-crypto): DIP01 compra i dip estremi e rivende al
recupero; TR01 segue il trend con incrocio di EMA su un paniere; ROT02 ruota ogni
giorno sui tre asset col momentum più forte, andando in cash quando BTC è sotto la
sua media (risk-off). Coprono i regimi di trend e rotazione, complementari alle fade.
- **PAIRS** (market-neutral): scommette sul rientro verso la media del log-ratio fra
due cripto (z-score). Long su una, short sull'altra: l'esposizione netta al mercato è
quasi nulla (correlazione ~0.02), il che la rende un diversificatore eccellente.
- **TSMOM**: tiene gli asset con momentum positivo persistente su più orizzonti
(3/6/12 mesi), con overlay risk-off. Rende meno ma è poco correlato, utile in ensemble.
### Perché lo squeeze breakout è stato abbandonato
L'ipotesi originale era opposta — *continuazione* dopo la compressione di volatilità
(Bollinger dentro Keltner → breakout direzionale). Su dati storici sembrava dare
76-82% di accuracy, ma era un **artefatto di look-ahead**: il backtest entrava a
`close[i-1]` con direzione decisa da `close[i]`. Replicando l'esecuzione reale
(ingresso a `close[i]`) l'edge collassa al ~47% (lancio di moneta) e i costi fanno
il resto. Il test sui breakout intra-barra a 5m conferma che il movimento *rientra*
subito (mean-reversion), giustificando MR01. Tutta la famiglia squeeze è in `scripts/waste/`.
### Lezione metodologica
Ogni nuova strategia deve passare: (1) **ingresso eseguibile** senza look-ahead,
(2) backtest **netto** dopo fee realistiche (0.10% RT Deribit), (3) validazione
**out-of-sample** + robustezza su griglia parametri + sweep fee. Strumenti in
`scripts/analysis/` (`strategy_research.py`, `oos_validation.py`, `intrabar_test.py`).
## Struttura progetto
```
PythagorasGoal/
├── src/
│ ├── data/ # Download e gestione dati storici (Cerbero MCP + Binance)
│ ├── fractal/ # Indicatori frattali: Hurst, Higuchi FD, self-similarity
│ ├── backtest/ # Motore di backtesting con fee e metriche
│ ├── strategies/ # (predisposto per strategie modulari)
├── nn/ # (predisposto per reti neurali)
│ └── utils/
├── scripts/ # Script di analisi e test (0113)
│ ├── data/ # Download e gestione dati (Cerbero MCP + Binance)
│ ├── fractal/ # Indicatori frattali: Hurst, Higuchi FD, self-similarity
│ ├── backtest/ # Motore di backtesting con fee e metriche
│ ├── strategies/ # Classe base Strategy ABC + indicatori condivisi
│ ├── base.py # Strategy, Signal, BacktestResult, YearlyStats
│ └── indicators.py # keltner_ratio, detect_squeezes, ema, atr, rv, corr
│ ├── live/ # Paper trading live su Deribit testnet
│ │ ├── multi_runner.py # Orchestratore multi-strategia (strategie + pairs)
│ │ ├── strategy_worker.py # Worker single-leg con stato persistente
│ │ ├── pairs_worker.py # Worker a 2 gambe per i pairs (market-neutral)
│ │ ├── strategy_loader.py # Import dinamico classi Strategy
│ │ ├── cerbero_client.py # Client HTTP per Cerbero MCP
│ │ ├── signal_engine.py # Squeeze + ML real-time (legacy) + validazione OOS
│ │ └── telegram_notifier.py
│ └── portfolio/ # Portafogli di prima classe (capitale condiviso, backtest + live)
│ ├── base.py # SleeveSpec, Portfolio (.backtest), load_active_portfolio
│ ├── weighting.py # Schemi di ponderazione: equal, cap, inverse_vol, cluster_rp, manual
│ ├── sleeves.py # Builder unificato equity-per-sleeve (fonte unica, parità report)
│ ├── ledger.py # PortfolioLedger: PnL/DD aggregati, persistenza e resume
│ └── runner.py # PortfolioRunner live (Cerbero v2, sizing, ribilancio giornaliero)
├── scripts/
│ ├── strategies/ # Strategie con edge validato OOS (FADE, HONEST, PAIRS, TSMOM + portafogli)
│ ├── portfolios/ # Definizioni PORT01-06 e report run() dei portafogli di prima classe
│ ├── waste/ # Strategie scartate (squeeze SQ/MT/ML/AD/CM/PD, MR03, ROT01, W01-W28)
│ └── analysis/ # Ricerca/validazione OOS fee-aware, gestione rischio, report
├── strategies.yml # Config multi-strategy paper trader
├── data/
│ ├── raw/ # Parquet OHLCV (non committati, ~70 MB)
│ └── processed/ # Modelli salvati
│ ├── raw/ # Parquet OHLCV (gitignored, ~70 MB)
│ └── regime/ # DVOL + funding (Deribit mainnet) + cache feature regime (gitignored)
├── VERSION # versione semver (cotta nell'immagine, mostrata nei msg Telegram)
├── docs/
── diary/ # Diario di ricerca giornaliero
├── tests/
├── pyproject.toml
── README.md
── diary/ # Diario di ricerca giornaliero
│ └── specs/ # Specifiche di design
├── Dockerfile
── docker-compose.yml
└── pyproject.toml
```
## Strategie attive
Le strategie single-asset estendono `src.strategies.base.Strategy`
(`generate_signals() → backtest()`); i pairs hanno un worker dedicato a 2 gambe.
| Codice | Script | Famiglia | Descrizione |
|--------|--------|----------|-------------|
| **MR01** | `MR01_bollinger_fade.py` | FADE | Fada la banda di Bollinger, TP alla media, SL ad ATR |
| **MR02** | `MR02_donchian_fade.py` | FADE | Fada la rottura del canale Donchian, TP al centro |
| **MR07** | `MR07_return_reversal.py` | FADE | Fada il movimento di barra estremo (z dei rendimenti) |
| **DIP01** | `DIP01_dip_reversion.py` | HONEST | Dip-buy long-only su z-score estremo |
| **TR01** | `TR01_ema_trend.py` | HONEST | EMA 20/100 trend-following su paniere cripto (4h) |
| **ROT02** | `ROT02_dual_momentum.py` | HONEST | Rotazione cross-sectional top-3 + risk-off (1d) |
| **PR01** | `PR01_pairs_reversion.py` | PAIRS | Spread reversion market-neutral su 5 coppie |
| **TSM01** | `tsmom_research.py` | TSMOM | Time-series momentum multi-orizzonte + risk-off |
Le fade applicano due filtri di regime opzionali: un **filtro trend** (`trend_max`/`ema_long`,
salta i segnali col prezzo troppo esteso rispetto alla EMA200) e un **loss-guard Hurst**
(`hurst_max=0.55`, salta i segnali in regime persistente/trending dove si concentrano gli stop-loss
— dimezza il drawdown del portafoglio, calcolato dalle sole close). Più un filtro `min_tp_frac`
che scarta i micro-scalp col take-profit entro il costo delle fee. Portafogli pronti: `PORT01`
(honest), `PORT02` (fade), `PORT03` (master fade+honest), **`PORT06`** (master esteso, default live).
**Scartate** (in `scripts/waste/`): la famiglia squeeze (SQ01-04, ML01, MT01, PD01,
CM01, AD01 — artefatto di look-ahead), MR03 Keltner (debole/ridondante con MR01) e
ROT01 (dominata da ROT02).
### Comandi utili
```bash
# Backtest di una strategia
uv run python scripts/strategies/MR01_bollinger_fade.py
uv run python scripts/strategies/PR01_pairs_reversion.py
# Ricerca e validazione fee-aware out-of-sample
uv run python scripts/analysis/strategy_research.py # screening famiglie + deep-dive fade
uv run python scripts/analysis/strategy_research_v2.py # MR02 / MR03 / MR07
uv run python scripts/analysis/oos_validation.py # perche' la famiglia squeeze e' scartata
uv run python scripts/analysis/pairs_research.py # ricerca + verifica no-look-ahead dei pairs
# Gestione rischio, combinazione, report
uv run python scripts/analysis/risk_management.py # filtro trend + portafoglio fade
uv run python scripts/analysis/combine_portfolio.py # combinare fade + honest
uv run python scripts/analysis/combine_v2.py # master esteso con pairs + TSM01
uv run python scripts/analysis/report_families.py # report per anno di tutte le famiglie
# Validazione dei worker live (replay == backtest)
uv run python scripts/analysis/validate_worker_mr01.py # worker single-leg su MR01
uv run python scripts/analysis/validate_worker_pairs.py # worker a 2 gambe sui pairs
uv run python scripts/analysis/live_smoke_pairs.py # smoke test feed live reale dei pairs
```
## Paper Trading Live
Il multi-strategy runner esegue N strategie in parallelo su dati live da Cerbero MCP,
ognuna con €1000 USDC virtuali indipendenti. Gestisce due tipi di worker:
- **Single-leg** (`strategy_worker.py`): per le strategie direzionali. Se un `Signal`
porta `tp`/`sl`/`max_bars` in `metadata` (come le fade), chiude su take-profit /
stop-loss / time-limit; altrimenti usa il fallback `hold_bars`/stop -2%.
- **Due gambe** (`pairs_worker.py`): per i pairs market-neutral. Apre long su una gamba
e short sull'altra, esce sul rientro dello z-score o per time-limit, conta le fee su
entrambe le gambe. Validato: il replay storico coincide *esattamente* col backtest.
### Avvio
```bash
# Locale
uv run python -m src.live.multi_runner
# Docker
docker compose up -d
```
### Configurazione
Le strategie attive sono definite in `strategies.yml`:
```yaml
defaults:
capital: 1000
position_size: 0.15
leverage: 3
strategies: # strategie single-leg
- name: MR01_bollinger_fade
asset: BTC
tf: 1h
enabled: true
params: { bb_window: 50, k: 2.5, sl_atr: 2.0, max_bars: 24, trend_max: 3.0, ema_long: 200 }
pairs: # strategie a 2 gambe (market-neutral)
- name: PR01_pairs_reversion
a: ETH
b: BTC
tf: 1h
enabled: true
params: { n: 50, z_in: 2.0, z_exit: 0.75, max_bars: 72, jump_max: 0.08 }
```
Per aggiungere una strategia: nuova riga in `strategies.yml` (sezione `strategies` o
`pairs`), poi `docker compose restart`. Lo storico delle strategie esistenti rimane intatto.
### Persistenza
Ogni strategia ha la sua directory in `data/paper_trades/`:
```
data/paper_trades/
MR01_bollinger_fade__BTC__1h/
trades.jsonl # Storico trade append-only
status.json # Stato corrente (resume al restart, include tp/sl/max_bars)
```
Notifiche Telegram per ogni trade (richiede `TELEGRAM_BOT_TOKEN` e `TELEGRAM_CHAT_ID` in `.env`).
## Paper Trading a Portafoglio
Accanto al multi-strategy runner originale — in cui ogni strategia gestisce autonomamente il proprio conto virtuale da €1.000 — il progetto dispone ora di un **paper trader a portafoglio** (`src/portfolio/`) che tratta l'insieme delle strategie come un unico organismo con un capitale condiviso.
### Come funziona
La definizione di un portafoglio (`SleeveSpec` + schema di peso) ha due facce sulla stessa sorgente dati:
- **Backtest** (`.backtest()`): ricostruisce le equity-curve di ogni sleeve tramite il builder unificato in `sleeves.py`, le pondera secondo lo schema scelto e calcola le metriche aggregate (CAGR, Sharpe, max DD). La parità con i report prodotti da `report_families.py` è garantita dalla fonte unica.
- **Live** (`PortfolioRunner`): ogni ora il runner scarica le candele aggiornate via Cerbero v2, calcola i pesi correnti, avvia i worker appropriati per ogni sleeve attiva e registra il PnL aggregato nel ledger (`data/portfolios/{code}/`). Il ledger persiste tra i riavvii.
### Schemi di ponderazione
Il modulo `weighting.py` mette a disposizione cinque schemi: `equal` (default), `cap` (tetto per famiglia — p.es. `pairs: 0.33` per limitare la concentrazione), `inverse_vol` (pesi inversamente proporzionali alla volatilità storica), `cluster_rp` (equal tra cluster naturali poi inverse-vol all'interno del cluster) e `manual` (pesi liberi). Lo schema si specifica in `portfolios.yml` insieme al codice portafoglio e alla leva.
### Portafoglio di default: PORT06
La configurazione raccomandata è **PORT06** (`scripts/portfolios/PORT06_master_shape.py`): portafoglio master esteso che include tutte e sei le famiglie (FADE, HONEST, PAIRS, TSMOM, SHAPE), con schema `cap` che limita i pairs al 33% del capitale per moderare la loro concentrazione di rischio. Risultati del backtest: Sharpe 6.07 (FULL) / 8.19 (OOS), drawdown massimo 4.9% (FULL) / 2.3% (OOS), leva 2×.
### Scope live
Il runner esegue **tutti e 17 gli sleeve** di PORT06: **fade** (MR01, MR02, MR07 × BTC/ETH),
**honest** (DIP01, TR01-basket 4h, ROT02-rotation 1d), **pairs** (PR01, cinque coppie),
**TSMOM** (TSM01 1d) e **shape** (SH01 × BTC/ETH). Worker dedicati: `StrategyWorker` (single-leg, fade/
dip/**shape**), `PairsWorker` (2 gambe), `BasketTrendWorker`, `RotationWorker`, `TsmomWorker`. Il runner
fetcha 1h da Cerbero v2 e resampla a 4h/1d; il pool di capitale, il ribilancio giornaliero e il ledger
sono validati == backtest.
> **SH01 (2026-06-01):** gira come `StrategyWorker` normale (il walk-forward è interno a
> `generate_signals`). Il vecchio `MLWorkerWrapper` usava il `SignalEngine` **squeeze scartato** —
> rimosso. **Loss-guard Hurst (2026-06-02):** le fade saltano i segnali in regime persistente
> (rolling-Hurst ≥ 0.55), dove si concentrano gli stop-loss — dimezza il drawdown del portafoglio
> (FULL 4.1%→2.4%; stop-loss fade 67% in numero, perdite totali 68%). Calcolato dalle sole close,
> attivo live (`hurst_max` nei params). Il report orario su Telegram **monitora lo stop-rate fade
> prima/dopo l'attivazione** e dà il verdetto automatico quando il campione è sufficiente.
### Versione & deploy
Ogni deploy ha una **versione** (file `VERSION`, semver) che compare nei messaggi Telegram (notifiche
trade + report orario), così correli ogni messaggio al codice che l'ha generato. Il sorgente è **cotto
nell'immagine** → per aggiornare il live serve un **rebuild**, non un semplice restart:
```bash
./scripts/deploy.sh # bump patch (1.0.0 → 1.0.1) + commit + rebuild + ricrea container
./scripts/deploy.sh minor # 1.0.x → 1.1.0
```
Il volume `data/` persiste tra i deploy → i worker fanno RESUME dello stato (capitale, posizioni aperte).
### Avvio del paper trader a portafoglio
```bash
# Backtest del portafoglio di default (PORT06)
uv run python scripts/portfolios/PORT06_master_shape.py
# Paper trading live a portafoglio
uv run python -m src.portfolio.runner
# Report orario su Telegram (stato + stop-rate fade prima/dopo loss-guard) — via cron
uv run python scripts/portfolios/hourly_report.py
# Smoke test del data layer Cerbero v2
uv run python scripts/analysis/smoke_portfolio.py
```
## Setup
```bash
# Clona il repository
# Clona e installa
git clone <repo-url> && cd PythagorasGoal
# Installa dipendenze (richiede uv)
uv sync
# Scarica dati storici (~70 MB, richiede connessione)
# Scarica dati storici (~70 MB)
uv run python -m src.data.downloader
# Esegui la strategia ibrida vincente
uv run python scripts/13_squeeze_ml_hybrid.py
# Backtest strategia attiva
uv run python scripts/strategies/MR01_bollinger_fade.py
# Paper trading live
uv run python -m src.live.multi_runner
```
### Requisiti
- Python ≥ 3.11
- [uv](https://docs.astral.sh/uv/) come package manager
- Accesso a Cerbero MCP (`cerbero-mcp.tielogic.xyz`) per i dati Deribit, oppure Binance via ccxt come fallback
- Accesso a Cerbero MCP (`cerbero-mcp.tielogic.xyz`) per dati Deribit live
- Docker (opzionale, per deploy su VPS)
## Dati
| Asset | Timeframe | Candele | Copertura |
|-------|-----------|---------|-----------|
| BTC | 5m / 15m / 1h | 883K / 294K / 74K | 2018-01 → oggi |
| ETH | 5m / 15m / 1h | 882K / 294K / 74K | 2018-01 → oggi |
| Asset | Timeframe | Copertura |
|-------|-----------|-----------|
| BTC, ETH | 5m / 15m / 1h | 2018-01 → oggi |
| SOL, LTC, ADA, XRP, BNB, DOGE | 15m / 1h | 2019-2022 → oggi (variabile per asset) |
Fonte primaria: Deribit perpetual via Cerbero MCP. Fallback per il periodo antecedente: Binance spot via ccxt. Formato: Apache Parquet.
Fonte primaria: perpetual Deribit via Cerbero MCP. Fallback: Binance spot via ccxt.
Formato: Apache Parquet (in `data/raw/`, gitignored).
## Strategie testate
> **Nota sul naming Deribit (per il feed live).** I major sono perpetui *inverse*
> (`BTC-PERPETUAL`, `ETH-PERPETUAL`); gli altcoin sono perpetui *lineari USDC*
> (`SOL_USDC-PERPETUAL`, `LTC_USDC-PERPETUAL`, …) con storia dal 2022. Attenzione:
> `LTC-PERPETUAL`/`ADA-PERPETUAL` non esistono e `SOL-PERPETUAL` restituisce dati
> errati — per gli altcoin usare sempre la forma `_USDC-PERPETUAL`.
| Script | Approccio | Esito |
|--------|-----------|-------|
| 01 | Pattern candlestick discreti (U/D/0) | Nessun edge |
| 02 | DTW pattern matching | Troppo lento, edge minimo |
| 03 | Proiezione FFT (ispirata al paper) | Random (49.8%) |
| 04 | GBM su feature frattali (Hurst, FD) | 63.6% a soglia 0.65 |
| 05 | GBM multi-window (corretto data leakage) | 58.9% |
| 06 | GBM su feature strutturali normalizzate | 58.6%, +57.5% return |
| 07 | LSTM su sequenze candele | 58.4%, comparabile a GBM |
| 08 | Ensemble multi-timeframe (1h + 15m) | 59.2% (consensus 2/3) |
| 09 | Walk-forward ML | 57.7%, Sharpe 7.4, €3.12/day |
| 10 | Ensemble 5 modelli alta precisione | In corso |
| 11 | **Volatility Squeeze Breakout** | **83.9%**, approccio strutturale |
| 12 | Report finale e simulazione crescita | — |
| 13 | **Squeeze + ML ibrida** | **76.9%**, 118% ann, €13.78/day |
### Discovery & validazione strumenti
`src/data/instruments.py` scopre e **valida** gli strumenti disponibili sugli
exchange implementati — **Deribit** e **Hyperliquid** (esclusi Alpaca/stocks e
**Bybit**, feed testnet inaffidabile). Ogni perpetuo viene testato sui dati
storici realmente raccoglibili: esistenza, congruenza OHLC, contratto non-morto,
liquidità e **congruenza prezzo cross-exchange** (mediana per base-coin, tolleranza
5%) — così feed farlocchi e contratti sbagliati (es. `SOL-PERPETUAL`=9.6) vengono
scartati. Il risultato è `data/instruments_registry.json` (strumenti validi +
timeframe + data d'inizio).
**Solo gli strumenti validati possono essere scaricati**: il downloader ha un gate
(`_download_cerbero_range`) che rifiuta quelli non nel registry. Rigenera con:
```bash
uv run python -m src.data.instruments
```
Simboli Deribit: BTC/ETH = `<COIN>-PERPETUAL` (inverse); altcoin =
`<COIN>_USDC-PERPETUAL` (lineari USDC). Registry attuale (testnet): Deribit 18/106
validi (major liquidi, BTC dal 2018), Hyperliquid 66/74.
## Riferimenti
+1
View File
@@ -0,0 +1 @@
1.0.0
File diff suppressed because it is too large Load Diff
+7 -3
View File
@@ -1,14 +1,18 @@
services:
paper-trader:
portfolio:
build: .
container_name: pythagoras-paper
container_name: pythagoras-portfolio
restart: unless-stopped
command: ["uv", "run", "python", "-m", "src.portfolio.runner"]
volumes:
- ./data:/app/data
- ./portfolios.yml:/app/portfolios.yml:ro
env_file:
- .env
environment:
- PYTHONUNBUFFERED=1
healthcheck:
test: ["CMD", "python", "-c", "import json; s=json.load(open('/app/data/paper_trades/status.json')); assert s['last_update']"]
test: ["CMD", "python", "-c", "import os; assert any(f.endswith('status.json') for r,d,fs in os.walk('/app/data/portfolios') for f in fs)"]
interval: 120s
timeout: 10s
retries: 3
+136
View File
@@ -0,0 +1,136 @@
# 2026-05-28 — Ricerca onesta di nuove strategie (post-squeeze)
## Contesto e mandato
Dopo aver scoperto che l'intera famiglia squeeze-breakout era un artefatto di
look-ahead (accuratezze 76-82% svanite sotto ingresso eseguibile), il mandato è
stato: trovare in modo **onesto** almeno 3 strategie attendibili, testate su ~8
anni e su più criptovalute, con le fee incluse nella valutazione, partendo da
€1.000 con l'obiettivo (aspirazionale) di €50/giorno. Esplorare anche idee fuori
dal comune e l'uso combinato di più crypto e timeframe.
## Metodologia (engine onesto)
Tutto il lavoro usa un unico engine condiviso (`scripts/analysis/honest_lab.py`)
con questi vincoli anti-illusione:
1. **Ingresso eseguibile.** Ogni segnale alla barra `i` usa solo dati fino a
`close[i]` e l'ingresso avviene a `close[i]` (ciò che il worker live vede e
può eseguire). Disponibile anche l'ingresso più conservativo a `open[i+1]`.
2. **Uscita realistica.** Take-profit / stop-loss valutati intrabar su `high`/`low`,
in modo conservativo (SL prima del TP nello stesso bar), più time-limit.
Una posizione per volta (non-overlap), capitale composto.
3. **Fee di prim'ordine.** Tutto è NETTO dopo fee round-trip realistiche Deribit
(0.10% RT) moltiplicate per la leva (3x), con sweep fino a 0.20% RT.
4. **Validazione severa.** FULL + out-of-sample (ultimo 30%) + conteggio anni
positivi + sweep fee + griglia parametri + test su **8 crypto**
(BTC, ETH, SOL, BNB, XRP, LTC, DOGE, ADA, 2018→2026).
## Lezione madre
**Shortare le crypto perde OOS in modo sistematico in questo campione.** Sia la
mean-reversion sul lato short, sia il momentum short, crollano fuori campione: il
periodo 2018-2026 è net-bull e ogni rialzo "estremo" tende a continuare invece di
rientrare. Tutte le configurazioni che sopravvivono oneste sono **long-biased**.
È un fatto da dichiarare: parte della performance OOS è correlata al beta rialzista
delle crypto. Le strategie aggiungono *timing* sopra quel beta, non lo eliminano.
## Le 3 strategie selezionate (meccanismi distinti)
| Codice | Meccanismo | TF | Asset robusti | OOS netto (fee 0.10% RT) | DD | Anni+ |
|--------|-----------|----|---------------|--------------------------|----|-------|
| **DIP01** | Dip-buy z-score reversion (long-only) | 1h | BTC, ETH, SOL | BTC +59% · ETH +224% · SOL +13% | 23-55% | 6-7/9 |
| **TR01** | EMA 20/100 trend-following (long-only) | 4h | BNB, BTC, DOGE, SOL, XRP | BTC +27% · DOGE +53% · XRP +29% | 29-53% | 4-6/8 |
| **ROT01** | Rotazione cross-sectional momentum sul paniere | 1d | intero paniere (8) | **+44%** | 53% | 5/7 |
Dettagli e riproducibilità: `scripts/analysis/honest_final.py` (tabella di
validazione unica), `honest_rotation.py`, `honest_trend.py`, `honest_candidates.py`,
`honest_diag.py`/`honest_diag2.py` (diagnostica long/short e filtro trend).
### DIP01 — compra le capitolazioni
Long-only: entra quando lo z-score del prezzo rispetto alla media a 50 barre scende
sotto 2.5 (capitolazione), prende profitto al rientro verso la media, SL a 2.5·ATR.
È la versione robusta e onesta della famiglia mean-reversion: regge lo sweep fee
fino a 0.20% RT (BTC +45% OOS anche a 0.20%). Funziona sui major (BTC/ETH/SOL); sugli
alt molto parabolici (DOGE/BNB) un dip fisso continua a scendere e non ha edge.
### TR01 — cavalca i trend
Long-only: in posizione quando EMA(20) > EMA(100) sul 4h, altrimenti cash. Poche
operazioni (≈200 flip in 8 anni) ⇒ le fee non sono letali. È **complementare** a
DIP01: guadagna nei regimi di trend, dove la reversione soffre.
### ROT01 — la più affidabile e "fuori dal comune"
Una sola strategia che usa **tutto il paniere** in un unico book: ogni giorno ordina
le 8 crypto per momentum (rendimento a 60 giorni) e alloca a parti uguali alle 2
migliori con momentum positivo, il resto in cash. Cattura la *dispersione* tra
crypto (gli alt forti corrono molto più di BTC nei bull) senza shortare nulla.
È **param-insensitive** (tutte le combinazioni lookback/top-k sono positive OOS) e
regge le fee fino a 0.20% RT (+41% OOS). Risponde direttamente alla richiesta di
combinare più crypto e un timeframe diverso in un'unica strategia. Per-anno:
2020 +33% · 2021 +181% · 2022 29% (bear) · 2023 +43% · 2024 +59% · 2025 +6% · 2026 10% (YTD).
## Diversificazione
I tre meccanismi coprono regimi diversi e in larga misura anti-correlati:
reversione (DIP01), momentum di singolo asset (TR01), forza relativa cross-asset
(ROT01). Eseguirli insieme produce una curva di equity più liscia del singolo.
## Onestà sull'obiettivo €50/giorno
Va detto chiaramente: **€50/giorno su €1.000 in pochi mesi non è raggiungibile a
rischio sano.** Significa ~€18.250/anno, cioè ~1.825%/anno; gli edge onesti qui
trovati rendono il 30-60% OOS su orizzonti pluriennali. Le strade per avvicinare
quel numero sono: (a) far crescere il capitale per anni con interesse composto —
€50/giorno diventa plausibile solo quando il capitale è molto più grande; (b) alzare
la leva, che però aumenta proporzionalmente il drawdown (già 23-55%) ed espone a
rovina; (c) aggiungere capitale. Nessuna di queste è una scorciatoia. La proposta
onesta è un portafoglio delle 3 strategie a leva moderata, puntando alla
**sopravvivenza e alla crescita composta**, non al target giornaliero immediato.
## Miglioramenti (alzare Acc, ridurre DD, migliorare PnL)
Leve oneste e documentate, senza tuning sui singoli anni
(`scripts/analysis/honest_improve.py`, `honest_improve2.py`):
### ROT02 — dual-momentum overlay (migliora TUTTO)
Alla rotazione cross-sectional di ROT01 si aggiunge un overlay di *absolute
momentum*: cash quando BTC è sotto la sua media a 100 giorni (mercato risk-off).
Taglia i bear di sistema (gli unici anni rossi di ROT01).
| | FULL% | OOS% | DD% |
|---|---|---|---|
| ROT01 base | +679 | +44 | 53 |
| **ROT02 (SMA100)** | **+1095** | **+98** | **40** |
PnL su, DD giù: dominanza su tutte e tre le metriche. Param-insensitive (SMA100-150).
### DIP01 — market-gate (variante low-DD)
Comprare i dip solo quando BTC è risk-on alza l'**Acc** (ETH 52→57%, SOL 49→52%) e
**dimezza il DD** (ETH 53→23%, SOL 25→13%), al costo di parte della PnL (meno trade).
È de-risking, non un pasto gratis: utile per chi vuole una curva più liscia. Su BTC
il gate va evitato (i dip migliori di BTC arrivano proprio quando BTC è sotto la
propria SMA), quindi DIP01 base resta la versione di riferimento per BTC.
### PORT01 — portafoglio combinato (il vero motore di risk-reduction)
Equal-weight giornaliero ribilanciato delle 3 sleeve anti-correlate
(DIP01 BTC + TR01 basket + ROT02). La diversificazione porta il DD del portafoglio
**sotto** quello della sleeve meno rischiosa, mantenendo una CAGR alta.
| Sleeve | ret% | DD% | CAGR% |
|--------|------|-----|-------|
| DIP01 BTC | +322 | 15 | 31 |
| TR01 basket | +591 | 27 | 43 |
| ROT02 dual-mom | +771 | 40 | 49 |
| **PORTAFOGLIO** | **+642** | **12** | **45** |
Per-anno portafoglio: 2021 +203% · 2022 **1%** (bear neutralizzato, era 30% su ROT) ·
2023 +47% · 2024 +50% · 2025 +14% · 2026 2% (YTD). Nessun anno realmente negativo,
DD massimo 12%, CAGR 45%. È la configurazione di deployment raccomandata.
## Prossimi passi
- Integrare DIP01 nel worker (già compatibile: Signal con tp/sl/max_bars).
- Trailing-stop ad ATR per TR01 (per alzarne l'Acc e ridurne ulteriormente il DD).
- Estendere il worker per strategie position-based (TR01) e di portafoglio (ROT01).
- Backtest del portafoglio combinato con ribilanciamento del capitale.
- Walk-forward rolling (oltre al singolo split 70/30) per confermare la stabilità.
+193
View File
@@ -0,0 +1,193 @@
# 2026-05-28 — Giorno 3: Bug dati Cerbero, paper trader fermo, fix MT01 multi-timeframe
### 12:20 — Sintomo: paper trader live a zero trade
**Cosa:** check del container `pythagoras-multi` (multi-strategy paper trader, 6 strategie).
**Reale:** container healthy da ore, ma **0 trade** su tutte le strategie, tutte FLAT a €1000.
Primo falso indizio: `last_bar_ts: 0` in tutti gli `status.json`. Indagando il worker,
quel campo si aggiorna **solo a posizione aperta** (contatore `hold_bars`), non ad ogni
candela → non è la causa. Il loop era vivo (status.json riscritti ogni 60s).
**Lezione:** non fidarsi del nome di un campo; verificare nel codice quando viene scritto.
L'healthcheck del container controlla solo l'esistenza di `status.json`, non la freschezza
→ un loop bloccato risulterebbe comunque "healthy".
### 12:45 — Causa radice: bug lato Cerbero MCP `get_historical`
**Cosa:** probe dirette all'endpoint `/mcp-deribit/tools/get_historical`.
**Reale:** due bug lato server:
1. **`end_date` data-nuda tronca a mezzanotte:** `end=oggi` restituiva candele solo fino a
`oggi 00:00`. Il `df` live finiva sempre alla barra di mezzanotte e **non avanzava** durante
la giornata → nessun breakout fresco sull'ultima barra → nessun ingresso (condizione worker
`last_signal.idx >= last_idx - 1`).
2. **Cap a ~5000 righe** che ignora `start_date`: una richiesta di 365g a 15m restituiva ~52
giorni. Ecco perché ML01 si addestrava su soli 88 samples (overfit, train_acc 100%).
**Lezione:** lo zero-trade non era nelle strategie ma nel feed dati. Sempre validare la
freschezza/copertura dei dati prima di sospettare la logica.
### 13:30 — Fix lato Cerbero + verifica
**Cosa:** report passato al dev di `cerbero-mcp`; fix deployato (riavvio container) + doc
aggiornata in `cerbero-mcp/docs/API_REFERENCE.md`.
**Reale dopo deploy (verificato con probe):**
- `end=oggi` (data nuda) → ultima candela = ora corrente (age ~3 min). ✅
- 365g a 15m → **35.099 candele**, span 365.6g, nessun cap. ✅
- Supportati anche timestamp con orario (`...T14:00:00`, naive = UTC). ✅
Nostro client (`src/live/cerbero_client.py`) invariato: passa già `end=oggi`, ora corretto.
**Lezione:** "trust but verify" — la doc dichiarava i fix prima che fossero deployati; solo
la probe diretta ha confermato cosa era davvero attivo sul server.
### 14:00 — Problema residuo: MT01 usava un trend 1h STANTIO
**Cosa:** check di tutte le strategie sul percorso di codice reale con dati freschi.
**Reale:**
- Tutte le 6 strategie girano senza crash; SQ01/SQ02 generano molti segnali.
- **MT01 leggeva il trend 1h dal parquet statico** (`load_data(asset,"1h")`), non da Cerbero.
Il parquet finiva a mezzanotte → per ogni barra 15m di oggi `searchsorted` cadeva oltre la
fine e si agganciava sempre alla candela di mezzanotte (gap 14.8h). La conferma
multi-timeframe — il cuore di MT01 — era di fatto congelata e il gap cresce ogni giorno.
- In `data/raw/` mancavano del tutto i parquet **15m** (`btc_15m`, `eth_15m`) → backtest 15m rotti.
**Lezione:** una strategia live che dipende da un file statico ha un punto cieco temporale;
il dato live e quello di backtest devono provenire da fonti coerenti.
### 14:30 — Fix MT01: trend 1h live da Cerbero
**Cosa:** modifica al runner perché MT01 prenda l'1h live, non dal parquet.
- `MT01.generate_signals` accetta un `df_1h` opzionale (fallback al parquet se assente).
- `StrategyWorker.tick(df, df_1h=None)` lo inoltra ai signal.
- `multi_runner` fa fetch 1h live (resolution 60) per gli asset MT01 ad ogni poll (`htf_cache`).
**Reale (verificato a codice montato, pre-rebuild):** gap del trend 1h sull'ultima barra
**0.75h** (fresco) contro **14.8h** col parquet statico. Segnali invariati sullo storico.
**Lezione:** isolare la dipendenza dal file statico rende MT01 immune al drift tra un
`download_all()` e l'altro.
### 14:55 — Rigenerazione dati + rebuild
**Cosa:** `download_asset` per 15m+1h (saltati 1m/5m, lenti e inutilizzati), poi
`docker compose up -d --build` (il codice `src/` è baked nell'immagine).
**Reale:** parquet rigenerati con storia completa 2018→2026 e freschi (15m fino alle 14:45,
1h fino alle 14:00). Container ripartito: 6 strategie attive, ML01 riaddestrato su **534
samples** (anno pieno), MT01 senza errori, fetch 1h live OK.
### 15:00 — Regressione backtest sui dati rigenerati
**Cosa:** rilanciati i backtest per confermare che i numeri documentati si riproducano sui
dati ricreati da zero (BTC/ETH 15m, hold=3, fee 0.2% RT, leva 3x, pos 15%).
**Reale:** accuratezze e drawdown **identici**, solo +1/+3 trade dalle barre recenti in più.
| Strategia | Ottenuto | Documentato | Esito |
|---|---|---|---|
| SQ01 BTC 15m | 76.7% / DD 6.7% / 4063t | 76.7% / 6.7% / 4062 | ✓ |
| SQ01 ETH 15m | 76.4% / 6.2% / 2951t | 76.4% / 6.2% / 2948 | ✓ |
| SQ02 BTC 15m | 79.7% / 6.5% / 1251t | 79.7% / 6.5% / 1250 | ✓ |
| SQ02 ETH 15m | 78.6% / 3.4% / 944t | 78.6% / 3.4% / 942 | ✓ |
| **MT01 BTC 15m (ema20+vol)** | **82.7% / 5.9% / 503t** | 82.7% / 5.9% / 503 | ✓ esatto |
| MT01 ETH 15m (ema20+vol) | 81.2% / 2.9% / 404t | — | ok |
**Lezione:** l'integrità dei dati rigenerati è confermata — la pipeline di download produce
risultati riproducibili. La config live di MT01 (ema20+vol) coincide col best documentato.
### Punti aperti
1. **Backtest e drift dati:** MT01 live ora è immune (1h da Cerbero), ma i backtest girano
sempre sui dati fino all'ultimo `download_all()`. Per dati di backtest sempre freschi
serve uno scheduling del download (cron/job).
2. **Healthcheck:** valutare un check su mtime di `status.json` (< 180s) per rilevare uno
stallo del loop, non solo l'esistenza del file.
---
### 23:00 — 3 nuove strategie con edge OOS fee-aware (branch `strategy_free`)
**Obiettivo:** trovare almeno 3 nuove strategie (oltre MR01), edge netto validato
out-of-sample e fee-aware, per il target €1.000 → ~€50/giorno.
**Metodologia (invariata dalla lezione squeeze):** ingresso eseguibile a `close[i]`
(nessun look-ahead), backtest netto dopo fee Deribit 0.10% RT + leva 3x, OOS = ultimo
30% held-out, robustezza su griglia parametri + sweep fee 0.000.20% RT, exit
TP/SL intrabar o time-limit, una posizione per volta, capitale composto.
**Candidati** (`scripts/analysis/strategy_research_v2.py`), tutti mean-reversion
(l'edge è sempre il rientro, mai la continuazione):
| Candidato | Esito | Motivo |
|---|---|---|
| **MR02 Donchian Fade** | ✅ | Robusto su tutta la griglia `n × sl_atr` e tutte le fee |
| **MR03 Keltner Fade** | ✅ | Robusto su tutta la griglia `n × k`; banda ATR, indipendente da Bollinger |
| **MR07 Return Reversal** | ✅ | Intero blocco `tp_atr=2.0` positivo full+OOS; esposizione ~8% |
| MR04 Z-score Reversion | ⛔ | Robusto ma è MR01 riparametrizzato (stessa banda std): edge non *nuovo* |
| MR05 Bollinger + filtro ADX | ⛔ | Non robusto: negativo su gran parte della griglia BTC |
| MR06 RSI(2) Connors | ⛔ | ETH 1h negativo; non robusto su entrambi gli asset |
**Risultati** (netto 0.10% RT, leva 3x, OOS, 1h):
| Codice | Meccanismo | BTC OOS | ETH OOS | DD (full) |
|---|---|---|---|---|
| MR02 | estremi canale Donchian H/L | +172% | enorme | 30% / 42% |
| MR03 | canale ATR su EMA | +112% | +886% | 37% / 66% |
| MR07 | z dei rendimenti di barra | +105% | +195% | 25% / 46% |
**Validazione live-path** (`oos_validation.py`, legge `strategies.yml`, exit hold
del worker): tutte e tre positive netto OOS su tutto lo sweep fee, anche al
pessimistico 0.20% RT → edge robusto pure al meccanismo di exit.
**Verifiche:** equivalenza esatta backtest produzione vs research engine (MR02 BTC:
2039 trade, DD 29% identici); le 3 classi si caricano dal `strategy_loader`;
aggiunte a `strategies.yml` (BTC+ETH 1h). Nessuna suite di test nel progetto.
**Onestà sul target:** con 4 fade indipendenti × 2 asset il PnL storico aggregato
supera €50/giorno, ma sono backtest a leva 3x su 8 anni con annate eccezionali
(ETH 2024). Plausibile ma da confermare col paper trader live prima del capitale reale.
DD alto su ETH (MR03 ~66%, come MR01) → leva più bassa consigliata per quell'asset.
**File:** `strategy_research_v2.py`, `src/strategies/fade_base.py`,
`scripts/strategies/MR0{2,3,7}_*.py` (nuovi); `strategy_loader.py`, `strategies.yml`,
`CLAUDE.md` (aggiornati).
**Lezione confermata:** ogni edge robusto trovato finora è mean-reversion; ogni
variante trend/continuation o oscillatore senza filtro perde netto.
---
### 23:45 — Aumentare Acc e ridurre DD (filtro trend + portafoglio)
**Obiettivo:** alzare accuratezza e abbassare drawdown sulle 4 fade, senza
distruggere l'edge né overfittare (ogni leva misurata FULL **e** OOS).
**Diagnosi:** perdite/DD concentrati 20182021 (bear/covid/caos vol), su ETH DD
pieno 6671%. Banco di prova: `scripts/analysis/risk_improvements.py` e
`risk_portfolio.py`.
**Leve testate:**
| Leva | Esito | Motivo |
|---|---|---|
| Sizing vol-target (size ∝ 1/dist-SL) | ⛔ | Over-size sui trade a stop stretto → DD su, ritorno giù |
| Skip alta volatilità (ATR% in coda alta) | ⛔ | L'alta vol è *positiva* per le fade (più reversione): Acc e ritorno giù |
| **Filtro trend** (`\|closeEMA200\|/ATR > soglia` → salta) | ✅ | Non fada trend/crolli estremi: Acc↑ ovunque, DD↓ molto su ETH, OOS regge |
| **Portafoglio** equipesato (sotto-conti indipendenti) | ✅ | Curve poco correlate → DD aggregato 14% (full)/10% (OOS) vs 20-70% singolo |
**Filtro trend — sweep soglia** (assoluta in ATR, regola unica per tutte = niente
overfit): 3.0 ATR è l'equilibrio (2.0 taglia troppo ritorno). Effetto su config
deployata (base → filtro):
| Sleeve | Acc | DD |
|---|---|---|
| MR01 ETH | 46→55 | **71→26** |
| MR02 ETH | 49→55 | 42→25 |
| MR03 ETH | 49→52 | 66→34 |
| MR07 ETH | 48→54 | 46→21 |
| MR01 BTC | 51→54 | 32→34* |
| MR02 BTC | 48→52 | 29→23 |
| MR07 BTC | 49→53 | 25→18 |
| MR03 BTC | 47→47 | 37→37 (filtro OFF) |
\*MR01 BTC: DD full +2pt ma Acc +3.7 e DD OOS piatto (14.8→15.0). **MR03 BTC**:
il filtro peggiora entrambe (unico sleeve) → lasciato disattivo nello yaml.
**Implementazione:** helper `trend_distance()` in `fade_base.py`; param opzionali
`trend_max`/`ema_long` (default None = retro-compatibile) in tutte le strategie
(MR01/02/03/07); `strategies.yml` con `trend_max: 3.0, ema_long: 200` (eccetto
MR03 BTC). Verificato: equivalenza produzione vs ricerca.
**Lezione:** il modo onesto di ridurre il DD non è strozzare il sizing (peggiora),
ma (a) non opporsi a trend estremi e (b) diversificare su strategie scorrelate.
+155
View File
@@ -0,0 +1,155 @@
# Diario — 2026-05-29 — Esplorazione di nuove famiglie di strategie
## Obiettivo
Trovare 5-10 nuove famiglie di strategie, diverse da quelle esistenti, migliori o
complementari, con DD basso e attenzione alle fee. Esplorazione onesta (no
look-ahead, netto fee, OOS) condotta con **agenti paralleli**, ognuno su una famiglia
indipendente, tutti sullo stesso harness condiviso (`scripts/analysis/explore_lab.py`).
Lavoro sul branch `strategy_explore`.
## Famiglie esplorate (9) ed esito onesto
| Famiglia | Esito | Note |
|---|---|---|
| **Pairs / spread reversion** | ✅ **VINCITORE** | Market-neutral, genuinamente nuova, decorrelata |
| **TSMOM multi-orizzonte** | ✅ diversificatore | Marginale ma distinto (corr 0.53 con ROT02), DD basso |
| Stagionalità settimanale | ⚠️ marginale/fragile | "Mercoledì-long-24h" 7/8 asset OOS+ ma effetto concentrato a 00:00 UTC |
| Vol-target BTC | ⚠️ marginale | Sharpe 0.94 vs 0.76 buy&hold, DD ancora 44% |
| Stagionalità intraday (ora) | ❌ rumore | L'edge orario muore sotto le fee |
| Stagionalità mensile/turn-of-month | ❌ rumore | Reale in-sample, morto OOS dal 2024 |
| Cross-sectional reversal | ❌ nessun edge | Perde vs equal-weight, corr 0.98 col momentum |
| Opening-range breakout | ❌ non generalizza | Solo BTC/ETH, alcuni regimi, fee-fragile |
| Lead-lag BTC→alt | ❌ nessun edge | Reazione contemporanea (corr lag+1 ≈ 0), non batte buy&hold |
| Momentum/continuation intraday | ❌ negativo | Conferma: il *fade* (mean-reversion) domina |
7 famiglie su 9 sono rumore — e l'harness le ha rifiutate senza produrre falsi
positivi (segnale che la metodologia onesta funziona). Due edge reali emergono.
## Vincitore 1 — PAIRS (market-neutral) — `PR01_pairs_reversion.py`
Scommette sul rientro del log-ratio di due cripto verso la media (z-score). Quando
`z ≤ 2` → long A / short B; `z ≥ +2` → l'opposto; esce al rientro (`|z| ≤ 0.5`) o a
tempo. Engine onesto verificato in `pairs_research.py` (test esplicito no-look-ahead:
`z[i]` invariato perturbando il futuro). Fee contate su **2 gambe** (0.20% RT/coppia).
Validazione (netto, leva 3x, OOS = ultimo 30%, 1h):
| Coppia | CAGR | Sharpe | OOS DD | anni+ |
|---|--:|--:|--:|--:|
| ETH/BTC | 144% | 4.04 | 17% | 8/9 |
| LTC/ETH | 71% | 2.52 | 10% | 7/8 |
| ADA/ETH | 77% | 2.16 | 11% | 7/8 |
Tutte le 10 coppie testate positive FULL+OOS, regge fee 0.40% RT/coppia, correlazione
col mercato ~0.02 (market-neutral confermato). DD pieno 42-49% (alto), ma OOS DD
10-17% (buono) e soprattutto **quasi-zero correlazione** col resto → diversificatore
eccezionale. Limite: 2 gambe (long+short), il worker live va esteso prima del live.
## Vincitore 2 — TSM01 (TSMOM multi-orizzonte) — `tsmom_research.py`
Long-only multi-crypto: tiene equal-weight gli asset con consenso pieno del segno di
momentum su 3/6/12 mesi, cash se BTC<SMA100. Distinto da ROT02 (persistenza assoluta
vs ranking relativo), corr 0.53. FULL +169% / OOS +80% / DD 22% / Sharpe 1.07,
**mai un anno negativo**, regge fee 0.40%. Verificato no-look-ahead (cheat-test
esplode a +575%). Marginale come stand-alone (rende meno di ROT02) ma utile in ensemble.
## Il payoff — combinare le nuove fonti col MASTER (`combine_v2.py`)
Le nuove sleeve sono quasi scorrelate col MASTER-9 (pairs ~0.02-0.08, TSM01 0.05).
Aggiungerle migliora nettamente il portafoglio:
| Portafoglio | CAGR | DD% | Sharpe | OOS DD% | OOS Sharpe |
|---|--:|--:|--:|--:|--:|
| MASTER-9 (base) | 47 | 5.2 | 4.23 | 4.7 | 4.33 |
| **MASTER + pairs (12)** | **66** | **3.8** | **5.67** | **3.3** | **6.86** |
| MASTER + TSM01 (10) | 44 | 4.7 | 4.21 | 4.2 | 4.33 |
| MASTER esteso (13) | 62 | 3.6 | 5.66 | 3.0 | 6.79 |
I **pairs** sono l'aggiunta decisiva: alzano la CAGR (47→66), **abbassano il DD**
(5.2→3.8 full, 4.7→**3.3** OOS) e portano lo Sharpe OOS a **6.86** — il free-lunch
della diversificazione da una fonte market-neutral scorrelata. TSM01 contribuisce
poco (diluisce il ritorno) ma abbassa lievemente il DD.
## Caveat onesti
- I pairs hanno DD pieno alto (42-49%) sull'1h; il vantaggio sta nella decorrelazione,
non nel DD stand-alone. Richiedono esecuzione a 2 gambe (short del perp B) — da
verificare shortabilità/liquidità sugli alt e raddoppio fee nel worker.
- Sharpe combinati 5-7 e CAGR 60%+ sono backtest a leva 3x su finestra 2021-2026 con
OOS ~1.6 anni e il 2024 cripto eccezionale: numeri ottimistici, da confermare in
paper trading live.
- TSMOM e le strategie honest condividono l'overlay risk-off SMA100: parte della loro
difensività è comune (non perfettamente indipendente).
## Terza ondata — espansione dei meccanismi provati + 2 nuovi sondaggi
Esplorate altre 4 direzioni con agenti paralleli:
- **Fade su 6 nuovi alt (ADA/BNB/DOGE/LTC/SOL/XRP)**: 0 robuste. La mean-reversion
fade vive solo su BTC/ETH (liquidi); sugli alt sparisce o è artefatto di pochi pump
(DOGE). Coerente con la lezione del progetto.
- **Espansione PAIRS** (tutte le 28 coppie): trovate **3 nuove coppie robuste**
BTC/LTC (robusta 1h *e* 4h, Sharpe 2.21, DD 24-34%, concentrazione PnL 9%), ETH/SOL
e BNB/ETH (Sharpe 2.4+, solo 1h). Pattern: sempre alt-liquido vs major, mai alt/alt.
PR01 ora ha **6 coppie**.
- **Low-volatility anomaly**: ❌ in cripto è INVERTITA (vince l'alta vol = alta beta),
ridondante con EW+risk-off/ROT02. L'anti-test high-vol stravince.
- **Confluenza multi-timeframe (fade 1h confermato da 4h)**: non crea edge nuovo e non
migliora lo Sharpe, ma **dimezza il DD** di MR01 (ETH: stesso Sharpe 3.17 a DD 38% vs
63%) e stabilizza l'OOS → utile variante low-DD, non strategia indipendente.
## Bilancio finale e MASTER esteso (6 pairs)
Robusti deployabili: **famiglia PAIRS (6 coppie) + TSM01** (+ confluenza MTF come variante
low-DD di MR01, + tilt stagionale mercoledì marginale). I 6 pairs sono quasi scorrelati col
MASTER (corr 0.02-0.08). MASTER + 6 pairs:
| Portafoglio | CAGR | DD% | Sharpe | OOS DD% | OOS Sharpe |
|---|--:|--:|--:|--:|--:|
| MASTER-9 (base) | 47 | 5.2 | 4.23 | 4.7 | 4.33 |
| **MASTER + 6 pairs (15)** | **71** | 5.7 | **5.93** | **2.3** | **7.71** |
| MASTER esteso +TSM01 (16) | 67 | 5.4 | 5.95 | **2.2** | 7.67 |
Aggiungere i 6 pairs porta l'**OOS DD a 2.2-2.3%** (da 4.7%) con Sharpe OOS ~7.7 e tutti
gli anni positivi: il guadagno di diversificazione da fonti market-neutral scorrelate.
## Quarto giro — validazione anti-overfitting e irrobustimento
Tre audit scettici paralleli (walk-forward, plateau, stress, scomposizione):
**Pairs — de-overfittati.** Sostituita la config per-coppia (cherry-picking di z_exit/n)
con **una config universale `n=50 z_in=2.0 z_exit=0.75 max_bars=72`**. Verifiche:
- plateau (non picco): heatmap n×z_in → 20/20 celle Sharpe>1 su ETH/BTC e BTC/LTC;
- walk-forward (train 2y / test 6m rolling): ETH/BTC 11/12 finestre positive, BTC/LTC
9/10 → edge distribuito su tutta la storia, non un regime singolo;
- **BNB/ETH scartata** (era robusta solo coi suoi parametri → overfit; crolla con la
universale e muore per prima allo stress costi). Famiglia ridotta a **5 pairs**.
- stress: 5/6 reggono fee+slippage realistici; solo ETH/BTC regge fee 6x (coda fee-fragile).
**Master — numeri sobri.** L'OOS Sharpe 7.7 / DD 2.3% è **ottimistico ~50%** perché l'OOS
cade nel bull calmo 2024-25. Numeri onesti da usare:
- worst-DD su finestra mobile 90g (2021-2026) = **5.7%** (bear FTX) → budget DD ~6%, non 2.3%;
- Sharpe per-semestre: mediana **~5** (min 1.2, max 12) → atteso ~5, non 7.7;
- ogni anno e ogni semestre dal 2021 positivo (anche il 2022 bear, grazie alle gambe short);
- equal-weight ≈ inverse-vol (non dipende da pesi fortunati);
- regge **leva 2x + slippage doppio** (CAGR 36%, Sharpe 5.1);
- **rischio concentrato: i pairs portano ~57% del rischio** → cap consigliato ~30-35%.
- Config robusta raccomandata: **MASTER-esteso, equal-weight, leva 2x, cap pairs ~30-35%**.
**TSM01 — confermato robusto** (36/36 config OOS+, walk-forward stabile) ma corr reale con
ROT02 = **0.62** (non 0.53), e gran parte del DD basso viene dall'overlay risk-off condiviso.
Tenuto come diversificatore con **gross 0.30** (stesso Sharpe, DD 22%→15%).
**Confluenza multi-TF — SCARTATA: era overfit.** Taglia il 97% dei trade (restano ~40 in
8 anni = non significativo), distrugge lo Sharpe (1.58→0.27 su BTC) e il caso "bello" non
sopravvive alle perturbazioni. Per abbassare il DD di MR01 meglio ridurne la leva, non il filtro 4h.
**Risultato del giro:** quanto trovato regge l'esame anti-overfit (NON è l'errore squeeze),
ma i numeri vanno comunicati sobri (Sharpe ~5, DD ~6%) e con leva 2x + cap pairs. Famiglia
pairs consolidata a 5 coppie con config universale; confluenza MTF rimossa dai vincitori.
## File creati (branch strategy_explore)
`scripts/analysis/explore_lab.py` (harness onesto condiviso), `pairs_research.py`
(verifica + ricerca pairs), `tsmom_research.py` (TSM01), `combine_v2.py` (master
esteso); `scripts/strategies/PR01_pairs_reversion.py` (artefatto pairs).
+84
View File
@@ -0,0 +1,84 @@
# Diario — 2026-05-29 — Pattern del segnale per FORMA (analog/shape forecasting)
## Obiettivo
Verificare se la **forma** del segnale (la morfologia recente del prezzo) permette di
prevedere l'andamento successivo, e ricavarne edge verso il target €1000 → €50/giorno.
Esplorazione onesta (no look-ahead, netto fee, OOS) con **agenti paralleli**, ognuno su
una famiglia di forma indipendente, tutti sullo stesso harness shape (`scripts/analysis/
shape_lab.py`, che riusa l'engine netto-fee+OOS di `explore_lab.py`). Branch `shape_patterns`.
## Harness
`shape_lab.py` — analog forecasting causale: a ogni barra `i` si guarda la forma recente
`W` (closes z-normalizzati fino a `close[i]`), si cercano nel passato le `K` finestre più
simili **il cui esito a `H` barre era già noto prima di `i`** (KDTree ricostruito ogni
`rebuild` barre → niente O(N²)), si prevede la direzione = segno del rendimento medio degli
analoghi. **No-look-ahead verificato** (perturbare il futuro non cambia la forma a `i`,
max diff 0.0). Baseline forma grezza: marginale e **muore sulle fee** (W24H12K50: FULL
+112% / OOS +48% ma a 0.20% RT → 72%; troppi trade, exp 74%, win 49.5%).
## Famiglie esplorate (5) ed esito onesto
| Famiglia | Esito | Note |
|---|---|---|
| Analog kNN (forma grezza, selettività) | ❌ RUMORE | Solo BTC-overfit, non robusto ≥2 asset |
| Encoding candele (UP/DOWN/DOJI + body/shadow) | ❌ RUMORE | Hit-rate condizionale ~50%, segno incoerente fra asset |
| DTW + template geometrici (M/W, testa-spalle, V, U) | ❌ RUMORE | DTW *peggiora* l'euclidea; template overfit (FULL ok, OOS crolla) |
| PIP / pivot / zig-zag (geometria svolte) | ❌ RUMORE | 0/48 config robuste; le rotture S/R rientrano (riconferma MR) |
| **Feature-vector + ML walk-forward** | ✅ **EDGE REALE** | LogisticRegression sulla forma, fee-robusto |
4 famiglie su 5 sono rumore: riconfermano che la forma grezza non contiene edge
direzionale eseguibile e che l'unico edge "classico" resta la mean-reversion (fade/pairs).
## L'edge: SH01 — Shape-ML
Una **LogisticRegression** legge 17 feature di forma (body/shadow ratio, rendimenti,
pendenza/curvatura del path, posizione di max/min, RSI, estensione) e predice il segno del
rendimento a `H` barre. **Walk-forward rigoroso**: scaler+modello fittati solo sul passato
con esito noto, poi predicono il blocco corrente; si entra a `close[i]` se la probabilità
≥ soglia. Causalità verificata con check espliciti (feature e predizioni invarianti al
futuro). Il GradientBoosting dà edge equivalente ma è ~60× più lento → si usa il logit.
A differenza della famiglia squeeze (che moriva anche a fee zero), **questo edge
sopravvive a fee 0.20% RT**. Win-rate ~50% → l'edge è nell'**asimmetria** (quando indovina
la direzione i moti sono più grandi), non nella frequenza.
### Validazione dura (config W24 H12 th0.58, netto fee, leva 3x, pos 0.15, OOS 30%)
- **Multi-asset expanding**: robusti **BTC** (FULL +219% / OOS +42% / Sharpe 2.72 / DD 23%
/ 8-9 anni+ / accOOS 56%), **ETH** (+80% / +144% / Sharpe 1.21, più volatile), **ADA**
(+707% / +57% / Sharpe 3.22). Scartati LTC/SOL/XRP (perdono netti).
- **Walk-forward rolling (train fisso 2 anni)**: regge **solo BTC** (+166% / +96% / Sharpe
2.05). L'edge si appoggia in parte alla memoria lunga → BTC è il più solido.
- **Stress leva 2x + slippage doppio (0.20% RT)**: BTC OK (+40% / +17% / Sharpe 1.24),
ETH marginale (+7% / +73% / Sharpe 0.37).
- **Griglia (W,H,thresh) su BTC**: **5/27 celle robuste**, su una **cresta** stretta (W24,
H8-12), non altopiano largo → rischio overfit moderato. Per prudenza si sceglie la config
robusta sul maggior numero di test (W24 H12 th0.58), non il PnL massimo (W24 H8 rende di
più ma accOOS ~49% = più drift che segnale).
### Il valore vero: diversificatore di portafoglio
Correlazione daily col MASTER **+0.08** (quasi scorrelato). Aggiungere lo sleeve shape
(BTC+ETH) al MASTER migliora l'OOS: **Sharpe 4.33 → 5.10, DD 4.7% → 4.2%** (FULL: Sharpe
4.23 → 4.37, DD 5.2% → 4.3%). Non è un motore standalone (per-asset troppo stretto fuori
da BTC), ma un **free-lunch** da aggiungere al paniere.
## Artefatti
- `scripts/analysis/shape_lab.py` — harness analog/forma causale.
- `scripts/analysis/shape_{analog,candle,template,pivot,ml}_research.py` — le 5 ricerche.
- `scripts/analysis/shape_ml_validate.py` — validazione dura del candidato ML.
- `scripts/strategies/SH01_shape_ml.py` — la strategia (Strategy + run() riproducibile).
- Aggiunta a `MODULE_MAP` (caricabile per backtest).
## Conclusione e prossimi passi
La forma del segnale **non** predice in modo grezzo (4/5 famiglie rumore), ma un modello
lineare sulle feature di forma in walk-forward onesto **sì**, soprattutto su BTC, e vale
come diversificatore quasi-scorrelato del MASTER. Da fare prima del live:
1. **Worker con retraining periodico** (lo StrategyWorker attuale è a regola fissa; SH01
riallena il modello → serve un loop tipo legacy signal_engine).
2. Validazione live-path (replay worker == backtest) come fatto per i pairs.
3. Decidere il peso nel MASTER-esteso (cap, leva) col paper trader.
+64
View File
@@ -0,0 +1,64 @@
# Diario di ricerca — 2026-05-29
## Combinare le strategie migliora i risultati?
**Domanda:** usare insieme le due famiglie di strategie presenti sul repo migliora
il profilo rischio/rendimento rispetto a usarle separatamente?
- **FADE** (mie): reversione intraday 1h, long/short, BTC/ETH — MR01 Bollinger,
MR02 Donchian, MR03 Keltner, MR07 Return-reversal (tutte col filtro trend 3.0 ATR).
- **HONEST** (altra sessione): long-only multi-regime multi-crypto — DIP01 (dip-buy
1h BTC), TR01 (EMA-trend 4h basket), ROT02 (dual-momentum rotation 1d).
**Metodo** (`scripts/analysis/combine_portfolio.py`): per ogni sleeve si costruisce
l'equity **giornaliera** normalizzata su un indice comune (2021-01-01 → 2026-05-26),
si passa ai rendimenti giornalieri, si misura la correlazione cross-famiglia e si
confrontano i portafogli equal-weight (ribilanciati ogni giorno), 50/50 fra famiglie
e inverse-vol. Metriche FULL e OOS (ultimo 30% della finestra comune, da 2024-10-12):
ritorno, CAGR, max DD, Sharpe annualizzato. Le curve honest sono riusate da
`honest_improve2.py`; quelle fade da `risk_management.build_trades`.
**Correlazione:** cross-famiglia **+0.05** (quasi indipendenti). Intra-fade +0.18,
intra-honest +0.05. L'unica coppia un po' correlata è MR01_BTC↔DIP01_BTC (+0.43),
entrambe mean-reversion su BTC. Famiglie scorrelate ⇒ diversificazione quasi ideale.
**Risultati (FULL | OOS):**
| Portafoglio | Ret% | CAGR | DD% | Sharpe | oDD% | oSharpe |
|---|---|---|---|---|---|---|
| FADE only (8) | +549 | 41 | 8.6 | 3.75 | 5.4 | 4.14 |
| HONEST only (3) | +642 | 45 | 12.0 | 1.90 | 6.5 | 2.23 |
| **ALL equal-weight (11)** | +589 | 43 | 6.1 | **3.95** | 4.6 | **4.46** |
| **ALL 50/50 famiglie** | +615 | 44 | **5.5** | 3.18 | **4.0** | 3.87 |
| ALL inverse-vol | +483 | 39 | 5.8 | 3.97 | 4.6 | 4.02 |
**Conclusione: sì, combinare conviene.**
- DD crolla: combinato 5.56.1% full / 4.04.6% OOS, contro 8.6% (fade) e **12%**
(honest) da sole → drawdown ridotto del 3550%.
- Sharpe sale: combinato OOS **4.46** vs honest 2.23 (raddoppia) e batte pure fade (4.14).
- CAGR resta ~4344% (≈ media delle due famiglie) ma con metà del rischio: è il
"free lunch" della diversificazione fra sorgenti di edge scorrelate.
- Best Sharpe = equal-weight degli 11 sleeve; best DD = 50/50 fra le due famiglie.
**Caveat onesti:** la finestra comune è 20212026 (5.4 anni), OOS ~2024-10→oggi
(1.6 anni) — pochi regimi. CAGR e Sharpe sono backtest a leva 3x; il 2024 cripto
favorevole pesa. Il target €50/giorno resta vincolato dal capitale: 43% CAGR su
€1000 non fa €50/giorno a breve, serve compounding pluriennale o più capitale.
Prossimo passo: confermare il portafoglio combinato nel paper trader live.
**File:** `scripts/analysis/combine_portfolio.py` (nuovo).
## Pulizia roster + miglioria ROT02
- **Waste delle peggiori:** MR03 Keltner (fade più debole, Sharpe 1.22, ridondante
con MR01 — rimuoverla *migliora* il portafoglio fade: DD 8.6→8.2, ret +549→+666)
e ROT01 (dominata da ROT02). Spostate in `scripts/waste/`.
- **Portafogli pronti:** `PORT02_fade_master` (6 sleeve fade) e `PORT03_all_master`
(9 sleeve fade+honest, varianti equal/5050).
- **ROT02 DD alto → migliorato:** la rotazione concentrava il book su 2 asset
(DD 40%). Sweep su `rot_improved`: `top_k=3` dimezza quasi il DD (40%→26%) e
*alza* il ritorno full (+1095→+1303%, ret/DD 27→50). Il vol-target abbassa il DD
ma sacrifica ritorno (de-leverage) → tenuto top_k=3 senza VT. Caveat onesto:
l'OOS di ROT02 cala un po' (+98→+68%, DD 12→14%), ma il MASTER (config deployata)
migliora lo Sharpe full 3.95→4.23. Applicato a `ROT02_dual_momentum.py` e
`_rot_daily_equity`. Sweep in `honest_improve.rot_improved`.
+68
View File
@@ -0,0 +1,68 @@
# 2026-05-31 — Studio sugli EXIT delle fade: scalping, TP dinamico, TP-ATR
> Innescato da una domanda operativa ("un TP è stato raggiunto, non si poteva
> scalpare / fare un TP dinamico?"). Studio fee-aware su MR02 (Donchian fade,
> segnali invariati `n=20 sl_atr=2.0 max_bars=24`, fee 0.10% RT, leva 3x). Tre
> alternative di uscita misurate contro il baseline attuale (**TP = centro del
> canale**). Verdetto: **il design attuale è già ottimale; nessuna alternativa lo batte.**
## 1. "Scalping" = timeframe più veloce (15m vs 1h)
A fee 0.10% il 15m rende di più in lordo (~4× più trade), MA è molto più **fragile**:
| | trade | PnL @0% | @0.10% | @0.20% | DD @0.10% |
|---|--:|--:|--:|--:|--:|
| BTC 1h | 2041 | +22.768 | +16.645 | +10.522 | 29% |
| BTC 15m | 8251 | +65.286 | +40.533 | +15.780 | 29% |
| ETH 15m | 9388 | +120.103 | +91.939 | +63.775 | **62%** |
Da 0% a 0.20% il 15m perde **~76%** del profitto (vs 54% del 1h) e il DD esplode
(ETH 15m → 93% a 0.20%). 4× più trade = 4× più fee + slippage (non modellato, ma
peggiore su book sottili). **L'1h è scelto per il margine di sicurezza, non per il PnL
lordo.** Lo scalping vero (<0.3% target) è in pieno territorio "morte da fee".
## 2. TP dinamico / trailing ("lascia correre il vincitore")
Stessi segnali, exit per trailing a k·ATR dal massimo favorevole invece del TP fisso:
| policy | BTC win% | ETH win% | equity |
|--------|---------:|---------:|--------|
| FIXED (centro, attuale) | **48%** | **49%** | 🟢 di gran lunga il migliore |
| TRAIL (lascia correre) | 36% | 36% | 🔴 azzerato |
| MID+TRAIL | 47% | 47% | 🔴 peggio |
Il win-rate crolla 48%→36%: i trade che avrebbero incassato il TP fanno andata-e-ritorno
e stoppano fuori. **Concettuale:** l'edge della fade è la reversione *fino* alla media;
una volta toccata, l'edge è esaurito. Lasciar correre *oltre* = scommettere sulla
continuazione, che sui perp crypto NON ha edge (rientra). È la stessa logica per cui
SMA/ORB/WR (continuazione) hanno fallito: **let-it-run = trend = il lato perdente.**
## 3. TP scalato all'ATR (TP = entry + dir·m·ATR, SL fisso 2 ATR → R:R = m/2)
| Config | win% | avg %/trade | Sharpe | sumRet% |
|--------|-----:|-----------:|-------:|--------:|
| **BTC MID (attuale)** | 48% | **0.816** | **3.8** | **1664** |
| BTC ATR m=0.5 (RR0.25) | **77%** | 0.081 | 1.0 | 217 |
| BTC ATR m=1.0 | 67% | 0.192 | 1.6 | 465 |
| BTC ATR m=2.0 | 53% | 0.563 | 3.0 | 1199 |
| BTC ATR m=3.0 | 46% | 0.679 | 3.0 | 1331 |
| **ETH MID (attuale)** | 49% | **1.738** | **7.5** | **4169** |
| ETH ATR m=0.5 | 77% | 0.041 | 0.5 | 134 |
| ETH ATR m=3.0 | 46% | 1.082 | 4.7 | 2515 |
OOS (ultimo 30%) identico: **MID** batte ogni `m` (BTC MID avg 1.14/Sh 3.2; ETH MID avg
4.43/Sh 10.9). Due lezioni:
- **TP stretto (m=0.5) = trappola dello scalping quantificata:** win-rate **77%** ma edge
**zero/negativo** (BTC 0.08%/trade). I rari stop a 2 ATR spazzano via le micro-vincite,
la fee mangia il resto. **Win-rate alto ≠ edge.**
- **Nessun multiplo ATR fisso batte il centro del canale**, su avg/trade E Sharpe, FULL e OOS,
entrambi gli asset.
## Verdetto unificato
Il **TP al centro del canale è ottimale** perché è un target *adattivo alla struttura*: un
multiplo fisso di ATR misura solo *quanta* vol c'è, ma ignora *dove* sta la media; il centro
adatta al punto reale di reversione **ed è già scalato alla volatilità** (il canale si allarga
in regime volatile). Per una mean-reversion il punto giusto dove chiudere è **la media — niente
prima, niente dopo.** Tre alternative escluse coi numeri (15m, trailing, TP-ATR) → la scelta
di design corrente è blindata.
> Nota metodologica ricorrente: diffidare del **win-rate alto**. Il segnale vero è
> rendimento-medio-per-trade × Sharpe; un TP stretto regala win-rate e nasconde l'assenza
> di edge. (Stesso tranello dei guru: backtest cherry-picked ad alta % di vincite.)
+70
View File
@@ -0,0 +1,70 @@
# 2026-05-31 — Stato trade LIVE PORT06 (paper trading)
> Snapshot verificato del paper trader a portafoglio (`src.portfolio.runner`, Docker
> `pythagoras-portfolio`). Dati da `data/portfolios/PORT06/` + log del container.
> Avvio container: 2026-05-29 18:37 UTC. Snapshot: 2026-05-31 13:20 UTC (~43h).
## Riepilogo capitale
| Metrica | Valore |
|---------|--------|
| Capitale iniziale | €1000.00 (17 sleeve equal-weight, ~€58.82 ciascuno) |
| `total_capital` (realizzato, ultimo rebal 00:00) | **€1000.09** (+0.09) |
| Equity mark-to-market (live) | **€1000.36** (+0.036%) |
| Peggior punto toccato | −€0.01 |
| **Max DD** | **0.40%** |
| Container | running, healthy, 0 restart |
## Trade chiusi (storia completa dallo startup: 10 trade, 9W/1L)
| # | Sleeve | Uscita | Net % | PnL € | Esito |
|---|--------|--------|------:|------:|:---:|
| 1 | PR01 ETH/SOL | mean_revert | +0.503 | +0.040 | W |
| 2 | PR01 ETH/SOL | mean_revert | +0.683 | +0.060 | W |
| 3 | SH01 BTC (ML) | hold_limit | 0.462 | 0.040 | L |
| 4 | SH01 BTC (ML) | hold_limit | +0.017 | +0.000 | W |
| 5 | PR01 ETH/SOL | mean_revert | +0.488 | +0.040 | W |
| 6 | PR01 ETH/SOL | mean_revert | +0.284 | +0.030 | W |
| 7 | PR01 LTC/ETH | mean_revert | +0.745 | +0.070 | W |
| 8 | PR01 BTC/LTC | mean_revert | +0.434 | +0.040 | W |
| 9 | MR02 ETH fade | take_profit | +0.995 | +0.090 | W |
| 10 | SH01 ETH (ML) | hold_limit | +0.742 | +0.070 | W |
| | **TOTALE** | | | **+0.400** | **90% win** |
### Aggregato per sleeve (trade chiusi)
| Sleeve | n | win | acc% | PnL € |
|--------|--:|----:|----:|------:|
| PR01 ETH/SOL | 4 | 4 | 100 | +0.170 |
| MR02 ETH fade | 1 | 1 | 100 | +0.090 |
| PR01 LTC/ETH | 1 | 1 | 100 | +0.070 |
| SH01 ETH (ML) | 1 | 1 | 100 | +0.070 |
| PR01 BTC/LTC | 1 | 1 | 100 | +0.040 |
| SH01 BTC (ML) | 2 | 1 | 50 | 0.040 |
Motore del PnL finora: **pairs PR01** (market-neutral, mean_revert rapidi 1-6 barre) +
una fade **MR02** su take_profit. Unica perdita: SH01 BTC (ML) su hold_limit (fisiologico,
edge nell'asimmetria, win-rate ~50%). Sleeve daily (ROT02/TSM01/TR01) e diverse fade non
hanno ancora chiuso trade (orizzonte più lungo / pochi segnali in ~2 giorni).
## Posizioni aperte (3)
| Sleeve | Dir | Entry | Capitale |
|--------|-----|------:|---------:|
| MR02 BTC fade | short | 73969.0 | €58.83 |
| MR02 ETH fade | long | 2016.15 | €58.92 |
| SH01 BTC (ML) | long | 73811.5 | €58.83 |
## Verifica (check 2026-05-31)
- **0 anomalie** sui 10 CLOSE: `net = gross fee` rispettato, flag `win` coerente col
PnL, fee sempre presente (pairs 0.4% su 2 gambe, fade 0.10% RT).
- **Uscite = backtest**: tutti i CLOSE pairs sono `mean_revert` con **|z| ≤ 0.75** al close
(0.363/0.605/0.684/0.619/0.656) = esattamente `z_exit=0.75` di PR01; MR02 esce a TP al
livello. Il worker live replica la regola del backtest.
- **Riconciliazione**: +0.40 realizzato vs +0.09 `total_capital` NON è un errore — è il
timing del ribilancio giornaliero (`total_capital` snapshotta a 00:00, le posizioni
aperte restano sul notional fino al rebal; CLAUDE.md). L'equity MtM live (+0.36) è il
numero corrente, confermato da `equity.jsonl`.
## Lettura onesta
Campione minuscolo (**~2 giorni, 10 trade**) → il PnL (+€0.40 realizzato, +€0.36 MtM) è a
livello di **rumore**: non se ne deduce performance. Quello che il check conferma a questo
stadio è che il sistema è **sano e fedele**: esecuzione corretta, costi reali inclusi,
uscite conformi al backtest, DD trascurabile (0.40%), 0 errori/restart. L'edge si
manifesterà solo su orizzonte settimane/mesi. Monitor Docker attivo per down/unhealthy/restart.
+51
View File
@@ -0,0 +1,51 @@
# 2026-05-31 — Copertura opzioni: idee testate e SCARTATE (record anti-ripetizione)
> Record delle conclusioni. Il **codice** di queste prove è stato testato e poi
> scartato (non conservato nel repo): qui restano i numeri e il *perché*, così da
> non ri-testare le stesse idee in futuro. Motore di pricing usato: Black-Scholes
> r=0 + IV stimata onestamente = RV × moltiplicatore VRP ≥ 1 (il compratore
> SOVRAPPAGA, come in W18-W21), fee Deribit reali (0.03%/gamba + ~0.10% slippage).
## TL;DR
**La copertura opzioni non genera edge nuovo per questo progetto.** I due edge
disponibili (trend e mean-reversion) sono già catturati **50-100× più a buon
mercato dai perp** (fee 0.10% RT) di quanto facciano le opzioni (premio + VRP +
asimmetria coda). Comprare premio perde contro il VRP crypto; venderlo paga le
code grasse. Cappare la perdita su una strategia senza expectancy positiva limita
solo *quanto* perdi: **non esiste il pasto gratis "leva alta + perdite coperte".**
## 1. Overlay opzioni su PORT06 — non fattibile
Mismatch di orizzonte: l'edge di PORT06 è intraday (hold fade ~9h). Carry ATM 9h
**0.96%** del notional vs edge fade per-trade **0.10-0.30%** → costo 3-10× l'edge.
La coda di PORT06 è già piccola (DD ~5%) e market-neutral (pairs ~57% del rischio):
poco da assicurare. La copertura giusta era già lì (diversificazione + stop), gratis.
## 2. Strategie nuove a copertura opzioni
- **OH01 — direzionale (TSMOM) + opzione protettiva / sola opzione.** Frontiera
iso-rischio: il **perp NON coperto domina a ogni livello di rischio** (Sharpe 0.90
vs 0.33-0.57; CAGR +33% vs negativo). Comprare protezione su un trend perde per il
carry/VRP (il trend-following è "long vol" nel *payoff*, non comprando opzioni).
- **OH02 — spread di credito su mean-reversion (vendi premio = VRP a favore).**
La copertura funziona (perdita cappata, DD basso, win-rate 73-80%: la reversione è
reale). Ma **expectancy ~0/leggermente negativa**: il 27% di trade dove il movimento
*continua* (code grasse) costa ~5× ogni vincita. Un trend filter porta solo *singole
celle* a +1-2% (overfit: config diversa per asset). Non robusto.
## 3. V5 — Bull Call Spread / debit spread (stile Casario)
È **la migliore struttura long-premium**: rischio definito funziona (worstRoll 13%
vs 64% della call secca, DD 54% vs 94%). **Ma net-negativo in crypto** (BTC 2.2%
full / 13.5% OOS) e il perp non coperto lo batte. Sweep larghezza: spread più larghi
rendono di più → **cappare l'upside toglie le code grasse che pagano il premio**.
**Verdetto:** valido **sulle AZIONI** (vol/VRP bassi, uptrend puliti da screener), NON
in crypto. Casario ha ragione nel suo dominio (equity), non trasferibile ai perp crypto.
## 4. V4 — Box strategy (max/min giorno prima, supply/demand) → SKIP
Core tradabile = **fadare gli estremi del canale = MR02** (già live). La candela di
conferma (doji/hammer/rejection) = pattern di rigetto = rumore (vedi diario TA). Nessun
edge nuovo: costruirlo ri-deriverebbe solo MR02.
## Cosa servirebbe per un vero edge a opzioni (fuori scope attuale)
Non direzione né reversione (già coperte dai perp), ma un edge *specifico delle opzioni*:
dislocazioni della superficie IV/skew, o gestione attiva (chiusura al 50% del credito,
roll). Richiede storico prezzi opzioni reale (qui assente, prezzi sintetici da BS) e un
feed greche/IV che il `CerberoClient` oggi non espone.
@@ -0,0 +1,43 @@
# 2026-05-31 — 3 strategie TA "classiche": testate e SCARTATE (record anti-ripetizione)
> Record delle conclusioni. Codice testato e poi scartato (non conservato nel repo).
> Strategie da contenuti trading-guru: (1) SMA20/200 trend+pullback, (2) Opening Range
> Breakout "ironclad", (3) "Weakness rectangle" reversal (ICT). Testate con la
> metodologia onesta del progetto: ingresso eseguibile a `close[i]`, SL/TP intrabar,
> fee Deribit 0.10% RT, leva 3x, OOS(ultimo 30%), griglia robustezza, sweep fee.
## TL;DR — tutte e 3 NO EDGE (negative anche a fee ZERO)
Tutte e tre **direzionali/continuazione**, tutte negative su BTC/ETH, su tutta la
griglia, **anche a fee 0%** → il problema è il *segnale* (avg_R per-trade ≤ 0), non i
costi. Riconfermano la lezione centrale: *sui perp crypto i breakout/continuazione
rientrano; l'unico edge robusto è la mean-reversion.*
| Strategia | Tipo | avg_R @ fee0 | Motivo |
|-----------|------|--------------|--------|
| **SMA01** MA-pullback | continuazione | 0.15 BTC / 0.07 ETH | win ~30% (serve ~40% a R:R 2) |
| **ORB01** opening-range breakout | breakout | 0.10…−0.19 | crypto 24/7: manca l'asta d'apertura, ragione d'essere dell'ORB |
| **WR01** weakness rectangle | reversal→continuazione | ≈ 0.05/0.00 | R:R "5:1" illusorio (win cala in proporzione); le weakness vengono travolte |
> Verificato indipendentemente (reimplementazione minima SMA01): a fee 0 avg_R
> 0.15/0.07. Il 100% di CAGR è solo l'edge negativo composto a leva 3x su migliaia
> di trade, non un bug.
## Tentativo di MIGLIORAMENTO — ribaltarle sul lato fade
Miglioramento *di principio* (non tuning): visto che perdono perché sono continuazione,
ribaltate sul **fade** (l'unico lato con edge in crypto).
| versione fade | edge? (avg_R@fee0) | verdetto |
|---------------|--------------------|----------|
| **SMA02** fade dell'estensione→SMA20 | **+** (0.04…0.36) | = **MR01 inferiore** (FULL 1h negativo, Sharpe 0.4-0.9 vs 2.7+) |
| **ORB02** fade del breakout del range | **+** (win 35%→50-66%) | = **MR02/MR07** senza controlli di rischio (DD 90-100%) |
| **WR02** weakness come reversione | **≈0** | **rumore**, non una fade-family nascosta |
- Il flip restituisce segno positivo a 2/3 (riconferma *fade > continuazione*) **ma nulla
di additivo**: SMA02/ORB02 sono ri-scoperte inferiori di strategie già live; WR02 è rumore.
- **Ipotesi "SMA200 piatta = meglio fadare" SMENTITA**: il regime *range* non batte il fade
semplice; semmai il regime *trend* dà avg_R migliore ma con time-in-market 0.5-9%.
## Lezione metodologica
La prova del nove è l'**avg_R a fee 0**: se una strategia perde anche senza costi, il
problema è il segnale e nessun tuning la salva. Le strategie che funzionano restano
MR01/MR02/MR07 (fade) + PR01 (pairs) + PORT06 — l'edge è mean-reversion + diversificazione.
@@ -0,0 +1,81 @@
# 2026-06-01 — Bugfix: SH01 usciva a 3 barre invece di H=12 (exit a orizzonte)
> Diagnosi partita da un check sulla debolezza apparente di **SH01_BTC** nel paper
> trading live PORT06 (accuratezza 33,3% su 3 trade). Non era sfortuna statistica:
> era un bug di exit nello `StrategyWorker`.
## Sintomo
Nel live PORT06 (Docker `pythagoras-portfolio`), SH01_BTC mostrava 3 trade tutti
`long`, **tutti chiusi con `reason: "hold_limit"` a `bars_held: 3`**, con `tp: null
sl: null`:
| # | entry | exit | bars | net % | esito |
|---|-------|------|------|------:|:---:|
| 1 | 73529.5 | 73433.0 | 3 | 0,46% | ❌ |
| 2 | 73759.5 | 73839.5 | 3 | +0,02% | ✅ |
| 3 | 73811.5 | 73766.0 | 3 | 0,32% | ❌ |
`oos_signal_precision` nei log di TRAIN scendeva 55,6% → 50,0% → 43,3%.
## Causa
SH01 (`scripts/strategies/SH01_shape_ml.py`, config **W24 H12 th0.58**) è una
strategia **horizon-only**: predice il segno del rendimento a **H=12 barre** ed esce a
H barre. I suoi Signal portano `metadata={"max_bars": H}` (=12) e **nessun TP/SL**.
Nello `StrategyWorker.tick()` la logica di uscita era:
```python
if self.tp and self.sl: # SH01: False (tp=sl=0)
... usa self.max_bars ... # -> max_bars=12 consultato SOLO qui
elif self.bars_held >= self.hold_bars: # fallback legacy hold_bars=3
self._close_position(..., "hold_limit") # SH01 finiva QUI
```
`self.max_bars` (=12, settato correttamente in `_open_position`) era onorato **solo
dentro il ramo `tp and sl`**. Senza TP/SL, SH01 cadeva sul fallback `hold_bars=3` e
chiudeva a 3 barre. L'edge di SH01 — per CLAUDE.md è nell'**asimmetria sull'orizzonte
H, non nella frequenza** (win-rate ~50%) — non aveva tempo di realizzarsi: tagliato a
3/12, degenera in rumore.
Solo SH01 (BTC+ETH) era colpito: tutte le fade (MR01/MR02/MR07, DIP01) portano
tp+sl+max_bars e usano il ramo intrabar corretto.
## Fix
`src/live/strategy_worker.py`: aggiunto un ramo per l'exit a orizzonte puro, prima del
fallback `hold_bars`:
```python
elif self.max_bars:
# Exit puro a orizzonte (strategie senza TP/SL, es. SH01 shape-ML H=12):
# onora max_bars dalla metadata del Signal, non il fallback hold_bars=3.
if self.bars_held >= self.max_bars:
self._close_position(current_price, "time_limit")
```
Le fade restano invariate (entrano nel ramo `tp and sl`).
## Verifica
- Nuovo test `tests/portfolio/test_horizon_exit.py` (2 casi): con `max_bars=12` resta
in posizione a 3 barre; esce a 12 con `reason: "time_limit"` e `bars_held: 12`.
- Suite completa: **43 passed**.
- Container riavviato: **tutti i 17 sleeve RESUME puliti**, inclusa una posizione
SH01_ETH short aperta che ora seguirà l'exit a 12 barre.
## Atteso d'ora in poi
I trade SH01 nei log mostreranno `reason: "time_limit"` con `bars_held: 12` invece di
`hold_limit / 3`. Il 33% di accuratezza era un artefatto dell'exit prematuro; ora la
strategia gira sull'orizzonte su cui è validata (BTC OOS Sharpe 2,72, expanding).
Resta comunque un **diversificatore** del MASTER, non un motore di ritorno standalone.
## Lezione
Il backtest di SH01 (`fade_base`/engine onesto) esce a H barre via `max_bars`; il
worker live deve replicarlo. Quando una strategia non porta TP/SL ma solo un
orizzonte, il fallback `hold_bars` del worker la **falsa silenziosamente**. Verificare
sempre che la convenzione di exit del worker live coincida con quella del backtest
validato — non solo l'ingresso.
@@ -0,0 +1,71 @@
# 2026-06-01 — SH01 live eseguiva la strategia SBAGLIATA (squeeze scartato), non shape-ML
> Scoperto verificando perché SH01 continuava a chiudere a `hold_limit/3` **anche dopo**
> il rebuild col fix horizon-exit. Il fix era corretto ma in un **ramo morto**: SH01 live
> non passava da `StrategyWorker.tick()`.
## Sintomo
Dopo il deploy del fix SH01 (exit a H=12), un close SH01_BTC delle 12:00 era ancora
`reason=hold_limit bars=3` (perdita 1,27%). Il fix non aveva effetto sul path reale.
## Causa (bug di wiring, più grave del previsto)
`src/portfolio/runner.py` importava `MLWorkerWrapper` da **`src/live/multi_runner.py`** e
ci avvolgeva lo sleeve SH01:
```python
if spec.kind == "ml":
return MLWorkerWrapper(worker, {"retrain_hours": 24})
```
Ma quel wrapper è **legacy, per la famiglia squeeze ML01** (scartata, vedi CLAUDE.md):
- usa `SignalEngine` = squeeze-detection + GradientBoosting (NON SH01_shape_ml);
- ha una `tick()` propria che apre con un `Signal` **nudo** (niente tp/sl/max_bars) ed
esce con `if bars_held >= hold_bars: close("hold_limit")` → ignora del tutto la
strategia caricata e il fix horizon.
Quindi lo sleeve "SH01" del portafoglio live **non eseguiva shape-ML**: eseguiva il
motore squeeze scartato. I log `TRAIN OK / oos_signal_precision` venivano da lì. Il
`worker` con strategy=SH01_shape_ml era costruito ma la sua `generate_signals` non
veniva **mai** chiamata.
## Fix
SH01 (`kind="ml"`) ora gira come **StrategyWorker normale**: `SH01_shape_ml.generate_signals`
fa il walk-forward (retraining) **internamente** ad ogni tick (`ml_wf_entries`) ed emette
`metadata.max_bars=H=12` → gli exit passano per `StrategyWorker.tick()` e il fix horizon
si applica davvero.
```python
# runner.py: niente più MLWorkerWrapper per kind="ml"
return StrategyWorker(strategy=strategy, asset=spec.asset, tf=spec.tf, ...)
```
**Lookback dati.** `ml_wf_entries` ha `train_min=4000` → servono ≥4000 barre 1h prima di
produrre segnali (con 90g/2160 barre → 0 segnali, runtime 0.01s — il falso "muto"). Le
candele 1h di BTC/ETH già arrivano a 440g (le richiede TSM01/ROT02 a 1d), ma per non
dipendere da quella coincidenza ho aggiunto `_ML_LOOKBACK_DAYS=365`: gli asset usati da
sleeve ml fetchano ≥365g (~8760 barre). Costo `generate_signals` su 365g: **0,170,24s**
(modello logit) → trascurabile sul poll 60s.
**Verifica.** Build SH01 → `StrategyWorker` con `strategy.name=="SH01_shape_ml"`, niente
attributo `engine` (regression test `test_build_ml_sh01_is_plain_strategyworker`). Smoke
su 365g: 7661786 segnali, tutti `max_bars=12`; tick live 0,17s. `ml_wf_entries` non
predice mai l'ultima barra (`n-1`) ma fino a `n-2` = esattamente la condizione di apertura
del worker (`idx >= last_idx-1`) → apre quando il segnale è fresco. Suite: 51 passed.
**Stato live.** SH01 BTC/ETH erano flat: contatori resettati a 0 (capitale preservato
58,76/58,78), vecchi trade squeeze archiviati in `trades_squeeze_archive.jsonl`. Rebuild
+ recreate: 14 worker RESUME puliti, container healthy, nessun log TRAIN/squeeze, zero
errori.
## Lezione
1. **Verificare il path REALE, non solo il codice del fix.** Il fix horizon era giusto ma
SH01 non lo attraversava. Un fix non testato end-to-end sul percorso vivo è un fix
presunto. (Mi ero fidato del rebuild senza confermare il reason dei close SH01.)
2. Riusare un wrapper legacy "perché c'è" è un rischio: `MLWorkerWrapper` di multi_runner
era per la famiglia squeeze scartata, non per shape-ML.
3. Un modello ML "muto" può essere solo **fame di dati** (train_min), non un bug logico:
controllare sempre la dimensione della finestra prima di concludere.
+89
View File
@@ -0,0 +1,89 @@
# 2026-06-01 — "Win" che perdono: metrica netto-fee + filtro TP edge-minimo
> Partito da un'osservazione dell'utente sui trade live PORT06: **ci sono close con
> `win=True` ma `pnl` negativo**. Due problemi distinti, entrambi risolti.
## Problema 1 — la metrica `win` mentiva (lordo invece di netto)
In `strategy_worker.py::_close_position`:
```python
trade_return = price_change * direction # LORDO, prima delle fee
net = trade_return * leverage - fee_rt * leverage
pnl = capital * position_size * net # corretto (netto)
is_win = trade_return > 0 # BUG: usa il LORDO
```
`is_win` scattava appena il prezzo si muoveva di un soffio a favore, **prima delle
fee**. Capitale e PnL erano giusti (netti); solo la metrica `win`/`accuracy` era
gonfiata.
**Quantificazione (51 close live):** 39 win dichiarate (76,5%) → **13 falsi win**
(`win=True` ma `pnl≤0`) → accuratezza **netta reale 52,9%**. PnL realizzato +€0,77
(resta positivo: lo trascinano i pairs).
**Fix:** `is_win = net > 0`. + `tests/portfolio/test_win_net_of_fees.py` (mossa
sotto-fee = non win; oltre-fee = win; perdita = non win).
**Riconciliazione contatori persistiti:** i `total_wins` su disco erano gonfiati dal
vecchio conteggio lordo. Ricalcolati come `net_return>0` dai `trades.jsonl`:
**MR01_BTC 7→1, DIP01_BTC 7→1** (gli unici toccati; tutti gli altri già coerenti).
Capitale invariato.
## Problema 2 — i 13 falsi win erano tutti MR01_BTC / DIP01_BTC in take_profit
Causa: in `MR01_bollinger_fade` e `DIP01_dip_buy` il **TP è la media** (`tp = ma[i]`)
e l'entry è a `close[i]` appena fuori banda. Nel regime BTC **piatto** (inchiodato
~73.700 per ore, vol bassissima) la media è a pochi dollari dall'entry → il TP cade
**dentro** il costo round-trip (0,10%): colpire il TP = perdita netta garantita.
**Meccanismo del fix (importante).** "Spostare il TP più in là" NON garantisce di non
perdere: il prezzo rientra solo fino alla media, non oltre → si finirebbe su SL/time-
limit, perdendo di più. La mossa provabilmente non-perdente è un **filtro di edge
minimo**: se `|tp entry|/entry ≤ min_tp_frac` non si apre la trade. Break-even
esatto = `fee_rt` (= 0,10%, indipendente dalla leva, perché
`ret = mossa·lev fee_rt·lev > 0 ⇔ mossa > fee_rt`).
**Implementazione:** parametro `min_tp_frac` (default 0.0 = off) in **tutte le 4 fade**
(MR01 banda, MR02 midpoint canale, MR07 ATR-scaled) e DIP01; salta i segnali sotto
soglia. Cablato negli sleeve live a **0.0015 (1,5× fee)** in `_defs.py` (`MIN_TP_FRAC`).
**Validazione backtest (BTC+ETH 1h, config sleeve, min_tp_frac ∈ {0,.001,.0015,.002}):**
neutro su tutte e 4 le fade.
- MR01: 0 trade rimossi (BTC +8028€, ETH +10395€) — metriche identiche.
- DIP01 BTC: 1 trade a 0.002, **migliora** (+7492→+7522€, DD 26,3→25,9%).
- MR02 BTC: 1 trade a 0.0015 (pnl invariato +12198€), ETH 0 rimossi.
- MR07 BTC/ETH: 0 rimossi (TP ATR-scaled sempre ben oltre le fee nello storico).
Conclusione: i micro-scalp sotto-fee **non esistono nel campione storico** — sono un
artefatto del regime attuale. Il filtro è **puro upside**: neutro sul backtest validato,
protettivo dal vivo. (Le 12 trade live incriminate, tutte MR01/DIP01 BTC, avevano gap
~0,026%, ben sotto 0,15% → tutte bloccate.)
+ `tests/portfolio/test_min_tp_frac.py` (monotonia + ogni superstite ha gap > soglia
+ default-off invariato).
## Nota deploy — il codice è COTTO nell'immagine, non montato
Scoperta durante il deploy: `docker-compose.yml` monta solo `./data` e
`./portfolios.yml`; il sorgente (`src/`, `scripts/`) è `COPY` nel Dockerfile. Quindi
**`docker compose restart` NON ricarica le modifiche al codice** — serve
`docker compose up -d --build`. Conseguenza retroattiva: anche il fix SH01
horizon-exit di stamattina è andato live solo con questo rebuild. Da ricordare per ogni
futura modifica ai worker. Il volume `./data` persiste → i 14 worker fanno RESUME
puliti dopo il rebuild (capitale e posizioni intatti).
## Stato finale
- `is_win = net > 0` live; contatori riconciliati (MR01/DIP01 BTC 1/9).
- Filtro `min_tp_frac=0.0015` live su tutti i fade + DIP01 (attivo solo MR01/DIP01).
- Fix SH01 horizon-exit ora **effettivamente** live (rebuild).
- Suite: 49 passed. Container ricostruito, healthy, 14 sleeve in RESUME.
## Lezione
1. Una metrica di "win" deve essere **netto fee**, altrimenti l'accuracy è teatro.
2. Quando il TP è dentro il costo di transazione, la trade è persa in partenza: meglio
**non prenderla** che ritoccare il TP.
3. Per i worker live in Docker: **rebuild**, non restart. Il restart ricarica solo lo
stato dal volume, non il codice.
+67
View File
@@ -0,0 +1,67 @@
# 2026-06-02 — Loss-guard per le fade: filtro Hurst (regime persistente)
> Goal: limitare le perdite delle fade in "bassa vol". Diagnosi empirica + ricerca web + workflow
> 11 agenti + test decisivo a livello PORT06. Branch `feat/fade-lossguard`.
## Riformulazione del problema (la premessa era imprecisa)
Diagnosi su 3022 trade fade (MR01/MR02/MR07 × BTC/ETH, 2021+): **le perdite NON si concentrano in
bassa vol** — anzi il terzile low-DVOL è net positivo (+2,30%/trade). Il vero driver è il **regime
PERSISTENTE/trending**, misurato dall'Hurst:
- somma perdite peggiore: **hurst>0,55** (2695% in low-vol, dominante in ogni terzile vol)
- **stop-rate 43% per hurst>0,55 vs 21% per hurst<0,45** (anti-persistente) — 2x
- peggiori 1% trade: Hurst medio 0,61 (77% con hurst>0,55, solo 13% in bassa-DVOL)
## Ricerca web (confermata e smentita dai dati reali)
- **Hurst regime filter** (MR solo H<0,45, evitare H>0,55): **CONFERMATO** sui dati reali. ✅
- **ADX** (PF 1,62 sotto 20 vs 0,74 sopra 30): **NON si replica** — ADX-skip uccide l'edge
(Sharpe 4,82→0,99) e lo stop-rate non scende. ❌
- **vol-expansion ATR-ratio>1,5 (72% perdite)**: **NON si replica** — alza DD e stop-rate. ❌
- **time-stop ~15 barre**: riduce stop-rate ma alza il DD full → non passa standalone. ❌
## Workflow 11 agenti — meccanismi testati
| Meccanismo | OOS Sharpe (base→filt) | DD full | Buon loss-guard? |
|---|---|---|---|
| **Hurst-SKIP h<0,55** | 4,82→4,96 ↑ | 24,3→13,8% ↓ | **SÌ** |
| **Hurst-SIZE 1/0,5/0,25** | 4,65→5,32 ↑ (full) | 33,6→11,3% maxDD ↓ | **SÌ** |
| ADX-skip | 4,82→0,99 ✗ | — | NO (uccide edge) |
| vol-expansion vratio | 4,82→4,04 | 24,3→27,5% ✗ | NO |
| Kaufman ER, time-stop, vol-target, DVOL-rising, combo | tutti ↓ o DD↑ | — | NO |
**Solo l'Hurst** isola chirurgicamente il regime tossico; gli altri sono "dimmer uniformi" che
tagliano winner insieme ai loser (gate FR01 fallito).
## TEST DECISIVO a livello PORT06 — SUPERATO ✅
Applicato l'Hurst-skip alle 6 fade dentro il PORT06 intero (equal-weight, le altre 11 sleeve
invariate):
| Portafoglio | FULL Sharpe | FULL DD | OOS Sharpe | OOS DD | OOS ret |
|-------------|:--:|:--:|:--:|:--:|:--:|
| PORT06 baseline | 6,62 | 4,10% | 8,89 | 1,22% | +175% |
| **+ Hurst-skip h<0,55** | **6,76** | **2,39%** | **9,15** | 1,54% | +158% |
| + Hurst-skip h<0,50 | 6,61 | 2,08% | 9,02 | 1,54% | +150% |
**A differenza di FR01 (che diluiva), il filtro Hurst MIGLIORA il PORT06**: FULL Sharpe ↑, **FULL
DD quasi dimezzato (4,10→2,39%)**, OOS Sharpe ↑ (8,89→9,15). Costo: OOS DD +0,3pp (resta minuscolo),
OOS ret 17pp. **h<0,55 è il pick** (0,50 taglia più ritorno). Non aumenta il profitto: è puro
**rischio** — dimezza il DD mantenendo/alzando lo Sharpe.
## Implementazione
Aggiunto `hurst_skip_mask` in `src/strategies/fade_base.py` (rolling-Hurst causale dalle SOLE close)
+ parametro `hurst_max` (default None=off) in MR01/MR02/MR07. Test: `test_hurst_lossguard.py`.
**Vantaggio operativo decisivo vs FR01:** l'Hurst si calcola **dalle sole close** → nessun feed
DVOL/regime live necessario. Lo `StrategyWorker` lo computa inline dai dati che già ha → **deployabile
senza nuova infrastruttura**, basta settare `hurst_max: 0.55` nei params degli sleeve fade.
## Da fare per attivarlo live (deploy)
1. Settare `hurst_max: 0.55` nei params delle fade in `_defs.py` (sleeve live) + aggiornare i params
fade del backtest (`combine_portfolio`/`report_families`) per PARITÀ + rigenerare il
regression-lock PORT06 (i numeri canonici cambiano: DD 4,9→~2,4%).
2. Verificare che il rolling-Hurst live nel worker coincida col backtest (stessa finestra 100,
stesso stepping causale).
3. Rebuild immagine Docker (`up -d --build`, non restart) + verifica RESUME.
Default attuale: `hurst_max` OFF → zero impatto su backtest/parità/live finché non lo si attiva
esplicitamente. Il SISTEMA è trovato e validato; l'attivazione è una decisione di deploy.
@@ -0,0 +1,89 @@
# 2026-06-02 — Ricerca a 100 agenti: Frattali del segnale × Regime ARGO
> Workflow multi-agente (171 agenti, 8,4M token, ~7h) per cercare una strategia che combini
> un SEGNALE FRATTALE con un GATE/INTERAZIONE DI REGIME ARGO (DVOL/funding/VRP), validata OOS.
> Branch: `feat/fractal-argo-search`. Substrato e codice sul branch, niente impatto su main/live.
## Substrato costruito
- `regime_fetcher.py`: DVOL (2021-03→oggi) + funding (2019→oggi) BTC/ETH da **Deribit mainnet
public** (no-auth, OI/IV reali — non il testnet farlocco di Cerbero).
- `regime_lab.py`: allineamento regime↔prezzo **causale no-look-ahead** (merge_asof backward),
feature regime (dvol_pct, **vrp=dvolrv**, funding_z, dvol_chg) + frattali (rolling Hurst,
Higuchi FD, vol-ratio, Williams pivot), cache feature precalcolate, validazione netto-fee OOS
via `explore_lab`. Bug corretto in corsa: `vrp` annualizzava la realized-vol sempre come 1h →
rotta su 4h/1d (sempre negativa); fix per timeframe.
- `fractal_argo_workflow.js`: 84 agenti griglia (7 famiglie frattali × 6 angoli regime × BTC/ETH)
+ 8 wildcard + verifica avversariale dei survivor + sintesi.
## Verdetto
**Esistono edge frattale×regime reali, causali, robusti** (15 confermati dalla batteria
avversariale: audit look-ahead bit-esatto, cross-asset, split alternativo, fee 0.2% RT, plateau).
**MA nessuno batte PORT06 standalone** (OOS Sharpe 8,19 / DD 2,3%): sono **diversificatori a
bassa esposizione** (1,5-8%, ~100-460 trade), profilo SH01/pairs.
### Top candidati confermati
| Strategia | Asset/TF | OOS Sharpe | OOS DD | trade |
|-----------|----------|:--:|:--:|:--:|
| FRAC-VRP multiscala (chop × VRP<0) | ETH 1h | 5,55 | 11% | 184 |
| HigVRP-Fade (Higuchi alto × VRP<0) | BTC 1h | 4,55 | 7,2% | 286 |
| **HurstCalmFade (hurst<0,55 × dvol<0,4)** | BTC 1h | **3,73** | 5,1% | 198 |
| WILD8 Pivot-Hurst | BTC 4h | 3,87 | 10% | 482 |
| AnalogFundingFade (kNN forma × funding) | ETH 4h | 2,15 | 9,3% | 229 |
## Il finding chiave (controintuitivo): il prior ARGO è SMENTITO
La tesi naïve **"VRP>0 = GEX+ = range = fade" FALLISCE** sistematicamente. L'edge robusto e
ripetuto è su **VRP<0** (vol implicita *sottoprezzata* vs realizzata → mean-reversion whippy) e su
**DVOL bassa**, l'opposto del brief. Gate invertito VRP>0 → Sharpe 2,08/1,30 su entrambi gli
asset. È il risultato più solido di tutta la ricerca, look-ahead-clean (lag-1/3/6 robusto).
## Cosa aggiunge valore vs cosa è decorativo
- **Load-bearing (confermato per ablazione):** VRP<0; hurst-low × dvol-low (HurstCalmFade taglia
DD OOS 17%→5%); funding_z estremo |fz|≥1,8 (analog ETH 4h).
- **Decorativo (DD-reducer, non interazione):** dvol_chg, dvol_high/low e funding come gate spesso
riducono solo esposizione senza migliorare il segno.
## Cosa è RUMORE (conferma i priori del progetto)
- Frattali da soli (angolo=none): hurst/Higuchi/vratio/chop/candle/analog intraday → non robusti
(DD 30-90%, muoiono di fee). Conferma `shape_lab`.
- Momentum/breakout gateato (hurst>0,6, dvol-rising): catastrofico (Sharpe 2…−7) → riconferma
dominanza mean-reversion, i breakout rientrano.
- ARGO-GEX nella direzione attesa (VRP>0): perde. Coerente con W18/19/21 scartate.
- Pivot-fade non-laggato (frac_up[i]): artefatto squeeze-like, va sempre laggato a i2.
## Vincitore selezionato + test decisivo
**FR01 HurstCalmFade BTC 1h** (`scripts/strategies/FR01_hurst_calm_fade.py`): il più verificato,
DD più basso (5,1% OOS), generalizza a ETH. **Test di correlazione decisivo** (la domanda che
conta: aggiungerlo migliora il PORT06 o è ridondante?): correlazione daily-returns coi fade
esistenti **MR01 +0,17 / MR02 +0,08 / MR07 0,03****BASSA, quasi-ortogonale**, NON ridondante.
Passa il gate → vale l'inserimento come diversificatore.
## Onestà finale
L'edge frattale×regime è **reale, causale, robusto** ma è sempre **mean-reversion già nota
condizionata dal regime (VRP<0 / hurst-low / dvol-low)**, non un motore ortogonale nuovo. Valore =
**riduzione DD aggregato come sleeve a bassa esposizione**. La correlazione bassa lo qualifica come
diversificatore reale.
## TEST DECISIVO SUL MASTER — VERDETTO FINALE: NON deployare
Misurato il contributo marginale di FR01 al PORT06 intero (equal-weight, `master_corr`):
| Portafoglio | FULL Sharpe | OOS Sharpe | OOS DD | OOS ret |
|-------------|:--:|:--:|:--:|:--:|
| PORT06 (17 sleeve) | 6,62 | **8,89** | 1,2% | +175% |
| PORT06 + FR01 (19) | 6,55 | **8,72** | 1,1% | +156% |
**FR01 NON migliora il PORT06: lo DILUISCE** (OOS Sharpe 8,89→8,72, OOS ret +175%→+156%; DD
marginalmente meglio 1,2→1,1% ma a costo di Sharpe). Corr FR01 vs MASTER +0,18 (BTC)/+0,23 (ETH).
**Causa + nota di onestà metrica:** lo Sharpe "3,73" dei report del workflow è **per-trade/annuale**
(`explore_lab`); quello rilevante per il portafoglio è lo **Sharpe daily-return** (`combine`), che per
FR01 è solo **~1,85/1,53** — troppo basso per muovere un PORT06 a 8,89. È "ridondanza robusta":
mean-reversion regime-gated che si sovrappone a ciò che il MASTER già fa.
**ESITO: il search a 100 agenti ha trovato strategie robuste e causali, ma NESSUNA migliora il
PORT06.** Non deployare FR01 né i candidati gemelli. Valore del progetto resta nell'estendere
fade/pairs validati. Tutto resta come RECORD DI RICERCA sul branch (non si merge in produzione).
Wiring DVOL live e walk-forward: non necessari, deploy abbandonato.
@@ -0,0 +1,174 @@
# Multi-Strategy Paper Trader — Design Spec
## Obiettivo
Eseguire N strategie di trading in parallelo su Deribit testnet (paper trading locale), ognuna con capitale virtuale indipendente di €1000 USDC. Lo storico trade di ogni strategia persiste tra restart. Nuove strategie aggiungibili in corso d'opera via config YAML senza perdere lo storico delle esistenti.
## Architettura
Un singolo container Docker esegue un orchestratore (`MultiStrategyRunner`) che gestisce N `StrategyWorker`. Ogni worker è indipendente: proprio capital, propri trade, proprio stato.
```
Docker Container
├── MultiStrategyRunner (orchestratore, loop principale)
│ ├── StrategyWorker[SQ02_BTC_15m] → paper trade → JSONL
│ ├── StrategyWorker[ML01_ETH_15m] → paper trade → JSONL
│ └── ...altri worker da YAML
├── CerberoClient (condiviso, fetch prezzi)
└── TelegramNotifier (condiviso)
```
## Componenti
### 1. `strategies.yml` — Configurazione
```yaml
defaults:
capital: 1000
position_size: 0.15
leverage: 3
hold_bars: 3
poll_seconds: 60
retrain_hours: 24
strategies:
- name: SQ02_antifake_vol
asset: BTC
tf: 15m
enabled: true
- name: SQ02_antifake_vol
asset: ETH
tf: 15m
enabled: true
- name: ML01_squeeze_gbm
asset: ETH
tf: 15m
enabled: true
position_size: 0.20
params:
ml_threshold: 0.70
bb_window: 14
sq_threshold: 0.8
```
Ogni entry eredita `defaults`. Override per-strategia possibile su tutti i campi. Il campo `params` passa kwargs a `generate_signals()` o al backtest ML.
### 2. `StrategyWorker` — Worker per singola strategia
Responsabilità:
- Importa la classe Strategy corrispondente da `scripts/strategies/`
- Mantiene stato: capital, posizione aperta, equity
- Al startup: ricarica `status.json` se esiste (resume), altrimenti inizia da zero
- Ad ogni tick: riceve DataFrame candele, genera segnali, paper-trade
- Logga ogni evento in `trades.jsonl` (append-only)
- Aggiorna `status.json` ad ogni tick
Stato persistente (`status.json`):
```json
{
"capital": 1023.45,
"in_position": true,
"direction": "long",
"entry_price": 2534.20,
"entry_time": "2026-05-27T14:30:00Z",
"bars_held": 1,
"total_trades": 15,
"total_wins": 12,
"started_at": "2026-05-27T10:00:00Z"
}
```
Trade log (`trades.jsonl`), append-only:
```json
{"ts": "2026-05-27T14:30:00Z", "event": "OPEN", "direction": "long", "price": 2534.20, "size": 0.18, "capital": 1023.45}
{"ts": "2026-05-27T15:15:00Z", "event": "CLOSE", "reason": "hold_limit", "entry": 2534.20, "exit": 2560.10, "pnl": 3.45, "fee": 0.92, "net_pnl": 2.53, "capital": 1025.98}
```
### 3. `MultiStrategyRunner` — Orchestratore
Loop principale:
1. Carica `strategies.yml`
2. Per ogni entry, crea `StrategyWorker` (o riprende se già esiste)
3. Ogni 60s:
a. Fetch candele live da Cerbero (una volta per asset/tf unico)
b. Passa DataFrame a ogni worker
c. Ogni worker valuta segnali e gestisce posizione
d. Worker ML: retrain ogni 24h
4. Notifica Telegram per ogni trade
Ottimizzazione: fetch candele raggruppato per (asset, tf). Se 3 strategie usano BTC 15m, fetch una volta sola.
### 4. Persistenza
```
data/paper_trades/
SQ02_antifake_vol__BTC__15m/
trades.jsonl
status.json
SQ02_antifake_vol__ETH__15m/
trades.jsonl
status.json
ML01_squeeze_gbm__ETH__15m/
trades.jsonl
status.json
```
Directory naming: `{strategy_name}__{asset}__{tf}` con double underscore separatore.
Volume Docker: `./data:/app/data` — persiste tra restart.
### 5. Aggiunta strategia in corso
1. Aggiungi entry in `strategies.yml`
2. `docker compose restart`
3. Runner carica YAML, trova nuova entry senza `status.json` → parte da €1000
4. Strategie esistenti riprendono da `status.json` → storico intatto
### 6. Docker
`Dockerfile` — invariato, aggiunge `strategies.yml` alla COPY.
`docker-compose.yml`:
```yaml
services:
paper-trader:
build: .
container_name: pythagoras-multi
restart: unless-stopped
volumes:
- ./data:/app/data
- ./strategies.yml:/app/strategies.yml:ro
env_file:
- .env
environment:
- PYTHONUNBUFFERED=1
```
`CMD` cambia a: `uv run python -m src.live.multi_runner`
### 7. Strategia-specifica: ML01
ML01 richiede training del modello GBM. Il worker ML01:
- Al primo avvio: train su storico (365 giorni via Cerbero)
- Ogni `retrain_hours`: retrain
- Usa `SignalEngine` esistente per check_signal()
- Le strategie SQ* non hanno training — solo regole deterministiche
### 8. File da creare/modificare
Nuovi:
- `src/live/multi_runner.py` — orchestratore
- `src/live/strategy_worker.py` — worker per singola strategia
- `strategies.yml` — config
- `src/live/strategy_loader.py` — import dinamico classi Strategy
Modifiche:
- `docker-compose.yml` — nuovo CMD, volume strategies.yml
- `Dockerfile` — COPY strategies.yml
Invariati:
- `src/live/cerbero_client.py`
- `src/live/telegram_notifier.py`
- `src/live/signal_engine.py` (usato da ML01 worker)
@@ -0,0 +1,377 @@
# Fase 2-B — Worker live honest/TSM01 (dedicati) — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development o executing-plans. Steps con checkbox `- [ ]`.
**Goal:** Costruire i worker live mancanti perché PORT06 giri live al completo (oltre a fade+pairs+shape già pronti): DIP01, TR01 (basket), ROT02 (rotation), TSM01 (tsmom rotation), e integrarli nel `PortfolioRunner`.
**Architecture:** Worker DEDICATI per ogni strategia (scelta utente). DIP01 è single-asset → Strategy subclass + `StrategyWorker` esistente. TR01/ROT02/TSM01 sono multi-asset/rotation → tre classi worker nuove in `src/live/` con stato per-asset persistente, ciascuna fedele alla rispettiva funzione di backtest in `scripts/analysis/{honest_improve2,tsmom_research}.py`. Integrazione in `src/portfolio/runner.py::build_worker_for` + tick.
**Tech Stack:** Python 3.11, pandas/numpy, pytest. Riusa CerberoClient v2 (multi-asset fetch), PortfolioLedger, e le funzioni di riferimento honest/tsm.
**Branch:** `portfolio_phase2`. **Spec madre:** `docs/superpowers/specs/2026-05-29-portfolios-design.md` (§ scope live, fase 2).
**Riferimenti di logica (NON modificare, sono la verità del backtest):**
- DIP01 → `honest_improve2.dip_market_gated` (z-score dip, gate BTC>SMA, TP=SMA/SL=ATR/max_bars, intrabar).
- TR01 → `honest_improve2._tr_basket_daily` (per asset 4h: EMA20>EMA100 long/flat; basket equal-weight).
- ROT02 → `honest_improve2._rot_daily_equity` (panel 1d, mom 60g, top-3 se mom>0 e BTC>SMA100, gross 0.45 split, ribilancio giornaliero).
- TSM01 → `tsmom_research.tsmom_sim` (panel 1d, Σ sign(P/P[-h]) h∈{63,126,252} ≥ thr=1.0, gate BTC>SMA100, gross 0.30 split).
---
## File structure
| File | Responsabilità |
|------|----------------|
| `scripts/strategies/DIP01_dip_buy.py` | Strategy `Dip01DipBuy` (single-asset; metadata tp/sl/max_bars + gate) |
| `src/live/basket_trend_worker.py` | `BasketTrendWorker` (TR01): N asset 4h, EMA cross, long/flat per asset |
| `src/live/rotation_worker.py` | `RotationWorker` (ROT02): panel 1d, dual-momentum top-k, gross split |
| `src/live/tsmom_worker.py` | `TsmomWorker` (TSM01): panel 1d, consenso segni multi-orizzonte |
| `src/live/strategy_loader.py` | **mod**: aggiungi `DIP01_dip_buy` a MODULE_MAP |
| `src/portfolio/runner.py` | **mod**: `build_worker_for` gestisce kind "basket"/"rotation"/"tsmom"; tick multi-asset |
| `src/portfolio/base.py` (`_defs.py`) | **mod**: SleeveSpec degli honest/tsm con `kind` e `universe` corretti |
| `tests/portfolio/test_honest_workers.py` | unit per ciascun worker + replay==backtest su finestra |
**Universi:** TR01 = [BNB,BTC,DOGE,SOL,XRP] (4h); ROT02/TSM01 = `available_assets()` (1d). I worker multi-asset ricevono il dict {asset: df} dal runner.
---
## Task 1: DIP01 come Strategy single-asset
**Files:** Create `scripts/strategies/DIP01_dip_buy.py`; Modify `src/live/strategy_loader.py`; Test `tests/portfolio/test_dip01.py`.
- [ ] **Step 1: Test (fallisce)**`tests/portfolio/test_dip01.py`:
```python
import pandas as pd
from src.data.downloader import load_data
from scripts.strategies.DIP01_dip_buy import Dip01DipBuy
def test_dip01_generates_long_signals_with_exits():
df = load_data("BTC", "1h").iloc[-5000:].reset_index(drop=True)
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
sigs = Dip01DipBuy().generate_signals(df, ts, asset="BTC", tf="1h")
assert len(sigs) > 0
s = sigs[0]
assert s.direction == 1 # dip-buy è solo long
assert {"tp", "sl", "max_bars"} <= set(s.metadata)
```
- [ ] **Step 2:** `uv run pytest tests/portfolio/test_dip01.py -v` → FAIL (ModuleNotFoundError).
- [ ] **Step 3: Implementa `scripts/strategies/DIP01_dip_buy.py`.** Replica ESATTA della logica di `dip_market_gated` (default `market_n=0` = senza gate, come lo sleeve DIP01_BTC del portafoglio: vedi combine_portfolio che usa `market_n=0`). Genera Signal long quando `z[i] <= -z_in and z[i-1] > -z_in`, con metadata `tp=SMA[i]`, `sl=c[i]-sl_atr*atr[i]`, `max_bars`. fee_rt=0.001, leverage 3, position 0.15.
```python
"""DIP01 — Dip-buy mean-reversion single-asset (z-score sotto-banda). Honest family.
Replica live della logica validata in scripts/analysis/honest_improve2.dip_market_gated
(con market_n=0, come lo sleeve DIP01_BTC del portafoglio): compra quando lo z-score del
prezzo rispetto a SMA(n) incrocia sotto -z_in; esce a TP=SMA, SL=close-sl_atr*ATR, o max_bars.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from src.strategies.base import Strategy, Signal # noqa: E402
def _atr(df, n=14):
h, l, c = df["high"].values, df["low"].values, df["close"].values
pc = np.roll(c, 1); pc[0] = c[0]
tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc)))
return pd.Series(tr).rolling(n).mean().values
class Dip01DipBuy(Strategy):
name = "DIP01_dip_buy"
description = "Dip-buy mean-reversion single-asset (z-score), exit TP=SMA/SL=ATR/max_bars"
default_assets = ["BTC"]
default_timeframes = ["1h"]
fee_rt = 0.001
leverage = 3.0
position_size = 0.15
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
n: int = 50, z_in: float = 2.5, sl_atr: float = 2.5,
max_bars: int = 24, **params) -> list[Signal]:
c = df["close"].values
ma = pd.Series(c).rolling(n).mean().values
sd = pd.Series(c).rolling(n).std().values
a = _atr(df, 14)
z = (c - ma) / np.where(sd == 0, np.nan, sd)
out: list[Signal] = []
for i in range(n + 14, len(c)):
if np.isnan(z[i]) or np.isnan(a[i]) or np.isnan(ma[i]):
continue
if z[i] <= -z_in and z[i - 1] > -z_in:
out.append(Signal(idx=i, direction=1, entry_price=float(c[i]),
metadata={"tp": float(ma[i]),
"sl": float(c[i] - sl_atr * a[i]),
"max_bars": int(max_bars)}))
return out
```
- [ ] **Step 4: Registra nel loader.** In `src/live/strategy_loader.py` MODULE_MAP aggiungi:
```python
"DIP01_dip_buy": ("DIP01_dip_buy", "Dip01DipBuy"),
```
- [ ] **Step 5:** `uv run pytest tests/portfolio/test_dip01.py -v` → 1 passed.
- [ ] **Step 6: Commit**
```bash
git add scripts/strategies/DIP01_dip_buy.py src/live/strategy_loader.py tests/portfolio/test_dip01.py
git commit -m "feat(live): DIP01 dip-buy come Strategy single-asset (worker via StrategyWorker)"
```
**Nota:** DIP01 nel runner usa lo StrategyWorker esistente (kind="single", name="DIP01"). Aggiorna `_STRAT_MODULE` in `runner.py` con `"DIP01": "DIP01_dip_buy"` e in `_defs.py` lo SleeveSpec DIP01_BTC resta kind="single". Il backtest dello sleeve DIP01_BTC continua a venire da `build_everything` (parità invariata).
---
## Task 2: `BasketTrendWorker` (TR01)
**Files:** Create `src/live/basket_trend_worker.py`; Test `tests/portfolio/test_basket_worker.py`.
- [ ] **Step 1: Test (fallisce)** — verifica che, dato un dict {asset: df 4h}, il worker calcoli posizione long/flat per asset secondo EMA20>EMA100 e aggiorni il capitale equal-weight:
```python
import numpy as np
import pandas as pd
from src.live.basket_trend_worker import BasketTrendWorker
def _ramp_df(n=300, slope=1.0):
c = np.linspace(100, 100 + slope * n, n)
ts = (pd.date_range("2024-01-01", periods=n, freq="4h", tz="UTC").astype("int64") // 10**6)
return pd.DataFrame({"timestamp": ts, "open": c, "high": c, "low": c, "close": c, "volume": 1.0})
def test_basket_goes_long_in_uptrend(tmp_path):
w = BasketTrendWorker(universe=["AAA", "BBB"], tf="4h", capital=1000.0, data_dir=tmp_path)
data = {"AAA": _ramp_df(slope=1.0), "BBB": _ramp_df(slope=1.0)}
w.tick(data)
assert w.positions["AAA"] == 1.0 and w.positions["BBB"] == 1.0 # EMA20>EMA100 in salita
```
- [ ] **Step 2:** `uv run pytest tests/portfolio/test_basket_worker.py -v` → FAIL.
- [ ] **Step 3: Implementa `src/live/basket_trend_worker.py`.** Stato: capitale totale + dict `positions` (asset→0/1) + persistenza. `tick(data: dict[str,df])`: per ogni asset calcola EMA20/EMA100 sull'ultima barra; target = 1.0 se ef>es else 0.0; applica fee `FEE_RT/2*LEV` sul turnover |Δpos|; aggiorna capitale equal-weight col rendimento di barra di ogni asset attivo (`POS*LEV*ret*pos/len(universe)`... mantieni la convenzione di `_tr_basket_daily`: ogni asset è uno sleeve normalizzato, equal-weight → applica `mean` dei rendimenti per-asset). Persisti `status.json` (capitale, positions, last_bar_ts per asset) e logga `trades.jsonl`. fee_rt=0.001, leverage 3, position 0.15.
```python
"""BasketTrendWorker (TR01): EMA20>EMA100 long/flat su un paniere, equal-weight.
Replica live di honest_improve2._tr_basket_daily."""
from __future__ import annotations
import json
from datetime import datetime, timezone
from pathlib import Path
import numpy as np
import pandas as pd
FEE_RT, LEV, POS = 0.001, 3.0, 0.15
def _ema(x, n):
return pd.Series(x).ewm(span=n, adjust=False).mean().values
class BasketTrendWorker:
def __init__(self, universe, tf="4h", capital=1000.0, position_size=POS,
leverage=LEV, fee_rt=FEE_RT, name="TR01_basket",
data_dir=Path("data/portfolio_paper")):
self.universe = list(universe)
self.tf = tf
self.initial_capital = capital
self.capital = capital
self.position_size = position_size
self.leverage = leverage
self.fee_rt = fee_rt
self.worker_id = f"{name}__{'-'.join(self.universe)}__{tf}"
self.work_dir = Path(data_dir) / self.worker_id
self.work_dir.mkdir(parents=True, exist_ok=True)
self.status_path = self.work_dir / "status.json"
self.trades_path = self.work_dir / "trades.jsonl"
self.positions = {a: 0.0 for a in self.universe}
self.last_bar_ts = {a: 0 for a in self.universe}
self.in_position = False # per il ribilancio del runner (skip se True)
self._load()
def _load(self):
if self.status_path.exists():
s = json.loads(self.status_path.read_text())
self.capital = s.get("capital", self.capital)
self.positions = {**self.positions, **s.get("positions", {})}
self.last_bar_ts = {**self.last_bar_ts, **s.get("last_bar_ts", {})}
self.in_position = any(v > 0 for v in self.positions.values())
def _save(self):
self.status_path.write_text(json.dumps({
"capital": round(self.capital, 2), "positions": self.positions,
"last_bar_ts": self.last_bar_ts,
"ts": datetime.now(timezone.utc).isoformat()}, indent=2))
def tick(self, data: dict):
rets = []
for a in self.universe:
df = data.get(a)
if df is None or len(df) < 110:
continue
c = df["close"].values
ef, es = _ema(c, 20)[-1], _ema(c, 100)[-1]
target = 1.0 if ef > es else 0.0
bar_ts = int(df["timestamp"].iloc[-1])
prev = self.positions[a]
# rendimento di barra realizzato sulla posizione precedente (chiusa->aperta barra)
if self.last_bar_ts[a] and bar_ts > self.last_bar_ts[a] and prev > 0:
r = (c[-1] - c[-2]) / c[-2]
rets.append(self.position_size * self.leverage * r * prev)
if target != prev:
self.capital -= self.capital * self.position_size * (self.fee_rt / 2) * abs(target - prev) / len(self.universe)
self._log(a, prev, target, float(c[-1]))
self.positions[a] = target
self.last_bar_ts[a] = bar_ts
if rets:
self.capital = max(self.capital * (1 + float(np.mean(rets))), 10.0)
self.in_position = any(v > 0 for v in self.positions.values())
self._save()
def _log(self, asset, frm, to, price):
with open(self.trades_path, "a") as f:
f.write(json.dumps({"ts": datetime.now(timezone.utc).isoformat(),
"asset": asset, "from": frm, "to": to,
"price": round(price, 6), "capital": round(self.capital, 2)}) + "\n")
@property
def status_summary(self):
longs = [a for a, v in self.positions.items() if v > 0]
return f"{self.worker_id}: cap={self.capital:.0f} long={longs}"
```
- [ ] **Step 4:** `uv run pytest tests/portfolio/test_basket_worker.py -v` → 1 passed.
- [ ] **Step 5: Commit**
```bash
git add src/live/basket_trend_worker.py tests/portfolio/test_basket_worker.py
git commit -m "feat(live): BasketTrendWorker (TR01) EMA-cross long/flat multi-asset"
```
---
## Task 3: `RotationWorker` (ROT02)
**Files:** Create `src/live/rotation_worker.py`; Test `tests/portfolio/test_rotation_worker.py`.
- [ ] **Step 1: Test (fallisce)** — dato {asset: df 1d}, sceglie i top-k per momentum 60g con gate BTC>SMA100 e imposta i pesi gross/k:
```python
import numpy as np
import pandas as pd
from src.live.rotation_worker import RotationWorker
def _df(n=200, slope=1.0):
c = np.linspace(100, 100 + slope * n, n)
ts = (pd.date_range("2023-01-01", periods=n, freq="1D", tz="UTC").astype("int64") // 10**6)
return pd.DataFrame({"timestamp": ts, "open": c, "high": c, "low": c, "close": c, "volume": 1.0})
def test_rotation_picks_top_momentum_when_risk_on(tmp_path):
w = RotationWorker(universe=["BTC", "AAA", "BBB"], top_k=2, gross=0.45, data_dir=tmp_path)
data = {"BTC": _df(slope=1.0), "AAA": _df(slope=3.0), "BBB": _df(slope=0.1)}
w.tick(data)
# BTC in uptrend -> risk_on; top-2 momentum = AAA e BTC; pesi gross/2
assert w.weights["AAA"] > 0 and abs(sum(w.weights.values()) - 0.45) < 1e-9
```
- [ ] **Step 2:** `uv run pytest tests/portfolio/test_rotation_worker.py -v` → FAIL.
- [ ] **Step 3: Implementa `src/live/rotation_worker.py`.** Replica di `_rot_daily_equity`: panel di close 1d allineato; `risk_on = BTC[-1] > SMA100(BTC)[-1]`; `mom = P[-1]/P[-61]-1`; `chosen = [top_k per mom con mom>0] se risk_on else []`; pesi `gross/len(chosen)`; turnover fee `FEE_RT/2 * Σ|Δw|`; capitale aggiornato col rendimento di portafoglio del giorno successivo (live: al tick si realizza il rendimento dell'ultima barra sui pesi correnti, poi si ricalcolano i pesi). Persisti capitale+weights+last_ts. `in_position = bool(weights)`.
(Implementazione analoga a BasketTrendWorker: stato persistente, `tick(data)` allinea i panel per timestamp comune, calcola momentum/gate, applica fee sul turnover e rendimento di barra. Mantieni `top_k=3, gross=0.45` come default — i valori dello sleeve ROT02_rot del portafoglio.)
- [ ] **Step 4:** test → 1 passed.
- [ ] **Step 5: Commit**
```bash
git add src/live/rotation_worker.py tests/portfolio/test_rotation_worker.py
git commit -m "feat(live): RotationWorker (ROT02) dual-momentum top-k risk-gated"
```
---
## Task 4: `TsmomWorker` (TSM01)
**Files:** Create `src/live/tsmom_worker.py`; Test `tests/portfolio/test_tsmom_worker.py`.
- [ ] **Step 1: Test (fallisce)** — consenso segni multi-orizzonte: sceglie gli asset con `Σ sign(P/P[-h]) ≥ thr` (h∈{63,126,252}) sotto gate, pesi gross/k.
- [ ] **Step 2-3: Implementa `src/live/tsmom_worker.py`** replicando `tsmom_sim`: `score[j] = mean_h sign(P[-1,j]/P[-1-h,j]-1)`; `chosen = [j: score>=thr] se risk_on`; pesi `gross/len(chosen)` con `gross=0.30`. Stessa struttura di RotationWorker (panel 1d, fee turnover, rendimento di barra, persistenza). Default `horizons=(63,126,252), thr=1.0, regime_n=100, gross=0.30`.
- [ ] **Step 4:** test → passed.
- [ ] **Step 5: Commit**
```bash
git add src/live/tsmom_worker.py tests/portfolio/test_tsmom_worker.py
git commit -m "feat(live): TsmomWorker (TSM01) consenso TSMOM multi-orizzonte risk-gated"
```
---
## Task 5: Integrazione nel PortfolioRunner
**Files:** Modify `src/portfolio/runner.py`, `scripts/portfolios/_defs.py`, `src/portfolio/base.py`; Test `tests/portfolio/test_runner_honest.py`.
- [ ] **Step 1:** In `_defs.py`, marca gli SleeveSpec multi-asset col `kind` giusto e l'universo:
- DIP01 → `kind="single", name="DIP01"` (resta StrategyWorker via _STRAT_MODULE["DIP01"]="DIP01_dip_buy").
- TR01 → `kind="basket"`, aggiungi campo universo (riusa `params={"universe": ["BNB","BTC","DOGE","SOL","XRP"], "tf": "4h"}`).
- ROT02 → `kind="rotation"`, `params={"top_k":3, "gross":0.45, "tf":"1d"}`.
- TSM01 → `kind="tsmom"`, `params={"horizons":[63,126,252], "thr":1.0, "gross":0.30, "tf":"1d"}`.
(Aggiungi `universe`/campi a SleeveSpec se serve, default None.)
- [ ] **Step 2:** In `runner.py::build_worker_for` aggiungi i rami `kind in ("basket","rotation","tsmom")` che costruiscono i rispettivi worker con `capital=alloc_capital` e `data_dir=DATA_DIR`. Aggiorna `_STRAT_MODULE` con `"DIP01": "DIP01_dip_buy"`. Rimuovi DIP01/TR01/ROT02/TSM01 dalla lista "saltati": ora sono supportati.
- [ ] **Step 3:** In `runner.run()` il tick deve passare ai worker multi-asset un dict {asset: df} (fetch di tutti gli asset dell'universo). Estendi la raccolta `keys` e il dispatch del tick: per kind basket/rotation/tsmom costruisci `data = {a: cache[(a, tf)] for a in universe}` e chiama `w.tick(data)`. Per `_worker_equity` i nuovi worker espongono `.capital` (già ok). Per il ribilancio, espongono `.in_position` (skip se True).
- [ ] **Step 4: Test** `tests/portfolio/test_runner_honest.py`: `build_worker_for` ritorna il tipo giusto per ogni kind con capitale = alloc; e `run()` con PORT06 non lascia più sleeve "saltati" (mocka il fetch o testa solo build).
- [ ] **Step 5:** `uv run pytest tests/portfolio/ -m "not network" -v` → tutti verdi.
- [ ] **Step 6: Commit**
```bash
git add src/portfolio/runner.py scripts/portfolios/_defs.py src/portfolio/base.py tests/portfolio/test_runner_honest.py
git commit -m "feat(portfolio): integra worker honest/TSM01 nel runner (PORT06 live completo)"
```
---
## Task 6: Validazione replay==backtest per i worker multi-asset
**Files:** Modify `scripts/analysis/validate_portfolio_runner.py` (o nuovo `validate_honest_workers.py`).
- [ ] **Step 1:** Per ogni worker multi-asset, replay bar-by-bar su dati storici (load_data) e confronto dell'equity finale con la funzione di riferimento (`_tr_basket_daily`, `_rot_daily_equity`, `tsmom_sim`) entro tolleranza. ROT02/TSM01 sono daily → replay veloce (poche migliaia di barre). TR01 4h → medio. Atteso: match stretto (differenze solo da bar-timing/cadenza). DIP01 ha il gap intrabar noto come le fade (documenta, non assert esatto).
- [ ] **Step 2: Commit**
```bash
git add scripts/analysis/validate_honest_workers.py
git commit -m "test(portfolio): replay worker honest/TSM01 == backtest di riferimento"
```
---
## Self-review
- **Copertura:** i 4 worker (DIP01 single via Strategy; TR01/ROT02/TSM01 dedicati) + integrazione runner + validazione → PORT06 gira live completo (niente più sleeve saltati).
- **Parità backtest:** invariata (gli sleeve del backtest vengono ancora da `build_everything`; i worker sono il path LIVE). La validazione replay==backtest (Task 6) certifica i worker live.
- **Gap noto:** DIP01, come le fade, ha exit intrabar nel backtest ma close-based nel live → gap strutturale documentato (non un bug). TR01/ROT02/TSM01 non hanno TP/SL intrabar (entry/exit a chiusura barra/giorno) → replay atteso stretto.
- **Tipi:** i nuovi worker espongono `.capital` e `.in_position` (richiesti da `_worker_equity`/`rebalance_allocations`); `tick(data: dict)` per i multi-asset vs `tick(df)`/`tick(dfa,dfb)` esistenti → il runner dispatcha per `kind`.
- **Rischio:** la convenzione di capitale/rendimento dei worker multi-asset deve combaciare con le funzioni di riferimento; la validazione Task 6 è il gate che lo verifica — se diverge, allineare la formula (non la reference).
> **Punto aperto:** verificare la disponibilità su Cerbero v2 dei timeframe 4h/1d per tutti gli asset dell'universo (TR01 usa 4h; ROT02/TSM01 usano 1d, oggi resample da 1h in get_df). Il runner live dovrà resamplare 1h→4h/1d dal feed v2 o fetchare nativamente — da decidere in Task 5/Step 3.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,233 @@
# Design — Cartella `portfolios/`: portafogli come oggetti di prima classe
**Data:** 2026-05-29
**Stato:** approvato in brainstorming, pronto per il piano di implementazione
**Branch:** `shape_patterns` (o branch dedicato `portfolios`)
## 1. Obiettivo e contesto
Oggi le strategie del progetto vivono come *sleeve* indipendenti: ogni worker del paper
trader (`StrategyWorker`, `PairsWorker`) gestisce un conto autonomo da €1000, con capitale
e stato propri in `data/paper_trades/{worker_id}/`. I "portafogli" `PORT01-03` esistenti
sono soltanto script di **report offline**: normalizzano le equity storiche dei singoli
sleeve e ne calcolano metriche equipesate. Non esiste un livello che gestisca davvero un
capitale condiviso, i pesi, il ribilanciamento e il PnL aggregato in tempo reale.
Questo design introduce una cartella `portfolios/` in cui il **portafoglio è un oggetto di
prima classe** che gestirà il trading e lo stato PnL. Un portafoglio possiede un capitale
totale, lo alloca ai propri sleeve secondo uno schema di pesi, dimensiona le posizioni,
ribilancia periodicamente e mantiene il ledger aggregato. La stessa definizione serve sia
al backtest sia al live, garantendo coerenza fra ciò che si misura e ciò che si tradia.
L'obiettivo strategico resta invariato: partire da €1000 e arrivare verso €50/giorno con un
paniere diversificato delle famiglie validate (fade, honest, pairs, TSMOM, shape-ML).
## 2. Decisioni di brainstorming
1. **Modello di capitale: pool condiviso.** Il portafoglio possiede il capitale totale, lo
alloca ai sleeve secondo i pesi, ridimensiona le posizioni e tiene lo stato/PnL
aggregato. I worker diventano esecutori.
2. **Scope: backtest + live unificati.** Un'unica classe `Portfolio` come fonte di verità,
capace sia di backtest/report storico sia di gestione live.
3. **Ribilanciamento periodico.** Il capitale viene riallocato ai pesi target a cadenza
fissa (giornaliera di default, configurabile), coerente con tutte le metriche misurate
finora.
4. **Schemi di peso supportati (tutti):** `equal` (default), `cap` (tetto per
famiglia/cluster, es. pairs 33% — configurazione sobria raccomandata), `inverse_vol`,
`cluster_rp` (equal fra cluster naturali poi inverse-vol dentro), `manual`.
5. **Scope live v1: tutti gli sleeve** — fade, honest, pairs (2 gambe) e shape-ML (SH01 via
worker con retraining periodico, sfruttando il `MLWorkerWrapper` esistente).
6. **Data layer Cerbero v2.** Il runner live adotta gli endpoint unificati v2: `get_historical`
unificato, `get_instruments` (naming robusto, niente `INSTRUMENT_MAP` hardcoded),
`get_ticker_batch` (fetch multi-gamba efficiente). Venue di trading = Deribit come ora.
### Analisi di accorpamento (a supporto delle decisioni)
`scripts/analysis/sleeve_clustering.py` ha mostrato che:
- i **cluster naturali** delle 17 sleeve non coincidono con le famiglie ma con
asset/regime: BTC-reversion, ETH-reversion, trend (TR01+TSM01), shape (SH_BTC+SH_ETH),
rotation (ROT02);
- la **ridondanza è lieve** (correlazione massima 0.43 MR01_BTC↔DIP01_BTC, 0.37 TR01↔TSM01):
nessuno sleeve è davvero fondibile, ognuno aggiunge diversificazione;
- a equal-weight i **pairs pesano il 47% del rischio** → giustifica lo schema `cap`;
- in OOS calmo equal-weight batte inverse-vol e risk-parity (i pairs ad alto rischio/ritorno
corrono liberi), ma è un risultato di regime → il cap resta la scelta prudente.
Il campo `cluster` di `SleeveSpec` codifica questi gruppi naturali per gli schemi `cap` e
`cluster_rp`.
## 3. Architettura e layout
Si rispecchia la struttura delle strategie (`src/strategies/` base + `scripts/strategies/`
concrete):
```
src/portfolio/
__init__.py
base.py # Portfolio (definizione + .backtest()), SleeveSpec, PortfolioResult
sleeves.py # costruzione UNIFICATA delle equity-per-sleeve (backtest);
# centralizza la logica oggi in combine_portfolio + report_families
weighting.py # schemi pesi: equal, cap, inverse_vol, cluster_rp, manual
ledger.py # PortfolioLedger: capitale, allocazioni, equity, PnL, peak/DD, persistenza
runner.py # PortfolioRunner (live): pool capital, sizing, ribilancio, aggregazione
scripts/portfolios/
PORT01_honest.py PORT02_fade.py PORT03_master.py
PORT04_master_pairs.py PORT05_master_esteso.py PORT06_master_shape.py
# definizioni concrete (lista SleeveSpec + schema pesi); run() = report backtest
portfolios.yml # config LIVE: portafoglio attivo, capitale, schema pesi, cap, cadenza, leva
```
**Integrazione col codice esistente:**
- Il backtest riusa i builder di equity-per-sleeve (`build_all_sleeves`, `pairs_sim`,
`shape_daily_equity`), centralizzati in `src/portfolio/sleeves.py`; `combine_portfolio.py`
e `report_families.py` diventano consumer sottili (niente duplicazione).
- Il live riusa da `multi_runner`: il fetch candele, `build_workers`,
`build_pairs_workers`, `MLWorkerWrapper`. `multi_runner` resta entrypoint legacy
single-sleeve finché `PortfolioRunner` non lo sostituisce.
- I vecchi `PORT01-03` di `scripts/strategies/` vengono migrati in `scripts/portfolios/`
come definizioni della nuova classe.
## 4. Definizione del portafoglio (schema)
```python
@dataclass
class SleeveSpec:
kind: str # "single" | "pairs" | "ml"
name: str # "MR01_bollinger_fade" | "PR01_pairs_reversion" | "SH01_shape_ml"
asset: str | None = None # single/ml
a: str | None = None # pairs: gamba long
b: str | None = None # pairs: gamba short
tf: str = "1h"
params: dict = field(default_factory=dict)
cluster: str = "" # BTC-rev | ETH-rev | trend | shape | rotation
@dataclass
class Portfolio:
code: str # "PORT06"
label: str # "Master + shape"
sleeves: list[SleeveSpec]
weighting: str = "equal" # equal | cap | inverse_vol | cluster_rp | manual
weights: dict | None = None # solo manual (sleeve-id -> peso)
caps: dict | None = None # solo cap: chiave = FAMIGLIA (derivata da kind/name:
# PAIRS/FADE/HONEST/SHAPE/TSM), es. {"PAIRS": 0.33}.
# cluster_rp usa invece il campo `cluster` degli sleeve.
total_capital: float = 1000.0
leverage: float = 3.0 # nota: 2x raccomandata per il live reale
rebalance: str = "1D"
vol_lookback: int = 90 # giorni per inverse_vol / cluster_rp
def backtest(self, ...) -> PortfolioResult: ...
def weight_vector(self, sleeve_returns) -> dict[str, float]: ...
```
Gli schemi di peso (in `weighting.py`) restituiscono un dict `sleeve-id -> peso` che somma a
1. `equal/cap/manual` sono statici; `inverse_vol/cluster_rp` si ricalcolano a ogni ribilancio
sulla finestra trailing `vol_lookback`, identicamente in backtest e live.
## 5. Faccia backtest
`Portfolio.backtest()` riusa la macchina che ha prodotto tutte le metriche viste finora,
centralizzata in `src/portfolio/sleeves.py`:
```
build_sleeve_equity(spec) -> pd.Series # equity daily normalizzata su IDX comune
kind="single" -> fade/honest daily equity builders
kind="pairs" -> pairs_sim -> daily
kind="ml" -> shape_daily_equity
```
Poi: `weight_vector()` → pesi → `port_returns()` con ribilancio giornaliero → `metrics()`
FULL/OOS + `yearly_returns()`. Restituisce un `PortfolioResult` con ret/CAGR/DD/Sharpe
(FULL e OOS), tabella per-anno e contributo al rischio per sleeve e per cluster. Lo `run()`
di ogni `scripts/portfolios/PORTxx.py` stampa questo report.
## 6. Faccia live (`PortfolioRunner`)
Loop a poll:
1. **Data layer v2.** All'avvio `get_instruments` risolve i nomi reali di ogni asset/coppia
(fallback a una mappa statica se l'endpoint non risponde). Per tick: `get_historical`
unificato per le candele + `get_ticker_batch` per i prezzi correnti di tutte le gambe in
un'unica chiamata.
2. **Costruzione sleeve→worker.** Riusa `build_workers` / `build_pairs_workers` /
`MLWorkerWrapper` (SH01). I worker sono esecutori, non possiedono più €1000 fissi.
3. **Capitale pool + sizing.** Il `PortfolioLedger` tiene `total_capital`. A ogni worker
viene assegnato `alloc_i = peso_i × total_capital`; il worker dimensiona il notional come
`alloc_i × position_size × leverage` (si riusa il campo `capital` del worker come base di
allocazione).
4. **Ribilancio (cadenza `rebalance`, default giornaliera).** `total_capital = Σ equity_sleeve`
(capitale + PnL realizzato); ricalcolo dei pesi (vol-based sulla finestra trailing o
statici); riallineo `alloc_i`.
5. **Aggregazione.** Dopo ogni tick il ledger aggiorna equity totale, peak, max_dd, PnL
aggregato e per-sleeve/cluster.
### Approssimazione dichiarata (limite noto)
Il ribilancio cambia la base di sizing delle posizioni **future**; le posizioni già aperte
restano sul notional con cui sono nate (nessun travaso forzato a metà trade). Per il paper
trading questo è fedele al backtest daily-rebalanced entro lo scarto dovuto al turnover
infragiornaliero. È un compromesso accettato per non introdurre la contabilità a ledger
unico (approccio C scartato in brainstorming), rimandata a quando si passerà a capitale
reale su un singolo conto-margine.
## 7. Persistenza e stato PnL
Stato del portafoglio separato dai singoli worker, in `data/portfolios/{code}/`:
```
data/portfolios/PORT06/
status.json # resume: total_capital, equity, peak, max_dd, pesi correnti,
# alloc+capitale+PnL per sleeve, ultimo ribilancio, ts
equity.jsonl # append-only: una riga per tick/giorno (ts, equity, dd, pnl_day) -> curva live
events.jsonl # append-only: ribilanci (pesi prima/dopo), milestone, errori
```
- I worker continuano a scrivere il proprio `trades.jsonl`/`status.json` in
`data/paper_trades/{worker_id}/` (storico per-sleeve intatto). Il portafoglio aggrega
sopra, non duplica i trade.
- **Resume:** al restart il runner ricarica lo `status.json` del portafoglio e gli stati
dei worker → riprende capitale, pesi e posizioni senza perdere storico.
- **Indicatori target:** il ledger espone `pnl_total`, `pnl_today`, `€/day` medio e DD
corrente.
- **Notifiche Telegram:** riepilogo a livello portafoglio (equity, PnL giorno, DD, ribilanci)
oltre alle notifiche per-trade dei worker.
## 8. Portafogli forniti e default
| Codice | Label | Sleeve | Pesi |
|--------|-------|--------|------|
| PORT01 | Honest | DIP01·TR01·ROT02 | equal |
| PORT02 | Fade master | MR01/02/07 × BTC/ETH (6) | equal |
| PORT03 | Master | fade+honest (9) | equal / manual 50-50 |
| PORT04 | Master + pairs | 9 + 5 pairs | equal · cap pairs 0.33 |
| PORT05 | Master esteso | 9 + pairs + TSM01 | equal · cap pairs |
| **PORT06** | **Master + shape** *(default)* | 9 + pairs + TSM01 + SH01 (BTC/ETH) | **cap pairs 0.33** |
**Default raccomandato:** PORT06 con `weighting="cap"` (pairs ~33%), `leverage=2` (sobrio),
`rebalance="1D"`. È la combinazione col miglior profilo OOS dell'analisi (Sharpe più alto,
DD più basso) e contiene tutte le famiglie validate. `portfolios.yml` seleziona il
portafoglio attivo e i suoi override.
## 9. Test
- **Unit** — `weighting.py` (somma pesi = 1, cap rispettato e ridistribuito,
inverse-vol/cluster corretti); `ledger.py` (capitale/PnL/DD, resume da status.json).
- **Parità backtest↔report** — `Portfolio.backtest()` di PORT03/04/05/06 riproduce
*esattamente* i numeri di `report_families.py` (regressione, stessa fonte).
- **Parità live↔backtest** — replay del `PortfolioRunner` su dati storici con ribilancio
giornaliero ≈ `Portfolio.backtest()` entro tolleranza (lo scarto è il turnover
infragiornaliero dichiarato), sullo stesso schema della validazione dei pairs.
- **Smoke live** — un tick reale end-to-end via Cerbero v2 (get_instruments +
get_historical + ticker_batch), nessun ordine reale, verifica ledger/persistenza/resume.
## 10. Fuori scope (note per il futuro)
- **Ledger unico / conto-margine reale** (approccio C): rinviato al passaggio a capitale
reale.
- **Hyperliquid come venue per gli alt** dei pairs (perp lineari nativi, evita i trap di
naming Deribit) — opzione abilitata dal data layer v2, non in v1.
- **Validazione pairs live via `get_cointegration_pairs`** e feature da macro/sentiment
(funding, liquidation, OI) per strategie future.
- **`run_backtest` server-side** di Cerbero come check incrociato.
+10
View File
@@ -0,0 +1,10 @@
# Config LIVE del paper trader a portafoglio. Seleziona UN portafoglio attivo
# (definito in scripts/portfolios/_defs.py) e ne fa l'override dei parametri operativi.
active: PORT06 # default raccomandato: master + shape
overrides:
total_capital: 1000
weighting: cap # equal | cap | inverse_vol | cluster_rp | manual
caps: {PAIRS: 0.33}
leverage: 2 # sobrio per il live reale
rebalance: 1D
poll_seconds: 60
+2
View File
@@ -14,6 +14,7 @@ dependencies = [
"torch>=2.0",
"matplotlib>=3.7",
"tqdm>=4.65",
"pyyaml>=6.0",
]
[project.optional-dependencies]
@@ -26,3 +27,4 @@ dev = [
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
markers = ["network: test che richiede Cerbero MCP (rete+token)"]
-298
View File
@@ -1,298 +0,0 @@
"""Report finale: TOP 5 metodi + simulazione crescita capitale €1000 → €50/giorno."""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
from src.data.downloader import load_data
print("=" * 70)
print(" REPORT FINALE — TOP 5 METODI")
print(" Target: accuracy >80%, ROI annuo >30%, €50/giorno da €1000")
print("=" * 70)
# Metodo 1: Squeeze Breakout ETH 1h (BBw=20, sqThr=0.8, volume confirmed)
# Metodo 2: Squeeze Breakout ETH 1h (BBw=30, sqThr=0.9, senza vol filter)
# Metodo 3: Squeeze Breakout BTC+ETH combinato
# Metodo 4: Squeeze Breakout 15m (alta frequenza)
# Metodo 5: GBM Structural + Squeeze filter (ibrido ML + strutturale)
FEE = 0.001
LEVERAGE = 3
INITIAL = 1000
def bollinger_bandwidth(close, window=20):
n = len(close)
result = np.full(n, np.nan)
for i in range(window, n):
w = close[i-window:i]
ma = np.mean(w)
std = np.std(w)
if ma > 0:
result[i] = (2 * 2 * std) / ma
return result
def keltner_ratio(close, high, low, window=20):
n = len(close)
result = np.full(n, np.nan)
for i in range(window, n):
wc = close[i-window:i]
wh = high[i-window:i]
wl = low[i-window:i]
ma = np.mean(wc)
bb_std = np.std(wc)
tr = np.maximum(wh - wl, np.maximum(np.abs(wh - np.roll(wc,1)), np.abs(wl - np.roll(wc,1))))
atr = np.mean(tr[1:])
kc_r = (ma + 1.5*atr) - (ma - 1.5*atr)
bb_r = (ma + 2*bb_std) - (ma - 2*bb_std)
if kc_r > 0:
result[i] = bb_r / kc_r
return result
def run_squeeze_backtest(close, high, low, volume, bb_w, sq_thr, brk_bars, vol_filter, split_pct=0.7, leverage=3, pos_pct=0.2):
n = len(close)
split = int(n * split_pct)
kcr = keltner_ratio(close, high, low, bb_w)
in_sq = False
sq_start = 0
capital = float(INITIAL)
equity = [capital]
trades = []
for i in range(bb_w + 1, n):
if np.isnan(kcr[i]):
continue
is_sq = kcr[i] < sq_thr
if is_sq and not in_sq:
in_sq = True
sq_start = i
elif not is_sq and in_sq:
in_sq = False
duration = i - sq_start
if duration < 5 or i < split or i + brk_bars >= n:
continue
# Volume check
if vol_filter:
avg_v = np.mean(volume[sq_start:i])
brk_v = np.mean(volume[i:i+brk_bars])
if avg_v > 0 and brk_v < avg_v * 1.3:
continue
first_ret = (close[i] - close[i-1]) / close[i-1]
if abs(first_ret) < 0.001:
continue
direction = 1 if first_ret > 0 else -1
actual = (close[i+brk_bars-1] - close[i-1]) / close[i-1]
is_correct = (direction == 1 and actual > 0) or (direction == -1 and actual < 0)
trade_ret = actual * direction
net = trade_ret * leverage - FEE * 2 * leverage
pnl = capital * pos_pct * net
capital += pnl
capital = max(capital, 0)
equity.append(capital)
trades.append({
"correct": is_correct,
"actual_ret": actual,
"net_pnl": pnl,
"capital_after": capital,
})
if not trades:
return None
correct = sum(1 for t in trades if t["correct"])
acc = correct / len(trades) * 100
total_ret = (capital - INITIAL) / INITIAL * 100
test_candles = n - split
test_days = test_candles / 24
test_years = test_days / 365.25
ann = ((capital / INITIAL) ** (1/test_years) - 1) * 100 if test_years > 0 and capital > 0 else -100
daily_pnl = (capital - INITIAL) / test_days if test_days > 0 else 0
peak = equity[0]
max_dd = 0
for v in equity:
if v > peak: peak = v
dd = (peak - v) / peak if peak > 0 else 0
max_dd = max(max_dd, dd)
return {
"trades": len(trades),
"accuracy": acc,
"total_return": total_ret,
"annualized": ann,
"max_drawdown": max_dd * 100,
"final_capital": capital,
"daily_pnl": daily_pnl,
"trades_per_year": len(trades) / test_years if test_years > 0 else 0,
}
methods = []
# --- Metodo 1: ETH 1h, BBw=20, sqThr=0.8, vol confirmed ---
df_eth = load_data("ETH", "1h")
r1 = run_squeeze_backtest(df_eth["close"].values, df_eth["high"].values, df_eth["low"].values, df_eth["volume"].values,
bb_w=20, sq_thr=0.8, brk_bars=3, vol_filter=True)
methods.append(("M1: ETH 1h Squeeze+Vol (BBw=20,sq=0.8)", r1))
# --- Metodo 2: ETH 1h, BBw=30, sqThr=0.9, no vol ---
r2 = run_squeeze_backtest(df_eth["close"].values, df_eth["high"].values, df_eth["low"].values, df_eth["volume"].values,
bb_w=30, sq_thr=0.9, brk_bars=3, vol_filter=False)
methods.append(("M2: ETH 1h Squeeze (BBw=30,sq=0.9)", r2))
# --- Metodo 3: BTC+ETH combinato ---
df_btc = load_data("BTC", "1h")
r3a = run_squeeze_backtest(df_btc["close"].values, df_btc["high"].values, df_btc["low"].values, df_btc["volume"].values,
bb_w=14, sq_thr=0.8, brk_bars=3, vol_filter=False, pos_pct=0.1)
r3b = run_squeeze_backtest(df_eth["close"].values, df_eth["high"].values, df_eth["low"].values, df_eth["volume"].values,
bb_w=20, sq_thr=0.8, brk_bars=3, vol_filter=False, pos_pct=0.1)
if r3a and r3b:
combined_trades = r3a["trades"] + r3b["trades"]
combined_correct = int(r3a["accuracy"]/100 * r3a["trades"]) + int(r3b["accuracy"]/100 * r3b["trades"])
combined_acc = combined_correct / combined_trades * 100 if combined_trades > 0 else 0
# Simulate portfolio
cap = float(INITIAL)
# Rough estimate: alternate between assets
for r in [r3a, r3b]:
ret_per_trade = r["total_return"] / 100 / r["trades"] if r["trades"] > 0 else 0
for _ in range(r["trades"]):
cap *= (1 + ret_per_trade * 0.5)
r3 = {
"trades": combined_trades,
"accuracy": combined_acc,
"total_return": (cap - INITIAL) / INITIAL * 100,
"annualized": r3a["annualized"] * 0.5 + r3b["annualized"] * 0.5,
"max_drawdown": max(r3a["max_drawdown"], r3b["max_drawdown"]),
"final_capital": cap,
"daily_pnl": r3a["daily_pnl"] + r3b["daily_pnl"],
"trades_per_year": r3a["trades_per_year"] + r3b["trades_per_year"],
}
methods.append(("M3: BTC+ETH 1h Portafoglio Squeeze", r3))
# --- Metodo 4: BTC 15m alta frequenza ---
df_btc_15 = load_data("BTC", "15m")
r4 = run_squeeze_backtest(df_btc_15["close"].values, df_btc_15["high"].values, df_btc_15["low"].values, df_btc_15["volume"].values,
bb_w=14, sq_thr=0.9, brk_bars=3, vol_filter=True)
methods.append(("M4: BTC 15m Squeeze+Vol alta freq", r4))
# --- Metodo 5: ETH 1h squeeze aggressivo ---
r5 = run_squeeze_backtest(df_eth["close"].values, df_eth["high"].values, df_eth["low"].values, df_eth["volume"].values,
bb_w=20, sq_thr=0.8, brk_bars=3, vol_filter=False, leverage=3)
methods.append(("M5: ETH 1h Squeeze aggressivo (no vol)", r5))
# --- Print results ---
print("\n")
for i, (name, r) in enumerate(methods, 1):
if r is None:
print(f" {name}: NO TRADES")
continue
print(f" {'='*65}")
print(f" #{i}{name}")
print(f" {'='*65}")
print(f" Trades: {r['trades']}")
print(f" Accuracy: {r['accuracy']:.1f}% {'' if r['accuracy'] >= 80 else '⚠️' if r['accuracy'] >= 70 else ''}")
print(f" Return totale: {r['total_return']:+.1f}%")
print(f" Return annuo: {r['annualized']:+.1f}% {'' if r['annualized'] >= 30 else '⚠️' if r['annualized'] >= 15 else ''}")
print(f" Max Drawdown: {r['max_drawdown']:.1f}%")
print(f" Capitale finale: €{r['final_capital']:.0f}")
print(f" €/giorno media: €{r['daily_pnl']:.2f}")
print(f" Trades/anno: {r['trades_per_year']:.0f}")
print()
# --- Simulazione crescita 6 mesi ---
print("\n" + "=" * 70)
print(" SIMULAZIONE CRESCITA CAPITALE — 6 MESI")
print(" Metodo: M1 (ETH 1h Squeeze+Vol) — il più preciso (83.9%)")
print("=" * 70)
# M1 params: ~87 trades in ~2.5 anni test = ~35 trades/anno = ~3 al mese
# Accuracy: 83.9%, average return per trade with 3x leverage
# Simulo con dati reali: prendo i trade dal test period
close = df_eth["close"].values
high = df_eth["high"].values
low = df_eth["low"].values
volume = df_eth["volume"].values
n = len(close)
split = int(n * 0.7)
kcr = keltner_ratio(close, high, low, 20)
in_sq = False
sq_start = 0
all_trade_rets = []
for i in range(21, n):
if np.isnan(kcr[i]):
continue
is_sq = kcr[i] < 0.8
if is_sq and not in_sq:
in_sq = True
sq_start = i
elif not is_sq and in_sq:
in_sq = False
if i - sq_start < 5 or i < split or i + 3 >= n:
continue
avg_v = np.mean(volume[sq_start:i])
brk_v = np.mean(volume[i:i+3])
if avg_v > 0 and brk_v < avg_v * 1.3:
continue
first_ret = (close[i] - close[i-1]) / close[i-1]
if abs(first_ret) < 0.001:
continue
direction = 1 if first_ret > 0 else -1
actual = (close[i+2] - close[i-1]) / close[i-1]
trade_ret = actual * direction
all_trade_rets.append(trade_ret)
avg_win = np.mean([r for r in all_trade_rets if r > 0]) if any(r > 0 for r in all_trade_rets) else 0
avg_loss = np.mean([r for r in all_trade_rets if r <= 0]) if any(r <= 0 for r in all_trade_rets) else 0
win_rate = sum(1 for r in all_trade_rets if r > 0) / len(all_trade_rets)
print(f"\n Statistiche trade:")
print(f" Win rate: {win_rate*100:.1f}%")
print(f" Avg win: {avg_win*100:.2f}%")
print(f" Avg loss: {avg_loss*100:.2f}%")
print(f" Trades totali nel test: {len(all_trade_rets)}")
print(f" Trades/mese stimati: ~{len(all_trade_rets) / 30:.0f}")
print(f"\n Crescita simulata mese per mese (€1000 iniziali, leva 3x, 20% per trade):")
capital = 1000.0
monthly_trades = max(len(all_trade_rets) // 30, 3)
# Shuffle trades to simulate different sequences
np.random.seed(42)
for month in range(1, 7):
n_trades = monthly_trades
month_rets = np.random.choice(all_trade_rets, size=n_trades, replace=True)
for ret in month_rets:
net = ret * LEVERAGE - FEE * 2 * LEVERAGE
capital += capital * 0.2 * net
capital = max(capital, 10)
daily_pnl = capital * 0.003 # stima conservativa 0.3% daily basata su performance
print(f" Mese {month}: capitale €{capital:.0f}, €/giorno stima: €{daily_pnl:.1f}")
print(f"\n Capitale dopo 6 mesi: €{capital:.0f}")
print(f" €/giorno necessari: €50")
print(f" €/giorno ottenibili (0.5% daily su capitale): €{capital * 0.005:.1f}")
if capital * 0.005 >= 50:
print(f"\n ✅ TARGET RAGGIUNGIBILE: con €{capital:.0f} di capitale, 0.5% daily = €{capital*0.005:.0f}/giorno")
else:
needed = 50 / 0.005
print(f"\n ⚠️ Servono €{needed:.0f} di capitale per €50/giorno al 0.5% daily")
print(f" Raggiungibile estendendo il periodo di crescita a ~{int(np.log(needed/1000) / np.log(1 + 0.15) + 0.5)} mesi")
View File
+156
View File
@@ -0,0 +1,156 @@
"""Studio: combinare TUTTE le strategie (fade + honest) migliora i risultati?
Due famiglie con meccanismi e orizzonti diversi:
FADE (intraday 1h, long/short, BTC/ETH): MR01 boll, MR02 donchian, MR07
return-reversal — tutte col filtro trend 3.0 ATR. (MR03 keltner -> waste.)
HONEST (long-only, multi-regime, multi-crypto): DIP01 (dip-buy 1h BTC),
TR01 (EMA trend 4h basket), ROT02 (dual-momentum rotation 1d).
Metodo: per ogni sleeve si costruisce l'equity GIORNALIERA normalizzata su un
indice comune (2021-01-01 -> 2026-05-26), si passa ai rendimenti giornalieri,
si misura la correlazione cross-famiglia e si confrontano i portafogli
equal-weight (ribilanciati ogni giorno) e inverse-vol. Metriche FULL e OOS
(ultimo 30% della finestra comune): ritorno, CAGR, max DD, Sharpe annualizzato.
Tutto NETTO (fee gia' incluse nelle sleeve), leva 3x, pos 15% per sleeve.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from src.data.downloader import load_data
from scripts.analysis.risk_management import strats_for, build_trades, INIT
# curve daily honest gia' pronte nell'altra famiglia
from scripts.analysis.honest_improve2 import (
_daily_equity, _norm, dip_market_gated, _tr_basket_daily, _rot_daily_equity,
)
IDX = pd.date_range("2021-01-01", "2026-05-26", freq="1D", tz="UTC")
OOS_FRAC = 0.30
SPLIT = int(len(IDX) * (1 - OOS_FRAC)) # confine OOS sulla finestra comune
OOS_DATE = IDX[SPLIT].date()
ANN = 365.0 # giorni/anno per annualizzare
# ---------------- equity giornaliere ----------------
def fade_daily_equity(asset: str, fn, params) -> pd.Series:
"""Equity giornaliera di uno sleeve fade: trade 1h (filtro trend 3.0) -> equity -> daily."""
df = load_data(asset, "1h")
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
trades = build_trades(fn(df, **params), df, trend_max=3.0)
n = len(df); eq = np.full(n, INIT, dtype=float); cap = INIT
for i, j, ret in sorted(trades, key=lambda t: t[1]):
cap = max(cap + cap * 0.15 * ret, 10.0)
eq[j:] = cap
s = pd.Series(eq, index=ts).resample("1D").last().reindex(IDX).ffill().bfill()
return _norm(s)
def build_all_sleeves() -> dict[str, pd.Series]:
sleeves: dict[str, pd.Series] = {}
# --- FADE: 8 sleeve ---
for asset in ["BTC", "ETH"]:
for nm, (fn, params) in strats_for(asset).items():
sleeves[f"{nm}_{asset}"] = fade_daily_equity(asset, fn, params)
# --- HONEST: 3 sleeve (riuso le funzioni dell'altra famiglia) ---
d = dip_market_gated("BTC", market_n=0, return_equity=True)
sleeves["DIP01_BTC"] = _norm(_daily_equity(d["eq_ts"], d["eq_v"], IDX))
sleeves["TR01_basket"] = _norm(_tr_basket_daily(["BNB", "BTC", "DOGE", "SOL", "XRP"], IDX))
sleeves["ROT02_rot"] = _norm(_rot_daily_equity(IDX))
return sleeves
# ---------------- metriche ----------------
def metrics(daily_ret: pd.Series, lo: int = 0, hi: int | None = None) -> dict:
r = daily_ret.iloc[lo:hi]
eq = (1 + r).cumprod()
peak = eq.cummax(); dd = float(((peak - eq) / peak).max() * 100)
yrs = len(r) / ANN
tot = (eq.iloc[-1] - 1) * 100
cagr = ((eq.iloc[-1]) ** (1 / yrs) - 1) * 100 if yrs > 0 else 0.0
sharpe = float(r.mean() / r.std() * np.sqrt(ANN)) if r.std() > 0 else 0.0
return dict(ret=tot, cagr=cagr, dd=dd, sharpe=sharpe)
def yearly_returns(daily_ret: pd.Series) -> dict[int, float]:
"""Rendimento % netto per anno solare dai rendimenti giornalieri composti."""
g = daily_ret.groupby(daily_ret.index.year).apply(lambda x: ((1 + x).prod() - 1) * 100)
return {int(y): float(v) for y, v in g.items()}
def port_returns(members: dict[str, pd.Series], weights: dict[str, float] | None = None) -> pd.Series:
"""Rendimenti giornalieri di un portafoglio ribilanciato ogni giorno ai pesi dati."""
dr = pd.DataFrame({k: v.pct_change().fillna(0.0) for k, v in members.items()})
if weights is None:
return dr.mean(axis=1)
w = pd.Series(weights); w = w / w.sum()
return (dr * w).sum(axis=1)
def inv_vol_weights(members: dict[str, pd.Series], lo=0, hi=None) -> dict[str, float]:
"""Pesi inversamente proporzionali alla volatilita' (stimata sulla finestra train)."""
vol = {k: v.pct_change().iloc[lo:hi].std() for k, v in members.items()}
inv = {k: (1.0 / s if s and s > 0 else 0.0) for k, s in vol.items()}
tot = sum(inv.values())
return {k: x / tot for k, x in inv.items()}
# ---------------- report ----------------
def row(label, dr):
f = metrics(dr); o = metrics(dr, lo=SPLIT)
print(f" {label:<26s}{f['ret']:>+9.0f}{f['cagr']:>7.0f}{f['dd']:>7.1f}{f['sharpe']:>7.2f}"
f" | {o['ret']:>+9.0f}{o['cagr']:>7.0f}{o['dd']:>7.1f}{o['sharpe']:>7.2f}")
def main():
print("Costruzione equity giornaliere (puo' richiedere ~1 min)...")
S = build_all_sleeves()
fade = {k: v for k, v in S.items() if k.startswith("MR")}
honest = {k: v for k, v in S.items() if not k.startswith("MR")}
# --- correlazione cross-famiglia ---
dr = pd.DataFrame({k: v.pct_change().fillna(0.0) for k, v in S.items()})
corr = dr.corr()
fade_k, hon_k = list(fade), list(honest)
cross = corr.loc[fade_k, hon_k]
print("\n" + "=" * 92)
print(f" CORRELAZIONE rendimenti giornalieri — FADE (righe) vs HONEST (colonne) | {IDX[0].date()}->{IDX[-1].date()}")
print("=" * 92)
print(f" {'':<12s}" + "".join(f"{c:>13s}" for c in hon_k))
for f in fade_k:
print(f" {f:<12s}" + "".join(f"{cross.loc[f,c]:>13.2f}" for c in hon_k))
intra_fade = corr.loc[fade_k, fade_k].values[np.triu_indices(len(fade_k), 1)].mean()
intra_hon = corr.loc[hon_k, hon_k].values[np.triu_indices(len(hon_k), 1)].mean()
print(f"\n Corr media intra-FADE {intra_fade:+.2f} | intra-HONEST {intra_hon:+.2f} | "
f"cross-famiglia {cross.values.mean():+.2f} (piu' bassa = piu' diversificazione)")
# --- confronto portafogli ---
print("\n" + "=" * 92)
print(f" PORTAFOGLI equal-weight (ribil. giornaliero) | OOS da {OOS_DATE} | leva3x pos15%/sleeve")
print("=" * 92)
print(f" {'portafoglio':<26s}{'Ret%':>9s}{'CAGR':>7s}{'DD%':>7s}{'Shrp':>7s}"
f" | {'oRet%':>9s}{'oCAGR':>7s}{'oDD%':>7s}{'oShrp':>7s}")
print(" " + "-" * 88)
row("FADE only (8 sleeve)", port_returns(fade))
row("HONEST only (3 sleeve)", port_returns(honest))
row("ALL equal-weight (11)", port_returns(S))
# 50/50 fra le due famiglie (ogni famiglia equipesata al suo interno)
fr, hr = port_returns(fade), port_returns(honest)
row("ALL 50/50 famiglie", (fr + hr) / 2)
# inverse-vol sul train, applicato a tutti gli 11 sleeve
w = inv_vol_weights(S, lo=0, hi=SPLIT)
row("ALL inverse-vol", port_returns(S, w))
print(" " + "-" * 88)
print(" Sharpe annualizzato sui rendimenti giornalieri. Confronta DD e Sharpe:")
print(" se il combinato ha DD piu' basso e Sharpe piu' alto delle singole famiglie, combinare conviene.")
if __name__ == "__main__":
main()
+87
View File
@@ -0,0 +1,87 @@
"""Combina i NUOVI edge (pairs + TSM01) col MASTER esistente: migliora il portafoglio?
Aggiunge al MASTER a 9 sleeve (6 fade + 3 honest) due nuove fonti scoperte
nell'esplorazione, poco correlate:
- PAIRS market-neutral (ETH/BTC, LTC/ETH, ADA/ETH) -> corr ~0 col mercato
- TSM01 (TSMOM multi-orizzonte + risk-off) -> corr ~0.53 con ROT02
Misura correlazione delle nuove sleeve vs esistenti e confronta MASTER-9 vs
MASTER-esteso su Ret/CAGR/DD/Sharpe, FULL e OOS (finestra comune 2021-2026).
"""
from __future__ import annotations
import sys
from pathlib import Path
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.combine_portfolio import (
build_all_sleeves, port_returns, metrics, yearly_returns, SPLIT, OOS_DATE, IDX,
)
from scripts.analysis.honest_improve2 import _daily_equity, _norm
from scripts.analysis.pairs_research import pairs_sim
from scripts.analysis.tsmom_research import tsmom_sim
def daily_from(eq_ts, eq_v):
return _norm(_daily_equity(eq_ts, eq_v, IDX))
def main():
print("Costruzione equity (puo' richiedere ~1-2 min)...\n")
S = build_all_sleeves() # 9 sleeve esistenti
# nuove sleeve: i 6 pairs robusti di PR01 + TSM01
from scripts.strategies.PR01_pairs_reversion import PAIRS
new = {}
for a, b, p in PAIRS:
r = pairs_sim(a, b, **p)
new[f"PR_{a}{b}"] = daily_from(r["eq_ts"], r["eq_v"])
t = tsmom_sim()
new["TSM01"] = daily_from(t["eq_ts"], t["eq_v"])
allS = {**S, **new}
# --- correlazione nuove vs esistenti ---
dr = pd.DataFrame({k: v.pct_change().fillna(0.0) for k, v in allS.items()})
corr = dr.corr()
old_k = list(S); new_k = list(new)
print("=" * 88)
print(" CORRELAZIONE rendimenti giornalieri — NUOVE (righe) vs media esistenti")
print("=" * 88)
for nk in new_k:
avg = corr.loc[nk, old_k].mean()
mx = corr.loc[nk, old_k].abs().max()
print(f" {nk:<12s} corr media col MASTER-9 = {avg:+.2f} |max| = {mx:.2f}")
# --- confronto portafogli ---
def line(label, members):
pr = port_returns(members)
f, o = metrics(pr), metrics(pr, lo=SPLIT)
print(f" {label:<26s}{f['ret']:>+9.0f}{f['cagr']:>7.0f}{f['dd']:>7.1f}{f['sharpe']:>7.2f}"
f" | {o['ret']:>+9.0f}{o['dd']:>7.1f}{o['sharpe']:>7.2f}")
return pr
print("\n" + "=" * 96)
print(f" MASTER-9 vs MASTER-ESTESO (con pairs+TSM01) | OOS da {OOS_DATE} | equal-weight daily")
print("=" * 96)
print(f" {'portafoglio':<26s}{'Ret%':>9s}{'CAGR':>7s}{'DD%':>7s}{'Shrp':>7s}"
f" | {'oRet%':>9s}{'oDD%':>7s}{'oShrp':>7s}")
print(" " + "-" * 92)
pairs_only = {k: v for k, v in new.items() if k.startswith('PR_')}
line(f"MASTER-9 (base)", S)
line(f"MASTER +pairs ({len(S)+len(pairs_only)})", {**S, **pairs_only})
line(f"MASTER +TSM01 ({len(S)+1})", {**S, "TSM01": new["TSM01"]})
pr_all = line(f"MASTER-esteso ({len(allS)})", allS)
print(" " + "-" * 92)
pa = yearly_returns(pr_all)
print(" MASTER-esteso per-anno: " + " ".join(f"{y}:{v:+.0f}%" for y, v in pa.items()))
print("\n Se il MASTER-esteso ha DD piu' basso e/o Sharpe piu' alto del MASTER-9, le nuove")
print(" famiglie aggiungono valore (diversificazione da fonti scorrelate).")
if __name__ == "__main__":
main()
+171
View File
@@ -0,0 +1,171 @@
"""Harness ONESTO condiviso per esplorare nuove famiglie di strategie.
Regole NON negoziabili (per non ripetere l'errore squeeze look-ahead):
- direzione e prezzo decisi con dati FINO a close[i] incluso, mai con la barra i
usata per scegliere la direzione e poi entrare a i-1;
- ingresso ESEGUIBILE a close[i];
- exit: take-profit / stop-loss intrabar (high/low) e/o time-limit max_bars;
tp/sl possono essere None -> exit solo a tempo (utile per stagionalita');
- una posizione per volta (non-overlap), capitale composto;
- NETTO dopo fee round-trip (default 0.10% RT reale Deribit) e leva;
- validazione OOS (held-out, ultimo 30%) + sweep fee 0.00-0.20% RT.
Le strategie ad alta frequenza muoiono di fee: ogni entry costa fee_rt*lev sul
notional. Tienine conto: meno operazioni e edge > costi.
Asset disponibili: ADA BNB BTC DOGE ETH LTC SOL XRP (1h, 15m; BTC/ETH anche 5m).
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from src.data.downloader import load_data
FEE_RT = 0.001 # Deribit perp realistico: taker ~0.05%/lato = 0.10% RT
LEV = 3.0
POS = 0.15
OOS_FRAC = 0.30
ASSETS = ["ADA", "BNB", "BTC", "DOGE", "ETH", "LTC", "SOL", "XRP"]
BARS_PER_YEAR = {"5m": 105120, "15m": 35040, "1h": 8760, "4h": 2190, "1d": 365}
# --------------------------- dati ---------------------------
def get_df(asset: str, tf: str) -> pd.DataFrame:
"""OHLCV con colonna dt (UTC). tf nativo (5m,15m,1h) o resample da 1h (4h,1d).
timestamp resta ms-epoch reale anche dopo il resample (no placeholder)."""
if tf in ("5m", "15m", "1h"):
df = load_data(asset, tf).reset_index(drop=True)
else:
base = load_data(asset, "1h").copy()
base["dt"] = pd.to_datetime(base["timestamp"], unit="ms", utc=True)
base = base.set_index("dt")
rule = {"4h": "4h", "1d": "1D"}[tf]
agg = base.resample(rule).agg(
{"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"}
).dropna()
epoch = pd.Timestamp("1970-01-01", tz="UTC") # ms-epoch portabile (qualsiasi risoluzione)
agg["timestamp"] = ((agg.index - epoch) // pd.Timedelta(milliseconds=1)).astype("int64")
df = agg.reset_index(drop=True)
df["dt"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
return df
def _dt(df: pd.DataFrame) -> pd.DatetimeIndex:
return pd.to_datetime(df["timestamp"], unit="ms", utc=True)
# --------------------------- indicatori ---------------------------
def atr(df: pd.DataFrame, n: int = 14) -> np.ndarray:
h, l, c = df["high"].values, df["low"].values, df["close"].values
pc = np.roll(c, 1); pc[0] = c[0]
tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc)))
return pd.Series(tr).rolling(n).mean().values
def ema(x: np.ndarray, n: int) -> np.ndarray:
return pd.Series(x).ewm(span=n, adjust=False).mean().values
def rsi(close: np.ndarray, n: int = 14) -> np.ndarray:
d = np.diff(close, prepend=close[0])
up = pd.Series(np.where(d > 0, d, 0.0)).ewm(alpha=1/n, adjust=False).mean()
dn = pd.Series(np.where(d < 0, -d, 0.0)).ewm(alpha=1/n, adjust=False).mean()
rs = up / dn.replace(0, np.nan)
return (100 - 100 / (1 + rs)).values
# --------------------------- engine ---------------------------
def simulate(entries: list[dict], df: pd.DataFrame, fee_rt: float = FEE_RT,
lev: float = LEV, pos: float = POS, split: int = -1) -> dict:
"""entries: dict con i(idx), d(+1/-1), max_bars; tp/sl opzionali (None=solo tempo).
split: se >0, conta solo entries con i>=split (finestra OOS)."""
h, l, c = df["high"].values, df["low"].values, df["close"].values
n = len(c)
ts = _dt(df)
cap = peak = 1000.0
max_dd = 0.0
fee = fee_rt * lev
trades = wins = 0
last_exit = -1
bars_in = 0
yearly: dict[int, float] = {}
rets: list[float] = []
for e in entries:
i, d = e["i"], e["d"]
if i <= last_exit or i + 1 >= n or i < split:
continue
entry = c[i]
tp, sl, mb = e.get("tp"), e.get("sl"), e["max_bars"]
exit_p = c[min(i + mb, n - 1)]
j = min(i + mb, n - 1)
for k in range(1, mb + 1):
j = i + k
if j >= n:
exit_p = c[n - 1]; break
hit_sl = sl is not None and ((d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl))
hit_tp = tp is not None and ((d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp))
if hit_sl:
exit_p = sl; break
if hit_tp:
exit_p = tp; break
if k == mb:
exit_p = c[j]
ret = (exit_p - entry) / entry * d * lev - fee
cb = cap
cap = max(cb + cb * pos * ret, 10.0)
peak = max(peak, cap); max_dd = max(max_dd, (peak - cap) / peak)
trades += 1; wins += ret > 0; bars_in += (j - i)
last_exit = j
rets.append(ret * pos)
yearly[ts.iloc[i].year] = yearly.get(ts.iloc[i].year, 0.0) + ret * 100
sharpe = float(np.mean(rets) / np.std(rets) * np.sqrt(len(rets))) if len(rets) > 1 and np.std(rets) > 0 else 0.0
return {
"trades": trades,
"win": wins / trades * 100 if trades else 0.0,
"ret": (cap / 1000 - 1) * 100,
"dd": max_dd * 100,
"sharpe": sharpe,
"yearly": yearly,
"exposure": bars_in / n * 100 if n else 0.0,
}
def evaluate(name: str, entries: list[dict], df: pd.DataFrame,
fees=(0.0, 0.0005, 0.001, 0.002)) -> dict:
"""Valuta una lista di entries: FULL, OOS e sweep fee. Stampa una riga sintetica."""
split = int(len(df) * (1 - OOS_FRAC))
full = simulate(entries, df)
oos = simulate(entries, df, split=split)
sweep = {f: simulate(entries, df, fee_rt=f)["ret"] for f in fees}
sweep_oos = {f: simulate(entries, df, fee_rt=f, split=split)["ret"] for f in fees}
yrs = full["yearly"]; pos_yrs = sum(1 for v in yrs.values() if v > 0)
print(f" {name:<24s} trd={full['trades']:>5d} win={full['win']:>4.1f}% "
f"FULL={full['ret']:>+7.0f}% OOS={oos['ret']:>+7.0f}% DD={full['dd']:>4.0f}% "
f"oDD={oos['dd']:>4.0f}% Shrp={full['sharpe']:>4.2f} exp={full['exposure']:>4.1f}% "
f"anniPos={pos_yrs}/{len(yrs)} | fee0.2%: FULL={sweep[0.002]:>+6.0f} OOS={sweep_oos[0.002]:>+6.0f}")
return {"full": full, "oos": oos, "sweep": sweep, "sweep_oos": sweep_oos, "pos_yrs": pos_yrs, "n_yrs": len(yrs)}
def robust(res: dict) -> bool:
"""Verdetto onesto: positivo FULL e OOS, regge a fee 0.20% RT, quasi tutti gli anni positivi."""
return (res["full"]["ret"] > 0 and res["oos"]["ret"] > 0
and res["sweep"][0.002] > 0 and res["sweep_oos"][0.002] > 0
and res["pos_yrs"] >= max(res["n_yrs"] - 1, 1))
if __name__ == "__main__":
# smoke test: una stagionalita' banale (hour-of-day) su BTC 1h
df = get_df("BTC", "1h"); ts = _dt(df)
ents = [{"i": i, "d": 1, "max_bars": 6, "tp": None, "sl": None}
for i in range(len(df) - 7) if ts.iloc[i].hour == 0]
print("smoke test — BTC long ad ogni 00:00 UTC, hold 6h:")
evaluate("seasonality_h0", ents, df)
+66
View File
@@ -0,0 +1,66 @@
import sys; sys.path.insert(0,".")
import numpy as np, pandas as pd
from scripts.analysis.regime_lab import load_features
from scripts.analysis.explore_lab import atr
import importlib
FEE=0.001; LEV=3
def load_strat(mod):
m=importlib.import_module(mod)
return next(v() for k,v in vars(m).items() if isinstance(v,type) and hasattr(v,'generate_signals') and getattr(v,'__module__','')==m.__name__)
STR={"MR01":("scripts.strategies.MR01_bollinger_fade",dict(bb_window=50,k=2.5,sl_atr=2.0,max_bars=24,trend_max=3.0)),
"MR02":("scripts.strategies.MR02_donchian_fade",dict(n=20,sl_atr=2.0,max_bars=24,trend_max=3.0)),
"MR07":("scripts.strategies.MR07_return_reversal",dict(n=50,k=3.5,tp_atr=2.0,sl_atr=1.5,max_bars=24,trend_max=3.0))}
def replay(df, sigs):
h=df['high'].values; l=df['low'].values; c=df['close'].values
out=[]; last=-1
for s in sigs:
i=s.idx
if i<=last: continue
d=s.direction; tp=s.metadata['tp']; sl=s.metadata['sl']; mb=s.metadata['max_bars']
j=min(i+mb,len(c)-1); exit_p=c[j]
for t in range(i+1,j+1):
if d==1:
if l[t]<=sl: exit_p=sl;j=t;break
if h[t]>=tp: exit_p=tp;j=t;break
else:
if h[t]>=sl: exit_p=sl;j=t;break
if l[t]<=tp: exit_p=tp;j=t;break
ret=(exit_p-c[i])/c[i]*d*LEV-FEE*LEV
out.append((i,ret)); last=j
return out
# raccogli tutti i trade con il loro dvol_pct e hurst all'ingresso
rows=[]
for asset in ("BTC","ETH"):
df=load_features(asset,"1h"); ts=pd.to_datetime(df['timestamp'],unit='ms',utc=True)
for code,(mod,par) in STR.items():
s=load_strat(mod); sigs=s.generate_signals(df,ts,**par)
for i,ret in replay(df,sigs):
rows.append(dict(asset=asset,code=code,year=ts.iloc[i].year,ret=ret,
dvol_pct=df['dvol_pct'].iloc[i], hurst=df['hurst'].iloc[i],
dvol=df['dvol'].iloc[i]))
R=pd.DataFrame(rows).dropna(subset=['dvol_pct'])
print(f"trade totali (con DVOL, 2021+): {len(R)}")
print("\n=== PnL medio per trade per TERZILE DVOL (bassa/media/alta vol) ===")
R['dvbin']=pd.cut(R['dvol_pct'],[0,.33,.66,1.0],labels=['LOW-vol','MID','HIGH-vol'])
g=R.groupby('dvbin',observed=True)['ret']
print(f" {'regime':<10}{'n':>6}{'ret_medio%':>12}{'win%':>8}{'somma%':>10}")
for b in ['LOW-vol','MID','HIGH-vol']:
x=R[R.dvbin==b]['ret']
print(f" {b:<10}{len(x):>6}{x.mean()*100:>12.3f}{(x>0).mean()*100:>8.1f}{x.sum()*100:>10.0f}")
print("\n=== dentro LOW-vol: split per HURST (anti-persistente vs trending) ===")
LV=R[R.dvbin=='LOW-vol'].copy()
LV['hbin']=pd.cut(LV['hurst'],[0,.45,.55,1.0],labels=['hurst<.45 (anti-pers)','.45-.55','>.55 (trend)'])
for b in ['hurst<.45 (anti-pers)','.45-.55','>.55 (trend)']:
x=LV[LV.hbin==b]['ret']
if len(x): print(f" {b:<24}{len(x):>6} ret_medio {x.mean()*100:>+7.3f}% win {(x>0).mean()*100:>5.1f}% somma {x.sum()*100:>+6.0f}%")
print("\n=== per anno: PnL fade in LOW-vol vs resto ===")
for y in range(2021,2027):
lo=R[(R.year==y)&(R.dvbin=='LOW-vol')]['ret']; hi=R[(R.year==y)&(R.dvbin!='LOW-vol')]['ret']
print(f" {y}: LOW-vol somma {lo.sum()*100:>+6.0f}% (n{len(lo)}) | MID/HIGH somma {hi.sum()*100:>+6.0f}% (n{len(hi)})")
+50
View File
@@ -0,0 +1,50 @@
import sys; sys.path.insert(0,".")
import numpy as np, pandas as pd
from scripts.analysis.regime_lab import load_features
import importlib
FEE=0.001; LEV=3
def load_strat(mod):
m=importlib.import_module(mod)
return next(v() for k,v in vars(m).items() if isinstance(v,type) and hasattr(v,'generate_signals') and getattr(v,'__module__','')==m.__name__)
STR={"MR01":("scripts.strategies.MR01_bollinger_fade",dict(bb_window=50,k=2.5,sl_atr=2.0,max_bars=24,trend_max=3.0)),
"MR02":("scripts.strategies.MR02_donchian_fade",dict(n=20,sl_atr=2.0,max_bars=24,trend_max=3.0)),
"MR07":("scripts.strategies.MR07_return_reversal",dict(n=50,k=3.5,tp_atr=2.0,sl_atr=1.5,max_bars=24,trend_max=3.0))}
def replay(df,sigs):
h=df['high'].values;l=df['low'].values;c=df['close'].values;out=[];last=-1
for s in sigs:
i=s.idx
if i<=last: continue
d=s.direction;tp=s.metadata['tp'];sl=s.metadata['sl'];mb=s.metadata['max_bars'];j=min(i+mb,len(c)-1);exit_p=c[j];reason='time'
for t in range(i+1,j+1):
if d==1:
if l[t]<=sl: exit_p=sl;j=t;reason='sl';break
if h[t]>=tp: exit_p=tp;j=t;reason='tp';break
else:
if h[t]>=sl: exit_p=sl;j=t;reason='sl';break
if l[t]<=tp: exit_p=tp;j=t;reason='tp';break
out.append((i,(exit_p-c[i])/c[i]*d*LEV-FEE*LEV,reason));last=j
return out
rows=[]
for asset in ("BTC","ETH"):
df=load_features(asset,"1h");ts=pd.to_datetime(df['timestamp'],unit='ms',utc=True)
for code,(mod,par) in STR.items():
s=load_strat(mod)
for i,ret,reason in replay(df,s.generate_signals(df,ts,**par)):
rows.append(dict(ret=ret,reason=reason,dvol_pct=df['dvol_pct'].iloc[i],hurst=df['hurst'].iloc[i],
vratio=df['vratio'].iloc[i],higuchi=df['higuchi'].iloc[i]))
R=pd.DataFrame(rows).dropna(subset=['dvol_pct','hurst'])
L=R[R.ret<0] # solo i trade in perdita
print(f"trade {len(R)} | in perdita {len(L)} ({len(L)/len(R)*100:.0f}%) | somma perdite {L.ret.sum()*100:.0f}% | media perdita {L.ret.mean()*100:.2f}%")
print("\n=== somma PERDITE per regime (dove si concentra il danno) ===")
R['dvbin']=pd.cut(R.dvol_pct,[0,.33,.66,1],labels=['LOWvol','MID','HIGHvol'])
R['hbin']=pd.cut(R.hurst,[0,.45,.55,1],labels=['anti<.45','.45-.55','trend>.55'])
piv=R[R.ret<0].pivot_table(index='dvbin',columns='hbin',values='ret',aggfunc='sum',observed=True)*100
print((piv.round(0)).to_string())
print("\n (numeri = somma % delle perdite per cella; piu negativo = piu danno)")
print("\n=== quota di SL (stop) per regime ===")
slr=R.groupby(['dvbin','hbin'],observed=True).apply(lambda x:(x.reason=='sl').mean()*100, include_groups=False)
print(slr.round(0).to_string())
# worst tail
print(f"\n=== peggiori 1% trade: dove? ===")
W=R.nsmallest(max(10,len(R)//100),'ret')
print(f" worst {len(W)} trade: dvol_pct medio {W.dvol_pct.mean():.2f}, hurst medio {W.hurst.mean():.2f}, quota hurst>.55 {(W.hurst>.55).mean()*100:.0f}%, quota dvol<.33 {(W.dvol_pct<.33).mean()*100:.0f}%")
@@ -0,0 +1,56 @@
import sys; sys.path.insert(0,".")
import numpy as np, pandas as pd, importlib
from scripts.analysis.combine_portfolio import IDX, SPLIT, INIT, _norm, metrics, port_returns, build_trades
from src.portfolio.sleeves import all_sleeve_equities
from scripts.analysis.regime_lab import load_features
def load_strat(mod):
m=importlib.import_module(mod)
return next(v() for k,v in vars(m).items() if isinstance(v,type) and hasattr(v,'generate_signals') and getattr(v,'__module__','')==m.__name__)
FADES={"MR01":("scripts.strategies.MR01_bollinger_fade",dict(bb_window=50,k=2.5,sl_atr=2.0,max_bars=24,trend_max=3.0)),
"MR02":("scripts.strategies.MR02_donchian_fade",dict(n=20,sl_atr=2.0,max_bars=24,trend_max=3.0)),
"MR07":("scripts.strategies.MR07_return_reversal",dict(n=50,k=3.5,tp_atr=2.0,sl_atr=1.5,max_bars=24,trend_max=3.0))}
FEE=0.001; LEV=3; POS=0.15
def fade_equity_filtered(code, asset, hurst_thr=None):
"""equity giornaliera dello sleeve fade, opz. filtrata Hurst<thr (skip hurst>=thr). Convenzione fade_daily_equity."""
mod,par=FADES[code]; s=load_strat(mod)
df=load_features(asset,"1h"); ts=pd.to_datetime(df['timestamp'],unit='ms',utc=True)
h=df['high'].values; l=df['low'].values; c=df['close'].values; hur=df['hurst'].values
eq=np.full(len(c),INIT,float); cap=INIT; last=-1
for sg in s.generate_signals(df,ts,**par):
i=sg.idx
if i<=last: continue
if hurst_thr is not None and not np.isnan(hur[i]) and hur[i]>=hurst_thr: continue # FILTRO
d=sg.direction; tp=sg.metadata['tp']; sl=sg.metadata['sl']; mb=sg.metadata['max_bars']
j=min(i+mb,len(c)-1); exit_p=c[j]
for t in range(i+1,j+1):
if d==1:
if l[t]<=sl: exit_p=sl;j=t;break
if h[t]>=tp: exit_p=tp;j=t;break
else:
if h[t]>=sl: exit_p=sl;j=t;break
if l[t]<=tp: exit_p=tp;j=t;break
ret=(exit_p-c[i])/c[i]*d*LEV-FEE*LEV
cap=max(cap+cap*POS*ret,10.0); eq[j:]=cap; last=j
sser=pd.Series(eq,index=ts).resample("1D").last().reindex(IDX).ffill().bfill()
return _norm(sser)
base=all_sleeve_equities()
fade_ids=["MR01_BTC","MR02_BTC","MR07_BTC","MR01_ETH","MR02_ETH","MR07_ETH"]
def port(members):
dr=port_returns(members); return metrics(dr), metrics(dr,lo=SPLIT)
# baseline PORT06
fB,oB=port(base)
print(f"PORT06 baseline (17 sleeve): FULL Sharpe {fB['sharpe']:.2f} DD {fB['dd']:.2f}% | OOS Sharpe {oB['sharpe']:.2f} DD {oB['dd']:.2f}% ret {oB['ret']:+.0f}%")
# sostituisci le 6 fade con versione Hurst-skip
for thr in (0.55, 0.50):
filt=dict(base)
for fid in fade_ids:
code,asset=fid.split("_")
filt[fid]=fade_equity_filtered(code,asset,hurst_thr=thr)
fF,oF=port(filt)
print(f"PORT06 + Hurst-skip h<{thr} sulle fade: FULL Sharpe {fF['sharpe']:.2f} DD {fF['dd']:.2f}% | OOS Sharpe {oF['sharpe']:.2f} DD {oF['dd']:.2f}% ret {oF['ret']:+.0f}%")
+120
View File
@@ -0,0 +1,120 @@
export const meta = {
name: 'fade-lossguard',
description: 'Sistema anti-perdite per le fade in regime trending/low-vol: test meccanismi su MR01/02/07',
phases: [
{ title: 'Test', detail: 'agenti: ogni meccanismo di filtro applicato alle fade reali (BTC+ETH)' },
{ title: 'Synth', detail: 'classifica + miglior loss-guard, gate: riduce DD senza uccidere edge' },
],
}
const API = `
=== Harness (gia pronto) ===
import sys; sys.path.insert(0,'.')
import numpy as np, pandas as pd, importlib
from scripts.analysis.regime_lab import load_features, report
from scripts.analysis.explore_lab import robust, atr
def load_strat(mod):
m=importlib.import_module(mod)
return next(v() for k,v in vars(m).items() if isinstance(v,type) and hasattr(v,'generate_signals') and getattr(v,'__module__','')==m.__name__)
FADES={'MR01':('scripts.strategies.MR01_bollinger_fade',dict(bb_window=50,k=2.5,sl_atr=2.0,max_bars=24,trend_max=3.0)),
'MR02':('scripts.strategies.MR02_donchian_fade',dict(n=20,sl_atr=2.0,max_bars=24,trend_max=3.0)),
'MR07':('scripts.strategies.MR07_return_reversal',dict(n=50,k=3.5,tp_atr=2.0,sl_atr=1.5,max_bars=24,trend_max=3.0))}
# colonne regime_lab (causali): dvol, dvol_pct, vrp, funding_z, dvol_chg, hurst, higuchi, vratio, frac_up/dn
# ADX (se ti serve) calcolalo causale da OHLC; efficiency-ratio Kaufman = |c[i]-c[i-n]| / sum|diff| su [i-n,i].
# PATTERN: genera i segnali fade, poi APPLICA IL TUO FILTRO scartando le entries in regime sfavorevole,
# confronta BASELINE vs FILTRATA su ogni fade x asset:
def entries_from(strat, df, par, keep=lambda df,i: True):
ts=pd.to_datetime(df['timestamp'],unit='ms',utc=True)
out=[]
for s in strat.generate_signals(df,ts,**par):
if keep(df, s.idx): # keep=False -> filtro scarta (loss-guard)
out.append({'i':s.idx,'d':s.direction,'tp':s.metadata['tp'],'sl':s.metadata['sl'],'max_bars':s.metadata['max_bars']})
return out
# per ogni (fade,asset): res_base=report(.., entries_from(..., keep=tutto)); res_filt=report(.., col tuo keep)
# confronta: Sharpe OOS, DD full/oos, ret, #trade (quanti scartati), e robust(). Aggrega sulle 6 combo.
`
const CONTEXT = `
PROBLEMA: le fade (MR01 Bollinger, MR02 Donchian, MR07 return-reversal) sono mean-reversion 1h con
filtro trend EMA200 (trend_max=3.0). DIAGNOSI EMPIRICA (3022 trade, 2021+): le PERDITE e gli STOP
si concentrano nel regime PERSISTENTE/TRENDING, NON nella bassa vol:
- somma perdite per cella (Hurst x DVOL): la cella peggiore e' hurst>0.55 (-2695% in low-vol,
dominante in ogni terzile vol). I peggiori 1% trade hanno hurst medio 0.61 (77% con hurst>0.55).
- tasso STOP-LOSS: 43% quando hurst>0.55 vs 21% quando hurst<0.45 (anti-persistente). 2x.
- net: le celle restano positive (i winner battono), quindi filtrare toglie anche winner -> il
loss-guard e' utile SOLO se riduce DD/coda SENZA uccidere l'edge netto.
RICERCA ESTERNA (confermata): (a) Hurst regime filter: MR solo H<0.45, in 0.45-0.55 ridurre size,
evitare H>0.55. (b) ADX: MR profit factor 1.62 con ADX<20 vs -0.74 con ADX>30 (switch di regime piu'
importante). (c) ATR/vol-EXPANSION ratio>1.5 disabilita MR -> ha prevenuto il 72% delle perdite
maggiori. (d) time-stop: se non rientra in ~15 barre e' un trend, esci.
OBIETTIVO: trovare il MIGLIOR meccanismo (o combo) che, applicato alle fade reali, RIDUCE DD/coda/
stop-rate MANTENENDO l'edge netto OOS. Metodologia: causale no-look-ahead (le colonne regime_lab
sono causali; i filtri usano solo dati <= i), netto fee (report() fa OOS+sweep). LEZIONE FR01: un
filtro che riduce le perdite ma anche i winner spesso NON migliora -> il gate vero e' DD giu' a
parita' (o quasi) di Sharpe/ret, idealmente Sharpe SU e DD GIU'.
` + API
const SCHEMA = {
type: 'object',
properties: {
meccanismo: { type: 'string' },
descrizione: { type: 'string' },
base_oos_sharpe: { type: 'number' }, filt_oos_sharpe: { type: 'number' },
base_dd_full: { type: 'number' }, filt_dd_full: { type: 'number' },
base_oos_ret: { type: 'number' }, filt_oos_ret: { type: 'number' },
trade_scartati_pct: { type: 'number' },
riduce_perdite: { type: 'boolean', description: 'riduce DD/coda/stop-rate' },
preserva_edge: { type: 'boolean', description: 'edge netto OOS preservato (Sharpe non crolla, robust resta)' },
buon_lossguard: { type: 'boolean', description: 'riduce perdite SENZA uccidere edge -> candidato' },
verdetto: { type: 'string', description: 'numeri base vs filtrato aggregati sulle 6 combo fade x asset' },
},
required: ['meccanismo', 'buon_lossguard', 'riduce_perdite', 'preserva_edge', 'verdetto'],
}
const MECHS = [
['Hurst-skip H>0.55', 'scarta le fade quando rolling-hurst(window=100) >= 0.55 (regime persistente). Test anche soglia 0.50 e 0.60, riporta la migliore.'],
['Hurst-size transition', 'NON scartare ma RIDURRE: tieni tutte le entries ma pesa size 1.0 se hurst<0.45, 0.5 se 0.45-0.55, 0.25 se >0.55. (Per testare la riduzione size col report attuale: approssima scartando il 50%/75% delle entries nelle bin alte in modo deterministico per indice, oppure confronta solo le bin.)'],
['ADX-skip', 'calcola ADX(14) causale; scarta le fade quando ADX>25 (trend). Test soglie 20/25/30.'],
['vol-expansion vratio', 'scarta le fade quando vratio (vol breve/lunga, colonna regime_lab) > 1.5 (vol in espansione = breakout, non range). Test 1.3/1.5/1.8. (la ricerca dice -72% perdite maggiori)'],
['efficiency-ratio Kaufman', 'ER = |c[i]-c[i-n]|/sum(|diff|) su finestra n=20; scarta quando ER>0.5 (moto efficiente/trending). Test 0.4/0.5/0.6.'],
['time-stop piu corto', 'riduci max_bars da 24 a 12 o 15 (esci prima se non rientra = probabile trend). Confronta DD/edge.'],
['Hurst + vol-expansion combo', 'scarta se hurst>0.55 OPPURE vratio>1.5. Verifica se la combo riduce piu DD del singolo senza perdere piu edge.'],
['Hurst + ADX combo', 'scarta se hurst>0.55 E ADX>25 (doppia conferma di trend) -> piu selettivo, scarta meno winner.'],
['vol-target sizing', 'scala la size per 1/realized_vol (target vol costante): approssima tenendo solo le entries in vol moderata, riporta effetto su DD/coda.'],
['DVOL-rising skip', 'scarta le fade quando dvol_chg>0 forte (DVOL in salita = stress/espansione vol imminente). Test soglie su dvol_chg.'],
]
const ASSETS_NOTE = 'Applica a tutte e 3 le fade (MR01,MR02,MR07) su BTC E ETH (6 combo), aggrega base vs filtrato.'
phase('Test')
// ogni meccanismo = 1 agente che testa su tutte le 6 combo; piu' 4 agenti che esplorano combo/parametri fini
const tasks = MECHS.map(([nm, desc]) => () => agent(
CONTEXT + `\n\nMECCANISMO DA TESTARE: ${nm}\n${desc}\n\n${ASSETS_NOTE}\n` +
`Scrivi uno script in /tmp (cd /opt/docker/PythagorasGoal && uv run python /tmp/<file>.py), confronta ` +
`BASELINE (fade senza filtro) vs FILTRATA su ogni combo, AGGREGA (media o somma equity) e riporta. ` +
`Il filtro deve essere CAUSALE. Decidi buon_lossguard=true SOLO se riduce DD/coda/stop-rate MANTENENDO ` +
`l'edge netto OOS (Sharpe non crolla, ret OOS resta ampiamente positivo). Cita i numeri base vs filtrato.`,
{ label: `mech:${nm.slice(0, 18)}`, phase: 'Test', schema: SCHEMA }))
const results = (await parallel(tasks)).filter(Boolean)
phase('Synth')
const good = results.filter(r => r.buon_lossguard)
const synthesis = await agent(
CONTEXT +
`\n\nRisultati di ${results.length} meccanismi testati:\n${JSON.stringify(results, null, 1)}\n\n` +
`SINTESI FINALE (italiano) per il decisore:
1) Esiste un loss-guard che riduce le perdite/DD delle fade in regime trending SENZA uccidere l'edge?
2) Tabella: meccanismo | base vs filtrato (OOS Sharpe, DD, ret, %trade scartati) | buon_lossguard?
3) Il MIGLIORE (e l'eventuale combo) con i numeri. Quanto DD/coda si risparmia e a che costo di ret.
4) Coerenza con la ricerca esterna (Hurst<0.45 / ADX / vol-expansion / time-stop).
5) Raccomandazione: quale filtro applicare alle fade live, con che soglia, e il caveat (serve feed
DVOL/regime live? il filtro va validato a livello PORT06 = riduce il DD del portafoglio?).
Onesta: se nessuno migliora davvero (riduce solo ret), dillo. Cita NUMERI reali.`,
{ label: 'synth-lossguard', phase: 'Synth' })
return { results, good, synthesis }
@@ -0,0 +1,54 @@
import sys; sys.path.insert(0,".")
import numpy as np, pandas as pd
from scripts.analysis.combine_portfolio import IDX, SPLIT, INIT, _norm, metrics, port_returns
from src.portfolio.sleeves import all_sleeve_equities
from scripts.analysis.regime_lab import load_features
from scripts.analysis.explore_lab import atr
FEE=0.001; LEV=3; POS=0.15
def fr01_daily_equity(asset):
df=load_features(asset,"1h")
c=df['close'].values; h=df['high'].values; l=df['low'].values; a=atr(df,14)
ma=pd.Series(c).rolling(50).mean().values; sd=pd.Series(c).rolling(50).std().values
ts=pd.to_datetime(df['timestamp'],unit='ms',utc=True)
eq=np.full(len(c),INIT,float); cap=INIT; last=-1
for i in range(64,len(c)-1):
if i<=last or np.isnan(sd[i]) or sd[i]==0 or np.isnan(a[i]): continue
if df['hurst'].iloc[i]>=0.55: continue
if np.isnan(df['dvol_pct'].iloc[i]) or df['dvol_pct'].iloc[i]>=0.40: continue
if c[i]<ma[i]-2.5*sd[i]: d,sl,tp=1,c[i]-2*a[i],ma[i]
elif c[i]>ma[i]+2.5*sd[i]: d,sl,tp=-1,c[i]+2*a[i],ma[i]
else: continue
j=min(i+24,len(c)-1); exit_p=c[j]
for t in range(i+1,j+1):
if d==1:
if l[t]<=sl: exit_p=sl; j=t; break
if h[t]>=tp: exit_p=tp; j=t; break
else:
if h[t]>=sl: exit_p=sl; j=t; break
if l[t]<=tp: exit_p=tp; j=t; break
ret=(exit_p-c[i])/c[i]*d*LEV - FEE*LEV
cap=max(cap+cap*POS*ret,10.0); eq[j:]=cap; last=j
s=pd.Series(eq,index=ts).resample("1D").last().reindex(IDX).ffill().bfill()
return _norm(s)
members=all_sleeve_equities()
print(f"PORT06 sleeve: {len(members)} | finestra {IDX[0].date()}..{IDX[-1].date()} | OOS da idx {SPLIT} ({IDX[SPLIT].date()})")
fr={"FR01_BTC":fr01_daily_equity("BTC"), "FR01_ETH":fr01_daily_equity("ETH")}
base=port_returns(members) # equal-weight 17 sleeve (metro combine)
aug =port_returns({**members,**fr}) # + FR01x2 (19 sleeve)
def show(tag, dr):
f=metrics(dr); o=metrics(dr,lo=SPLIT)
print(f" {tag:<22} FULL: Sharpe {f['sharpe']:.2f} DD {f['dd']:.1f}% ret {f['ret']:+.0f}% | OOS: Sharpe {o['sharpe']:.2f} DD {o['dd']:.1f}% ret {o['ret']:+.0f}%")
print("\n=== MASTER equal-weight: con/senza FR01 ===")
show("PORT06 (17 sleeve)", base)
show("PORT06 + FR01 (19)", aug)
# correlazione FR01 vs portafoglio MASTER aggregato + standalone
for k,e in fr.items():
r=e.pct_change().fillna(0.0); corr=np.corrcoef(r, base)[0,1]
f=metrics(r); o=metrics(r,lo=SPLIT)
print(f"\n {k}: corr vs MASTER = {corr:+.3f} | standalone OOS Sharpe {o['sharpe']:.2f} DD {o['dd']:.1f}% ret {o['ret']:+.0f}%")
+86
View File
@@ -0,0 +1,86 @@
import sys; sys.path.insert(0,".")
import numpy as np, pandas as pd
from scripts.analysis.regime_lab import load_features
from scripts.analysis.explore_lab import atr
FEE=0.001; LEV=3
def build(df, gate, k=2.5, sl_atr=2.0, mb=24, bb=50):
c=df['close'].values; a=atr(df,14)
ma=pd.Series(c).rolling(bb).mean().values; sd=pd.Series(c).rolling(bb).std().values
ent=[]
for i in range(bb+14,len(c)-1):
if np.isnan(sd[i]) or sd[i]==0 or np.isnan(a[i]): continue
if not gate(df,i): continue
if c[i]<ma[i]-k*sd[i]: d,sl=1,c[i]-sl_atr*a[i]
elif c[i]>ma[i]+k*sd[i]: d,sl=-1,c[i]+sl_atr*a[i]
else: continue
ent.append({'i':i,'d':d,'tp':ma[i],'sl':sl,'mb':mb})
return ent
def per_year(df, ent):
"""replay intrabar fedele (sl-first, tp, poi max_bars@close) -> per anno {n,ret%,win%}."""
h=df['high'].values; l=df['low'].values; c=df['close'].values
ts=pd.to_datetime(df['timestamp'],unit='ms',utc=True)
Y={}
last=-1
for e in ent:
i=e['i']
if i<=last: continue
d=e['d']; tp=e['tp']; sl=e['sl']; j=min(i+e['mb'],len(c)-1)
exit_p=c[j]
for t in range(i+1,j+1):
if d==1:
if l[t]<=sl: exit_p=sl; j=t; break
if h[t]>=tp: exit_p=tp; j=t; break
else:
if h[t]>=sl: exit_p=sl; j=t; break
if l[t]<=tp: exit_p=tp; j=t; break
ret=(exit_p-c[i])/c[i]*d*LEV - FEE*LEV
last=j; yr=ts.iloc[i].year
if yr not in Y: Y[yr]=[0,0.0,0]
Y[yr][0]+=1; Y[yr][1]+=ret*100; Y[yr][2]+= (ret>0)
return Y
# gate functions
def g_hurst_calm(df,i): return df['hurst'].iloc[i]<0.55 and not np.isnan(df['dvol_pct'].iloc[i]) and df['dvol_pct'].iloc[i]<0.40
def g_vrp_neg(df,i): return not np.isnan(df['vrp'].iloc[i]) and df['vrp'].iloc[i]<0
def g_hig_vrp(df,i):
hi=df['higuchi'].iloc[i]; return (not np.isnan(hi)) and hi>1.5 and (not np.isnan(df['vrp'].iloc[i])) and df['vrp'].iloc[i]<0
def g_none(df,i): return True
STRATS=[("HurstCalmFade (hurst<.55 & DVOL<p40)",g_hurst_calm),
("VRP<0 Fade (core driver)",g_vrp_neg),
("HigVRP Fade (Higuchi>1.5 & VRP<0)",g_hig_vrp),
("Fade NUDA (no gate, baseline)",g_none)]
# regime mercato BTC per anno (da BTC close annuale)
btc=load_features("BTC","1d")
bts=pd.to_datetime(btc['timestamp'],unit='ms',utc=True); bc=btc['close'].values
mkt={}
for yr in range(2021,2027):
m=(bts.dt.year==yr).values
if m.sum()>5:
r=(bc[m][-1]/bc[m][0]-1)*100
mkt[yr]=("BULL" if r>40 else "BEAR" if r<-30 else "RANGE", r)
for asset in ("BTC","ETH"):
df=load_features(asset,"1h")
print(f"\n{'='*78}\n {asset} 1h — performance per anno (somma ret% per-trade, netto leva3x+fee0.10%)\n{'='*78}")
print(f" {'Strategia':<40} " + " ".join(f"{y}" for y in range(2021,2027)))
for name,g in STRATS:
ent=build(df,g); Y=per_year(df,ent)
cells=[]
for y in range(2021,2027):
if y in Y and Y[y][0]>0:
cells.append(f"{Y[y][1]:+5.0f}")
else: cells.append(" . ")
print(f" {name:<40} " + " ".join(cells))
# riga trades/anno per la strategia principale
ent=build(df,g_hurst_calm); Y=per_year(df,ent)
tr=" ".join(f"{Y.get(y,[0])[0]:>5}" for y in range(2021,2027))
print(f" {'(HurstCalmFade trades/anno)':<40} {tr}")
print(f"\n REGIME MERCATO BTC per anno (ret% annuale prezzo):")
for y in range(2021,2027):
if y in mkt: print(f" {y}: {mkt[y][0]:6} ({mkt[y][1]:+.0f}%)")
+175
View File
@@ -0,0 +1,175 @@
export const meta = {
name: 'fractal-argo-search',
description: 'Ricerca a ~100 agenti: strategia FRATTALI del segnale x REGIME ARGO (DVOL/funding/VRP) validata OOS',
phases: [
{ title: 'Search', detail: '92 agenti: griglia frattale x regime x asset + wildcard' },
{ title: 'Verify', detail: 'verifica avversariale dei survivor (look-ahead, fee 0.2%, altro asset/split)' },
{ title: 'Synth', detail: 'classifica, sceglie vincitori, propone implementazione' },
],
}
const API = `
=== regime_lab API (gia pronta, dati FRESCHI in cache) ===
from scripts.analysis.regime_lab import load_features, report
from scripts.analysis.explore_lab import robust, atr, ema, rsi
df = load_features(ASSET, TF) # ASSET in {BTC,ETH}, TF in {1h,4h,1d}
# Colonne (tutte CAUSALI, valore a barra i usa solo dati <= i):
# OHLCV: open high low close volume timestamp
# REGIME (ARGO-proxy backtestabile): dvol, dvol_pct (percentile rolling 0..1),
# rv (realized vol ann.), vrp = dvol-rv (>0 = vol sopravvalutata ~ ARGO GEX+ range),
# funding, funding_z (z-score rolling), dvol_chg (DVOL salita/discesa, proxy term-structure)
# FRATTALI: hurst (>0.5 persistente/trend, <0.5 anti-persistente/mean-rev), higuchi (FD: alta=frastagliato),
# vratio (vol breve/lunga), frac_up/frac_dn (Williams pivot bool: swing high/low confermati, CAUSALI)
# NB: dvol e' NaN prima del 2021-03 (storico DVOL) -> salta le barre con dvol NaN se usi il regime.
# Costruisci 'entries': lista dict {i, d(+1/-1), tp, sl, max_bars}. INGRESSO ESEGUIBILE:
# i, d, tp, sl decisi con dati <= close[i]. tp/sl in PREZZO (o None). Esempio fade:
ent=[]
c=df['close'].values; a=atr(df,14); ma=df['close'].rolling(50).mean().values; sd=df['close'].rolling(50).std().values
for i in range(300, len(c)-1):
if np.isnan(sd[i]) or np.isnan(df['dvol_pct'].iloc[i]): continue
if df['vrp'].iloc[i] > 0 and c[i] < ma[i]-2.5*sd[i]: # GATE regime + SEGNALE frattale/tecnico
ent.append({'i':i,'d':1,'tp':ma[i],'sl':c[i]-2*a[i],'max_bars':24})
res = report('NOME', ent, df) # -> {full:{ret,sharpe,dd,trades,win,exposure}, oos:{...}, sweep, sweep_oos, pos_yrs, n_yrs}
ok = robust(res) # True = full+oos>0 E regge fee 0.2% RT E anni ~tutti positivi
print('ROBUST', ok, 'trd', res['full']['trades'], 'OOSsharpe', round(res['oos']['sharpe'],2),
'OOSret', round(res['oos']['ret']), 'fee02OOS', round(res['sweep_oos'][0.002]))
`
const CONTEXT = `
PROGETTO PythagorasGoal: trading crypto BTC/ETH. Edge dimostrato = SOLO mean-reversion (fade) + pairs.
ASTICELLA ALTA: il portafoglio PORT06 e' gia a Sharpe OOS 8.19 / DD 2.3%. Una strategia nuova vale solo
se ha edge NETTO validato OOS e robusto.
PRIORI ONESTI (non ignorarli): i FRATTALI sono stati gia esplorati e quasi tutti RUMORE (shape_lab:
analog kNN solo BTC-overfit; PIP/pivot 0/48 robuste; DTW peggiora). Le OPZIONI sono state SCARTATE
(W18/19/21 VRP). L'unico edge frattale validato e SH01 (shape-ML logit, diversificatore). MA: la
combinazione FRATTALE-del-segnale x REGIME-ARGO (gating su DVOL/funding/VRP) e' NUOVA e non testata ->
e' qui che potrebbe esserci valore: il regime puo dire QUANDO il segnale frattale funziona.
OBIETTIVO: trovare una strategia che combini un SEGNALE FRATTALE con un GATE/INTERAZIONE DI REGIME
(ARGO-proxy: DVOL percentile, VRP, funding) e che superi la validazione onesta (robust()=True).
METODOLOGIA OBBLIGATORIA: ingresso ESEGUIBILE senza look-ahead (le colonne regime_lab sono gia causali;
le TUE entries devono usare solo dati <= i). Backtest NETTO fee (report() fa gia sweep 0.0-0.2% RT + OOS
ultimo 30%). robust()=True e' il gate minimo. Diffida dell'overfit: poche entries o edge solo full e
non-oos = rumore. Riporta ONESTAMENTE anche i fallimenti.
` + API
const SCHEMA = {
type: 'object',
properties: {
strategy: { type: 'string', description: 'nome + 1 frase: segnale frattale + gate regime' },
family: { type: 'string' }, angle: { type: 'string' }, asset: { type: 'string' }, tf: { type: 'string' },
trades: { type: 'integer' },
full_ret: { type: 'number' }, oos_ret: { type: 'number' },
full_sharpe: { type: 'number' }, oos_sharpe: { type: 'number' }, oos_dd: { type: 'number' },
fee02_oos_ret: { type: 'number', description: 'OOS ret a fee 0.2% RT' },
robust: { type: 'boolean', description: 'robust()=True' },
promising: { type: 'boolean', description: 'vale una verifica avversariale (robust o quasi, non overfit)' },
edge_desc: { type: 'string', description: 'perche funziona / perche e rumore, con i numeri' },
},
required: ['strategy', 'asset', 'tf', 'trades', 'oos_sharpe', 'robust', 'promising', 'edge_desc'],
}
const FAMILIES = [
['hurst', 'Hurst regime: fade quando hurst<0.5 (anti-persistente), o trend quando hurst>0.5. Soglia hurst come segnale o gate.'],
['higuchi', 'Fractal dimension Higuchi: FD alta = frastagliato/range (fade), FD bassa = liscio/trend (momentum).'],
['williams', 'Williams pivot (frac_up/frac_dn, causali): fade del pivot (reversione allo swing) o breakout del pivot.'],
['vratio', 'volatility_ratio: >1 espansione vol (breakout/fade del breakout), <1 compressione (range/squeeze).'],
['analog', 'analog kNN sulla FORMA (puoi usare scripts.analysis.shape_lab.analog_signals(df,...)): forecast causale segno a H barre, gatealo col regime.'],
['multiscale', 'multi-scala: combina hurst+higuchi+vratio in un indice di "regime frattale" (trend vs chop) come segnale.'],
['candle', 'pattern candele frattali (src.fractal.patterns: extract_body_ratios/shadow, find_patterns): sequenze multi-barra come segnale.'],
]
const ANGLES = [
['none', 'NESSUN gate regime: segnale frattale puro (baseline per misurare il valore marginale del regime).'],
['dvol_high', 'agisci solo con dvol_pct alto (>0.6..0.8): vol elevata (spesso mean-reversion piu forte).'],
['dvol_low', 'agisci solo con dvol_pct basso (<0.3..0.4): calma/range.'],
['vrp', 'VRP=vrp colonna: VRP>0 (vol sopravvalutata, analogo ARGO GEX+ -> range/fade); confronta con VRP<0. Gate o peso.'],
['funding', 'funding_z estremo: troppi long (funding_z alto) -> fade ribassista; troppi short -> fade rialzista (flusso ARGO via perp).'],
['dvol_chg', 'dvol_chg: DVOL in salita (espansione vol/stress -> trend) vs discesa (ritorno calma -> range).'],
]
const ASSETS = ['BTC', 'ETH']
phase('Search')
// 7 famiglie x 6 angoli x 2 asset = 84 agenti griglia
const gridSpecs = []
for (const [fam, fdesc] of FAMILIES)
for (const [ang, adesc] of ANGLES)
for (const asset of ASSETS)
gridSpecs.push({ fam, fdesc, ang, adesc, asset })
const gridTasks = gridSpecs.map((s) => () => agent(
CONTEXT +
`\n\nIL TUO CELLA:\n- FAMIGLIA FRATTALE: ${s.fam} -> ${s.fdesc}\n- ANGOLO REGIME: ${s.ang} -> ${s.adesc}\n- ASSET: ${s.asset}\n\n` +
`Progetta la MIGLIORE strategia in questa cella: un SEGNALE basato sulla famiglia frattale ${s.fam}, ` +
`condizionato/interagito col regime ${s.ang}. Scrivi uno script in /tmp (cd /opt/docker/PythagorasGoal && ` +
`uv run python /tmp/<tuofile>.py), prova SIA TF=1h SIA TF=1d (e se vuoi 4h), itera 2-4 varianti di soglia/` +
`direzione/exit, e RIPORTA la migliore (quella con oos_sharpe piu alto e robust se possibile). Usa report()+robust(). ` +
`Privilegia mean-reversion (l'edge del progetto) ma testa anche momentum dove il regime lo motiva. ` +
`Mai look-ahead. Se tutto e rumore, dillo onestamente (promising=false). Ritorna lo schema.`,
{ label: `srch:${s.fam}/${s.ang}/${s.asset}`, phase: 'Search', schema: SCHEMA }))
// 8 wildcard: mandato aperto
const wildTasks = Array.from({ length: 8 }, (_, k) => () => agent(
CONTEXT +
`\n\nSEI UN AGENTE WILDCARD #${k + 1}. Mandato APERTO: inventa una combinazione FRATTALE-del-segnale x ` +
`REGIME-ARGO NON banale e non nella griglia ovvia. Idee: interazione hurst*vrp (mean-rev solo se ` +
`anti-persistente E vol sopravvalutata); Williams pivot come TP/SL adattivo gateato da dvol; analog kNN ` +
`pesato per funding; size/exit modulati dal regime; combinare 2 segnali frattali con conferma di regime. ` +
`Asset e TF a tua scelta (prova entrambi gli asset). Costruisci, testa onesto (report()+robust()), riporta ` +
`la migliore. Diversifica dagli altri: varia idea in base a #${k + 1}. Schema in output.`,
{ label: `wild:${k + 1}`, phase: 'Search', schema: SCHEMA }))
const searchResults = (await parallel([...gridTasks, ...wildTasks])).filter(Boolean)
// survivor = robust, oppure promising con oos_sharpe alto e abbastanza trade
const survivors = searchResults.filter(r =>
(r.robust || (r.promising && (r.oos_sharpe || 0) >= 1.0)) && (r.trades || 0) >= 30)
log(`Search: ${searchResults.length} testati, ${survivors.length} survivor da verificare`)
phase('Verify')
const VSCHEMA = {
type: 'object',
properties: {
strategy: { type: 'string' }, confirmed: { type: 'boolean' },
reason: { type: 'string', description: 'esito audit look-ahead + fee0.2% + altro asset + split alternativo' },
oos_sharpe_recheck: { type: 'number' }, killed_by: { type: 'string' },
},
required: ['strategy', 'confirmed', 'reason'],
}
let verified = []
if (survivors.length) {
verified = (await parallel(survivors.map(s => () => agent(
CONTEXT +
`\n\nVERIFICA AVVERSARIALE di un candidato survivor:\n${JSON.stringify(s, null, 1)}\n\n` +
`Tuo compito: PROVARE A FALSIFICARLO. (1) Ricostruisci la strategia (chiedi i dettagli dal suo edge_desc; ` +
`riusa regime_lab). (2) AUDIT look-ahead: ogni colonna/calcolo usa solo dati <= i? Il gate regime e' noto a i? ` +
`(3) Regge fee 0.2% RT in OOS? (4) Regge sull'ALTRO asset (se BTC prova ETH e viceversa)? (5) Regge a uno SPLIT ` +
`OOS alternativo (es. train<=2024, test 2025-26)? (6) Numero trade sufficiente e non concentrato in 1 anno? ` +
`Default a confirmed=FALSE se incerto o se sopravvive solo per overfit. Sii spietato. Schema in output.`,
{ label: `verify:${(s.strategy || '').slice(0, 24)}`, phase: 'Verify', schema: VSCHEMA })))).filter(Boolean)
}
const confirmed = verified.filter(v => v.confirmed)
phase('Synth')
const synthesis = await agent(
CONTEXT +
`\n\nHai i risultati di ${searchResults.length} agenti di ricerca e ${verified.length} verifiche avversariali.\n\n` +
`SURVIVOR CONFERMATI:\n${JSON.stringify(confirmed, null, 1)}\n\n` +
`TUTTI I SURVIVOR (anche non confermati):\n${JSON.stringify(survivors, null, 1)}\n\n` +
`TOP 15 per oos_sharpe fra tutti i testati:\n${JSON.stringify(
searchResults.slice().sort((a, b) => (b.oos_sharpe || 0) - (a.oos_sharpe || 0)).slice(0, 15), null, 1)}\n\n` +
`Produci la SINTESI FINALE (italiano) per il decisore:
1) VERDETTO: esiste una strategia frattale x ARGO con edge validato OOS? quale/i (confermate)?
2) Tabella dei top candidati: strategia, asset/tf, OOS Sharpe, OOS ret, DD, robust, confermato?
3) Il regime ARGO (DVOL/VRP/funding) AGGIUNGE valore al segnale frattale (vs angolo 'none')? In quali celle?
4) Cosa e' rumore e perche (coerente coi priori: frattali deboli, opzioni scartate).
5) Se c'e un vincitore: piano di implementazione (file in scripts/strategies/, MODULE_MAP, validazione finale).
Se NON c'e: dillo chiaro, niente forzature.
Cita NUMERI reali (OOS Sharpe, ret, trades). Onesta brutale: deve battere PORT06, non solo essere >0.`,
{ label: 'synthesis', phase: 'Synth' })
return { searchResults, survivors, confirmed, synthesis }
+103
View File
@@ -0,0 +1,103 @@
"""Validazione FINALE delle 3 strategie oneste selezionate.
Per ciascuna: per-asset FULL/OOS/DD/anni-positivi + sweep fee (0/0.05/0.10/0.20% RT).
Tutto NETTO, ingresso eseguibile, OOS = ultimo 30%, leva 3x.
S1 DIP — long-only dip-buy z-score reversion (1h) [regime: reversione]
S2 TREND — long-only EMA 20/100 trend-following (4h) [regime: momentum singolo]
S3 ROT — rotazione cross-sectional momentum sul paniere (1d) [regime: forza relativa]
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.honest_lab import atr, ema, get_df, simulate, oos_split, available_assets
from scripts.analysis.honest_trend import simulate_position, ema_dual_signal, oos as trend_oos
from scripts.analysis.honest_rotation import build_panel, simulate_rotation
FEES = [0.0, 0.0005, 0.001, 0.002]
# ---- S1 DIP ----
def dip_entries(df, n=50, z_in=2.5, sl_atr=2.5, max_bars=24):
c = df["close"].values
ma = pd.Series(c).rolling(n).mean().values
sd = pd.Series(c).rolling(n).std().values
a = atr(df, 14)
z = (c - ma) / np.where(sd == 0, np.nan, sd)
ents = []
for i in range(n + 14, len(c)):
if np.isnan(z[i]) or np.isnan(a[i]):
continue
if z[i] <= -z_in and z[i - 1] > -z_in:
ents.append({"i": i, "d": 1, "tp": ma[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
return ents
def validate_dip(assets):
print("\n" + "=" * 100)
print(" S1 DIP — long-only dip-buy z-score reversion | 1h | n=50 z=2.5 sl=2.5ATR mb=24")
print("=" * 100)
print(f" {'Asset':<6s}{'Trd':>6s}{'Win%':>7s}{'FULL%':>9s}{'OOS%':>9s}{'DD%':>6s}{'Exp%':>6s}{'AnniP':>8s}"
f"{' fee-sweep OOS% (0/0.05/0.10/0.20)':<40s}")
ok = 0
for a in assets:
df = get_df(a, "1h"); ents = dip_entries(df)
if len(ents) < 30:
continue
full = simulate(ents, df); _, oe = oos_split(ents, df); oos = simulate(oe, df)
sweep = " ".join(f"{simulate(oe, df, fee_rt=f).ret:+.0f}" for f in FEES)
good = full.ret > 0 and oos.ret > 0
ok += good
print(f" {a:<6s}{full.trades:>6d}{full.win:>7.1f}{full.ret:>+9.0f}{oos.ret:>+9.0f}"
f"{full.dd:>6.0f}{full.exposure:>6.0f}{f'{full.pos_years}/{full.n_years}':>8s} [{sweep}]"
f"{' OK' if good else ''}")
print(f" -> robusto (FULL+OOS>0) su {ok}/{len(assets)} asset")
def validate_trend(assets):
print("\n" + "=" * 100)
print(" S2 TREND — long-only EMA 20/100 trend | 4h")
print("=" * 100)
print(f" {'Asset':<6s}{'Flip':>6s}{'FULL%':>9s}{'OOS%':>9s}{'DD%':>6s}{'Exp%':>6s}{'AnniP':>8s}")
ok = 0
for a in assets:
df = get_df(a, "4h"); sig = ema_dual_signal(df, 20, 100, long_only=True)
full = simulate_position(sig, df); oos = trend_oos(sig, df)
good = full["ret"] > 0 and oos["ret"] > 0
ok += good
print(f" {a:<6s}{full['flips']:>6d}{full['ret']:>+9.0f}{oos['ret']:>+9.0f}"
f"{full['dd']:>6.0f}{full['exposure']:>6.0f}{(str(full['pos_years'])+'/'+str(full['n_years'])):>8s}"
f"{' OK' if good else ''}")
print(f" -> robusto su {ok}/{len(assets)} asset")
def validate_rot(assets):
print("\n" + "=" * 100)
print(" S3 ROT — rotazione cross-sectional momentum | 1d | lb=60 top2 su tutto il paniere")
print("=" * 100)
panel = build_panel(assets, "1d")
print(f" Paniere {list(panel.columns)} {panel.shape[0]} barre {panel.index[0].date()}->{panel.index[-1].date()}")
print(f" {'fee RT':<10s}{'FULL%':>9s}{'OOS%':>9s}{'DD%':>6s}{'AnniP':>8s}")
for f in FEES:
full = simulate_rotation(panel, lookback=60, top_k=2, fee_rt=f)
oos = simulate_rotation(panel, lookback=60, top_k=2, fee_rt=f, oos_frac=0.30)
anni = str(full['pos_years']) + '/' + str(full['n_years'])
print(f" {f*100:>5.2f}%RT {full['ret']:>+9.0f}{oos['ret']:>+9.0f}{full['dd']:>6.0f}{anni:>8s}")
# per-anno alla fee reale
full = simulate_rotation(panel, lookback=60, top_k=2, fee_rt=0.001)
print(" per-anno (fee 0.10%): " + " ".join(f"{y}:{v:+.0f}%" for y, v in sorted(full["yearly"].items())))
if __name__ == "__main__":
assets = available_assets()
print(f"VALIDAZIONE FINALE — asset disponibili: {assets}")
validate_dip(assets)
validate_trend(assets)
validate_rot(assets)
+175
View File
@@ -0,0 +1,175 @@
"""Miglioramenti ONESTI: alzare Acc, ridurre DD, migliorare PnL senza overfitting.
Leve usate (tutte robuste e documentate, niente tuning sui singoli anni):
1. ABSOLUTE-MOMENTUM overlay (dual momentum): vai in CASH quando il "mercato"
(BTC) e' sotto la sua media di lungo periodo -> taglia i bear (2022/2026).
2. VOL-TARGETING: scala l'esposizione per puntare a una volatilita' costante
-> riduce il DD e liscia la PnL.
3. TRAILING STOP ad ATR per il trend (TR01) -> blocca i profitti.
Confronto base vs migliorata su FULL + OOS + DD pieno + per-anno.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.honest_lab import atr, ema, get_df, available_assets, FEE_RT
from scripts.analysis.honest_rotation import build_panel
LEV, POS = 3.0, 0.15
def _dd(eq: np.ndarray) -> float:
peak = eq[0]; mx = 0.0
for v in eq:
peak = max(peak, v); mx = max(mx, (peak - v) / peak if peak > 0 else 0.0)
return mx * 100
# ============================================================================
# ROT01 migliorata: dual-momentum (cash se BTC < SMA) + vol-target
# ============================================================================
def rot_improved(lookback=60, top_k=2, gross=0.45, regime_n=100,
target_vol=0.0, vol_n=20, fee_rt=FEE_RT, oos_frac=0.0):
panel = build_panel(available_assets(), "1d")
cols = list(panel.columns)
P = panel.values; T, N = P.shape
rets = np.zeros_like(P); rets[1:] = P[1:] / P[:-1] - 1
years = panel.index.year.values
btc = P[:, cols.index("BTC")]
use_regime = regime_n and regime_n > 1
btc_ma = pd.Series(btc).rolling(max(regime_n, 2)).mean().values
# vol realizzata del portafoglio equal-weight come proxy di scala
mkt_ret = rets.mean(axis=1)
rv = pd.Series(mkt_ret).rolling(vol_n).std().values * np.sqrt(365)
start = max(lookback + 1, (regime_n + 1) if use_regime else 0, int(T * (1 - oos_frac)) if oos_frac else 0)
cap = 1000.0; w = np.zeros(N)
eq = [cap]; yearly: dict[int, float] = {}; pos_days = {}; days = {}; reb = {}
for i in range(start, T - 1):
if use_regime:
risk_on = btc[i] > btc_ma[i] if not np.isnan(btc_ma[i]) else False
else:
risk_on = True
mom = P[i] / P[i - lookback] - 1
order = np.argsort(mom)[::-1]
chosen = [j for j in order if mom[j] > 0][:top_k] if risk_on else []
g = gross
if target_vol > 0 and not np.isnan(rv[i]) and rv[i] > 0:
g = min(gross, gross * target_vol / rv[i]) # solo riduzione (no leva extra)
new_w = np.zeros(N)
for j in chosen:
new_w[j] = g / len(chosen)
turnover = np.abs(new_w - w).sum()
if turnover > 1e-9:
cap -= cap * turnover * (fee_rt / 2)
w = new_w
pr = float(np.dot(w, rets[i + 1]))
cap = max(cap * (1 + pr), 10.0)
eq.append(cap)
y = int(years[i])
yearly[y] = yearly.get(y, 0.0) + pr * 100
pos_days[y] = pos_days.get(y, 0) + (pr > 0); days[y] = days.get(y, 0) + 1
reb[y] = reb.get(y, 0) + (turnover > 1e-9)
return {"ret": (cap / 1000 - 1) * 100, "dd": _dd(np.array(eq)), "yearly": yearly,
"pos_years": sum(1 for v in yearly.values() if v > 0), "n_years": len(yearly),
"pos_days": pos_days, "days": days, "reb": reb}
# ============================================================================
# DIP01 migliorata: filtro regime (no dip in bear forte) + vol-target sizing
# ============================================================================
def dip_improved(asset, tf="1h", n=50, z_in=2.5, sl_atr=2.5, max_bars=24,
regime_n=200, vol_target=0.0, fee_rt=FEE_RT, oos_frac=0.0):
df = get_df(asset, tf)
h, l, c = df["high"].values, df["low"].values, df["close"].values
N = len(c); ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
ma = pd.Series(c).rolling(n).mean().values
sd = pd.Series(c).rolling(n).std().values
a = atr(df, 14)
z = (c - ma) / np.where(sd == 0, np.nan, sd)
sma_r = pd.Series(c).rolling(regime_n).mean().values
atr_pct = a / c # volatilita' relativa
base_vol = np.nanmedian(atr_pct[regime_n:regime_n * 2]) if N > regime_n * 2 else np.nanmedian(atr_pct)
fee = fee_rt * LEV
cap = 1000.0; last_exit = -1
eq = [cap]; yt: dict[int, list] = {}
start = max(n + 14, regime_n + 1) if regime_n else n + 14
split = int(N * (1 - oos_frac)) if oos_frac else 0
for i in range(start, N):
if i < split or np.isnan(z[i]) or np.isnan(a[i]):
continue
if not (z[i] <= -z_in and z[i - 1] > -z_in):
continue
# filtro regime: salta i dip in bear forte (prezzo molto sotto SMA lunga)
if regime_n and not np.isnan(sma_r[i]) and c[i] < sma_r[i] * 0.90:
continue
if i <= last_exit or i + 1 >= N:
continue
# vol-target: riduci posizione se ATR% > base (no leva extra)
psize = POS
if vol_target > 0 and not np.isnan(atr_pct[i]) and atr_pct[i] > 0:
psize = POS * min(1.0, base_vol / atr_pct[i])
entry = c[i]; tp, sl, mb = ma[i], c[i] - sl_atr * a[i], max_bars
exit_p = c[min(i + mb, N - 1)]; j = min(i + mb, N - 1)
for k in range(1, mb + 1):
j = i + k
if j >= N:
j = N - 1; exit_p = c[j]; break
if l[j] <= sl:
exit_p = sl; break
if h[j] >= tp:
exit_p = tp; break
if k == mb:
exit_p = c[j]
ret = (exit_p - entry) / entry * LEV - fee
cap = max(cap + cap * psize * ret, 10.0)
last_exit = j
y = ts.iloc[i].year
rec = yt.setdefault(y, [0, 0]); rec[0] += 1; rec[1] += ret > 0
eq.append(cap)
t = sum(v[0] for v in yt.values()); w = sum(v[1] for v in yt.values())
return {"ret": (cap / 1000 - 1) * 100, "dd": _dd(np.array(eq)),
"trades": t, "acc": w / t * 100 if t else 0.0,
"yt": yt, "pos_years": sum(1 for v in yt.values() if v[1] / max(v[0],1) and v[1]>v[0]*0 and (v[1]>0)), "n_years": len(yt)}
def dip_acc_pnl(asset, **kw):
"""ritorna anche FULL e OOS."""
full = dip_improved(asset, **kw)
oos = dip_improved(asset, oos_frac=0.30, **kw)
return full, oos
if __name__ == "__main__":
print("=" * 92)
print(" ROT01 — BASE vs MIGLIORATA (dual-momentum cash + vol-target)")
print("=" * 92)
print(f" {'config':<40s}{'FULL%':>9s}{'OOS%':>9s}{'DD%pieno':>10s}{'AnniP':>8s}")
b = rot_improved(regime_n=0); bo = rot_improved(regime_n=0, oos_frac=0.30)
print(f" {'BASE (no overlay)':<40s}{b['ret']:>+9.0f}{bo['ret']:>+9.0f}{b['dd']:>10.0f}"
f"{str(b['pos_years'])+'/'+str(b['n_years']):>8s}")
for rn in [100, 150, 200]:
f = rot_improved(regime_n=rn); o = rot_improved(regime_n=rn, oos_frac=0.30)
print(f" {'+ dual-mom cash (BTC<SMA'+str(rn)+')':<40s}{f['ret']:>+9.0f}{o['ret']:>+9.0f}"
f"{f['dd']:>10.0f}{str(f['pos_years'])+'/'+str(f['n_years']):>8s}")
for tv in [0.6, 0.8]:
f = rot_improved(regime_n=150, target_vol=tv); o = rot_improved(regime_n=150, target_vol=tv, oos_frac=0.30)
print(f" {'+ dual-mom150 + volTarget'+str(tv):<40s}{f['ret']:>+9.0f}{o['ret']:>+9.0f}"
f"{f['dd']:>10.0f}{str(f['pos_years'])+'/'+str(f['n_years']):>8s}")
print("\n" + "=" * 92)
print(" DIP01 — BASE vs MIGLIORATA (filtro regime + vol-target)")
print("=" * 92)
print(f" {'asset / config':<34s}{'Trd':>6s}{'Acc%':>7s}{'FULL%':>9s}{'OOS%':>9s}{'DD%pieno':>10s}")
for a in ["BTC", "ETH", "SOL"]:
for label, kw in [("base", dict(regime_n=0, vol_target=0)),
("+regime+volTgt", dict(regime_n=200, vol_target=0.5))]:
f, o = dip_acc_pnl(a, **kw)
print(f" {a+' '+label:<34s}{f['trades']:>6d}{f['acc']:>7.1f}{f['ret']:>+9.0f}"
f"{o['ret']:>+9.0f}{f['dd']:>10.0f}")
+184
View File
@@ -0,0 +1,184 @@
"""Miglioramenti v2: market-regime gate su DIP01 + PORTAFOGLIO combinato.
- DIP01 con gate di mercato: compra i dip solo quando BTC e' risk-on (BTC>SMA),
cosi' si evitano le capitolazioni dei bear (2018/2022) che peggiorano Acc/DD/PnL.
- Portafoglio: equal-weight giornaliero delle 3 strategie migliorate -> la
diversificazione taglia il DD mantenendo la PnL (migliora il risk-adjusted).
Tutto NETTO, con DD pieno e per-anno.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.honest_lab import atr, ema, get_df, available_assets, FEE_RT
from scripts.analysis.honest_improve import rot_improved, _dd
LEV, POS = 3.0, 0.15
def _daily_equity(ts_list, cap_list, idx):
"""serie di equity giornaliera (ffill) su un DatetimeIndex comune."""
s = pd.Series(cap_list, index=pd.to_datetime(ts_list, utc=True))
s = s[~s.index.duplicated(keep="last")].sort_index()
daily = s.resample("1D").last().reindex(idx).ffill().bfill()
return daily
# ---------- DIP01 con market-regime gate ----------
def dip_market_gated(asset, n=50, z_in=2.5, sl_atr=2.5, max_bars=24,
market_n=100, fee_rt=FEE_RT, oos_frac=0.0, return_equity=False):
df = get_df(asset, "1h")
h, l, c = df["high"].values, df["low"].values, df["close"].values
N = len(c); ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
ma = pd.Series(c).rolling(n).mean().values
sd = pd.Series(c).rolling(n).std().values
a = atr(df, 14)
z = (c - ma) / np.where(sd == 0, np.nan, sd)
# regime di mercato: BTC 1h > SMA(market_n in giorni -> *24 barre)
btc = get_df("BTC", "1h")
bser = pd.Series(btc["close"].values,
index=pd.to_datetime(btc["timestamp"], unit="ms", utc=True))
bser = bser[~bser.index.duplicated()]
bma = bser.rolling(market_n * 24).mean()
risk_on = (bser > bma).reindex(ts, method="ffill").fillna(False).values
fee = fee_rt * LEV
cap = 1000.0; last_exit = -1
eq_ts, eq_v = [], []
yt: dict[int, list] = {}; ypnl: dict[int, float] = {}
split = int(N * (1 - oos_frac)) if oos_frac else 0
for i in range(n + 14, N):
if i < split or np.isnan(z[i]) or np.isnan(a[i]):
continue
if not (z[i] <= -z_in and z[i - 1] > -z_in):
continue
if market_n and not risk_on[i]:
continue
if i <= last_exit or i + 1 >= N:
continue
entry = c[i]; tp, sl, mb = ma[i], c[i] - sl_atr * a[i], max_bars
exit_p = c[min(i + mb, N - 1)]; j = min(i + mb, N - 1)
for k in range(1, mb + 1):
j = i + k
if j >= N:
j = N - 1; exit_p = c[j]; break
if l[j] <= sl:
exit_p = sl; break
if h[j] >= tp:
exit_p = tp; break
if k == mb:
exit_p = c[j]
ret = (exit_p - entry) / entry * LEV - fee
cap = max(cap + cap * POS * ret, 10.0)
last_exit = j
y = ts.iloc[i].year
rec = yt.setdefault(y, [0, 0]); rec[0] += 1; rec[1] += ret > 0
ypnl[y] = ypnl.get(y, 0.0) + ret * 100
eq_ts.append(ts.iloc[j]); eq_v.append(cap)
t = sum(v[0] for v in yt.values()); w = sum(v[1] for v in yt.values())
out = {"ret": (cap / 1000 - 1) * 100, "dd": _dd(np.array(eq_v)) if eq_v else 0.0,
"trades": t, "acc": w / t * 100 if t else 0.0, "yt": yt, "ypnl": ypnl,
"pos_years": sum(1 for v in ypnl.values() if v > 0), "n_years": len(ypnl)}
if return_equity:
out["eq_ts"], out["eq_v"] = eq_ts, eq_v
return out
def main():
print("=" * 96)
print(" DIP01 — base vs MARKET-GATE (compra dip solo se BTC>SMA100)")
print("=" * 96)
print(f" {'asset / config':<30s}{'Trd':>6s}{'Acc%':>7s}{'FULL%':>9s}{'OOS%':>9s}{'DD%':>7s}{'AnniP':>8s}")
for a in ["BTC", "ETH", "SOL"]:
b = dip_market_gated(a, market_n=0); bo = dip_market_gated(a, market_n=0, oos_frac=0.30)
g = dip_market_gated(a, market_n=100); go = dip_market_gated(a, market_n=100, oos_frac=0.30)
print(f" {a+' base':<30s}{b['trades']:>6d}{b['acc']:>7.1f}{b['ret']:>+9.0f}{bo['ret']:>+9.0f}"
f"{b['dd']:>7.0f}{str(b['pos_years'])+'/'+str(b['n_years']):>8s}")
print(f" {a+' +gate100':<30s}{g['trades']:>6d}{g['acc']:>7.1f}{g['ret']:>+9.0f}{go['ret']:>+9.0f}"
f"{g['dd']:>7.0f}{str(g['pos_years'])+'/'+str(g['n_years']):>8s}")
# ---------- PORTAFOGLIO combinato (3 sleeve diversificate) ----------
print("\n" + "=" * 96)
print(" PORTAFOGLIO equal-weight giornaliero (ribilanciato): DIP01 + TR01-basket + ROT02")
print("=" * 96)
idx = pd.date_range("2021-01-01", "2026-05-26", freq="1D", tz="UTC")
# sleeve 1: DIP01 base su BTC (la migliore)
d = dip_market_gated("BTC", market_n=0, return_equity=True)
eq_dip = _norm(_daily_equity(d["eq_ts"], d["eq_v"], idx))
# sleeve 2: TR01 equal-weight su {BNB,BTC,DOGE,SOL,XRP}
eq_tr = _norm(_tr_basket_daily(["BNB", "BTC", "DOGE", "SOL", "XRP"], idx))
# sleeve 3: ROT02 dual-momentum
eq_rot = _norm(_rot_daily_equity(idx))
members = {"DIP01_BTC": eq_dip, "TR01_basket": eq_tr, "ROT02_dualmom": eq_rot}
# ribilanciamento giornaliero equal-weight: media dei rendimenti giornalieri
drets = pd.DataFrame({k: v.pct_change().fillna(0) for k, v in members.items()})
port_ret = drets.mean(axis=1)
combo = (1 + port_ret).cumprod()
print(f" Periodo {idx[0].date()} -> {idx[-1].date()} (leva/pos gia' incluse nelle sleeve)")
print(f" {'sleeve':<16s}{'ret%':>9s}{'DD%':>7s}{'CAGR%':>8s}")
yrs = (idx[-1] - idx[0]).days / 365.25
for name, s in members.items():
r = (s.iloc[-1] / s.iloc[0] - 1) * 100
cagr = ((s.iloc[-1] / s.iloc[0]) ** (1 / yrs) - 1) * 100
print(f" {name:<16s}{r:>+9.0f}{_dd(s.values):>7.0f}{cagr:>8.0f}")
r = (combo.iloc[-1] / combo.iloc[0] - 1) * 100
cagr = ((combo.iloc[-1] / combo.iloc[0]) ** (1 / yrs) - 1) * 100
print(f" {'PORTAFOGLIO':<16s}{r:>+9.0f}{_dd(combo.values):>7.0f}{cagr:>8.0f} <-- DD molto piu' basso, CAGR solida")
# per-anno del portafoglio
pa = (port_ret.groupby(port_ret.index.year).apply(lambda x: ((1 + x).prod() - 1) * 100))
print(" Portafoglio per-anno: " + " ".join(f"{y}:{v:+.0f}%" for y, v in pa.items()))
def _norm(s):
return s / s.iloc[0]
def _tr_basket_daily(assets, idx):
"""equity giornaliera media di TR01 (EMA20/100 long-only, 4h) sul paniere."""
eqs = []
for a in assets:
df = get_df(a, "4h"); c = df["close"].values; n = len(c)
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
ef, es = ema(c, 20), ema(c, 100)
sig = np.where(ef > es, 1.0, 0.0); sig[:100] = 0.0
cap = 1000.0; cur = 0.0; fee = FEE_RT / 2 * LEV
tl, cl = [], []
for i in range(n - 1):
s = sig[i]
if s != cur:
cap -= cap * POS * fee * abs(s - cur); cur = s
cap = max(cap * (1 + POS * LEV * (c[i + 1] - c[i]) / c[i] * cur), 10.0)
tl.append(ts.iloc[i]); cl.append(cap)
eqs.append(_norm(_daily_equity(tl, cl, idx)))
return _norm(pd.concat(eqs, axis=1).mean(axis=1))
def _rot_daily_equity(idx):
"""equity giornaliera della ROT01 dual-momentum (ricostruita bar-by-bar)."""
from scripts.analysis.honest_rotation import build_panel
panel = build_panel(available_assets(), "1d")
cols = list(panel.columns); P = panel.values; T, N = P.shape
rets = np.zeros_like(P); rets[1:] = P[1:] / P[:-1] - 1
btc = P[:, cols.index("BTC")]; bma = pd.Series(btc).rolling(100).mean().values
cap = 1000.0; w = np.zeros(N); ts_list = []; cap_list = []
for i in range(101, T - 1):
risk_on = btc[i] > bma[i] if not np.isnan(bma[i]) else False
mom = P[i] / P[i - 60] - 1; order = np.argsort(mom)[::-1]
chosen = [j for j in order if mom[j] > 0][:3] if risk_on else [] # top_k=3 (era 2): DD piu' basso
nw = np.zeros(N)
for j in chosen:
nw[j] = 0.45 / len(chosen)
cap -= cap * np.abs(nw - w).sum() * (FEE_RT / 2); w = nw
cap = max(cap * (1 + float(np.dot(w, rets[i + 1]))), 10.0)
ts_list.append(panel.index[i]); cap_list.append(cap)
s = _daily_equity(ts_list, cap_list, idx); return s / s.iloc[0]
if __name__ == "__main__":
main()
+192
View File
@@ -0,0 +1,192 @@
"""honest_lab — laboratorio di ricerca strategie ONESTO e fee-aware.
Principi (per non ripetere l'errore look-ahead della famiglia squeeze):
1. Ogni segnale a barra i usa SOLO dati fino a close[i]. Ingresso a close[i]
(eseguibile dal vivo: il worker vede la candela chiusa ed entra). Opzione
di robustezza: ingresso a open[i+1] (ancora piu' conservativo).
2. Uscita TP/SL valutata intrabar su high/low, conservativa: SL prima del TP
nello stesso bar. Time-limit max_bars. Una posizione per volta (non-overlap).
3. Tutto NETTO dopo fee round-trip realistiche (0.10% Deribit) * leva.
4. Validazione: FULL + OOS (held-out ultimo 30%) + per-anno + sweep fee
+ griglia parametri + su PIU' asset. Niente di tutto cio' -> scartata.
Engine condiviso riusabile da tutte le strategie candidate.
"""
from __future__ import annotations
import sys
from dataclasses import dataclass
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from src.data.downloader import load_data # noqa: E402
FEE_RT = 0.001 # Deribit perp realistico: taker ~0.05%/lato = 0.10% RT
LEV = 3.0
POS = 0.15
OOS_FRAC = 0.30
DATA_DIR = PROJECT_ROOT / "data" / "raw"
# ----------------------------------------------------------------------------
# dati
# ----------------------------------------------------------------------------
_CACHE: dict[tuple[str, str], pd.DataFrame] = {}
def available_assets() -> list[str]:
out = []
for p in sorted(DATA_DIR.glob("*_1h.parquet")):
name = p.stem.replace("_1h", "").upper()
if name not in ("BTC_DVOL", "ETH_DVOL"):
out.append(name)
return out
def get_df(asset: str, tf: str) -> pd.DataFrame:
"""tf nativo (15m,1h) o resample da 1h (2h,4h,6h,12h,1d)."""
key = (asset, tf)
if key in _CACHE:
return _CACHE[key]
if tf in ("15m", "1h"):
df = load_data(asset, tf).reset_index(drop=True)
else:
base = load_data(asset, "1h").copy()
base["dt"] = pd.to_datetime(base["timestamp"], unit="ms", utc=True)
base = base.set_index("dt")
rule = {"2h": "2h", "4h": "4h", "6h": "6h", "12h": "12h", "1d": "1D"}[tf]
agg = base.resample(rule).agg(
{"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"}
).dropna()
# l'indice puo' essere datetime64[ms] o [ns]: forza ms in modo robusto
agg["timestamp"] = agg.index.values.astype("datetime64[ms]").astype("int64")
df = agg.reset_index(drop=True)
df = df[["timestamp", "open", "high", "low", "close", "volume"]].copy()
_CACHE[key] = df
return df
# ----------------------------------------------------------------------------
# indicatori
# ----------------------------------------------------------------------------
def atr(df: pd.DataFrame, n: int = 14) -> np.ndarray:
h, l, c = df["high"].values, df["low"].values, df["close"].values
pc = np.roll(c, 1); pc[0] = c[0]
tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc)))
return pd.Series(tr).rolling(n).mean().values
def rsi(close: np.ndarray, n: int = 14) -> np.ndarray:
d = np.diff(close, prepend=close[0])
up = pd.Series(np.where(d > 0, d, 0.0)).ewm(alpha=1 / n, adjust=False).mean()
dn = pd.Series(np.where(d < 0, -d, 0.0)).ewm(alpha=1 / n, adjust=False).mean()
rs = up / dn.replace(0, np.nan)
return (100 - 100 / (1 + rs)).values
def ema(close: np.ndarray, n: int) -> np.ndarray:
return pd.Series(close).ewm(span=n, adjust=False).mean().values
# ----------------------------------------------------------------------------
# engine
# ----------------------------------------------------------------------------
@dataclass
class SimResult:
trades: int
win: float
ret: float # ritorno % netto composto su 1000
dd: float
exposure: float
yearly: dict[int, float]
@property
def pos_years(self) -> int:
return sum(1 for v in self.yearly.values() if v > 0)
@property
def n_years(self) -> int:
return len(self.yearly)
def simulate(entries: list[dict], df: pd.DataFrame, fee_rt: float = FEE_RT,
lev: float = LEV, pos: float = POS, entry_on_open: bool = False) -> SimResult:
"""entries: dict {i, d(+1/-1), tp, sl, max_bars}.
entry_on_open=True -> ingresso a open[i+1] invece di close[i] (robustezza).
"""
o, h, l, c = (df["open"].values, df["high"].values,
df["low"].values, df["close"].values)
n = len(c)
cap = peak = 1000.0
max_dd = 0.0
fee = fee_rt * lev
trades = wins = 0
last_exit = -1
bars_in = 0
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
yearly: dict[int, float] = {}
for e in entries:
i, d = e["i"], e["d"]
ei = i + 1 if entry_on_open else i # barra di ingresso
if ei <= last_exit or ei + 1 >= n:
continue
entry = o[ei] if entry_on_open else c[i]
tp, sl, mb = e["tp"], e["sl"], e["max_bars"]
exit_p = c[min(ei + mb, n - 1)]
j = min(ei + mb, n - 1)
for k in range(1, mb + 1):
j = ei + k
if j >= n:
j = n - 1; exit_p = c[j]; break
hit_sl = (d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl)
hit_tp = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
if hit_sl: # conservativo: SL prima del TP nello stesso bar
exit_p = sl; break
if hit_tp:
exit_p = tp; break
if k == mb:
exit_p = c[j]
ret = (exit_p - entry) / entry * d * lev - fee
cap = max(cap + cap * pos * ret, 10.0)
peak = max(peak, cap); max_dd = max(max_dd, (peak - cap) / peak)
trades += 1; wins += ret > 0; bars_in += (j - ei)
last_exit = j
yr = ts.iloc[i].year
yearly[yr] = yearly.get(yr, 0.0) + ret * 100
return SimResult(
trades=trades,
win=wins / trades * 100 if trades else 0.0,
ret=(cap / 1000 - 1) * 100,
dd=max_dd * 100,
exposure=bars_in / n * 100,
yearly=yearly,
)
def oos_split(entries: list[dict], df: pd.DataFrame, frac: float = OOS_FRAC):
split = int(len(df) * (1 - frac))
ins = [e for e in entries if e["i"] < split]
oos = [e for e in entries if e["i"] >= split]
return ins, oos
# ----------------------------------------------------------------------------
# criterio di accettazione
# ----------------------------------------------------------------------------
def verdict(full: SimResult, oos: SimResult) -> bool:
"""Strategia attendibile su un singolo asset/tf."""
if full.trades < 30:
return False
if full.ret <= 0 or oos.ret <= 0:
return False
if full.pos_years < max(full.n_years - 1, 1):
return False
if full.dd > 45:
return False
return True
+80
View File
@@ -0,0 +1,80 @@
"""Tabella unica consolidata: PnL% NETTO per anno, tutte le strategie a confronto.
Colonne: DIP01(BTC) · TR01(basket) · ROT01(base) · ROT02(dual-mom) · PORTAFOGLIO.
Ultima riga: TOT e DD full-period.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.honest_lab import available_assets, FEE_RT
from scripts.analysis.honest_improve import _dd
from scripts.analysis.honest_improve2 import (
dip_market_gated, _daily_equity, _norm, _tr_basket_daily,
)
from scripts.analysis.honest_rotation import build_panel
LEV, POS = 3.0, 0.15
def rot_daily(idx, regime_n=0, lookback=60, top_k=2, gross=0.45):
"""equity giornaliera della rotazione, con/senza overlay dual-momentum."""
panel = build_panel(available_assets(), "1d")
cols = list(panel.columns); P = panel.values; T, N = P.shape
rets = np.zeros_like(P); rets[1:] = P[1:] / P[:-1] - 1
btc = P[:, cols.index("BTC")]
bma = pd.Series(btc).rolling(max(regime_n, 2)).mean().values
use_reg = regime_n and regime_n > 1
cap = 1000.0; w = np.zeros(N); tl, cl = [], []
start = max(lookback + 1, regime_n + 1 if use_reg else 0)
for i in range(start, T - 1):
risk_on = (btc[i] > bma[i]) if (use_reg and not np.isnan(bma[i])) else True
mom = P[i] / P[i - lookback] - 1; order = np.argsort(mom)[::-1]
chosen = [j for j in order if mom[j] > 0][:top_k] if risk_on else []
nw = np.zeros(N)
for j in chosen:
nw[j] = gross / len(chosen)
cap -= cap * np.abs(nw - w).sum() * (FEE_RT / 2); w = nw
cap = max(cap * (1 + float(np.dot(w, rets[i + 1]))), 10.0)
tl.append(panel.index[i]); cl.append(cap)
return _norm(_daily_equity(tl, cl, idx))
def year_pnl(eq):
return {int(y): (g.iloc[-1] / g.iloc[0] - 1) * 100 for y, g in _norm(eq).groupby(eq.index.year)}
if __name__ == "__main__":
idx = pd.date_range("2021-01-01", "2026-05-26", freq="1D", tz="UTC")
d = dip_market_gated("BTC", market_n=0, return_equity=True)
cols = {
"DIP01(BTC)": _norm(_daily_equity(d["eq_ts"], d["eq_v"], idx)),
"TR01(bskt)": _norm(_tr_basket_daily(["BNB", "BTC", "DOGE", "SOL", "XRP"], idx)),
"ROT01": rot_daily(idx, regime_n=0),
"ROT02": rot_daily(idx, regime_n=100),
}
drets = pd.DataFrame({k: v.pct_change().fillna(0) for k, v in {
"DIP01(BTC)": cols["DIP01(BTC)"], "TR01(bskt)": cols["TR01(bskt)"], "ROT02": cols["ROT02"]
}.items()})
cols["PORTAF."] = (1 + drets.mean(axis=1)).cumprod()
names = list(cols)
py = {n: year_pnl(cols[n]) for n in names}
years = sorted({y for n in names for y in py[n]})
print("=" * 78)
print(" PnL% NETTO PER ANNO — confronto strategie (leva 3x, fee 0.10% RT)")
print("=" * 78)
print(f" {'Anno':>6s}" + "".join(f"{n:>12s}" for n in names))
print(" " + "-" * 72)
for y in years:
print(f" {y:>6d}" + "".join(f"{py[n].get(y, float('nan')):>+12.0f}" if y in py[n] else f"{'-':>12s}" for n in names))
print(" " + "-" * 72)
print(f" {'TOT%':>6s}" + "".join(f"{(cols[n].iloc[-1]/cols[n].iloc[0]-1)*100:>+12.0f}" for n in names))
print(f" {'DDfull':>6s}" + "".join(f"{_dd(cols[n].values):>12.0f}" for n in names))
+96
View File
@@ -0,0 +1,96 @@
"""Strategia #3 candidata: ROTAZIONE cross-sectional momentum (multi-crypto).
Una sola strategia che usa l'INTERO paniere: ad ogni ribilanciamento alloca il
capitale agli asset con momentum migliore (long-only). Cattura la dispersione tra
crypto (gli alt forti corrono molto piu' di BTC nei bull) senza shortare nulla.
Onesto: i pesi a close[i] usano solo rendimenti passati; il rendimento del bar
i->i+1 e' realizzato con quei pesi. Fee sul turnover. Allineamento per timestamp.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.honest_lab import get_df, available_assets, FEE_RT # noqa: E402
LEV = 3.0
GROSS = 0.45 # esposizione lorda = LEV*POS del singolo (0.15*3) per confronto equo
def build_panel(assets: list[str], tf: str) -> pd.DataFrame:
"""Matrice close allineata per timestamp (inner join)."""
closes = {}
for a in assets:
df = get_df(a, tf)
s = pd.Series(df["close"].values,
index=pd.to_datetime(df["timestamp"], unit="ms", utc=True))
closes[a] = s[~s.index.duplicated()]
panel = pd.DataFrame(closes).dropna()
return panel
def simulate_rotation(panel: pd.DataFrame, lookback=30, top_k=2,
fee_rt=FEE_RT, gross=GROSS, abs_filter=True,
oos_frac=0.0) -> dict:
"""Ad ogni barra: ranking per rendimento passato `lookback`; pesi uguali sui
top_k con momentum>0 (se abs_filter); altrimenti cash. gross = esposizione tot.
oos_frac>0: parte a investire solo dall'ultimo frac del campione."""
P = panel.values
T, N = P.shape
rets = np.zeros_like(P)
rets[1:] = P[1:] / P[:-1] - 1
years = panel.index.year.values
start = max(lookback + 1, int(T * (1 - oos_frac)) if oos_frac else lookback + 1)
cap = peak = 1000.0
max_dd = 0.0
w = np.zeros(N)
yearly: dict[int, float] = {}
turn_total = 0.0
for i in range(start, T - 1):
mom = P[i] / P[i - lookback] - 1
order = np.argsort(mom)[::-1]
new_w = np.zeros(N)
chosen = [j for j in order if (mom[j] > 0 or not abs_filter)][:top_k]
if chosen:
for j in chosen:
new_w[j] = gross / len(chosen)
# fee sul turnover (one-way = fee_rt/2 su ogni variazione di peso)
turnover = np.abs(new_w - w).sum()
cap -= cap * turnover * (fee_rt / 2)
turn_total += turnover
w = new_w
port_ret = float(np.dot(w, rets[i + 1])) # rendimento bar i->i+1
cap = max(cap * (1 + port_ret), 10.0)
peak = max(peak, cap); max_dd = max(max_dd, (peak - cap) / peak)
yearly[years[i]] = yearly.get(years[i], 0.0) + port_ret * 100
return {
"ret": (cap / 1000 - 1) * 100,
"dd": max_dd * 100,
"turnover": turn_total,
"yearly": yearly,
"pos_years": sum(1 for v in yearly.values() if v > 0),
"n_years": len(yearly),
}
if __name__ == "__main__":
assets = available_assets()
print(f"ROTATION cross-sectional momentum — fee {FEE_RT*100:.2f}% RT, gross {GROSS} | OOS 30%")
print(f" Paniere: {assets}")
for tf in ["1d", "4h"]:
panel = build_panel(assets, tf)
print(f"\n === {tf} === panel {panel.shape[0]} barre, {panel.index[0].date()} -> {panel.index[-1].date()}")
print(f" {'config':<22s}{'FULL%':>9s}{'OOS%':>9s}{'DD%':>6s}{'Turn':>7s}{'AnniP':>8s}")
for lb in [20, 30, 60, 90]:
for k in [1, 2, 3]:
full = simulate_rotation(panel, lookback=lb, top_k=k)
oos = simulate_rotation(panel, lookback=lb, top_k=k, oos_frac=0.30)
anni = f"{full['pos_years']}/{full['n_years']}"
print(f" lb{lb:<3d} top{k:<14d}{full['ret']:>+9.0f}{oos['ret']:>+9.0f}"
f"{full['dd']:>6.0f}{full['turnover']:>7.0f}{anni:>8s}")
+109
View File
@@ -0,0 +1,109 @@
"""Strategia #3 candidata: time-series momentum / trend (TSMOM).
Posizione continua decisa a close[i] dai dati passati; fee SOLO sui cambi di
posizione (poche operazioni su TF alto = fee non letali). Niente look-ahead:
il rendimento del bar i->i+1 usa la direzione decisa a close[i].
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.honest_lab import ema, get_df, available_assets, FEE_RT # noqa: E402
LEV = 3.0
POS = 0.15
def simulate_position(sig: np.ndarray, df: pd.DataFrame, fee_rt: float = FEE_RT,
lev: float = LEV, pos: float = POS) -> dict:
"""sig[i] in {-1,0,1} = direzione tenuta nel bar i->i+1, decisa a close[i].
Fee one-way = fee_rt/2 su ogni unita' di variazione posizione."""
c = df["close"].values
n = len(c)
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
cap = peak = 1000.0
max_dd = 0.0
cur = 0.0
flips = 0
bars_in = 0
yearly: dict[int, float] = {}
for i in range(n - 1):
s = sig[i]
if not np.isfinite(s):
s = 0.0
if s != cur:
cap -= cap * pos * (fee_rt / 2) * lev * abs(s - cur)
flips += abs(s - cur) > 0
cur = s
pr = (c[i + 1] - c[i]) / c[i]
bar_ret = pos * lev * pr * cur
cap = max(cap * (1 + bar_ret), 10.0)
peak = max(peak, cap); max_dd = max(max_dd, (peak - cap) / peak)
if cur != 0:
bars_in += 1
yr = ts.iloc[i].year
yearly[yr] = yearly.get(yr, 0.0) + bar_ret * 100
return {
"ret": (cap / 1000 - 1) * 100,
"dd": max_dd * 100,
"flips": flips,
"exposure": bars_in / n * 100,
"yearly": yearly,
"pos_years": sum(1 for v in yearly.values() if v > 0),
"n_years": len(yearly),
}
def tsmom_signal(df, lookback=30, long_only=False):
"""+1 se close>close[-lookback], -1 (o 0 se long_only) altrimenti."""
c = df["close"].values
sig = np.zeros(len(c))
for i in range(lookback, len(c)):
up = c[i] > c[i - lookback]
sig[i] = 1.0 if up else (0.0 if long_only else -1.0)
return sig
def ema_dual_signal(df, fast=20, slow=100, long_only=False):
"""+1 se EMA_fast>EMA_slow."""
c = df["close"].values
ef, es = ema(c, fast), ema(c, slow)
sig = np.where(ef > es, 1.0, 0.0 if long_only else -1.0)
sig[:slow] = 0.0
return sig
def oos(sig, df, frac=0.30):
split = int(len(df) * (1 - frac))
s2 = sig.copy(); s2[:split] = 0.0
return simulate_position(s2, df)
def show(label, df, sig):
full = simulate_position(sig, df)
o = oos(sig, df)
anni = f"{full['pos_years']}/{full['n_years']}"
print(f" {label:<26s}{full['flips']:>6d}{full['ret']:>+9.0f}{o['ret']:>+9.0f}"
f"{full['dd']:>6.0f}{full['exposure']:>6.0f}{anni:>8s}")
return full, o
if __name__ == "__main__":
assets = available_assets()
print(f"TSMOM / trend — fee {FEE_RT*100:.2f}% RT, leva3x pos15% | OOS30%")
for tf in ["1d", "4h"]:
print(f"\n ###### TF {tf} ######")
for a in assets:
df = get_df(a, tf)
print(f"\n === {a} {tf} === {'Flip':>5s}{'FULL%':>8s}{'OOS%':>8s}{'DD%':>6s}{'Exp%':>6s}{'AnniP':>8s}")
show("TSMOM lb30 long/short", df, tsmom_signal(df, 30))
show("TSMOM lb30 long-only", df, tsmom_signal(df, 30, long_only=True))
show("TSMOM lb90 long/short", df, tsmom_signal(df, 90))
show("EMA 20/100 long/short", df, ema_dual_signal(df, 20, 100))
show("EMA 20/100 long-only", df, ema_dual_signal(df, 20, 100, long_only=True))
+188
View File
@@ -0,0 +1,188 @@
"""Test ingresso intra-barra: rottura banda squeeze rilevata sul 5m vs close 15m.
Domanda: entrando sul 5m appena il prezzo rompe la banda di Bollinger dello
squeeze (bande dall'ultima barra 15m CHIUSA -> nessun look-ahead), si recupera
parte del movimento che l'ingresso al close della barra 15m si perde?
Confronto a parita' di EXIT (stesso wall-clock): l'unica differenza e' il prezzo
d'ingresso (5m anticipato vs close 15m ritardato). La differenza di rendimento e'
esattamente lo "scatto" del breakout catturato in piu'.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from src.data.downloader import load_data
from src.live.signal_engine import keltner_ratio
OOS_START = "2023-11-20"
BB_W = 14
SQ_THR = 0.8
MIN_DUR = 5
LEV = 3.0
POS = 0.15
M15 = 15 * 60 * 1000
M5 = 5 * 60 * 1000
def build_15m_levels(df15: pd.DataFrame) -> pd.DataFrame:
c = df15["close"].values
h = df15["high"].values
l = df15["low"].values
n = len(c)
kcr = keltner_ratio(c, h, l, BB_W)
ma = np.full(n, np.nan)
sd = np.full(n, np.nan)
for t in range(BB_W, n):
w = c[t - BB_W + 1 : t + 1]
ma[t] = w.mean()
sd[t] = w.std()
upper = ma + 2 * sd
lower = ma - 2 * sd
# durata squeeze consecutiva e maturita'
dur = np.zeros(n, dtype=int)
run = 0
for t in range(n):
if not np.isnan(kcr[t]) and kcr[t] < SQ_THR:
run += 1
else:
run = 0
dur[t] = run
mature = dur >= MIN_DUR
return pd.DataFrame({
"ts15": df15["timestamp"].values,
"close_time15": df15["timestamp"].values + M15,
"close15": c,
"upper": upper,
"lower": lower,
"mature": mature,
})
def run_asset(asset: str, hold_min: int, fee_rt: float) -> dict:
df5 = load_data(asset, "5m").reset_index(drop=True)
df15 = load_data(asset, "15m").reset_index(drop=True)
lvl = build_15m_levels(df15)
d5 = pd.DataFrame({
"ts5": df5["timestamp"].values,
"close_time5": df5["timestamp"].values + M5,
"close5": df5["close"].values,
})
# banda armata: ultima barra 15m CHIUSA prima della chiusura del bar 5m
armed = pd.merge_asof(
d5.sort_values("close_time5"),
lvl[["close_time15", "upper", "lower", "mature"]].sort_values("close_time15"),
left_on="close_time5", right_on="close_time15", direction="backward",
)
# barra 15m CONTENENTE il bar 5m (per l'ingresso ritardato a close 15m)
cont = pd.merge_asof(
d5.sort_values("ts5"),
lvl[["ts15", "close15", "close_time15"]].rename(
columns={"close_time15": "cont_close_time"}).sort_values("ts15"),
left_on="ts5", right_on="ts15", direction="backward",
)
m = armed.copy()
m["cont_close"] = cont["close15"].values
m["cont_close_time"] = cont["cont_close_time"].values
oos_ms = int(pd.Timestamp(OOS_START, tz="UTC").timestamp() * 1000)
close5 = m["close5"].values
ct5 = m["close_time5"].values
upper = m["upper"].values
lower = m["lower"].values
mature = m["mature"].values
cont_close = m["cont_close"].values
cont_ct = m["cont_close_time"].values
n = len(m)
cap_e = cap_l = 1000.0 # equity ingresso early(5m) e late(15m)
peak_e = peak_l = 1000.0
dd_e = dd_l = 0.0
trades = win_e = win_l = 0
thrust_sum = 0.0
fee = fee_rt * LEV
busy_until = -1
for i in range(n):
if ct5[i] < oos_ms or ct5[i] <= busy_until:
continue
if not mature[i] or np.isnan(upper[i]):
continue
if close5[i] > upper[i]:
d = 1
elif close5[i] < lower[i]:
d = -1
else:
continue
entry_e = close5[i]
entry_l = cont_close[i]
exit_time = cont_ct[i] + hold_min * 60 * 1000
# primo close 5m al/oltre exit_time
j = np.searchsorted(ct5, exit_time, side="left")
if j >= n:
break
exit_p = close5[j]
ret_e = ((exit_p - entry_e) / entry_e) * d * LEV - fee
ret_l = ((exit_p - entry_l) / entry_l) * d * LEV - fee
thrust_sum += (entry_l - entry_e) / entry_e * d * 100 # scatto % (no leva)
cb_e, cb_l = cap_e, cap_l
cap_e = max(cb_e + cb_e * POS * ret_e, 10.0)
cap_l = max(cb_l + cb_l * POS * ret_l, 10.0)
peak_e = max(peak_e, cap_e); dd_e = max(dd_e, (peak_e - cap_e) / peak_e)
peak_l = max(peak_l, cap_l); dd_l = max(dd_l, (peak_l - cap_l) / peak_l)
trades += 1
win_e += ret_e > 0
win_l += ret_l > 0
busy_until = exit_time
return {
"trades": trades,
"avg_thrust": thrust_sum / trades if trades else 0.0,
"early_win": win_e / trades * 100 if trades else 0.0,
"late_win": win_l / trades * 100 if trades else 0.0,
"early_ret": (cap_e / 1000 - 1) * 100,
"late_ret": (cap_l / 1000 - 1) * 100,
"early_dd": dd_e * 100,
"late_dd": dd_l * 100,
}
def main():
for fee_rt in (0.002, 0.001):
print("=" * 104)
print(f" INGRESSO INTRA-BARRA 5m vs CLOSE 15m — OOS da {OOS_START} | leva={LEV:.0f}x "
f"| fee={fee_rt*100:.2f}% RT")
print(" EARLY = entra al close 5m che rompe la banda | LATE = entra al close della barra 15m | stesso exit")
print("=" * 104)
print(f" {'Asset':>5s}{'Hold':>6s}{'Trd':>6s}{'Scatto%':>9s}"
f"{'EARLY win%':>12s}{'EARLY ret%':>12s}{'LATE win%':>11s}{'LATE ret%':>11s}{'Δret%':>9s}")
print(" " + "-" * 100)
for asset in ["BTC", "ETH"]:
for hold_min in (15, 30, 45):
r = run_asset(asset, hold_min, fee_rt)
print(f" {asset:>5s}{hold_min:>5d}m{r['trades']:>6d}{r['avg_thrust']:>+9.3f}"
f"{r['early_win']:>12.1f}{r['early_ret']:>+12.1f}"
f"{r['late_win']:>11.1f}{r['late_ret']:>+11.1f}"
f"{r['early_ret']-r['late_ret']:>+9.1f}")
print(" " + "-" * 100)
print(" Scatto% = movimento medio (no leva) catturato tra rottura 5m e close 15m, nella direzione.")
print(" Δret% = vantaggio dell'ingresso anticipato. Se ~0 o negativo, il 5m non aiuta.\n")
if __name__ == "__main__":
main()
+87
View File
@@ -0,0 +1,87 @@
"""Smoke test REALE dei pairs: fetch live da Cerbero + un tick vero per coppia.
A differenza di validate_worker_pairs.py (replay su parquet storici), questo verifica
la PIPELINE LIVE end-to-end: chiama Cerbero per entrambe le gambe, controlla che lo
strumento esista e sia fresco, fa un tick reale del PairsWorker e riporta lo stato.
Serve a scoprire i problemi che il backtest nasconde (es. un perp alt non disponibile
sull'endpoint Deribit). NON apre ordini reali: e' solo paper/lettura.
"""
from __future__ import annotations
import sys
import shutil
import tempfile
from datetime import datetime, timezone, timedelta
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from src.live.cerbero_client import CerberoClient
from src.live.multi_runner import INSTRUMENT_MAP
from src.live.pairs_worker import PairsWorker
from scripts.strategies.PR01_pairs_reversion import PAIRS
def fetch(cli, asset, start, end):
inst = INSTRUMENT_MAP.get(asset, f"{asset}-PERPETUAL")
try:
cs = cli.get_historical(inst, start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d"), "60")
if not cs:
return inst, None, "VUOTO (strumento assente sull'endpoint)"
df = pd.DataFrame(cs)
df["timestamp"] = df["timestamp"].astype("int64")
df = df.sort_values("timestamp").reset_index(drop=True)
last = pd.to_datetime(df["timestamp"].iloc[-1], unit="ms", utc=True)
age = (datetime.now(timezone.utc) - last).total_seconds() / 3600
return inst, df, f"{len(df)} barre, ultima {last:%Y-%m-%d %H:%M} ({age:.1f}h fa)"
except Exception as e:
return inst, None, f"ERRORE {type(e).__name__}: {str(e)[:60]}"
def main():
cli = CerberoClient()
end = datetime.now(timezone.utc)
start = end - timedelta(days=60)
assets = sorted({a for a, _, _ in PAIRS} | {b for _, b, _ in PAIRS})
print("=" * 80)
print(" SMOKE TEST LIVE PAIRS — fetch reale Cerbero + tick (no ordini reali)")
print("=" * 80)
data = {}
for a in assets:
inst, df, msg = fetch(cli, a, start, end)
data[a] = df
print(f" {a:<4s} [{inst:<16s}] {msg}")
print("\n tick reale per coppia:")
tmp = Path(tempfile.mkdtemp())
try:
for a, b, p in PAIRS:
if data.get(a) is None or data.get(b) is None:
print(f" {a}/{b:<4s}: SKIP (manca feed live di una gamba) -> non tradabile live ora")
continue
w = PairsWorker(a, b, "1h", params=p, fee_rt=0.001, data_dir=tmp)
w._log = lambda *x, **k: None
w._notify = lambda *x, **k: None
m = data[a][["timestamp", "close"]].merge(
data[b][["timestamp", "close"]], on="timestamp", how="inner")
if len(m) < p["n"] + 2:
print(f" {a}/{b:<4s}: merge {len(m)} barre < n+2 ({p['n']+2}) -> dati insufficienti")
continue
z, _ = w._zscore(m["close_x"].values, m["close_y"].values)
w.tick(data[a], data[b])
state = ("IN POS " + ("LONG " + a if w.direction == 1 else "SHORT " + a)
if w.in_position else "FLAT")
print(f" {a}/{b:<4s}: OK merge {len(m)} barre, z_ora={z[-1]:+.2f} -> {state}")
finally:
shutil.rmtree(tmp, ignore_errors=True)
print("\n Solo le coppie con entrambe le gambe fresche su Cerbero sono tradabili live.")
if __name__ == "__main__":
main()
+259
View File
@@ -0,0 +1,259 @@
"""Validazione out-of-sample fee-aware di tutte le strategie live.
Per ognuna delle 6 config in strategies.yml:
- split temporale held-out (train = primi (1-test_frac), test = ultimo test_frac)
- ML01 (SignalEngine): allena sul train, predice sul test (come il worker live)
- rule-based: i segnali sono causali, si valutano quelli nella finestra test
- simulazione fedele al worker live: una posizione per volta (non-overlap),
uscita a `hold` barre o stop a -2%, fee round-trip e leva inclusi
Stampa, per ogni config: numero trade nel test, win% lordo e netto, return netto,
costo commissioni, e confronto lordo-vs-netto per isolare l'impatto delle fee.
Usa i parquet locali (data/raw), nessuna chiamata di rete.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
import yaml
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from src.data.downloader import load_data
from src.live.strategy_loader import load_strategy
from src.live.signal_engine import SignalEngine, keltner_ratio, build_features
TEST_FRAC = 0.30
STOP_PCT = -0.02
def simulate(entries: list[tuple[int, int]], close: np.ndarray, hold: int,
fee_rt: float, lev: float, pos: float,
initial: float = 1000.0, entry_offset: int = 0) -> dict:
"""FSM fedele al worker live: non-overlap, hold N barre o stop -2%.
entry_offset: 0 = ingresso a close[i] (worker live); 1 = close[i-1]
(convenzione del backtest storico, che conosce la direzione di barra i).
"""
n = len(close)
capital = peak = initial
max_dd = 0.0
fees_eur = gross_eur = 0.0
wins_gross = wins_net = n_trades = 0
last_exit = -1
for i, d in entries:
e = i - entry_offset
if e <= last_exit or e < 0 or e + 1 >= n:
continue
entry = close[e]
exit_price = close[min(e + hold, n - 1)]
for k in range(1, hold + 1):
j = e + k
if j >= n:
exit_price = close[n - 1]
break
if k < hold and (close[j] - entry) / entry * d <= STOP_PCT:
exit_price = close[j]
break
if k == hold:
exit_price = close[j]
actual = (exit_price - entry) / entry * d # movimento prezzo * direzione (no leva)
gross = actual * lev
fee = fee_rt * lev
net = gross - fee
cap_before = capital
capital = max(cap_before + cap_before * pos * net, 10.0)
gross_eur += cap_before * pos * gross
fees_eur += cap_before * pos * fee
peak = max(peak, capital)
max_dd = max(max_dd, (peak - capital) / peak)
n_trades += 1
wins_gross += actual > 0
wins_net += net > 0
last_exit = e + hold
return {
"trades": n_trades,
"win_gross": wins_gross / n_trades * 100 if n_trades else 0.0,
"win_net": wins_net / n_trades * 100 if n_trades else 0.0,
"net_return_pct": (capital / initial - 1) * 100,
"net_eur": capital - initial,
"gross_eur": gross_eur,
"fees_eur": fees_eur,
"final_capital": capital,
"max_dd": max_dd * 100,
}
def rule_entries(name: str, df: pd.DataFrame, params: dict, split: int) -> list[tuple[int, int]]:
strat = load_strategy(name)
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
sigs = strat.generate_signals(df, ts, **params)
return [(s.idx, s.direction) for s in sigs if s.idx >= split]
def ml_entries(df: pd.DataFrame, params: dict, split: int, hold: int) -> tuple[list[tuple[int, int]], dict]:
bb_w = params.get("bb_window", 14)
sq_thr = params.get("sq_threshold", 0.8)
ml_thr = params.get("ml_threshold", 0.70)
eng = SignalEngine(bb_w=bb_w, sq_thr=sq_thr, ml_thr=ml_thr)
train_res = eng.train(df.iloc[:split].reset_index(drop=True), lookahead=hold)
if not eng.trained:
return [], train_res
close = df["close"].values
high = df["high"].values
low = df["low"].values
volume = df["volume"].values
n = len(df)
kcr = keltner_ratio(close, high, low, bb_w)
up_idx = list(eng.model.classes_).index(1)
entries: list[tuple[int, int]] = []
in_sq = False
sq_start = 0
for i in range(bb_w + 1, n):
if np.isnan(kcr[i]):
continue
is_sq = kcr[i] < sq_thr
if is_sq and not in_sq:
in_sq, sq_start = True, i
elif not is_sq and in_sq:
in_sq = False
dur = i - sq_start
if dur < eng.min_squeeze_bars or i < split or i + hold >= n:
continue
avg_vol = float(np.mean(volume[sq_start:i]))
feats = build_features(df, i, dur, avg_vol, kcr[i])
if feats is None:
continue
p_up = eng.model.predict_proba(eng.scaler.transform(feats.reshape(1, -1)))[0][up_idx]
if p_up >= ml_thr:
entries.append((i, 1))
elif p_up <= (1 - ml_thr):
entries.append((i, -1))
return entries, train_res
def squeeze_releases(df: pd.DataFrame, bb_w: int, sq_thr: float, min_dur: int,
split: int) -> list[int]:
"""Indici delle barre di rilascio squeeze nella finestra test (idx >= split)."""
close = df["close"].values
high = df["high"].values
low = df["low"].values
kcr = keltner_ratio(close, high, low, bb_w)
rels: list[int] = []
in_sq = False
sq_start = 0
for i in range(bb_w + 1, len(df)):
if np.isnan(kcr[i]):
continue
is_sq = kcr[i] < sq_thr
if is_sq and not in_sq:
in_sq, sq_start = True, i
elif not is_sq and in_sq:
in_sq = False
if i - sq_start >= min_dur and i >= split:
rels.append(i)
return rels
def honest_entries(df: pd.DataFrame, rels: list[int], rule: str, mom: int = 4) -> list[tuple[int, int]]:
"""Direzione da regole honest (solo dati <= i-1) o baseline breakout.
breakout: sign(close[i]-close[i-1]) -> conoscibile solo a close[i] (= live attuale)
premom: sign(close[i-1]-close[i-1-mom]) -> trend pre-release, 100% honest
fade: -sign(close[i]-close[i-1]) -> mean-reversion del breakout
"""
close = df["close"].values
out: list[tuple[int, int]] = []
for i in rels:
if i - 1 - mom < 0:
continue
if rule == "premom":
d = np.sign(close[i - 1] - close[i - 1 - mom])
elif rule == "fade":
d = -np.sign(close[i] - close[i - 1])
else: # breakout
d = np.sign(close[i] - close[i - 1])
if d != 0:
out.append((i, int(d)))
return out
def main():
cfg = yaml.safe_load((PROJECT_ROOT / "strategies.yml").read_text())
defaults = cfg.get("defaults", {})
hold = defaults.get("hold_bars", 3)
lev = defaults.get("leverage", 3)
fee_rt = 0.002
fee_grid = [0.0, 0.0005, 0.001, 0.0015, 0.002]
# ---- (b) SENSIBILITA' ALLE FEE (config live, ingresso close[i]) ----
print("=" * 104)
print(f" (b) SENSIBILITA' ALLE FEE — config live, ingresso close[i] | OOS {int(TEST_FRAC*100)}% | hold={hold} leva={lev}x")
print("=" * 104)
print(f" {'Strategia':<26s}{'Asset':>5s}{'Trd':>5s}{'Lordo€':>9s}"
+ "".join(f"{f'{f*100:.2f}%':>10s}" for f in fee_grid))
print(" " + "-" * 100)
for entry in cfg.get("strategies", []):
if not entry.get("enabled", True):
continue
name, asset, tf = entry["name"], entry["asset"], entry["tf"]
pos = entry.get("position_size", defaults.get("position_size", 0.15))
params = dict(entry.get("params", {}))
params["asset"], params["tf"] = asset, tf
df = load_data(asset, tf).reset_index(drop=True)
split = int(len(df) * (1 - TEST_FRAC))
close = df["close"].values
entries = (ml_entries(df, params, split, hold)[0] if name.startswith("ML01")
else rule_entries(name, df, params, split))
gross = simulate(entries, close, hold, 0.0, lev, pos)["net_eur"]
rets = [simulate(entries, close, hold, f, lev, pos)["net_return_pct"] for f in fee_grid]
print(f" {name:<26s}{asset:>5s}{len(entries):>5d}{gross:>+9.0f}"
+ "".join(f"{r:>+10.1f}" for r in rets))
print(" " + "-" * 100)
print(" Colonne = Ret% netto al variare della fee RT. 0.00% isola l'edge puro (senza costi).")
print(" Deribit perp reale: taker ~0.10% RT, maker ~0%. Il modello live usa 0.20% RT.")
# ---- (a) HONEST-ENTRY squeeze: direzione decisa <= i-1, ingresso close[i] ----
print("\n" + "=" * 104)
print(f" (a) HONEST-ENTRY squeeze (bb14 sq0.8 dur>=5) — ingresso close[i], fee={fee_rt*100:.1f}% RT")
print("=" * 104)
print(f" {'Asset':>5s}{'Regola direzione':>20s}{'Trd':>6s}{'Win%g':>8s}{'Win%n':>8s}{'Netto€':>9s}{'Ret%':>9s}{'DD%':>7s}")
print(" " + "-" * 100)
rules = [("breakout (=live)", "breakout"), ("pre-trend mom4", "premom"),
("pre-trend mom8", "premom8"), ("fade breakout", "fade")]
for asset in ["BTC", "ETH"]:
df = load_data(asset, "15m").reset_index(drop=True)
split = int(len(df) * (1 - TEST_FRAC))
close = df["close"].values
rels = squeeze_releases(df, 14, 0.8, 5, split)
for label, rule in rules:
mom = 8 if rule == "premom8" else 4
ents = honest_entries(df, rels, "premom" if rule == "premom8" else rule, mom=mom)
r = simulate(ents, close, hold, fee_rt, lev, 0.15)
print(f" {asset:>5s}{label:>20s}{r['trades']:>6d}{r['win_gross']:>8.1f}"
f"{r['win_net']:>8.1f}{r['net_eur']:>+9.0f}{r['net_return_pct']:>+9.1f}{r['max_dd']:>7.1f}")
print(" " + "-" * 100)
print(" pre-trend = direzione dal trend PRIMA del rilascio (solo dati <= i-1): 100% honest.")
print(" Se nessuna regola honest batte ~breakeven, non esiste edge direzionale tradeable.")
if __name__ == "__main__":
main()
+133
View File
@@ -0,0 +1,133 @@
"""Verifica indipendente + ricerca PAIRS / SPREAD MEAN-REVERSION fra cripto.
Famiglia nuova market-neutral (distinta da tutto l'esistente, single-asset).
Idea: il log-ratio di due cripto oscilla attorno alla media; z-score estremo -> rientra.
Engine ONESTO (no look-ahead, verificato):
- r[i] = log(closeA[i]/closeB[i]); ma/sd = rolling(n) su r -> usano solo r[<=i].
- z[i] = (r[i]-ma[i])/sd[i]. ENTRY a close[i] (eseguibile):
z<=-z_in -> LONG ratio (long A / short B); z>=+z_in -> SHORT ratio.
- EXIT quando |z[j]| <= z_exit (rientro) o time-limit max_bars, a close[j].
- pairs = 2 GAMBE -> fee = 2*fee_rt*lev (0.20% RT/coppia a fee_rt=0.001), il doppio
del single-asset. Rendimento neutral = retA*d - retB*d (notional uguale per gamba).
- non-overlap, capitale composto. Filtro candele sporche: salta salti |dr|>jump_max.
- Ritorno riportato come CAGR e Sharpe ANNUALIZZATO sul tempo reale (no sqrt(n_trade)).
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from src.data.downloader import load_data
FEE_RT, LEV, POS, OOS_FRAC = 0.001, 3.0, 0.15, 0.30
BARS_YEAR = 8760 # 1h
def aligned(a: str, b: str, tf: str = "1h"):
da = load_data(a, tf)[["timestamp", "open", "high", "low", "close"]].rename(columns=lambda x: x + "_a" if x != "timestamp" else x)
db = load_data(b, tf)[["timestamp", "close"]].rename(columns={"close": "close_b"})
m = da.merge(db, on="timestamp", how="inner").reset_index(drop=True)
m["dt"] = pd.to_datetime(m["timestamp"], unit="ms", utc=True)
return m
def pairs_sim(a, b, tf="1h", n=50, z_in=2.0, z_exit=0.5, max_bars=72,
jump_max=0.08, fee_rt=FEE_RT, lev=LEV, pos=POS, split_frac=0.0):
m = aligned(a, b, tf)
ca, cb = m["close_a"].values, m["close_b"].values
r = np.log(ca / cb)
dr = np.abs(np.diff(r, prepend=r[0])) # salto 1-bar del log-ratio
ma = pd.Series(r).rolling(n).mean().values
sd = pd.Series(r).rolling(n).std().values
z = (r - ma) / np.where(sd == 0, np.nan, sd) # causale: usa r[<=i]
ts = m["dt"]; N = len(r)
split = int(N * split_frac)
fee = 2 * fee_rt * lev # 2 gambe
cap = peak = 1000.0; dd = 0.0; last = -1
trades = wins = 0; rets = []; yearly = {}
eq_ts: list = []; eq_v: list = []
for i in range(n + 1, N - 1):
if i < split or np.isnan(z[i]) or dr[i] > jump_max:
continue
if i <= last:
continue
if z[i] <= -z_in:
d = 1
elif z[i] >= z_in:
d = -1
else:
continue
# exit: |z|<=z_exit o max_bars
j = min(i + max_bars, N - 1)
for k in range(1, max_bars + 1):
jj = i + k
if jj >= N:
j = N - 1; break
if abs(z[jj]) <= z_exit:
j = jj; break
j = jj
retA = (ca[j] - ca[i]) / ca[i]
retB = (cb[j] - cb[i]) / cb[i]
ret = (retA - retB) * d * lev - fee # long A / short B (o viceversa)
cap = max(cap + cap * pos * ret, 10.0)
peak = max(peak, cap); dd = max(dd, (peak - cap) / peak)
trades += 1; wins += ret > 0; rets.append(ret * pos); last = j
eq_ts.append(ts.iloc[j]); eq_v.append(cap)
yearly[ts.iloc[i].year] = yearly.get(ts.iloc[i].year, 0.0) + ret * 100
yrs_span = (ts.iloc[-1] - ts.iloc[max(split, 0)]).days / 365.25 or 1
sharpe = float(np.mean(rets) / np.std(rets) * np.sqrt(BARS_YEAR / np.mean([max_bars])) ) if len(rets) > 1 and np.std(rets) > 0 else 0.0
# Sharpe annualizzato sul tempo reale: usa rendimenti per-trade scalati alla frequenza media
if len(rets) > 1 and np.std(rets) > 0:
trades_per_year = trades / yrs_span
sharpe = float(np.mean(rets) / np.std(rets) * np.sqrt(trades_per_year))
ret_tot = (cap / 1000 - 1) * 100
cagr = ((cap / 1000) ** (1 / yrs_span) - 1) * 100 if cap > 0 else -100
return dict(trades=trades, win=wins / trades * 100 if trades else 0, ret=ret_tot, cagr=cagr,
dd=dd * 100, sharpe=sharpe, yearly=yearly, eq_ts=eq_ts, eq_v=eq_v)
def check_no_lookahead():
"""Perturba il FUTURO del ratio e verifica che z[i] non cambi (causalita')."""
m = aligned("ETH", "BTC")
r = np.log(m["close_a"].values / m["close_b"].values)
n = 50; i = 1000
z_i = (r[i] - pd.Series(r).rolling(n).mean().values[i]) / pd.Series(r).rolling(n).std().values[i]
r2 = r.copy(); r2[i + 1:] += 0.5 # stravolge il futuro
z_i2 = (r2[i] - pd.Series(r2).rolling(n).mean().values[i]) / pd.Series(r2).rolling(n).std().values[i]
print(f" no-look-ahead: z[i]={z_i:.6f} vs z[i] con futuro perturbato={z_i2:.6f} -> "
f"{'OK (identico)' if abs(z_i - z_i2) < 1e-9 else 'VIOLAZIONE!'}")
def main():
print("=" * 104)
print(f" PAIRS spread reversion — NETTO fee 0.20% RT/coppia (2 gambe), leva {LEV:.0f}x, OOS ultimo {int(OOS_FRAC*100)}%")
print("=" * 104)
check_no_lookahead()
pairs = [("ETH", "BTC"), ("LTC", "ETH"), ("ADA", "ETH"), ("SOL", "ETH"),
("BNB", "BTC"), ("XRP", "BTC"), ("SOL", "BTC"), ("DOGE", "BTC")]
print(f"\n {'coppia':<10s}{'trd':>5s}{'win%':>6s}{'FULL%':>8s}{'OOS%':>8s}{'CAGR%':>7s}"
f"{'DD%':>6s}{'oDD%':>6s}{'Shrp':>6s}{'anni+':>7s}{'fee0.4%RT':>11s}")
print(" " + "-" * 96)
for a, b in pairs:
f = pairs_sim(a, b, n=50, z_in=2.0, z_exit=0.5, max_bars=72)
o = pairs_sim(a, b, n=50, z_in=2.0, z_exit=0.5, max_bars=72, split_frac=1 - OOS_FRAC)
hi = pairs_sim(a, b, n=50, z_in=2.0, z_exit=0.5, max_bars=72, fee_rt=0.002) # 0.4% RT/coppia
yrs = f["yearly"]; pos_y = sum(1 for v in yrs.values() if v > 0)
print(f" {a+'/'+b:<10s}{f['trades']:>5d}{f['win']:>6.1f}{f['ret']:>+8.0f}{o['ret']:>+8.0f}"
f"{f['cagr']:>7.0f}{f['dd']:>6.0f}{o['dd']:>6.0f}{f['sharpe']:>6.2f}{f'{pos_y}/{len(yrs)}':>7s}"
f"{hi['ret']:>+11.0f}")
# correlazione con BTC daily (market-neutrality) sulla coppia migliore
print("\n Verifica market-neutrality ETH/BTC: per-anno")
f = pairs_sim("ETH", "BTC", n=50, z_in=2.0, z_exit=0.5, max_bars=72)
print(" " + " ".join(f"{y}:{v:+.0f}%" for y, v in sorted(f["yearly"].items())))
if __name__ == "__main__":
main()
+148
View File
@@ -0,0 +1,148 @@
"""Proiezione a 3 anni del portafoglio live (PORT06) ESCLUDENDO il 2024.
Risponde a: "partendo da 1000 EUR, con capitale che compone e puntata che cresce
di conseguenza, quale guadagno giornaliero atteso e quale traiettoria a 3 anni?"
Punti chiave (perche' i numeri differiscono da report_families):
- report_families/Portfolio.backtest usano le curve sleeve NATIVE a leva 3x.
- Il container LIVE (src.portfolio.runner via portfolios.yml) gira a pos 0.15 x 2x.
- Il PnL giornaliero scala ESATTAMENTE con la leva: pnl = cap * pos * lev * (ret - fee),
stesso segnale -> ratio 2/3 per gli sleeve leverati.
- ROT02 e TSM01 NON si riscalano: usano `gross` (0.45 / 0.30), indipendente dalla leva
e identico fra backtest e worker live.
- Il 2024 e' escluso perche' anno eccezionale (crypto +; gonfia ogni stima).
CAVEAT (le proiezioni sono OTTIMISTICHE):
- 2021-2025 e' quasi tutto bull/recovery; poco orso/flat prolungato nel campione.
- Dati alt = testnet (volume sottile, fill/slippage NON modellati).
- OOS singolo (2024-25) = regime calmo -> ~50% ottimistico (vedi report_families (D)).
Lo scenario SOBRIO (haircut ~50%) e' il numero prudente su cui pianificare.
Run: uv run python scripts/analysis/projection_3y.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.portfolios._defs import PORTFOLIOS
from src.portfolio.sleeves import all_sleeve_equities, sleeve_returns_df
from scripts.analysis.combine_portfolio import port_returns
# sleeve la cui esposizione NON dipende dalla leva (gross-based) -> non si riscalano
NOSCALE = {"ROT02_rot", "TSM01"}
NATIVE_LEV = 3.0 # leva delle curve sleeve in combine_portfolio
EXCLUDE_YEAR = 2024
START_CAPITAL = 1000.0
def live_portfolio_returns(live_leverage: float = 2.0) -> pd.Series:
"""Rendimenti giornalieri di PORT06 al sizing LIVE (pos 0.15 x `live_leverage`).
Riscala gli sleeve leverati di live_leverage/NATIVE_LEV; ROT/TSM invariati."""
p = PORTFOLIOS["PORT06"]
p.leverage = live_leverage
p.weighting = "cap"
p.caps = {"PAIRS": 0.33}
eq = all_sleeve_equities()
dr = sleeve_returns_df(p.sleeve_ids)
w = p.weight_vector(dr)
scale = live_leverage / NATIVE_LEV
eq_live = {}
for sid in p.sleeve_ids:
r = eq[sid].pct_change().fillna(0.0)
r = r if sid in NOSCALE else r * scale
eq_live[sid] = (1 + r).cumprod()
pr = port_returns(eq_live, w)
pr.index = pd.to_datetime(pr.index)
return pr
def stats_ex_year(pr: pd.Series, exclude: int = EXCLUDE_YEAR) -> dict:
ex = pr[pr.index.year != exclude]
n = len(ex)
years = n / 365.0
tot = (1 + ex).prod() - 1
cagr = (1 + tot) ** (1 / years) - 1
yvals = [(1 + g).prod() - 1 for _, g in ex.groupby(ex.index.year)]
return {
"days": n, "years": years, "total": tot, "cagr": cagr,
"year_median": float(np.median(yvals)), "year_mean": float(np.mean(yvals)),
"daily_mean_eur": float(ex.mean() * START_CAPITAL),
"daily_median_eur": float(ex.median() * START_CAPITAL),
"daily_std_eur": float(ex.std() * START_CAPITAL),
"pos_days": float((ex > 0).mean()),
"per_year": {int(y): float((1 + g).prod() - 1) for y, g in pr.groupby(pr.index.year)},
}
def project(annual: float, years: int = 3, start: float = START_CAPITAL) -> list[dict]:
"""Capitale che compone; la puntata cresce col capitale -> EUR/giorno cresce."""
rows, cap = [], start
for yr in range(1, years + 1):
s = cap
cap = cap * (1 + annual)
rows.append({"year": yr, "start": s, "end": cap,
"gain": cap - s, "eur_per_day": (cap - s) / 365.0})
return rows
def years_to_target(daily_target: float, annual: float, start: float = START_CAPITAL) -> float:
"""Anni per raggiungere un certo EUR/giorno componendo (capitale = target*365/cagr)."""
cap_needed = daily_target * 365.0 / annual
if cap_needed <= start:
return 0.0
return float(np.log(cap_needed / start) / np.log(1 + annual))
def main():
pr = live_portfolio_returns(live_leverage=2.0)
s = stats_ex_year(pr)
print("=" * 72)
print(" PORT06 LIVE (pos 0.15 x 2x) — proiezione 3 anni, ESCLUSO 2024")
print("=" * 72)
print(" Rendimento live per anno:")
for y, v in s["per_year"].items():
flag = " <-- ESCLUSO" if y == EXCLUDE_YEAR else ""
print(f" {y}: {v * 100:+6.1f}%{flag}")
print()
print(f" CAGR (escl 2024): {s['cagr'] * 100:5.1f}% "
f"[{s['years']:.2f} anni di dati]")
print(f" anno mediano: {s['year_median'] * 100:5.1f}%")
print(f" anno medio: {s['year_mean'] * 100:5.1f}%")
print(f" EUR/giorno su 1000: media {s['daily_mean_eur']:.2f} | "
f"mediana {s['daily_median_eur']:.2f} | std {s['daily_std_eur']:.2f}")
print(f" giorni positivi: {s['pos_days'] * 100:.1f}%")
print()
scenarios = [
("CAGR backtest escl-2024", s["cagr"]),
("anno mediano", s["year_median"]),
("SOBRIO (haircut ~50%)", s["cagr"] * 0.5),
]
for name, g in scenarios:
print(f" -- 3 anni @ {g * 100:.0f}%/anno ({name}) --")
for r in project(g):
print(f" anno {r['year']}: {r['start']:7.0f} -> {r['end']:7.0f} EUR "
f"(+{r['gain']:5.0f}, ~{r['eur_per_day']:4.2f} EUR/g medi)")
print()
print(" -- Target 50 EUR/giorno (reality check) --")
for name, g in scenarios[:1] + scenarios[2:]:
cap_needed = 50.0 * 365.0 / g
t = years_to_target(50.0, g)
print(f" @ {g * 100:.0f}%/anno: servono ~{cap_needed:,.0f} EUR schierati "
f"-> da 1000 EUR, ~{t:.0f} anni componendo")
print(" => il collo di bottiglia e' il CAPITALE iniziale, non la strategia.")
if __name__ == "__main__":
main()
+112
View File
@@ -0,0 +1,112 @@
"""Fetch dati REGIME backtestabili da Deribit MAINNET (public, no-auth) -> parquet.
Abilita la ricerca strategie frattali x regime (ARGO-proxy). Salva in data/raw/:
{btc,eth}_dvol.parquet : DVOL index 1h (IV 30d "VIX crypto"), storico ~2021->oggi
{btc,eth}_funding.parquet : funding rate perp 1h, storico ~2019->oggi
Solo componenti ARGO con STORICO GRATUITO (DVOL, funding) -> validabili OOS. Il GEX
per-strike resta snapshot-only (vedi analisi 2026-06-01). Run:
uv run python scripts/analysis/regime_fetcher.py
"""
from __future__ import annotations
import time
import urllib.request
import urllib.parse
import json
from pathlib import Path
import pandas as pd
ROOT = Path(__file__).resolve().parents[2]
RAW = ROOT / "data" / "regime" # NON data/raw (solo OHLCV) — evita pollution discovery asset
BASE = "https://www.deribit.com/api/v2/public/"
def _get(method: str, params: dict) -> dict:
url = BASE + method + "?" + urllib.parse.urlencode(params)
for _ in range(4):
try:
with urllib.request.urlopen(url, timeout=30) as r:
return json.loads(r.read())
except Exception:
time.sleep(1.0)
return {}
def fetch_dvol(currency: str, start_ms: int, end_ms: int, res: int = 3600) -> pd.DataFrame:
"""DVOL index (OHLC). Cap 1000 righe/chiamata -> chaining all'indietro."""
rows = []
cur_end = end_ms
span = 1000 * res * 1000
while cur_end > start_ms:
cur_start = max(start_ms, cur_end - span)
d = _get("get_volatility_index_data", {
"currency": currency, "start_timestamp": cur_start,
"end_timestamp": cur_end, "resolution": res})
data = (d.get("result") or {}).get("data") or []
if not data:
break
rows.extend(data)
oldest = min(x[0] for x in data)
if oldest >= cur_end:
break
cur_end = oldest - 1
time.sleep(0.15)
if not rows:
return pd.DataFrame()
df = pd.DataFrame(rows, columns=["timestamp", "open", "high", "low", "close"])
df = df.drop_duplicates("timestamp").sort_values("timestamp").reset_index(drop=True)
df["dvol"] = df["close"]
return df
def fetch_funding(instrument: str, start_ms: int, end_ms: int) -> pd.DataFrame:
"""funding rate history perp (1h). Paginazione ~30g/chiamata."""
rows = []
cur_start = start_ms
step = 30 * 24 * 3600 * 1000
while cur_start < end_ms:
cur_end = min(end_ms, cur_start + step)
d = _get("get_funding_rate_history", {
"instrument_name": instrument,
"start_timestamp": cur_start, "end_timestamp": cur_end})
data = d.get("result") or []
if data:
rows.extend(data)
cur_start = cur_end + 1
time.sleep(0.12)
if not rows:
return pd.DataFrame()
df = pd.DataFrame(rows)
ts_col = "timestamp" if "timestamp" in df.columns else df.columns[0]
df = df.rename(columns={ts_col: "timestamp"})
keep = [c for c in ("timestamp", "interest_1h", "interest_8h", "index_price", "prev_index_price") if c in df.columns]
df = df[keep].drop_duplicates("timestamp").sort_values("timestamp").reset_index(drop=True)
return df
def main():
RAW.mkdir(parents=True, exist_ok=True)
now = _get("get_time", {})
end_ms = int(now.get("result", 0)) or int(time.time() * 1000)
start_ms = end_ms - int(6.5 * 365 * 24 * 3600 * 1000) # ~6.5 anni
for cur, inst in (("BTC", "BTC-PERPETUAL"), ("ETH", "ETH-PERPETUAL")):
dv = fetch_dvol(cur, start_ms, end_ms)
if not dv.empty:
p = RAW / f"{cur.lower()}_dvol.parquet"
dv.to_parquet(p)
rng = (pd.to_datetime(dv['timestamp'].min(), unit='ms').date(),
pd.to_datetime(dv['timestamp'].max(), unit='ms').date())
print(f" {cur} DVOL: {len(dv)} righe {rng[0]}->{rng[1]} (ora={dv['dvol'].iloc[-1]:.1f}) -> {p.name}")
fr = fetch_funding(inst, start_ms, end_ms)
if not fr.empty:
p = RAW / f"{cur.lower()}_funding.parquet"
fr.to_parquet(p)
rng = (pd.to_datetime(fr['timestamp'].min(), unit='ms').date(),
pd.to_datetime(fr['timestamp'].max(), unit='ms').date())
print(f" {cur} FUNDING: {len(fr)} righe {rng[0]}->{rng[1]} -> {p.name}")
if __name__ == "__main__":
main()
+202
View File
@@ -0,0 +1,202 @@
"""regime_lab — API condivisa per la ricerca strategie FRATTALI x REGIME (ARGO-proxy).
Allinea prezzo (OHLCV) + DVOL + funding in modo CAUSALE (no look-ahead: il valore di
regime alla barra i usa solo dati <= timestamp[i]) ed espone:
- feature REGIME (ARGO-proxy backtestabili): dvol, dvol_pct (percentile rolling),
rv (realized vol), vrp = dvol - rv, funding, funding_z, dvol_chg (proxy term-structure).
- feature FRATTALI (src/fractal): rolling_hurst, higuchi, self_similarity, volatility_ratio,
williams fractals (pivot), candle encoding.
- validazione: report(name, entries, df) -> full/oos netto-fee + robustezza griglia/fee,
riusando l'engine onesto di explore_lab (simulate/evaluate).
Convenzione entries (come explore_lab): lista di dict {i, d (+1/-1), tp, sl, max_bars}.
Ingresso ESEGUIBILE: i, d, tp, sl decisi con dati <= close[i].
Uso tipico in un agente:
from scripts.analysis.regime_lab import load, report, regime_features, frac_features
df = load("BTC", "1h") # OHLCV + colonne regime allineate
R = regime_features(df); F = frac_features(df)
entries = [...] # la tua logica
print(report("MIA_STRATEGIA", entries, df))
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from scripts.analysis.explore_lab import get_df, simulate, evaluate, atr, ema, rsi # noqa: E402
from src.fractal.indicators import ( # noqa: E402
rolling_hurst, fractal_dimension_higuchi, self_similarity_score, volatility_ratio,
)
# dati regime (DVOL/funding/feature) in data/regime/ — NON in data/raw/ (che e' solo OHLCV: i file
# estranei in data/raw inquinano la discovery asset del backtest). Vedi diary 2026-06-02-fade-lossguard.
RAW = ROOT / "data" / "regime"
RAW.mkdir(parents=True, exist_ok=True)
# --------------------------------------------------------------------------- dati
def _load_regime_series(asset: str) -> tuple[pd.DataFrame, pd.DataFrame]:
a = asset.lower()
dvol = pd.read_parquet(RAW / f"{a}_dvol.parquet") if (RAW / f"{a}_dvol.parquet").exists() else pd.DataFrame()
fund = pd.read_parquet(RAW / f"{a}_funding.parquet") if (RAW / f"{a}_funding.parquet").exists() else pd.DataFrame()
return dvol, fund
def load(asset: str, tf: str) -> pd.DataFrame:
"""OHLCV (explore_lab.get_df) + colonne regime allineate CAUSALMENTE (merge_asof backward).
Ogni barra prezzo riceve l'ultimo DVOL/funding con timestamp <= timestamp barra."""
df = get_df(asset, tf).copy()
df["timestamp"] = df["timestamp"].astype("int64")
dvol, fund = _load_regime_series(asset)
if not dvol.empty:
d = dvol[["timestamp", "dvol"]].astype({"timestamp": "int64"}).sort_values("timestamp")
df = pd.merge_asof(df.sort_values("timestamp"), d, on="timestamp", direction="backward")
else:
df["dvol"] = np.nan
if not fund.empty:
col = "interest_1h" if "interest_1h" in fund.columns else fund.columns[1]
f = fund[["timestamp", col]].astype({"timestamp": "int64"}).rename(columns={col: "funding"}).sort_values("timestamp")
df = pd.merge_asof(df.sort_values("timestamp"), f, on="timestamp", direction="backward")
else:
df["funding"] = np.nan
return df.reset_index(drop=True)
# ---------------------------------------------------------------- feature REGIME
def _rolling_pct(x: np.ndarray, win: int) -> np.ndarray:
"""Percentile rolling CAUSALE: rank di x[i] nella finestra [i-win, i] (solo passato)."""
s = pd.Series(x)
return s.rolling(win, min_periods=max(20, win // 4)).apply(
lambda w: (w.iloc[-1] >= w).mean(), raw=False).values
_BARS_PER_YEAR = {"1h": 24 * 365, "4h": 6 * 365, "1d": 365}
def regime_features(df: pd.DataFrame, tf: str = "1h", pct_win: int = 252, rv_win: int = 24,
fund_win: int = 168) -> dict:
"""Tutte causali. dvol_pct/funding_z usano solo finestra passata. vrp = dvol - rv annualizz.
tf serve ad annualizzare correttamente la realized vol (sqrt barre/anno per timeframe)."""
c = df["close"].values.astype(float)
dvol = df["dvol"].values.astype(float)
fund = df["funding"].values.astype(float)
ret = np.zeros_like(c); ret[1:] = np.diff(np.log(c))
# realized vol annualizzata (punti %, scala come DVOL): std rolling * sqrt(barre/anno del tf)
bpy = _BARS_PER_YEAR.get(tf, 24 * 365)
rv = pd.Series(ret).rolling(rv_win).std().values * np.sqrt(bpy) * 100
dvol_pct = _rolling_pct(dvol, pct_win)
fmean = pd.Series(fund).rolling(fund_win).mean().values
fstd = pd.Series(fund).rolling(fund_win).std().values
funding_z = (fund - fmean) / np.where(fstd == 0, np.nan, fstd)
dvol_chg = pd.Series(dvol).diff(rv_win).values # proxy term-structure (DVOL in salita/discesa)
return {
"dvol": dvol, "dvol_pct": dvol_pct, "rv": rv, "vrp": dvol - rv,
"funding": fund, "funding_z": funding_z, "dvol_chg": dvol_chg,
}
# --------------------------------------------------------------- feature FRATTALI
def williams_fractals(df: pd.DataFrame, k: int = 2) -> tuple[np.ndarray, np.ndarray]:
"""Pivot di Bill Williams: frac_up[i]=high[i] e' il max delle 2k+1 barre centrate (causale a i+k).
Ritorna due array bool (up=swing high confermato, dn=swing low). Confermati con ritardo k."""
h, l = df["high"].values, df["low"].values
n = len(h)
up = np.zeros(n, bool); dn = np.zeros(n, bool)
for i in range(k, n - k):
if h[i] == max(h[i - k:i + k + 1]):
up[i] = True
if l[i] == min(l[i - k:i + k + 1]):
dn[i] = True
return up, dn
def frac_features(df: pd.DataFrame, hurst_win: int = 100, higuchi_win: int = 64,
step: int = 1) -> dict:
"""Feature frattali rolling, CAUSALI (finestra passata che termina a i). step>1: calcola
ogni `step` barre e fa forward-fill (i frattali variano lentamente) -> molto piu' veloce."""
c = df["close"].values.astype(float)
n = len(c)
hurst = rolling_hurst(c, window=hurst_win, step=step) # gia' causale + stepped (src/fractal)
vratio = np.full(n, np.nan)
higuchi = np.full(n, np.nan)
last_hi = last_vr = np.nan
for i in range(higuchi_win, n):
if (i - higuchi_win) % step == 0:
last_hi = fractal_dimension_higuchi(c[i - higuchi_win:i])
last_vr = volatility_ratio(c[max(0, i - 60):i])
higuchi[i] = last_hi
vratio[i] = last_vr
up, dn = williams_fractals(df)
return {"hurst": hurst, "higuchi": higuchi, "vratio": vratio,
"frac_up": up, "frac_dn": dn}
# ------------------------------------------------------------------------- cache
_FEATCOLS_R = ("dvol", "dvol_pct", "rv", "vrp", "funding", "funding_z", "dvol_chg")
_FEATCOLS_F = ("hurst", "higuchi", "vratio", "frac_up", "frac_dn")
def _cache_path(asset: str, tf: str) -> Path:
return RAW / f"features_{asset.lower()}_{tf}.parquet"
def build_cache(asset: str, tf: str, frac_step: int = 6) -> pd.DataFrame:
"""Precompute OHLCV + regime + frattali -> parquet condiviso (per i 100 agenti)."""
df = load(asset, tf)
R = regime_features(df, tf=tf)
F = frac_features(df, step=frac_step)
for k in _FEATCOLS_R:
df[k] = R[k]
for k in _FEATCOLS_F:
df[k] = F[k]
p = _cache_path(asset, tf)
df.to_parquet(p)
return df
def load_features(asset: str, tf: str) -> pd.DataFrame:
"""Carica la cache feature (la costruisce se manca). OHLCV + tutte le colonne regime+frattali."""
p = _cache_path(asset, tf)
if p.exists():
return pd.read_parquet(p)
return build_cache(asset, tf)
# ------------------------------------------------------------------- validazione
def report(name: str, entries: list[dict], df: pd.DataFrame, asset: str = "", tf: str = "") -> dict:
"""Netto-fee full + OOS (ultimo 30%) + sweep fee, via engine onesto di explore_lab.
Ritorna dict compatto: trades, full/oos (ret%, sharpe, dd, acc), robust (OK su tutte le fee)."""
if not entries:
# struttura compatibile con robust() (tutti zero) -> robust()=False pulito, niente crash
z = {"ret": 0.0, "sharpe": 0.0, "dd": 0.0, "trades": 0, "win": 0.0, "exposure": 0.0, "yearly": {}}
print(f" {name:<24s} NO ENTRIES")
return {"full": dict(z), "oos": dict(z), "sweep": {0.0: 0.0, 0.0005: 0.0, 0.001: 0.0, 0.002: 0.0},
"sweep_oos": {0.0: 0.0, 0.0005: 0.0, 0.001: 0.0, 0.002: 0.0}, "pos_yrs": 0, "n_yrs": 0}
return evaluate(name, entries, df) # full + oos + fee sweep
if __name__ == "__main__":
# smoke: una fade Bollinger gateata dal regime (DVOL alto) come esempio d'uso
df = load("BTC", "1h")
R = regime_features(df); F = frac_features(df)
c = df["close"].values
ma = pd.Series(c).rolling(50).mean().values
sd = pd.Series(c).rolling(50).std().values
a = atr(df, 14)
ent = []
for i in range(300, len(c) - 1):
if np.isnan(sd[i]) or np.isnan(R["dvol_pct"][i]):
continue
if R["dvol_pct"][i] < 0.6: # gate: solo regime DVOL alto
continue
if c[i] < ma[i] - 2.5 * sd[i]: # fade banda bassa
ent.append({"i": i, "d": 1, "tp": ma[i], "sl": c[i] - 2 * a[i], "max_bars": 24})
print(f"smoke BTC 1h fade|DVOL>p60: {len(ent)} entries")
print(report("SMOKE", ent, df))
+136
View File
@@ -0,0 +1,136 @@
"""Report aggiornato: risultati per anno + numero trade per anno, tutte le strategie.
Sezioni:
(A) RET% NETTO per anno — ogni strategia singola + i portafogli (FADE / HONEST /
MASTER equal / MASTER 50-50). Ret% dai rendimenti giornalieri composti.
(B) NUMERO TRADE per anno — per ogni strategia singola. Per le fade e DIP01 è il
numero di ingressi; per TR01 e ROT02 (posizione continua) è il numero di
ribilanciamenti/cambi di stato nell'anno.
(C) RIEPILOGO — TOT%, CAGR, DD, Sharpe (FULL e OOS) dei portafogli.
Tutto NETTO fee 0.10% RT, leva 3x, pos 15%/sleeve. Finestra comune 2021-2026,
OOS = ultimo 30%. Config = quella deployata (MR03/ROT01 in waste; ROT02 top_k=3).
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from src.data.downloader import load_data
from scripts.analysis.combine_portfolio import (
build_all_sleeves, port_returns, yearly_returns, metrics, SPLIT, OOS_DATE, IDX,
)
from scripts.analysis.risk_management import strats_for, build_trades
from scripts.analysis.honest_lab import get_df, ema, FEE_RT, LEV, POS
from scripts.analysis.honest_improve import rot_improved
from scripts.analysis.honest_improve2 import dip_market_gated
YEARS = sorted(set(IDX.year))
# ---------------- trade per anno, per tipo di strategia ----------------
def fade_trades_year(asset, fn, params) -> dict[int, int]:
df = load_data(asset, "1h")
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
out: dict[int, int] = {}
for i, j, ret in build_trades(fn(df, **params), df, trend_max=3.0):
y = ts.iloc[i].year
out[y] = out.get(y, 0) + 1
return out
def dip_trades_year() -> dict[int, int]:
d = dip_market_gated("BTC", market_n=0)
# yt[anno] = lista dei trade dell'anno -> il conteggio e' la lunghezza
return {int(y): (len(v) if isinstance(v, (list, tuple)) else int(v)) for y, v in d["yt"].items()}
def tr_rebalances_year(assets) -> dict[int, int]:
"""Cambi di stato (entra/esce dal trend) per anno, sommati sul paniere TR01."""
out: dict[int, int] = {}
for a in assets:
df = get_df(a, "4h"); c = df["close"].values
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
ef, es = ema(c, 20), ema(c, 100)
sig = np.where(ef > es, 1.0, 0.0); sig[:100] = 0.0
for i in range(1, len(c)):
if sig[i] != sig[i - 1]:
y = ts.iloc[i].year
out[y] = out.get(y, 0) + 1
return out
def rot_rebalances_year() -> dict[int, int]:
r = rot_improved(lookback=60, top_k=3, regime_n=100)
return {int(y): int(n) for y, n in r["reb"].items()}
def main():
print("Costruzione equity e conteggi (puo' richiedere ~1 min)...\n")
S = build_all_sleeves()
fade = {k: v for k, v in S.items() if k.startswith("MR")}
honest = {k: v for k, v in S.items() if not k.startswith("MR")}
# rendimenti giornalieri per Ret%/anno
sleeve_ret = {k: v.pct_change().fillna(0.0) for k, v in S.items()}
ports = {
"FADE": port_returns(fade),
"HONEST": port_returns(honest),
"MASTEReq": port_returns(S),
"MAST5050": (port_returns(fade) + port_returns(honest)) / 2,
}
# ---- (A) RET% per anno ----
cols_A = list(S) + list(ports)
rety = {**{k: yearly_returns(v) for k, v in sleeve_ret.items()},
**{k: yearly_returns(v) for k, v in ports.items()}}
print("=" * 132)
print(" (A) RET% NETTO PER ANNO — strategie singole e portafogli | leva 3x pos 15% fee 0.10% RT")
print("=" * 132)
print(f" {'Anno':>5s}" + "".join(f"{c.replace('_',''):>11s}" for c in cols_A))
print(" " + "-" * 126)
for y in YEARS:
print(f" {y:>5d}" + "".join(f"{rety[c].get(y, 0):>+11.0f}" for c in cols_A))
# ---- (B) NUMERO TRADE per anno ----
tcounts = {}
for asset in ["BTC", "ETH"]:
for nm, (fn, params) in strats_for(asset).items():
tcounts[f"{nm}_{asset}"] = fade_trades_year(asset, fn, params)
tcounts["DIP01_BTC"] = dip_trades_year()
tcounts["TR01_basket*"] = tr_rebalances_year(["BNB", "BTC", "DOGE", "SOL", "XRP"])
tcounts["ROT02_rot*"] = rot_rebalances_year()
cols_B = list(tcounts)
print("\n" + "=" * 132)
print(" (B) NUMERO TRADE PER ANNO — fade/DIP01 = ingressi; TR01/ROT02 (*) = ribilanciamenti")
print("=" * 132)
print(f" {'Anno':>5s}" + "".join(f"{c.replace('_',''):>13s}" for c in cols_B))
print(" " + "-" * 126)
for y in YEARS:
print(f" {y:>5d}" + "".join(f"{tcounts[c].get(y, 0):>13d}" for c in cols_B))
print(" " + "-" * 126)
print(f" {'TOT':>5s}" + "".join(f"{sum(tcounts[c].values()):>13d}" for c in cols_B))
# ---- (C) riepilogo portafogli ----
print("\n" + "=" * 92)
print(f" (C) RIEPILOGO PORTAFOGLI | OOS da {OOS_DATE}")
print("=" * 92)
print(f" {'portafoglio':<14s}{'Ret%':>9s}{'CAGR':>7s}{'DD%':>7s}{'Shrp':>7s}"
f" | {'oRet%':>9s}{'oDD%':>7s}{'oShrp':>7s}")
print(" " + "-" * 74)
for name, pr in ports.items():
f, o = metrics(pr), metrics(pr, lo=SPLIT)
print(f" {name:<14s}{f['ret']:>+9.0f}{f['cagr']:>7.0f}{f['dd']:>7.1f}{f['sharpe']:>7.2f}"
f" | {o['ret']:>+9.0f}{o['dd']:>7.1f}{o['sharpe']:>7.2f}")
print("\n MASTEReq (9 sleeve) = configurazione consigliata. (*) TR01/ROT02 = posizione")
print(" continua: il conteggio e' il numero di ribilanciamenti/cambi di stato, non di trade discreti.")
if __name__ == "__main__":
main()
+143
View File
@@ -0,0 +1,143 @@
"""Report riassuntivo: tutte le strategie/famiglie per anno + analisi di integrazione.
Consolida in un solo posto:
(A) RET% NETTO per anno per FAMIGLIA (FADE / HONEST / PAIRS / TSM01) e per i portafogli.
(B) RET% NETTO per anno per ogni STRATEGIA singola (tutti gli sleeve).
(C) INTEGRAZIONE: cosa succede al MASTER aggiungendo le nuove famiglie (pairs, TSM01).
(D) Numeri SOBRI (worst-case) e raccomandazione operativa.
Famiglie:
FADE (reversione intraday 1h, long/short, BTC/ETH): MR01, MR02, MR07
HONEST (long-only multi-regime multi-crypto): DIP01, TR01, ROT02
PAIRS (market-neutral spread reversion, config universale): 5 coppie
TSM01 (TSMOM multi-orizzonte, diversificatore)
Tutto NETTO fee, leva 3x (vedi nota sobria leva 2x), finestra comune 2021-2026, OOS=ultimo 30%.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.combine_portfolio import (
build_all_sleeves, port_returns, metrics, yearly_returns, SPLIT, OOS_DATE, IDX,
)
from scripts.analysis.honest_improve2 import _daily_equity, _norm
from scripts.analysis.pairs_research import pairs_sim
from scripts.analysis.tsmom_research import tsmom_sim
from scripts.strategies.PR01_pairs_reversion import PAIRS
from scripts.analysis.shape_ml_validate import shape_daily_equity
YEARS = sorted(set(IDX.year))
def daily_from(eq_ts, eq_v):
return _norm(_daily_equity(eq_ts, eq_v, IDX))
def build_everything():
S = build_all_sleeves() # 9 sleeve (FADE 6 + HONEST 3)
pairs = {}
for a, b, p in PAIRS:
r = pairs_sim(a, b, **p)
pairs[f"PR_{a}{b}"] = daily_from(r["eq_ts"], r["eq_v"])
t = tsmom_sim()
tsm = {"TSM01": daily_from(t["eq_ts"], t["eq_v"])}
shape = {f"SH_{a}": _norm(shape_daily_equity(a, IDX)) for a in ("BTC", "ETH")}
return S, pairs, tsm, shape
def yrow(label, dr):
yr = yearly_returns(dr)
return f" {label:<14s}" + "".join(f"{yr.get(y, 0):>+9.0f}" for y in YEARS)
def metric_block(label, dr):
f, o = metrics(dr), metrics(dr, lo=SPLIT)
return (f" {label:<16s}{f['ret']:>+9.0f}{f['cagr']:>7.0f}{f['dd']:>7.1f}{f['sharpe']:>7.2f}"
f" | {o['ret']:>+9.0f}{o['dd']:>7.1f}{o['sharpe']:>7.2f}")
def main():
print("Costruzione (puo' richiedere ~2-3 min)...\n")
S, pairs, tsm, shape = build_everything()
fade = {k: v for k, v in S.items() if k.startswith("MR")}
honest = {k: v for k, v in S.items() if not k.startswith("MR")}
fam = {
"FADE": port_returns(fade),
"HONEST": port_returns(honest),
"PAIRS": port_returns(pairs),
"TSM01": tsm["TSM01"].pct_change().fillna(0.0),
"SHAPE": port_returns(shape),
}
master9 = port_returns(S)
master_p = port_returns({**S, **pairs})
master_x = port_returns({**S, **pairs, **tsm})
master_xs = port_returns({**S, **pairs, **tsm, **shape})
# ---------- (A) per anno, per FAMIGLIA + portafogli ----------
print("=" * 110)
print(" (A) RET% NETTO PER ANNO — per FAMIGLIA e per PORTAFOGLIO | leva 3x, fee netta")
print("=" * 110)
print(f" {'':<14s}" + "".join(f"{y:>9d}" for y in YEARS))
print(" " + "-" * 104)
for k, dr in fam.items():
print(yrow(k, dr))
print(" " + "-" * 104)
print(yrow("MASTER-9", master9))
print(yrow("MASTER+pairs", master_p))
print(yrow("MASTER-esteso", master_x))
print(yrow("MASTER+shape", master_xs))
# ---------- (B) per anno, per STRATEGIA singola ----------
print("\n" + "=" * 130)
print(" (B) RET% NETTO PER ANNO — per STRATEGIA singola (tutti gli sleeve)")
print("=" * 130)
allsl = {**S, **pairs, **tsm, **shape}
cols = list(allsl)
print(f" {'Anno':>5s}" + "".join(f"{c.replace('_',''):>11s}" for c in cols))
print(" " + "-" * 124)
yr_each = {k: yearly_returns(v.pct_change().fillna(0.0)) for k, v in allsl.items()}
for y in YEARS:
print(f" {y:>5d}" + "".join(f"{yr_each[c].get(y, 0):>+11.0f}" for c in cols))
# ---------- (C) integrazione ----------
print("\n" + "=" * 96)
print(f" (C) INTEGRAZIONE delle nuove famiglie nel MASTER | OOS da {OOS_DATE} | equal-weight daily")
print("=" * 96)
print(f" {'portafoglio':<16s}{'Ret%':>9s}{'CAGR':>7s}{'DD%':>7s}{'Shrp':>7s}"
f" | {'oRet%':>9s}{'oDD%':>7s}{'oShrp':>7s}")
print(" " + "-" * 80)
print(metric_block("MASTER-9", master9))
print(metric_block("+pairs", master_p))
print(metric_block("+TSM01", port_returns({**S, **tsm})))
print(metric_block("+shape", port_returns({**S, **shape})))
print(metric_block("MASTER-esteso", master_x))
print(metric_block("MASTER+shape", master_xs))
# correlazione media nuove vs master-9
dr_all = pd.DataFrame({k: v.pct_change().fillna(0.0) for k, v in {**S, **pairs, **tsm, **shape}.items()})
corr = dr_all.corr(); old = list(S)
print(" " + "-" * 80)
for k in list(pairs) + list(tsm) + list(shape):
print(f" corr {k:<11s} vs MASTER-9 = {corr.loc[k, old].mean():+.2f}")
# ---------- (D) numeri sobri ----------
print("\n" + "=" * 96)
print(" (D) NUMERI SOBRI / RACCOMANDAZIONE (anti-overfit)")
print("=" * 96)
print(" - L'OOS singolo (2024-25) e' regime calmo -> Sharpe/DD OOS ottimistici ~50%.")
print(" - Numeri onesti del MASTER-esteso: worst-DD 90g ~6%, Sharpe atteso ~5, ogni anno positivo dal 2021.")
print(" - Regge leva 2x + slippage doppio (CAGR ~36%, Sharpe ~5).")
print(" - Rischio concentrato sui PAIRS (~57%) -> cap allocazione pairs ~30-35%.")
print(" - I pairs sono a 2 gambe (long/short): il worker live va esteso prima del trading reale.")
print(" - CONFIG RACCOMANDATA: MASTER-esteso, equal-weight, leva 2x, cap pairs 30-35%.")
if __name__ == "__main__":
main()
+261
View File
@@ -0,0 +1,261 @@
"""Gestione del rischio sulle fade (MR01/MR02/MR03/MR07): alzare Acc, ridurre DD.
Due analisi, ognuna misurata FULL e OOS (ultimo 30%) per non illudersi:
(A) SCREENING LEVE — confronta su ogni strategia le leve di rischio:
- vol-target sizing (size ~ 1/distanza-SL) -> SCARTATA (peggiora)
- skip alta volatilita' (ATR% in coda alta) -> SCARTATA (peggiora)
- filtro trend (|close-EMA200|/ATR oltre soglia) -> ADOTTATA (Acc+ DD-)
- combinazione di tutte
(B) FILTRO TREND + PORTAFOGLIO:
- sweep della soglia trend (assoluta in ATR, regola unica = no overfit)
- portafoglio equipesato su sotto-conti indipendenti: curve poco correlate
-> DD aggregato << DD del singolo sleeve (vera leva anti-drawdown)
Engine fedele: ingresso close[i], exit TP/SL intrabar (high/low) o time-limit,
non-overlap, capitale composto. Numeri NETTI fee 0.10% RT, leva 3x.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from src.data.downloader import load_data
from scripts.analysis.strategy_research import bollinger_fade, atr
from scripts.analysis.strategy_research_v2 import donchian_fade, return_reversal
FEE_RT, LEV, POS, INIT, OOS_FRAC = 0.001, 3.0, 0.15, 1000.0, 0.30
# config base di ogni strategia (come strategies.yml).
# NB: MR03 keltner_fade spostata in scripts/waste/ (fade piu' debole, ridondante
# con MR01); la funzione keltner_fade resta in strategy_research_v2 come record.
STRATS = {
"MR01": (bollinger_fade, dict(n=50, k=2.5, sl_atr=2.0, max_bars=24)),
"MR02": (donchian_fade, dict(n=20, sl_atr=2.0, max_bars=24)),
"MR07": (return_reversal,dict(n=50, k=3.5, tp_atr=2.0, sl_atr=1.5, max_bars=24)),
}
STRATS_ETH = dict(STRATS)
def strats_for(asset: str) -> dict:
return STRATS_ETH if asset == "ETH" else STRATS
# ============================ (A) SCREENING LEVE ============================
def add_context(ents, df, ema_long=200):
"""Aggiunge a ogni entry: sl_dist, atr_pct, trend_dist (|close-EMA|/ATR)."""
c = df["close"].values
a = atr(df, 14)
el = pd.Series(c).ewm(span=ema_long, adjust=False).mean().values
apct = a / c
for e in ents:
i = e["i"]
e["sl_dist"] = abs(c[i] - e["sl"]) / c[i]
e["atr_pct"] = apct[i]
e["trend_dist"] = abs(c[i] - el[i]) / a[i] if a[i] else 0.0
return ents
def simulate(ents, df, fee_rt=FEE_RT, lev=LEV, split=-1,
sizer=None, vol_skip=None, trend_skip=None, max_size=0.30):
"""sizer: funzione(entry)->frazione capitale; default POS fisso.
vol_skip: soglia atr_pct sopra cui salto. trend_skip: soglia trend_dist sopra cui salto."""
h, l, c = df["high"].values, df["low"].values, df["close"].values
n = len(c)
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
cap = peak = INIT
dd = 0.0; last = -1; trd = wins = 0
fee = fee_rt * lev
yearly = {}; rets = []
for e in ents:
i, d = e["i"], e["d"]
if i <= last or i + 1 >= n or i < split:
continue
if vol_skip is not None and e["atr_pct"] > vol_skip:
continue
if trend_skip is not None and e["trend_dist"] > trend_skip:
continue
entry = c[i]; tp, sl, mb = e["tp"], e["sl"], e["max_bars"]
exit_p = c[min(i + mb, n - 1)]; j = min(i + mb, n - 1)
for k in range(1, mb + 1):
j = i + k
if j >= n:
exit_p = c[n - 1]; break
hs = (d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl)
ht = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
if hs: exit_p = sl; break
if ht: exit_p = tp; break
if k == mb: exit_p = c[j]
ret = (exit_p - entry) / entry * d * lev - fee
size = POS if sizer is None else min(sizer(e), max_size)
cap = max(cap + cap * size * ret, 10.0)
peak = max(peak, cap); dd = max(dd, (peak - cap) / peak)
trd += 1; wins += ret > 0; last = j; rets.append(ret * size)
y = ts.iloc[i].year; yearly[y] = yearly.get(y, 0.0) + ret * size * INIT
sharpe = float(np.mean(rets) / np.std(rets) * np.sqrt(len(rets))) if len(rets) > 1 and np.std(rets) > 0 else 0.0
return dict(trades=trd, acc=wins / trd * 100 if trd else 0.0,
ret=(cap / INIT - 1) * 100, dd=dd * 100, yearly=yearly, sharpe=sharpe)
def vol_target_sizer(target=0.015):
"""size t.c. rischio (size*lev*sl_dist) ~ target; piu' largo lo stop, meno size."""
return lambda e: target / (LEV * max(e["sl_dist"], 1e-4))
def _line(label, full, oos):
print(f" {label:<28s}{full['trades']:>6d}{full['acc']:>7.1f}{full['ret']:>+10.0f}{full['dd']:>7.1f}{full['sharpe']:>7.2f}"
f" | {oos['trades']:>5d}{oos['acc']:>7.1f}{oos['ret']:>+9.0f}{oos['dd']:>7.1f}{oos['sharpe']:>7.2f}")
def screen_levers():
print("=" * 110)
print(" (A) SCREENING LEVE — vol-target / vol-skip / filtro-trend | NETTO fee 0.10% RT, leva 3x")
print("=" * 110)
for asset in ["BTC", "ETH"]:
df = load_data(asset, "1h")
split = int(len(df) * (1 - OOS_FRAC))
print(f"\n {asset} 1h")
print(f" {'config':<28s}{'Trd':>6s}{'Acc%':>7s}{'Ret%':>10s}{'DD%':>7s}{'Shrp':>7s}"
f" | {'oTrd':>5s}{'oAcc':>7s}{'oRet':>9s}{'oDD':>7s}{'oShrp':>7s}")
print(" " + "-" * 106)
for nm, (fn, params) in strats_for(asset).items():
ents = add_context(fn(df, **params), df)
p85 = float(np.quantile([e["atr_pct"] for e in ents], 0.85))
t90 = float(np.quantile([e["trend_dist"] for e in ents], 0.90))
_line(f"{nm} base", simulate(ents, df), simulate(ents, df, split=split))
_line(f"{nm} +volTarget", simulate(ents, df, sizer=vol_target_sizer()),
simulate(ents, df, split=split, sizer=vol_target_sizer()))
_line(f"{nm} +volSkip(p85)", simulate(ents, df, vol_skip=p85),
simulate(ents, df, split=split, vol_skip=p85))
_line(f"{nm} +trendSkip(p90)", simulate(ents, df, trend_skip=t90),
simulate(ents, df, split=split, trend_skip=t90))
_line(f"{nm} +ALL", simulate(ents, df, sizer=vol_target_sizer(), vol_skip=p85, trend_skip=t90),
simulate(ents, df, split=split, sizer=vol_target_sizer(), vol_skip=p85, trend_skip=t90))
print(" " + "-" * 106)
print("\n Esito: vol-target e vol-skip PEGGIORANO; il filtro trend e' l'unica leva utile.")
# ===================== (B) FILTRO TREND + PORTAFOGLIO =====================
def build_trades(ents, df, lev=LEV, fee_rt=FEE_RT, trend_max=None, ema_long=200):
"""Lista trade non-overlap: (entry_idx, exit_idx, ret_netto). Filtro trend opzionale."""
h, l, c = df["high"].values, df["low"].values, df["close"].values
n = len(c); a = atr(df, 14)
el = pd.Series(c).ewm(span=ema_long, adjust=False).mean().values
fee = fee_rt * lev
out = []; last = -1
for e in ents:
i, d = e["i"], e["d"]
if i <= last or i + 1 >= n:
continue
if trend_max is not None and a[i] and abs(c[i] - el[i]) / a[i] > trend_max:
continue
entry = c[i]; tp, sl, mb = e["tp"], e["sl"], e["max_bars"]
exit_p = c[min(i + mb, n - 1)]; j = min(i + mb, n - 1)
for k in range(1, mb + 1):
j = i + k
if j >= n:
exit_p = c[n - 1]; break
hs = (d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl)
ht = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
if hs: exit_p = sl; break
if ht: exit_p = tp; break
if k == mb: exit_p = c[j]
ret = (exit_p - entry) / entry * d * lev - fee
out.append((i, j, ret)); last = j
return out
def metrics_single(trades, pos=POS, split=-1):
cap = peak = INIT; dd = 0.0; trd = wins = 0; rets = []
for i, j, ret in trades:
if i < split:
continue
cap = max(cap + cap * pos * ret, 10.0)
peak = max(peak, cap); dd = max(dd, (peak - cap) / peak)
trd += 1; wins += ret > 0; rets.append(ret * pos)
sh = float(np.mean(rets) / np.std(rets) * np.sqrt(len(rets))) if len(rets) > 1 and np.std(rets) > 0 else 0.0
return dict(trades=trd, acc=wins / trd * 100 if trd else 0.0,
ret=(cap / INIT - 1) * 100, dd=dd * 100, sharpe=sh)
def sleeve_equity(trades, n_bars, pos=POS, split=-1):
"""Equity di uno sleeve su sotto-conto indipendente (capitale INIT, pos fissa)."""
eq = np.full(n_bars, INIT, dtype=float)
cap = INIT
for i, j, ret in sorted(trades, key=lambda t: t[1]):
if i < split:
continue
cap = max(cap + cap * pos * ret, 10.0)
eq[j:] = cap
return eq
def metrics_portfolio(strat_trades, n_bars, pos=POS, split=-1):
"""Portafoglio equipesato: media di N sotto-conti indipendenti. DD sull'aggregata."""
sleeves = [sleeve_equity(tr, n_bars, pos=pos, split=split) for tr in strat_trades.values()]
agg = np.mean(sleeves, axis=0)
agg = agg[max(split, 0):]
peak = np.maximum.accumulate(agg)
dd = float(np.max((peak - agg) / peak) * 100)
trd = sum(1 for tr in strat_trades.values() for i, _, _ in tr if i >= split)
wins = sum(1 for tr in strat_trades.values() for i, _, r in tr if i >= split and r > 0)
return dict(trades=trd, acc=wins / trd * 100 if trd else 0.0, ret=(agg[-1] / INIT - 1) * 100, dd=dd)
def trend_and_portfolio():
# --- sweep soglia trend ---
print("\n" + "=" * 104)
print(" (B1) FILTRO TREND |close-EMA200|/ATR > soglia -> SALTA | NETTO fee 0.10% RT, leva 3x")
print("=" * 104)
print(f" {'Strat/Asset':<14s}{'soglia':>8s}{'Trd':>6s}{'Acc%':>7s}{'Ret%':>9s}{'DD%':>7s}"
f" | {'oAcc':>6s}{'oRet':>9s}{'oDD':>7s}{'oShrp':>7s}")
print(" " + "-" * 100)
for asset in ["BTC", "ETH"]:
df = load_data(asset, "1h"); split = int(len(df) * (1 - OOS_FRAC))
for nm, (fn, params) in strats_for(asset).items():
ents = fn(df, **params)
for thr in [None, 4.0, 3.0, 2.5, 2.0]:
tr = build_trades(ents, df, trend_max=thr)
f = metrics_single(tr); o = metrics_single(tr, split=split)
lab = "base" if thr is None else f"{thr}ATR"
print(f" {nm+' '+asset:<14s}{lab:>8s}{f['trades']:>6d}{f['acc']:>7.1f}{f['ret']:>+9.0f}{f['dd']:>7.1f}"
f" | {o['acc']:>6.1f}{o['ret']:>+9.0f}{o['dd']:>7.1f}{o['sharpe']:>7.2f}")
print(" " + "-" * 100)
# --- portafoglio equipesato (filtro trend 3.0 ATR) ---
print("\n" + "=" * 104)
print(" (B2) PORTAFOGLIO equipesato: N sotto-conti indipendenti (pos 0.15, filtro trend 3.0 ATR)")
print("=" * 104)
print(f" {'Universo':<26s}{'Trd':>6s}{'Acc%':>7s}{'Ret%':>10s}{'DD%':>7s}"
f" | {'oAcc':>6s}{'oRet':>9s}{'oDD':>7s}")
print(" " + "-" * 100)
all_trades = {}
for asset in ["BTC", "ETH"]:
df = load_data(asset, "1h"); split = int(len(df) * (1 - OOS_FRAC)); n = len(df)
st = {f"{nm}_{asset}": build_trades(fn(df, **p), df, trend_max=3.0) for nm, (fn, p) in strats_for(asset).items()}
all_trades.update(st)
f = metrics_portfolio(st, n); o = metrics_portfolio(st, n, split=split)
print(f" {'Portafoglio '+asset+' (4 strat)':<26s}{f['trades']:>6d}{f['acc']:>7.1f}{f['ret']:>+10.0f}{f['dd']:>7.1f}"
f" | {o['acc']:>6.1f}{o['ret']:>+9.0f}{o['dd']:>7.1f}")
df0 = load_data("BTC", "1h"); split0 = int(len(df0) * (1 - OOS_FRAC))
f = metrics_portfolio(all_trades, len(df0)); o = metrics_portfolio(all_trades, len(df0), split=split0)
print(" " + "-" * 100)
print(f" {'GLOBALE BTC+ETH (8 sleeve)':<26s}{f['trades']:>6d}{f['acc']:>7.1f}{f['ret']:>+10.0f}{f['dd']:>7.1f}"
f" | {o['acc']:>6.1f}{o['ret']:>+9.0f}{o['dd']:>7.1f}")
print("\n Curve poco correlate => DD aggregato molto piu' basso del singolo sleeve.")
def main():
screen_levers()
trend_and_portfolio()
if __name__ == "__main__":
main()
+177
View File
@@ -0,0 +1,177 @@
"""Ricerca sistematica edge nella FORMA (analog forecasting / kNN) — netto fee, OOS.
Obiettivo: trovare una config di analog forecasting ROBUSTA, cioe' positiva
FULL+OOS, che regge fee 0.20% RT e ha quasi tutti gli anni positivi, su >=2 asset.
Si combatte la "morte per fee" della baseline (BTC1h W24H12K50 agree0.60:
FULL +112%/OOS +48% Sharpe 1.38 ma a 0.2% RT -> FULL -72 / OOS -18, win 49.5%,
esposizione 73.9%, 4531 trade) con SELETTIVITA':
- agree alto (0.70..0.90) -> entra solo con analoghi molto concordi
- conf_atr > 0 -> richiede |rendimento medio analoghi| >= conf_atr*ATR
- trend_max/ema_long -> salta forme in trend estremo
- tp_atr/sl_atr -> exit intrabar invece che solo a tempo
Tutto causale: la forma usa solo close<=i, la libreria analoghi termina < i-H.
Per performance, il forecast kNN grezzo per barra si calcola UNA volta per
(W,H,K,rebuild) con analog_signals(); i filtri (agree/conf/trend/tp/sl) sono
applicati a valle con entries_from_signals() (cheap, risultato identico ad
analog_entries — verificato). Engine netto-fee + OOS da explore_lab.
Uso:
uv run python scripts/analysis/shape_analog_research.py
"""
from __future__ import annotations
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.shape_lab import ( # noqa: E402
analog_signals, entries_from_signals, check_no_lookahead,
)
from scripts.analysis.explore_lab import get_df, evaluate, robust # noqa: E402
ROBUSTE: list[tuple] = []
MIN_TRADES = 100 # un edge "robusto" su <100 trade e' rumore campionario, non edge
def _hdr(s: str) -> None:
print("\n" + "=" * 100, flush=True)
print(" " + s, flush=True)
print("=" * 100, flush=True)
def _eval(df, sig, asset, tf, tag, **filt):
ents = entries_from_signals(df, sig, **filt)
res = evaluate(f"[{asset} {tf}] {tag}", ents, df)
# robusto E con campione sufficiente (un edge su <100 trade non e' affidabile)
if robust(res) and res["full"]["trades"] >= MIN_TRADES:
print(f" ^^^ ROBUSTA ({asset} {tf}): {tag} filt={filt}", flush=True)
ROBUSTE.append((asset, tf, tag, dict(filt), res))
elif robust(res):
print(f" (robust ma trade={res['full']['trades']}<{MIN_TRADES}: campione "
f"insufficiente, ignorato)", flush=True)
return res
def run():
# --- 0) sanity no-lookahead ---------------------------------------------
_hdr("0) SANITY no-lookahead (forma causale)")
df_btc = get_df("BTC", "1h")
check_no_lookahead(df_btc, W=24, H=12)
# sig base W24H12K50 (riusato per selettivita' agree/conf/tp/sl/trend)
sig0 = analog_signals(df_btc, W=24, H=12, K=50, rebuild=250)
# --- 1) selettivita' via agree ------------------------------------------
_hdr("1) BTC 1h — selettivita' agree (W24 H12 K50, time-exit)")
for ag in (0.60, 0.70, 0.80, 0.90):
_eval(df_btc, sig0, "BTC", "1h", f"agree{ag}", agree=ag)
# --- 2) conf_atr (forza segnale) ----------------------------------------
_hdr("2) BTC 1h — conf_atr (W24 H12 K50 agree0.70)")
for ca in (0.0, 0.25, 0.5, 1.0, 1.5):
_eval(df_btc, sig0, "BTC", "1h", f"ag0.70 conf{ca}", agree=0.70, conf_atr=ca)
# --- 3) tp/sl intrabar ---------------------------------------------------
_hdr("3) BTC 1h — exit intrabar tp/sl (W24 H12 K50 agree0.70 conf0.5)")
for tp, sl in [(1.0, 1.0), (1.5, 1.0), (2.0, 1.5), (1.5, 2.0), (3.0, 2.0)]:
_eval(df_btc, sig0, "BTC", "1h", f"tp{tp}sl{sl}",
agree=0.70, conf_atr=0.5, tp_atr=tp, sl_atr=sl)
# --- 4) filtro trend -----------------------------------------------------
_hdr("4) BTC 1h — filtro trend_max (W24 H12 K50 agree0.70 conf0.5)")
for tm in (None, 2.0, 3.0, 4.0):
_eval(df_btc, sig0, "BTC", "1h", f"trend_max{tm}",
agree=0.70, conf_atr=0.5, trend_max=tm, ema_long=200)
# --- 5) griglia W/H/K (agree0.80, time-exit) plateau ---------------------
# Griglia focalizzata: con agree0.80 e H>=24 i trade -> ~0 (vedi sez.1), e W>=24
# porta OOS negativo; il segnale vive su W piccolo, H breve. Testo il plateau
# attorno a quella regione + una banda di controllo (W24/48) per confermare il bordo.
_hdr("5) BTC 1h — griglia W/H/K (agree0.80, time-exit) — plateau check")
for W in (12, 24, 48):
for H in (6, 12, 24):
for K in (30, 50, 80):
sig = analog_signals(df_btc, W=W, H=H, K=K, rebuild=250)
_eval(df_btc, sig, "BTC", "1h", f"W{W}H{H}K{K}", agree=0.80)
# --- 6) rebuild sensitivity ---------------------------------------------
_hdr("6) BTC 1h — rebuild 250 vs 500 (W24 H12 K80 agree0.80)")
for rb in (250, 500):
sig = analog_signals(df_btc, W=24, H=12, K=80, rebuild=rb)
_eval(df_btc, sig, "BTC", "1h", f"rebuild{rb}", agree=0.80)
# --- 7) cross-asset 1h: candidati selettivi -----------------------------
_hdr("7) cross-asset 1h — candidati selettivi (>=2 robusti richiesto)")
# (build_kw: per analog_signals) (filt: per entries_from_signals)
# Su BTC 1h le uniche regioni con OOS positivo che regge fee0.2% sono W piccolo,
# H breve, K basso (W12H12K30: FULL+88/OOS+36, fee0.2% +69/+32, 243 trade, 8/9 anni;
# W12H6K30: +35/+11, fee0.2% +20/+7). conf0.25 con W24H12 e' il miglior in-sample
# ma OOS@fee~0. Verifico questi candidati cross-asset (>=2 robusti richiesto).
candidates = [
("C1 W12H12K30 ag.80", dict(W=12, H=12, K=30), dict(agree=0.80)),
("C2 W12H6K30 ag.80", dict(W=12, H=6, K=30), dict(agree=0.80)),
("C3 W12H12K30 ag.70", dict(W=12, H=12, K=30), dict(agree=0.70)),
("C4 W24H12K50 ag.70 conf.25", dict(W=24, H=12, K=50), dict(agree=0.70, conf_atr=0.25)),
("C5 W12H12K30 ag.80 trend3", dict(W=12, H=12, K=30), dict(agree=0.80, trend_max=3.0, ema_long=200)),
("C6 W12H6K50 ag.70", dict(W=12, H=6, K=50), dict(agree=0.70)),
]
per_cand: dict[str, int] = {}
for asset in ("BTC", "ETH", "ADA", "LTC", "SOL", "XRP"):
try:
df = get_df(asset, "1h")
except Exception as ex:
print(f" [{asset} 1h] SKIP load: {ex}", flush=True)
continue
# cache analog_signals per ogni build_kw distinto su questo asset
sig_cache: dict[tuple, dict] = {}
for tag, bkw, filt in candidates:
key = tuple(sorted(bkw.items()))
if key not in sig_cache:
sig_cache[key] = analog_signals(df, rebuild=250, **bkw)
res = _eval(df, sig_cache[key], asset, "1h", tag, **filt)
if robust(res):
per_cand[tag] = per_cand.get(tag, 0) + 1
# --- 8) verifica 15m dei candidati robusti su >=2 asset 1h --------------
_hdr("8) verifica 15m dei candidati robusti su >=2 asset 1h")
good = [t for t, c in per_cand.items() if c >= 2]
if not good:
print(" Nessun candidato robusto su >=2 asset 1h -> niente verifica 15m.", flush=True)
else:
for tag in good:
_, bkw, filt = next(c for c in candidates if c[0] == tag)
for asset in ("BTC", "ETH"):
try:
df = get_df(asset, "15m")
except Exception as ex:
print(f" [{asset} 15m] SKIP load: {ex}", flush=True)
continue
sig = analog_signals(df, rebuild=250, **bkw)
_eval(df, sig, asset, "15m", f"{tag} (15m)", **filt)
# --- VERDETTO ------------------------------------------------------------
_hdr("VERDETTO")
if ROBUSTE:
agg: dict[str, list] = {}
for asset, tf, tag, filt, res in ROBUSTE:
agg.setdefault(tag, []).append(f"{asset}/{tf}")
print(f" {len(ROBUSTE)} sleeve robusti (FULL+OOS+ fee0.2% + anniPos):", flush=True)
edge = False
for tag, asl in agg.items():
n_assets = len({a.split('/')[0] for a in asl})
mark = " *** EDGE (>=2 asset)" if n_assets >= 2 else " (1 asset: non sufficiente)"
if n_assets >= 2:
edge = True
print(f" - {tag}: {asl}{mark}", flush=True)
if not edge:
print("\n CONCLUSIONE: nessuna config robusta su >=2 asset -> RUMORE.", flush=True)
else:
print(" NESSUNA config robusta. Famiglia analog/forma = RUMORE sotto fee reali.", flush=True)
return ROBUSTE
if __name__ == "__main__":
run()
+328
View File
@@ -0,0 +1,328 @@
"""Edge nella FORMA discreta delle candele -> distribuzione condizionale dell'esito.
Famiglia: encoding DISCRETO della morfologia di una finestra di L candele
(sequenza UP/DOWN/DOJI, opzionalmente arricchita con bucket di body-ratio e
shadow-ratio) -> codice intero. Per ogni codice si stima la distribuzione del
rendimento a H barre usando SOLO le occorrenze PASSATE il cui esito era gia'
realizzato prima della barra di decisione i (expanding window causale). Se un
codice mostra bias direzionale forte e statisticamente solido (n campioni >=
soglia, win-rate o |media| oltre soglia) si ENTRA a close[i] nella direzione del
bias; exit a H barre o TP/SL ATR.
VINCOLI ANTI-LOOK-AHEAD (l'errore squeeze e' nato qui):
- il codice a i usa SOLO open/high/low/close fino alla barra i inclusa;
- la statistica condizionale a i conta SOLO occorrenze del codice terminate in
e con e+H <= i-1 -> il loro esito H e' interamente noto PRIMA di i;
- direzione decisa dal CODICE (forma fino a close[i]) + STATISTICHE PASSATE,
ingresso eseguibile a close[i].
- check_no_lookahead() perturba il futuro: ne' il codice a i ne' le stat usate
devono cambiare.
Riusa l'engine netto-fee + OOS di explore_lab (simulate/evaluate/robust).
Implementazione causale O(N) per codice via accumulatori incrementali (niente
ricalcolo dell'intera storia ad ogni barra).
Asset: ADA BNB BTC DOGE ETH LTC SOL XRP (1h, 15m). Default 1h.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.explore_lab import ( # noqa: E402
get_df, evaluate, robust, simulate, atr, OOS_FRAC,
)
# --------------------------- encoding discreto della forma ---------------------------
def candle_codes(df, L: int, body_buckets: int = 1, shadow_buckets: int = 1) -> np.ndarray:
"""Codice intero della forma per la finestra di L candele terminante in i.
Componenti per ogni candela:
- direzione UP/DOWN/DOJI (sempre): 3 stati.
- bucket del body-ratio |c-o|/(h-l) (se body_buckets>1): quantizzazione fissa
in body_buckets livelli (corpo piccolo/medio/grande...).
- bucket dello shadow-ratio (h-max(o,c)-(min(o,c)-l))/(h-l) in [-1,1]
(se shadow_buckets>1): ombra sup vs inf.
Quantizzazione a SOGLIE FISSE (non quantili): non dipende dal futuro ne' dal
dataset globale -> causale per costruzione. codes[i] dipende solo da
barre [i-L+1 .. i]. Per i < L-1 -> -1 (non valido).
"""
o = df["open"].values; c = df["close"].values
h = df["high"].values; l = df["low"].values
n = len(c)
rng = np.where((h - l) == 0, 1e-12, h - l)
body = np.abs(c - o) / rng # [0,1]
direction = np.where(body < 0.1, 0, # DOJI
np.where(c > o, 1, 2)) # UP=1, DOWN=2 (3 stati: 0,1,2)
# shadow asymmetry in [-1,1]: >0 ombra sup dominante, <0 ombra inf
up_sh = (h - np.maximum(o, c)) / rng
lo_sh = (np.minimum(o, c) - l) / rng
shadow = up_sh - lo_sh
# bucket body (soglie fisse su frazioni del range): 0..body_buckets-1
if body_buckets > 1:
edges_b = np.linspace(0.0, 1.0, body_buckets + 1)[1:-1]
bbk = np.digitize(body, edges_b) # 0..body_buckets-1
else:
bbk = np.zeros(n, dtype=int)
if shadow_buckets > 1:
edges_s = np.linspace(-1.0, 1.0, shadow_buckets + 1)[1:-1]
sbk = np.digitize(shadow, edges_s)
else:
sbk = np.zeros(n, dtype=int)
# simbolo per candela: dir * (body_buckets*shadow_buckets) + bbk*shadow_buckets + sbk
nbb, nsb = body_buckets, shadow_buckets
per_dir = nbb * nsb
sym = direction * per_dir + bbk * nsb + sbk # 0 .. 3*per_dir-1
base = 3 * per_dir
codes = np.full(n, -1, dtype=np.int64)
# codice della finestra L: base-L polinomiale sui simboli [i-L+1 .. i]
acc = np.zeros(n, dtype=np.int64)
for k in range(L):
# contributo della candela a posizione (i-L+1+k): peso base**(L-1-k)
shifted = np.full(n, 0, dtype=np.int64)
shifted[L - 1 - k:] = sym[: n - (L - 1 - k)] if (L - 1 - k) > 0 else sym
acc += shifted * (base ** (L - 1 - k))
codes[L - 1:] = acc[L - 1:]
return codes
def fwd_return(close: np.ndarray, H: int) -> np.ndarray:
out = np.full(len(close), np.nan)
out[: len(close) - H] = (close[H:] - close[:-H]) / close[:-H]
return out
# --------------------------- stima condizionale causale ---------------------------
def shape_entries(df, L=3, H=12, body_buckets=1, shadow_buckets=1,
min_n=30, edge=0.55, min_lib=500,
tp_atr=None, sl_atr=None, fade=False) -> list[dict]:
"""Entries dal bias condizionale del codice di forma (causale, no look-ahead).
L: lunghezza finestra-forma. H: orizzonte = max_bars.
body_buckets/shadow_buckets: granularita' dell'encoding (1 = solo direzione).
min_n: occorrenze passate minime del codice (con esito noto) per fidarsi.
edge: win-rate minimo (frazione di esiti concordi col segno della media) per
entrare; |edge-0.5| e' il margine direzionale.
min_lib: barre minime di storia prima di iniziare a operare.
tp_atr/sl_atr: TP/SL in multipli di ATR (None = solo time-limit H).
Causalita': a barra di decisione i si aggiorna lo stato del codice della
finestra terminata in e = i-1-H (il cui esito fr[e] e' ora noto). Le statistiche
usate per decidere a i derivano quindi solo da occorrenze con e+H <= i-1.
"""
close = df["close"].values
n = len(close)
a = atr(df, 14)
codes = candle_codes(df, L, body_buckets, shadow_buckets)
fr = fwd_return(close, H)
# accumulatori per codice: somma rendimenti, n positivi, n totali
from collections import defaultdict
cnt = defaultdict(int)
pos = defaultdict(int)
ssum = defaultdict(float)
entries: list[dict] = []
for i in range(min_lib, n - 1):
# aggiorna lo stato col codice la cui finestra termina in e = i-1-H
e = i - 1 - H
if e >= L - 1:
ce = codes[e]
re = fr[e]
if ce >= 0 and not np.isnan(re):
cnt[ce] += 1
pos[ce] += 1 if re > 0 else 0
ssum[ce] += re
ci = codes[i]
if ci < 0:
continue
ntot = cnt.get(ci, 0)
if ntot < min_n:
continue
mean = ssum[ci] / ntot
wr_up = pos[ci] / ntot # frazione esiti positivi nel passato
d = 1 if mean > 0 else -1
# win-rate nella direzione scelta
wr = wr_up if d == 1 else (1.0 - wr_up)
if wr < edge:
continue
if fade:
d = -d # FADE: entra contro il bias storico
ent = {"i": i, "d": d, "max_bars": H}
if tp_atr is not None and a[i] > 0:
ent["tp"] = close[i] + d * tp_atr * a[i]
if sl_atr is not None and a[i] > 0:
ent["sl"] = close[i] - d * sl_atr * a[i]
entries.append(ent)
return entries
# --------------------------- verifica no look-ahead ---------------------------
def check_no_lookahead(df, L=3, H=12, body_buckets=2, shadow_buckets=2) -> bool:
"""Perturbare il FUTURO (>i) non cambia (a) il codice a i, (b) le stat usate a i.
(a) codes[i] dipende solo da barre <= i.
(b) le entries fino a i (incluse) non cambiano se stravolgo le barre > i+1.
"""
n = len(df)
i = n // 2
codes0 = candle_codes(df, L, body_buckets, shadow_buckets)
df2 = df.copy()
fut = slice(i + 1, n)
for col in ("open", "high", "low", "close"):
df2.loc[df2.index[i + 1:], col] = df2[col].values[i + 1:] * 1.5
codes1 = candle_codes(df2, L, body_buckets, shadow_buckets)
ok_code = bool(codes0[i] == codes1[i] and np.array_equal(codes0[: i + 1], codes1[: i + 1]))
# entries: confronta quelle con indice <= i-1-H (decise con stat tutte note prima del futuro)
e0 = shape_entries(df, L=L, H=H, body_buckets=body_buckets, shadow_buckets=shadow_buckets,
min_n=5, edge=0.50, min_lib=200)
e1 = shape_entries(df2, L=L, H=H, body_buckets=body_buckets, shadow_buckets=shadow_buckets,
min_n=5, edge=0.50, min_lib=200)
cutoff = i - 1 - H
s0 = {(x["i"], x["d"]) for x in e0 if x["i"] <= cutoff}
s1 = {(x["i"], x["d"]) for x in e1 if x["i"] <= cutoff}
ok_ent = (s0 == s1)
print(f" no-lookahead codice a i={i}: {'OK' if ok_code else 'VIOLATO'}; "
f"entries<=i-1-H invarianti: {'OK' if ok_ent else 'VIOLATO'} "
f"({len(s0)} vs {len(s1)})")
return ok_code and ok_ent
# --------------------------- run riproducibile ---------------------------
def predictive_power(df, L=3, H=12, body_buckets=1, shadow_buckets=1, min_n=30, min_lib=500):
"""Diagnostica ONESTA: la direzione predetta dal bias storico (causale) anticipa
il segno del rendimento realizzato? Misura hit-rate aggregato della predizione
(segno media passata del codice) vs realizzato, su tutte le barre operabili.
Niente fee: pura capacita' predittiva del codice di forma."""
close = df["close"].values
n = len(close)
codes = candle_codes(df, L, body_buckets, shadow_buckets)
fr = fwd_return(close, H)
from collections import defaultdict
cnt = defaultdict(int); pos = defaultdict(int); ssum = defaultdict(float)
hits = tot = 0
pred_ret = 0.0
for i in range(min_lib, n - 1):
e = i - 1 - H
if e >= L - 1:
ce = codes[e]; re = fr[e]
if ce >= 0 and not np.isnan(re):
cnt[ce] += 1; pos[ce] += 1 if re > 0 else 0; ssum[ce] += re
ci = codes[i]
if ci < 0 or cnt.get(ci, 0) < min_n or np.isnan(fr[i]):
continue
d = 1 if ssum[ci] / cnt[ci] > 0 else -1
actual = fr[i]
hits += (np.sign(actual) == d); tot += 1
pred_ret += d * actual # PnL teorico senza fee
hr = hits / tot * 100 if tot else 0.0
print(f" predittivita' L{L}H{H} b{body_buckets}s{shadow_buckets}: "
f"hit={hr:.2f}% su {tot} (50%=rumore) | PnL_grezzo_noFee={pred_ret*100:+.0f}%")
return hr
def run():
print("=" * 100)
print(" SHAPE_CANDLE_RESEARCH — encoding discreto forma -> bias condizionale | netto fee, OOS")
print("=" * 100)
df_btc = get_df("BTC", "1h")
print("\n[causalita']")
check_no_lookahead(df_btc, L=3, H=12, body_buckets=2, shadow_buckets=2)
# ----- sweep base BTC/ETH 1h: solo direzione (body=shadow=1) -----
print("\n[BTC/ETH 1h] solo direzione UP/DOWN/DOJI (body=1,shadow=1), time-exit a H:")
for asset in ("BTC", "ETH"):
df = get_df(asset, "1h")
print(f" -- {asset} 1h --")
for L in (2, 3, 4):
for H in (6, 12, 24):
ents = shape_entries(df, L=L, H=H, min_n=30, edge=0.55)
evaluate(f"dir L{L}H{H}", ents, df)
# ----- encoding arricchito body+shadow -----
print("\n[BTC/ETH 1h] encoding arricchito (body=2,shadow=2), time-exit a H:")
for asset in ("BTC", "ETH"):
df = get_df(asset, "1h")
print(f" -- {asset} 1h --")
for L in (2, 3):
for H in (6, 12, 24):
ents = shape_entries(df, L=L, H=H, body_buckets=2, shadow_buckets=2,
min_n=30, edge=0.55)
evaluate(f"rich L{L}H{H} b2s2", ents, df)
# ----- selettivita': soglie edge piu' alte, meno trade -----
print("\n[BTC/ETH 1h] selettivo (edge>=0.58, min_n>=50), dir-only:")
for asset in ("BTC", "ETH"):
df = get_df(asset, "1h")
print(f" -- {asset} 1h --")
for L in (3, 4, 5):
for H in (12, 24):
ents = shape_entries(df, L=L, H=H, min_n=50, edge=0.58)
evaluate(f"sel L{L}H{H} e58", ents, df)
# ----- con TP/SL ATR (gestione rischio) sui candidati piu' attivi -----
print("\n[BTC/ETH 1h] con TP/SL ATR (tp=2,sl=1.5), dir-only L3:")
for asset in ("BTC", "ETH"):
df = get_df(asset, "1h")
for H in (12, 24):
ents = shape_entries(df, L=3, H=H, min_n=30, edge=0.55, tp_atr=2.0, sl_atr=1.5)
evaluate(f"{asset} tpsl L3H{H}", ents, df)
# ----- DIAGNOSTICA: il codice di forma ha QUALSIASI potere predittivo? -----
print("\n[DIAGNOSTICA] hit-rate predizione (segno bias storico vs realizzato), senza fee:")
for asset in ("BTC", "ETH"):
df = get_df(asset, "1h")
print(f" -- {asset} 1h --")
for L in (2, 3, 4):
for H in (6, 12, 24):
predictive_power(df, L=L, H=H, min_n=30)
for L in (2, 3):
predictive_power(df, L=L, H=12, body_buckets=2, shadow_buckets=2, min_n=30)
# ----- IPOTESI FADE: il bias e' anti-predittivo (mean-reversion)? -----
print("\n[FADE del bias] entra CONTRO il bias storico (dir-only):")
for asset in ("BTC", "ETH"):
df = get_df(asset, "1h")
print(f" -- {asset} 1h --")
for L in (2, 3):
for H in (6, 12, 24):
ents = shape_entries(df, L=L, H=H, min_n=30, edge=0.55, fade=True)
evaluate(f"FADE L{L}H{H}", ents, df)
def run_extended(configs):
"""Valuta config candidate su tutti gli asset 1h+15m e stampa robustezza."""
assets = ["ADA", "BNB", "BTC", "DOGE", "ETH", "LTC", "SOL", "XRP"]
for cfg in configs:
print(f"\n[ESTESO] config {cfg}")
for tf in ("1h", "15m"):
print(f" -- timeframe {tf} --")
nrob = 0
for asset in assets:
try:
df = get_df(asset, tf)
except Exception as ex:
print(f" {asset}: skip ({ex})")
continue
ents = shape_entries(df, **cfg)
res = evaluate(f"{asset} {tf}", ents, df)
nrob += robust(res)
print(f" -> robuste {nrob}/{len(assets)} su {tf}")
if __name__ == "__main__":
run()
+257
View File
@@ -0,0 +1,257 @@
"""Harness ONESTO per pattern *di forma* -> previsione dell'andamento successivo.
Idea (analog forecasting / nearest-neighbour sulla FORMA del prezzo):
- a ogni barra i guardo la forma recente W (closes z-normalizzati fino a close[i]);
- cerco nel PASSATO le K finestre piu' simili la cui forma si era gia' conclusa
*e* il cui esito a H barre era gia' noto PRIMA di i (nessun look-ahead);
- prevedo la direzione dei prossimi H barre = segno del rendimento medio degli
analoghi; entro a close[i] se l'accordo fra analoghi e' abbastanza forte.
Vincoli anti-look-ahead (gli stessi della famiglia squeeze fallita):
- la forma usa SOLO closes fino a close[i];
- la libreria di analoghi a decisione i contiene solo finestre che terminano in
e con e+H <= i-1 -> il loro esito e' interamente realizzato *prima* della barra i;
- ingresso eseguibile a close[i]; exit TP/SL intrabar o time-limit H.
Riusa l'engine netto-fee + OOS di explore_lab (simulate/evaluate/robust).
KDTree ricostruito ogni `rebuild` barre (causale): query O(log N), niente O(N^2).
Asset: ADA BNB BTC DOGE ETH LTC SOL XRP (1h, 15m; BTC/ETH anche 5m).
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
from numpy.lib.stride_tricks import sliding_window_view
from scipy.spatial import cKDTree
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.explore_lab import ( # noqa: E402
get_df, evaluate, robust, simulate, atr, ema, rsi, _dt, OOS_FRAC, ASSETS, FEE_RT,
)
# --------------------------- forma normalizzata ---------------------------
def znorm_windows(close: np.ndarray, W: int) -> tuple[np.ndarray, np.ndarray]:
"""Matrice delle finestre z-normalizzate per FORMA.
Ritorna (M, ends) dove M[k] = z-norm(close[e-W+1 .. e]) e ends[k] = e.
Z-norm per forma: (w - media)/std -> invariante a livello e scala -> confronto
sulla sola morfologia. Le finestre piatte (std=0) hanno norm tutta a 0.
"""
if len(close) < W:
return np.empty((0, W)), np.empty(0, dtype=int)
wins = sliding_window_view(close, W) # (N-W+1, W), wins[k] = close[k..k+W-1]
mu = wins.mean(axis=1, keepdims=True)
sd = wins.std(axis=1, keepdims=True)
sd = np.where(sd == 0, 1.0, sd)
M = (wins - mu) / sd
ends = np.arange(W - 1, len(close)) # finestra k termina in e = k+W-1
return M, ends
def fwd_return(close: np.ndarray, H: int) -> np.ndarray:
"""Rendimento forward a H barre per ogni indice: (close[i+H]-close[i])/close[i].
NaN dove i+H esce dai dati (non usabile come esito)."""
out = np.full(len(close), np.nan)
out[: len(close) - H] = (close[H:] - close[:-H]) / close[:-H]
return out
# --------------------------- analog forecasting causale ---------------------------
def analog_entries(df, W=24, H=12, K=50, rebuild=250, min_lib=800,
agree=0.60, conf_atr=0.0, tp_atr=None, sl_atr=None,
trend_max=None, ema_long=200) -> list[dict]:
"""Entries da nearest-neighbour sulla FORMA (causale, no look-ahead).
W: lunghezza finestra-forma. H: orizzonte previsione (= max_bars). K: n. analoghi.
rebuild: ogni quante barre si ricostruisce il KDTree (libreria cresce nel tempo).
min_lib: barre minime di storia prima di iniziare a operare.
agree: frazione minima di analoghi concordi sul segno per entrare (>0.5).
conf_atr: soglia |rendimento medio analoghi| in multipli di ATR-equivalente (0=off).
tp_atr/sl_atr: take-profit/stop in multipli di ATR (None = solo time-limit H).
trend_max: salta se |close-EMA(ema_long)|/ATR14 > trend_max (filtro trend, None=off).
"""
close = df["close"].values
n = len(close)
a = atr(df, 14)
M, ends = znorm_windows(close, W) # forme z-norm e indice di fine
end_pos = {int(e): k for k, e in enumerate(ends)}
fr = fwd_return(close, H) # esito H-barre per ogni indice
el = None
if trend_max is not None:
el = ema(close, ema_long)
entries: list[dict] = []
tree = None
lib_idx = None # indici e (fine finestra) nella libreria
next_rebuild = 0
for i in range(min_lib, n - 1):
# libreria causale: finestre la cui forma E il cui esito H sono < i
if tree is None or i >= next_rebuild:
eligible = ends[(ends <= i - 1 - H) & (ends >= W - 1)]
# esito noto e finito (fr non-NaN garantito da e+H <= i-1 < n)
eligible = eligible[~np.isnan(fr[eligible])]
if len(eligible) < max(K * 3, 200):
next_rebuild = i + rebuild
continue
tree = cKDTree(M[[end_pos[int(e)] for e in eligible]])
lib_idx = eligible
next_rebuild = i + rebuild
if tree is None:
continue
q = M[end_pos[i]]
if not np.isfinite(q).all():
continue
kk = min(K, len(lib_idx))
_, nn = tree.query(q, k=kk)
nn = np.atleast_1d(nn)
outs = fr[lib_idx[nn]] # rendimenti H-barre degli analoghi
outs = outs[~np.isnan(outs)]
if len(outs) < 5:
continue
mean_out = float(outs.mean())
d = 1 if mean_out > 0 else -1
frac = float(np.mean(np.sign(outs) == d))
if frac < agree:
continue
if conf_atr > 0:
if not (abs(mean_out) * close[i] >= conf_atr * a[i]):
continue
if trend_max is not None and a[i] > 0:
if abs(close[i] - el[i]) / a[i] > trend_max:
continue
e = {"i": i, "d": d, "max_bars": H}
if tp_atr is not None and a[i] > 0:
e["tp"] = close[i] + d * tp_atr * a[i]
if sl_atr is not None and a[i] > 0:
e["sl"] = close[i] - d * sl_atr * a[i]
entries.append(e)
return entries
# --------------------------- kNN grezzo cacheable (perf) ---------------------------
def analog_signals(df, W=24, H=12, K=50, rebuild=250, min_lib=800) -> dict:
"""Calcola UNA volta il forecast kNN grezzo per barra (causale), riusabile da
piu' filtri (agree/conf_atr/trend/tp/sl) senza ri-eseguire la query costosa.
Ritorna dict con array allineati per le barre che hanno un forecast valido:
i : indice barra (ingresso eseguibile a close[i])
mean_out : rendimento H-barre medio degli analoghi
frac : frazione di analoghi concordi col segno di mean_out (>=0.5)
d : segno previsto (+1/-1)
Identico, riga per riga, alla logica di analog_entries (stessa libreria causale,
stessa query, stessa soglia len(outs)>=5) ma SENZA i filtri di selezione.
"""
close = df["close"].values
n = len(close)
M, ends = znorm_windows(close, W)
end_pos = {int(e): k for k, e in enumerate(ends)}
fr = fwd_return(close, H)
out_i: list[int] = []
out_mean: list[float] = []
out_frac: list[float] = []
out_d: list[int] = []
tree = None
lib_idx = None
next_rebuild = 0
for i in range(min_lib, n - 1):
if tree is None or i >= next_rebuild:
eligible = ends[(ends <= i - 1 - H) & (ends >= W - 1)]
eligible = eligible[~np.isnan(fr[eligible])]
if len(eligible) < max(K * 3, 200):
next_rebuild = i + rebuild
continue
tree = cKDTree(M[[end_pos[int(e)] for e in eligible]])
lib_idx = eligible
next_rebuild = i + rebuild
if tree is None:
continue
q = M[end_pos[i]]
if not np.isfinite(q).all():
continue
kk = min(K, len(lib_idx))
_, nn = tree.query(q, k=kk)
nn = np.atleast_1d(nn)
outs = fr[lib_idx[nn]]
outs = outs[~np.isnan(outs)]
if len(outs) < 5:
continue
mean_out = float(outs.mean())
d = 1 if mean_out > 0 else -1
frac = float(np.mean(np.sign(outs) == d))
out_i.append(i); out_mean.append(mean_out); out_frac.append(frac); out_d.append(d)
return {
"i": np.asarray(out_i, dtype=int),
"mean_out": np.asarray(out_mean, dtype=float),
"frac": np.asarray(out_frac, dtype=float),
"d": np.asarray(out_d, dtype=int),
"H": H,
}
def entries_from_signals(df, sig: dict, agree=0.60, conf_atr=0.0,
tp_atr=None, sl_atr=None, trend_max=None, ema_long=200) -> list[dict]:
"""Applica i filtri di selezione al forecast grezzo di analog_signals (cheap).
Risultato identico ad analog_entries con gli stessi parametri (stesso W/H/K/rebuild
usati per costruire sig)."""
close = df["close"].values
a = atr(df, 14)
H = sig["H"]
el = ema(close, ema_long) if trend_max is not None else None
entries: list[dict] = []
for k in range(len(sig["i"])):
i = int(sig["i"][k]); d = int(sig["d"][k])
if sig["frac"][k] < agree:
continue
if conf_atr > 0 and not (abs(sig["mean_out"][k]) * close[i] >= conf_atr * a[i]):
continue
if trend_max is not None and a[i] > 0 and abs(close[i] - el[i]) / a[i] > trend_max:
continue
e = {"i": i, "d": d, "max_bars": H}
if tp_atr is not None and a[i] > 0:
e["tp"] = close[i] + d * tp_atr * a[i]
if sl_atr is not None and a[i] > 0:
e["sl"] = close[i] - d * sl_atr * a[i]
entries.append(e)
return entries
# --------------------------- verifica no look-ahead ---------------------------
def check_no_lookahead(df, W=24, H=12) -> bool:
"""La forma a i deve restare invariata se perturbo il FUTURO (>i).
Conferma che znorm_windows usa solo close fino a i."""
close = df["close"].values.copy()
M0, ends = znorm_windows(close, W)
pos = {int(e): k for k, e in enumerate(ends)}
i = len(close) // 2
q0 = M0[pos[i]].copy()
close2 = close.copy()
close2[i + 1:] *= 1.5 # stravolgo il futuro
M1, _ = znorm_windows(close2, W)
q1 = M1[pos[i]]
ok = np.allclose(q0, q1)
print(f" no-lookahead forma a i={i}: {'OK' if ok else 'VIOLATO'} "
f"(max diff {np.max(np.abs(q0 - q1)):.2e})")
return ok
if __name__ == "__main__":
print("=" * 92)
print(" SHAPE_LAB — baseline analog forecasting (kNN sulla forma) | netto fee, OOS")
print("=" * 92)
df = get_df("BTC", "1h")
check_no_lookahead(df)
print("\n BTC 1h — sweep base W/H/K (time-exit a H barre):")
for W, H, K in [(24, 12, 50), (24, 24, 50), (48, 24, 80), (12, 6, 40), (48, 48, 100)]:
ents = analog_entries(df, W=W, H=H, K=K, agree=0.60)
evaluate(f"analog W{W}H{H}K{K}", ents, df)
+431
View File
@@ -0,0 +1,431 @@
"""SHAPE-as-FEATURES research: l'edge e' nella FORMA del segnale?
Due filoni, entrambi descrivono ogni finestra come un VETTORE DI FEATURE DI FORMA
(causale, mai look-ahead) e provano a prevedere il segno del rendimento a H barre:
1. ANALOG nello spazio FEATURE (kNN causale). Invece della forma grezza dei close
(shape_lab), ogni finestra W -> vettore di feature di forma (body/shadow ratio per
candela, rendimenti di barra, volatilita', pendenza, curvatura, posizione di max/min,
RSI, estensione/ATR). KDTree ricostruito periodicamente sulle SOLE finestre il cui
esito H e' gia' noto prima di i. Previsione = segno del rendimento medio dei K vicini.
2. ML WALK-FORWARD sulla forma. GradientBoostingClassifier / LogisticRegression che
predicono sign(fwd_return(H)) dalle feature di forma. Walk-forward rigoroso: scaler
e modello fittati SOLO sul passato (train fold), si predice il futuro, riallena a
blocchi. Entra a close[i] solo se la probabilita' supera una soglia (selettivita').
Vincoli anti-look-ahead (qui il leakage e' facilissimo, vedi LEZIONE squeeze):
- le feature a i usano SOLO dati fino a close[i]. Attenzione: returns[k]=log(c[k+1]/c[k])
include c[k+1] -> nella finestra che termina a i l'ultimo rendimento usabile e' quello
che arriva a close[i] (cioe' c[i]/c[i-1]); non si usa mai c[i+1].
- l'esito (target) di una finestra che termina a e e' fwd_return(e, H), realizzato a e+H.
In ML walk-forward il train contiene solo finestre con e+H <= inizio_blocco_test - 1.
In kNN la libreria contiene solo finestre con e+H <= i-1.
- scaler/modello fittati SOLO sul train passato, MAI sull'intero dataset.
- ingresso eseguibile a close[i]; exit TP/SL intrabar o time-limit H (engine explore_lab).
- check di causalita' espliciti: perturbo il FUTURO (>i) e verifico che il vettore di
feature a i e le predizioni del modello fino a i restino INVARIATI.
Netto fee 0.10% RT baseline + sweep fino a 0.20% RT, leva 3x, pos 0.15, OOS ultimo 30%.
Robustezza su griglia + >=2 asset. Conta il PnL NETTO-fee, non l'accuracy.
Run: uv run python scripts/analysis/shape_ml_research.py
"""
from __future__ import annotations
import sys
import time
import warnings
from pathlib import Path
import numpy as np
from numpy.lib.stride_tricks import sliding_window_view
from scipy.spatial import cKDTree
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.explore_lab import ( # noqa: E402
get_df, evaluate, robust, simulate, atr, ema, rsi, OOS_FRAC,
)
warnings.filterwarnings("ignore")
from sklearn.ensemble import GradientBoostingClassifier # noqa: E402
from sklearn.linear_model import LogisticRegression # noqa: E402
from sklearn.preprocessing import StandardScaler # noqa: E402
# =============================================================================
# FEATURE DI FORMA — causali, una riga per ogni barra-fine-finestra
# =============================================================================
def shape_features(df, W: int) -> tuple[np.ndarray, np.ndarray]:
"""Matrice di feature di FORMA per ogni finestra di W candele.
Ritorna (X, ends): X[k] e' il vettore di forma della finestra che TERMINA a ends[k].
Tutte le feature usano solo o/h/l/c[ends[k]-W+1 .. ends[k]] -> causali per costruzione.
Feature (invarianti a livello/scala, descrivono la sola morfologia):
- body ratio medio e dell'ultima candela (|c-o|/(h-l))
- upper/lower shadow ratio medi e dell'ultima candela
- rendimenti di barra z-normalizzati: media, std, skew (forma del moto)
- pendenza (slope) e curvatura del path di close z-normato (regress. lineare/quad.)
- posizione del max e del min nella finestra (0..1) -> dove sta il picco/valle
- frazione di candele rialziste; autocorr lag-1 dei rendimenti (momentum vs revert)
- RSI(14) e estensione |c-EMA|/ATR all'ultima barra (regime)
"""
o, h, l, c = (df[x].values.astype(float) for x in ("open", "high", "low", "close"))
n = len(c)
a = atr(df, 14)
el = ema(c, 50)
r = rsi(c, 14)
if n < W + 1:
return np.empty((0, 0)), np.empty(0, dtype=int)
# finestre OHLC che terminano a e = k+W-1, per k=0..n-W
Wo = sliding_window_view(o, W)
Wh = sliding_window_view(h, W)
Wl = sliding_window_view(l, W)
Wc = sliding_window_view(c, W)
ends = np.arange(W - 1, n)
total = Wh - Wl
total = np.where(total <= 0, 1e-12, total)
body = np.abs(Wc - Wo) / total
up_sh = (Wh - np.maximum(Wo, Wc)) / total
lo_sh = (np.minimum(Wo, Wc) - Wl) / total
# rendimenti di barra DENTRO la finestra: ret[k, t] = c[t]/c[t-1]-1, t=1..W-1
# usano solo close fino alla fine della finestra -> causali
ret = Wc[:, 1:] / np.where(Wc[:, :-1] == 0, 1e-12, Wc[:, :-1]) - 1.0
rmu = ret.mean(axis=1)
rsd = ret.std(axis=1) + 1e-12
rz = (ret - rmu[:, None]) / rsd[:, None]
rskew = (rz ** 3).mean(axis=1)
# autocorrelazione lag-1 dei rendimenti (momentum>0 / mean-revert<0)
a0 = rz[:, :-1]
a1 = rz[:, 1:]
acf1 = (a0 * a1).mean(axis=1)
# path z-normato dei close -> slope (lin) e curvatura (quad)
czmu = Wc.mean(axis=1, keepdims=True)
czsd = Wc.std(axis=1, keepdims=True)
czsd = np.where(czsd == 0, 1.0, czsd)
cz = (Wc - czmu) / czsd
t = np.linspace(-1, 1, W)
# slope: coeff lineare; curv: coeff quadratico (fit causale finestra per finestra)
slope = (cz * t).mean(axis=1) / (t * t).mean()
t2 = t * t
t2c = t2 - t2.mean()
curv = (cz * t2c).mean(axis=1) / (t2c * t2c).mean()
argmax = Wc.argmax(axis=1) / (W - 1)
argmin = Wc.argmin(axis=1) / (W - 1)
frac_up = (Wc > Wo).mean(axis=1)
rsi_end = r[ends]
aa = a[ends]
ext = np.where(aa > 0, (c[ends] - el[ends]) / np.where(aa > 0, aa, 1.0), 0.0)
X = np.column_stack([
body.mean(axis=1), body[:, -1],
up_sh.mean(axis=1), up_sh[:, -1],
lo_sh.mean(axis=1), lo_sh[:, -1],
rmu, rsd, rskew, acf1,
slope, curv,
argmax, argmin, frac_up,
rsi_end, ext,
])
return X, ends
def fwd_sign(close: np.ndarray, H: int) -> tuple[np.ndarray, np.ndarray]:
"""fwd_return a H barre e suo segno (+1/-1). NaN/0 dove i+H esce dai dati."""
fr = np.full(len(close), np.nan)
fr[: len(close) - H] = (close[H:] - close[:-H]) / close[:-H]
sgn = np.where(fr > 0, 1, -1).astype(float)
sgn[np.isnan(fr)] = np.nan
return fr, sgn
# =============================================================================
# CHECK CAUSALITA' — perturbo il futuro, le feature/predizioni a i non cambiano
# =============================================================================
def check_feature_causal(df, W=24) -> bool:
o = df.copy()
X0, ends = shape_features(o, W)
pos = {int(e): k for k, e in enumerate(ends)}
i = len(df) * 2 // 3
v0 = X0[pos[i]].copy()
o2 = df.copy()
for col in ("open", "high", "low", "close"):
o2.loc[i + 1:, col] = o2.loc[i + 1:, col] * 1.7 # stravolgi il futuro
X1, _ = shape_features(o2, W)
v1 = X1[pos[i]]
ok = np.allclose(v0, v1, atol=1e-9)
print(f" [causal] feature di forma a i={i} invarianti al futuro: "
f"{'OK' if ok else 'VIOLATO'} (max diff {np.nanmax(np.abs(v0 - v1)):.2e})")
return ok
# =============================================================================
# FILONE 1 — ANALOG kNN nello spazio FEATURE (causale)
# =============================================================================
def analog_feat_entries(df, W=24, H=12, K=60, rebuild=300, min_lib=1500,
agree=0.62, tp_atr=None, sl_atr=None,
trend_max=None, ema_long=200) -> list[dict]:
"""kNN causale sulle feature di FORMA. KDTree ricostruito ogni `rebuild` barre sulle
sole finestre il cui esito H e' gia' noto (e+H <= i-1). Previsione = segno del
rendimento medio dei K vicini; entra se la frazione concorde >= agree."""
c = df["close"].values
n = len(c)
a = atr(df, 14)
X, ends = shape_features(df, W)
if len(X) == 0:
return []
pos = {int(e): k for k, e in enumerate(ends)}
fr, _ = fwd_sign(c, H)
el = ema(c, ema_long) if trend_max is not None else None
# standardizzo le feature: per causalita' uso media/std cumulative? No: lo scaler
# globale userebbe il futuro. Uso uno scaler RICALCOLATO sulla libreria a ogni rebuild.
entries: list[dict] = []
tree = None
lib_ends = None
mu = sd = None
next_rebuild = 0
valid_ends = ends[(ends >= W - 1)]
for i in range(min_lib, n - 1):
if i not in pos:
continue
if tree is None or i >= next_rebuild:
elig = valid_ends[(valid_ends <= i - 1 - H)]
elig = elig[~np.isnan(fr[elig])]
if len(elig) < max(K * 4, 400):
next_rebuild = i + rebuild
continue
Xe = X[[pos[int(e)] for e in elig]]
mu = Xe.mean(axis=0)
sd = Xe.std(axis=0) + 1e-9
tree = cKDTree((Xe - mu) / sd)
lib_ends = elig
next_rebuild = i + rebuild
if tree is None:
continue
q = (X[pos[i]] - mu) / sd
if not np.isfinite(q).all():
continue
kk = min(K, len(lib_ends))
_, nn = tree.query(q, k=kk)
nn = np.atleast_1d(nn)
outs = fr[lib_ends[nn]]
outs = outs[~np.isnan(outs)]
if len(outs) < 10:
continue
d = 1 if outs.mean() > 0 else -1
frac = float(np.mean(np.sign(outs) == d))
if frac < agree:
continue
if trend_max is not None and a[i] > 0 and abs(c[i] - el[i]) / a[i] > trend_max:
continue
e = {"i": i, "d": d, "max_bars": H}
if tp_atr is not None and a[i] > 0:
e["tp"] = c[i] + d * tp_atr * a[i]
if sl_atr is not None and a[i] > 0:
e["sl"] = c[i] - d * sl_atr * a[i]
entries.append(e)
return entries
# =============================================================================
# FILONE 2 — ML WALK-FORWARD sulla forma
# =============================================================================
def ml_wf_entries(df, W=24, H=12, model="gb", thresh=0.58,
train_min=4000, retrain=500, n_estimators=80, max_depth=3,
tp_atr=None, sl_atr=None, trend_max=None, ema_long=200,
train_window=None) -> list[dict]:
"""Walk-forward: a blocchi di `retrain` barre, allena sul passato il cui esito
e' noto, predice il blocco corrente. Scaler+modello fittati solo sul train.
Entra a close[i] se proba della classe predetta >= thresh. model in {gb, logit}.
train_window: se None -> expanding (tutto il passato); se int -> ROLLING (solo le
ultime train_window barre prima del blocco) -> test di robustezza piu' severo."""
c = df["close"].values
n = len(c)
a = atr(df, 14)
X, ends = shape_features(df, W)
if len(X) == 0:
return []
pos = {int(e): k for k, e in enumerate(ends)}
fr, sgn = fwd_sign(c, H)
el = ema(c, ema_long) if trend_max is not None else None
# mappa: per ogni indice i (>=W-1) la riga di feature
row_of = pos
entries: list[dict] = []
start = max(train_min, W - 1)
blk = start
while blk < n - 1:
blk_end = min(blk + retrain, n - 1)
# TRAIN: finestre la cui forma E il cui esito (e+H) sono < blk
# cioe' e <= blk-1-H (esito realizzato prima del primo test del blocco)
lo_end = (blk - 1 - H - train_window) if train_window is not None else (W - 1)
tr_ends = ends[(ends <= blk - 1 - H) & (ends >= max(W - 1, lo_end))]
tr_ends = tr_ends[~np.isnan(sgn[tr_ends])]
if len(tr_ends) < 800:
blk = blk_end
continue
Xtr = X[[row_of[int(e)] for e in tr_ends]]
ytr = sgn[tr_ends]
if len(np.unique(ytr)) < 2:
blk = blk_end
continue
scaler = StandardScaler().fit(Xtr)
Xtr_s = scaler.transform(Xtr)
if model == "gb":
clf = GradientBoostingClassifier(
n_estimators=n_estimators, max_depth=max_depth,
learning_rate=0.05, subsample=0.8, random_state=0)
else:
clf = LogisticRegression(C=0.5, max_iter=1000)
clf.fit(Xtr_s, ytr)
classes = clf.classes_
# PREDICI il blocco [blk, blk_end)
test_i = [i for i in range(blk, blk_end) if i in row_of]
if test_i:
Xte = scaler.transform(X[[row_of[i] for i in test_i]])
proba = clf.predict_proba(Xte)
for row, i in enumerate(test_i):
p = proba[row]
j = int(np.argmax(p))
if p[j] < thresh:
continue
d = int(classes[j])
if not np.isfinite(X[row_of[i]]).all():
continue
if trend_max is not None and a[i] > 0 and abs(c[i] - el[i]) / a[i] > trend_max:
continue
e = {"i": i, "d": d, "max_bars": H}
if tp_atr is not None and a[i] > 0:
e["tp"] = c[i] + d * tp_atr * a[i]
if sl_atr is not None and a[i] > 0:
e["sl"] = c[i] - d * sl_atr * a[i]
entries.append(e)
blk = blk_end
return entries
def check_ml_causal(df, W=24, H=12) -> bool:
"""Le predizioni walk-forward fino all'indice T non devono cambiare se perturbo
i dati DOPO T. Confronto le entries con i<=T su df vs df col futuro stravolto."""
T = int(len(df) * 0.7)
e0 = ml_wf_entries(df, W=W, H=H, model="logit", retrain=400, train_min=3000)
df2 = df.copy()
for col in ("open", "high", "low", "close", "volume"):
df2.loc[T + 1:, col] = df2.loc[T + 1:, col] * 1.6
e1 = ml_wf_entries(df2, W=W, H=H, model="logit", retrain=400, train_min=3000)
s0 = {(x["i"], x["d"]) for x in e0 if x["i"] <= T - H}
s1 = {(x["i"], x["d"]) for x in e1 if x["i"] <= T - H}
ok = s0 == s1
print(f" [causal] predizioni ML fino a T={T}-H invarianti al futuro: "
f"{'OK' if ok else 'VIOLATO'} ({len(s0 ^ s1)} differenze)")
return ok
# =============================================================================
# RUN
# =============================================================================
def acc_oos(entries, df) -> float:
"""Accuracy OOS (ultimo 30%): frazione di trade con esito favorevole (segno giusto),
indipendente da tp/sl. Misura la qualita' del segnale, separata dal PnL."""
split = int(len(df) * (1 - OOS_FRAC))
c = df["close"].values
n = len(c)
ok = tot = 0
for e in entries:
i, d, mb = e["i"], e["d"], e["max_bars"]
if i < split or i + mb >= n:
continue
tot += 1
ok += (c[i + mb] - c[i]) * d > 0
return ok / tot * 100 if tot else 0.0
def run(with_gb: bool = False):
"""with_gb=False (default): solo LogisticRegression (veloce, ~36s/config). Il
GradientBoostingClassifier da' edge equivalente ma e' ~60x piu' lento (~42 min/config
su 73k barre 1h) e non aggiunge niente: includilo solo con with_gb=True per conferma."""
t0 = time.time()
print("=" * 100)
print(" SHAPE_ML_RESEARCH — forma come VETTORE DI FEATURE | analog kNN + ML walk-forward")
print(" netto fee 0.10% RT (sweep 0.20%), leva 3x, pos 0.15, OOS ultimo 30%")
print("=" * 100)
assets = ["BTC", "ETH"]
dfs = {a: get_df(a, "1h") for a in assets}
print("\n[1] CHECK CAUSALITA' (no look-ahead):")
check_feature_causal(dfs["BTC"], W=24)
check_ml_causal(dfs["BTC"], W=24, H=12)
# ---------------------------------------------------------------------
print("\n[2] FILONE 1 — ANALOG kNN nello spazio FEATURE (time-exit a H):")
print(" confronto con shape_lab (analog grezzo sui close) implicito: stessa logica,"
" feature di forma al posto dei close z-normati.")
keep1 = []
for W, H, K, agree in [(24, 12, 60, 0.60), (24, 12, 80, 0.65),
(48, 24, 80, 0.62), (16, 8, 50, 0.62), (48, 12, 100, 0.65)]:
for a in assets:
ents = analog_feat_entries(dfs[a], W=W, H=H, K=K, agree=agree)
res = evaluate(f"{a} aF W{W}H{H}K{K} ag{agree}", ents, dfs[a])
if robust(res):
keep1.append((a, W, H, K, agree))
print(f" -> analog-feature robusti: {keep1 if keep1 else 'NESSUNO'}")
# con TP/SL ATR (exit gestita) + filtro trend
print("\n analog-feature con TP/SL ATR + filtro trend (riduce DD):")
for W, H, K, agree in [(24, 12, 80, 0.62), (48, 24, 80, 0.62)]:
for a in assets:
ents = analog_feat_entries(dfs[a], W=W, H=H, K=K, agree=agree,
tp_atr=1.5, sl_atr=1.5, trend_max=3.0)
res = evaluate(f"{a} aF W{W}H{H} tp/sl trend", ents, dfs[a])
if robust(res):
keep1.append((a, W, H, K, agree, "tpsl"))
# ---------------------------------------------------------------------
print("\n[3] FILONE 2 — ML WALK-FORWARD sulla forma:")
print(" accuracy OOS riportata ACCANTO al PnL (accuracy alta != edge, lezione squeeze)")
keep2 = []
configs = [
("logit", 24, 12, 0.56), ("logit", 24, 12, 0.58), ("logit", 24, 12, 0.60),
("logit", 48, 24, 0.58),
]
if with_gb:
configs += [("gb", 24, 12, 0.58), ("gb", 48, 24, 0.58)]
for model, W, H, th in configs:
for a in assets:
ents = ml_wf_entries(dfs[a], W=W, H=H, model=model, thresh=th)
res = evaluate(f"{a} {model} W{W}H{H} th{th}", ents, dfs[a])
ac = acc_oos(ents, dfs[a])
yr = {k: round(v) for k, v in sorted(res["full"]["yearly"].items())}
print(f" ^ accOOS={ac:4.1f}% anni={yr}")
# tieni se: FULL+OOS+ e regge fee 0.20% RT su entrambe le finestre
if (res["full"]["ret"] > 0 and res["oos"]["ret"] > 0
and res["sweep"][0.002] > 0 and res["sweep_oos"][0.002] > 0):
keep2.append((a, model, W, H, th))
print("\n" + "=" * 100)
print(" VERDETTO")
print(f" FILONE 1 analog-feature kNN: {'robusti ' + str(keep1) if keep1 else 'NESSUNO ROBUSTO (rumore: win~50%, fee 0.2% negativo)'}")
print(f" FILONE 2 ML walk-forward (FULL+OOS+ e regge fee 0.2%): {keep2 if keep2 else 'NESSUNO'}")
print(" Edge reale: la DIREZIONE letta dalla forma via LogisticRegression walk-forward")
print(" e' redditizia netto-fee (BTC W24H12 th0.58 il piu' robusto: 8/9 anni+, DD 23%).")
print(f" tempo: {time.time() - t0:.0f}s")
print("=" * 100)
if __name__ == "__main__":
run()
+178
View File
@@ -0,0 +1,178 @@
"""Validazione DURA del solo edge sopravvissuto alla ricerca shape: ML walk-forward
(LogisticRegression) sulle feature di FORMA. Tutto il resto della famiglia shape e' rumore.
Candidato: BTC logit W24H12 th0.58 (FULL +219% / OOS +42% / Sharpe 2.72 / 8-9 anni+,
regge fee 0.20% RT). Prima di promuoverlo a strategia serve (metodologia obbligatoria):
1. ROBUSTEZZA MULTI-ASSET: stessa config su BTC/ETH/LTC/SOL/ADA/XRP 1h.
2. WALK-FORWARD ROLLING (train fisso 2y) oltre all'expanding -> niente "memoria infinita".
3. STRESS leva 2x + slippage doppio (fee 0.20% RT) -> regge in condizioni realistiche?
4. ROBUSTEZZA SU GRIGLIA (th, W, H) -> plateau, non picco.
5. CORRELAZIONE col MASTER + integrazione -> e' un diversificatore (free-lunch)?
Tutto netto-fee, OOS = ultimo 30%. Conta il PnL netto, non l'accuracy (lezione squeeze).
Run: uv run python scripts/analysis/shape_ml_validate.py
"""
from __future__ import annotations
import sys
import time
import warnings
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
warnings.filterwarnings("ignore")
from scripts.analysis.explore_lab import get_df, evaluate, robust, FEE_RT, LEV, POS, OOS_FRAC
from scripts.analysis.shape_ml_research import ml_wf_entries, acc_oos
ASSETS = ["BTC", "ETH", "LTC", "SOL", "ADA", "XRP"]
TWO_YEARS_1H = 24 * 365 * 2 # ~17520 barre = finestra rolling 2 anni
# ---------------------------------------------------------------------------
def line(name, ents, df, fees=(0.0, 0.001, 0.002)):
"""Riga evaluate + accuracy OOS, ritorna (res, robusto?)."""
res = evaluate(name, ents, df)
ac = acc_oos(ents, df)
rb = (res["full"]["ret"] > 0 and res["oos"]["ret"] > 0
and res["sweep"][0.002] > 0 and res["sweep_oos"][0.002] > 0)
print(f" ^ accOOS={ac:4.1f}% {'[ROBUST fee0.2%]' if rb else ''}")
return res, rb
# ---------------------------------------------------------------------------
def sec_multi_asset(W=24, H=12, th=0.58):
print("\n[1] MULTI-ASSET — logit W%dH%d th%.2f, walk-forward EXPANDING (1h):" % (W, H, th))
ok = []
dfs = {}
for a in ASSETS:
df = get_df(a, "1h"); dfs[a] = df
ents = ml_wf_entries(df, W=W, H=H, model="logit", thresh=th)
_, rb = line(f"{a} exp", ents, df)
if rb:
ok.append(a)
print(f" -> EXPANDING robusti (fee0.2%): {ok if ok else 'NESSUNO'}")
return dfs, ok
def sec_rolling(dfs, W=24, H=12, th=0.58, tw=TWO_YEARS_1H):
print("\n[2] WALK-FORWARD ROLLING — train fisso ~2 anni (%d barre), stessa config:" % tw)
ok = []
for a in ASSETS:
ents = ml_wf_entries(dfs[a], W=W, H=H, model="logit", thresh=th, train_window=tw)
_, rb = line(f"{a} roll2y", ents, dfs[a])
if rb:
ok.append(a)
print(f" -> ROLLING robusti (fee0.2%): {ok if ok else 'NESSUNO'}")
return ok
def sec_stress(dfs, W=24, H=12, th=0.58):
print("\n[3] STRESS — leva 2x + slippage doppio (fee 0.20% RT) su BTC/ETH:")
print(" (la config nominale e' leva 3x fee 0.10%; qui peggioro entrambe)")
from scripts.analysis.explore_lab import simulate
for a in ["BTC", "ETH"]:
ents = ml_wf_entries(dfs[a], W=W, H=H, model="logit", thresh=th)
df = dfs[a]
split = int(len(df) * (1 - OOS_FRAC))
base = simulate(ents, df, fee_rt=0.001, lev=3.0)
stress_f = simulate(ents, df, fee_rt=0.002, lev=2.0)
stress_o = simulate(ents, df, fee_rt=0.002, lev=2.0, split=split)
print(f" {a}: base(3x,0.1%) FULL={base['ret']:+.0f}% Shrp={base['sharpe']:.2f} | "
f"STRESS(2x,0.2%) FULL={stress_f['ret']:+.0f}% OOS={stress_o['ret']:+.0f}% "
f"DD={stress_f['dd']:.0f}% Shrp={stress_f['sharpe']:.2f} "
f"{'OK' if stress_f['ret'] > 0 and stress_o['ret'] > 0 else 'KO'}")
def sec_grid(dfs, asset="BTC"):
print(f"\n[4] ROBUSTEZZA GRIGLIA su {asset} (plateau, non picco):")
rob = tot = 0
for W in (16, 24, 32):
for H in (8, 12, 16):
for th in (0.56, 0.58, 0.60):
ents = ml_wf_entries(dfs[asset], W=W, H=H, model="logit", thresh=th)
_, rb = line(f"{asset} W{W}H{H}th{th}", ents, dfs[asset])
tot += 1; rob += rb
print(f" -> {asset}: {rob}/{tot} celle robuste a fee 0.2% (plateau se alta frazione)")
# ---------------------------------------------------------------------------
def shape_daily_equity(asset, IDX, W=24, H=12, th=0.58):
"""Equity giornaliera dello sleeve shape-ML (time-exit a H, non-overlap, pos 0.15,
leva 3x, fee 0.10% RT), normalizzata sull'indice comune dei portafogli."""
from src.data.downloader import load_data
df = get_df(asset, "1h")
c = df["close"].values
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
ents = ml_wf_entries(df, W=W, H=H, model="logit", thresh=th)
n = len(c); eq = np.full(n, 1000.0); cap = 1000.0; last_exit = -1
fee = FEE_RT * LEV
for e in sorted(ents, key=lambda x: x["i"]):
i, d, mb = e["i"], e["d"], e["max_bars"]
if i <= last_exit or i + mb >= n:
continue
j = i + mb
ret = (c[j] - c[i]) / c[i] * d * LEV - fee
cap = max(cap + cap * POS * ret, 10.0)
eq[j:] = cap; last_exit = j
s = pd.Series(eq, index=ts).resample("1D").last().reindex(IDX).ffill().bfill()
return s / s.iloc[0]
def sec_master_integration():
print("\n[5] CORRELAZIONE + INTEGRAZIONE COL MASTER:")
from scripts.analysis.combine_portfolio import (
build_all_sleeves, port_returns, metrics, IDX, SPLIT,
)
sleeves = build_all_sleeves()
# sleeve shape: BTC + ETH (i due con piu' storia/edge)
shape = {}
for a in ("BTC", "ETH"):
shape[f"SH_{a}"] = shape_daily_equity(a, IDX)
dr_master = port_returns(sleeves) # MASTER equal-weight attuale
dr_shape = port_returns(shape)
corr = float(dr_master.corr(dr_shape))
print(f" correlazione daily MASTER vs sleeve-shape: {corr:+.3f}")
# correlazione media shape vs ogni sleeve esistente
cs = {k: float(port_returns({k: v}).corr(dr_shape)) for k, v in sleeves.items()}
print(" corr shape vs singole sleeve: " + ", ".join(f"{k}={v:+.2f}" for k, v in cs.items()))
base = {**sleeves}
ext = {**sleeves, **shape}
fb, ob = metrics(port_returns(base)), metrics(port_returns(base), lo=SPLIT)
fe, oe = metrics(port_returns(ext)), metrics(port_returns(ext), lo=SPLIT)
print(" %-22s %9s %6s %6s %6s | %9s %6s %6s %6s" %
("portafoglio", "FULLret", "CAGR", "DD", "Shrp", "OOSret", "CAGR", "DD", "Shrp"))
print(" %-22s %+9.0f %6.0f %6.1f %6.2f | %+9.0f %6.0f %6.1f %6.2f" %
("MASTER (9 sleeve)", fb["ret"], fb["cagr"], fb["dd"], fb["sharpe"],
ob["ret"], ob["cagr"], ob["dd"], ob["sharpe"]))
print(" %-22s %+9.0f %6.0f %6.1f %6.2f | %+9.0f %6.0f %6.1f %6.2f" %
("MASTER + shape", fe["ret"], fe["cagr"], fe["dd"], fe["sharpe"],
oe["ret"], oe["cagr"], oe["dd"], oe["sharpe"]))
better = oe["sharpe"] > ob["sharpe"] and oe["dd"] <= ob["dd"] + 1
print(f" -> aggiungere shape MIGLIORA il MASTER OOS (Sharpe up, DD ~stabile)? "
f"{'SI' if better else 'NO'}")
def run():
t0 = time.time()
print("=" * 100)
print(" VALIDAZIONE DURA — shape-ML (LogisticRegression walk-forward sulle feature di forma)")
print("=" * 100)
dfs, _ = sec_multi_asset()
sec_rolling(dfs)
sec_stress(dfs)
sec_grid(dfs, "BTC")
sec_master_integration()
print(f"\n tempo totale: {time.time() - t0:.0f}s")
print("=" * 100)
if __name__ == "__main__":
run()
+333
View File
@@ -0,0 +1,333 @@
"""Famiglia SHAPE-PIVOT: geometria a punti di svolta (PIP / pivot) -> bias futuro.
Idea (causale, no look-ahead):
- a ogni barra i comprimo la finestra di L barre terminante a close[i] nei suoi
P punti percettivamente importanti (PIP, Perceptually Important Points: i punti
di massima deviazione dalla retta congiungente — Fu et al.);
- la sequenza di P punti e' una POLILINEA = forma geometrica grezza;
- la classifico con feature interpretabili e CAUSALI:
* trend dei pivot interni: higher-highs/higher-lows (HH/HL) vs lower-* (LH/LL);
* convergenza/divergenza delle pendenze (triangoli/cunei);
* distanza % di close[i] dall'ultimo pivot alto/basso (vicino a R / a S);
* pendenza dell'ultimo segmento (slancio recente);
- per ogni CLASSE geometrica stimo l'esito medio a H barre usando SOLO occorrenze
passate il cui esito era gia' realizzato prima di i (statistica causale rolling);
- entro a close[i] nella direzione del bias di classe se l'edge passato e' netto;
exit a H barre o TP/SL in ATR.
VINCOLI (CLAUDE.md "metodologia obbligatoria" + "lezione squeeze look-ahead"):
- PIP/pivot calcolati SOLO su close[i-L+1 .. i]; nessun pivot "confermato dal futuro".
- ogni statistica per-classe usa solo campioni con esito (entry+H) <= i-1.
- ingresso eseguibile a close[i]; netto fee (0.10% RT base, sweep a 0.20%); leva 3x,
pos 0.15; validazione OOS (ultimo 30%) + robustezza griglia + >=2 asset.
- check di causalita' esplicito (perturbo il futuro: la forma a i non cambia).
Riusa l'engine netto-fee + OOS di explore_lab (simulate/evaluate/robust).
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.explore_lab import ( # noqa: E402
get_df, evaluate, robust, simulate, atr, ema, _dt, OOS_FRAC,
)
# =========================================================================
# PIP — Perceptually Important Points (causale, solo su close[a..b])
# =========================================================================
def pip_indices(seg: np.ndarray, p: int) -> list[int]:
"""Estrae p indici PIP dalla serie `seg` (inclusi i 2 estremi).
Algoritmo Fu et al.: parti dai 2 estremi; aggiungi iterativamente il punto a
massima distanza VERTICALE dalla retta che unisce i due PIP adiacenti, finche'
non hai p punti. Tutto sul segmento dato -> nessun look-ahead se seg=close[..i].
"""
n = len(seg)
if p >= n:
return list(range(n))
pts = [0, n - 1]
while len(pts) < p:
best_d, best_k = -1.0, -1
for s in range(len(pts) - 1):
l, r = pts[s], pts[s + 1]
if r - l < 2:
continue
x1, y1 = l, seg[l]
x2, y2 = r, seg[r]
dx = x2 - x1
# distanza verticale dalla retta (interpolazione lineare in x)
for k in range(l + 1, r):
if dx == 0:
dist = abs(seg[k] - y1)
else:
yline = y1 + (y2 - y1) * (k - x1) / dx
dist = abs(seg[k] - yline)
if dist > best_d:
best_d, best_k = dist, k
if best_k < 0:
break
# inserisci mantenendo l'ordine
for s in range(len(pts) - 1):
if pts[s] < best_k < pts[s + 1]:
pts.insert(s + 1, best_k)
break
return pts
# =========================================================================
# Classe geometrica della polilinea PIP (feature causali interpretabili)
# =========================================================================
def shape_class(seg: np.ndarray, p: int) -> tuple | None:
"""Ritorna una tupla-classe discreta della forma PIP di `seg`, o None se degenere.
Feature (tutte da seg=close[..i], causali):
- dir_seq: per ogni pivot interno, segno della variazione vs precedente
(sequenza su/giu) -> cattura HH/HL vs LH/LL e zig-zag;
- conv: convergenza pendenze inizio vs fine (triangolo/cuneo): segno di
(|slope_last| - |slope_first|) discretizzato;
- loc: posizione di close[i] nel range della finestra (vicino a max=resistenza,
vicino a min=supporto), in 3 bucket.
La classe e' invariante a livello/scala (z-norm implicito su forma).
"""
idx = pip_indices(seg, p)
if len(idx) < 3:
return None
y = seg[idx]
rng = y.max() - y.min()
if rng <= 0:
return None
yn = (y - y.min()) / rng # forma normalizzata 0..1
# sequenza direzioni dei segmenti (su=1 / giu=0)
diffs = np.diff(yn)
dir_seq = tuple(int(x > 0) for x in diffs)
# convergenza: pendenza primo vs ultimo segmento
s_first = abs(diffs[0])
s_last = abs(diffs[-1])
if s_last > s_first * 1.3:
conv = 1 # divergente (slancio finale)
elif s_last < s_first * 0.77:
conv = -1 # convergente (compressione, triangolo/cuneo)
else:
conv = 0
# posizione di close[i] (=ultimo punto) nel range: 0..1 in 3 bucket
last = yn[-1]
loc = 0 if last < 0.33 else (2 if last > 0.67 else 1)
return (dir_seq, conv, loc)
# =========================================================================
# Strategia: bias per-classe stimato CAUSALMENTE (rolling, esito realizzato)
# =========================================================================
def pivot_entries(df, L=48, P=5, H=12, min_lib=1000, min_samples=20,
edge=0.0, tp_atr=None, sl_atr=None,
trend_max=None, ema_long=200, mode="bias") -> list[dict]:
"""Entries dalla geometria PIP con bias di classe causale.
L: lunghezza finestra-forma. P: n. punti PIP. H: orizzonte (=max_bars).
min_lib: barre minime prima di operare. min_samples: campioni minimi per fidarsi
della statistica di una classe. edge: |rendimento medio classe| minimo
(frazione, es. 0.002 = 0.2%) per entrare. mode:
- "bias": entra nel verso del rendimento medio passato della classe (momentum
della forma: la classe X storicamente -> su/giu);
- "fade": entra nel verso OPPOSTO (test mean-reversion della forma).
Statistica per-classe accumulata SOLO con esiti realizzati < i (causale stretta).
"""
close = df["close"].values
high = df["high"].values
low = df["low"].values
n = len(close)
a = atr(df, 14)
el = ema(close, ema_long) if trend_max is not None else None
# stato rolling per classe: somma rendimenti e conteggio (solo esiti < i)
cls_sum: dict[tuple, float] = {}
cls_cnt: dict[tuple, int] = {}
# coda di campioni la cui forma e' stata calcolata ma esito non ancora maturo
# pending[t] = (classe, indice_entry t) -> matura quando t+H <= i-1
pending: list[tuple] = [] # (mature_at, cls, t)
pend_ptr = 0
entries: list[dict] = []
for i in range(min_lib, n - 1):
# 1) integra nello storico tutti i campioni il cui esito e' realizzato (< i)
# un campione formato a t matura quando t+H <= i-1 => mature_at = t+H+1 <= i
while pend_ptr < len(pending) and pending[pend_ptr][0] <= i:
_, cls_p, t = pending[pend_ptr]
ret_real = (close[t + H] - close[t]) / close[t]
cls_sum[cls_p] = cls_sum.get(cls_p, 0.0) + ret_real
cls_cnt[cls_p] = cls_cnt.get(cls_p, 0) + 1
pend_ptr += 1
# 2) forma corrente (solo close fino a i)
seg = close[i - L + 1: i + 1]
cls = shape_class(seg, P)
if cls is None:
continue
# registra il campione corrente come pending (esito da realizzare in futuro)
pending.append((i + H + 1, cls, i))
# 3) decisione con statistica PASSATA della classe
cnt = cls_cnt.get(cls, 0)
if cnt < min_samples:
continue
mean_ret = cls_sum[cls] / cnt
if abs(mean_ret) < edge:
continue
d = 1 if mean_ret > 0 else -1
if mode == "fade":
d = -d
# filtro trend opzionale
if trend_max is not None and a[i] > 0:
if abs(close[i] - el[i]) / a[i] > trend_max:
continue
e = {"i": i, "d": d, "max_bars": H}
if tp_atr is not None and a[i] > 0:
e["tp"] = close[i] + d * tp_atr * a[i]
if sl_atr is not None and a[i] > 0:
e["sl"] = close[i] - d * sl_atr * a[i]
entries.append(e)
return entries
# =========================================================================
# Filone (c): distanza da supporto/resistenza locale (ultimo pivot alto/basso)
# =========================================================================
def sr_entries(df, L=48, P=7, H=12, near=0.5, mode="fade",
tp_atr=None, sl_atr=None, trend_max=None, ema_long=200) -> list[dict]:
"""Filone (c): close[i] vicino all'ultimo pivot alto (R) o basso (S) della forma.
Usa i PIP per individuare l'ultimo massimo/minimo locale (resistenza/supporto) e
misura la distanza % di close[i]. Se close e' entro `near`*ATR da R -> bias short
(mode='fade': rimbalzo da R) o long (mode='break': rottura). Simmetrico per S.
Tutto causale: PIP su close[..i], decisione a close[i].
"""
close = df["close"].values
n = len(close)
a = atr(df, 14)
el = ema(close, ema_long) if trend_max is not None else None
entries: list[dict] = []
for i in range(L, n - 1):
seg = close[i - L + 1: i + 1]
idx = pip_indices(seg, P)
if len(idx) < 3 or a[i] <= 0:
continue
y = seg[idx]
# pivot interni (escludi i 2 estremi e l'ultimo punto = close[i])
inner = y[1:-1]
if len(inner) == 0:
continue
res = inner.max() # resistenza locale
sup = inner.min() # supporto locale
cur = close[i]
dist_r = (res - cur) / a[i]
dist_s = (cur - sup) / a[i]
d = None
if 0 <= dist_r <= near: # appena sotto R
d = -1 if mode == "fade" else 1
elif 0 <= dist_s <= near: # appena sopra S
d = 1 if mode == "fade" else -1
if d is None:
continue
if trend_max is not None and abs(cur - el[i]) / a[i] > trend_max:
continue
e = {"i": i, "d": d, "max_bars": H}
if tp_atr is not None:
e["tp"] = cur + d * tp_atr * a[i]
if sl_atr is not None:
e["sl"] = cur - d * sl_atr * a[i]
entries.append(e)
return entries
# =========================================================================
# Check causalita' esplicito
# =========================================================================
def check_no_lookahead(df, L=48, P=5) -> bool:
"""La classe-forma a i non deve cambiare se perturbo il FUTURO (>i)."""
close = df["close"].values.copy()
i = len(close) // 2
seg0 = close[i - L + 1: i + 1].copy()
c0 = shape_class(seg0, P)
close2 = close.copy()
close2[i + 1:] *= 1.7 # stravolge il futuro
seg1 = close2[i - L + 1: i + 1]
c1 = shape_class(seg1, P)
ok = (c0 == c1)
print(f" no-lookahead classe-forma a i={i}: {'OK' if ok else 'VIOLATO'} "
f"(c0={c0} c1={c1})")
# check su PIP indices
p0 = pip_indices(seg0, P)
p1 = pip_indices(seg1, P)
ok2 = (p0 == p1)
print(f" no-lookahead indici PIP: {'OK' if ok2 else 'VIOLATO'}")
return ok and ok2
# =========================================================================
# run() riproducibile
# =========================================================================
def run():
print("=" * 100)
print(" SHAPE-PIVOT RESEARCH — geometria PIP/pivot -> bias futuro | netto fee, OOS")
print("=" * 100)
df_btc = get_df("BTC", "1h")
print("\n[CAUSALITA']")
check_no_lookahead(df_btc, L=48, P=5)
assets = ["BTC", "ETH", "SOL", "ADA"]
dfs = {a: get_df(a, "1h") for a in assets}
# ---- A) bias di classe PIP (momentum della forma) ----
print("\n[A] BIAS di classe PIP (entra nel verso del rendimento medio passato della classe)")
print(" sweep L/P/H, edge=0.002, min_samples=25, time-exit a H")
A_grid = [(48, 5, 12), (48, 5, 24), (72, 6, 24), (36, 5, 12), (96, 7, 24), (48, 7, 12)]
for L, P, H in A_grid:
print(f" -- L{L} P{P} H{H} --")
for a in assets:
ents = pivot_entries(dfs[a], L=L, P=P, H=H, edge=0.002, min_samples=25, mode="bias")
evaluate(f"{a} bias L{L}P{P}H{H}", ents, dfs[a])
# ---- B) fade di classe PIP (mean-reversion della forma) ----
print("\n[B] FADE di classe PIP (entra opposto al bias storico -> test mean-reversion)")
for L, P, H in A_grid:
print(f" -- L{L} P{P} H{H} --")
for a in assets:
ents = pivot_entries(dfs[a], L=L, P=P, H=H, edge=0.002, min_samples=25, mode="fade")
evaluate(f"{a} fade L{L}P{P}H{H}", ents, dfs[a])
# ---- C) supporto/resistenza locale dai pivot ----
print("\n[C] S/R locale dai PIP — FADE (rimbalzo da R/S) vs BREAK (rottura)")
for mode in ("fade", "break"):
for near in (0.5, 1.0):
print(f" -- mode={mode} near={near} ATR, TP/SL 1.5/1.5 ATR, H=12 --")
for a in assets:
ents = sr_entries(dfs[a], L=48, P=7, H=12, near=near, mode=mode,
tp_atr=1.5, sl_atr=1.5)
evaluate(f"{a} SR-{mode} near{near}", ents, dfs[a])
# ---- D) miglior candidato con TP/SL ATR + filtro trend (se A o B mostra segnali) ----
print("\n[D] FADE di classe con TP/SL ATR (2.0/1.5) + filtro trend 3.0, L48 P5 H24")
for a in assets:
ents = pivot_entries(dfs[a], L=48, P=5, H=24, edge=0.002, min_samples=25,
mode="fade", tp_atr=2.0, sl_atr=1.5, trend_max=3.0)
res = evaluate(f"{a} fadeTPSL L48P5H24", ents, dfs[a])
if robust(res):
print(f" ^^^ {a} ROBUSTO")
print("\n" + "=" * 100)
print(" Verdetto: cerca righe con FULL>0 E OOS>0 E fee0.2% OOS>0 su >=2 asset.")
print("=" * 100)
if __name__ == "__main__":
run()
+443
View File
@@ -0,0 +1,443 @@
"""SHAPE_TEMPLATE_RESEARCH — edge nella FORMA del prezzo: distanze alternative e template canonici.
Due filoni, sull'harness ONESTO condiviso (shape_lab + explore_lab), netto-fee e OOS:
1. ANALOG con distanza di FORMA alternativa (DTW warping-invariant, correlazione/coseno)
confrontata HEAD-TO-HEAD con l'euclidea a PARITA' di selettivita' (stessa libreria,
stesso K, stessa soglia di accordo). DTW e' O(W^2): si usa una libreria SOTTOCAMPIONATA
(uno start ogni `step` barre) + W ridotto + banda di Sakoe-Chiba.
2. TEMPLATE di forma canonici (doppio top/bottom, testa-spalle, V-reversal, salita/discesa
lineare, U). A ogni i misuro la similarita' (correlazione di Pearson sulla finestra
z-normalizzata) fra forma recente e ogni template; se supera soglia, entro a close[i]
nella DIREZIONE ATTESA del template stimata SOLO sul passato (esito medio causale delle
occorrenze gia' concluse di quel template), exit H barre o tp/sl ATR.
VINCOLI anti-look-ahead (verificati esplicitamente):
- la forma/match a i usa SOLO close fino a i (z-norm causale);
- la direzione attesa di ogni template e la libreria analog usano SOLO occorrenze il cui
esito a H barre e' gia' realizzato PRIMA di i (end + H <= i-1);
- ingresso eseguibile a close[i]; exit TP/SL intrabar o time-limit H.
Netto fee 0.10% RT baseline + sweep fino a 0.20%. Leva 3x, pos 0.15. OOS ultimo 30%.
Run riproducibile: uv run python scripts/analysis/shape_template_research.py
DTW e' costoso: usa run_in_background per gli sweep larghi (vedi --sweep).
"""
from __future__ import annotations
import sys
import time
from pathlib import Path
import numpy as np
from numpy.lib.stride_tricks import sliding_window_view
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.explore_lab import get_df, evaluate, robust, simulate, atr, ema, OOS_FRAC # noqa: E402
from scripts.analysis.shape_lab import znorm_windows, fwd_return # noqa: E402
RNG_SEED = 7
SUBC_ASSETS = ["BTC", "ETH", "SOL"]
# =========================================================================================
# DISTANZE DI FORMA
# =========================================================================================
def _euclid(q: np.ndarray, lib: np.ndarray) -> np.ndarray:
"""Distanza euclidea fra q (W,) e ogni riga di lib (M,W). Forme gia' z-normalizzate."""
return np.sqrt(((lib - q) ** 2).sum(axis=1))
def _corr_dist(q: np.ndarray, lib: np.ndarray) -> np.ndarray:
"""Distanza = 1 - correlazione di Pearson (q,lib gia' z-norm: corr = q.lib / W)."""
# forme z-norm hanno media 0 std 1 -> dot/W e' la correlazione di Pearson
corr = (lib @ q) / q.shape[0]
return 1.0 - corr
def _cosine_dist(q: np.ndarray, lib: np.ndarray) -> np.ndarray:
"""Distanza = 1 - coseno fra q e ogni riga di lib."""
qn = q / (np.linalg.norm(q) + 1e-12)
ln = lib / (np.linalg.norm(lib, axis=1, keepdims=True) + 1e-12)
return 1.0 - (ln @ qn)
def _dtw_one(a: np.ndarray, b: np.ndarray, band: int) -> float:
"""DTW 1D con banda di Sakoe-Chiba (|i-j|<=band). a,b stessa lunghezza W."""
n = len(a)
INF = 1e18
prev = np.full(n + 1, INF)
prev[0] = 0.0
for i in range(1, n + 1):
cur = np.full(n + 1, INF)
jlo = max(1, i - band)
jhi = min(n, i + band)
ai = a[i - 1]
for j in range(jlo, jhi + 1):
cost = abs(ai - b[j - 1])
m = prev[j]
if prev[j - 1] < m:
m = prev[j - 1]
if cur[j - 1] < m:
m = cur[j - 1]
cur[j] = cost + m
prev = cur
return float(prev[n])
def _dtw_dist(q: np.ndarray, lib: np.ndarray, band: int) -> np.ndarray:
"""DTW di q contro ogni riga di lib. O(M * W * band)."""
out = np.empty(lib.shape[0])
for k in range(lib.shape[0]):
out[k] = _dtw_one(q, lib[k], band)
return out
DIST_FUNCS = {"euclid": _euclid, "corr": _corr_dist, "cosine": _cosine_dist}
# =========================================================================================
# FILONE 1 — ANALOG con distanza configurabile (libreria sottocampionata, causale)
# =========================================================================================
def analog_dist_entries(df, dist="euclid", W=24, H=12, K=40, step=5, rebuild=500,
min_lib=2000, agree=0.62, dtw_band=4, dtw_prefilter=200,
decide_step=1, tp_atr=None, sl_atr=None,
trend_max=None, ema_long=200) -> list[dict]:
"""Analog kNN sulla FORMA con metrica `dist` ('euclid'|'corr'|'cosine'|'dtw').
Libreria SOTTOCAMPIONATA: si considerano solo finestre che terminano a indici
multipli di `step` (riduce N e rende DTW trattabile). Causalita': la libreria a
decisione i contiene solo finestre con end<=i-1-H (esito gia' realizzato).
Ricostruita ogni `rebuild` barre. Stessa firma per tutte le metriche -> confronto
head-to-head a parita' di selettivita' (stesso W,H,K,agree).
DTW (costoso, O(W*band) per coppia in Python): si PREFILTRA con la correlazione ai
`dtw_prefilter` candidati piu' simili, poi si fa DTW-rerank solo su quelli (approccio
standard lower-bound/rerank). `decide_step`>1 valuta una barra ogni decide_step (non
cambia la causalita', riduce solo il numero di query DTW).
"""
close = df["close"].values
n = len(close)
a = atr(df, 14)
M, ends = znorm_windows(close, W)
end_pos = {int(e): k for k, e in enumerate(ends)}
fr = fwd_return(close, H)
el = ema(close, ema_long) if trend_max is not None else None
# candidati di libreria: solo end multipli di step (sottocampionamento causale fisso)
base_ends = ends[(ends % step == 0)]
entries: list[dict] = []
lib_M = None
lib_idx = None
next_rebuild = 0
for i in range(min_lib, n - 1):
if i % decide_step != 0:
continue
if lib_M is None or i >= next_rebuild:
elig = base_ends[(base_ends <= i - 1 - H) & (base_ends >= W - 1)]
elig = elig[~np.isnan(fr[elig])]
if len(elig) < max(K * 3, 200):
next_rebuild = i + rebuild
continue
lib_M = M[[end_pos[int(e)] for e in elig]]
lib_idx = elig
next_rebuild = i + rebuild
if lib_M is None:
continue
q = M[end_pos[i]]
if not np.isfinite(q).all():
continue
if trend_max is not None and a[i] > 0 and abs(close[i] - el[i]) / a[i] > trend_max:
continue
if dist == "dtw":
# prefiltro corr (cheap, vettoriale) -> DTW-rerank solo sui top dtw_prefilter
pre = _corr_dist(q, lib_M)
npre = min(dtw_prefilter, len(lib_idx))
cand = np.argpartition(pre, npre - 1)[:npre]
dd_cand = _dtw_dist(q, lib_M[cand], dtw_band)
kk = min(K, len(cand))
sub = np.argpartition(dd_cand, kk - 1)[:kk]
nn = cand[sub]
outs = fr[lib_idx[nn]]
outs = outs[~np.isnan(outs)]
if len(outs) < 5:
continue
mean_out = float(outs.mean())
d = 1 if mean_out > 0 else -1
frac = float(np.mean(np.sign(outs) == d))
if frac < agree:
continue
e = {"i": i, "d": d, "max_bars": H}
if tp_atr is not None and a[i] > 0:
e["tp"] = close[i] + d * tp_atr * a[i]
if sl_atr is not None and a[i] > 0:
e["sl"] = close[i] - d * sl_atr * a[i]
entries.append(e)
continue
dd = DIST_FUNCS[dist](q, lib_M)
kk = min(K, len(lib_idx))
nn = np.argpartition(dd, kk - 1)[:kk]
outs = fr[lib_idx[nn]]
outs = outs[~np.isnan(outs)]
if len(outs) < 5:
continue
mean_out = float(outs.mean())
d = 1 if mean_out > 0 else -1
frac = float(np.mean(np.sign(outs) == d))
if frac < agree:
continue
e = {"i": i, "d": d, "max_bars": H}
if tp_atr is not None and a[i] > 0:
e["tp"] = close[i] + d * tp_atr * a[i]
if sl_atr is not None and a[i] > 0:
e["sl"] = close[i] - d * sl_atr * a[i]
entries.append(e)
return entries
# =========================================================================================
# FILONE 2 — TEMPLATE di forma canonici
# =========================================================================================
def make_templates(W: int) -> dict[str, np.ndarray]:
"""Template parametrici z-normalizzati di lunghezza W (forma pura, no scala/livello).
Sono solo descrittori di FORMA recente (gli ultimi W close). La direzione attesa NON
e' decisa a priori: viene stimata causalmente sul passato (vedi template_entries).
"""
t = np.linspace(0, 1, W)
s = 0.012 # ampiezza gaussiana scalata sulla finestra (W-indipendente in t in [0,1])
g = lambda c: np.exp(-((t - c) ** 2) / s)
raw = {
# estremi di reversione a DOPPIO picco (due massimi / minimi simmetrici)
"double_top": g(0.25) + g(0.75), # M: due cime
"double_bottom": -(g(0.25) + g(0.75)), # W: due fondi
# testa-spalle: spalla-testa-spalla (centro piu' alto)
"head_shoulders": g(0.2) + 1.7 * g(0.5) + g(0.8),
"inv_head_shoulders": -(g(0.2) + 1.7 * g(0.5) + g(0.8)),
# singola reversione
"v_bottom": np.abs(t - 0.5),
"inv_v_top": -np.abs(t - 0.5),
"u_bottom": (t - 0.5) ** 2,
"arch_top": -((t - 0.5) ** 2),
# trend lineari
"ramp_up": t,
"ramp_down": -t,
}
out = {}
for k, v in raw.items():
v = np.asarray(v, dtype=float)
sd = v.std()
out[k] = (v - v.mean()) / (sd if sd > 0 else 1.0)
return out
def template_entries(df, W=24, H=12, corr_min=0.85, dir_min=0.10, min_lib=2000,
rebuild=300, tp_atr=None, sl_atr=None, trend_max=None, ema_long=200,
templates=None) -> list[dict]:
"""Entries da match con template canonici, DIREZIONE stimata SOLO sul passato.
A ogni i, per ogni template calcolo la correlazione di Pearson fra la forma recente
z-norm (close[i-W+1..i]) e il template. Prendo il template a correlazione massima; se
>= corr_min lo considero "attivo". La DIREZIONE in cui entrare e' il segno del rendimento
forward MEDIO storico delle occorrenze gia' concluse (end+H<=i-1) di quel template
(stesso criterio di match), purche' |media| in barre-equivalenti superi dir_min*media_atr-ish
-> qui dir_min e' una soglia sulla |media forward| relativa (frazione). NIENTE direzione a
priori: se il passato non e' coerente (occorrenze<min o segno debole) si salta.
"""
close = df["close"].values
n = len(close)
a = atr(df, 14)
M, ends = znorm_windows(close, W)
end_pos = {int(e): k for k, e in enumerate(ends)}
fr = fwd_return(close, H)
el = ema(close, ema_long) if trend_max is not None else None
tps = templates if templates is not None else make_templates(W)
names = list(tps.keys())
T = np.stack([tps[k] for k in names]) # (NT, W), gia' z-norm
# match-history: per ogni end di libreria, quale template e con che corr
# (precalcolo causale: per ogni end, corr con ogni template)
# corr Pearson fra forme z-norm = dot/W
lib_ends = ends[ends >= W - 1]
lib_M = M[[end_pos[int(e)] for e in lib_ends]] # (L, W)
corr_mat = (lib_M @ T.T) / W # (L, NT)
best_tpl = np.argmax(corr_mat, axis=1)
best_corr = corr_mat[np.arange(len(lib_ends)), best_tpl]
lib_fr = fr[lib_ends]
lib_end_arr = lib_ends
entries: list[dict] = []
# cache direzione per template, ricostruita ogni rebuild barre
dir_cache: dict[int, int] = {}
next_rebuild = 0
for i in range(min_lib, n - 1):
q = M[end_pos[i]]
if not np.isfinite(q).all():
continue
cq = (T @ q) / W # corr con ogni template
bt = int(np.argmax(cq))
if cq[bt] < corr_min:
continue
if trend_max is not None and a[i] > 0 and abs(close[i] - el[i]) / a[i] > trend_max:
continue
# direzione attesa: media forward causale delle occorrenze concluse dello stesso template
if i >= next_rebuild:
dir_cache = {}
next_rebuild = i + rebuild
if bt not in dir_cache:
mask = (lib_end_arr <= i - 1 - H) & (best_tpl == bt) & (best_corr >= corr_min) & (~np.isnan(lib_fr))
outs = lib_fr[mask]
if len(outs) < 30:
dir_cache[bt] = 0
else:
m = float(outs.mean())
# soglia: |media| forward deve superare dir_min volte la std forward (edge vs rumore)
sd = float(outs.std()) + 1e-12
dir_cache[bt] = (1 if m > 0 else -1) if abs(m) / sd >= dir_min else 0
d = dir_cache[bt]
if d == 0:
continue
e = {"i": i, "d": d, "max_bars": H}
if tp_atr is not None and a[i] > 0:
e["tp"] = close[i] + d * tp_atr * a[i]
if sl_atr is not None and a[i] > 0:
e["sl"] = close[i] - d * sl_atr * a[i]
entries.append(e)
return entries
# =========================================================================================
# CHECK CAUSALITA' espliciti
# =========================================================================================
def check_causality_analog(df, **kw) -> bool:
"""Le entries non devono cambiare se perturbo il FUTURO oltre l'ultima barra usata.
Tronco il df a una certa lunghezza L e verifico che le entries con i<L-H-1 siano
identiche a quelle calcolate sul df completo (la coda futura non le tocca)."""
L = int(len(df) * 0.55)
H = kw.get("H", 12)
full = analog_dist_entries(df, **kw)
trunc = analog_dist_entries(df.iloc[:L].reset_index(drop=True), **kw)
horizon = L - H - 2
f = {e["i"]: e["d"] for e in full if e["i"] < horizon}
t = {e["i"]: e["d"] for e in trunc if e["i"] < horizon}
ok = (f == t)
print(f" causalita' analog ({kw.get('dist','euclid')}): {'OK' if ok else 'VIOLATO'} "
f"({len(f)} entries confrontate <{horizon})")
return ok
def check_causality_template(df, **kw) -> bool:
L = int(len(df) * 0.55)
H = kw.get("H", 12)
full = template_entries(df, **kw)
trunc = template_entries(df.iloc[:L].reset_index(drop=True), **kw)
horizon = L - H - 2
f = {e["i"]: e["d"] for e in full if e["i"] < horizon}
t = {e["i"]: e["d"] for e in trunc if e["i"] < horizon}
ok = (f == t)
print(f" causalita' template: {'OK' if ok else 'VIOLATO'} "
f"({len(f)} entries confrontate <{horizon})")
return ok
# =========================================================================================
# RUN
# =========================================================================================
def run_head_to_head(assets=SUBC_ASSETS, W=16, H=12, K=40, step=6, agree=0.62,
decide_step=4, dtw_prefilter=120):
"""Confronto HEAD-TO-HEAD delle metriche di forma a PARITA' di selettivita'.
Tutte le metriche valutano le STESSE barre-decisione (decide_step) con lo STESSO
W/H/K/agree: l'unica variabile e' la distanza. decide_step>1 serve a rendere DTW
trattabile (pura Python ~9ms/query); applicato a tutte per equita'.
"""
print("=" * 100)
print(f" FILONE 1 — ANALOG head-to-head metriche (W{W} H{H} K{K} step{step} "
f"agree{agree} decide_step{decide_step}) | netto fee, OOS")
print("=" * 100)
results = {}
for asset in assets:
df = get_df(asset, "1h")
print(f"\n --- {asset} 1h (n={len(df)}) ---", flush=True)
for dist in ["euclid", "corr", "cosine", "dtw"]:
t0 = time.time()
ents = analog_dist_entries(df, dist=dist, W=W, H=H, K=K, step=step, agree=agree,
dtw_band=max(2, W // 5), dtw_prefilter=dtw_prefilter,
decide_step=decide_step)
dt = time.time() - t0
res = evaluate(f"{dist:<7s}", ents, df)
results[(asset, dist)] = res
print(f" ^ time={dt:>5.1f}s robust={'YES' if robust(res) else 'no '}", flush=True)
return results
def run_templates(assets=SUBC_ASSETS, W=20, H=12, corr_min=0.85, dir_min=0.10):
print("=" * 100)
print(f" FILONE 2 — TEMPLATE canonici (W{W} H{H} corr>={corr_min} dir>={dir_min}) | netto fee, OOS")
print("=" * 100)
results = {}
for asset in assets:
df = get_df(asset, "1h")
print(f"\n --- {asset} 1h (n={len(df)}) ---")
for cm in [0.80, 0.85, 0.90]:
ents = template_entries(df, W=W, H=H, corr_min=cm, dir_min=dir_min)
res = evaluate(f"corr_min={cm}", ents, df)
results[(asset, cm)] = res
print(f" ^ robust={'YES' if robust(res) else 'no '}")
return results
def run_sweep():
"""Sweep largo (lento per via di DTW). Usa run_in_background."""
print("=" * 100)
print(" SWEEP LARGO — analog griglia W/H/K/step x metriche + template griglia")
print("=" * 100)
for W in [16, 20, 28]:
for H in [8, 12, 24]:
print(f"\n##### W={W} H={H} #####")
run_head_to_head(W=W, H=H, K=40, step=6, agree=0.62)
for W in [16, 20, 28]:
for H in [8, 12, 24]:
print(f"\n##### TEMPLATE W={W} H={H} #####")
run_templates(W=W, H=H, corr_min=0.85, dir_min=0.10)
def run():
np.random.seed(RNG_SEED)
print("#" * 100)
print(" SHAPE_TEMPLATE_RESEARCH — distanze di forma alternative + template canonici")
print("#" * 100)
# 1) check causalita' espliciti
print("\n[CAUSALITA']")
dfb = get_df("BTC", "1h")
check_causality_analog(dfb, dist="euclid", W=20, H=12, K=40, step=6, min_lib=2000)
check_causality_analog(dfb, dist="dtw", W=16, H=12, K=40, step=8, min_lib=2000,
dtw_band=3, decide_step=20)
check_causality_template(dfb, W=20, H=12, corr_min=0.85)
# 2) head-to-head metriche
print()
run_head_to_head()
# 3) template
print()
run_templates()
if __name__ == "__main__":
if "--sweep" in sys.argv:
run_sweep()
elif "--templates" in sys.argv:
run_templates()
elif "--h2h" in sys.argv:
run_head_to_head()
else:
run()
+173
View File
@@ -0,0 +1,173 @@
"""Analisi di ACCORPAMENTO degli sleeve: le strategie possono essere raggruppate
meglio o diversamente rispetto all'attuale "per famiglia"?
Costruisce le 17 sleeve daily (FADE 6 + HONEST 3 + PAIRS 5 + TSM01 + SHAPE 2),
e risponde con evidenza a:
1. CORRELAZIONE: matrice completa -> quali sleeve sono ridondanti (corr alta)?
2. CLUSTER: clustering gerarchico sulla distanza 1-corr -> i gruppi NATURALI
coincidono con le famiglie o no?
3. RISCHIO: contributo di ogni sleeve alla volatilita' del portafoglio equal-weight
-> chi domina il rischio (e va cappato)?
4. PESI: confronto equal-weight vs inverse-vol vs risk-parity (per cluster) su
ritorno/DD/Sharpe FULL e OOS.
Tutto netto fee, leva 3x, finestra comune 2021-2026, OOS = ultimo 30%.
Run: uv run python scripts/analysis/sleeve_clustering.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scipy.cluster.hierarchy import linkage, fcluster
from scipy.spatial.distance import squareform
from scripts.analysis.report_families import build_everything
from scripts.analysis.combine_portfolio import port_returns, metrics, SPLIT
def daily_matrix(sleeves: dict) -> pd.DataFrame:
return pd.DataFrame({k: v.pct_change().fillna(0.0) for k, v in sleeves.items()})
def risk_contributions(dr: pd.DataFrame, w: np.ndarray) -> np.ndarray:
"""Contributo % di ogni sleeve alla varianza del portafoglio (w'Σ)."""
cov = dr.cov().values
port_var = float(w @ cov @ w)
mrc = cov @ w # marginal risk contribution
rc = w * mrc # risk contribution (somma = port_var)
return rc / port_var * 100 if port_var > 0 else rc
def inv_vol(dr: pd.DataFrame) -> np.ndarray:
v = dr.std().values
inv = np.where(v > 0, 1.0 / v, 0.0)
return inv / inv.sum()
def cluster_risk_parity(dr: pd.DataFrame, labels: np.ndarray) -> dict:
"""Peso: equal fra i CLUSTER, poi inverse-vol DENTRO ogni cluster.
Diversifica per gruppo-naturale invece che per sleeve -> non sovrappesa cluster affollati."""
cols = list(dr.columns)
w = np.zeros(len(cols))
clusters = sorted(set(labels))
per_cluster = 1.0 / len(clusters)
for cl in clusters:
idx = [i for i, lb in enumerate(labels) if lb == cl]
sub = dr.iloc[:, idx]
iv = inv_vol(sub)
for j, i in enumerate(idx):
w[i] = per_cluster * iv[j]
return {cols[i]: w[i] for i in range(len(cols))}
def main():
print("Costruzione 17 sleeve (~2-3 min)...\n")
S, pairs, tsm, shape = build_everything()
all_sl = {**S, **pairs, **tsm, **shape}
dr = daily_matrix(all_sl)
cols = list(dr.columns)
n = len(cols)
fam_of = {}
for k in cols:
if k.startswith("MR"):
fam_of[k] = "FADE"
elif k.startswith("PR_"):
fam_of[k] = "PAIRS"
elif k.startswith("SH_"):
fam_of[k] = "SHAPE"
elif k == "TSM01":
fam_of[k] = "TSM"
else:
fam_of[k] = "HONEST"
# ---------- 1. correlazione ----------
print("=" * 100)
print(" (1) MATRICE DI CORRELAZIONE daily fra sleeve")
print("=" * 100)
corr = dr.corr()
short = [c.replace("_", "")[:8] for c in cols]
print(" " + "".join(f"{s[:6]:>7s}" for s in short))
for i, c in enumerate(cols):
print(f" {short[i]:<6s}" + "".join(f"{corr.iloc[i, j]:>7.2f}" for j in range(n)))
# coppie piu' correlate (candidati all'accorpamento)
print("\n Coppie piu' correlate (>0.5 -> ridondanza potenziale):")
pairs_corr = []
for i in range(n):
for j in range(i + 1, n):
pairs_corr.append((corr.iloc[i, j], cols[i], cols[j]))
pairs_corr.sort(reverse=True)
for cc, a, b in pairs_corr[:12]:
flag = " <-- stessa famiglia" if fam_of[a] == fam_of[b] else " <-- CROSS-famiglia"
print(f" {a:<11s} {b:<11s} {cc:+.2f}{flag if cc > 0.5 else ''}")
# ---------- 2. cluster ----------
print("\n" + "=" * 100)
print(" (2) CLUSTERING GERARCHICO (distanza = 1-corr) — i gruppi naturali")
print("=" * 100)
dist = 1.0 - corr.values
np.fill_diagonal(dist, 0.0)
dist = (dist + dist.T) / 2
Z = linkage(squareform(dist, checks=False), method="average")
for thr in (0.85, 0.95):
labels = fcluster(Z, t=thr, criterion="distance")
groups: dict[int, list] = {}
for c, lb in zip(cols, labels):
groups.setdefault(lb, []).append(c)
print(f"\n taglio a distanza {thr} (corr>{1-thr:.2f}) -> {len(groups)} cluster:")
for lb, members in sorted(groups.items()):
fams = {fam_of[m] for m in members}
print(f" C{lb}: {', '.join(members)} [{'/'.join(sorted(fams))}]")
# ---------- 3. rischio ----------
print("\n" + "=" * 100)
print(" (3) CONTRIBUTO AL RISCHIO (equal-weight) — chi domina la volatilita'")
print("=" * 100)
w_eq = np.ones(n) / n
rc = risk_contributions(dr, w_eq)
order = np.argsort(rc)[::-1]
print(f" {'sleeve':<12s}{'peso%':>7s}{'risk%':>7s} famiglia")
for i in order:
print(f" {cols[i]:<12s}{w_eq[i]*100:>7.1f}{rc[i]:>7.1f} {fam_of[cols[i]]}")
# rischio per famiglia
print("\n contributo al rischio per FAMIGLIA (equal-weight sleeve):")
fam_rc: dict[str, float] = {}
for i, c in enumerate(cols):
fam_rc[fam_of[c]] = fam_rc.get(fam_of[c], 0.0) + rc[i]
for f, v in sorted(fam_rc.items(), key=lambda x: -x[1]):
print(f" {f:<8s} {v:>5.1f}%")
# ---------- 4. schemi di peso ----------
print("\n" + "=" * 100)
print(" (4) SCHEMI DI PESO a confronto | FULL ret/DD/Sharpe | OOS ret/DD/Sharpe")
print("=" * 100)
labels95 = fcluster(Z, t=0.95, criterion="distance")
schemes = {
"equal-weight": {c: 1.0 / n for c in cols},
"inverse-vol": {cols[i]: inv_vol(dr)[i] for i in range(n)},
"cluster-risk-parity": cluster_risk_parity(dr, labels95),
}
print(f" {'schema':<22s}{'Ret%':>9s}{'DD%':>7s}{'Shrp':>7s} | {'oRet%':>9s}{'oDD%':>7s}{'oShrp':>7s}")
print(" " + "-" * 78)
for nm, w in schemes.items():
dserved = port_returns(all_sl, w)
f, o = metrics(dserved), metrics(dserved, lo=SPLIT)
print(f" {nm:<22s}{f['ret']:>+9.0f}{f['dd']:>7.1f}{f['sharpe']:>7.2f} | "
f"{o['ret']:>+9.0f}{o['dd']:>7.1f}{o['sharpe']:>7.2f}")
print("\n Lettura: se i cluster naturali != famiglie, conviene pesare per CLUSTER (rischio)")
print(" invece che per famiglia. Se inverse-vol/risk-parity battono equal-weight in OOS,")
print(" l'accorpamento attuale (equal-weight per sleeve) e' migliorabile.")
if __name__ == "__main__":
main()
+39
View File
@@ -0,0 +1,39 @@
"""Smoke reale: un giro di fetch v2 + build worker + un tick del portafoglio attivo.
NON apre ordini reali (paper). Verifica data layer v2 + sizing + ledger."""
import sys, shutil, tempfile
from pathlib import Path
from datetime import datetime, timezone, timedelta
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from src.portfolio.base import load_active_portfolio
from src.portfolio.ledger import PortfolioLedger
from src.portfolio.runner import build_worker_for, _worker_equity
from src.live.cerbero_client import CerberoClient
from src.live.multi_runner import INSTRUMENT_MAP
def main():
tmp = Path(tempfile.mkdtemp())
p = load_active_portfolio(PROJECT_ROOT / "portfolios.yml")
ledger = PortfolioLedger(p.code, total_capital=p.total_capital, data_dir=tmp)
alloc = ledger.allocate({s.sid: 1.0 / len(p.sleeves) for s in p.sleeves})
client = CerberoClient()
print(f"Portafoglio attivo: {p.code} ({p.label}) — {len(p.sleeves)} sleeve, leva {p.leverage}x")
end = datetime.now(timezone.utc); start = end - timedelta(days=60)
ok = 0
for s in p.sleeves[:3]:
asset = s.asset or s.a
inst = INSTRUMENT_MAP.get(asset, f"{asset}-PERPETUAL")
candles = client.get_historical_v2(inst, start.strftime("%Y-%m-%d"),
end.strftime("%Y-%m-%d"), s.tf)
print(f" {s.sid:<12s} {inst:<18s} candele={len(candles)}")
ok += len(candles) > 0
print(f"OK: {ok}/3 sleeve con feed v2 fresco. Ledger equity iniziale={ledger.equity}")
shutil.rmtree(tmp, ignore_errors=True)
if __name__ == "__main__":
main()
+258
View File
@@ -0,0 +1,258 @@
"""Ricerca strategie fee-aware, OOS, oltre la famiglia squeeze.
Lezioni apprese (squeeze breakout = nessun edge):
- le FEE sono vincolo di prim'ordine -> default fee realistica Deribit 0.10% RT
(taker 0.05%/lato, maker ~0%); poche operazioni meglio di molte
- i breakout RIENTRANO -> si esplora mean-reversion, non continuation
- ogni numero e' NETTO dopo fee+leva, su finestra held-out + per anno
Engine realistico: ingresso a close[i] (eseguibile), uscita su TP/SL intrabar
(high/low) o time-limit, una posizione per volta (non-overlap), capitale composto.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from src.data.downloader import load_data
FEE_RT = 0.001 # Deribit perp realistico: taker 0.05%/lato
LEV = 3.0
POS = 0.15
OOS_FRAC = 0.30
BARS_PER_YEAR = {"15m": 35040, "1h": 8760, "4h": 2190, "1d": 365}
# ----------------------------- dati -----------------------------
def get_df(asset: str, tf: str) -> pd.DataFrame:
"""tf nativo (15m,1h) o resample da 1h (4h,1d)."""
if tf in ("15m", "1h"):
return load_data(asset, tf).reset_index(drop=True)
base = load_data(asset, "1h").copy()
base["dt"] = pd.to_datetime(base["timestamp"], unit="ms", utc=True)
base = base.set_index("dt")
rule = {"4h": "4h", "1d": "1D"}[tf]
agg = base.resample(rule).agg(
{"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"}
).dropna()
agg["timestamp"] = agg.index.asi8 // 10**6
return agg.reset_index(drop=True)
# --------------------------- indicatori ---------------------------
def atr(df: pd.DataFrame, n: int = 14) -> np.ndarray:
h, l, c = df["high"].values, df["low"].values, df["close"].values
pc = np.roll(c, 1); pc[0] = c[0]
tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc)))
return pd.Series(tr).rolling(n).mean().values
def rsi(close: np.ndarray, n: int = 14) -> np.ndarray:
d = np.diff(close, prepend=close[0])
up = pd.Series(np.where(d > 0, d, 0.0)).ewm(alpha=1/n, adjust=False).mean()
dn = pd.Series(np.where(d < 0, -d, 0.0)).ewm(alpha=1/n, adjust=False).mean()
rs = up / dn.replace(0, np.nan)
return (100 - 100 / (1 + rs)).values
# --------------------------- engine ---------------------------
def simulate(entries: list[dict], df: pd.DataFrame, fee_rt: float = FEE_RT,
lev: float = LEV, pos: float = POS) -> dict:
"""entries: dict con i(idx), d(+1/-1), tp(prezzo), sl(prezzo), max_bars."""
h, l, c = df["high"].values, df["low"].values, df["close"].values
n = len(c)
cap = peak = 1000.0
max_dd = 0.0
fee = fee_rt * lev
trades = wins = 0
last_exit = -1
bars_in = 0
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
yearly: dict[int, float] = {}
for e in entries:
i, d = e["i"], e["d"]
if i <= last_exit or i + 1 >= n:
continue
entry = c[i]
tp, sl, mb = e["tp"], e["sl"], e["max_bars"]
exit_p = c[min(i + mb, n - 1)]
for k in range(1, mb + 1):
j = i + k
if j >= n:
exit_p = c[n - 1]; break
hit_sl = (d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl)
hit_tp = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
if hit_sl: # conservativo: SL prima del TP nello stesso bar
exit_p = sl; break
if hit_tp:
exit_p = tp; break
if k == mb:
exit_p = c[j]
ret = (exit_p - entry) / entry * d * lev - fee
cb = cap
cap = max(cb + cb * pos * ret, 10.0)
peak = max(peak, cap); max_dd = max(max_dd, (peak - cap) / peak)
trades += 1; wins += ret > 0; bars_in += min(mb, j - i)
last_exit = j
yearly[ts.iloc[i].year] = yearly.get(ts.iloc[i].year, 0.0) + ret * 100
return {
"trades": trades,
"win": wins / trades * 100 if trades else 0.0,
"ret": (cap / 1000 - 1) * 100,
"dd": max_dd * 100,
"yearly": yearly,
"exposure": bars_in / n * 100,
}
# --------------------------- strategie ---------------------------
def bollinger_fade(df, n=20, k=2.0, sl_atr=2.0, max_bars=24):
"""Mean-reversion: fada il close oltre la banda, TP alla media, SL = k_atr*ATR."""
c = df["close"].values
ma = pd.Series(c).rolling(n).mean().values
sd = pd.Series(c).rolling(n).std().values
a = atr(df, 14)
up, lo = ma + k * sd, ma - k * sd
ents = []
for i in range(n + 14, len(c)):
if np.isnan(up[i]) or np.isnan(a[i]):
continue
if c[i] < lo[i] and c[i - 1] >= lo[i - 1]: # appena sotto la banda
ents.append({"i": i, "d": 1, "tp": ma[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
elif c[i] > up[i] and c[i - 1] <= up[i - 1]:
ents.append({"i": i, "d": -1, "tp": ma[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
return ents
def rsi_revert(df, n=14, lo=30, hi=70, sl_atr=2.0, max_bars=24, ma_n=20):
"""RSI mean-reversion: long su RSI<lo che risale, TP alla media mobile."""
c = df["close"].values
r = rsi(c, n)
ma = pd.Series(c).rolling(ma_n).mean().values
a = atr(df, 14)
ents = []
for i in range(max(n, ma_n) + 1, len(c)):
if np.isnan(r[i]) or np.isnan(ma[i]) or np.isnan(a[i]):
continue
if r[i - 1] < lo <= r[i]:
ents.append({"i": i, "d": 1, "tp": ma[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
elif r[i - 1] > hi >= r[i]:
ents.append({"i": i, "d": -1, "tp": ma[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
return ents
def donchian_trend(df, n=20, sl_atr=2.0, tp_atr=6.0, max_bars=120):
"""Trend-following: breakout canale Donchian, TP/SL in multipli di ATR."""
h, l, c = df["high"].values, df["low"].values, df["close"].values
hh = pd.Series(h).rolling(n).max().shift(1).values
ll = pd.Series(l).rolling(n).min().shift(1).values
a = atr(df, 14)
ents = []
for i in range(n + 14, len(c)):
if np.isnan(hh[i]) or np.isnan(a[i]):
continue
if c[i] > hh[i]:
ents.append({"i": i, "d": 1, "tp": c[i] + tp_atr * a[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
elif c[i] < ll[i]:
ents.append({"i": i, "d": -1, "tp": c[i] - tp_atr * a[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
return ents
STRATS = {
"BOLL_fade k2 m24": (bollinger_fade, dict(n=20, k=2.0, sl_atr=2.0, max_bars=24)),
"BOLL_fade k2.5 m24": (bollinger_fade, dict(n=20, k=2.5, sl_atr=2.0, max_bars=24)),
"RSI_revert 30/70": (rsi_revert, dict(n=14, lo=30, hi=70, sl_atr=2.0, max_bars=24)),
"RSI_revert 25/75": (rsi_revert, dict(n=14, lo=25, hi=75, sl_atr=2.0, max_bars=24)),
"DONCH_trend n20": (donchian_trend, dict(n=20, sl_atr=2.0, tp_atr=6.0, max_bars=120)),
"DONCH_trend n50": (donchian_trend, dict(n=50, sl_atr=2.0, tp_atr=8.0, max_bars=200)),
}
def deep_dive():
print("\n" + "#" * 120)
print(" APPROFONDIMENTO BOLLINGER FADE (mean-reversion) — l'unica famiglia con edge netto")
print("#" * 120)
cases = [("BTC", "1h"), ("ETH", "1h"), ("BTC", "4h"), ("ETH", "4h")]
base = dict(n=20, k=2.5, sl_atr=2.0, max_bars=24)
# --- per anno (config base k2.5/n20) ---
print(f"\n [1] PnL NETTO per anno — n=20 k=2.5 sl=2ATR | fee {FEE_RT*100:.2f}% RT")
all_years = sorted({y for a, tf in cases for y in simulate(bollinger_fade(get_df(a, tf), **base), get_df(a, tf))["yearly"]})
print(f" {'Asset/TF':<10s}" + "".join(f"{y:>8d}" for y in all_years) + f"{'TOT%':>9s}{'DD%':>6s}")
for a, tf in cases:
df = get_df(a, tf)
r = simulate(bollinger_fade(df, **base), df)
row = "".join(f"{r['yearly'].get(y, 0):>+8.0f}" for y in all_years)
print(f" {a+' '+tf:<10s}{row}{r['ret']:>+9.0f}{r['dd']:>6.0f}")
# --- sensibilita' fee ---
print(f"\n [2] SENSIBILITA' FEE — Ret% FULL / OOS (n=20 k=2.5)")
fees = [0.0, 0.0005, 0.001, 0.002]
print(f" {'Asset/TF':<10s}" + "".join(f"{f'{f*100:.2f}%RT':>22s}" for f in fees))
print(f" {'':<10s}" + "".join(f"{'full':>11s}{'oos':>11s}" for _ in fees))
for a, tf in cases:
df = get_df(a, tf)
ents = bollinger_fade(df, **base)
split = int(len(df) * (1 - OOS_FRAC))
oents = [e for e in ents if e["i"] >= split]
cells = ""
for f in fees:
cells += f"{simulate(ents, df, fee_rt=f)['ret']:>+11.0f}{simulate(oents, df, fee_rt=f)['ret']:>+11.0f}"
print(f" {a+' '+tf:<10s}{cells}")
# --- griglia parametri (robustezza) su BTC/ETH 1h ---
print(f"\n [3] GRIGLIA PARAMETRI — Ret%OOS (DD%) | fee {FEE_RT*100:.2f}% RT, deve essere stabile")
for a in ["BTC", "ETH"]:
df = get_df(a, "1h")
split = int(len(df) * (1 - OOS_FRAC))
print(f"\n {a} 1h " + "".join(f"{f'k={k}':>16s}" for k in [2.0, 2.5, 3.0]))
for n in [14, 20, 30, 50]:
cells = ""
for k in [2.0, 2.5, 3.0]:
ents = [e for e in bollinger_fade(df, n=n, k=k, sl_atr=2.0, max_bars=24) if e["i"] >= split]
r = simulate(ents, df)
cell = f"{r['ret']:+.0f}({r['dd']:.0f})"
cells += f"{cell:>16s}"
print(f" n={n:<4d}{cells}")
def main():
print("=" * 120)
print(f" RICERCA STRATEGIE — NETTO dopo fee {FEE_RT*100:.2f}% RT | leva {LEV:.0f}x | pos {POS*100:.0f}% "
f"| OOS = ultimo {int(OOS_FRAC*100)}%")
print("=" * 120)
print(f" {'Strategia':<20s}{'Asset':>5s}{'TF':>5s}{'Trd':>6s}{'Tr/yr':>7s}{'Win%':>7s}"
f"{'Ret%FULL':>10s}{'Ret%OOS':>10s}{'DD%':>7s}{'Exp%':>7s}{'AnniPos':>9s}")
print(" " + "-" * 116)
for label, (fn, params) in STRATS.items():
for asset in ["BTC", "ETH"]:
for tf in ["1h", "4h"]:
df = get_df(asset, tf)
ents = fn(df, **params)
full = simulate(ents, df)
split = int(len(df) * (1 - OOS_FRAC))
oos = simulate([e for e in ents if e["i"] >= split], df)
yrs = full["yearly"]
pos_yrs = sum(1 for v in yrs.values() if v > 0)
tr_yr = full["trades"] / max(len(yrs), 1)
flag = " <<<" if oos["ret"] > 0 and full["ret"] > 0 and pos_yrs >= max(len(yrs) - 1, 1) else ""
print(f" {label:<20s}{asset:>5s}{tf:>5s}{full['trades']:>6d}{tr_yr:>7.0f}{full['win']:>7.1f}"
f"{full['ret']:>+10.1f}{oos['ret']:>+10.1f}{full['dd']:>7.1f}{full['exposure']:>7.1f}"
f"{f'{pos_yrs}/{len(yrs)}':>9s}{flag}")
print(" " + "-" * 116)
print(" Ret%FULL/OOS = ritorno NETTO composto su €1000. AnniPos = anni con PnL netto>0.")
print(" <<< = positivo full+OOS e robusto (quasi tutti gli anni positivi).")
deep_dive()
if __name__ == "__main__":
main()
+306
View File
@@ -0,0 +1,306 @@
"""Ricerca v2 — nuove strategie oltre MR01, stessa metodologia fee-aware OOS.
Lezioni ereditate (vedi strategy_research.py / oos_validation.py):
- mean-reversion ha edge, continuation/trend NO (i breakout rientrano)
- fee = vincolo di prim'ordine -> default Deribit 0.10% RT, poche operazioni meglio
- ingresso ESEGUIBILE a close[i] (mai look-ahead con direzione da barra i)
- ogni numero NETTO dopo fee+leva, su finestra held-out (OOS=ultimo 30%) + per anno
Nuovi candidati (tutti fade/mean-reversion con ingresso onesto):
MR02 donchian_fade - fade rottura canale Donchian (opposto del trend che muore)
MR03 keltner_fade - fade canale Keltner (ATR), TP alla EMA media
MR04 zscore_revert - fade deviazione z-score estrema, TP alla media
MR05 boll_fade_adx - Bollinger fade con filtro regime ADX (solo mercato laterale)
Engine identico a strategy_research.simulate (ingresso close[i], exit TP/SL intrabar
high/low o time-limit, non-overlap, capitale composto).
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
# riusa engine, dati e indicatori gia' validati
from scripts.analysis.strategy_research import (
FEE_RT, LEV, POS, OOS_FRAC, get_df, atr, rsi, simulate,
)
# --------------------------- indicatori extra ---------------------------
def ema(x: np.ndarray, n: int) -> np.ndarray:
return pd.Series(x).ewm(span=n, adjust=False).mean().values
def adx(df: pd.DataFrame, n: int = 14) -> np.ndarray:
"""Average Directional Index: misura la forza del trend (alto=trend, basso=range)."""
h, l, c = df["high"].values, df["low"].values, df["close"].values
up = h - np.roll(h, 1)
dn = np.roll(l, 1) - l
up[0] = dn[0] = 0.0
plus_dm = np.where((up > dn) & (up > 0), up, 0.0)
minus_dm = np.where((dn > up) & (dn > 0), dn, 0.0)
pc = np.roll(c, 1); pc[0] = c[0]
tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc)))
atr_n = pd.Series(tr).ewm(alpha=1/n, adjust=False).mean().values
pdi = 100 * pd.Series(plus_dm).ewm(alpha=1/n, adjust=False).mean().values / np.where(atr_n == 0, np.nan, atr_n)
mdi = 100 * pd.Series(minus_dm).ewm(alpha=1/n, adjust=False).mean().values / np.where(atr_n == 0, np.nan, atr_n)
dx = 100 * np.abs(pdi - mdi) / np.where((pdi + mdi) == 0, np.nan, pdi + mdi)
return pd.Series(dx).ewm(alpha=1/n, adjust=False).mean().values
# --------------------------- strategie nuove ---------------------------
def donchian_fade(df, n=20, sl_atr=2.0, max_bars=24):
"""MR02 — fade rottura canale Donchian: rompe sopra max-N => short verso il mid.
Coerente con 'i breakout rientrano': l'opposto di donchian_trend (che fallisce).
Ingresso a close[i] sulla barra che chiude oltre il canale precedente.
TP al centro del canale, SL = sl_atr*ATR oltre l'estremo.
"""
h, l, c = df["high"].values, df["low"].values, df["close"].values
hh = pd.Series(h).rolling(n).max().shift(1).values
ll = pd.Series(l).rolling(n).min().shift(1).values
a = atr(df, 14)
ents = []
for i in range(n + 14, len(c)):
if np.isnan(hh[i]) or np.isnan(a[i]):
continue
mid = (hh[i] + ll[i]) / 2.0
if c[i] > hh[i] and c[i - 1] <= hh[i - 1]: # rottura rialzista => fade short
ents.append({"i": i, "d": -1, "tp": mid, "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
elif c[i] < ll[i] and c[i - 1] >= ll[i - 1]: # rottura ribassista => fade long
ents.append({"i": i, "d": 1, "tp": mid, "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
return ents
def keltner_fade(df, n=20, k=2.0, sl_atr=2.0, max_bars=24):
"""MR03 — fade canale Keltner (EMA +/- k*ATR), TP alla EMA media.
Come Bollinger ma banda basata su ATR (volatilita' di range) invece che std:
reagisce diversamente ai gap. Ingresso quando close esce dalla banda.
"""
c = df["close"].values
e = ema(c, n)
a = atr(df, n)
up, lo = e + k * a, e - k * a
ents = []
for i in range(n + 1, len(c)):
if np.isnan(up[i]) or np.isnan(a[i]):
continue
if c[i] < lo[i] and c[i - 1] >= lo[i - 1]:
ents.append({"i": i, "d": 1, "tp": e[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
elif c[i] > up[i] and c[i - 1] <= up[i - 1]:
ents.append({"i": i, "d": -1, "tp": e[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
return ents
def zscore_revert(df, n=50, z=2.0, sl_atr=2.5, max_bars=24):
"""MR04 — fade deviazione z-score estrema dalla media, TP alla media.
z = (close-ma)/std. Entra quando |z| supera la soglia (close fuori); chiude
quando torna alla media. Banda di Bollinger riparametrizzata in z (equivalente
a k=z) ma con SL piu' largo e finestra lunga: poche operazioni, alta selettivita'.
"""
c = df["close"].values
ma = pd.Series(c).rolling(n).mean().values
sd = pd.Series(c).rolling(n).std().values
a = atr(df, 14)
ents = []
for i in range(n + 14, len(c)):
if np.isnan(ma[i]) or sd[i] == 0 or np.isnan(a[i]):
continue
zi = (c[i] - ma[i]) / sd[i]
zp = (c[i - 1] - ma[i - 1]) / sd[i - 1] if sd[i - 1] else 0.0
if zi <= -z and zp > -z:
ents.append({"i": i, "d": 1, "tp": ma[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
elif zi >= z and zp < z:
ents.append({"i": i, "d": -1, "tp": ma[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
return ents
def boll_fade_adx(df, n=50, k=2.5, sl_atr=2.0, max_bars=24, adx_max=25.0):
"""MR05 — Bollinger fade SOLO in regime laterale (ADX < adx_max).
Il fade soffre quando c'e' trend forte (il prezzo continua oltre la banda).
Filtro ADX: opera solo quando la forza del trend e' bassa -> meno trade, edge piu' pulito.
"""
c = df["close"].values
ma = pd.Series(c).rolling(n).mean().values
sd = pd.Series(c).rolling(n).std().values
a = atr(df, 14)
ax = adx(df, 14)
up, lo = ma + k * sd, ma - k * sd
ents = []
for i in range(n + 14, len(c)):
if np.isnan(up[i]) or np.isnan(a[i]) or np.isnan(ax[i]):
continue
if ax[i] >= adx_max: # trend forte: niente fade
continue
if c[i] < lo[i] and c[i - 1] >= lo[i - 1]:
ents.append({"i": i, "d": 1, "tp": ma[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
elif c[i] > up[i] and c[i - 1] <= up[i - 1]:
ents.append({"i": i, "d": -1, "tp": ma[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
return ents
def rsi2_fade(df, rsi_n=2, lo=10, hi=90, ma_n=200, tp_atr=2.0, sl_atr=3.0, max_bars=24):
"""MR06 — Connors RSI(2) pullback in direzione del trend, TP/SL in ATR.
Meccanismo distinto da MR01/MR03: non usa bande di prezzo ma l'oscillatore
RSI(2), che satura su micro-estremi. Filtro di trend con SMA lunga:
- close SOPRA la SMA (uptrend) + RSI(2) < lo (dip) -> long, target rimbalzo
- close SOTTO la SMA (downtrend) + RSI(2) > hi (pop) -> short
TP = tp_atr*ATR a favore, SL = sl_atr*ATR contro. Compra il ritracciamento
nel trend, non il contro-trend.
"""
c = df["close"].values
r = rsi(c, rsi_n)
ma = pd.Series(c).rolling(ma_n).mean().values
a = atr(df, 14)
ents = []
for i in range(ma_n + 14, len(c)):
if np.isnan(r[i]) or np.isnan(ma[i]) or np.isnan(a[i]):
continue
if r[i] < lo and c[i] > ma[i]: # dip in uptrend -> long
ents.append({"i": i, "d": 1, "tp": c[i] + tp_atr * a[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
elif r[i] > hi and c[i] < ma[i]: # pop in downtrend -> short
ents.append({"i": i, "d": -1, "tp": c[i] - tp_atr * a[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
return ents
def return_reversal(df, n=50, k=3.5, tp_atr=2.0, sl_atr=1.5, max_bars=24):
"""MR07 — fade movimento di barra estremo (return reversal).
Misura il rendimento dell'ultima barra in unita' di deviazione standard rolling
dei rendimenti. Se |ret| > k*sigma, fada nella direzione opposta; TP/SL in ATR.
Meccanismo distinto: usa la volatilita' dei RENDIMENTI, non i livelli di prezzo.
Config robusta (k=3.5, tp=2ATR, sl=1.5ATR): positivo full+OOS BTC e ETH 1h,
DD piu' contenuto (BTC 25% / ETH 46%).
"""
c = df["close"].values
ret = np.zeros_like(c)
ret[1:] = np.diff(c) / c[:-1]
sig = pd.Series(ret).rolling(n).std().values
a = atr(df, 14)
ents = []
for i in range(n + 14, len(c)):
if np.isnan(sig[i]) or sig[i] == 0 or np.isnan(a[i]):
continue
z = ret[i] / sig[i]
if z <= -k: # crollo di barra -> fade long
ents.append({"i": i, "d": 1, "tp": c[i] + tp_atr * a[i], "sl": c[i] - sl_atr * a[i], "max_bars": max_bars})
elif z >= k: # spike di barra -> fade short
ents.append({"i": i, "d": -1, "tp": c[i] - tp_atr * a[i], "sl": c[i] + sl_atr * a[i], "max_bars": max_bars})
return ents
CANDIDATES = {
"MR02 donch_fade n20": (donchian_fade, dict(n=20, sl_atr=2.0, max_bars=24)),
"MR02 donch_fade n50": (donchian_fade, dict(n=50, sl_atr=2.0, max_bars=24)),
"MR03 kelt_fade k2": (keltner_fade, dict(n=20, k=2.0, sl_atr=2.0, max_bars=24)),
"MR03 kelt_fade k2.5": (keltner_fade, dict(n=20, k=2.5, sl_atr=2.0, max_bars=24)),
"MR04 zscore z2 n50": (zscore_revert, dict(n=50, z=2.0, sl_atr=2.5, max_bars=24)),
"MR04 zscore z2.5 n50": (zscore_revert, dict(n=50, z=2.5, sl_atr=2.5, max_bars=24)),
"MR05 boll_adx n50": (boll_fade_adx, dict(n=50, k=2.5, sl_atr=2.0, max_bars=24, adx_max=25)),
"MR05 boll_adx n20": (boll_fade_adx, dict(n=20, k=2.5, sl_atr=2.0, max_bars=24, adx_max=25)),
"MR06 rsi2 10/90": (rsi2_fade, dict(rsi_n=2, lo=10, hi=90, ma_n=200, tp_atr=2.0, sl_atr=3.0, max_bars=24)),
"MR06 rsi2 5/95": (rsi2_fade, dict(rsi_n=2, lo=5, hi=95, ma_n=200, tp_atr=2.0, sl_atr=3.0, max_bars=24)),
"MR07 retrev k3.5": (return_reversal, dict(n=50, k=3.5, tp_atr=2.0, sl_atr=1.5, max_bars=24)),
"MR07 retrev k3.0": (return_reversal, dict(n=50, k=3.0, tp_atr=2.0, sl_atr=1.5, max_bars=24)),
}
def table():
print("=" * 122)
print(f" RICERCA v2 — NETTO dopo fee {FEE_RT*100:.2f}% RT | leva {LEV:.0f}x | pos {POS*100:.0f}% "
f"| OOS = ultimo {int(OOS_FRAC*100)}%")
print("=" * 122)
print(f" {'Strategia':<22s}{'Asset':>5s}{'TF':>5s}{'Trd':>6s}{'Tr/yr':>7s}{'Win%':>7s}"
f"{'Ret%FULL':>10s}{'Ret%OOS':>10s}{'DD%':>7s}{'Exp%':>7s}{'AnniPos':>9s}")
print(" " + "-" * 118)
for label, (fn, params) in CANDIDATES.items():
for asset in ["BTC", "ETH"]:
for tf in ["1h", "4h"]:
df = get_df(asset, tf)
ents = fn(df, **params)
full = simulate(ents, df)
split = int(len(df) * (1 - OOS_FRAC))
oos = simulate([e for e in ents if e["i"] >= split], df)
yrs = full["yearly"]
pos_yrs = sum(1 for v in yrs.values() if v > 0)
tr_yr = full["trades"] / max(len(yrs), 1)
robust = oos["ret"] > 0 and full["ret"] > 0 and pos_yrs >= max(len(yrs) - 1, 1)
flag = " <<<" if robust else ""
print(f" {label:<22s}{asset:>5s}{tf:>5s}{full['trades']:>6d}{tr_yr:>7.0f}{full['win']:>7.1f}"
f"{full['ret']:>+10.1f}{oos['ret']:>+10.1f}{full['dd']:>7.1f}{full['exposure']:>7.1f}"
f"{f'{pos_yrs}/{len(yrs)}':>9s}{flag}")
print(" " + "-" * 118)
print(" <<< = positivo full+OOS e robusto (quasi tutti gli anni positivi).")
def deep_dive():
"""Robustezza dei 3 candidati promossi: fee sweep + griglia parametri OOS."""
split_of = lambda df: int(len(df) * (1 - OOS_FRAC))
fees = [0.0, 0.0005, 0.001, 0.002]
print("\n" + "#" * 122)
print(" APPROFONDIMENTO MR02 / MR03 / MR05 — robustezza fee + griglia (deve restare positivo)")
print("#" * 122)
# --- MR02 Donchian Fade ---
print(f"\n [MR02 donchian_fade] SENSIBILITA' FEE — Ret% FULL/OOS (n=20)")
print(f" {'Asset/TF':<10s}" + "".join(f"{f'{f*100:.2f}%RT':>22s}" for f in fees))
print(f" {'':<10s}" + "".join(f"{'full':>11s}{'oos':>11s}" for _ in fees))
for a, tf in [("BTC", "1h"), ("ETH", "1h"), ("BTC", "4h"), ("ETH", "4h")]:
df = get_df(a, tf); sp = split_of(df)
ents = donchian_fade(df, n=20, sl_atr=2.0, max_bars=24)
oents = [e for e in ents if e["i"] >= sp]
cells = "".join(f"{simulate(ents, df, fee_rt=f)['ret']:>+11.0f}{simulate(oents, df, fee_rt=f)['ret']:>+11.0f}" for f in fees)
print(f" {a+' '+tf:<10s}{cells}")
print(f"\n [MR02] GRIGLIA n x sl_atr — Ret%OOS(DD%) | fee {FEE_RT*100:.2f}% RT")
for a in ["BTC", "ETH"]:
df = get_df(a, "1h"); sp = split_of(df)
print(f"\n {a} 1h " + "".join(f"{f'sl={s}':>16s}" for s in [1.5, 2.0, 3.0]))
for n in [10, 20, 30, 50]:
cells = ""
for s in [1.5, 2.0, 3.0]:
r = simulate([e for e in donchian_fade(df, n=n, sl_atr=s, max_bars=24) if e["i"] >= sp], df)
cell = "%+.0f(%.0f)" % (r["ret"], r["dd"])
cells += f"{cell:>16s}"
print(f" n={n:<4d}{cells}")
# --- MR03 Keltner Fade ---
print(f"\n [MR03 keltner_fade] GRIGLIA n x k — Ret%OOS(DD%) | fee {FEE_RT*100:.2f}% RT")
for a in ["BTC", "ETH"]:
df = get_df(a, "1h"); sp = split_of(df)
print(f"\n {a} 1h " + "".join(f"{f'k={k}':>16s}" for k in [1.5, 2.0, 2.5]))
for n in [14, 20, 30, 50]:
cells = ""
for k in [1.5, 2.0, 2.5]:
r = simulate([e for e in keltner_fade(df, n=n, k=k, sl_atr=2.0, max_bars=24) if e["i"] >= sp], df)
cell = "%+.0f(%.0f)" % (r["ret"], r["dd"])
cells += f"{cell:>16s}"
print(f" n={n:<4d}{cells}")
# --- MR05 Bollinger Fade + ADX ---
print(f"\n [MR05 boll_fade_adx] GRIGLIA n x adx_max — Ret%OOS(DD%) | fee {FEE_RT*100:.2f}% RT")
for a in ["BTC", "ETH"]:
df = get_df(a, "1h"); sp = split_of(df)
print(f"\n {a} 1h " + "".join(f"{f'adx<{x}':>16s}" for x in [20, 25, 30]))
for n in [20, 30, 50]:
cells = ""
for x in [20, 25, 30]:
r = simulate([e for e in boll_fade_adx(df, n=n, k=2.5, sl_atr=2.0, max_bars=24, adx_max=x) if e["i"] >= sp], df)
cell = "%+.0f(%.0f)" % (r["ret"], r["dd"])
cells += f"{cell:>16s}"
print(f" n={n:<4d}{cells}")
if __name__ == "__main__":
table()
deep_dive()
+94
View File
@@ -0,0 +1,94 @@
"""Verifica indipendente + ricerca TSM01 — Time-Series Momentum multi-orizzonte.
Long-only, multi-crypto, bassa frequenza. Per ogni asset il segnale è il CONSENSO
dei segni del momentum su più orizzonti lunghi (3/6/12 mesi); si tengono equal-weight
gli asset con consenso pieno positivo. Overlay risk-off: cash se BTC < SMA100.
Distinta da ROT02 (cross-sectional ranking): qui conta la PERSISTENZA assoluta lenta
di ogni asset, non la classifica relativa. Correlazione con ROT02 ~0.62 -> fattore
parzialmente indipendente, utile come diversificatore (NON come motore di ritorno:
rende meno di ROT02 a parita' di OOS). DD basso.
Anti-overfit: edge su ALTOPIANO (36/36 config orizzonti x thr x regime_n restano OOS+),
walk-forward stabile (4 anni up, 2 piatti per risk-off, mai un anno negativo), regge
fee 0.40% RT. Gran parte del DD basso viene dall'overlay risk-off SMA100 (condiviso),
la struttura multi-orizzonte aggiunge ~+38pp OOS e alza lo Sharpe 0.58->1.07.
Default gross=0.30 (era 0.45): stesso Sharpe ma DD 22%->15% (scelta robusta, non la piu' redditizia).
Engine onesto: pesi a close[i] da soli rendimenti passati, realizzo i->i+1, fee
one-way fee_rt/2 sul turnover. NETTO, leva implicita gross. OOS = ultimo 30%.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.honest_lab import available_assets, FEE_RT
from scripts.analysis.honest_rotation import build_panel
GROSS, OOS_FRAC = 0.30, 0.30 # gross 0.30 (anti-overfit): stesso Sharpe di 0.45, DD piu' basso
def tsmom_sim(horizons=(63, 126, 252), thr=1.0, regime_n=100, gross=GROSS,
fee_rt=FEE_RT, oos_frac=0.0, cheat=False):
"""horizons in giorni. thr=1.0 -> consenso pieno (tutti i segni positivi)."""
panel = build_panel(available_assets(), "1d")
cols = list(panel.columns); P = panel.values; T, N = P.shape
rets = np.zeros_like(P); rets[1:] = P[1:] / P[:-1] - 1
years = panel.index.year.values
btc = P[:, cols.index("BTC")]
bma = pd.Series(btc).rolling(regime_n).mean().values
start = max(max(horizons) + 1, regime_n + 1, int(T * (1 - oos_frac)) if oos_frac else 0)
cap = 1000.0; w = np.zeros(N); eq = [cap]; yearly = {}
eq_ts: list = []; eq_v: list = []
for i in range(start, T - 1):
risk_on = btc[i] > bma[i] if not np.isnan(bma[i]) else False
wi = i + 1 if cheat else i # cheat: usa il futuro (test no-look-ahead)
score = np.zeros(N)
for h in horizons:
score += np.sign(P[wi] / P[wi - h] - 1)
score /= len(horizons)
chosen = [j for j in range(N) if score[j] >= thr] if risk_on else []
nw = np.zeros(N)
for j in chosen:
nw[j] = gross / len(chosen)
cap -= cap * np.abs(nw - w).sum() * (fee_rt / 2); w = nw
cap = max(cap * (1 + float(np.dot(w, rets[i + 1]))), 10.0)
eq.append(cap)
eq_ts.append(panel.index[i + 1]); eq_v.append(cap)
y = int(years[i]); yearly[y] = yearly.get(y, 0.0) + float(np.dot(w, rets[i + 1])) * 100
eq = np.array(eq); peak = np.maximum.accumulate(eq)
dd = float(np.max((peak - eq) / peak) * 100)
yrs = (panel.index[-1] - panel.index[start]).days / 365.25 or 1
rets_d = np.diff(eq) / eq[:-1]
sharpe = float(np.mean(rets_d) / np.std(rets_d) * np.sqrt(365)) if np.std(rets_d) > 0 else 0.0
return dict(ret=(cap / 1000 - 1) * 100, cagr=((cap / 1000) ** (1 / yrs) - 1) * 100,
dd=dd, sharpe=sharpe, yearly=yearly, eq_ts=eq_ts, eq_v=eq_v,
pos_years=sum(1 for v in yearly.values() if v > 0), n_years=len(yearly))
def main():
print("=" * 90)
print(" TSM01 — TSMOM multi-orizzonte (3/6/12m consenso pieno) + risk-off SMA100")
print("=" * 90)
# no-look-ahead: cheat deve esplodere
base = tsmom_sim()
ch = tsmom_sim(cheat=True)
print(f" no-look-ahead: onesto FULL={base['ret']:+.0f}% vs cheat(futuro)={ch['ret']:+.0f}% -> "
f"{'OK (il cheat esplode -> niente leak)' if ch['ret'] > base['ret'] * 2 else 'CONTROLLARE'}")
o = tsmom_sim(oos_frac=1 - OOS_FRAC)
hi = tsmom_sim(fee_rt=0.002)
print(f"\n FULL {base['ret']:+.0f}% CAGR {base['cagr']:.0f}% DD {base['dd']:.0f}% "
f"Sharpe {base['sharpe']:.2f} anni+ {base['pos_years']}/{base['n_years']}")
print(f" OOS {o['ret']:+.0f}% DD {o['dd']:.0f}% | fee 0.40% RT: FULL {hi['ret']:+.0f}%")
print(" Per-anno: " + " ".join(f"{y}:{v:+.0f}%" for y, v in sorted(base["yearly"].items())))
if __name__ == "__main__":
main()
@@ -0,0 +1,74 @@
"""Demo numerica: il worker fade col NUOVO exit intrabar riproduce il backtest intrabar?
Replay bar-by-bar dello StrategyWorker (MR01 Bollinger fade) su una finestra storica e
confronto del rendimento col backtest di riferimento build_trades (che esce intrabar su
high/low al livello). Filtro trend disattivato in entrambi per isolare l'effetto-exit.
Atteso: dopo il fix (worker esce su high/low al livello, SL prioritario, come build_trades)
il rendimento del worker ≈ backtest. Prima del fix (exit solo sul close) divergeva.
Run: uv run python scripts/analysis/validate_fade_intrabar.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import tempfile, shutil
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from src.data.downloader import load_data
from src.live.strategy_worker import StrategyWorker
from src.live.strategy_loader import load_strategy
from scripts.analysis.risk_management import bollinger_fade, build_trades
CORE = dict(n=50, k=2.5, sl_atr=2.0, max_bars=24) # MR01, niente filtro trend
POS = 0.15
def backtest_return(df) -> tuple[float, int]:
ents = bollinger_fade(df, **CORE)
trades = build_trades(ents, df, trend_max=None) # intrabar, no trend filter
cap = 1000.0
for _, _, ret in trades:
cap = max(cap + cap * POS * ret, 10.0)
return (cap / 1000 - 1) * 100, len(trades)
def worker_replay_return(df) -> tuple[float, int]:
tmp = Path(tempfile.mkdtemp())
try:
w = StrategyWorker(strategy=load_strategy("MR01_bollinger_fade"), asset="BTC", tf="1h",
capital=1000.0, params=dict(CORE), data_dir=tmp)
# niente I/O per tick (replay veloce)
w._save_state = lambda *a, **k: None
w._log = lambda *a, **k: None
w._notify = lambda *a, **k: None
n = len(df)
for i in range(101, n):
w.tick(df.iloc[: i + 1])
return (w.capital / 1000 - 1) * 100, w.total_trades
finally:
shutil.rmtree(tmp, ignore_errors=True)
def main():
df = load_data("BTC", "1h").iloc[-4000:].reset_index(drop=True)
print("=" * 84)
print(" DEMO exit intrabar — worker fade MR01 (replay) vs backtest intrabar | BTC 1h, 4000 barre")
print("=" * 84)
bt_ret, bt_n = backtest_return(df)
wk_ret, wk_n = worker_replay_return(df)
gap = wk_ret - bt_ret
print(f" backtest build_trades : {bt_ret:+.1f}% ({bt_n} trade)")
print(f" worker replay (intrabar): {wk_ret:+.1f}% ({wk_n} trade)")
print(f" gap = {gap:+.1f} punti % -> {'OK (allineato)' if abs(gap) < max(abs(bt_ret) * 0.10, 3) else 'DIVERGE'}")
print("\n Col vecchio exit close-only il worker divergeva (usciva tardi/altrove);")
print(" ora esce su high/low al livello come il backtest -> gap ridotto al bar-timing residuo.")
if __name__ == "__main__":
main()
+112
View File
@@ -0,0 +1,112 @@
"""Validazione dei worker live multi-asset (TR01/ROT02/TSM01): il replay bar-by-bar del
worker riproduce la funzione di backtest di riferimento?
Replay onesto: si alimenta il worker con finestre crescenti dei dati storici (stesso
universo e stessa config della reference) e si confronta il rendimento finale con la
funzione di riferimento. Non si pretende parità al centesimo (differenze attese da
bar-timing e dalla convenzione capitale-singolo vs media-di-equity), ma il tracking
deve essere stretto e dello stesso segno/ordine di grandezza.
Riferimenti:
TR01 -> honest_improve2._tr_basket_daily
ROT02 -> honest_improve2._rot_daily_equity
TSM01 -> tsmom_research.tsmom_sim
Run: uv run python scripts/analysis/validate_honest_workers.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.explore_lab import get_df
from scripts.analysis.honest_lab import available_assets
from src.live.basket_trend_worker import BasketTrendWorker
from src.live.rotation_worker import RotationWorker
from src.live.tsmom_worker import TsmomWorker
def _aligned_panel(assets, tf):
"""{asset: df get_df} -> DataFrame allineato sui timestamp comuni (timestamp + close per asset)."""
frames = {}
for a in assets:
try:
d = get_df(a, tf)[["timestamp", "close"]].rename(columns={"close": a})
frames[a] = d
except Exception:
pass
panel = None
for a, f in frames.items():
panel = f if panel is None else panel.merge(f, on="timestamp", how="inner")
return panel.sort_values("timestamp").reset_index(drop=True), list(frames)
def _asset_df(panel, a):
"""df OHLCV minimale (close = open = ...) per un asset dal panel allineato."""
c = panel[a].values
return pd.DataFrame({"timestamp": panel["timestamp"].values,
"open": c, "high": c, "low": c, "close": c, "volume": 1.0})
def replay(worker, panel, cols, start):
"""Replay bar-by-bar: a ogni step feed delle finestre crescenti. Ritorna ret% finale."""
n = len(panel)
for i in range(start, n):
sub = panel.iloc[: i + 1]
data = {a: _asset_df(sub, a) for a in cols}
worker.tick(data)
return (worker.capital / worker.initial_capital - 1) * 100
def main():
import tempfile, shutil
tmp = Path(tempfile.mkdtemp())
print("=" * 92)
print(" VALIDAZIONE worker live multi-asset (replay vs backtest di riferimento)")
print("=" * 92)
try:
# ---- ROT02 ----
from scripts.analysis.honest_improve2 import _rot_daily_equity
idx = pd.date_range("2021-01-01", "2026-05-26", freq="1D", tz="UTC")
ref_rot = (_rot_daily_equity(idx).iloc[-1] - 1) * 100
uni = available_assets()
panel, cols = _aligned_panel(uni, "1d")
wr = RotationWorker(universe=cols, top_k=3, gross=0.45, tf="1d",
capital=1000.0, data_dir=tmp)
rot = replay(wr, panel, cols, start=101)
print(f" ROT02 worker={rot:+.0f}% reference={ref_rot:+.0f}% "
f"univ={len(cols)} barre={len(panel)}")
# ---- TSM01 ----
from scripts.analysis.tsmom_research import tsmom_sim
ref_tsm = tsmom_sim()["ret"]
wt = TsmomWorker(universe=cols, horizons=(63, 126, 252), thr=1.0, gross=0.30,
tf="1d", capital=1000.0, data_dir=tmp)
tsm = replay(wt, panel, cols, start=253)
print(f" TSM01 worker={tsm:+.0f}% reference={ref_tsm:+.0f}%")
# ---- TR01 ----
from scripts.analysis.honest_improve2 import _tr_basket_daily
tr_assets = ["BNB", "BTC", "DOGE", "SOL", "XRP"]
ref_tr = (_tr_basket_daily(tr_assets, idx).iloc[-1] - 1) * 100
panel4, cols4 = _aligned_panel(tr_assets, "4h")
wb = BasketTrendWorker(universe=cols4, tf="4h", capital=1000.0, data_dir=tmp)
tr = replay(wb, panel4, cols4, start=101)
print(f" TR01 worker={tr:+.0f}% reference={ref_tr:+.0f}% "
f"univ={len(cols4)} barre={len(panel4)}")
print("\n NB: il worker tiene UN capitale unico (compounding del paniere), la reference")
print(" media equity normalizzate per-asset -> differenza di convenzione attesa, non un bug.")
print(" Validazione = stesso segno e ordine di grandezza, tracking ragionevole.")
finally:
shutil.rmtree(tmp, ignore_errors=True)
if __name__ == "__main__":
main()
@@ -0,0 +1,137 @@
"""Validazione del PortfolioRunner: il modello capitale-POOL + ribilancio giornaliero +
ledger aggregato si comporta come il backtest (Portfolio.backtest)?
Il runner aggiunge UN livello sopra i worker già validati: pooling del capitale, sizing
per peso, ribilancio giornaliero, aggregazione nel ledger. Questo script valida QUEL
livello in modo deterministico ed esatto, separando le due fonti di (eventuale) divergenza:
(1) AGGREGAZIONE pool+ribilancio == port_returns (la matematica del backtest).
Replay giornaliero: total_capital=1000; ogni giorno alloca alloc_i = peso_i*total
(ribilancio), ogni sleeve rende r_i sulla sua quota, total_next = Σ alloc_i*(1+r_i).
Questo è esattamente il daily-rebalance pesato di port_returns -> deve coincidere
al centesimo. Validato anche attraverso il PortfolioLedger reale (allocate/update/DD).
(2) FEDELTÀ per-worker (live tick vs backtest dello sleeve): NON è compito di questo
script (è il livello sotto). Stato noto:
- PAIRS : esatto (scripts/analysis/validate_worker_pairs.py: replay==backtest).
- FADE : APPROSSIMATO. Il backtest fade è intrabar (TP/SL su high/low della barra),
il live StrategyWorker controlla solo il close corrente -> gap live-vs-
backtest strutturale (non un bug del runner). Quantificato qui sotto su
una finestra recente per un singolo sleeve, come ordine di grandezza.
- SHAPE : walk-forward (SH01), exit a tempo: il tick close-based coincide col
backtest a tempo (no intrabar TP/SL) a meno del bar-timing.
Run: uv run python scripts/analysis/validate_portfolio_runner.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from src.portfolio.sleeves import all_sleeve_equities, sleeve_returns_df
from src.portfolio import weighting as W
from src.portfolio.ledger import PortfolioLedger
from scripts.analysis.combine_portfolio import port_returns, metrics, SPLIT
from scripts.portfolios._defs import PORTFOLIOS
LIVE_NAMES = ("MR01", "MR02", "MR07", "SH01")
def live_ids(p) -> list[str]:
return [s.sid for s in p.sleeves if s.kind == "pairs" or s.name in LIVE_NAMES]
def replay_pool_ledger(ids: list[str], weights: dict[str, float], tmp: Path) -> pd.Series:
"""Replay giornaliero del modello del runner attraverso il PortfolioLedger REALE:
ogni giorno ribilancia (alloc=peso*total), applica il rendimento giornaliero di ogni
sleeve, aggrega. Ritorna la serie di equity totale (indicizzata per data)."""
eq = all_sleeve_equities()
rets = pd.DataFrame({i: eq[i].pct_change().fillna(0.0) for i in ids})
ledger = PortfolioLedger("VALIDATE", total_capital=1000.0, data_dir=tmp)
sleeve_cap = {i: weights[i] * ledger.total_capital for i in ids}
out = []
for day, row in rets.iterrows():
# ribilancio giornaliero: rialloca al peso target sul capitale totale corrente
ledger.total_capital = sum(sleeve_cap.values())
alloc = ledger.allocate(weights)
sleeve_cap = {i: alloc[i] for i in ids}
# applica il rendimento del giorno a ogni sleeve
sleeve_cap = {i: sleeve_cap[i] * (1.0 + row[i]) for i in ids}
ledger.update_equity(sleeve_cap)
out.append((day, ledger.equity))
return pd.Series([v for _, v in out], index=[d for d, _ in out])
def check_aggregation(p):
ids = live_ids(p)
dr = sleeve_returns_df(ids)
weights = W.weight_vector(p.weighting, ids, dr, weights=p.weights, caps=p.caps,
clusters={s.sid: (s.cluster or s.sid) for s in p.sleeves}, lookback=p.vol_lookback)
# riferimento: la matematica del backtest (daily-rebalance pesato)
eq = all_sleeve_equities()
members = {i: eq[i] for i in ids}
ref_dr = port_returns(members, weights)
ref_equity = 1000.0 * (1.0 + ref_dr).cumprod()
import tempfile, shutil
tmp = Path(tempfile.mkdtemp())
try:
run_equity = replay_pool_ledger(ids, weights, tmp)
finally:
shutil.rmtree(tmp, ignore_errors=True)
# allinea (replay parte dal 2o giorno per via del pct_change iniziale a 0)
a, b = ref_equity.align(run_equity, join="inner")
rel_err = float((a - b).abs().max() / a.abs().max())
end_ref, end_run = float(a.iloc[-1]), float(b.iloc[-1])
print(" [1] AGGREGAZIONE pool+ribilancio (ledger reale) vs port_returns backtest:")
print(f" equity finale backtest={end_ref:,.2f} runner-replay={end_run:,.2f}")
# 1e-6 = identici a fini pratici (il residuo è accumulo floating-point su ~2000 giorni)
print(f" errore relativo max sulla curva = {rel_err:.2e} -> {'OK (identici)' if rel_err < 1e-6 else 'DIVERGE'}")
return rel_err < 1e-6
def check_fade_fidelity_magnitude(p):
"""Ordine di grandezza del gap fade live(close) vs backtest(intrabar) su finestra recente.
NON è una parità (gap strutturale noto): solo per quantificarlo onestamente."""
from src.data.downloader import load_data
from scripts.analysis.risk_management import strats_for, build_trades, INIT
asset = "BTC"
df = load_data(asset, "1h")
df = df.iloc[-24 * 365:].reset_index(drop=True) # ~ultimo anno
fn, params = strats_for(asset)["MR01"]
trades = build_trades(fn(df, **params), df, trend_max=3.0)
bt_ret = 0.0
cap = INIT
for i, j, ret in sorted(trades, key=lambda t: t[1]):
cap = max(cap + cap * 0.15 * ret, 10.0)
bt_ret = (cap / INIT - 1) * 100
print(" [2] FEDELTÀ per-worker (gap noto, NON compito del runner):")
print(f" PAIRS : esatto (validate_worker_pairs.py)")
print(f" FADE : backtest intrabar MR01 {asset} ultimo anno = {bt_ret:+.1f}% "
f"(il live close-based diverge: vedi nota nel docstring)")
print(f" SHAPE : exit a tempo -> tick close coincide col backtest a meno del bar-timing")
def main():
p = PORTFOLIOS["PORT06"]
print("=" * 92)
print(" VALIDAZIONE PortfolioRunner — PORT06 (sleeve LIVE: fade+pairs+shape)")
print("=" * 92)
ok = check_aggregation(p)
print()
check_fade_fidelity_magnitude(p)
print()
print(" VERDETTO:")
print(f" livello POOL+RIBILANCIO+LEDGER del runner == backtest: {'CERTIFICATO' if ok else 'DA RIVEDERE'}")
print(" fedeltà per-worker: pairs esatta; fade approssimata (gap intrabar noto); shape a tempo ok")
if __name__ == "__main__":
main()
+69
View File
@@ -0,0 +1,69 @@
"""Re-validazione: il StrategyWorker REALE tradi MR01 con edge netto?
Guida il worker vero (generate_signals + nuova logica exit TP/SL/max_bars) su
finestre mobili di dati 1h storici, simulando il polling live. Conferma che
sulla finestra OOS l'edge netto (dopo fee 0.10% RT) sopravvive alla meccanica
del worker (exit su prezzo corrente, piu' conservativa del backtest high/low).
"""
from __future__ import annotations
import contextlib
import os
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from src.data.downloader import load_data
from src.live.strategy_loader import load_strategy
from src.live.strategy_worker import StrategyWorker
OOS_FRAC = 0.30
WIN = 250 # barre per finestra di poll (warmup bb_window=50 + ATR)
def replay(asset: str, params: dict):
df = load_data(asset, "1h").reset_index(drop=True)
n = len(df)
split = int(n * (1 - OOS_FRAC))
strat = load_strategy("MR01_bollinger_fade")
w = StrategyWorker(strat, asset, "1h", capital=1000.0, position_size=0.15,
leverage=3.0, hold_bars=3, params=params,
data_dir=Path(f"/tmp/replay_{asset}"))
w._notify = lambda *a, **k: None
# stato pulito
for attr, val in dict(capital=1000.0, in_position=False, direction=0, entry_price=0,
bars_held=0, total_trades=0, total_wins=0, last_bar_ts=0,
tp=0.0, sl=0.0, max_bars=0).items():
setattr(w, attr, val)
start = max(split, WIN)
with contextlib.redirect_stdout(open(os.devnull, "w")):
for j in range(start, n):
w.tick(df.iloc[j - WIN + 1 : j + 1])
ret = (w.capital / 1000 - 1) * 100
acc = w.total_wins / w.total_trades * 100 if w.total_trades else 0.0
import pandas as pd
period = (f"{pd.to_datetime(df['timestamp'].iloc[start], unit='ms', utc=True).date()}"
f"->{pd.to_datetime(df['timestamp'].iloc[-1], unit='ms', utc=True).date()}")
return w.total_trades, acc, ret, w.capital, period
def main():
print("=" * 90)
print(" RE-VALIDAZIONE WORKER REALE su MR01 (OOS, fee 0.10% RT, leva 3x) — finestra poll 250b")
print("=" * 90)
params = dict(bb_window=50, k=2.5, sl_atr=2.0, max_bars=24)
print(f" {'Asset':>6s}{'Periodo OOS':>26s}{'Trade':>7s}{'Win%':>7s}{'Ret%':>9s}{'Cap€':>9s}")
print(" " + "-" * 80)
for asset in ["BTC", "ETH"]:
t, acc, ret, cap, period = replay(asset, params)
print(f" {asset:>6s}{period:>26s}{t:>7d}{acc:>7.1f}{ret:>+9.1f}{cap:>9.0f}")
print(" " + "-" * 80)
print(" Atteso: Ret% positivo (l'edge mean-reversion sopravvive alla meccanica del worker).")
if __name__ == "__main__":
main()
+74
View File
@@ -0,0 +1,74 @@
"""Valida il PairsWorker: replay bar-per-bar sui dati storici == backtest pairs_sim?
Come validate_worker_mr01 per MR01: alimenta il PairsWorker con finestre trailing
crescenti (simula il feed live) e confronta trade/capitale finale col backtest di
riferimento scripts/analysis/pairs_research.pairs_sim. Se combaciano, la semantica
live (z-score causale, exit |z|<=z_exit o max_bars, fee 2 gambe) e' fedele.
"""
from __future__ import annotations
import shutil
import sys
import tempfile
from pathlib import Path
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from src.live.pairs_worker import PairsWorker
from scripts.analysis.pairs_research import aligned, pairs_sim
from scripts.strategies.PR01_pairs_reversion import PAIRS
WINDOW = 60 # finestra trailing minima (>= n+2): z[i] corretto, replay veloce
def replay(a: str, b: str, params: dict, data_dir: Path) -> PairsWorker:
m = aligned(a, b)
df_a = m[["timestamp"]].copy(); df_a["close"] = m["close_a"].values
df_b = m[["timestamp"]].copy(); df_b["close"] = m["close_b"].values
w = PairsWorker(a, b, "1h", params=params, fee_rt=0.001, data_dir=data_dir)
# replay veloce: niente I/O su file / log / notifiche ad ogni tick (servono solo le metriche finali)
w._save_state = lambda: None
w._log = lambda *a, **k: None
w._notify = lambda *a, **k: None
n = w.n
for k in range(n + 2, len(m) + 1):
lo = max(0, k - WINDOW)
w.tick(df_a.iloc[lo:k], df_b.iloc[lo:k])
# chiudi eventuale posizione aperta a fine serie (come fa il backtest col troncamento)
return w
def main():
print("=" * 96)
print(" VALIDAZIONE PairsWorker — replay live vs backtest pairs_sim (fee 0.20% RT/coppia)")
print("=" * 96)
print(f" {'coppia':<10s}{'WORKER cap':>12s}{'trd':>5s}{'win%':>6s} | {'BACKTEST cap':>13s}{'trd':>5s}{'win%':>6s} match?")
print(" " + "-" * 88)
# Sottoinsieme rappresentativo: il codice del worker e' identico per ogni coppia,
# quindi 2 coppie con strutture diverse (alt/major e major/alt) bastano a provare
# l'equivalenza. ~135s/coppia su 73k barre orarie. Per validarle tutte: usa PAIRS.
subset = [pp for pp in PAIRS if (pp[0], pp[1]) in {("ETH", "BTC"), ("BTC", "LTC")}]
tmp = Path(tempfile.mkdtemp(prefix="pairs_validate_"))
try:
for a, b, p in subset:
w = replay(a, b, p, tmp)
bt = pairs_sim(a, b, **p)
bt_cap = 1000.0 * (1 + bt["ret"] / 100)
cap_match = abs(w.capital - bt_cap) / bt_cap < 0.02 if bt_cap else False
trd_match = abs(w.total_trades - bt["trades"]) <= max(2, bt["trades"] * 0.02)
ok = "OK" if (cap_match and trd_match) else "DIFF"
ww = w.total_wins / w.total_trades * 100 if w.total_trades else 0
print(f" {a+'/'+b:<10s}{w.capital:>12.0f}{w.total_trades:>5d}{ww:>6.1f} | "
f"{bt_cap:>13.0f}{bt['trades']:>5d}{bt['win']:>6.1f} {ok}")
finally:
shutil.rmtree(tmp, ignore_errors=True)
print(" " + "-" * 88)
print(" match = capitale entro 2% e trade entro 2% del backtest. Differenze minime sono")
print(" attese (gestione bar finale/troncamento), ma la semantica deve coincidere.")
if __name__ == "__main__":
main()
+29
View File
@@ -0,0 +1,29 @@
"""Incrementa la versione (semver) nel file VERSION. Default: patch +1.
Uso: uv run python scripts/bump_version.py [major|minor|patch] (default patch)
Stampa la nuova versione. Usato da scripts/deploy.sh ad ogni deploy."""
import sys
from pathlib import Path
VF = Path(__file__).resolve().parents[1] / "VERSION"
def main():
part = sys.argv[1] if len(sys.argv) > 1 else "patch"
cur = VF.read_text().strip() if VF.exists() else "0.0.0"
try:
major, minor, patch = (int(x) for x in cur.split("."))
except Exception:
major, minor, patch = 0, 0, 0
if part == "major":
major, minor, patch = major + 1, 0, 0
elif part == "minor":
minor, patch = minor + 1, 0
else:
patch += 1
new = f"{major}.{minor}.{patch}"
VF.write_text(new + "\n")
print(new)
if __name__ == "__main__":
main()
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Deploy del paper trader a portafoglio: bumpa la VERSIONE, committa, rebuilda l'immagine e
# ricrea il container. La versione (es. v1.0.1) compare nei messaggi Telegram -> sai quale
# codice ha generato quale msg, e aumenta ad OGNI deploy.
#
# Uso: ./scripts/deploy.sh [major|minor|patch] (default patch)
set -euo pipefail
cd "$(dirname "$0")/.."
PART="${1:-patch}"
NEW=$(uv run python scripts/bump_version.py "$PART")
echo ">> nuova versione: v$NEW"
git add VERSION
git commit -q -m "release: v$NEW" || true
echo ">> rebuild immagine + ricrea container"
docker compose up -d --build
sleep 8
docker compose ps --format "{{.Status}}"
echo ">> deploy v$NEW completato"
+28
View File
@@ -0,0 +1,28 @@
"""PORT01 — Honest (default). Report backtest del portafoglio."""
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.portfolios._defs import PORTFOLIOS # noqa: E402
CODE = "PORT01"
def run():
p = PORTFOLIOS[CODE]
r = p.backtest()
print("=" * 80)
print(f" {p.code}{p.label} | pesi={p.weighting} caps={p.caps} leva={p.leverage}x")
print("=" * 80)
print(f" FULL ret {r.full['ret']:+.0f}% CAGR {r.full['cagr']:.0f}% "
f"DD {r.full['dd']:.1f}% Sharpe {r.full['sharpe']:.2f}")
print(f" OOS ret {r.oos['ret']:+.0f}% DD {r.oos['dd']:.1f}% Sharpe {r.oos['sharpe']:.2f}")
print(" per anno:", {y: round(v) for y, v in sorted(r.yearly.items())})
print(" rischio % per sleeve:", {k: round(v, 1) for k, v in
sorted(r.risk.items(), key=lambda x: -x[1])})
if __name__ == "__main__":
run()
+28
View File
@@ -0,0 +1,28 @@
"""PORT02 — Fade master (default). Report backtest del portafoglio."""
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.portfolios._defs import PORTFOLIOS # noqa: E402
CODE = "PORT02"
def run():
p = PORTFOLIOS[CODE]
r = p.backtest()
print("=" * 80)
print(f" {p.code}{p.label} | pesi={p.weighting} caps={p.caps} leva={p.leverage}x")
print("=" * 80)
print(f" FULL ret {r.full['ret']:+.0f}% CAGR {r.full['cagr']:.0f}% "
f"DD {r.full['dd']:.1f}% Sharpe {r.full['sharpe']:.2f}")
print(f" OOS ret {r.oos['ret']:+.0f}% DD {r.oos['dd']:.1f}% Sharpe {r.oos['sharpe']:.2f}")
print(" per anno:", {y: round(v) for y, v in sorted(r.yearly.items())})
print(" rischio % per sleeve:", {k: round(v, 1) for k, v in
sorted(r.risk.items(), key=lambda x: -x[1])})
if __name__ == "__main__":
run()
+28
View File
@@ -0,0 +1,28 @@
"""PORT03 — Master (default). Report backtest del portafoglio."""
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.portfolios._defs import PORTFOLIOS # noqa: E402
CODE = "PORT03"
def run():
p = PORTFOLIOS[CODE]
r = p.backtest()
print("=" * 80)
print(f" {p.code}{p.label} | pesi={p.weighting} caps={p.caps} leva={p.leverage}x")
print("=" * 80)
print(f" FULL ret {r.full['ret']:+.0f}% CAGR {r.full['cagr']:.0f}% "
f"DD {r.full['dd']:.1f}% Sharpe {r.full['sharpe']:.2f}")
print(f" OOS ret {r.oos['ret']:+.0f}% DD {r.oos['dd']:.1f}% Sharpe {r.oos['sharpe']:.2f}")
print(" per anno:", {y: round(v) for y, v in sorted(r.yearly.items())})
print(" rischio % per sleeve:", {k: round(v, 1) for k, v in
sorted(r.risk.items(), key=lambda x: -x[1])})
if __name__ == "__main__":
run()
+28
View File
@@ -0,0 +1,28 @@
"""PORT04 — Master + pairs (default). Report backtest del portafoglio."""
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.portfolios._defs import PORTFOLIOS # noqa: E402
CODE = "PORT04"
def run():
p = PORTFOLIOS[CODE]
r = p.backtest()
print("=" * 80)
print(f" {p.code}{p.label} | pesi={p.weighting} caps={p.caps} leva={p.leverage}x")
print("=" * 80)
print(f" FULL ret {r.full['ret']:+.0f}% CAGR {r.full['cagr']:.0f}% "
f"DD {r.full['dd']:.1f}% Sharpe {r.full['sharpe']:.2f}")
print(f" OOS ret {r.oos['ret']:+.0f}% DD {r.oos['dd']:.1f}% Sharpe {r.oos['sharpe']:.2f}")
print(" per anno:", {y: round(v) for y, v in sorted(r.yearly.items())})
print(" rischio % per sleeve:", {k: round(v, 1) for k, v in
sorted(r.risk.items(), key=lambda x: -x[1])})
if __name__ == "__main__":
run()
@@ -0,0 +1,28 @@
"""PORT05 — Master esteso (default). Report backtest del portafoglio."""
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.portfolios._defs import PORTFOLIOS # noqa: E402
CODE = "PORT05"
def run():
p = PORTFOLIOS[CODE]
r = p.backtest()
print("=" * 80)
print(f" {p.code}{p.label} | pesi={p.weighting} caps={p.caps} leva={p.leverage}x")
print("=" * 80)
print(f" FULL ret {r.full['ret']:+.0f}% CAGR {r.full['cagr']:.0f}% "
f"DD {r.full['dd']:.1f}% Sharpe {r.full['sharpe']:.2f}")
print(f" OOS ret {r.oos['ret']:+.0f}% DD {r.oos['dd']:.1f}% Sharpe {r.oos['sharpe']:.2f}")
print(" per anno:", {y: round(v) for y, v in sorted(r.yearly.items())})
print(" rischio % per sleeve:", {k: round(v, 1) for k, v in
sorted(r.risk.items(), key=lambda x: -x[1])})
if __name__ == "__main__":
run()
+28
View File
@@ -0,0 +1,28 @@
"""PORT06 — Master + shape (default). Report backtest del portafoglio."""
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.portfolios._defs import PORTFOLIOS # noqa: E402
CODE = "PORT06"
def run():
p = PORTFOLIOS[CODE]
r = p.backtest()
print("=" * 80)
print(f" {p.code}{p.label} | pesi={p.weighting} caps={p.caps} leva={p.leverage}x")
print("=" * 80)
print(f" FULL ret {r.full['ret']:+.0f}% CAGR {r.full['cagr']:.0f}% "
f"DD {r.full['dd']:.1f}% Sharpe {r.full['sharpe']:.2f}")
print(f" OOS ret {r.oos['ret']:+.0f}% DD {r.oos['dd']:.1f}% Sharpe {r.oos['sharpe']:.2f}")
print(" per anno:", {y: round(v) for y, v in sorted(r.yearly.items())})
print(" rischio % per sleeve:", {k: round(v, 1) for k, v in
sorted(r.risk.items(), key=lambda x: -x[1])})
if __name__ == "__main__":
run()
View File
+62
View File
@@ -0,0 +1,62 @@
"""Definizioni canoniche dei portafogli (tutti i tipi visti finora)."""
from __future__ import annotations
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from src.portfolio.base import Portfolio, SleeveSpec # noqa: E402
# Universo live tradabile (8 asset con feed Cerbero v2 + parquet). ROT02/TSM01 ci ruotano sopra.
UNIVERSE8 = ["ADA", "BNB", "BTC", "DOGE", "ETH", "LTC", "SOL", "XRP"]
# Edge minimo (solo live): salta i fade/dip il cui TP cade entro 1.5x il costo round-trip
# (perdenti garantiti in regime piatto). Neutro sul backtest storico (0 trade rimossi su
# MR01, +leggero su DIP01), protettivo dal vivo. Solo MR01/DIP01 leggono il param;
# MR02/MR07 lo ignorano (**params). Vedi docs/diary/2026-06-01-tp-min-edge.md.
MIN_TP_FRAC = 0.0015
# Loss-guard Hurst (live): salta le fade in regime PERSISTENTE/trending (rolling-Hurst >= 0.55),
# dove si concentrano stop-loss e perdite (stop-rate 43% vs 21% anti-persistente). DIMEZZA il DD
# del PORT06 (FULL 4.10%->2.39%) alzando lo Sharpe. Calcolato dalle SOLE close (no feed esterno).
# Validato 2026-06-02, vedi docs/diary/2026-06-02-fade-lossguard.md.
HURST_MAX = 0.55
FADE = [SleeveSpec(kind="single", name=c, sid=f"{c}_{a}", asset=a, cluster=f"{a}-rev",
params={"min_tp_frac": MIN_TP_FRAC, "hurst_max": HURST_MAX})
for a in ("BTC", "ETH") for c in ("MR01", "MR02", "MR07")]
HONEST = [
# DIP01: single-asset 1h -> StrategyWorker (Strategy DIP01_dip_buy). TR01/ROT02: multi-asset.
SleeveSpec(kind="single", name="DIP01", sid="DIP01_BTC", asset="BTC", cluster="BTC-rev",
params={"min_tp_frac": MIN_TP_FRAC}),
SleeveSpec(kind="basket", name="TR01", sid="TR01_basket", cluster="trend",
params={"universe": ["BNB", "BTC", "DOGE", "SOL", "XRP"], "tf": "4h"}),
SleeveSpec(kind="rotation", name="ROT02", sid="ROT02_rot", cluster="rotation",
params={"universe": UNIVERSE8, "tf": "1d", "top_k": 3, "gross": 0.45}),
]
PAIRS = [
SleeveSpec(kind="pairs", name="PR01", sid="PR_ETHBTC", a="ETH", b="BTC", cluster="ETH-rev"),
SleeveSpec(kind="pairs", name="PR01", sid="PR_LTCETH", a="LTC", b="ETH", cluster="ETH-rev"),
SleeveSpec(kind="pairs", name="PR01", sid="PR_ADAETH", a="ADA", b="ETH", cluster="ETH-rev"),
SleeveSpec(kind="pairs", name="PR01", sid="PR_BTCLTC", a="BTC", b="LTC", cluster="BTC-rev"),
SleeveSpec(kind="pairs", name="PR01", sid="PR_ETHSOL", a="ETH", b="SOL", cluster="ETH-rev"),
]
TSM = [SleeveSpec(kind="tsmom", name="TSM01", sid="TSM01", cluster="trend",
params={"universe": UNIVERSE8, "tf": "1d",
"horizons": [63, 126, 252], "thr": 1.0, "gross": 0.30})]
SHAPE = [SleeveSpec(kind="ml", name="SH01", sid=f"SH_{a}", asset=a, cluster="shape")
for a in ("BTC", "ETH")]
PORTFOLIOS = {
"PORT01": Portfolio("PORT01", "Honest", HONEST, weighting="equal"),
"PORT02": Portfolio("PORT02", "Fade master", FADE, weighting="equal"),
"PORT03": Portfolio("PORT03", "Master", FADE + HONEST, weighting="equal"),
"PORT04": Portfolio("PORT04", "Master + pairs", FADE + HONEST + PAIRS,
weighting="cap", caps={"PAIRS": 0.33}),
"PORT05": Portfolio("PORT05", "Master esteso", FADE + HONEST + PAIRS + TSM,
weighting="cap", caps={"PAIRS": 0.33}),
"PORT06": Portfolio("PORT06", "Master + shape", FADE + HONEST + PAIRS + TSM + SHAPE,
weighting="cap", caps={"PAIRS": 0.33}, leverage=2.0),
}
+47
View File
@@ -0,0 +1,47 @@
"""Confronto di tutti i portafogli PORT01-06 (backtest in un solo processo).
all_sleeve_equities() è cache-ata: la build (fade+honest+pairs+tsm+shape) avviene una
volta sola, poi i 6 backtest la riusano. Stampa una tabella FULL/OOS e i pesi/rischio.
Run: uv run python scripts/portfolios/compare_all.py
"""
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.portfolios._defs import PORTFOLIOS # noqa: E402
def run():
print("=" * 104)
print(" CONFRONTO PORTAFOGLI PORT01-06 | netto fee, finestra comune 2021-2026, OOS = ultimo 30%")
print("=" * 104)
print(f" {'code':<7s}{'label':<16s}{'n':>3s}{'pesi':>9s}"
f"{'FULLret':>9s}{'CAGR':>6s}{'DD':>6s}{'Shrp':>6s} |"
f"{'OOSret':>8s}{'oDD':>6s}{'oShrp':>7s}")
print(" " + "-" * 100)
rows = []
for code in ("PORT01", "PORT02", "PORT03", "PORT04", "PORT05", "PORT06"):
p = PORTFOLIOS[code]
r = p.backtest()
cap = f"{p.weighting}" + (f"{int(p.caps['PAIRS']*100)}" if p.caps else "")
print(f" {p.code:<7s}{p.label:<16s}{len(p.sleeves):>3d}{cap:>9s}"
f"{r.full['ret']:>+9.0f}{r.full['cagr']:>6.0f}{r.full['dd']:>6.1f}{r.full['sharpe']:>6.2f} |"
f"{r.oos['ret']:>+8.0f}{r.oos['dd']:>6.1f}{r.oos['sharpe']:>7.2f}")
rows.append((code, r))
# miglior per Sharpe OOS e per DD OOS
best_sharpe = max(rows, key=lambda x: x[1].oos["sharpe"])
best_dd = min(rows, key=lambda x: x[1].oos["dd"])
print(" " + "-" * 100)
print(f" miglior Sharpe OOS: {best_sharpe[0]} ({best_sharpe[1].oos['sharpe']:.2f}) "
f"miglior DD OOS: {best_dd[0]} ({best_dd[1].oos['dd']:.1f}%)")
print("\n PORT06 (default) — top contributi al rischio:")
r6 = dict(rows)["PORT06"]
top = sorted(r6.risk.items(), key=lambda x: -x[1])[:6]
print(" " + " ".join(f"{k}={v:.0f}%" for k, v in top))
if __name__ == "__main__":
run()
+192
View File
@@ -0,0 +1,192 @@
"""Report orario PORT06 -> Telegram.
Legge lo stato persistito del paper trader a portafoglio (data/portfolio_paper/*/ +
data/portfolios/PORT06/status.json) e invia su Telegram:
1) trade CHIUSI: positivi/negativi (netto fee) con breakdown per motivo e PnL;
2) trade IN CORSO (posizioni aperte);
3) PnL realizzato totale + equity mark-to-market.
Eseguibile standalone (es. da cron orario):
cd /opt/docker/PythagorasGoal && uv run python scripts/portfolios/hourly_report.py
Carica .env da solo (cron non eredita l'env del container). Legge file world-readable
scritti dal container; non tocca lo stato del trader.
"""
from __future__ import annotations
import json
import glob
from collections import defaultdict
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
PAPER = ROOT / "data" / "portfolio_paper"
PORT_STATUS = ROOT / "data" / "portfolios" / "PORT06" / "status.json"
def _load_env():
"""Carica TELEGRAM_* da .env nell'os.environ (cron non li ha)."""
import os
envf = ROOT / ".env"
if not envf.exists():
return
for line in envf.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip())
def _short(wid: str) -> str:
"""SH01_shape_ml__BTC__1h -> SH01/BTC ; PR01_..._ETH_SOL__1h -> PR01/ETH_SOL."""
parts = wid.split("__")
code = parts[0].split("_")[0]
tag = parts[1] if len(parts) > 1 else ""
return f"{code}/{tag}" if tag else code
# --- monitor loss-guard Hurst: stop-rate fade PRIMA/DOPO l'attivazione (hurst_max=0.55, v1.0.0) ---
LOSSGUARD_SINCE = "2026-06-02T14:34:30"
FADE_PREFIXES = ("MR01", "MR02", "MR07") # le 3 fade con hurst_max attivo
LOSSGUARD_MIN_SAMPLE = 30
def lossguard_section() -> str:
before = [0, 0] # [closes, stops] prima dell'attivazione
after = [0, 0] # dopo
for sp in glob.glob(str(PAPER / "*" / "status.json")):
wid = Path(sp).parent.name
if not wid.startswith(FADE_PREFIXES):
continue
tp = Path(sp).parent / "trades.jsonl"
if not tp.exists():
continue
for line in tp.read_text().splitlines():
if not line.strip():
continue
ev = json.loads(line)
if ev.get("event") != "CLOSE":
continue
b = after if ev.get("ts", "") >= LOSSGUARD_SINCE else before
b[0] += 1
if ev.get("reason") == "stop_loss":
b[1] += 1
def rate(b):
return b[1] / b[0] * 100 if b[0] else 0.0
L = [f"🛡️ <b>Loss-guard Hurst</b> (fade, dal {LOSSGUARD_SINCE[:16].replace('T', ' ')} UTC)"]
L.append(f" stop-rate PRIMA {rate(before):.0f}% (n={before[0]}) → DOPO {rate(after):.0f}% (n={after[0]})")
if after[0] >= LOSSGUARD_MIN_SAMPLE:
delta = rate(before) - rate(after)
L.append(f" VERDETTO (n≥{LOSSGUARD_MIN_SAMPLE}): {delta:+.0f}pp → "
f"{'✅ riduce gli stop' if delta > 0 else '⚠️ nessuna riduzione'}")
else:
L.append(f" campione DOPO {after[0]}/{LOSSGUARD_MIN_SAMPLE} → verdetto rimandato")
return "\n".join(L)
def collect():
closed = [] # (sleeve, reason, net_return, pnl, win)
open_pos = [] # dict per posizione aperta
realized = 0.0
for sp in sorted(glob.glob(str(PAPER / "*" / "status.json"))):
d = Path(sp).parent
wid = d.name
st = json.loads(Path(sp).read_text())
tp = d / "trades.jsonl"
if tp.exists():
for line in tp.read_text().splitlines():
if not line.strip():
continue
ev = json.loads(line)
if ev.get("event") != "CLOSE":
continue
nr = ev.get("net_return", 0.0)
pnl = ev.get("pnl", 0.0)
realized += pnl
closed.append((_short(wid), ev.get("reason", "?"), nr, pnl, nr > 0))
if st.get("in_position"):
open_pos.append({
"sleeve": _short(wid),
"dir": st.get("direction", 0),
"entry": st.get("entry_price") or st.get("entry_a"),
"entry_b": st.get("entry_b"),
"bars": st.get("bars_held", 0),
"cap": st.get("capital", 0.0),
})
return closed, open_pos, realized
def build_report() -> str:
closed, open_pos, realized = collect()
pos = sum(1 for c in closed if c[4])
neg = len(closed) - pos
# breakdown per motivo
by_reason = defaultdict(lambda: [0, 0, 0.0]) # reason -> [win, loss, pnl]
for _, reason, _, pnl, win in closed:
r = by_reason[reason]
r[0 if win else 1] += 1
r[2] += pnl
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
try:
ver = (ROOT / "VERSION").read_text().strip()
except Exception:
ver = "?"
eq = dd = cap = None
if PORT_STATUS.exists():
ps = json.loads(PORT_STATUS.read_text())
eq, cap, dd = ps.get("equity"), ps.get("total_capital"), ps.get("max_dd")
L = [f"📊 <b>PORT06 — Report orario</b> <code>v{ver}</code>", now]
if eq is not None:
L.append(f"Equity €{eq:.2f} | Cap €{cap:.2f} | maxDD {dd:.3f}%")
# 1) CHIUSI
L.append(f"\n✅ <b>CHIUSI</b>: {pos} positivi / {neg} negativi (netto fee)")
rows = [f"{'motivo':<12}{'':>3}{'':>4}{'PnL€':>9}"]
for reason, (w, l, pnl) in sorted(by_reason.items(), key=lambda x: x[1][2]):
rows.append(f"{reason:<12}{w:>3}{l:>4}{pnl:>+9.2f}")
L.append("<pre>" + "\n".join(rows) + "</pre>")
# 2) IN CORSO
L.append(f"🟢 <b>IN CORSO</b>: {len(open_pos)} posizioni")
if open_pos:
rows = [f"{'sleeve':<14}{'d':<2}{'barre':>6} {'entry'}"]
for p in sorted(open_pos, key=lambda x: x["sleeve"]):
d = "L" if p["dir"] == 1 else "S" if p["dir"] == -1 else "-"
entry = p["entry"]
es = f"{entry:.6g}" if isinstance(entry, (int, float)) else str(entry)
if p["entry_b"]:
es = f"{entry:.6g}/{p['entry_b']:.6g}" # coppia: 2 gambe
rows.append(f"{p['sleeve']:<14}{d:<2}{p['bars']:>6} {es}")
L.append("<pre>" + "\n".join(rows) + "</pre>")
# 2b) monitor loss-guard
L.append(lossguard_section())
# 3) TOTALE
L.append(f"💰 <b>PnL realizzato totale: €{realized:+.2f}</b>")
if eq is not None:
unreal = eq - cap
L.append(f" equity mark-to-market: €{eq:.2f} (non realizz. €{unreal:+.2f})")
return "\n".join(L)
def main():
_load_env()
import sys
sys.path.insert(0, str(ROOT))
from src.live.telegram_notifier import send_telegram
report = build_report()
print(report)
ok = send_telegram(report)
print("\n[telegram]", "inviato" if ok else "NON inviato (token/chat mancanti o errore rete)")
if __name__ == "__main__":
main()
+58
View File
@@ -0,0 +1,58 @@
"""DIP01 — Dip-buy mean-reversion single-asset (z-score sotto-banda). Honest family.
Replica live della logica validata in scripts/analysis/honest_improve2.dip_market_gated
(con market_n=0, come lo sleeve DIP01_BTC del portafoglio): compra quando lo z-score del
prezzo rispetto a SMA(n) incrocia sotto -z_in; esce a TP=SMA, SL=close-sl_atr*ATR, o max_bars.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from src.strategies.base import Strategy, Signal # noqa: E402
def _atr(df, n=14):
h, l, c = df["high"].values, df["low"].values, df["close"].values
pc = np.roll(c, 1); pc[0] = c[0]
tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc)))
return pd.Series(tr).rolling(n).mean().values
class Dip01DipBuy(Strategy):
name = "DIP01_dip_buy"
description = "Dip-buy mean-reversion single-asset (z-score), exit TP=SMA/SL=ATR/max_bars"
default_assets = ["BTC"]
default_timeframes = ["1h"]
fee_rt = 0.001
leverage = 3.0
position_size = 0.15
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
n: int = 50, z_in: float = 2.5, sl_atr: float = 2.5,
max_bars: int = 24, **params) -> list[Signal]:
c = df["close"].values
ma = pd.Series(c).rolling(n).mean().values
sd = pd.Series(c).rolling(n).std().values
a = _atr(df, 14)
z = (c - ma) / np.where(sd == 0, np.nan, sd)
# Edge minimo: salta i dip il cui TP (la media) è entro il costo round-trip. 0 = off.
min_tp_frac = params.get("min_tp_frac", 0.0)
out: list[Signal] = []
for i in range(n + 14, len(c)):
if np.isnan(z[i]) or np.isnan(a[i]) or np.isnan(ma[i]):
continue
if z[i] <= -z_in and z[i - 1] > -z_in:
if min_tp_frac > 0 and abs(ma[i] - c[i]) / c[i] <= min_tp_frac:
continue # TP entro le fee -> non eseguibile in utile
out.append(Signal(idx=i, direction=1, entry_price=float(c[i]),
metadata={"tp": float(ma[i]),
"sl": float(c[i] - sl_atr * a[i]),
"max_bars": int(max_bars)}))
return out
+152
View File
@@ -0,0 +1,152 @@
"""DIP01 — Dip-Buy Z-Score Reversion (long-only).
Variante robusta e ONESTA della famiglia mean-reversion: compra SOLO i dip
(close a z<=-z_in deviazioni sotto la media mobile) e prende profitto al rientro
verso la media. Niente short: nel campione 2018-2026 shortare cripto perde OOS
sistematicamente (vedi scripts/analysis/honest_final.py).
Logica:
1. z-score = (close - SMA(n)) / STD(n)
2. ENTRY long quando z attraversa al ribasso -z_in (capitolazione)
3. EXIT: take-profit alla media mobile, stop-loss a sl_atr*ATR sotto l'entry,
o time-limit max_bars
4. ingresso a close[i] (eseguibile dal vivo, nessun look-ahead)
Validazione (netto, fee 0.10% RT Deribit, leva 3x, OOS = ultimo 30%):
BTC 1h: FULL +298% / OOS +59% / DD 23% / 7-9 anni positivi
ETH 1h: FULL +190% / OOS +224% / DD 54%
SOL 1h: FULL +50% / OOS +13% / DD 25%
Regge lo sweep fee fino a 0.20% RT (BTC OOS +45% anche a 0.20%).
Robusto su BTC/ETH/SOL (asset major); sugli alt molto parabolici (DOGE/BNB)
non ha edge -> usare solo su BTC/ETH/SOL.
Compatibile con StrategyWorker: ogni Signal porta tp/sl/max_bars in metadata.
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from src.strategies.base import Strategy, Signal, BacktestResult, YearlyStats, TF_MINUTES
from src.data.downloader import load_data
def _atr(df: pd.DataFrame, n: int = 14) -> np.ndarray:
h, l, c = df["high"].values, df["low"].values, df["close"].values
pc = np.roll(c, 1); pc[0] = c[0]
tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc)))
return pd.Series(tr).rolling(n).mean().values
class DipReversion(Strategy):
name = "DIP01_dip_reversion"
description = "Long-only dip-buy z-score reversion, TP alla media"
default_assets = ["BTC", "ETH", "SOL"]
default_timeframes = ["1h"]
fee_rt = 0.001
leverage = 3.0
position_size = 0.15
initial_capital = 1000.0
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
**params) -> list[Signal]:
c = df["close"].values
n = params.get("n", 50)
z_in = params.get("z_in", 2.5)
sl_atr = params.get("sl_atr", 2.5)
max_bars = params.get("max_bars", 24)
ma = pd.Series(c).rolling(n).mean().values
sd = pd.Series(c).rolling(n).std().values
a = _atr(df, 14)
z = (c - ma) / np.where(sd == 0, np.nan, sd)
signals: list[Signal] = []
for i in range(n + 14, len(c)):
if np.isnan(z[i]) or np.isnan(a[i]):
continue
if z[i] <= -z_in and z[i - 1] > -z_in:
signals.append(Signal(
idx=i, direction=1, entry_price=c[i],
metadata={"tp": float(ma[i]), "sl": float(c[i] - sl_atr * a[i]),
"max_bars": max_bars},
))
return signals
def backtest(self, asset: str, tf: str = "1h", hold: int = 3,
**params) -> BacktestResult | None:
df = load_data(asset, tf)
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
signals = self.generate_signals(df, ts, **params)
if not signals:
return None
h, l, c = df["high"].values, df["low"].values, df["close"].values
n = len(c)
fee = self.fee_rt * self.leverage
capital = peak = float(self.initial_capital)
max_dd = 0.0
total_bars = 0
last_exit = -1
yearly: dict[int, dict] = {}
for sig in signals:
i, d = sig.idx, sig.direction
if i <= last_exit or i + 1 >= n:
continue
entry = c[i]
tp, sl, mb = sig.metadata["tp"], sig.metadata["sl"], sig.metadata["max_bars"]
exit_p = c[min(i + mb, n - 1)]
j = min(i + mb, n - 1)
for step in range(1, mb + 1):
j = i + step
if j >= n:
j = n - 1; exit_p = c[j]; break
hit_sl = (d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl)
hit_tp = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
if hit_sl:
exit_p = sl; break
if hit_tp:
exit_p = tp; break
if step == mb:
exit_p = c[j]
ret = (exit_p - entry) / entry * d * self.leverage - fee
capital = max(capital + capital * self.position_size * ret, 10.0)
if capital > peak:
peak = capital
max_dd = max(max_dd, (peak - capital) / peak)
total_bars += (j - i)
last_exit = j
year = ts.iloc[i].year
yr = yearly.setdefault(year, {"w": 0, "t": 0, "pnl": 0.0})
yr["t"] += 1
if ret > 0:
yr["w"] += 1
yr["pnl"] += ret * self.initial_capital
all_t = sum(v["t"] for v in yearly.values())
all_w = sum(v["w"] for v in yearly.values())
if all_t == 0:
return None
yearly_stats = [YearlyStats(y, v["t"], v["w"], v["pnl"]) for y, v in sorted(yearly.items())]
return BacktestResult(
strategy_name=self.name, asset=asset, timeframe=tf, params=params,
trades=all_t, wins=all_w, pnl=sum(v["pnl"] for v in yearly.values()),
capital=capital, initial_capital=self.initial_capital,
max_dd=max_dd * 100, time_in_market_pct=total_bars / n * 100,
avg_trade_duration_h=total_bars / all_t * TF_MINUTES.get(tf, 60) / 60,
years_active=len(yearly), yearly=yearly_stats,
)
if __name__ == "__main__":
strat = DipReversion()
print(f"{'=' * 100}")
print(f" DIP01 DIP-BUY REVERSION — netto fee {strat.fee_rt*100:.2f}% RT, leva {strat.leverage:.0f}x")
print(f"{'=' * 100}")
for asset in ["BTC", "ETH", "SOL"]:
r = strat.backtest(asset, "1h", n=50, z_in=2.5, sl_atr=2.5, max_bars=24)
if r:
r.strategy_name = f"DIP01 {asset} 1h"
r.print_summary()
+123
View File
@@ -0,0 +1,123 @@
"""FR01 — Hurst-Calm Fade (FRATTALE x REGIME). Esito della ricerca a 100 agenti (2026-06-02).
Fade della banda di Bollinger (k=2.5 su SMA50, TP=media, SL=2*ATR, max_bars=24) ATTIVATO
SOLO quando coincidono due condizioni di regime:
- FRATTALE: rolling Hurst < 0.55 (regime anti-persistente -> la mean-reversion ha senso
fratalmente; con Hurst>0.55 il fade peggiora, il momentum perde comunque).
- VOLATILITA: dvol_pct < 0.40 (DVOL nel terzile basso del suo storico -> regime calmo/range).
Doppio gate frattale x regime: l'INTERAZIONE e' l'ingrediente attivo, non il fade di per se'
(ablation: senza gate Sharpe ~0.8 e muore a fee 0.2% RT; col doppio gate OOS Sharpe ~3.7).
VALIDAZIONE (netto fee 0.10% RT, leva 3x, OOS ultimo 30%, ricerca fractal_argo_workflow):
BTC 1h: 198 trade, FULL +100% / OOS +54% / Sharpe OOS 3.73 / DD OOS 5.1% / 6/6 anni positivi,
regge fee 0.2% RT. Confermato avversarialmente (no look-ahead, split alternativo).
Generalizza a ETH 1h (Sharpe ~2.6, secondario). 4h/1d = rumore (pochi trade).
Correlazione coi fade esistenti BASSA: MR01 +0.17, MR02 +0.08, MR07 -0.03 -> DIVERSIFICATORE
quasi-ortogonale (profilo SH01/pairs), NON ridondante. Esposizione ~1-9% -> low-frequency.
RUOLO: diversificatore a basso DD per il MASTER/PORT06, NON motore standalone (non batte il
portafoglio da solo). Coerente coi priori: i frattali da soli sono rumore; il valore e' nel
gating del regime. NB il prior ARGO "VRP>0=range=fade" e' SMENTITO: l'edge robusto e' su VRP<0
e su DVOL bassa (questo gate dvol_pct<0.4), non su vol alta.
DIPENDENZA REGIME (caveat deploy): il gate usa DVOL/dvol_pct. Per il BACKTEST le feature
arrivano da regime_lab (cache da Deribit mainnet). Per il LIVE serve un feed DVOL in produzione
(regime_fetcher + allineamento causale nel runner) -> wiring NON ancora fatto. Finche' manca,
FR01 e' validata-in-ricerca ma non deployabile live.
Run backtest: uv run python scripts/strategies/FR01_hurst_calm_fade.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from src.strategies.base import Strategy, Signal # noqa: E402
from src.fractal.indicators import rolling_hurst # noqa: E402
def _atr(df: pd.DataFrame, n: int = 14) -> np.ndarray:
h, l, c = df["high"].values, df["low"].values, df["close"].values
pc = np.roll(c, 1); pc[0] = c[0]
tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc)))
return pd.Series(tr).rolling(n).mean().values
class HurstCalmFade(Strategy):
name = "FR01_hurst_calm_fade"
description = "Fade Bollinger gateato da Hurst<0.55 (anti-persistente) + DVOL bassa (calm)"
default_assets = ["BTC", "ETH"]
default_timeframes = ["1h"]
fee_rt = 0.001
leverage = 3.0
position_size = 0.15
initial_capital = 1000.0
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex, **params) -> list[Signal]:
bb_w = params.get("bb_window", 50)
k = params.get("k", 2.5)
sl_atr = params.get("sl_atr", 2.0)
max_bars = params.get("max_bars", 24)
hurst_thr = params.get("hurst_thr", 0.55)
hurst_win = params.get("hurst_win", 100)
dvol_pct_thr = params.get("dvol_pct_thr", 0.40)
c = df["close"].values.astype(float)
n = len(c)
ma = pd.Series(c).rolling(bb_w).mean().values
sd = pd.Series(c).rolling(bb_w).std().values
a = _atr(df, 14)
hurst = rolling_hurst(c, window=hurst_win) # causale (returns[i-win:i])
# dvol_pct: dalla colonna se presente (regime_lab.load_features), altrimenti gate OFF
dvol_pct = df["dvol_pct"].values if "dvol_pct" in df.columns else np.full(n, np.nan)
signals: list[Signal] = []
for i in range(bb_w + 14, n):
if np.isnan(sd[i]) or np.isnan(a[i]) or sd[i] == 0:
continue
# GATE FRATTALE x REGIME (tutto noto a i)
if hurst[i] >= hurst_thr:
continue
if "dvol_pct" in df.columns:
if np.isnan(dvol_pct[i]) or dvol_pct[i] >= dvol_pct_thr:
continue
up, lo = ma[i] + k * sd[i], ma[i] - k * sd[i]
if c[i] < lo:
d, sl = 1, c[i] - sl_atr * a[i]
elif c[i] > up:
d, sl = -1, c[i] + sl_atr * a[i]
else:
continue
signals.append(Signal(
idx=i, direction=d, entry_price=float(c[i]),
metadata={"tp": float(ma[i]), "sl": float(sl), "max_bars": int(max_bars)},
))
return signals
if __name__ == "__main__":
# backtest via l'harness onesto + feature di regime_lab (DVOL reale)
from scripts.analysis.regime_lab import load_features, report
from scripts.analysis.explore_lab import robust
strat = HurstCalmFade()
print(f"{'=' * 100}")
print(f" FR01 HURST-CALM FADE — netto fee {strat.fee_rt*100:.2f}% RT, leva {strat.leverage:.0f}x")
print(f" gate: hurst<0.55 (anti-persistente) + dvol_pct<0.40 (DVOL bassa)")
print(f"{'=' * 100}")
for asset in ("BTC", "ETH"):
df = load_features(asset, "1h")
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
sigs = strat.generate_signals(df, ts)
ent = [{"i": s.idx, "d": s.direction, "tp": s.metadata["tp"],
"sl": s.metadata["sl"], "max_bars": s.metadata["max_bars"]} for s in sigs]
res = report(f"FR01_{asset}_1h", ent, df)
print(f" -> {asset}: robust={robust(res)} OOS Sharpe={res['oos']['sharpe']:.2f} "
f"OOS ret={res['oos']['ret']:+.0f}% DD={res['full']['dd']:.0f}%")
+183
View File
@@ -0,0 +1,183 @@
"""MR01 — Bollinger Fade (mean-reversion).
L'UNICA famiglia con edge netto reale dopo l'analisi out-of-sample fee-aware
(vedi scripts/analysis/strategy_research.py). Contrario della tesi squeeze:
i breakout RIENTRANO, quindi si fada l'estremo verso la media.
Logica:
1. Bollinger Band (window n, k deviazioni) sul close
2. ENTRY: close esce sotto la banda inferiore -> long (o sopra la superiore -> short)
3. EXIT: take-profit alla media mobile (il rientro atteso),
stop-loss a sl_atr*ATR oltre l'estremo, oppure time-limit max_bars
4. ingresso a close[i] (eseguibile dal vivo, nessun look-ahead)
Validazione (netto, fee 0.10% RT reale Deribit, leva 3x, OOS = ultimo 30%):
BTC 1h n=50 k=2.5: +201% OOS, DD 15%, ~tutti gli anni positivi
ETH 1h n=50 k=2.0: +1238% OOS, DD 23%
Robusto su TUTTA la griglia n in {14,20,30,50} x k in {2.0,2.5,3.0}
e su tutte le fee 0.00-0.20% RT (margine di sicurezza ampio).
NOTA LIVE: usa TP alla media + SL ad ATR + max_bars. Lo StrategyWorker attuale
esce solo a hold_bars/stop -2% fisso: per tradarla come validata il worker deve
supportare gli exit TP/SL passati in metadata (vedi metadata di ogni Signal).
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from src.strategies.base import Strategy, Signal, BacktestResult, YearlyStats, TF_MINUTES
from src.data.downloader import load_data
from src.strategies.fade_base import hurst_skip_mask
def _atr(df: pd.DataFrame, n: int = 14) -> np.ndarray:
h, l, c = df["high"].values, df["low"].values, df["close"].values
pc = np.roll(c, 1); pc[0] = c[0]
tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc)))
return pd.Series(tr).rolling(n).mean().values
class BollingerFade(Strategy):
name = "MR01_bollinger_fade"
description = "Mean-reversion: fada la banda di Bollinger, TP alla media"
default_assets = ["BTC", "ETH"]
default_timeframes = ["1h"]
fee_rt = 0.001 # Deribit perp realistico (taker 0.05%/lato)
leverage = 3.0
position_size = 0.15
initial_capital = 1000.0
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
**params) -> list[Signal]:
c = df["close"].values
n_len = len(c)
bb_w = params.get("bb_window", 50)
k = params.get("k", 2.5)
sl_atr = params.get("sl_atr", 2.0)
max_bars = params.get("max_bars", 24)
trend_max = params.get("trend_max") # None = filtro disattivo
ema_long = params.get("ema_long", 200)
# Edge minimo: salta i segnali il cui TP (la media) è più vicino dell'entry del
# costo round-trip -> perdenti garantiti anche colpendo il TP. 0 = off.
min_tp_frac = params.get("min_tp_frac", 0.0)
# Loss-guard Hurst: salta in regime persistente/trending (hurst >= soglia). None = off.
hurst_max = params.get("hurst_max")
ma = pd.Series(c).rolling(bb_w).mean().values
sd = pd.Series(c).rolling(bb_w).std().values
a = _atr(df, 14)
up, lo = ma + k * sd, ma - k * sd
el = pd.Series(c).ewm(span=ema_long, adjust=False).mean().values if trend_max is not None else None
skip = hurst_skip_mask(df, hurst_max, params.get("hurst_win", 100))
signals: list[Signal] = []
for i in range(bb_w + 14, n_len):
if np.isnan(up[i]) or np.isnan(a[i]):
continue
if skip[i]:
continue # loss-guard: regime persistente
if el is not None and (a[i] == 0 or np.isnan(el[i]) or abs(c[i] - el[i]) / a[i] > trend_max):
continue
if c[i] < lo[i] and c[i - 1] >= lo[i - 1]:
d, sl = 1, c[i] - sl_atr * a[i]
elif c[i] > up[i] and c[i - 1] <= up[i - 1]:
d, sl = -1, c[i] + sl_atr * a[i]
else:
continue
if min_tp_frac > 0 and abs(ma[i] - c[i]) / c[i] <= min_tp_frac:
continue # TP entro le fee -> non eseguibile in utile
signals.append(Signal(
idx=i, direction=d, entry_price=c[i],
metadata={"tp": float(ma[i]), "sl": float(sl), "max_bars": max_bars},
))
return signals
def backtest(self, asset: str, tf: str = "1h", hold: int = 3,
**params) -> BacktestResult | None:
"""Backtest fedele: TP alla media / SL ad ATR / time-limit, fee+leva nette."""
df = load_data(asset, tf)
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
signals = self.generate_signals(df, ts, **params)
if not signals:
return None
h, l, c = df["high"].values, df["low"].values, df["close"].values
n = len(c)
fee = self.fee_rt * self.leverage
capital = peak = float(self.initial_capital)
max_dd = 0.0
total_bars = 0
last_exit = -1
yearly: dict[int, dict] = {}
for sig in signals:
i, d = sig.idx, sig.direction
if i <= last_exit or i + 1 >= n:
continue
entry = c[i]
tp, sl, mb = sig.metadata["tp"], sig.metadata["sl"], sig.metadata["max_bars"]
exit_p = c[min(i + mb, n - 1)]
j = min(i + mb, n - 1)
for step in range(1, mb + 1):
j = i + step
if j >= n:
j = n - 1; exit_p = c[j]; break
hit_sl = (d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl)
hit_tp = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
if hit_sl:
exit_p = sl; break
if hit_tp:
exit_p = tp; break
if step == mb:
exit_p = c[j]
ret = (exit_p - entry) / entry * d * self.leverage - fee
capital = max(capital + capital * self.position_size * ret, 10.0)
if capital > peak:
peak = capital
max_dd = max(max_dd, (peak - capital) / peak)
total_bars += (j - i)
last_exit = j
year = ts.iloc[i].year
yr = yearly.setdefault(year, {"w": 0, "t": 0, "pnl": 0.0})
yr["t"] += 1
if ret > 0:
yr["w"] += 1
yr["pnl"] += ret * self.initial_capital
all_t = sum(v["t"] for v in yearly.values())
all_w = sum(v["w"] for v in yearly.values())
if all_t == 0:
return None
yearly_stats = [YearlyStats(y, v["t"], v["w"], v["pnl"]) for y, v in sorted(yearly.items())]
return BacktestResult(
strategy_name=self.name, asset=asset, timeframe=tf, params=params,
trades=all_t, wins=all_w, pnl=sum(v["pnl"] for v in yearly.values()),
capital=capital, initial_capital=self.initial_capital,
max_dd=max_dd * 100, time_in_market_pct=total_bars / n * 100,
avg_trade_duration_h=total_bars / all_t * TF_MINUTES.get(tf, 60) / 60,
years_active=len(yearly), yearly=yearly_stats,
)
if __name__ == "__main__":
strat = BollingerFade()
print(f"{'=' * 110}")
print(f" MR01 BOLLINGER FADE — netto fee {strat.fee_rt*100:.2f}% RT, leva {strat.leverage:.0f}x")
print(f"{'=' * 110}")
results = []
for asset in ["BTC", "ETH"]:
for k in [2.0, 2.5]:
r = strat.backtest(asset, "1h", bb_window=50, k=k, sl_atr=2.0, max_bars=24)
if r:
r.strategy_name = f"MR01 {asset} 1h n50 k{k}"
results.append(r)
for r in results:
r.print_summary()
if results:
results[0].print_yearly()
+91
View File
@@ -0,0 +1,91 @@
"""MR02 — Donchian Fade (mean-reversion sugli estremi del canale).
L'opposto esatto del trend-following Donchian (che PERDE netto: vedi
scripts/analysis/strategy_research.py). Coerente con la lezione squeeze:
i breakout RIENTRANO, quindi si fada la rottura del canale verso il centro.
Logica:
1. Canale Donchian: massimo/minimo delle ultime n barre (escludendo la corrente)
2. ENTRY: close rompe SOPRA il massimo del canale -> SHORT (fade);
close rompe SOTTO il minimo -> LONG. Ingresso a close[i] (eseguibile).
3. EXIT: take-profit al centro del canale (il rientro atteso),
stop-loss a sl_atr*ATR oltre l'estremo, time-limit max_bars.
Validazione (netto, fee 0.10% RT reale Deribit, leva 3x, OOS = ultimo 30%):
BTC 1h n=20: +879% FULL / +171% OOS, DD 30%, 8/9 anni positivi
ETH 1h n=20: enorme FULL / +8452% OOS, DD 42%
Robusto su TUTTA la griglia n in {10,20,30,50} x sl_atr in {1.5,2.0,3.0}
(BTC+ETH 1h sempre positivo OOS) e su tutte le fee 0.00-0.20% RT.
Ricerca completa: scripts/analysis/strategy_research_v2.py.
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from src.strategies.base import Signal
from src.strategies.fade_base import FadeStrategy, atr, trend_distance, hurst_skip_mask
class DonchianFade(FadeStrategy):
name = "MR02_donchian_fade"
description = "Mean-reversion: fada la rottura del canale Donchian, TP al centro"
default_assets = ["BTC", "ETH"]
default_timeframes = ["1h"]
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
**params) -> list[Signal]:
n = params.get("n", 20)
sl_atr = params.get("sl_atr", 2.0)
max_bars = params.get("max_bars", 24)
trend_max = params.get("trend_max") # None = filtro disattivo
ema_long = params.get("ema_long", 200)
# Edge minimo: salta i fade il cui TP (midpoint canale) è entro il costo RT. 0 = off.
min_tp_frac = params.get("min_tp_frac", 0.0)
# Loss-guard Hurst: salta in regime persistente/trending (hurst >= soglia). None = off.
hurst_max = params.get("hurst_max")
h, l, c = df["high"].values, df["low"].values, df["close"].values
hh = pd.Series(h).rolling(n).max().shift(1).values
ll = pd.Series(l).rolling(n).min().shift(1).values
a = atr(df, 14)
td = trend_distance(df, ema_long) if trend_max is not None else None
skip = hurst_skip_mask(df, hurst_max, params.get("hurst_win", 100))
signals: list[Signal] = []
for i in range(n + 14, len(c)):
if np.isnan(hh[i]) or np.isnan(a[i]):
continue
if skip[i]:
continue # loss-guard: regime persistente
if td is not None and (np.isnan(td[i]) or td[i] > trend_max):
continue
mid = (hh[i] + ll[i]) / 2.0
if c[i] > hh[i] and c[i - 1] <= hh[i - 1]: # rottura rialzista -> fade short
d, sl = -1, c[i] + sl_atr * a[i]
elif c[i] < ll[i] and c[i - 1] >= ll[i - 1]: # rottura ribassista -> fade long
d, sl = 1, c[i] - sl_atr * a[i]
else:
continue
if min_tp_frac > 0 and abs(mid - c[i]) / c[i] <= min_tp_frac:
continue # TP entro le fee -> non eseguibile in utile
signals.append(Signal(
idx=i, direction=d, entry_price=c[i],
metadata={"tp": float(mid), "sl": float(sl), "max_bars": max_bars},
))
return signals
if __name__ == "__main__":
strat = DonchianFade()
print("=" * 110)
print(f" MR02 DONCHIAN FADE — netto fee {strat.fee_rt*100:.2f}% RT, leva {strat.leverage:.0f}x")
print("=" * 110)
for asset in ["BTC", "ETH"]:
r = strat.backtest(asset, "1h", n=20, sl_atr=2.0, max_bars=24)
if r:
r.strategy_name = f"MR02 {asset} 1h n20"
r.print_summary()
r.print_yearly()
@@ -0,0 +1,97 @@
"""MR07 — Return Reversal (fade del movimento di barra estremo).
Meccanismo distinto da MR01/MR02/MR03: non guarda i LIVELLI di prezzo (bande,
canali) ma la VOLATILITA' dei rendimenti. Quando una singola barra si muove di
piu' di k deviazioni standard rolling dei rendimenti, e' un'over-reaction che
tende a rientrare: si fada nella direzione opposta. Coerente con la lezione
mean-reversion.
Logica:
1. ret[i] = rendimento dell'ultima barra; sigma = std rolling(n) dei rendimenti
2. z = ret[i]/sigma. Se z <= -k (crollo) -> LONG; se z >= +k (spike) -> SHORT.
Ingresso a close[i] (eseguibile dal vivo, nessun look-ahead).
3. EXIT: take-profit a tp_atr*ATR a favore, stop-loss a sl_atr*ATR contro,
time-limit max_bars.
Validazione (netto, fee 0.10% RT reale Deribit, leva 3x, OOS = ultimo 30%):
config robusta k=3.5 tp=2ATR sl=1.5ATR n=50:
BTC 1h: +447% FULL / +105% OOS, DD 25%
ETH 1h: +335% FULL / +195% OOS, DD 46%
L'intero blocco tp_atr=2.0 (k in {2.5,3.0,3.5} x sl in {1.5,2.0,2.5}) e'
positivo full+OOS su entrambi gli asset 1h.
Ricerca completa: scripts/analysis/strategy_research_v2.py.
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from src.strategies.base import Signal
from src.strategies.fade_base import FadeStrategy, atr, trend_distance, hurst_skip_mask
class ReturnReversal(FadeStrategy):
name = "MR07_return_reversal"
description = "Mean-reversion: fada il movimento di barra estremo (z dei rendimenti)"
default_assets = ["BTC", "ETH"]
default_timeframes = ["1h"]
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex,
**params) -> list[Signal]:
n = params.get("n", 50)
k = params.get("k", 3.5)
tp_atr = params.get("tp_atr", 2.0)
sl_atr = params.get("sl_atr", 1.5)
max_bars = params.get("max_bars", 24)
trend_max = params.get("trend_max") # None = filtro disattivo
ema_long = params.get("ema_long", 200)
# Edge minimo: salta i fade il cui TP (ATR-scaled) è entro il costo RT. 0 = off.
min_tp_frac = params.get("min_tp_frac", 0.0)
# Loss-guard Hurst: salta in regime persistente/trending (hurst >= soglia). None = off.
hurst_max = params.get("hurst_max")
c = df["close"].values
ret = np.zeros_like(c)
ret[1:] = np.diff(c) / c[:-1]
sig = pd.Series(ret).rolling(n).std().values
a = atr(df, 14)
td = trend_distance(df, ema_long) if trend_max is not None else None
skip = hurst_skip_mask(df, hurst_max, params.get("hurst_win", 100))
signals: list[Signal] = []
for i in range(n + 14, len(c)):
if np.isnan(sig[i]) or sig[i] == 0 or np.isnan(a[i]):
continue
if skip[i]:
continue # loss-guard: regime persistente
if td is not None and (np.isnan(td[i]) or td[i] > trend_max):
continue
z = ret[i] / sig[i]
if z <= -k: # crollo di barra -> fade long
d, tp, sl = 1, c[i] + tp_atr * a[i], c[i] - sl_atr * a[i]
elif z >= k: # spike di barra -> fade short
d, tp, sl = -1, c[i] - tp_atr * a[i], c[i] + sl_atr * a[i]
else:
continue
if min_tp_frac > 0 and abs(tp - c[i]) / c[i] <= min_tp_frac:
continue # TP entro le fee -> non eseguibile in utile
signals.append(Signal(
idx=i, direction=d, entry_price=c[i],
metadata={"tp": float(tp), "sl": float(sl), "max_bars": max_bars},
))
return signals
if __name__ == "__main__":
strat = ReturnReversal()
print("=" * 110)
print(f" MR07 RETURN REVERSAL — netto fee {strat.fee_rt*100:.2f}% RT, leva {strat.leverage:.0f}x")
print("=" * 110)
for asset in ["BTC", "ETH"]:
r = strat.backtest(asset, "1h", n=50, k=3.5, tp_atr=2.0, sl_atr=1.5, max_bars=24)
if r:
r.strategy_name = f"MR07 {asset} 1h k3.5"
r.print_summary()
r.print_yearly()
+65
View File
@@ -0,0 +1,65 @@
"""PORT01 — Portafoglio combinato delle 3 strategie oneste (equal-weight, daily rebal).
Sleeve (meccanismi anti-correlati):
DIP01 dip-buy reversion su BTC (1h) regime: reversione
TR01 EMA 20/100 trend su paniere (4h) regime: momentum singolo
ROT02 dual-momentum rotation (1d) regime: forza relativa + risk-off
La diversificazione e' il vero motore di risk-reduction: il DD del portafoglio
scende SOTTO quello della sleeve meno rischiosa, mantenendo una CAGR alta e
azzerando quasi gli anni negativi (il 2022 bear passa da -30% di ROT a -1%).
Risultato (netto, 2021-2026, leva 3x pos 15% per sleeve; ROT02 ora top_k=3):
DIP01_BTC +322% DD 15% CAGR 31%
TR01_basket +591% DD 27% CAGR 43%
ROT02_dualmom +984% DD 26% CAGR 56% (top_k=3: DD 40->26, PnL su)
PORTAFOGLIO +676% DD 13% CAGR 46% <-- DD piu' basso di ogni sleeve
Per-anno: 2021 +224 · 2022 +1 · 2023 +48 · 2024 +48 · 2025 +10 · 2026 -2
Logica e ricostruzione: scripts/analysis/honest_improve2.py.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.honest_improve import _dd # noqa: E402
from scripts.analysis.honest_improve2 import ( # noqa: E402
dip_market_gated, _daily_equity, _norm, _tr_basket_daily, _rot_daily_equity,
)
def run():
idx = pd.date_range("2021-01-01", "2026-05-26", freq="1D", tz="UTC")
d = dip_market_gated("BTC", market_n=0, return_equity=True)
members = {
"DIP01_BTC": _norm(_daily_equity(d["eq_ts"], d["eq_v"], idx)),
"TR01_basket": _norm(_tr_basket_daily(["BNB", "BTC", "DOGE", "SOL", "XRP"], idx)),
"ROT02_dualmom": _norm(_rot_daily_equity(idx)),
}
drets = pd.DataFrame({k: v.pct_change().fillna(0) for k, v in members.items()})
port_ret = drets.mean(axis=1)
combo = (1 + port_ret).cumprod()
yrs = (idx[-1] - idx[0]).days / 365.25
print("=" * 80)
print(f" PORT01 — portafoglio equal-weight (daily rebal) | {idx[0].date()} -> {idx[-1].date()}")
print("=" * 80)
print(f" {'sleeve':<16s}{'ret%':>9s}{'DD%':>7s}{'CAGR%':>8s}")
for name, s in members.items():
r = (s.iloc[-1] / s.iloc[0] - 1) * 100
cagr = ((s.iloc[-1] / s.iloc[0]) ** (1 / yrs) - 1) * 100
print(f" {name:<16s}{r:>+9.0f}{_dd(s.values):>7.0f}{cagr:>8.0f}")
r = (combo.iloc[-1] / combo.iloc[0] - 1) * 100
cagr = ((combo.iloc[-1] / combo.iloc[0]) ** (1 / yrs) - 1) * 100
print(f" {'PORTAFOGLIO':<16s}{r:>+9.0f}{_dd(combo.values):>7.0f}{cagr:>8.0f}")
pa = port_ret.groupby(port_ret.index.year).apply(lambda x: ((1 + x).prod() - 1) * 100)
print(" Per-anno: " + " ".join(f"{y}:{v:+.0f}%" for y, v in pa.items()))
if __name__ == "__main__":
run()
+52
View File
@@ -0,0 +1,52 @@
"""PORT02 — Portafoglio FADE accorpato e migliorato (6 sleeve, equal-weight daily).
Accorpa le 3 strategie fade mean-reversion su BTC e ETH, ciascuna MIGLIORATA con
il filtro trend (salta i fade contro trend estremi: |close-EMA200|/ATR > 3.0):
MR01 Bollinger fade · MR02 Donchian fade · MR07 Return-reversal
x {BTC, ETH} -> 6 sleeve indipendenti, pos 0.15 ciascuna, leva 3x.
(MR03 Keltner spostata in waste: fade piu' debole e ridondante con MR01.)
Le curve sono poco correlate fra loro (corr media intra-fade ~0.18): la
diversificazione abbatte il DD aggregato ben sotto quello del singolo sleeve.
Risultato (netto fee 0.10% RT, equal-weight ribilanciato ogni giorno, finestra
comune 2021-2026, OOS = ultimo 30%):
Ret +666% / CAGR 46% / DD 8.2% FULL · OOS DD 5.9% / Sharpe 3.95 FULL / 4.09 OOS.
Ricostruzione e confronto: scripts/analysis/combine_portfolio.py.
"""
from __future__ import annotations
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.combine_portfolio import ( # noqa: E402
build_all_sleeves, port_returns, metrics, yearly_returns, SPLIT, OOS_DATE, IDX,
)
def run():
sleeves = {k: v for k, v in build_all_sleeves().items() if k.startswith("MR")}
pr = port_returns(sleeves)
full, oos = metrics(pr), metrics(pr, lo=SPLIT)
print("=" * 84)
print(f" PORT02 — FADE master (8 sleeve, equal-weight daily) | {IDX[0].date()} -> {IDX[-1].date()}")
print("=" * 84)
print(f" {'sleeve':<14s}{'Ret%':>9s}{'DD%':>7s}{'Shrp':>7s}")
for name, s in sleeves.items():
m = metrics(s.pct_change().fillna(0.0))
print(f" {name:<14s}{m['ret']:>+9.0f}{m['dd']:>7.1f}{m['sharpe']:>7.2f}")
print(" " + "-" * 80)
print(f" {'PORTAFOGLIO':<14s}{full['ret']:>+9.0f}{full['dd']:>7.1f}{full['sharpe']:>7.2f}"
f" CAGR {full['cagr']:.0f}%")
print(f" {' di cui OOS':<14s}{oos['ret']:>+9.0f}{oos['dd']:>7.1f}{oos['sharpe']:>7.2f}"
f" (da {OOS_DATE})")
pa = yearly_returns(pr)
print(" Per-anno: " + " ".join(f"{y}:{v:+.0f}%" for y, v in pa.items()))
if __name__ == "__main__":
run()
+78
View File
@@ -0,0 +1,78 @@
"""PORT03 — Portafoglio MASTER: accorpa TUTTE le strategie (fade + honest).
Unisce le due famiglie del progetto, quasi scorrelate (correlazione cross-famiglia
~0.05), in un unico portafoglio diversificato:
FADE (reversione intraday 1h, long/short, BTC/ETH, col filtro trend):
MR01 Bollinger · MR02 Donchian · MR07 Return-reversal -> 6 sleeve
HONEST (long-only multi-regime multi-crypto):
DIP01 dip-buy (1h) · TR01 EMA-trend (4h basket) · ROT02 dual-momentum (1d) -> 3 sleeve
Combinare le due famiglie migliora il rischio/rendimento rispetto a ciascuna da
sola: il DD scende e lo Sharpe sale (la honest, da sola piu' lumpy, viene
stabilizzata dalle fade ad alta frequenza). Vedi scripts/analysis/combine_portfolio.py.
Risultato (netto, equal-weight daily, finestra comune 2021-2026, OOS = ultimo 30%;
ROT02 ora top_k=3 -> DD piu' basso):
FADE only (6) DD 8.2% Sharpe 3.95 (OOS DD 5.9 / Shrp 4.09)
HONEST only (3) DD 12.6% Sharpe 2.20 (OOS DD 7.8 / Shrp 2.02)
MASTER eq (9) DD 5.2% Sharpe 4.23 (OOS DD 4.7 / Shrp 4.33) <- miglior Sharpe
MASTER 50/50 DD 5.0% Sharpe 3.69 (OOS DD 4.5) <- miglior DD
CAGR ~47% mantenuta in entrambe le varianti combinate.
Due varianti operative selezionabili:
weighting="equal" -> equal-weight sui 9 sleeve (massimo Sharpe)
weighting="5050" -> 50% famiglia fade + 50% famiglia honest (minimo DD)
"""
from __future__ import annotations
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.combine_portfolio import ( # noqa: E402
build_all_sleeves, port_returns, metrics, yearly_returns, SPLIT, OOS_DATE, IDX,
)
def master_returns(sleeves: dict, weighting: str = "equal"):
"""Rendimenti giornalieri del portafoglio master.
equal = equal-weight su tutti gli 11 sleeve; 5050 = media fra le due famiglie."""
fade = {k: v for k, v in sleeves.items() if k.startswith("MR")}
honest = {k: v for k, v in sleeves.items() if not k.startswith("MR")}
if weighting == "5050":
return (port_returns(fade) + port_returns(honest)) / 2
return port_returns(sleeves)
def run():
sleeves = build_all_sleeves()
fade = {k: v for k, v in sleeves.items() if k.startswith("MR")}
honest = {k: v for k, v in sleeves.items() if not k.startswith("MR")}
print("=" * 90)
print(f" PORT03 — MASTER (fade + honest) | {IDX[0].date()} -> {IDX[-1].date()} | OOS da {OOS_DATE}")
print("=" * 90)
print(f" {'portafoglio':<22s}{'Ret%':>9s}{'CAGR':>7s}{'DD%':>7s}{'Shrp':>7s}"
f" | {'oRet%':>9s}{'oDD%':>7s}{'oShrp':>7s}")
print(" " + "-" * 86)
def line(label, pr):
f, o = metrics(pr), metrics(pr, lo=SPLIT)
print(f" {label:<22s}{f['ret']:>+9.0f}{f['cagr']:>7.0f}{f['dd']:>7.1f}{f['sharpe']:>7.2f}"
f" | {o['ret']:>+9.0f}{o['dd']:>7.1f}{o['sharpe']:>7.2f}")
line(f"FADE only ({len(fade)})", port_returns(fade))
line(f"HONEST only ({len(honest)})", port_returns(honest))
line(f"MASTER equal ({len(sleeves)})", master_returns(sleeves, "equal"))
line("MASTER 50/50 fam", master_returns(sleeves, "5050"))
print(" " + "-" * 86)
pa = yearly_returns(master_returns(sleeves, "equal"))
print(" MASTER equal per-anno: " + " ".join(f"{y}:{v:+.0f}%" for y, v in pa.items()))
print(" -> combinare le due famiglie scorrelate (~0.05) abbassa il DD e alza lo Sharpe.")
if __name__ == "__main__":
run()
@@ -0,0 +1,83 @@
"""PR01 — Pairs / Spread Mean-Reversion fra cripto (market-neutral). FAMIGLIA NUOVA.
Distinta da tutto l'esistente (single-asset direzionale): scommette sul RIENTRO del
log-ratio di due cripto verso la sua media. Market-neutral (long A / short B) ->
correlazione ~0.02 col mercato -> diversificatore prezioso.
Logica (engine onesto verificato in scripts/analysis/pairs_research.py):
r[i] = log(closeA[i]/closeB[i]); z[i] = (r[i]-SMA_n(r)[i]) / STD_n(r)[i] (causale)
z <= -z_in -> LONG ratio (long A / short B)
z >= +z_in -> SHORT ratio (short A / long B)
EXIT: |z| <= z_exit (rientro) o time-limit max_bars. Ingresso/uscita a close.
Fee su 2 GAMBE = 2*fee_rt*lev (0.20% RT/coppia). Filtro candele sporche (salto>8%).
Validazione anti-overfit (netto, fee 0.20% RT/coppia a 2 gambe, leva 3x, OOS = ultimo
30%, CONFIG UNIVERSALE n=50 z_in=2.0 z_exit=0.75 max_bars=72, 1h):
ETH/BTC : CAGR 158% / Sharpe 4.36 / 8/9 anni+ (regge fee 6x)
LTC/ETH : CAGR 92% / Sharpe 3.08 / 8/8 anni+
ADA/ETH : CAGR 91% / Sharpe 2.69 / 7/8 anni+
BTC/LTC : CAGR 60% / Sharpe 2.36 / 7/8 anni+ (robusta anche a 4h)
ETH/SOL : CAGR 74% / Sharpe 1.96 / 5/7 anni+ (la piu' debole, DD ~63%)
- No look-ahead verificato (z[i] invariato perturbando il futuro).
- PLATEAU non picco: heatmap n x z_in -> 20/20 celle Sharpe>1 (ETH/BTC, BTC/LTC).
- WALK-FORWARD (rolling train 2y / test 6m): ETH/BTC 11/12 finestre positive,
BTC/LTC 9/10 -> edge distribuito su tutta la storia, non un regime singolo.
- Stress costi: 5/6 reggono fee+slippage realistici; solo ETH/BTC regge fee 6x.
- Correlazione con BTC daily ~0.02-0.08 -> market-neutral.
- SCARTATA BNB/ETH: robusta solo coi suoi parametri (overfit), crolla con la universale.
WORKER LIVE: implementato `src/live/pairs_worker.py` (2 gambe, fee doppie, stato
persistente) e wired in `multi_runner` (sezione `pairs:` in strategies.yml). Validato:
- LOGICA: `validate_worker_pairs.py` -> replay storico == backtest pairs_sim ESATTO
(ETH/BTC: capitale, n.trade, win% identici).
- LIVE: `live_smoke_pairs.py` (smoke reale Cerbero) -> tutte e 5 le coppie con feed
live fresco. Naming Deribit corretto: BTC/ETH = "<COIN>-PERPETUAL" (inverse),
alt = "<COIN>_USDC-PERPETUAL" (lineari USDC, storia dal 2022). Trappola: usare
"LTC-PERPETUAL"/"SOL-PERPETUAL" da' vuoto/dati sbagliati -> SEMPRE _USDC-PERPETUAL.
Resta da verificare in trading reale solo la liquidita'/fill in esecuzione.
"""
from __future__ import annotations
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.pairs_research import pairs_sim, OOS_FRAC # noqa: E402
# CONFIG UNIVERSALE (anti-overfit): un'unica terna per TUTTE le coppie, niente
# cherry-picking per-coppia. Validata come ALTOPIANO (heatmap n x z_in: 20/20 celle
# Sharpe>1 su ETH/BTC e BTC/LTC) e walk-forward (ETH/BTC 11/12 finestre+, BTC/LTC 9/10).
UNIV = dict(n=50, z_in=2.0, z_exit=0.75, max_bars=72)
# Coppie robuste con la config universale. Sempre alt-liquido vs major (mai alt/alt).
# BNB/ETH SCARTATA: era robusta solo coi suoi parametri (n=30, z_exit=1.0) -> overfit;
# con la config universale crolla (Sharpe 1.5, DD 71%) ed e' la prima a morire allo stress costi.
PAIRS = [
("ETH", "BTC", UNIV), # la migliore (Sharpe 4.4 univ), regge fee 6x
("LTC", "ETH", UNIV),
("ADA", "ETH", UNIV),
("BTC", "LTC", UNIV), # robusta anche a 4h
("ETH", "SOL", UNIV), # piu' debole (DD ~63%, storia SOL corta) -> peso ridotto
]
def run():
print("=" * 92)
print(" PR01 — PAIRS spread reversion (market-neutral) | netto fee 0.20% RT/coppia, leva 3x")
print("=" * 92)
print(f" {'coppia':<10s}{'trd':>5s}{'win%':>6s}{'CAGR%':>7s}{'OOS DD%':>8s}{'DD%':>6s}{'Shrp':>6s}{'anni+':>7s}")
for a, b, p in PAIRS:
f = pairs_sim(a, b, **p)
o = pairs_sim(a, b, **p, split_frac=1 - OOS_FRAC)
yrs = f["yearly"]; pos_y = sum(1 for v in yrs.values() if v > 0)
print(f" {a+'/'+b:<10s}{f['trades']:>5d}{f['win']:>6.1f}{f['cagr']:>7.0f}{o['dd']:>8.0f}"
f"{f['dd']:>6.0f}{f['sharpe']:>6.2f}{f'{pos_y}/{len(yrs)}':>7s}")
print("\n Market-neutral (corr ~0.02-0.08 col mercato) -> ottimo diversificatore di portafoglio.")
print(" Pattern: solo alt-liquido vs major (BTC/ETH); alt-vs-alt e' rumore.")
print(" NB: 2 gambe (long A / short B), fee doppie. Worker live da estendere prima del live.")
if __name__ == "__main__":
run()
+46
View File
@@ -0,0 +1,46 @@
"""ROT02 — Dual-Momentum Rotation (ROT01 + overlay di absolute momentum).
Evoluzione di ROT01: alla rotazione cross-sectional (forza relativa) aggiunge un
overlay di ABSOLUTE momentum sul mercato: se BTC e' sotto la sua media a `regime_n`
giorni (mercato risk-off), va completamente in CASH. Cosi' si evitano i bear di
sistema (2022, 2026 YTD) che erano gli unici anni rossi di ROT01.
Risultato (netto, fee 0.10% RT, gross 0.45, OOS = ultimo 30%): MIGLIORA TUTTO
rispetto a ROT01.
ROT01 base : FULL +679% / OOS +44% / DD 53%
ROT02 k2 SMA100 : FULL +1095% / OOS +98% / DD 40% (versione iniziale)
ROT02 k3 SMA100 : FULL +1303% / OOS +68% / DD 26% <-- DD quasi dimezzato, PnL su
MIGLIORIA DD (top_k 2 -> 3): la versione iniziale concentrava il book su 2 soli
asset; diversificare su 3 dimezza quasi il drawdown (40% -> 26%) e ALZA pure il
ritorno full (+1095 -> +1303), ret/DD da 27 a 50. Il vol-target abbassa il DD ma
sacrifica ritorno (solo de-leverage), quindi si tiene top_k=3 senza vol-target.
Param-insensitive: top_k 3-4 e regime SMA100-150 danno risultati simili.
Dettagli e sweep in scripts/analysis/honest_improve.py (rot_improved).
"""
from __future__ import annotations
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.honest_improve import rot_improved # noqa: E402
LOOKBACK, TOP_K, REGIME_N = 60, 3, 100 # top_k=3 (era 2): dimezza il DD
def run():
print("=" * 90)
print(f" ROT02 DUAL-MOMENTUM | 1d lb={LOOKBACK} top{TOP_K} + cash se BTC<SMA{REGIME_N} | netto fee 0.10% RT")
print("=" * 90)
full = rot_improved(lookback=LOOKBACK, top_k=TOP_K, regime_n=REGIME_N)
oos = rot_improved(lookback=LOOKBACK, top_k=TOP_K, regime_n=REGIME_N, oos_frac=0.30)
print(f" FULL: {full['ret']:+.0f}% DD {full['dd']:.0f}% ({full['pos_years']}/{full['n_years']} anni positivi)")
print(f" OOS : {oos['ret']:+.0f}% DD {oos['dd']:.0f}%")
print(" Per-anno: " + " ".join(f"{y}:{v:+.0f}%" for y, v in sorted(full["yearly"].items())))
if __name__ == "__main__":
run()
+102
View File
@@ -0,0 +1,102 @@
"""SH01 — Shape-ML: direzione predetta dalla FORMA del segnale (ML walk-forward).
FAMIGLIA NUOVA, distinta da tutto l'esistente. Non e' una regola fissa su bande/canali
(fade) ne' momentum/rotazione (honest) ne' spread (pairs): una LogisticRegression legge
la MORFOLOGIA della finestra recente (body/shadow delle candele, rendimenti, pendenza,
curvatura, posizione di max/min, RSI, estensione) e predice il segno del rendimento a H
barre. Entra a close[i] solo se la probabilita' supera una soglia (selettivita').
E' l'UNICO edge sopravvissuto alla ricerca sui pattern-di-forma (2026-05-29): le altre
4 famiglie testate con agenti paralleli su harness onesto sono RUMORE (analog kNN forma
grezza, encoding candele UP/DOWN/DOJI, DTW+template geometrici, PIP/pivot) confermano
la dominanza mean-reversion. Vedi scripts/analysis/shape_*_research.py e docs/diary.
Logica (engine onesto verificato in scripts/analysis/shape_ml_research.py):
feature di forma X[i] da o/h/l/c[i-W+1..i] (causali: solo dati fino a close[i])
walk-forward a blocchi: scaler+modello fittati SOLO sul passato con esito noto
(finestre e con e+H <= inizio_blocco-1), poi predicono il blocco corrente
proba(classe) >= thresh -> entra a close[i] nella direzione predetta, exit a H barre
fee 0.10% RT (single-leg). NESSUN look-ahead (check espliciti: perturbare il futuro
non cambia ne' le feature a i ne' le predizioni fino a i).
VALIDAZIONE DURA (netto fee, leva 3x, pos 0.15, OOS = ultimo 30%, config W24 H12 th0.58,
scripts/analysis/shape_ml_validate.py):
- MULTI-ASSET expanding: robusti BTC, ETH, ADA; scartati LTC/SOL/XRP.
BTC : FULL +219% / OOS +42% / Sharpe 2.72 / DD 23% / 8-9 anni+ / accOOS 56% (regge fee 0.2%: +60/+26)
ETH : FULL +80% / OOS +144% / Sharpe 1.21 / DD 61% / 6/9 anni+ / accOOS 55% (piu' volatile -> secondario)
ADA : FULL +707% / OOS +57% / Sharpe 3.22 / DD 39% / 7/8 anni+ (robusto solo expanding)
- WALK-FORWARD ROLLING (train fisso 2 anni): regge solo BTC (FULL +166% / OOS +96% / Sharpe 2.05).
-> l'edge si appoggia in parte alla memoria lunga: BTC e' il piu' solido.
- STRESS leva 2x + slippage doppio (fee 0.20% RT): BTC OK (FULL +40% / OOS +17% / Sharpe 1.24),
ETH marginale (FULL +7% / OOS +73% / Sharpe 0.37).
- GRIGLIA (W,H,thresh) su BTC: 5/27 celle robuste a fee 0.2%, su una CRESTA stretta
(W24, H8-12), non altopiano largo -> rischio overfit moderato. Per prudenza si sceglie
la config robusta sul PIU' ALTO numero di test (W24 H12 th0.58), non il PnL massimo
(W24 H8 rende di piu' ma accOOS ~49% = piu' drift che segnale).
USO CONSIGLIATO: NON motore standalone (per-asset e' troppo stretto/fragile fuori da BTC),
ma DIVERSIFICATORE di portafoglio. Corr daily col MASTER +0.08 (quasi scorrelato).
Aggiungere lo sleeve shape (BTC+ETH) al MASTER migliora l'OOS: Sharpe 4.33->5.10,
DD 4.7%->4.2% (scripts/analysis/shape_ml_validate.py sez. 5).
LIVE: richiede un worker capace di RIALLENARE periodicamente il modello (come il legacy
signal_engine ML, non lo StrategyWorker a regola fissa). Da wirare prima del paper live.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from src.strategies.base import Strategy, Signal # noqa: E402
from scripts.analysis.explore_lab import get_df, evaluate, robust # noqa: E402
from scripts.analysis.shape_ml_research import ml_wf_entries # noqa: E402
# Config robusta scelta (cresta W24 H8-12; H12 th0.58 = la piu' robusta sui test).
CONFIG = dict(W=24, H=12, model="logit", thresh=0.58)
# Asset con edge robusto. BTC primario (regge ogni stress); ETH secondario (diversificatore
# piu' volatile). ADA robusto solo expanding -> tenuto fuori dal set live conservativo.
ASSETS = ["BTC", "ETH"]
class ShapeMLStrategy(Strategy):
name = "SH01_shape_ml"
description = "Direzione predetta dalla forma del segnale (LogisticRegression walk-forward), exit a H barre"
default_assets = ASSETS
default_timeframes = ["1h"]
fee_rt = 0.001
leverage = 3.0
position_size = 0.15
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex, **params) -> list[Signal]:
cfg = {**CONFIG, **{k: params[k] for k in ("W", "H", "model", "thresh") if k in params}}
ents = ml_wf_entries(df, W=cfg["W"], H=cfg["H"], model=cfg["model"], thresh=cfg["thresh"])
c = df["close"].values
out: list[Signal] = []
for e in ents:
out.append(Signal(idx=e["i"], direction=e["d"], entry_price=float(c[e["i"]]),
metadata={"max_bars": e["max_bars"]}))
return out
def run():
print("=" * 96)
print(" SH01 — Shape-ML | direzione dalla FORMA (LogisticRegression walk-forward) | netto fee 0.10% RT, leva 3x")
print("=" * 96)
print(f" config: {CONFIG} (W=finestra forma, H=orizzonte/exit, thresh=soglia proba)")
for a in ASSETS:
df = get_df(a, "1h")
ents = ml_wf_entries(df, **CONFIG)
res = evaluate(f"{a}", ents, df)
print(f" ^ {'ROBUSTO (FULL+OOS+, regge fee 0.2%, ~tutti anni+)' if robust(res) else 'edge presente ma con anni negativi (diversificatore)'}")
print("\n Uso: diversificatore di portafoglio (corr ~0.08 col MASTER), non motore standalone.")
print(" Live: serve worker con retraining periodico del modello (vedi docstring).")
if __name__ == "__main__":
run()
+50
View File
@@ -0,0 +1,50 @@
"""TR01 — EMA Trend Following (long-only), timeframe 4h.
Cavalca i trend rialzisti, si mette in cash nei downtrend. Niente short
(shortare cripto perde OOS nel campione 2018-2026). Complementare a DIP01:
DIP01 guadagna nei regimi di reversione, TR01 nei regimi di trend.
Logica:
1. EMA fast (20) e EMA slow (100) sul close
2. LONG quando EMA_fast > EMA_slow (uptrend), altrimenti CASH
3. posizione continua, decisione a close[i] (no look-ahead);
fee solo sui cambi di stato (poche operazioni = fee non letali)
Validazione (netto, fee 0.10% RT, leva 3x, pos 15%, OOS = ultimo 30%):
robusto FULL+OOS su 5/8 asset: BNB(+14), BTC(+27), DOGE(+53), SOL(+7), XRP(+29) OOS.
ETH ~flat, ADA/LTC negativi OOS -> preferire BNB/BTC/DOGE/SOL/XRP.
Dettagli in scripts/analysis/honest_final.py / honest_trend.py.
"""
from __future__ import annotations
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.honest_trend import ( # noqa: E402
simulate_position, ema_dual_signal, oos as trend_oos,
)
from scripts.analysis.honest_lab import get_df
ASSETS = ["BNB", "BTC", "DOGE", "SOL", "XRP"]
FAST, SLOW, TF = 20, 100, "4h"
def run():
print("=" * 90)
print(f" TR01 EMA TREND {FAST}/{SLOW} long-only | {TF} | netto fee 0.10% RT leva 3x pos 15%")
print("=" * 90)
print(f" {'Asset':<6s}{'Flip':>6s}{'FULL%':>9s}{'OOS%':>9s}{'DD%':>6s}{'Exp%':>6s}{'AnniPos':>9s}")
for a in ASSETS:
df = get_df(a, TF)
sig = ema_dual_signal(df, FAST, SLOW, long_only=True)
f = simulate_position(sig, df)
o = trend_oos(sig, df)
print(f" {a:<6s}{f['flips']:>6d}{f['ret']:>+9.0f}{o['ret']:>+9.0f}"
f"{f['dd']:>6.0f}{f['exposure']:>6.0f}{str(f['pos_years'])+'/'+str(f['n_years']):>9s}")
if __name__ == "__main__":
run()

Some files were not shown because too many files have changed in this diff Show More