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>
This commit is contained in:
@@ -34,8 +34,15 @@ src/live/ → paper trading live multi-strategia
|
||||
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)
|
||||
scripts/strategies/ → strategie con edge validato OOS: FADE (MR01/MR02/MR07),
|
||||
HONEST (DIP01/TR01/ROT02), PAIRS (PR01), TSMOM + portafogli (PORT01/02/03)
|
||||
scripts/portfolios/ → definizioni PORT01-06 + report run()
|
||||
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, ...)
|
||||
strategies.yml → config multi-strategy paper trader
|
||||
@@ -56,6 +63,9 @@ uv run python scripts/analysis/oos_validation.py # perche' la fami
|
||||
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
|
||||
docker compose up -d # deploy Docker
|
||||
uv run pytest # test
|
||||
```
|
||||
@@ -183,6 +193,26 @@ equal-weight, leva 2x, cap pairs ~30-35%** (i pairs sono ~57% del rischio; worke
|
||||
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: serve worker con retraining
|
||||
periodico** (lo StrategyWorker è a regola fissa) → in `MODULE_MAP` ma non ancora in
|
||||
`strategies.yml`. Diario: `docs/diary/2026-05-29-shape.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.
|
||||
@@ -197,6 +227,15 @@ ma quei numeri sono backtest a leva 3x su 8 anni e includono anni eccezionali (e
|
||||
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 v1:** il runner esegue gli sleeve con worker pronti = fade (MR01/02/07) + pairs (PR01) + shape (SH01, via `MLWorkerWrapper` con retraining). Gli sleeve **honest (DIP01/TR01/ROT02) e TSM01 sono SALTATI nel live** (nessun worker dedicato ancora) → restano solo nel backtest; il runner li logga come saltati e rinormalizza i pesi sugli sleeve eseguibili. Worker honest/TSM01 = fase 2.
|
||||
- **Limite noto:** al ribilancio le posizioni APERTE restano sul loro notional (non travasate); comportamento 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.
|
||||
|
||||
@@ -107,16 +107,23 @@ PythagorasGoal/
|
||||
│ ├── 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
|
||||
│ ├── 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
|
||||
@@ -244,6 +251,42 @@ data/paper_trades/
|
||||
|
||||
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 (v1)
|
||||
|
||||
Il runner esegue le famiglie per cui esiste un worker dedicato: **fade** (MR01, MR02, MR07), **pairs** (PR01, cinque coppie) e **shape** (SH01, con retraining periodico via `MLWorkerWrapper`). Le famiglie **honest** (DIP01, TR01, ROT02) e **TSMOM** (TSM01) sono al momento escluse dall'esecuzione live — restano nel backtest — e il runner lo segnala nel log, rinormalizzando automaticamente i pesi sugli sleeve attivi. Il supporto ai worker honest e TSM01 è previsto nella fase 2.
|
||||
|
||||
### 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
|
||||
|
||||
# Smoke test del data layer Cerbero v2
|
||||
uv run python scripts/analysis/smoke_portfolio.py
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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.
|
||||
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.
|
||||
@@ -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
|
||||
@@ -27,3 +27,4 @@ dev = [
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
asyncio_mode = "auto"
|
||||
markers = ["network: test che richiede Cerbero MCP (rete+token)"]
|
||||
|
||||
@@ -31,6 +31,7 @@ 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))
|
||||
|
||||
@@ -47,7 +48,8 @@ def build_everything():
|
||||
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"])}
|
||||
return S, pairs, tsm
|
||||
shape = {f"SH_{a}": _norm(shape_daily_equity(a, IDX)) for a in ("BTC", "ETH")}
|
||||
return S, pairs, tsm, shape
|
||||
|
||||
|
||||
def yrow(label, dr):
|
||||
@@ -62,8 +64,8 @@ def metric_block(label, dr):
|
||||
|
||||
|
||||
def main():
|
||||
print("Costruzione (puo' richiedere ~1-2 min)...\n")
|
||||
S, pairs, tsm = build_everything()
|
||||
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")}
|
||||
|
||||
@@ -72,10 +74,12 @@ def main():
|
||||
"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)
|
||||
@@ -89,12 +93,13 @@ def main():
|
||||
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}
|
||||
allsl = {**S, **pairs, **tsm, **shape}
|
||||
cols = list(allsl)
|
||||
print(f" {'Anno':>5s}" + "".join(f"{c.replace('_',''):>11s}" for c in cols))
|
||||
print(" " + "-" * 124)
|
||||
@@ -112,12 +117,14 @@ def main():
|
||||
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}.items()})
|
||||
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):
|
||||
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 ----------
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,40 @@
|
||||
"""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
|
||||
|
||||
FADE = [SleeveSpec(kind="single", name=c, sid=f"{c}_{a}", asset=a, cluster=f"{a}-rev")
|
||||
for a in ("BTC", "ETH") for c in ("MR01", "MR02", "MR07")]
|
||||
HONEST = [
|
||||
SleeveSpec(kind="single", name="DIP01", sid="DIP01_BTC", asset="BTC", cluster="BTC-rev"),
|
||||
SleeveSpec(kind="single", name="TR01", sid="TR01_basket", cluster="trend"),
|
||||
SleeveSpec(kind="single", name="ROT02", sid="ROT02_rot", cluster="rotation"),
|
||||
]
|
||||
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="single", name="TSM01", sid="TSM01", cluster="trend")]
|
||||
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),
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -49,6 +49,28 @@ class CerberoClient:
|
||||
})
|
||||
return data.get("candles", [])
|
||||
|
||||
def get_historical_v2(self, instrument: str, start_date: str, end_date: str,
|
||||
interval: str = "1h", exchange: str = "deribit") -> list[dict]:
|
||||
"""Endpoint unificato v2: /mcp/tools/get_historical (exchange deribit|hyperliquid).
|
||||
Stesso shape candele del legacy: [{timestamp(ms), open, high, low, close, volume}]."""
|
||||
data = self._post("/mcp/tools/get_historical", {
|
||||
"exchange": exchange, "instrument": instrument,
|
||||
"interval": interval, "start_date": start_date, "end_date": end_date,
|
||||
})
|
||||
return data.get("candles", [])
|
||||
|
||||
def get_instruments(self, currency: str, kind: str = "future",
|
||||
exchange: str = "deribit", limit: int = 100) -> list[dict]:
|
||||
"""Enumera gli strumenti reali (v2). Usato per risolvere il naming senza hardcoding."""
|
||||
data = self._post("/mcp/tools/get_instruments", {
|
||||
"exchange": exchange, "currency": currency, "kind": kind, "limit": limit,
|
||||
})
|
||||
return data.get("instruments", data if isinstance(data, list) else [])
|
||||
|
||||
def get_ticker_batch(self, instruments: list[str]) -> dict:
|
||||
"""Prezzi correnti di N strumenti in una sola chiamata (v2, Deribit)."""
|
||||
return self._post("/mcp-deribit/tools/get_ticker_batch", {"instruments": instruments})
|
||||
|
||||
# --- Account ---
|
||||
|
||||
def get_account_summary(self, currency: str = "USDC") -> dict:
|
||||
|
||||
@@ -20,6 +20,10 @@ MODULE_MAP = {
|
||||
"MR01_bollinger_fade": ("MR01_bollinger_fade", "BollingerFade"),
|
||||
"MR02_donchian_fade": ("MR02_donchian_fade", "DonchianFade"),
|
||||
"MR07_return_reversal": ("MR07_return_reversal", "ReturnReversal"),
|
||||
# SH01 Shape-ML: generate_signals fa walk-forward (riallena il modello) -> pesante
|
||||
# per-tick. Caricabile per backtest; per il live serve un worker con retraining
|
||||
# periodico (come il legacy signal_engine), NON lo StrategyWorker a regola fissa.
|
||||
"SH01_shape_ml": ("SH01_shape_ml", "ShapeMLStrategy"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Portfolio: definizione (sleeve + schema pesi) con faccia di backtest.
|
||||
La faccia live è in runner.py."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from src.portfolio import weighting as W
|
||||
from src.portfolio.sleeves import all_sleeve_equities, sleeve_returns_df
|
||||
from scripts.analysis.combine_portfolio import port_returns, metrics, yearly_returns, SPLIT
|
||||
|
||||
|
||||
@dataclass
|
||||
class SleeveSpec:
|
||||
kind: str
|
||||
name: str
|
||||
sid: str
|
||||
asset: str | None = None
|
||||
a: str | None = None
|
||||
b: str | None = None
|
||||
tf: str = "1h"
|
||||
params: dict = field(default_factory=dict)
|
||||
cluster: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class PortfolioResult:
|
||||
code: str
|
||||
weights: dict
|
||||
full: dict
|
||||
oos: dict
|
||||
yearly: dict
|
||||
risk: dict
|
||||
|
||||
|
||||
@dataclass
|
||||
class Portfolio:
|
||||
code: str
|
||||
label: str
|
||||
sleeves: list[SleeveSpec]
|
||||
weighting: str = "equal"
|
||||
weights: dict | None = None
|
||||
caps: dict | None = None
|
||||
total_capital: float = 1000.0
|
||||
leverage: float = 3.0
|
||||
rebalance: str = "1D"
|
||||
vol_lookback: int = 90
|
||||
|
||||
@property
|
||||
def sleeve_ids(self) -> list[str]:
|
||||
return [s.sid for s in self.sleeves]
|
||||
|
||||
@property
|
||||
def clusters(self) -> dict[str, str]:
|
||||
return {s.sid: (s.cluster or s.sid) for s in self.sleeves}
|
||||
|
||||
def weight_vector(self, returns_df: pd.DataFrame | None = None) -> dict[str, float]:
|
||||
return W.weight_vector(
|
||||
self.weighting, self.sleeve_ids, returns_df,
|
||||
weights=self.weights, caps=self.caps,
|
||||
clusters=self.clusters, lookback=self.vol_lookback,
|
||||
)
|
||||
|
||||
def backtest(self) -> PortfolioResult:
|
||||
eq = all_sleeve_equities()
|
||||
members = {sid: eq[sid] for sid in self.sleeve_ids}
|
||||
dr = sleeve_returns_df(self.sleeve_ids)
|
||||
w = self.weight_vector(dr)
|
||||
port_dr = port_returns(members, w)
|
||||
full, oos = metrics(port_dr), metrics(port_dr, lo=SPLIT)
|
||||
import numpy as np
|
||||
we = np.ones(len(self.sleeve_ids)) / len(self.sleeve_ids)
|
||||
cov = dr.cov().values
|
||||
pv = float(we @ cov @ we)
|
||||
rc = we * (cov @ we)
|
||||
risk = {sid: float(rc[k] / pv * 100) if pv > 0 else 0.0
|
||||
for k, sid in enumerate(self.sleeve_ids)}
|
||||
return PortfolioResult(self.code, w, full, oos, yearly_returns(port_dr), risk)
|
||||
|
||||
|
||||
def load_active_portfolio(config_path) -> "Portfolio":
|
||||
"""Carica il portafoglio attivo da portfolios.yml applicando gli override."""
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from scripts.portfolios._defs import PORTFOLIOS
|
||||
|
||||
cfg = yaml.safe_load(Path(config_path).read_text())
|
||||
p = PORTFOLIOS[cfg["active"]]
|
||||
ov = cfg.get("overrides", {})
|
||||
for k in ("total_capital", "weighting", "caps", "leverage", "rebalance", "vol_lookback"):
|
||||
if k in ov and ov[k] is not None:
|
||||
setattr(p, k, ov[k])
|
||||
return p
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Ledger aggregato del portafoglio: capitale, allocazioni, equity, PnL, peak/DD, persistenza."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class PortfolioLedger:
|
||||
def __init__(self, code: str, total_capital: float = 1000.0,
|
||||
data_dir: Path = Path("data/portfolios")):
|
||||
self.code = code
|
||||
self.initial_capital = total_capital
|
||||
self.total_capital = total_capital
|
||||
self.work_dir = Path(data_dir) / code
|
||||
self.work_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.status_path = self.work_dir / "status.json"
|
||||
self.equity_path = self.work_dir / "equity.jsonl"
|
||||
self.events_path = self.work_dir / "events.jsonl"
|
||||
self.equity = total_capital
|
||||
self.peak = total_capital
|
||||
self.max_dd = 0.0
|
||||
self.weights: dict[str, float] = {}
|
||||
self.alloc: dict[str, float] = {}
|
||||
self.last_rebalance = ""
|
||||
self._load()
|
||||
|
||||
def _load(self):
|
||||
if not self.status_path.exists():
|
||||
return
|
||||
s = json.loads(self.status_path.read_text())
|
||||
self.total_capital = s.get("total_capital", self.total_capital)
|
||||
self.equity = s.get("equity", self.equity)
|
||||
self.peak = s.get("peak", self.peak)
|
||||
self.max_dd = s.get("max_dd", self.max_dd)
|
||||
self.weights = s.get("weights", {})
|
||||
self.alloc = s.get("alloc", {})
|
||||
self.last_rebalance = s.get("last_rebalance", "")
|
||||
|
||||
def allocate(self, weights: dict[str, float]) -> dict[str, float]:
|
||||
self.weights = dict(weights)
|
||||
self.alloc = {sid: round(self.total_capital * w, 6) for sid, w in weights.items()}
|
||||
self.last_rebalance = datetime.now(timezone.utc).isoformat()
|
||||
self._append(self.events_path, {"event": "rebalance", "weights": self.weights,
|
||||
"total_capital": self.total_capital})
|
||||
return self.alloc
|
||||
|
||||
def update_equity(self, sleeve_equity: dict[str, float], pnl_day: float = 0.0):
|
||||
self.equity = float(sum(sleeve_equity.values()))
|
||||
if self.equity > self.peak:
|
||||
self.peak = self.equity
|
||||
dd = (self.peak - self.equity) / self.peak * 100 if self.peak > 0 else 0.0
|
||||
self.max_dd = max(self.max_dd, dd)
|
||||
self._append(self.equity_path, {
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
"equity": round(self.equity, 2), "dd": round(dd, 3),
|
||||
"pnl_day": round(pnl_day, 2),
|
||||
"pnl_total": round(self.equity - self.initial_capital, 2),
|
||||
})
|
||||
|
||||
def save(self):
|
||||
self.status_path.write_text(json.dumps({
|
||||
"code": self.code, "total_capital": round(self.total_capital, 2),
|
||||
"equity": round(self.equity, 2), "peak": round(self.peak, 2),
|
||||
"max_dd": round(self.max_dd, 3), "weights": self.weights,
|
||||
"alloc": self.alloc, "last_rebalance": self.last_rebalance,
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
}, indent=2))
|
||||
|
||||
@staticmethod
|
||||
def _append(path: Path, row: dict):
|
||||
with open(path, "a") as f:
|
||||
f.write(json.dumps(row) + "\n")
|
||||
@@ -0,0 +1,158 @@
|
||||
"""PortfolioRunner: faccia live del portafoglio (capitale pool, sizing, ribilancio, ledger).
|
||||
Riusa i worker esistenti come esecutori e il data layer Cerbero v2."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from src.portfolio.base import SleeveSpec, Portfolio
|
||||
from src.portfolio.ledger import PortfolioLedger
|
||||
from src.live.strategy_worker import StrategyWorker
|
||||
from src.live.pairs_worker import PairsWorker
|
||||
from src.live.multi_runner import MLWorkerWrapper
|
||||
from src.live.strategy_loader import load_strategy
|
||||
|
||||
# Codice-breve sleeve -> nome modulo Strategy in scripts/strategies/
|
||||
_STRAT_MODULE = {
|
||||
"MR01": "MR01_bollinger_fade", "MR02": "MR02_donchian_fade",
|
||||
"MR07": "MR07_return_reversal", "SH01": "SH01_shape_ml",
|
||||
# DIP01/TR01/ROT02 sono honest a sé: vedi nota nel design (worker dedicati in fase 2)
|
||||
}
|
||||
DATA_DIR = Path("data/portfolio_paper")
|
||||
|
||||
|
||||
def build_worker_for(spec: SleeveSpec, alloc_capital: float, leverage: float,
|
||||
data_dir: Path = DATA_DIR, position_size: float = 0.15):
|
||||
"""Costruisce il worker esecutore per uno sleeve con capitale = quota allocata."""
|
||||
if spec.kind == "pairs":
|
||||
return PairsWorker(
|
||||
asset_a=spec.a, asset_b=spec.b, tf=spec.tf, params=spec.params,
|
||||
capital=alloc_capital, position_size=position_size, leverage=leverage,
|
||||
fee_rt=0.001, name="PR01_pairs_reversion", data_dir=data_dir,
|
||||
)
|
||||
module = _STRAT_MODULE.get(spec.name)
|
||||
if module is None:
|
||||
raise ValueError(f"sleeve live non ancora supportato: {spec.name} "
|
||||
f"(honest DIP01/TR01/ROT02 richiedono worker dedicati, fase 2)")
|
||||
strategy = load_strategy(module)
|
||||
worker = StrategyWorker(
|
||||
strategy=strategy, asset=spec.asset, tf=spec.tf, capital=alloc_capital,
|
||||
position_size=position_size, leverage=leverage, params=spec.params, data_dir=data_dir,
|
||||
)
|
||||
if spec.kind == "ml": # SH01: retraining periodico
|
||||
return MLWorkerWrapper(worker, {"retrain_hours": 24})
|
||||
return worker
|
||||
|
||||
|
||||
def _worker_equity(w) -> float:
|
||||
inner = getattr(w, "worker", w) # smonta MLWorkerWrapper
|
||||
return float(getattr(inner, "capital", 0.0))
|
||||
|
||||
|
||||
def rebalance_allocations(ledger: PortfolioLedger, workers: dict, weights: dict[str, float]):
|
||||
"""Ribilancio: total_capital = Σ equity sleeve; riallinea il capitale-base di ogni worker
|
||||
a peso×total. I worker con posizione APERTA NON vengono ritoccati (la posizione mantiene
|
||||
il suo notional, come da approssimazione dichiarata): il nuovo capitale-base si applica
|
||||
alla prossima posizione, quando il worker è flat."""
|
||||
ledger.total_capital = sum(_worker_equity(w) for w in workers.values())
|
||||
alloc = ledger.allocate(weights)
|
||||
for sid, w in workers.items():
|
||||
inner = getattr(w, "worker", w)
|
||||
if getattr(inner, "in_position", False):
|
||||
continue
|
||||
inner.capital = alloc.get(sid, inner.capital)
|
||||
ledger.save()
|
||||
|
||||
|
||||
def run(config_path: str = "portfolios.yml"):
|
||||
"""Loop live a portafoglio. Data layer Cerbero v2; ribilancio a fine giornata UTC.
|
||||
Gli sleeve senza worker live (honest DIP01/TR01/ROT02) vengono SALTATI con warning
|
||||
(restano solo in backtest); i pesi sono rinormalizzati sugli sleeve eseguibili."""
|
||||
import time
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import pandas as pd
|
||||
from src.portfolio.base import load_active_portfolio
|
||||
from src.portfolio.sleeves import sleeve_returns_df
|
||||
from src.portfolio import weighting as W
|
||||
from src.live.cerbero_client import CerberoClient
|
||||
from src.live.multi_runner import INSTRUMENT_MAP
|
||||
|
||||
p: Portfolio = load_active_portfolio(config_path)
|
||||
|
||||
import yaml as _yaml
|
||||
_ov = (_yaml.safe_load(__import__("pathlib").Path(config_path).read_text()) or {}).get("overrides", {})
|
||||
poll = int(_ov.get("poll_seconds", 60))
|
||||
|
||||
def _supported(s):
|
||||
return s.kind == "pairs" or s.name in _STRAT_MODULE
|
||||
live_specs = [s for s in p.sleeves if _supported(s)]
|
||||
skipped = [s.sid for s in p.sleeves if not _supported(s)]
|
||||
if skipped:
|
||||
print(f"[runner] sleeve saltati nel live (worker non disponibili): {skipped}")
|
||||
live_ids = [s.sid for s in live_specs]
|
||||
clusters = {s.sid: (s.cluster or s.sid) for s in live_specs}
|
||||
|
||||
ledger = PortfolioLedger(p.code, total_capital=p.total_capital)
|
||||
client = CerberoClient()
|
||||
|
||||
dr = sleeve_returns_df(live_ids)
|
||||
weights = W.weight_vector(p.weighting, live_ids, dr, weights=p.weights,
|
||||
caps=p.caps, clusters=clusters, lookback=p.vol_lookback)
|
||||
alloc = ledger.allocate(weights)
|
||||
workers = {s.sid: build_worker_for(s, alloc[s.sid], p.leverage) for s in live_specs}
|
||||
|
||||
inst_map = dict(INSTRUMENT_MAP)
|
||||
last_day = ""
|
||||
while True:
|
||||
try:
|
||||
keys = set()
|
||||
for s in live_specs:
|
||||
if s.kind == "pairs":
|
||||
keys.add((s.a, s.tf)); keys.add((s.b, s.tf))
|
||||
else:
|
||||
keys.add((s.asset, s.tf))
|
||||
cache = {}
|
||||
end = datetime.now(timezone.utc); start = end - timedelta(days=60)
|
||||
for asset, tf in keys:
|
||||
inst = inst_map.get(asset, f"{asset}-PERPETUAL")
|
||||
candles = client.get_historical_v2(inst, start.strftime("%Y-%m-%d"),
|
||||
end.strftime("%Y-%m-%d"), tf)
|
||||
if candles:
|
||||
df = pd.DataFrame(candles)
|
||||
df["timestamp"] = df["timestamp"].astype("int64")
|
||||
cache[(asset, tf)] = df.sort_values("timestamp").reset_index(drop=True)
|
||||
|
||||
for s in live_specs:
|
||||
w = workers[s.sid]
|
||||
if s.kind == "pairs":
|
||||
ka, kb = (s.a, s.tf), (s.b, s.tf)
|
||||
if ka in cache and kb in cache:
|
||||
w.tick(cache[ka], cache[kb])
|
||||
else:
|
||||
key = (s.asset, s.tf)
|
||||
if key in cache:
|
||||
inner = getattr(w, "worker", w)
|
||||
if hasattr(w, "needs_training") and w.needs_training():
|
||||
w.train(cache[key], hold=inner.hold_bars)
|
||||
w.tick(cache[key])
|
||||
|
||||
ledger.update_equity({sid: _worker_equity(wk) for sid, wk in workers.items()})
|
||||
|
||||
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||
if today != last_day and last_day:
|
||||
dr = sleeve_returns_df(live_ids)
|
||||
weights = W.weight_vector(p.weighting, live_ids, dr, weights=p.weights,
|
||||
caps=p.caps, clusters=clusters, lookback=p.vol_lookback)
|
||||
rebalance_allocations(ledger, workers, weights)
|
||||
last_day = today
|
||||
ledger.save()
|
||||
except KeyboardInterrupt:
|
||||
ledger.save()
|
||||
print("shutdown")
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"[runner] errore: {e}")
|
||||
time.sleep(poll)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Unico builder delle equity GIORNALIERE per sleeve (fonte di verità del backtest).
|
||||
|
||||
Delega a scripts/analysis/report_families.build_everything (che a sua volta usa
|
||||
combine_portfolio + pairs_research + tsmom_research + shape_ml_validate), così le
|
||||
metriche del Portfolio coincidono per costruzione con report_families."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
_CACHE: dict[str, pd.Series] | None = None
|
||||
|
||||
|
||||
def all_sleeve_equities() -> dict[str, pd.Series]:
|
||||
"""{sleeve_id: equity giornaliera normalizzata su IDX comune}. Cache di processo."""
|
||||
global _CACHE
|
||||
if _CACHE is None:
|
||||
from scripts.analysis.report_families import build_everything
|
||||
S, pairs, tsm, shape = build_everything()
|
||||
_CACHE = {**S, **pairs, **tsm, **shape}
|
||||
return _CACHE
|
||||
|
||||
|
||||
def sleeve_returns_df(ids: list[str]) -> pd.DataFrame:
|
||||
"""Rendimenti giornalieri allineati per gli sleeve richiesti."""
|
||||
eq = all_sleeve_equities()
|
||||
return pd.DataFrame({i: eq[i].pct_change().fillna(0.0) for i in ids})
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Schemi di peso per i portafogli. Ogni funzione ritorna {sleeve_id: peso} con somma 1."""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
_PREFIX = [("PR_", "PAIRS"), ("SH_", "SHAPE"), ("TSM", "TSM"), ("MR", "FADE")]
|
||||
|
||||
|
||||
def family_of(sleeve_id: str) -> str:
|
||||
for pre, fam in _PREFIX:
|
||||
if sleeve_id.startswith(pre):
|
||||
return fam
|
||||
return "HONEST"
|
||||
|
||||
|
||||
def _normalize(w: dict[str, float]) -> dict[str, float]:
|
||||
tot = sum(w.values())
|
||||
return {k: (v / tot if tot > 0 else 0.0) for k, v in w.items()}
|
||||
|
||||
|
||||
def equal(ids: list[str]) -> dict[str, float]:
|
||||
n = len(ids)
|
||||
return {i: 1.0 / n for i in ids} if n else {}
|
||||
|
||||
|
||||
def manual(ids: list[str], weights: dict[str, float]) -> dict[str, float]:
|
||||
return _normalize({i: float(weights.get(i, 0.0)) for i in ids})
|
||||
|
||||
|
||||
def cap(ids: list[str], caps: dict[str, float]) -> dict[str, float]:
|
||||
"""Equal-weight con tetto al peso AGGREGATO di una famiglia; l'eccesso ridistribuito
|
||||
pro-quota alle famiglie non cappate (iterativo finché tutti i cap sono rispettati)."""
|
||||
w = equal(ids)
|
||||
fam = {i: family_of(i) for i in ids}
|
||||
for _ in range(10):
|
||||
over = {}
|
||||
for f, lim in caps.items():
|
||||
members = [i for i in ids if fam[i] == f]
|
||||
cur = sum(w[i] for i in members)
|
||||
if cur > lim + 1e-12 and members:
|
||||
over[f] = (members, lim, cur)
|
||||
if not over:
|
||||
break
|
||||
free_ids = [i for i in ids if fam[i] not in caps]
|
||||
freed = 0.0
|
||||
for f, (members, lim, cur) in over.items():
|
||||
scale = lim / cur
|
||||
for i in members:
|
||||
freed += w[i] * (1 - scale)
|
||||
w[i] *= scale
|
||||
if free_ids and freed > 0:
|
||||
add = freed / len(free_ids)
|
||||
for i in free_ids:
|
||||
w[i] += add
|
||||
else:
|
||||
break
|
||||
return _normalize(w)
|
||||
|
||||
|
||||
def inverse_vol(ids: list[str], returns_df: pd.DataFrame, lookback: int) -> dict[str, float]:
|
||||
sub = returns_df[ids].iloc[-lookback:]
|
||||
vol = sub.std()
|
||||
inv = {i: (1.0 / vol[i] if vol[i] and vol[i] > 0 else 0.0) for i in ids}
|
||||
return _normalize(inv)
|
||||
|
||||
|
||||
def cluster_rp(ids: list[str], clusters: dict[str, str],
|
||||
returns_df: pd.DataFrame, lookback: int) -> dict[str, float]:
|
||||
"""Equal fra i cluster naturali, poi inverse-vol dentro ogni cluster."""
|
||||
groups: dict[str, list[str]] = {}
|
||||
for i in ids:
|
||||
groups.setdefault(clusters.get(i, i), []).append(i)
|
||||
per = 1.0 / len(groups) if groups else 0.0
|
||||
w: dict[str, float] = {}
|
||||
for members in groups.values():
|
||||
iv = inverse_vol(members, returns_df, lookback)
|
||||
for i in members:
|
||||
w[i] = per * iv[i]
|
||||
return _normalize(w)
|
||||
|
||||
|
||||
def weight_vector(scheme: str, ids: list[str], returns_df: pd.DataFrame | None = None,
|
||||
*, weights: dict | None = None, caps: dict | None = None,
|
||||
clusters: dict | None = None, lookback: int = 90) -> dict[str, float]:
|
||||
if scheme == "equal":
|
||||
return equal(ids)
|
||||
if scheme == "manual":
|
||||
return manual(ids, weights or {})
|
||||
if scheme == "cap":
|
||||
return cap(ids, caps or {})
|
||||
if scheme == "inverse_vol":
|
||||
return inverse_vol(ids, returns_df, lookback)
|
||||
if scheme == "cluster_rp":
|
||||
return cluster_rp(ids, clusters or {}, returns_df, lookback)
|
||||
raise ValueError(f"schema peso sconosciuto: {scheme}")
|
||||
@@ -0,0 +1,24 @@
|
||||
import pytest
|
||||
from src.portfolio.base import Portfolio, SleeveSpec
|
||||
from scripts.analysis.report_families import build_everything
|
||||
from scripts.analysis.combine_portfolio import port_returns, metrics, SPLIT
|
||||
|
||||
|
||||
def _master9_specs():
|
||||
fade = [SleeveSpec(kind="single", name=f"{c}", sid=f"{c}_{a}", asset=a, cluster=f"{a}-rev")
|
||||
for a in ("BTC", "ETH") for c in ("MR01", "MR02", "MR07")]
|
||||
honest = [SleeveSpec(kind="single", name="DIP01", sid="DIP01_BTC", asset="BTC", cluster="BTC-rev"),
|
||||
SleeveSpec(kind="single", name="TR01", sid="TR01_basket", cluster="trend"),
|
||||
SleeveSpec(kind="single", name="ROT02", sid="ROT02_rot", cluster="rotation")]
|
||||
return fade + honest
|
||||
|
||||
|
||||
def test_master9_backtest_matches_report():
|
||||
p = Portfolio(code="PORT03", label="Master", sleeves=_master9_specs(), weighting="equal")
|
||||
res = p.backtest()
|
||||
S, _, _, _ = build_everything()
|
||||
dr_ref = port_returns(S)
|
||||
ref_full, ref_oos = metrics(dr_ref), metrics(dr_ref, lo=SPLIT)
|
||||
assert res.full["sharpe"] == pytest.approx(ref_full["sharpe"], abs=1e-6)
|
||||
assert res.full["dd"] == pytest.approx(ref_full["dd"], abs=1e-6)
|
||||
assert res.oos["sharpe"] == pytest.approx(ref_oos["sharpe"], abs=1e-6)
|
||||
@@ -0,0 +1,10 @@
|
||||
import pytest
|
||||
from scripts.portfolios._defs import PORTFOLIOS
|
||||
|
||||
|
||||
def test_port06_cap_backtest_numbers_locked():
|
||||
r = PORTFOLIOS["PORT06"].backtest()
|
||||
# regression-lock dei numeri del default (cap pairs 0.33) — vedi report_families
|
||||
assert r.full["sharpe"] == pytest.approx(6.07, abs=0.15)
|
||||
assert r.oos["sharpe"] == pytest.approx(8.19, abs=0.25)
|
||||
assert r.full["dd"] == pytest.approx(4.9, abs=0.5)
|
||||
@@ -0,0 +1,18 @@
|
||||
import pytest
|
||||
from src.live.cerbero_client import CerberoClient
|
||||
|
||||
|
||||
@pytest.mark.network
|
||||
def test_get_historical_v2_shape():
|
||||
cli = CerberoClient()
|
||||
candles = cli.get_historical_v2("BTC-PERPETUAL", "2026-05-25", "2026-05-27", "1h")
|
||||
assert len(candles) > 0
|
||||
c0 = candles[0]
|
||||
assert {"timestamp", "open", "high", "low", "close", "volume"} <= set(c0)
|
||||
|
||||
|
||||
@pytest.mark.network
|
||||
def test_get_instruments_returns_list():
|
||||
cli = CerberoClient()
|
||||
inst = cli.get_instruments("ETH", "future")
|
||||
assert isinstance(inst, list) and len(inst) > 0
|
||||
@@ -0,0 +1,10 @@
|
||||
from src.portfolio.base import load_active_portfolio
|
||||
|
||||
|
||||
def test_load_active_applies_overrides(tmp_path):
|
||||
cfg = tmp_path / "portfolios.yml"
|
||||
cfg.write_text("active: PORT06\noverrides:\n leverage: 2\n total_capital: 500\n")
|
||||
p = load_active_portfolio(cfg)
|
||||
assert p.code == "PORT06"
|
||||
assert p.leverage == 2.0
|
||||
assert p.total_capital == 500
|
||||
@@ -0,0 +1,17 @@
|
||||
from scripts.portfolios._defs import PORTFOLIOS
|
||||
|
||||
|
||||
def test_six_portfolios_defined():
|
||||
assert set(PORTFOLIOS) == {"PORT01", "PORT02", "PORT03", "PORT04", "PORT05", "PORT06"}
|
||||
|
||||
|
||||
def test_port06_is_master_shape_cap():
|
||||
p = PORTFOLIOS["PORT06"]
|
||||
sids = set(p.sleeve_ids)
|
||||
assert {"SH_BTC", "SH_ETH", "TSM01", "PR_ETHBTC"} <= sids
|
||||
assert len(sids) == 17
|
||||
assert p.weighting == "cap" and p.caps == {"PAIRS": 0.33}
|
||||
|
||||
|
||||
def test_default_leverage_sober():
|
||||
assert PORTFOLIOS["PORT06"].leverage == 2.0
|
||||
@@ -0,0 +1,26 @@
|
||||
from pathlib import Path
|
||||
from src.portfolio.ledger import PortfolioLedger
|
||||
|
||||
|
||||
def test_alloc_split_by_weights(tmp_path):
|
||||
L = PortfolioLedger("PORTX", total_capital=1000.0, data_dir=tmp_path)
|
||||
alloc = L.allocate({"a": 0.6, "b": 0.4})
|
||||
assert alloc == {"a": 600.0, "b": 400.0}
|
||||
|
||||
|
||||
def test_update_tracks_equity_and_dd(tmp_path):
|
||||
L = PortfolioLedger("PORTX", total_capital=1000.0, data_dir=tmp_path)
|
||||
L.update_equity({"a": 700.0, "b": 500.0})
|
||||
assert L.equity == 1200.0 and L.peak == 1200.0 and L.max_dd == 0.0
|
||||
L.update_equity({"a": 500.0, "b": 400.0})
|
||||
assert L.equity == 900.0
|
||||
assert abs(L.max_dd - 25.0) < 1e-9
|
||||
|
||||
|
||||
def test_persist_and_resume(tmp_path):
|
||||
L = PortfolioLedger("PORTX", total_capital=1000.0, data_dir=tmp_path)
|
||||
L.update_equity({"a": 1100.0})
|
||||
L.save()
|
||||
L2 = PortfolioLedger("PORTX", total_capital=1000.0, data_dir=tmp_path)
|
||||
assert L2.equity == 1100.0 and L2.peak == 1100.0
|
||||
assert (tmp_path / "PORTX" / "equity.jsonl").exists()
|
||||
@@ -0,0 +1,20 @@
|
||||
from src.portfolio.runner import build_worker_for
|
||||
from src.portfolio.base import SleeveSpec
|
||||
from src.live.strategy_worker import StrategyWorker
|
||||
from src.live.pairs_worker import PairsWorker
|
||||
|
||||
|
||||
def test_build_single_worker_capital_from_alloc(tmp_path):
|
||||
spec = SleeveSpec(kind="single", name="MR01", sid="MR01_BTC", asset="BTC",
|
||||
params={"bb_window": 50, "k": 2.5, "sl_atr": 2.0, "max_bars": 24})
|
||||
w = build_worker_for(spec, alloc_capital=300.0, leverage=2.0, data_dir=tmp_path)
|
||||
assert isinstance(w, StrategyWorker)
|
||||
assert w.capital == 300.0 and w.leverage == 2.0
|
||||
|
||||
|
||||
def test_build_pairs_worker(tmp_path):
|
||||
spec = SleeveSpec(kind="pairs", name="PR01", sid="PR_ETHBTC", a="ETH", b="BTC",
|
||||
params={"n": 50, "z_in": 2.0, "z_exit": 0.75, "max_bars": 72})
|
||||
w = build_worker_for(spec, alloc_capital=200.0, leverage=2.0, data_dir=tmp_path)
|
||||
assert isinstance(w, PairsWorker)
|
||||
assert w.capital == 200.0
|
||||
@@ -0,0 +1,25 @@
|
||||
from src.portfolio.runner import rebalance_allocations
|
||||
from src.portfolio.ledger import PortfolioLedger
|
||||
|
||||
|
||||
class FakeWorker:
|
||||
def __init__(self, cap, in_position=False):
|
||||
self.capital = cap
|
||||
self.in_position = in_position
|
||||
|
||||
|
||||
def test_rebalance_resizes_flat_workers(tmp_path):
|
||||
L = PortfolioLedger("PX", total_capital=1000.0, data_dir=tmp_path)
|
||||
workers = {"a": FakeWorker(700.0), "b": FakeWorker(500.0)} # equity 1200, both flat
|
||||
rebalance_allocations(L, workers, {"a": 0.5, "b": 0.5})
|
||||
assert L.total_capital == 1200.0
|
||||
assert workers["a"].capital == 600.0 and workers["b"].capital == 600.0
|
||||
|
||||
|
||||
def test_rebalance_skips_in_position_worker(tmp_path):
|
||||
L = PortfolioLedger("PX", total_capital=1000.0, data_dir=tmp_path)
|
||||
workers = {"a": FakeWorker(700.0, in_position=True), "b": FakeWorker(500.0)}
|
||||
rebalance_allocations(L, workers, {"a": 0.5, "b": 0.5})
|
||||
assert L.total_capital == 1200.0
|
||||
assert workers["a"].capital == 700.0 # invariato: posizione aperta
|
||||
assert workers["b"].capital == 600.0 # flat: riallineato
|
||||
@@ -0,0 +1,21 @@
|
||||
import pandas as pd
|
||||
from src.portfolio import sleeves as S
|
||||
|
||||
ALL_IDS = {"MR01_BTC", "MR02_BTC", "MR07_BTC", "MR01_ETH", "MR02_ETH", "MR07_ETH",
|
||||
"DIP01_BTC", "TR01_basket", "ROT02_rot",
|
||||
"PR_ETHBTC", "PR_LTCETH", "PR_ADAETH", "PR_BTCLTC", "PR_ETHSOL",
|
||||
"TSM01", "SH_BTC", "SH_ETH"}
|
||||
|
||||
|
||||
def test_all_sleeve_equities_keys_and_index():
|
||||
eq = S.all_sleeve_equities()
|
||||
assert ALL_IDS <= set(eq)
|
||||
s = eq["MR01_BTC"]
|
||||
assert isinstance(s, pd.Series) and len(s) > 100
|
||||
assert str(s.index.tz) == "UTC"
|
||||
|
||||
|
||||
def test_returns_df_aligned():
|
||||
df = S.sleeve_returns_df(["MR01_BTC", "PR_ETHBTC", "SH_BTC"])
|
||||
assert list(df.columns) == ["MR01_BTC", "PR_ETHBTC", "SH_BTC"]
|
||||
assert df.isna().sum().sum() == 0
|
||||
@@ -0,0 +1,53 @@
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from src.portfolio import weighting as W
|
||||
|
||||
|
||||
def test_family_of():
|
||||
assert W.family_of("PR_ETHBTC") == "PAIRS"
|
||||
assert W.family_of("SH_BTC") == "SHAPE"
|
||||
assert W.family_of("TSM01") == "TSM"
|
||||
assert W.family_of("MR01_BTC") == "FADE"
|
||||
assert W.family_of("DIP01_BTC") == "HONEST"
|
||||
|
||||
|
||||
def test_equal_sums_to_one():
|
||||
w = W.equal(["a", "b", "c", "d"])
|
||||
assert pytest.approx(sum(w.values())) == 1.0
|
||||
assert all(abs(v - 0.25) < 1e-9 for v in w.values())
|
||||
|
||||
|
||||
def test_manual_normalizes():
|
||||
w = W.manual(["a", "b"], {"a": 3, "b": 1})
|
||||
assert pytest.approx(w["a"]) == 0.75 and pytest.approx(w["b"]) == 0.25
|
||||
|
||||
|
||||
def test_cap_limits_family_and_redistributes():
|
||||
ids = ["PR_ETHBTC", "PR_LTCETH", "MR01_BTC", "MR02_BTC"]
|
||||
w = W.cap(ids, caps={"PAIRS": 0.30})
|
||||
pairs_w = w["PR_ETHBTC"] + w["PR_LTCETH"]
|
||||
assert pytest.approx(pairs_w, abs=1e-9) == 0.30
|
||||
assert pytest.approx(sum(w.values())) == 1.0
|
||||
assert w["MR01_BTC"] > 0.25
|
||||
|
||||
|
||||
def test_inverse_vol_prefers_low_vol():
|
||||
idx = pd.date_range("2024-01-01", periods=100, freq="D", tz="UTC")
|
||||
rng = np.random.default_rng(0)
|
||||
df = pd.DataFrame({"lo": rng.normal(0, 0.01, 100), "hi": rng.normal(0, 0.05, 100)}, index=idx)
|
||||
w = W.inverse_vol(["lo", "hi"], df, lookback=90)
|
||||
assert w["lo"] > w["hi"]
|
||||
assert pytest.approx(sum(w.values())) == 1.0
|
||||
|
||||
|
||||
def test_cluster_rp_equal_across_clusters():
|
||||
idx = pd.date_range("2024-01-01", periods=100, freq="D", tz="UTC")
|
||||
rng = np.random.default_rng(1)
|
||||
cols = ["MR01_BTC", "MR02_BTC", "PR_ETHBTC"]
|
||||
df = pd.DataFrame({c: rng.normal(0, 0.02, 100) for c in cols}, index=idx)
|
||||
clusters = {"MR01_BTC": "BTC-rev", "MR02_BTC": "BTC-rev", "PR_ETHBTC": "ETH-rev"}
|
||||
w = W.cluster_rp(cols, clusters, df, lookback=90)
|
||||
assert pytest.approx(sum(w.values())) == 1.0
|
||||
# due cluster equipesati: il cluster con 1 solo sleeve (ETH-rev) prende ~0.5
|
||||
assert pytest.approx(w["PR_ETHBTC"], abs=1e-9) == 0.5
|
||||
Reference in New Issue
Block a user