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:
2026-05-29 17:21:51 +02:00
46 changed files with 5071 additions and 14 deletions
+39
View File
@@ -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.
+51 -8
View File
@@ -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
+84
View File
@@ -0,0 +1,84 @@
# Diario — 2026-05-29 — Pattern del segnale per FORMA (analog/shape forecasting)
## Obiettivo
Verificare se la **forma** del segnale (la morfologia recente del prezzo) permette di
prevedere l'andamento successivo, e ricavarne edge verso il target €1000 → €50/giorno.
Esplorazione onesta (no look-ahead, netto fee, OOS) con **agenti paralleli**, ognuno su
una famiglia di forma indipendente, tutti sullo stesso harness shape (`scripts/analysis/
shape_lab.py`, che riusa l'engine netto-fee+OOS di `explore_lab.py`). Branch `shape_patterns`.
## Harness
`shape_lab.py` — analog forecasting causale: a ogni barra `i` si guarda la forma recente
`W` (closes z-normalizzati fino a `close[i]`), si cercano nel passato le `K` finestre più
simili **il cui esito a `H` barre era già noto prima di `i`** (KDTree ricostruito ogni
`rebuild` barre → niente O(N²)), si prevede la direzione = segno del rendimento medio degli
analoghi. **No-look-ahead verificato** (perturbare il futuro non cambia la forma a `i`,
max diff 0.0). Baseline forma grezza: marginale e **muore sulle fee** (W24H12K50: FULL
+112% / OOS +48% ma a 0.20% RT → 72%; troppi trade, exp 74%, win 49.5%).
## Famiglie esplorate (5) ed esito onesto
| Famiglia | Esito | Note |
|---|---|---|
| Analog kNN (forma grezza, selettività) | ❌ RUMORE | Solo BTC-overfit, non robusto ≥2 asset |
| Encoding candele (UP/DOWN/DOJI + body/shadow) | ❌ RUMORE | Hit-rate condizionale ~50%, segno incoerente fra asset |
| DTW + template geometrici (M/W, testa-spalle, V, U) | ❌ RUMORE | DTW *peggiora* l'euclidea; template overfit (FULL ok, OOS crolla) |
| PIP / pivot / zig-zag (geometria svolte) | ❌ RUMORE | 0/48 config robuste; le rotture S/R rientrano (riconferma MR) |
| **Feature-vector + ML walk-forward** | ✅ **EDGE REALE** | LogisticRegression sulla forma, fee-robusto |
4 famiglie su 5 sono rumore: riconfermano che la forma grezza non contiene edge
direzionale eseguibile e che l'unico edge "classico" resta la mean-reversion (fade/pairs).
## L'edge: SH01 — Shape-ML
Una **LogisticRegression** legge 17 feature di forma (body/shadow ratio, rendimenti,
pendenza/curvatura del path, posizione di max/min, RSI, estensione) e predice il segno del
rendimento a `H` barre. **Walk-forward rigoroso**: scaler+modello fittati solo sul passato
con esito noto, poi predicono il blocco corrente; si entra a `close[i]` se la probabilità
≥ soglia. Causalità verificata con check espliciti (feature e predizioni invarianti al
futuro). Il GradientBoosting dà edge equivalente ma è ~60× più lento → si usa il logit.
A differenza della famiglia squeeze (che moriva anche a fee zero), **questo edge
sopravvive a fee 0.20% RT**. Win-rate ~50% → l'edge è nell'**asimmetria** (quando indovina
la direzione i moti sono più grandi), non nella frequenza.
### Validazione dura (config W24 H12 th0.58, netto fee, leva 3x, pos 0.15, OOS 30%)
- **Multi-asset expanding**: robusti **BTC** (FULL +219% / OOS +42% / Sharpe 2.72 / DD 23%
/ 8-9 anni+ / accOOS 56%), **ETH** (+80% / +144% / Sharpe 1.21, più volatile), **ADA**
(+707% / +57% / Sharpe 3.22). Scartati LTC/SOL/XRP (perdono netti).
- **Walk-forward rolling (train fisso 2 anni)**: regge **solo BTC** (+166% / +96% / Sharpe
2.05). L'edge si appoggia in parte alla memoria lunga → BTC è il più solido.
- **Stress leva 2x + slippage doppio (0.20% RT)**: BTC OK (+40% / +17% / Sharpe 1.24),
ETH marginale (+7% / +73% / Sharpe 0.37).
- **Griglia (W,H,thresh) su BTC**: **5/27 celle robuste**, su una **cresta** stretta (W24,
H8-12), non altopiano largo → rischio overfit moderato. Per prudenza si sceglie la config
robusta sul maggior numero di test (W24 H12 th0.58), non il PnL massimo (W24 H8 rende di
più ma accOOS ~49% = più drift che segnale).
### Il valore vero: diversificatore di portafoglio
Correlazione daily col MASTER **+0.08** (quasi scorrelato). Aggiungere lo sleeve shape
(BTC+ETH) al MASTER migliora l'OOS: **Sharpe 4.33 → 5.10, DD 4.7% → 4.2%** (FULL: Sharpe
4.23 → 4.37, DD 5.2% → 4.3%). Non è un motore standalone (per-asset troppo stretto fuori
da BTC), ma un **free-lunch** da aggiungere al paniere.
## Artefatti
- `scripts/analysis/shape_lab.py` — harness analog/forma causale.
- `scripts/analysis/shape_{analog,candle,template,pivot,ml}_research.py` — le 5 ricerche.
- `scripts/analysis/shape_ml_validate.py` — validazione dura del candidato ML.
- `scripts/strategies/SH01_shape_ml.py` — la strategia (Strategy + run() riproducibile).
- Aggiunta a `MODULE_MAP` (caricabile per backtest).
## Conclusione e prossimi passi
La forma del segnale **non** predice in modo grezzo (4/5 famiglie rumore), ma un modello
lineare sulle feature di forma in walk-forward onesto **sì**, soprattutto su BTC, e vale
come diversificatore quasi-scorrelato del MASTER. Da fare prima del live:
1. **Worker con retraining periodico** (lo StrategyWorker attuale è a regola fissa; SH01
riallena il modello → serve un loop tipo legacy signal_engine).
2. Validazione live-path (replay worker == backtest) come fatto per i pairs.
3. Decidere il peso nel MASTER-esteso (cap, leva) col paper trader.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,233 @@
# Design — Cartella `portfolios/`: portafogli come oggetti di prima classe
**Data:** 2026-05-29
**Stato:** approvato in brainstorming, pronto per il piano di implementazione
**Branch:** `shape_patterns` (o branch dedicato `portfolios`)
## 1. Obiettivo e contesto
Oggi le strategie del progetto vivono come *sleeve* indipendenti: ogni worker del paper
trader (`StrategyWorker`, `PairsWorker`) gestisce un conto autonomo da €1000, con capitale
e stato propri in `data/paper_trades/{worker_id}/`. I "portafogli" `PORT01-03` esistenti
sono soltanto script di **report offline**: normalizzano le equity storiche dei singoli
sleeve e ne calcolano metriche equipesate. Non esiste un livello che gestisca davvero un
capitale condiviso, i pesi, il ribilanciamento e il PnL aggregato in tempo reale.
Questo design introduce una cartella `portfolios/` in cui il **portafoglio è un oggetto di
prima classe** che gestirà il trading e lo stato PnL. Un portafoglio possiede un capitale
totale, lo alloca ai propri sleeve secondo uno schema di pesi, dimensiona le posizioni,
ribilancia periodicamente e mantiene il ledger aggregato. La stessa definizione serve sia
al backtest sia al live, garantendo coerenza fra ciò che si misura e ciò che si tradia.
L'obiettivo strategico resta invariato: partire da €1000 e arrivare verso €50/giorno con un
paniere diversificato delle famiglie validate (fade, honest, pairs, TSMOM, shape-ML).
## 2. Decisioni di brainstorming
1. **Modello di capitale: pool condiviso.** Il portafoglio possiede il capitale totale, lo
alloca ai sleeve secondo i pesi, ridimensiona le posizioni e tiene lo stato/PnL
aggregato. I worker diventano esecutori.
2. **Scope: backtest + live unificati.** Un'unica classe `Portfolio` come fonte di verità,
capace sia di backtest/report storico sia di gestione live.
3. **Ribilanciamento periodico.** Il capitale viene riallocato ai pesi target a cadenza
fissa (giornaliera di default, configurabile), coerente con tutte le metriche misurate
finora.
4. **Schemi di peso supportati (tutti):** `equal` (default), `cap` (tetto per
famiglia/cluster, es. pairs 33% — configurazione sobria raccomandata), `inverse_vol`,
`cluster_rp` (equal fra cluster naturali poi inverse-vol dentro), `manual`.
5. **Scope live v1: tutti gli sleeve** — fade, honest, pairs (2 gambe) e shape-ML (SH01 via
worker con retraining periodico, sfruttando il `MLWorkerWrapper` esistente).
6. **Data layer Cerbero v2.** Il runner live adotta gli endpoint unificati v2: `get_historical`
unificato, `get_instruments` (naming robusto, niente `INSTRUMENT_MAP` hardcoded),
`get_ticker_batch` (fetch multi-gamba efficiente). Venue di trading = Deribit come ora.
### Analisi di accorpamento (a supporto delle decisioni)
`scripts/analysis/sleeve_clustering.py` ha mostrato che:
- i **cluster naturali** delle 17 sleeve non coincidono con le famiglie ma con
asset/regime: BTC-reversion, ETH-reversion, trend (TR01+TSM01), shape (SH_BTC+SH_ETH),
rotation (ROT02);
- la **ridondanza è lieve** (correlazione massima 0.43 MR01_BTC↔DIP01_BTC, 0.37 TR01↔TSM01):
nessuno sleeve è davvero fondibile, ognuno aggiunge diversificazione;
- a equal-weight i **pairs pesano il 47% del rischio** → giustifica lo schema `cap`;
- in OOS calmo equal-weight batte inverse-vol e risk-parity (i pairs ad alto rischio/ritorno
corrono liberi), ma è un risultato di regime → il cap resta la scelta prudente.
Il campo `cluster` di `SleeveSpec` codifica questi gruppi naturali per gli schemi `cap` e
`cluster_rp`.
## 3. Architettura e layout
Si rispecchia la struttura delle strategie (`src/strategies/` base + `scripts/strategies/`
concrete):
```
src/portfolio/
__init__.py
base.py # Portfolio (definizione + .backtest()), SleeveSpec, PortfolioResult
sleeves.py # costruzione UNIFICATA delle equity-per-sleeve (backtest);
# centralizza la logica oggi in combine_portfolio + report_families
weighting.py # schemi pesi: equal, cap, inverse_vol, cluster_rp, manual
ledger.py # PortfolioLedger: capitale, allocazioni, equity, PnL, peak/DD, persistenza
runner.py # PortfolioRunner (live): pool capital, sizing, ribilancio, aggregazione
scripts/portfolios/
PORT01_honest.py PORT02_fade.py PORT03_master.py
PORT04_master_pairs.py PORT05_master_esteso.py PORT06_master_shape.py
# definizioni concrete (lista SleeveSpec + schema pesi); run() = report backtest
portfolios.yml # config LIVE: portafoglio attivo, capitale, schema pesi, cap, cadenza, leva
```
**Integrazione col codice esistente:**
- Il backtest riusa i builder di equity-per-sleeve (`build_all_sleeves`, `pairs_sim`,
`shape_daily_equity`), centralizzati in `src/portfolio/sleeves.py`; `combine_portfolio.py`
e `report_families.py` diventano consumer sottili (niente duplicazione).
- Il live riusa da `multi_runner`: il fetch candele, `build_workers`,
`build_pairs_workers`, `MLWorkerWrapper`. `multi_runner` resta entrypoint legacy
single-sleeve finché `PortfolioRunner` non lo sostituisce.
- I vecchi `PORT01-03` di `scripts/strategies/` vengono migrati in `scripts/portfolios/`
come definizioni della nuova classe.
## 4. Definizione del portafoglio (schema)
```python
@dataclass
class SleeveSpec:
kind: str # "single" | "pairs" | "ml"
name: str # "MR01_bollinger_fade" | "PR01_pairs_reversion" | "SH01_shape_ml"
asset: str | None = None # single/ml
a: str | None = None # pairs: gamba long
b: str | None = None # pairs: gamba short
tf: str = "1h"
params: dict = field(default_factory=dict)
cluster: str = "" # BTC-rev | ETH-rev | trend | shape | rotation
@dataclass
class Portfolio:
code: str # "PORT06"
label: str # "Master + shape"
sleeves: list[SleeveSpec]
weighting: str = "equal" # equal | cap | inverse_vol | cluster_rp | manual
weights: dict | None = None # solo manual (sleeve-id -> peso)
caps: dict | None = None # solo cap: chiave = FAMIGLIA (derivata da kind/name:
# PAIRS/FADE/HONEST/SHAPE/TSM), es. {"PAIRS": 0.33}.
# cluster_rp usa invece il campo `cluster` degli sleeve.
total_capital: float = 1000.0
leverage: float = 3.0 # nota: 2x raccomandata per il live reale
rebalance: str = "1D"
vol_lookback: int = 90 # giorni per inverse_vol / cluster_rp
def backtest(self, ...) -> PortfolioResult: ...
def weight_vector(self, sleeve_returns) -> dict[str, float]: ...
```
Gli schemi di peso (in `weighting.py`) restituiscono un dict `sleeve-id -> peso` che somma a
1. `equal/cap/manual` sono statici; `inverse_vol/cluster_rp` si ricalcolano a ogni ribilancio
sulla finestra trailing `vol_lookback`, identicamente in backtest e live.
## 5. Faccia backtest
`Portfolio.backtest()` riusa la macchina che ha prodotto tutte le metriche viste finora,
centralizzata in `src/portfolio/sleeves.py`:
```
build_sleeve_equity(spec) -> pd.Series # equity daily normalizzata su IDX comune
kind="single" -> fade/honest daily equity builders
kind="pairs" -> pairs_sim -> daily
kind="ml" -> shape_daily_equity
```
Poi: `weight_vector()` → pesi → `port_returns()` con ribilancio giornaliero → `metrics()`
FULL/OOS + `yearly_returns()`. Restituisce un `PortfolioResult` con ret/CAGR/DD/Sharpe
(FULL e OOS), tabella per-anno e contributo al rischio per sleeve e per cluster. Lo `run()`
di ogni `scripts/portfolios/PORTxx.py` stampa questo report.
## 6. Faccia live (`PortfolioRunner`)
Loop a poll:
1. **Data layer v2.** All'avvio `get_instruments` risolve i nomi reali di ogni asset/coppia
(fallback a una mappa statica se l'endpoint non risponde). Per tick: `get_historical`
unificato per le candele + `get_ticker_batch` per i prezzi correnti di tutte le gambe in
un'unica chiamata.
2. **Costruzione sleeve→worker.** Riusa `build_workers` / `build_pairs_workers` /
`MLWorkerWrapper` (SH01). I worker sono esecutori, non possiedono più €1000 fissi.
3. **Capitale pool + sizing.** Il `PortfolioLedger` tiene `total_capital`. A ogni worker
viene assegnato `alloc_i = peso_i × total_capital`; il worker dimensiona il notional come
`alloc_i × position_size × leverage` (si riusa il campo `capital` del worker come base di
allocazione).
4. **Ribilancio (cadenza `rebalance`, default giornaliera).** `total_capital = Σ equity_sleeve`
(capitale + PnL realizzato); ricalcolo dei pesi (vol-based sulla finestra trailing o
statici); riallineo `alloc_i`.
5. **Aggregazione.** Dopo ogni tick il ledger aggiorna equity totale, peak, max_dd, PnL
aggregato e per-sleeve/cluster.
### Approssimazione dichiarata (limite noto)
Il ribilancio cambia la base di sizing delle posizioni **future**; le posizioni già aperte
restano sul notional con cui sono nate (nessun travaso forzato a metà trade). Per il paper
trading questo è fedele al backtest daily-rebalanced entro lo scarto dovuto al turnover
infragiornaliero. È un compromesso accettato per non introdurre la contabilità a ledger
unico (approccio C scartato in brainstorming), rimandata a quando si passerà a capitale
reale su un singolo conto-margine.
## 7. Persistenza e stato PnL
Stato del portafoglio separato dai singoli worker, in `data/portfolios/{code}/`:
```
data/portfolios/PORT06/
status.json # resume: total_capital, equity, peak, max_dd, pesi correnti,
# alloc+capitale+PnL per sleeve, ultimo ribilancio, ts
equity.jsonl # append-only: una riga per tick/giorno (ts, equity, dd, pnl_day) -> curva live
events.jsonl # append-only: ribilanci (pesi prima/dopo), milestone, errori
```
- I worker continuano a scrivere il proprio `trades.jsonl`/`status.json` in
`data/paper_trades/{worker_id}/` (storico per-sleeve intatto). Il portafoglio aggrega
sopra, non duplica i trade.
- **Resume:** al restart il runner ricarica lo `status.json` del portafoglio e gli stati
dei worker → riprende capitale, pesi e posizioni senza perdere storico.
- **Indicatori target:** il ledger espone `pnl_total`, `pnl_today`, `€/day` medio e DD
corrente.
- **Notifiche Telegram:** riepilogo a livello portafoglio (equity, PnL giorno, DD, ribilanci)
oltre alle notifiche per-trade dei worker.
## 8. Portafogli forniti e default
| Codice | Label | Sleeve | Pesi |
|--------|-------|--------|------|
| PORT01 | Honest | DIP01·TR01·ROT02 | equal |
| PORT02 | Fade master | MR01/02/07 × BTC/ETH (6) | equal |
| PORT03 | Master | fade+honest (9) | equal / manual 50-50 |
| PORT04 | Master + pairs | 9 + 5 pairs | equal · cap pairs 0.33 |
| PORT05 | Master esteso | 9 + pairs + TSM01 | equal · cap pairs |
| **PORT06** | **Master + shape** *(default)* | 9 + pairs + TSM01 + SH01 (BTC/ETH) | **cap pairs 0.33** |
**Default raccomandato:** PORT06 con `weighting="cap"` (pairs ~33%), `leverage=2` (sobrio),
`rebalance="1D"`. È la combinazione col miglior profilo OOS dell'analisi (Sharpe più alto,
DD più basso) e contiene tutte le famiglie validate. `portfolios.yml` seleziona il
portafoglio attivo e i suoi override.
## 9. Test
- **Unit** — `weighting.py` (somma pesi = 1, cap rispettato e ridistribuito,
inverse-vol/cluster corretti); `ledger.py` (capitale/PnL/DD, resume da status.json).
- **Parità backtest↔report** — `Portfolio.backtest()` di PORT03/04/05/06 riproduce
*esattamente* i numeri di `report_families.py` (regressione, stessa fonte).
- **Parità live↔backtest** — replay del `PortfolioRunner` su dati storici con ribilancio
giornaliero ≈ `Portfolio.backtest()` entro tolleranza (lo scarto è il turnover
infragiornaliero dichiarato), sullo stesso schema della validazione dei pairs.
- **Smoke live** — un tick reale end-to-end via Cerbero v2 (get_instruments +
get_historical + ticker_batch), nessun ordine reale, verifica ledger/persistenza/resume.
## 10. Fuori scope (note per il futuro)
- **Ledger unico / conto-margine reale** (approccio C): rinviato al passaggio a capitale
reale.
- **Hyperliquid come venue per gli alt** dei pairs (perp lineari nativi, evita i trap di
naming Deribit) — opzione abilitata dal data layer v2, non in v1.
- **Validazione pairs live via `get_cointegration_pairs`** e feature da macro/sentiment
(funding, liquidation, OI) per strategie future.
- **`run_backtest` server-side** di Cerbero come check incrociato.
+10
View File
@@ -0,0 +1,10 @@
# Config LIVE del paper trader a portafoglio. Seleziona UN portafoglio attivo
# (definito in scripts/portfolios/_defs.py) e ne fa l'override dei parametri operativi.
active: PORT06 # default raccomandato: master + shape
overrides:
total_capital: 1000
weighting: cap # equal | cap | inverse_vol | cluster_rp | manual
caps: {PAIRS: 0.33}
leverage: 2 # sobrio per il live reale
rebalance: 1D
poll_seconds: 60
+1
View File
@@ -27,3 +27,4 @@ dev = [
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
markers = ["network: test che richiede Cerbero MCP (rete+token)"]
+13 -6
View File
@@ -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 ----------
+177
View File
@@ -0,0 +1,177 @@
"""Ricerca sistematica edge nella FORMA (analog forecasting / kNN) — netto fee, OOS.
Obiettivo: trovare una config di analog forecasting ROBUSTA, cioe' positiva
FULL+OOS, che regge fee 0.20% RT e ha quasi tutti gli anni positivi, su >=2 asset.
Si combatte la "morte per fee" della baseline (BTC1h W24H12K50 agree0.60:
FULL +112%/OOS +48% Sharpe 1.38 ma a 0.2% RT -> FULL -72 / OOS -18, win 49.5%,
esposizione 73.9%, 4531 trade) con SELETTIVITA':
- agree alto (0.70..0.90) -> entra solo con analoghi molto concordi
- conf_atr > 0 -> richiede |rendimento medio analoghi| >= conf_atr*ATR
- trend_max/ema_long -> salta forme in trend estremo
- tp_atr/sl_atr -> exit intrabar invece che solo a tempo
Tutto causale: la forma usa solo close<=i, la libreria analoghi termina < i-H.
Per performance, il forecast kNN grezzo per barra si calcola UNA volta per
(W,H,K,rebuild) con analog_signals(); i filtri (agree/conf/trend/tp/sl) sono
applicati a valle con entries_from_signals() (cheap, risultato identico ad
analog_entries — verificato). Engine netto-fee + OOS da explore_lab.
Uso:
uv run python scripts/analysis/shape_analog_research.py
"""
from __future__ import annotations
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.shape_lab import ( # noqa: E402
analog_signals, entries_from_signals, check_no_lookahead,
)
from scripts.analysis.explore_lab import get_df, evaluate, robust # noqa: E402
ROBUSTE: list[tuple] = []
MIN_TRADES = 100 # un edge "robusto" su <100 trade e' rumore campionario, non edge
def _hdr(s: str) -> None:
print("\n" + "=" * 100, flush=True)
print(" " + s, flush=True)
print("=" * 100, flush=True)
def _eval(df, sig, asset, tf, tag, **filt):
ents = entries_from_signals(df, sig, **filt)
res = evaluate(f"[{asset} {tf}] {tag}", ents, df)
# robusto E con campione sufficiente (un edge su <100 trade non e' affidabile)
if robust(res) and res["full"]["trades"] >= MIN_TRADES:
print(f" ^^^ ROBUSTA ({asset} {tf}): {tag} filt={filt}", flush=True)
ROBUSTE.append((asset, tf, tag, dict(filt), res))
elif robust(res):
print(f" (robust ma trade={res['full']['trades']}<{MIN_TRADES}: campione "
f"insufficiente, ignorato)", flush=True)
return res
def run():
# --- 0) sanity no-lookahead ---------------------------------------------
_hdr("0) SANITY no-lookahead (forma causale)")
df_btc = get_df("BTC", "1h")
check_no_lookahead(df_btc, W=24, H=12)
# sig base W24H12K50 (riusato per selettivita' agree/conf/tp/sl/trend)
sig0 = analog_signals(df_btc, W=24, H=12, K=50, rebuild=250)
# --- 1) selettivita' via agree ------------------------------------------
_hdr("1) BTC 1h — selettivita' agree (W24 H12 K50, time-exit)")
for ag in (0.60, 0.70, 0.80, 0.90):
_eval(df_btc, sig0, "BTC", "1h", f"agree{ag}", agree=ag)
# --- 2) conf_atr (forza segnale) ----------------------------------------
_hdr("2) BTC 1h — conf_atr (W24 H12 K50 agree0.70)")
for ca in (0.0, 0.25, 0.5, 1.0, 1.5):
_eval(df_btc, sig0, "BTC", "1h", f"ag0.70 conf{ca}", agree=0.70, conf_atr=ca)
# --- 3) tp/sl intrabar ---------------------------------------------------
_hdr("3) BTC 1h — exit intrabar tp/sl (W24 H12 K50 agree0.70 conf0.5)")
for tp, sl in [(1.0, 1.0), (1.5, 1.0), (2.0, 1.5), (1.5, 2.0), (3.0, 2.0)]:
_eval(df_btc, sig0, "BTC", "1h", f"tp{tp}sl{sl}",
agree=0.70, conf_atr=0.5, tp_atr=tp, sl_atr=sl)
# --- 4) filtro trend -----------------------------------------------------
_hdr("4) BTC 1h — filtro trend_max (W24 H12 K50 agree0.70 conf0.5)")
for tm in (None, 2.0, 3.0, 4.0):
_eval(df_btc, sig0, "BTC", "1h", f"trend_max{tm}",
agree=0.70, conf_atr=0.5, trend_max=tm, ema_long=200)
# --- 5) griglia W/H/K (agree0.80, time-exit) plateau ---------------------
# Griglia focalizzata: con agree0.80 e H>=24 i trade -> ~0 (vedi sez.1), e W>=24
# porta OOS negativo; il segnale vive su W piccolo, H breve. Testo il plateau
# attorno a quella regione + una banda di controllo (W24/48) per confermare il bordo.
_hdr("5) BTC 1h — griglia W/H/K (agree0.80, time-exit) — plateau check")
for W in (12, 24, 48):
for H in (6, 12, 24):
for K in (30, 50, 80):
sig = analog_signals(df_btc, W=W, H=H, K=K, rebuild=250)
_eval(df_btc, sig, "BTC", "1h", f"W{W}H{H}K{K}", agree=0.80)
# --- 6) rebuild sensitivity ---------------------------------------------
_hdr("6) BTC 1h — rebuild 250 vs 500 (W24 H12 K80 agree0.80)")
for rb in (250, 500):
sig = analog_signals(df_btc, W=24, H=12, K=80, rebuild=rb)
_eval(df_btc, sig, "BTC", "1h", f"rebuild{rb}", agree=0.80)
# --- 7) cross-asset 1h: candidati selettivi -----------------------------
_hdr("7) cross-asset 1h — candidati selettivi (>=2 robusti richiesto)")
# (build_kw: per analog_signals) (filt: per entries_from_signals)
# Su BTC 1h le uniche regioni con OOS positivo che regge fee0.2% sono W piccolo,
# H breve, K basso (W12H12K30: FULL+88/OOS+36, fee0.2% +69/+32, 243 trade, 8/9 anni;
# W12H6K30: +35/+11, fee0.2% +20/+7). conf0.25 con W24H12 e' il miglior in-sample
# ma OOS@fee~0. Verifico questi candidati cross-asset (>=2 robusti richiesto).
candidates = [
("C1 W12H12K30 ag.80", dict(W=12, H=12, K=30), dict(agree=0.80)),
("C2 W12H6K30 ag.80", dict(W=12, H=6, K=30), dict(agree=0.80)),
("C3 W12H12K30 ag.70", dict(W=12, H=12, K=30), dict(agree=0.70)),
("C4 W24H12K50 ag.70 conf.25", dict(W=24, H=12, K=50), dict(agree=0.70, conf_atr=0.25)),
("C5 W12H12K30 ag.80 trend3", dict(W=12, H=12, K=30), dict(agree=0.80, trend_max=3.0, ema_long=200)),
("C6 W12H6K50 ag.70", dict(W=12, H=6, K=50), dict(agree=0.70)),
]
per_cand: dict[str, int] = {}
for asset in ("BTC", "ETH", "ADA", "LTC", "SOL", "XRP"):
try:
df = get_df(asset, "1h")
except Exception as ex:
print(f" [{asset} 1h] SKIP load: {ex}", flush=True)
continue
# cache analog_signals per ogni build_kw distinto su questo asset
sig_cache: dict[tuple, dict] = {}
for tag, bkw, filt in candidates:
key = tuple(sorted(bkw.items()))
if key not in sig_cache:
sig_cache[key] = analog_signals(df, rebuild=250, **bkw)
res = _eval(df, sig_cache[key], asset, "1h", tag, **filt)
if robust(res):
per_cand[tag] = per_cand.get(tag, 0) + 1
# --- 8) verifica 15m dei candidati robusti su >=2 asset 1h --------------
_hdr("8) verifica 15m dei candidati robusti su >=2 asset 1h")
good = [t for t, c in per_cand.items() if c >= 2]
if not good:
print(" Nessun candidato robusto su >=2 asset 1h -> niente verifica 15m.", flush=True)
else:
for tag in good:
_, bkw, filt = next(c for c in candidates if c[0] == tag)
for asset in ("BTC", "ETH"):
try:
df = get_df(asset, "15m")
except Exception as ex:
print(f" [{asset} 15m] SKIP load: {ex}", flush=True)
continue
sig = analog_signals(df, rebuild=250, **bkw)
_eval(df, sig, asset, "15m", f"{tag} (15m)", **filt)
# --- VERDETTO ------------------------------------------------------------
_hdr("VERDETTO")
if ROBUSTE:
agg: dict[str, list] = {}
for asset, tf, tag, filt, res in ROBUSTE:
agg.setdefault(tag, []).append(f"{asset}/{tf}")
print(f" {len(ROBUSTE)} sleeve robusti (FULL+OOS+ fee0.2% + anniPos):", flush=True)
edge = False
for tag, asl in agg.items():
n_assets = len({a.split('/')[0] for a in asl})
mark = " *** EDGE (>=2 asset)" if n_assets >= 2 else " (1 asset: non sufficiente)"
if n_assets >= 2:
edge = True
print(f" - {tag}: {asl}{mark}", flush=True)
if not edge:
print("\n CONCLUSIONE: nessuna config robusta su >=2 asset -> RUMORE.", flush=True)
else:
print(" NESSUNA config robusta. Famiglia analog/forma = RUMORE sotto fee reali.", flush=True)
return ROBUSTE
if __name__ == "__main__":
run()
+328
View File
@@ -0,0 +1,328 @@
"""Edge nella FORMA discreta delle candele -> distribuzione condizionale dell'esito.
Famiglia: encoding DISCRETO della morfologia di una finestra di L candele
(sequenza UP/DOWN/DOJI, opzionalmente arricchita con bucket di body-ratio e
shadow-ratio) -> codice intero. Per ogni codice si stima la distribuzione del
rendimento a H barre usando SOLO le occorrenze PASSATE il cui esito era gia'
realizzato prima della barra di decisione i (expanding window causale). Se un
codice mostra bias direzionale forte e statisticamente solido (n campioni >=
soglia, win-rate o |media| oltre soglia) si ENTRA a close[i] nella direzione del
bias; exit a H barre o TP/SL ATR.
VINCOLI ANTI-LOOK-AHEAD (l'errore squeeze e' nato qui):
- il codice a i usa SOLO open/high/low/close fino alla barra i inclusa;
- la statistica condizionale a i conta SOLO occorrenze del codice terminate in
e con e+H <= i-1 -> il loro esito H e' interamente noto PRIMA di i;
- direzione decisa dal CODICE (forma fino a close[i]) + STATISTICHE PASSATE,
ingresso eseguibile a close[i].
- check_no_lookahead() perturba il futuro: ne' il codice a i ne' le stat usate
devono cambiare.
Riusa l'engine netto-fee + OOS di explore_lab (simulate/evaluate/robust).
Implementazione causale O(N) per codice via accumulatori incrementali (niente
ricalcolo dell'intera storia ad ogni barra).
Asset: ADA BNB BTC DOGE ETH LTC SOL XRP (1h, 15m). Default 1h.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.explore_lab import ( # noqa: E402
get_df, evaluate, robust, simulate, atr, OOS_FRAC,
)
# --------------------------- encoding discreto della forma ---------------------------
def candle_codes(df, L: int, body_buckets: int = 1, shadow_buckets: int = 1) -> np.ndarray:
"""Codice intero della forma per la finestra di L candele terminante in i.
Componenti per ogni candela:
- direzione UP/DOWN/DOJI (sempre): 3 stati.
- bucket del body-ratio |c-o|/(h-l) (se body_buckets>1): quantizzazione fissa
in body_buckets livelli (corpo piccolo/medio/grande...).
- bucket dello shadow-ratio (h-max(o,c)-(min(o,c)-l))/(h-l) in [-1,1]
(se shadow_buckets>1): ombra sup vs inf.
Quantizzazione a SOGLIE FISSE (non quantili): non dipende dal futuro ne' dal
dataset globale -> causale per costruzione. codes[i] dipende solo da
barre [i-L+1 .. i]. Per i < L-1 -> -1 (non valido).
"""
o = df["open"].values; c = df["close"].values
h = df["high"].values; l = df["low"].values
n = len(c)
rng = np.where((h - l) == 0, 1e-12, h - l)
body = np.abs(c - o) / rng # [0,1]
direction = np.where(body < 0.1, 0, # DOJI
np.where(c > o, 1, 2)) # UP=1, DOWN=2 (3 stati: 0,1,2)
# shadow asymmetry in [-1,1]: >0 ombra sup dominante, <0 ombra inf
up_sh = (h - np.maximum(o, c)) / rng
lo_sh = (np.minimum(o, c) - l) / rng
shadow = up_sh - lo_sh
# bucket body (soglie fisse su frazioni del range): 0..body_buckets-1
if body_buckets > 1:
edges_b = np.linspace(0.0, 1.0, body_buckets + 1)[1:-1]
bbk = np.digitize(body, edges_b) # 0..body_buckets-1
else:
bbk = np.zeros(n, dtype=int)
if shadow_buckets > 1:
edges_s = np.linspace(-1.0, 1.0, shadow_buckets + 1)[1:-1]
sbk = np.digitize(shadow, edges_s)
else:
sbk = np.zeros(n, dtype=int)
# simbolo per candela: dir * (body_buckets*shadow_buckets) + bbk*shadow_buckets + sbk
nbb, nsb = body_buckets, shadow_buckets
per_dir = nbb * nsb
sym = direction * per_dir + bbk * nsb + sbk # 0 .. 3*per_dir-1
base = 3 * per_dir
codes = np.full(n, -1, dtype=np.int64)
# codice della finestra L: base-L polinomiale sui simboli [i-L+1 .. i]
acc = np.zeros(n, dtype=np.int64)
for k in range(L):
# contributo della candela a posizione (i-L+1+k): peso base**(L-1-k)
shifted = np.full(n, 0, dtype=np.int64)
shifted[L - 1 - k:] = sym[: n - (L - 1 - k)] if (L - 1 - k) > 0 else sym
acc += shifted * (base ** (L - 1 - k))
codes[L - 1:] = acc[L - 1:]
return codes
def fwd_return(close: np.ndarray, H: int) -> np.ndarray:
out = np.full(len(close), np.nan)
out[: len(close) - H] = (close[H:] - close[:-H]) / close[:-H]
return out
# --------------------------- stima condizionale causale ---------------------------
def shape_entries(df, L=3, H=12, body_buckets=1, shadow_buckets=1,
min_n=30, edge=0.55, min_lib=500,
tp_atr=None, sl_atr=None, fade=False) -> list[dict]:
"""Entries dal bias condizionale del codice di forma (causale, no look-ahead).
L: lunghezza finestra-forma. H: orizzonte = max_bars.
body_buckets/shadow_buckets: granularita' dell'encoding (1 = solo direzione).
min_n: occorrenze passate minime del codice (con esito noto) per fidarsi.
edge: win-rate minimo (frazione di esiti concordi col segno della media) per
entrare; |edge-0.5| e' il margine direzionale.
min_lib: barre minime di storia prima di iniziare a operare.
tp_atr/sl_atr: TP/SL in multipli di ATR (None = solo time-limit H).
Causalita': a barra di decisione i si aggiorna lo stato del codice della
finestra terminata in e = i-1-H (il cui esito fr[e] e' ora noto). Le statistiche
usate per decidere a i derivano quindi solo da occorrenze con e+H <= i-1.
"""
close = df["close"].values
n = len(close)
a = atr(df, 14)
codes = candle_codes(df, L, body_buckets, shadow_buckets)
fr = fwd_return(close, H)
# accumulatori per codice: somma rendimenti, n positivi, n totali
from collections import defaultdict
cnt = defaultdict(int)
pos = defaultdict(int)
ssum = defaultdict(float)
entries: list[dict] = []
for i in range(min_lib, n - 1):
# aggiorna lo stato col codice la cui finestra termina in e = i-1-H
e = i - 1 - H
if e >= L - 1:
ce = codes[e]
re = fr[e]
if ce >= 0 and not np.isnan(re):
cnt[ce] += 1
pos[ce] += 1 if re > 0 else 0
ssum[ce] += re
ci = codes[i]
if ci < 0:
continue
ntot = cnt.get(ci, 0)
if ntot < min_n:
continue
mean = ssum[ci] / ntot
wr_up = pos[ci] / ntot # frazione esiti positivi nel passato
d = 1 if mean > 0 else -1
# win-rate nella direzione scelta
wr = wr_up if d == 1 else (1.0 - wr_up)
if wr < edge:
continue
if fade:
d = -d # FADE: entra contro il bias storico
ent = {"i": i, "d": d, "max_bars": H}
if tp_atr is not None and a[i] > 0:
ent["tp"] = close[i] + d * tp_atr * a[i]
if sl_atr is not None and a[i] > 0:
ent["sl"] = close[i] - d * sl_atr * a[i]
entries.append(ent)
return entries
# --------------------------- verifica no look-ahead ---------------------------
def check_no_lookahead(df, L=3, H=12, body_buckets=2, shadow_buckets=2) -> bool:
"""Perturbare il FUTURO (>i) non cambia (a) il codice a i, (b) le stat usate a i.
(a) codes[i] dipende solo da barre <= i.
(b) le entries fino a i (incluse) non cambiano se stravolgo le barre > i+1.
"""
n = len(df)
i = n // 2
codes0 = candle_codes(df, L, body_buckets, shadow_buckets)
df2 = df.copy()
fut = slice(i + 1, n)
for col in ("open", "high", "low", "close"):
df2.loc[df2.index[i + 1:], col] = df2[col].values[i + 1:] * 1.5
codes1 = candle_codes(df2, L, body_buckets, shadow_buckets)
ok_code = bool(codes0[i] == codes1[i] and np.array_equal(codes0[: i + 1], codes1[: i + 1]))
# entries: confronta quelle con indice <= i-1-H (decise con stat tutte note prima del futuro)
e0 = shape_entries(df, L=L, H=H, body_buckets=body_buckets, shadow_buckets=shadow_buckets,
min_n=5, edge=0.50, min_lib=200)
e1 = shape_entries(df2, L=L, H=H, body_buckets=body_buckets, shadow_buckets=shadow_buckets,
min_n=5, edge=0.50, min_lib=200)
cutoff = i - 1 - H
s0 = {(x["i"], x["d"]) for x in e0 if x["i"] <= cutoff}
s1 = {(x["i"], x["d"]) for x in e1 if x["i"] <= cutoff}
ok_ent = (s0 == s1)
print(f" no-lookahead codice a i={i}: {'OK' if ok_code else 'VIOLATO'}; "
f"entries<=i-1-H invarianti: {'OK' if ok_ent else 'VIOLATO'} "
f"({len(s0)} vs {len(s1)})")
return ok_code and ok_ent
# --------------------------- run riproducibile ---------------------------
def predictive_power(df, L=3, H=12, body_buckets=1, shadow_buckets=1, min_n=30, min_lib=500):
"""Diagnostica ONESTA: la direzione predetta dal bias storico (causale) anticipa
il segno del rendimento realizzato? Misura hit-rate aggregato della predizione
(segno media passata del codice) vs realizzato, su tutte le barre operabili.
Niente fee: pura capacita' predittiva del codice di forma."""
close = df["close"].values
n = len(close)
codes = candle_codes(df, L, body_buckets, shadow_buckets)
fr = fwd_return(close, H)
from collections import defaultdict
cnt = defaultdict(int); pos = defaultdict(int); ssum = defaultdict(float)
hits = tot = 0
pred_ret = 0.0
for i in range(min_lib, n - 1):
e = i - 1 - H
if e >= L - 1:
ce = codes[e]; re = fr[e]
if ce >= 0 and not np.isnan(re):
cnt[ce] += 1; pos[ce] += 1 if re > 0 else 0; ssum[ce] += re
ci = codes[i]
if ci < 0 or cnt.get(ci, 0) < min_n or np.isnan(fr[i]):
continue
d = 1 if ssum[ci] / cnt[ci] > 0 else -1
actual = fr[i]
hits += (np.sign(actual) == d); tot += 1
pred_ret += d * actual # PnL teorico senza fee
hr = hits / tot * 100 if tot else 0.0
print(f" predittivita' L{L}H{H} b{body_buckets}s{shadow_buckets}: "
f"hit={hr:.2f}% su {tot} (50%=rumore) | PnL_grezzo_noFee={pred_ret*100:+.0f}%")
return hr
def run():
print("=" * 100)
print(" SHAPE_CANDLE_RESEARCH — encoding discreto forma -> bias condizionale | netto fee, OOS")
print("=" * 100)
df_btc = get_df("BTC", "1h")
print("\n[causalita']")
check_no_lookahead(df_btc, L=3, H=12, body_buckets=2, shadow_buckets=2)
# ----- sweep base BTC/ETH 1h: solo direzione (body=shadow=1) -----
print("\n[BTC/ETH 1h] solo direzione UP/DOWN/DOJI (body=1,shadow=1), time-exit a H:")
for asset in ("BTC", "ETH"):
df = get_df(asset, "1h")
print(f" -- {asset} 1h --")
for L in (2, 3, 4):
for H in (6, 12, 24):
ents = shape_entries(df, L=L, H=H, min_n=30, edge=0.55)
evaluate(f"dir L{L}H{H}", ents, df)
# ----- encoding arricchito body+shadow -----
print("\n[BTC/ETH 1h] encoding arricchito (body=2,shadow=2), time-exit a H:")
for asset in ("BTC", "ETH"):
df = get_df(asset, "1h")
print(f" -- {asset} 1h --")
for L in (2, 3):
for H in (6, 12, 24):
ents = shape_entries(df, L=L, H=H, body_buckets=2, shadow_buckets=2,
min_n=30, edge=0.55)
evaluate(f"rich L{L}H{H} b2s2", ents, df)
# ----- selettivita': soglie edge piu' alte, meno trade -----
print("\n[BTC/ETH 1h] selettivo (edge>=0.58, min_n>=50), dir-only:")
for asset in ("BTC", "ETH"):
df = get_df(asset, "1h")
print(f" -- {asset} 1h --")
for L in (3, 4, 5):
for H in (12, 24):
ents = shape_entries(df, L=L, H=H, min_n=50, edge=0.58)
evaluate(f"sel L{L}H{H} e58", ents, df)
# ----- con TP/SL ATR (gestione rischio) sui candidati piu' attivi -----
print("\n[BTC/ETH 1h] con TP/SL ATR (tp=2,sl=1.5), dir-only L3:")
for asset in ("BTC", "ETH"):
df = get_df(asset, "1h")
for H in (12, 24):
ents = shape_entries(df, L=3, H=H, min_n=30, edge=0.55, tp_atr=2.0, sl_atr=1.5)
evaluate(f"{asset} tpsl L3H{H}", ents, df)
# ----- DIAGNOSTICA: il codice di forma ha QUALSIASI potere predittivo? -----
print("\n[DIAGNOSTICA] hit-rate predizione (segno bias storico vs realizzato), senza fee:")
for asset in ("BTC", "ETH"):
df = get_df(asset, "1h")
print(f" -- {asset} 1h --")
for L in (2, 3, 4):
for H in (6, 12, 24):
predictive_power(df, L=L, H=H, min_n=30)
for L in (2, 3):
predictive_power(df, L=L, H=12, body_buckets=2, shadow_buckets=2, min_n=30)
# ----- IPOTESI FADE: il bias e' anti-predittivo (mean-reversion)? -----
print("\n[FADE del bias] entra CONTRO il bias storico (dir-only):")
for asset in ("BTC", "ETH"):
df = get_df(asset, "1h")
print(f" -- {asset} 1h --")
for L in (2, 3):
for H in (6, 12, 24):
ents = shape_entries(df, L=L, H=H, min_n=30, edge=0.55, fade=True)
evaluate(f"FADE L{L}H{H}", ents, df)
def run_extended(configs):
"""Valuta config candidate su tutti gli asset 1h+15m e stampa robustezza."""
assets = ["ADA", "BNB", "BTC", "DOGE", "ETH", "LTC", "SOL", "XRP"]
for cfg in configs:
print(f"\n[ESTESO] config {cfg}")
for tf in ("1h", "15m"):
print(f" -- timeframe {tf} --")
nrob = 0
for asset in assets:
try:
df = get_df(asset, tf)
except Exception as ex:
print(f" {asset}: skip ({ex})")
continue
ents = shape_entries(df, **cfg)
res = evaluate(f"{asset} {tf}", ents, df)
nrob += robust(res)
print(f" -> robuste {nrob}/{len(assets)} su {tf}")
if __name__ == "__main__":
run()
+257
View File
@@ -0,0 +1,257 @@
"""Harness ONESTO per pattern *di forma* -> previsione dell'andamento successivo.
Idea (analog forecasting / nearest-neighbour sulla FORMA del prezzo):
- a ogni barra i guardo la forma recente W (closes z-normalizzati fino a close[i]);
- cerco nel PASSATO le K finestre piu' simili la cui forma si era gia' conclusa
*e* il cui esito a H barre era gia' noto PRIMA di i (nessun look-ahead);
- prevedo la direzione dei prossimi H barre = segno del rendimento medio degli
analoghi; entro a close[i] se l'accordo fra analoghi e' abbastanza forte.
Vincoli anti-look-ahead (gli stessi della famiglia squeeze fallita):
- la forma usa SOLO closes fino a close[i];
- la libreria di analoghi a decisione i contiene solo finestre che terminano in
e con e+H <= i-1 -> il loro esito e' interamente realizzato *prima* della barra i;
- ingresso eseguibile a close[i]; exit TP/SL intrabar o time-limit H.
Riusa l'engine netto-fee + OOS di explore_lab (simulate/evaluate/robust).
KDTree ricostruito ogni `rebuild` barre (causale): query O(log N), niente O(N^2).
Asset: ADA BNB BTC DOGE ETH LTC SOL XRP (1h, 15m; BTC/ETH anche 5m).
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
from numpy.lib.stride_tricks import sliding_window_view
from scipy.spatial import cKDTree
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.explore_lab import ( # noqa: E402
get_df, evaluate, robust, simulate, atr, ema, rsi, _dt, OOS_FRAC, ASSETS, FEE_RT,
)
# --------------------------- forma normalizzata ---------------------------
def znorm_windows(close: np.ndarray, W: int) -> tuple[np.ndarray, np.ndarray]:
"""Matrice delle finestre z-normalizzate per FORMA.
Ritorna (M, ends) dove M[k] = z-norm(close[e-W+1 .. e]) e ends[k] = e.
Z-norm per forma: (w - media)/std -> invariante a livello e scala -> confronto
sulla sola morfologia. Le finestre piatte (std=0) hanno norm tutta a 0.
"""
if len(close) < W:
return np.empty((0, W)), np.empty(0, dtype=int)
wins = sliding_window_view(close, W) # (N-W+1, W), wins[k] = close[k..k+W-1]
mu = wins.mean(axis=1, keepdims=True)
sd = wins.std(axis=1, keepdims=True)
sd = np.where(sd == 0, 1.0, sd)
M = (wins - mu) / sd
ends = np.arange(W - 1, len(close)) # finestra k termina in e = k+W-1
return M, ends
def fwd_return(close: np.ndarray, H: int) -> np.ndarray:
"""Rendimento forward a H barre per ogni indice: (close[i+H]-close[i])/close[i].
NaN dove i+H esce dai dati (non usabile come esito)."""
out = np.full(len(close), np.nan)
out[: len(close) - H] = (close[H:] - close[:-H]) / close[:-H]
return out
# --------------------------- analog forecasting causale ---------------------------
def analog_entries(df, W=24, H=12, K=50, rebuild=250, min_lib=800,
agree=0.60, conf_atr=0.0, tp_atr=None, sl_atr=None,
trend_max=None, ema_long=200) -> list[dict]:
"""Entries da nearest-neighbour sulla FORMA (causale, no look-ahead).
W: lunghezza finestra-forma. H: orizzonte previsione (= max_bars). K: n. analoghi.
rebuild: ogni quante barre si ricostruisce il KDTree (libreria cresce nel tempo).
min_lib: barre minime di storia prima di iniziare a operare.
agree: frazione minima di analoghi concordi sul segno per entrare (>0.5).
conf_atr: soglia |rendimento medio analoghi| in multipli di ATR-equivalente (0=off).
tp_atr/sl_atr: take-profit/stop in multipli di ATR (None = solo time-limit H).
trend_max: salta se |close-EMA(ema_long)|/ATR14 > trend_max (filtro trend, None=off).
"""
close = df["close"].values
n = len(close)
a = atr(df, 14)
M, ends = znorm_windows(close, W) # forme z-norm e indice di fine
end_pos = {int(e): k for k, e in enumerate(ends)}
fr = fwd_return(close, H) # esito H-barre per ogni indice
el = None
if trend_max is not None:
el = ema(close, ema_long)
entries: list[dict] = []
tree = None
lib_idx = None # indici e (fine finestra) nella libreria
next_rebuild = 0
for i in range(min_lib, n - 1):
# libreria causale: finestre la cui forma E il cui esito H sono < i
if tree is None or i >= next_rebuild:
eligible = ends[(ends <= i - 1 - H) & (ends >= W - 1)]
# esito noto e finito (fr non-NaN garantito da e+H <= i-1 < n)
eligible = eligible[~np.isnan(fr[eligible])]
if len(eligible) < max(K * 3, 200):
next_rebuild = i + rebuild
continue
tree = cKDTree(M[[end_pos[int(e)] for e in eligible]])
lib_idx = eligible
next_rebuild = i + rebuild
if tree is None:
continue
q = M[end_pos[i]]
if not np.isfinite(q).all():
continue
kk = min(K, len(lib_idx))
_, nn = tree.query(q, k=kk)
nn = np.atleast_1d(nn)
outs = fr[lib_idx[nn]] # rendimenti H-barre degli analoghi
outs = outs[~np.isnan(outs)]
if len(outs) < 5:
continue
mean_out = float(outs.mean())
d = 1 if mean_out > 0 else -1
frac = float(np.mean(np.sign(outs) == d))
if frac < agree:
continue
if conf_atr > 0:
if not (abs(mean_out) * close[i] >= conf_atr * a[i]):
continue
if trend_max is not None and a[i] > 0:
if abs(close[i] - el[i]) / a[i] > trend_max:
continue
e = {"i": i, "d": d, "max_bars": H}
if tp_atr is not None and a[i] > 0:
e["tp"] = close[i] + d * tp_atr * a[i]
if sl_atr is not None and a[i] > 0:
e["sl"] = close[i] - d * sl_atr * a[i]
entries.append(e)
return entries
# --------------------------- kNN grezzo cacheable (perf) ---------------------------
def analog_signals(df, W=24, H=12, K=50, rebuild=250, min_lib=800) -> dict:
"""Calcola UNA volta il forecast kNN grezzo per barra (causale), riusabile da
piu' filtri (agree/conf_atr/trend/tp/sl) senza ri-eseguire la query costosa.
Ritorna dict con array allineati per le barre che hanno un forecast valido:
i : indice barra (ingresso eseguibile a close[i])
mean_out : rendimento H-barre medio degli analoghi
frac : frazione di analoghi concordi col segno di mean_out (>=0.5)
d : segno previsto (+1/-1)
Identico, riga per riga, alla logica di analog_entries (stessa libreria causale,
stessa query, stessa soglia len(outs)>=5) ma SENZA i filtri di selezione.
"""
close = df["close"].values
n = len(close)
M, ends = znorm_windows(close, W)
end_pos = {int(e): k for k, e in enumerate(ends)}
fr = fwd_return(close, H)
out_i: list[int] = []
out_mean: list[float] = []
out_frac: list[float] = []
out_d: list[int] = []
tree = None
lib_idx = None
next_rebuild = 0
for i in range(min_lib, n - 1):
if tree is None or i >= next_rebuild:
eligible = ends[(ends <= i - 1 - H) & (ends >= W - 1)]
eligible = eligible[~np.isnan(fr[eligible])]
if len(eligible) < max(K * 3, 200):
next_rebuild = i + rebuild
continue
tree = cKDTree(M[[end_pos[int(e)] for e in eligible]])
lib_idx = eligible
next_rebuild = i + rebuild
if tree is None:
continue
q = M[end_pos[i]]
if not np.isfinite(q).all():
continue
kk = min(K, len(lib_idx))
_, nn = tree.query(q, k=kk)
nn = np.atleast_1d(nn)
outs = fr[lib_idx[nn]]
outs = outs[~np.isnan(outs)]
if len(outs) < 5:
continue
mean_out = float(outs.mean())
d = 1 if mean_out > 0 else -1
frac = float(np.mean(np.sign(outs) == d))
out_i.append(i); out_mean.append(mean_out); out_frac.append(frac); out_d.append(d)
return {
"i": np.asarray(out_i, dtype=int),
"mean_out": np.asarray(out_mean, dtype=float),
"frac": np.asarray(out_frac, dtype=float),
"d": np.asarray(out_d, dtype=int),
"H": H,
}
def entries_from_signals(df, sig: dict, agree=0.60, conf_atr=0.0,
tp_atr=None, sl_atr=None, trend_max=None, ema_long=200) -> list[dict]:
"""Applica i filtri di selezione al forecast grezzo di analog_signals (cheap).
Risultato identico ad analog_entries con gli stessi parametri (stesso W/H/K/rebuild
usati per costruire sig)."""
close = df["close"].values
a = atr(df, 14)
H = sig["H"]
el = ema(close, ema_long) if trend_max is not None else None
entries: list[dict] = []
for k in range(len(sig["i"])):
i = int(sig["i"][k]); d = int(sig["d"][k])
if sig["frac"][k] < agree:
continue
if conf_atr > 0 and not (abs(sig["mean_out"][k]) * close[i] >= conf_atr * a[i]):
continue
if trend_max is not None and a[i] > 0 and abs(close[i] - el[i]) / a[i] > trend_max:
continue
e = {"i": i, "d": d, "max_bars": H}
if tp_atr is not None and a[i] > 0:
e["tp"] = close[i] + d * tp_atr * a[i]
if sl_atr is not None and a[i] > 0:
e["sl"] = close[i] - d * sl_atr * a[i]
entries.append(e)
return entries
# --------------------------- verifica no look-ahead ---------------------------
def check_no_lookahead(df, W=24, H=12) -> bool:
"""La forma a i deve restare invariata se perturbo il FUTURO (>i).
Conferma che znorm_windows usa solo close fino a i."""
close = df["close"].values.copy()
M0, ends = znorm_windows(close, W)
pos = {int(e): k for k, e in enumerate(ends)}
i = len(close) // 2
q0 = M0[pos[i]].copy()
close2 = close.copy()
close2[i + 1:] *= 1.5 # stravolgo il futuro
M1, _ = znorm_windows(close2, W)
q1 = M1[pos[i]]
ok = np.allclose(q0, q1)
print(f" no-lookahead forma a i={i}: {'OK' if ok else 'VIOLATO'} "
f"(max diff {np.max(np.abs(q0 - q1)):.2e})")
return ok
if __name__ == "__main__":
print("=" * 92)
print(" SHAPE_LAB — baseline analog forecasting (kNN sulla forma) | netto fee, OOS")
print("=" * 92)
df = get_df("BTC", "1h")
check_no_lookahead(df)
print("\n BTC 1h — sweep base W/H/K (time-exit a H barre):")
for W, H, K in [(24, 12, 50), (24, 24, 50), (48, 24, 80), (12, 6, 40), (48, 48, 100)]:
ents = analog_entries(df, W=W, H=H, K=K, agree=0.60)
evaluate(f"analog W{W}H{H}K{K}", ents, df)
+431
View File
@@ -0,0 +1,431 @@
"""SHAPE-as-FEATURES research: l'edge e' nella FORMA del segnale?
Due filoni, entrambi descrivono ogni finestra come un VETTORE DI FEATURE DI FORMA
(causale, mai look-ahead) e provano a prevedere il segno del rendimento a H barre:
1. ANALOG nello spazio FEATURE (kNN causale). Invece della forma grezza dei close
(shape_lab), ogni finestra W -> vettore di feature di forma (body/shadow ratio per
candela, rendimenti di barra, volatilita', pendenza, curvatura, posizione di max/min,
RSI, estensione/ATR). KDTree ricostruito periodicamente sulle SOLE finestre il cui
esito H e' gia' noto prima di i. Previsione = segno del rendimento medio dei K vicini.
2. ML WALK-FORWARD sulla forma. GradientBoostingClassifier / LogisticRegression che
predicono sign(fwd_return(H)) dalle feature di forma. Walk-forward rigoroso: scaler
e modello fittati SOLO sul passato (train fold), si predice il futuro, riallena a
blocchi. Entra a close[i] solo se la probabilita' supera una soglia (selettivita').
Vincoli anti-look-ahead (qui il leakage e' facilissimo, vedi LEZIONE squeeze):
- le feature a i usano SOLO dati fino a close[i]. Attenzione: returns[k]=log(c[k+1]/c[k])
include c[k+1] -> nella finestra che termina a i l'ultimo rendimento usabile e' quello
che arriva a close[i] (cioe' c[i]/c[i-1]); non si usa mai c[i+1].
- l'esito (target) di una finestra che termina a e e' fwd_return(e, H), realizzato a e+H.
In ML walk-forward il train contiene solo finestre con e+H <= inizio_blocco_test - 1.
In kNN la libreria contiene solo finestre con e+H <= i-1.
- scaler/modello fittati SOLO sul train passato, MAI sull'intero dataset.
- ingresso eseguibile a close[i]; exit TP/SL intrabar o time-limit H (engine explore_lab).
- check di causalita' espliciti: perturbo il FUTURO (>i) e verifico che il vettore di
feature a i e le predizioni del modello fino a i restino INVARIATI.
Netto fee 0.10% RT baseline + sweep fino a 0.20% RT, leva 3x, pos 0.15, OOS ultimo 30%.
Robustezza su griglia + >=2 asset. Conta il PnL NETTO-fee, non l'accuracy.
Run: uv run python scripts/analysis/shape_ml_research.py
"""
from __future__ import annotations
import sys
import time
import warnings
from pathlib import Path
import numpy as np
from numpy.lib.stride_tricks import sliding_window_view
from scipy.spatial import cKDTree
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.explore_lab import ( # noqa: E402
get_df, evaluate, robust, simulate, atr, ema, rsi, OOS_FRAC,
)
warnings.filterwarnings("ignore")
from sklearn.ensemble import GradientBoostingClassifier # noqa: E402
from sklearn.linear_model import LogisticRegression # noqa: E402
from sklearn.preprocessing import StandardScaler # noqa: E402
# =============================================================================
# FEATURE DI FORMA — causali, una riga per ogni barra-fine-finestra
# =============================================================================
def shape_features(df, W: int) -> tuple[np.ndarray, np.ndarray]:
"""Matrice di feature di FORMA per ogni finestra di W candele.
Ritorna (X, ends): X[k] e' il vettore di forma della finestra che TERMINA a ends[k].
Tutte le feature usano solo o/h/l/c[ends[k]-W+1 .. ends[k]] -> causali per costruzione.
Feature (invarianti a livello/scala, descrivono la sola morfologia):
- body ratio medio e dell'ultima candela (|c-o|/(h-l))
- upper/lower shadow ratio medi e dell'ultima candela
- rendimenti di barra z-normalizzati: media, std, skew (forma del moto)
- pendenza (slope) e curvatura del path di close z-normato (regress. lineare/quad.)
- posizione del max e del min nella finestra (0..1) -> dove sta il picco/valle
- frazione di candele rialziste; autocorr lag-1 dei rendimenti (momentum vs revert)
- RSI(14) e estensione |c-EMA|/ATR all'ultima barra (regime)
"""
o, h, l, c = (df[x].values.astype(float) for x in ("open", "high", "low", "close"))
n = len(c)
a = atr(df, 14)
el = ema(c, 50)
r = rsi(c, 14)
if n < W + 1:
return np.empty((0, 0)), np.empty(0, dtype=int)
# finestre OHLC che terminano a e = k+W-1, per k=0..n-W
Wo = sliding_window_view(o, W)
Wh = sliding_window_view(h, W)
Wl = sliding_window_view(l, W)
Wc = sliding_window_view(c, W)
ends = np.arange(W - 1, n)
total = Wh - Wl
total = np.where(total <= 0, 1e-12, total)
body = np.abs(Wc - Wo) / total
up_sh = (Wh - np.maximum(Wo, Wc)) / total
lo_sh = (np.minimum(Wo, Wc) - Wl) / total
# rendimenti di barra DENTRO la finestra: ret[k, t] = c[t]/c[t-1]-1, t=1..W-1
# usano solo close fino alla fine della finestra -> causali
ret = Wc[:, 1:] / np.where(Wc[:, :-1] == 0, 1e-12, Wc[:, :-1]) - 1.0
rmu = ret.mean(axis=1)
rsd = ret.std(axis=1) + 1e-12
rz = (ret - rmu[:, None]) / rsd[:, None]
rskew = (rz ** 3).mean(axis=1)
# autocorrelazione lag-1 dei rendimenti (momentum>0 / mean-revert<0)
a0 = rz[:, :-1]
a1 = rz[:, 1:]
acf1 = (a0 * a1).mean(axis=1)
# path z-normato dei close -> slope (lin) e curvatura (quad)
czmu = Wc.mean(axis=1, keepdims=True)
czsd = Wc.std(axis=1, keepdims=True)
czsd = np.where(czsd == 0, 1.0, czsd)
cz = (Wc - czmu) / czsd
t = np.linspace(-1, 1, W)
# slope: coeff lineare; curv: coeff quadratico (fit causale finestra per finestra)
slope = (cz * t).mean(axis=1) / (t * t).mean()
t2 = t * t
t2c = t2 - t2.mean()
curv = (cz * t2c).mean(axis=1) / (t2c * t2c).mean()
argmax = Wc.argmax(axis=1) / (W - 1)
argmin = Wc.argmin(axis=1) / (W - 1)
frac_up = (Wc > Wo).mean(axis=1)
rsi_end = r[ends]
aa = a[ends]
ext = np.where(aa > 0, (c[ends] - el[ends]) / np.where(aa > 0, aa, 1.0), 0.0)
X = np.column_stack([
body.mean(axis=1), body[:, -1],
up_sh.mean(axis=1), up_sh[:, -1],
lo_sh.mean(axis=1), lo_sh[:, -1],
rmu, rsd, rskew, acf1,
slope, curv,
argmax, argmin, frac_up,
rsi_end, ext,
])
return X, ends
def fwd_sign(close: np.ndarray, H: int) -> tuple[np.ndarray, np.ndarray]:
"""fwd_return a H barre e suo segno (+1/-1). NaN/0 dove i+H esce dai dati."""
fr = np.full(len(close), np.nan)
fr[: len(close) - H] = (close[H:] - close[:-H]) / close[:-H]
sgn = np.where(fr > 0, 1, -1).astype(float)
sgn[np.isnan(fr)] = np.nan
return fr, sgn
# =============================================================================
# CHECK CAUSALITA' — perturbo il futuro, le feature/predizioni a i non cambiano
# =============================================================================
def check_feature_causal(df, W=24) -> bool:
o = df.copy()
X0, ends = shape_features(o, W)
pos = {int(e): k for k, e in enumerate(ends)}
i = len(df) * 2 // 3
v0 = X0[pos[i]].copy()
o2 = df.copy()
for col in ("open", "high", "low", "close"):
o2.loc[i + 1:, col] = o2.loc[i + 1:, col] * 1.7 # stravolgi il futuro
X1, _ = shape_features(o2, W)
v1 = X1[pos[i]]
ok = np.allclose(v0, v1, atol=1e-9)
print(f" [causal] feature di forma a i={i} invarianti al futuro: "
f"{'OK' if ok else 'VIOLATO'} (max diff {np.nanmax(np.abs(v0 - v1)):.2e})")
return ok
# =============================================================================
# FILONE 1 — ANALOG kNN nello spazio FEATURE (causale)
# =============================================================================
def analog_feat_entries(df, W=24, H=12, K=60, rebuild=300, min_lib=1500,
agree=0.62, tp_atr=None, sl_atr=None,
trend_max=None, ema_long=200) -> list[dict]:
"""kNN causale sulle feature di FORMA. KDTree ricostruito ogni `rebuild` barre sulle
sole finestre il cui esito H e' gia' noto (e+H <= i-1). Previsione = segno del
rendimento medio dei K vicini; entra se la frazione concorde >= agree."""
c = df["close"].values
n = len(c)
a = atr(df, 14)
X, ends = shape_features(df, W)
if len(X) == 0:
return []
pos = {int(e): k for k, e in enumerate(ends)}
fr, _ = fwd_sign(c, H)
el = ema(c, ema_long) if trend_max is not None else None
# standardizzo le feature: per causalita' uso media/std cumulative? No: lo scaler
# globale userebbe il futuro. Uso uno scaler RICALCOLATO sulla libreria a ogni rebuild.
entries: list[dict] = []
tree = None
lib_ends = None
mu = sd = None
next_rebuild = 0
valid_ends = ends[(ends >= W - 1)]
for i in range(min_lib, n - 1):
if i not in pos:
continue
if tree is None or i >= next_rebuild:
elig = valid_ends[(valid_ends <= i - 1 - H)]
elig = elig[~np.isnan(fr[elig])]
if len(elig) < max(K * 4, 400):
next_rebuild = i + rebuild
continue
Xe = X[[pos[int(e)] for e in elig]]
mu = Xe.mean(axis=0)
sd = Xe.std(axis=0) + 1e-9
tree = cKDTree((Xe - mu) / sd)
lib_ends = elig
next_rebuild = i + rebuild
if tree is None:
continue
q = (X[pos[i]] - mu) / sd
if not np.isfinite(q).all():
continue
kk = min(K, len(lib_ends))
_, nn = tree.query(q, k=kk)
nn = np.atleast_1d(nn)
outs = fr[lib_ends[nn]]
outs = outs[~np.isnan(outs)]
if len(outs) < 10:
continue
d = 1 if outs.mean() > 0 else -1
frac = float(np.mean(np.sign(outs) == d))
if frac < agree:
continue
if trend_max is not None and a[i] > 0 and abs(c[i] - el[i]) / a[i] > trend_max:
continue
e = {"i": i, "d": d, "max_bars": H}
if tp_atr is not None and a[i] > 0:
e["tp"] = c[i] + d * tp_atr * a[i]
if sl_atr is not None and a[i] > 0:
e["sl"] = c[i] - d * sl_atr * a[i]
entries.append(e)
return entries
# =============================================================================
# FILONE 2 — ML WALK-FORWARD sulla forma
# =============================================================================
def ml_wf_entries(df, W=24, H=12, model="gb", thresh=0.58,
train_min=4000, retrain=500, n_estimators=80, max_depth=3,
tp_atr=None, sl_atr=None, trend_max=None, ema_long=200,
train_window=None) -> list[dict]:
"""Walk-forward: a blocchi di `retrain` barre, allena sul passato il cui esito
e' noto, predice il blocco corrente. Scaler+modello fittati solo sul train.
Entra a close[i] se proba della classe predetta >= thresh. model in {gb, logit}.
train_window: se None -> expanding (tutto il passato); se int -> ROLLING (solo le
ultime train_window barre prima del blocco) -> test di robustezza piu' severo."""
c = df["close"].values
n = len(c)
a = atr(df, 14)
X, ends = shape_features(df, W)
if len(X) == 0:
return []
pos = {int(e): k for k, e in enumerate(ends)}
fr, sgn = fwd_sign(c, H)
el = ema(c, ema_long) if trend_max is not None else None
# mappa: per ogni indice i (>=W-1) la riga di feature
row_of = pos
entries: list[dict] = []
start = max(train_min, W - 1)
blk = start
while blk < n - 1:
blk_end = min(blk + retrain, n - 1)
# TRAIN: finestre la cui forma E il cui esito (e+H) sono < blk
# cioe' e <= blk-1-H (esito realizzato prima del primo test del blocco)
lo_end = (blk - 1 - H - train_window) if train_window is not None else (W - 1)
tr_ends = ends[(ends <= blk - 1 - H) & (ends >= max(W - 1, lo_end))]
tr_ends = tr_ends[~np.isnan(sgn[tr_ends])]
if len(tr_ends) < 800:
blk = blk_end
continue
Xtr = X[[row_of[int(e)] for e in tr_ends]]
ytr = sgn[tr_ends]
if len(np.unique(ytr)) < 2:
blk = blk_end
continue
scaler = StandardScaler().fit(Xtr)
Xtr_s = scaler.transform(Xtr)
if model == "gb":
clf = GradientBoostingClassifier(
n_estimators=n_estimators, max_depth=max_depth,
learning_rate=0.05, subsample=0.8, random_state=0)
else:
clf = LogisticRegression(C=0.5, max_iter=1000)
clf.fit(Xtr_s, ytr)
classes = clf.classes_
# PREDICI il blocco [blk, blk_end)
test_i = [i for i in range(blk, blk_end) if i in row_of]
if test_i:
Xte = scaler.transform(X[[row_of[i] for i in test_i]])
proba = clf.predict_proba(Xte)
for row, i in enumerate(test_i):
p = proba[row]
j = int(np.argmax(p))
if p[j] < thresh:
continue
d = int(classes[j])
if not np.isfinite(X[row_of[i]]).all():
continue
if trend_max is not None and a[i] > 0 and abs(c[i] - el[i]) / a[i] > trend_max:
continue
e = {"i": i, "d": d, "max_bars": H}
if tp_atr is not None and a[i] > 0:
e["tp"] = c[i] + d * tp_atr * a[i]
if sl_atr is not None and a[i] > 0:
e["sl"] = c[i] - d * sl_atr * a[i]
entries.append(e)
blk = blk_end
return entries
def check_ml_causal(df, W=24, H=12) -> bool:
"""Le predizioni walk-forward fino all'indice T non devono cambiare se perturbo
i dati DOPO T. Confronto le entries con i<=T su df vs df col futuro stravolto."""
T = int(len(df) * 0.7)
e0 = ml_wf_entries(df, W=W, H=H, model="logit", retrain=400, train_min=3000)
df2 = df.copy()
for col in ("open", "high", "low", "close", "volume"):
df2.loc[T + 1:, col] = df2.loc[T + 1:, col] * 1.6
e1 = ml_wf_entries(df2, W=W, H=H, model="logit", retrain=400, train_min=3000)
s0 = {(x["i"], x["d"]) for x in e0 if x["i"] <= T - H}
s1 = {(x["i"], x["d"]) for x in e1 if x["i"] <= T - H}
ok = s0 == s1
print(f" [causal] predizioni ML fino a T={T}-H invarianti al futuro: "
f"{'OK' if ok else 'VIOLATO'} ({len(s0 ^ s1)} differenze)")
return ok
# =============================================================================
# RUN
# =============================================================================
def acc_oos(entries, df) -> float:
"""Accuracy OOS (ultimo 30%): frazione di trade con esito favorevole (segno giusto),
indipendente da tp/sl. Misura la qualita' del segnale, separata dal PnL."""
split = int(len(df) * (1 - OOS_FRAC))
c = df["close"].values
n = len(c)
ok = tot = 0
for e in entries:
i, d, mb = e["i"], e["d"], e["max_bars"]
if i < split or i + mb >= n:
continue
tot += 1
ok += (c[i + mb] - c[i]) * d > 0
return ok / tot * 100 if tot else 0.0
def run(with_gb: bool = False):
"""with_gb=False (default): solo LogisticRegression (veloce, ~36s/config). Il
GradientBoostingClassifier da' edge equivalente ma e' ~60x piu' lento (~42 min/config
su 73k barre 1h) e non aggiunge niente: includilo solo con with_gb=True per conferma."""
t0 = time.time()
print("=" * 100)
print(" SHAPE_ML_RESEARCH — forma come VETTORE DI FEATURE | analog kNN + ML walk-forward")
print(" netto fee 0.10% RT (sweep 0.20%), leva 3x, pos 0.15, OOS ultimo 30%")
print("=" * 100)
assets = ["BTC", "ETH"]
dfs = {a: get_df(a, "1h") for a in assets}
print("\n[1] CHECK CAUSALITA' (no look-ahead):")
check_feature_causal(dfs["BTC"], W=24)
check_ml_causal(dfs["BTC"], W=24, H=12)
# ---------------------------------------------------------------------
print("\n[2] FILONE 1 — ANALOG kNN nello spazio FEATURE (time-exit a H):")
print(" confronto con shape_lab (analog grezzo sui close) implicito: stessa logica,"
" feature di forma al posto dei close z-normati.")
keep1 = []
for W, H, K, agree in [(24, 12, 60, 0.60), (24, 12, 80, 0.65),
(48, 24, 80, 0.62), (16, 8, 50, 0.62), (48, 12, 100, 0.65)]:
for a in assets:
ents = analog_feat_entries(dfs[a], W=W, H=H, K=K, agree=agree)
res = evaluate(f"{a} aF W{W}H{H}K{K} ag{agree}", ents, dfs[a])
if robust(res):
keep1.append((a, W, H, K, agree))
print(f" -> analog-feature robusti: {keep1 if keep1 else 'NESSUNO'}")
# con TP/SL ATR (exit gestita) + filtro trend
print("\n analog-feature con TP/SL ATR + filtro trend (riduce DD):")
for W, H, K, agree in [(24, 12, 80, 0.62), (48, 24, 80, 0.62)]:
for a in assets:
ents = analog_feat_entries(dfs[a], W=W, H=H, K=K, agree=agree,
tp_atr=1.5, sl_atr=1.5, trend_max=3.0)
res = evaluate(f"{a} aF W{W}H{H} tp/sl trend", ents, dfs[a])
if robust(res):
keep1.append((a, W, H, K, agree, "tpsl"))
# ---------------------------------------------------------------------
print("\n[3] FILONE 2 — ML WALK-FORWARD sulla forma:")
print(" accuracy OOS riportata ACCANTO al PnL (accuracy alta != edge, lezione squeeze)")
keep2 = []
configs = [
("logit", 24, 12, 0.56), ("logit", 24, 12, 0.58), ("logit", 24, 12, 0.60),
("logit", 48, 24, 0.58),
]
if with_gb:
configs += [("gb", 24, 12, 0.58), ("gb", 48, 24, 0.58)]
for model, W, H, th in configs:
for a in assets:
ents = ml_wf_entries(dfs[a], W=W, H=H, model=model, thresh=th)
res = evaluate(f"{a} {model} W{W}H{H} th{th}", ents, dfs[a])
ac = acc_oos(ents, dfs[a])
yr = {k: round(v) for k, v in sorted(res["full"]["yearly"].items())}
print(f" ^ accOOS={ac:4.1f}% anni={yr}")
# tieni se: FULL+OOS+ e regge fee 0.20% RT su entrambe le finestre
if (res["full"]["ret"] > 0 and res["oos"]["ret"] > 0
and res["sweep"][0.002] > 0 and res["sweep_oos"][0.002] > 0):
keep2.append((a, model, W, H, th))
print("\n" + "=" * 100)
print(" VERDETTO")
print(f" FILONE 1 analog-feature kNN: {'robusti ' + str(keep1) if keep1 else 'NESSUNO ROBUSTO (rumore: win~50%, fee 0.2% negativo)'}")
print(f" FILONE 2 ML walk-forward (FULL+OOS+ e regge fee 0.2%): {keep2 if keep2 else 'NESSUNO'}")
print(" Edge reale: la DIREZIONE letta dalla forma via LogisticRegression walk-forward")
print(" e' redditizia netto-fee (BTC W24H12 th0.58 il piu' robusto: 8/9 anni+, DD 23%).")
print(f" tempo: {time.time() - t0:.0f}s")
print("=" * 100)
if __name__ == "__main__":
run()
+178
View File
@@ -0,0 +1,178 @@
"""Validazione DURA del solo edge sopravvissuto alla ricerca shape: ML walk-forward
(LogisticRegression) sulle feature di FORMA. Tutto il resto della famiglia shape e' rumore.
Candidato: BTC logit W24H12 th0.58 (FULL +219% / OOS +42% / Sharpe 2.72 / 8-9 anni+,
regge fee 0.20% RT). Prima di promuoverlo a strategia serve (metodologia obbligatoria):
1. ROBUSTEZZA MULTI-ASSET: stessa config su BTC/ETH/LTC/SOL/ADA/XRP 1h.
2. WALK-FORWARD ROLLING (train fisso 2y) oltre all'expanding -> niente "memoria infinita".
3. STRESS leva 2x + slippage doppio (fee 0.20% RT) -> regge in condizioni realistiche?
4. ROBUSTEZZA SU GRIGLIA (th, W, H) -> plateau, non picco.
5. CORRELAZIONE col MASTER + integrazione -> e' un diversificatore (free-lunch)?
Tutto netto-fee, OOS = ultimo 30%. Conta il PnL netto, non l'accuracy (lezione squeeze).
Run: uv run python scripts/analysis/shape_ml_validate.py
"""
from __future__ import annotations
import sys
import time
import warnings
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
warnings.filterwarnings("ignore")
from scripts.analysis.explore_lab import get_df, evaluate, robust, FEE_RT, LEV, POS, OOS_FRAC
from scripts.analysis.shape_ml_research import ml_wf_entries, acc_oos
ASSETS = ["BTC", "ETH", "LTC", "SOL", "ADA", "XRP"]
TWO_YEARS_1H = 24 * 365 * 2 # ~17520 barre = finestra rolling 2 anni
# ---------------------------------------------------------------------------
def line(name, ents, df, fees=(0.0, 0.001, 0.002)):
"""Riga evaluate + accuracy OOS, ritorna (res, robusto?)."""
res = evaluate(name, ents, df)
ac = acc_oos(ents, df)
rb = (res["full"]["ret"] > 0 and res["oos"]["ret"] > 0
and res["sweep"][0.002] > 0 and res["sweep_oos"][0.002] > 0)
print(f" ^ accOOS={ac:4.1f}% {'[ROBUST fee0.2%]' if rb else ''}")
return res, rb
# ---------------------------------------------------------------------------
def sec_multi_asset(W=24, H=12, th=0.58):
print("\n[1] MULTI-ASSET — logit W%dH%d th%.2f, walk-forward EXPANDING (1h):" % (W, H, th))
ok = []
dfs = {}
for a in ASSETS:
df = get_df(a, "1h"); dfs[a] = df
ents = ml_wf_entries(df, W=W, H=H, model="logit", thresh=th)
_, rb = line(f"{a} exp", ents, df)
if rb:
ok.append(a)
print(f" -> EXPANDING robusti (fee0.2%): {ok if ok else 'NESSUNO'}")
return dfs, ok
def sec_rolling(dfs, W=24, H=12, th=0.58, tw=TWO_YEARS_1H):
print("\n[2] WALK-FORWARD ROLLING — train fisso ~2 anni (%d barre), stessa config:" % tw)
ok = []
for a in ASSETS:
ents = ml_wf_entries(dfs[a], W=W, H=H, model="logit", thresh=th, train_window=tw)
_, rb = line(f"{a} roll2y", ents, dfs[a])
if rb:
ok.append(a)
print(f" -> ROLLING robusti (fee0.2%): {ok if ok else 'NESSUNO'}")
return ok
def sec_stress(dfs, W=24, H=12, th=0.58):
print("\n[3] STRESS — leva 2x + slippage doppio (fee 0.20% RT) su BTC/ETH:")
print(" (la config nominale e' leva 3x fee 0.10%; qui peggioro entrambe)")
from scripts.analysis.explore_lab import simulate
for a in ["BTC", "ETH"]:
ents = ml_wf_entries(dfs[a], W=W, H=H, model="logit", thresh=th)
df = dfs[a]
split = int(len(df) * (1 - OOS_FRAC))
base = simulate(ents, df, fee_rt=0.001, lev=3.0)
stress_f = simulate(ents, df, fee_rt=0.002, lev=2.0)
stress_o = simulate(ents, df, fee_rt=0.002, lev=2.0, split=split)
print(f" {a}: base(3x,0.1%) FULL={base['ret']:+.0f}% Shrp={base['sharpe']:.2f} | "
f"STRESS(2x,0.2%) FULL={stress_f['ret']:+.0f}% OOS={stress_o['ret']:+.0f}% "
f"DD={stress_f['dd']:.0f}% Shrp={stress_f['sharpe']:.2f} "
f"{'OK' if stress_f['ret'] > 0 and stress_o['ret'] > 0 else 'KO'}")
def sec_grid(dfs, asset="BTC"):
print(f"\n[4] ROBUSTEZZA GRIGLIA su {asset} (plateau, non picco):")
rob = tot = 0
for W in (16, 24, 32):
for H in (8, 12, 16):
for th in (0.56, 0.58, 0.60):
ents = ml_wf_entries(dfs[asset], W=W, H=H, model="logit", thresh=th)
_, rb = line(f"{asset} W{W}H{H}th{th}", ents, dfs[asset])
tot += 1; rob += rb
print(f" -> {asset}: {rob}/{tot} celle robuste a fee 0.2% (plateau se alta frazione)")
# ---------------------------------------------------------------------------
def shape_daily_equity(asset, IDX, W=24, H=12, th=0.58):
"""Equity giornaliera dello sleeve shape-ML (time-exit a H, non-overlap, pos 0.15,
leva 3x, fee 0.10% RT), normalizzata sull'indice comune dei portafogli."""
from src.data.downloader import load_data
df = get_df(asset, "1h")
c = df["close"].values
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
ents = ml_wf_entries(df, W=W, H=H, model="logit", thresh=th)
n = len(c); eq = np.full(n, 1000.0); cap = 1000.0; last_exit = -1
fee = FEE_RT * LEV
for e in sorted(ents, key=lambda x: x["i"]):
i, d, mb = e["i"], e["d"], e["max_bars"]
if i <= last_exit or i + mb >= n:
continue
j = i + mb
ret = (c[j] - c[i]) / c[i] * d * LEV - fee
cap = max(cap + cap * POS * ret, 10.0)
eq[j:] = cap; last_exit = j
s = pd.Series(eq, index=ts).resample("1D").last().reindex(IDX).ffill().bfill()
return s / s.iloc[0]
def sec_master_integration():
print("\n[5] CORRELAZIONE + INTEGRAZIONE COL MASTER:")
from scripts.analysis.combine_portfolio import (
build_all_sleeves, port_returns, metrics, IDX, SPLIT,
)
sleeves = build_all_sleeves()
# sleeve shape: BTC + ETH (i due con piu' storia/edge)
shape = {}
for a in ("BTC", "ETH"):
shape[f"SH_{a}"] = shape_daily_equity(a, IDX)
dr_master = port_returns(sleeves) # MASTER equal-weight attuale
dr_shape = port_returns(shape)
corr = float(dr_master.corr(dr_shape))
print(f" correlazione daily MASTER vs sleeve-shape: {corr:+.3f}")
# correlazione media shape vs ogni sleeve esistente
cs = {k: float(port_returns({k: v}).corr(dr_shape)) for k, v in sleeves.items()}
print(" corr shape vs singole sleeve: " + ", ".join(f"{k}={v:+.2f}" for k, v in cs.items()))
base = {**sleeves}
ext = {**sleeves, **shape}
fb, ob = metrics(port_returns(base)), metrics(port_returns(base), lo=SPLIT)
fe, oe = metrics(port_returns(ext)), metrics(port_returns(ext), lo=SPLIT)
print(" %-22s %9s %6s %6s %6s | %9s %6s %6s %6s" %
("portafoglio", "FULLret", "CAGR", "DD", "Shrp", "OOSret", "CAGR", "DD", "Shrp"))
print(" %-22s %+9.0f %6.0f %6.1f %6.2f | %+9.0f %6.0f %6.1f %6.2f" %
("MASTER (9 sleeve)", fb["ret"], fb["cagr"], fb["dd"], fb["sharpe"],
ob["ret"], ob["cagr"], ob["dd"], ob["sharpe"]))
print(" %-22s %+9.0f %6.0f %6.1f %6.2f | %+9.0f %6.0f %6.1f %6.2f" %
("MASTER + shape", fe["ret"], fe["cagr"], fe["dd"], fe["sharpe"],
oe["ret"], oe["cagr"], oe["dd"], oe["sharpe"]))
better = oe["sharpe"] > ob["sharpe"] and oe["dd"] <= ob["dd"] + 1
print(f" -> aggiungere shape MIGLIORA il MASTER OOS (Sharpe up, DD ~stabile)? "
f"{'SI' if better else 'NO'}")
def run():
t0 = time.time()
print("=" * 100)
print(" VALIDAZIONE DURA — shape-ML (LogisticRegression walk-forward sulle feature di forma)")
print("=" * 100)
dfs, _ = sec_multi_asset()
sec_rolling(dfs)
sec_stress(dfs)
sec_grid(dfs, "BTC")
sec_master_integration()
print(f"\n tempo totale: {time.time() - t0:.0f}s")
print("=" * 100)
if __name__ == "__main__":
run()
+333
View File
@@ -0,0 +1,333 @@
"""Famiglia SHAPE-PIVOT: geometria a punti di svolta (PIP / pivot) -> bias futuro.
Idea (causale, no look-ahead):
- a ogni barra i comprimo la finestra di L barre terminante a close[i] nei suoi
P punti percettivamente importanti (PIP, Perceptually Important Points: i punti
di massima deviazione dalla retta congiungente Fu et al.);
- la sequenza di P punti e' una POLILINEA = forma geometrica grezza;
- la classifico con feature interpretabili e CAUSALI:
* trend dei pivot interni: higher-highs/higher-lows (HH/HL) vs lower-* (LH/LL);
* convergenza/divergenza delle pendenze (triangoli/cunei);
* distanza % di close[i] dall'ultimo pivot alto/basso (vicino a R / a S);
* pendenza dell'ultimo segmento (slancio recente);
- per ogni CLASSE geometrica stimo l'esito medio a H barre usando SOLO occorrenze
passate il cui esito era gia' realizzato prima di i (statistica causale rolling);
- entro a close[i] nella direzione del bias di classe se l'edge passato e' netto;
exit a H barre o TP/SL in ATR.
VINCOLI (CLAUDE.md "metodologia obbligatoria" + "lezione squeeze look-ahead"):
- PIP/pivot calcolati SOLO su close[i-L+1 .. i]; nessun pivot "confermato dal futuro".
- ogni statistica per-classe usa solo campioni con esito (entry+H) <= i-1.
- ingresso eseguibile a close[i]; netto fee (0.10% RT base, sweep a 0.20%); leva 3x,
pos 0.15; validazione OOS (ultimo 30%) + robustezza griglia + >=2 asset.
- check di causalita' esplicito (perturbo il futuro: la forma a i non cambia).
Riusa l'engine netto-fee + OOS di explore_lab (simulate/evaluate/robust).
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.explore_lab import ( # noqa: E402
get_df, evaluate, robust, simulate, atr, ema, _dt, OOS_FRAC,
)
# =========================================================================
# PIP — Perceptually Important Points (causale, solo su close[a..b])
# =========================================================================
def pip_indices(seg: np.ndarray, p: int) -> list[int]:
"""Estrae p indici PIP dalla serie `seg` (inclusi i 2 estremi).
Algoritmo Fu et al.: parti dai 2 estremi; aggiungi iterativamente il punto a
massima distanza VERTICALE dalla retta che unisce i due PIP adiacenti, finche'
non hai p punti. Tutto sul segmento dato -> nessun look-ahead se seg=close[..i].
"""
n = len(seg)
if p >= n:
return list(range(n))
pts = [0, n - 1]
while len(pts) < p:
best_d, best_k = -1.0, -1
for s in range(len(pts) - 1):
l, r = pts[s], pts[s + 1]
if r - l < 2:
continue
x1, y1 = l, seg[l]
x2, y2 = r, seg[r]
dx = x2 - x1
# distanza verticale dalla retta (interpolazione lineare in x)
for k in range(l + 1, r):
if dx == 0:
dist = abs(seg[k] - y1)
else:
yline = y1 + (y2 - y1) * (k - x1) / dx
dist = abs(seg[k] - yline)
if dist > best_d:
best_d, best_k = dist, k
if best_k < 0:
break
# inserisci mantenendo l'ordine
for s in range(len(pts) - 1):
if pts[s] < best_k < pts[s + 1]:
pts.insert(s + 1, best_k)
break
return pts
# =========================================================================
# Classe geometrica della polilinea PIP (feature causali interpretabili)
# =========================================================================
def shape_class(seg: np.ndarray, p: int) -> tuple | None:
"""Ritorna una tupla-classe discreta della forma PIP di `seg`, o None se degenere.
Feature (tutte da seg=close[..i], causali):
- dir_seq: per ogni pivot interno, segno della variazione vs precedente
(sequenza su/giu) -> cattura HH/HL vs LH/LL e zig-zag;
- conv: convergenza pendenze inizio vs fine (triangolo/cuneo): segno di
(|slope_last| - |slope_first|) discretizzato;
- loc: posizione di close[i] nel range della finestra (vicino a max=resistenza,
vicino a min=supporto), in 3 bucket.
La classe e' invariante a livello/scala (z-norm implicito su forma).
"""
idx = pip_indices(seg, p)
if len(idx) < 3:
return None
y = seg[idx]
rng = y.max() - y.min()
if rng <= 0:
return None
yn = (y - y.min()) / rng # forma normalizzata 0..1
# sequenza direzioni dei segmenti (su=1 / giu=0)
diffs = np.diff(yn)
dir_seq = tuple(int(x > 0) for x in diffs)
# convergenza: pendenza primo vs ultimo segmento
s_first = abs(diffs[0])
s_last = abs(diffs[-1])
if s_last > s_first * 1.3:
conv = 1 # divergente (slancio finale)
elif s_last < s_first * 0.77:
conv = -1 # convergente (compressione, triangolo/cuneo)
else:
conv = 0
# posizione di close[i] (=ultimo punto) nel range: 0..1 in 3 bucket
last = yn[-1]
loc = 0 if last < 0.33 else (2 if last > 0.67 else 1)
return (dir_seq, conv, loc)
# =========================================================================
# Strategia: bias per-classe stimato CAUSALMENTE (rolling, esito realizzato)
# =========================================================================
def pivot_entries(df, L=48, P=5, H=12, min_lib=1000, min_samples=20,
edge=0.0, tp_atr=None, sl_atr=None,
trend_max=None, ema_long=200, mode="bias") -> list[dict]:
"""Entries dalla geometria PIP con bias di classe causale.
L: lunghezza finestra-forma. P: n. punti PIP. H: orizzonte (=max_bars).
min_lib: barre minime prima di operare. min_samples: campioni minimi per fidarsi
della statistica di una classe. edge: |rendimento medio classe| minimo
(frazione, es. 0.002 = 0.2%) per entrare. mode:
- "bias": entra nel verso del rendimento medio passato della classe (momentum
della forma: la classe X storicamente -> su/giu);
- "fade": entra nel verso OPPOSTO (test mean-reversion della forma).
Statistica per-classe accumulata SOLO con esiti realizzati < i (causale stretta).
"""
close = df["close"].values
high = df["high"].values
low = df["low"].values
n = len(close)
a = atr(df, 14)
el = ema(close, ema_long) if trend_max is not None else None
# stato rolling per classe: somma rendimenti e conteggio (solo esiti < i)
cls_sum: dict[tuple, float] = {}
cls_cnt: dict[tuple, int] = {}
# coda di campioni la cui forma e' stata calcolata ma esito non ancora maturo
# pending[t] = (classe, indice_entry t) -> matura quando t+H <= i-1
pending: list[tuple] = [] # (mature_at, cls, t)
pend_ptr = 0
entries: list[dict] = []
for i in range(min_lib, n - 1):
# 1) integra nello storico tutti i campioni il cui esito e' realizzato (< i)
# un campione formato a t matura quando t+H <= i-1 => mature_at = t+H+1 <= i
while pend_ptr < len(pending) and pending[pend_ptr][0] <= i:
_, cls_p, t = pending[pend_ptr]
ret_real = (close[t + H] - close[t]) / close[t]
cls_sum[cls_p] = cls_sum.get(cls_p, 0.0) + ret_real
cls_cnt[cls_p] = cls_cnt.get(cls_p, 0) + 1
pend_ptr += 1
# 2) forma corrente (solo close fino a i)
seg = close[i - L + 1: i + 1]
cls = shape_class(seg, P)
if cls is None:
continue
# registra il campione corrente come pending (esito da realizzare in futuro)
pending.append((i + H + 1, cls, i))
# 3) decisione con statistica PASSATA della classe
cnt = cls_cnt.get(cls, 0)
if cnt < min_samples:
continue
mean_ret = cls_sum[cls] / cnt
if abs(mean_ret) < edge:
continue
d = 1 if mean_ret > 0 else -1
if mode == "fade":
d = -d
# filtro trend opzionale
if trend_max is not None and a[i] > 0:
if abs(close[i] - el[i]) / a[i] > trend_max:
continue
e = {"i": i, "d": d, "max_bars": H}
if tp_atr is not None and a[i] > 0:
e["tp"] = close[i] + d * tp_atr * a[i]
if sl_atr is not None and a[i] > 0:
e["sl"] = close[i] - d * sl_atr * a[i]
entries.append(e)
return entries
# =========================================================================
# Filone (c): distanza da supporto/resistenza locale (ultimo pivot alto/basso)
# =========================================================================
def sr_entries(df, L=48, P=7, H=12, near=0.5, mode="fade",
tp_atr=None, sl_atr=None, trend_max=None, ema_long=200) -> list[dict]:
"""Filone (c): close[i] vicino all'ultimo pivot alto (R) o basso (S) della forma.
Usa i PIP per individuare l'ultimo massimo/minimo locale (resistenza/supporto) e
misura la distanza % di close[i]. Se close e' entro `near`*ATR da R -> bias short
(mode='fade': rimbalzo da R) o long (mode='break': rottura). Simmetrico per S.
Tutto causale: PIP su close[..i], decisione a close[i].
"""
close = df["close"].values
n = len(close)
a = atr(df, 14)
el = ema(close, ema_long) if trend_max is not None else None
entries: list[dict] = []
for i in range(L, n - 1):
seg = close[i - L + 1: i + 1]
idx = pip_indices(seg, P)
if len(idx) < 3 or a[i] <= 0:
continue
y = seg[idx]
# pivot interni (escludi i 2 estremi e l'ultimo punto = close[i])
inner = y[1:-1]
if len(inner) == 0:
continue
res = inner.max() # resistenza locale
sup = inner.min() # supporto locale
cur = close[i]
dist_r = (res - cur) / a[i]
dist_s = (cur - sup) / a[i]
d = None
if 0 <= dist_r <= near: # appena sotto R
d = -1 if mode == "fade" else 1
elif 0 <= dist_s <= near: # appena sopra S
d = 1 if mode == "fade" else -1
if d is None:
continue
if trend_max is not None and abs(cur - el[i]) / a[i] > trend_max:
continue
e = {"i": i, "d": d, "max_bars": H}
if tp_atr is not None:
e["tp"] = cur + d * tp_atr * a[i]
if sl_atr is not None:
e["sl"] = cur - d * sl_atr * a[i]
entries.append(e)
return entries
# =========================================================================
# Check causalita' esplicito
# =========================================================================
def check_no_lookahead(df, L=48, P=5) -> bool:
"""La classe-forma a i non deve cambiare se perturbo il FUTURO (>i)."""
close = df["close"].values.copy()
i = len(close) // 2
seg0 = close[i - L + 1: i + 1].copy()
c0 = shape_class(seg0, P)
close2 = close.copy()
close2[i + 1:] *= 1.7 # stravolge il futuro
seg1 = close2[i - L + 1: i + 1]
c1 = shape_class(seg1, P)
ok = (c0 == c1)
print(f" no-lookahead classe-forma a i={i}: {'OK' if ok else 'VIOLATO'} "
f"(c0={c0} c1={c1})")
# check su PIP indices
p0 = pip_indices(seg0, P)
p1 = pip_indices(seg1, P)
ok2 = (p0 == p1)
print(f" no-lookahead indici PIP: {'OK' if ok2 else 'VIOLATO'}")
return ok and ok2
# =========================================================================
# run() riproducibile
# =========================================================================
def run():
print("=" * 100)
print(" SHAPE-PIVOT RESEARCH — geometria PIP/pivot -> bias futuro | netto fee, OOS")
print("=" * 100)
df_btc = get_df("BTC", "1h")
print("\n[CAUSALITA']")
check_no_lookahead(df_btc, L=48, P=5)
assets = ["BTC", "ETH", "SOL", "ADA"]
dfs = {a: get_df(a, "1h") for a in assets}
# ---- A) bias di classe PIP (momentum della forma) ----
print("\n[A] BIAS di classe PIP (entra nel verso del rendimento medio passato della classe)")
print(" sweep L/P/H, edge=0.002, min_samples=25, time-exit a H")
A_grid = [(48, 5, 12), (48, 5, 24), (72, 6, 24), (36, 5, 12), (96, 7, 24), (48, 7, 12)]
for L, P, H in A_grid:
print(f" -- L{L} P{P} H{H} --")
for a in assets:
ents = pivot_entries(dfs[a], L=L, P=P, H=H, edge=0.002, min_samples=25, mode="bias")
evaluate(f"{a} bias L{L}P{P}H{H}", ents, dfs[a])
# ---- B) fade di classe PIP (mean-reversion della forma) ----
print("\n[B] FADE di classe PIP (entra opposto al bias storico -> test mean-reversion)")
for L, P, H in A_grid:
print(f" -- L{L} P{P} H{H} --")
for a in assets:
ents = pivot_entries(dfs[a], L=L, P=P, H=H, edge=0.002, min_samples=25, mode="fade")
evaluate(f"{a} fade L{L}P{P}H{H}", ents, dfs[a])
# ---- C) supporto/resistenza locale dai pivot ----
print("\n[C] S/R locale dai PIP — FADE (rimbalzo da R/S) vs BREAK (rottura)")
for mode in ("fade", "break"):
for near in (0.5, 1.0):
print(f" -- mode={mode} near={near} ATR, TP/SL 1.5/1.5 ATR, H=12 --")
for a in assets:
ents = sr_entries(dfs[a], L=48, P=7, H=12, near=near, mode=mode,
tp_atr=1.5, sl_atr=1.5)
evaluate(f"{a} SR-{mode} near{near}", ents, dfs[a])
# ---- D) miglior candidato con TP/SL ATR + filtro trend (se A o B mostra segnali) ----
print("\n[D] FADE di classe con TP/SL ATR (2.0/1.5) + filtro trend 3.0, L48 P5 H24")
for a in assets:
ents = pivot_entries(dfs[a], L=48, P=5, H=24, edge=0.002, min_samples=25,
mode="fade", tp_atr=2.0, sl_atr=1.5, trend_max=3.0)
res = evaluate(f"{a} fadeTPSL L48P5H24", ents, dfs[a])
if robust(res):
print(f" ^^^ {a} ROBUSTO")
print("\n" + "=" * 100)
print(" Verdetto: cerca righe con FULL>0 E OOS>0 E fee0.2% OOS>0 su >=2 asset.")
print("=" * 100)
if __name__ == "__main__":
run()
+443
View File
@@ -0,0 +1,443 @@
"""SHAPE_TEMPLATE_RESEARCH — edge nella FORMA del prezzo: distanze alternative e template canonici.
Due filoni, sull'harness ONESTO condiviso (shape_lab + explore_lab), netto-fee e OOS:
1. ANALOG con distanza di FORMA alternativa (DTW warping-invariant, correlazione/coseno)
confrontata HEAD-TO-HEAD con l'euclidea a PARITA' di selettivita' (stessa libreria,
stesso K, stessa soglia di accordo). DTW e' O(W^2): si usa una libreria SOTTOCAMPIONATA
(uno start ogni `step` barre) + W ridotto + banda di Sakoe-Chiba.
2. TEMPLATE di forma canonici (doppio top/bottom, testa-spalle, V-reversal, salita/discesa
lineare, U). A ogni i misuro la similarita' (correlazione di Pearson sulla finestra
z-normalizzata) fra forma recente e ogni template; se supera soglia, entro a close[i]
nella DIREZIONE ATTESA del template stimata SOLO sul passato (esito medio causale delle
occorrenze gia' concluse di quel template), exit H barre o tp/sl ATR.
VINCOLI anti-look-ahead (verificati esplicitamente):
- la forma/match a i usa SOLO close fino a i (z-norm causale);
- la direzione attesa di ogni template e la libreria analog usano SOLO occorrenze il cui
esito a H barre e' gia' realizzato PRIMA di i (end + H <= i-1);
- ingresso eseguibile a close[i]; exit TP/SL intrabar o time-limit H.
Netto fee 0.10% RT baseline + sweep fino a 0.20%. Leva 3x, pos 0.15. OOS ultimo 30%.
Run riproducibile: uv run python scripts/analysis/shape_template_research.py
DTW e' costoso: usa run_in_background per gli sweep larghi (vedi --sweep).
"""
from __future__ import annotations
import sys
import time
from pathlib import Path
import numpy as np
from numpy.lib.stride_tricks import sliding_window_view
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.explore_lab import get_df, evaluate, robust, simulate, atr, ema, OOS_FRAC # noqa: E402
from scripts.analysis.shape_lab import znorm_windows, fwd_return # noqa: E402
RNG_SEED = 7
SUBC_ASSETS = ["BTC", "ETH", "SOL"]
# =========================================================================================
# DISTANZE DI FORMA
# =========================================================================================
def _euclid(q: np.ndarray, lib: np.ndarray) -> np.ndarray:
"""Distanza euclidea fra q (W,) e ogni riga di lib (M,W). Forme gia' z-normalizzate."""
return np.sqrt(((lib - q) ** 2).sum(axis=1))
def _corr_dist(q: np.ndarray, lib: np.ndarray) -> np.ndarray:
"""Distanza = 1 - correlazione di Pearson (q,lib gia' z-norm: corr = q.lib / W)."""
# forme z-norm hanno media 0 std 1 -> dot/W e' la correlazione di Pearson
corr = (lib @ q) / q.shape[0]
return 1.0 - corr
def _cosine_dist(q: np.ndarray, lib: np.ndarray) -> np.ndarray:
"""Distanza = 1 - coseno fra q e ogni riga di lib."""
qn = q / (np.linalg.norm(q) + 1e-12)
ln = lib / (np.linalg.norm(lib, axis=1, keepdims=True) + 1e-12)
return 1.0 - (ln @ qn)
def _dtw_one(a: np.ndarray, b: np.ndarray, band: int) -> float:
"""DTW 1D con banda di Sakoe-Chiba (|i-j|<=band). a,b stessa lunghezza W."""
n = len(a)
INF = 1e18
prev = np.full(n + 1, INF)
prev[0] = 0.0
for i in range(1, n + 1):
cur = np.full(n + 1, INF)
jlo = max(1, i - band)
jhi = min(n, i + band)
ai = a[i - 1]
for j in range(jlo, jhi + 1):
cost = abs(ai - b[j - 1])
m = prev[j]
if prev[j - 1] < m:
m = prev[j - 1]
if cur[j - 1] < m:
m = cur[j - 1]
cur[j] = cost + m
prev = cur
return float(prev[n])
def _dtw_dist(q: np.ndarray, lib: np.ndarray, band: int) -> np.ndarray:
"""DTW di q contro ogni riga di lib. O(M * W * band)."""
out = np.empty(lib.shape[0])
for k in range(lib.shape[0]):
out[k] = _dtw_one(q, lib[k], band)
return out
DIST_FUNCS = {"euclid": _euclid, "corr": _corr_dist, "cosine": _cosine_dist}
# =========================================================================================
# FILONE 1 — ANALOG con distanza configurabile (libreria sottocampionata, causale)
# =========================================================================================
def analog_dist_entries(df, dist="euclid", W=24, H=12, K=40, step=5, rebuild=500,
min_lib=2000, agree=0.62, dtw_band=4, dtw_prefilter=200,
decide_step=1, tp_atr=None, sl_atr=None,
trend_max=None, ema_long=200) -> list[dict]:
"""Analog kNN sulla FORMA con metrica `dist` ('euclid'|'corr'|'cosine'|'dtw').
Libreria SOTTOCAMPIONATA: si considerano solo finestre che terminano a indici
multipli di `step` (riduce N e rende DTW trattabile). Causalita': la libreria a
decisione i contiene solo finestre con end<=i-1-H (esito gia' realizzato).
Ricostruita ogni `rebuild` barre. Stessa firma per tutte le metriche -> confronto
head-to-head a parita' di selettivita' (stesso W,H,K,agree).
DTW (costoso, O(W*band) per coppia in Python): si PREFILTRA con la correlazione ai
`dtw_prefilter` candidati piu' simili, poi si fa DTW-rerank solo su quelli (approccio
standard lower-bound/rerank). `decide_step`>1 valuta una barra ogni decide_step (non
cambia la causalita', riduce solo il numero di query DTW).
"""
close = df["close"].values
n = len(close)
a = atr(df, 14)
M, ends = znorm_windows(close, W)
end_pos = {int(e): k for k, e in enumerate(ends)}
fr = fwd_return(close, H)
el = ema(close, ema_long) if trend_max is not None else None
# candidati di libreria: solo end multipli di step (sottocampionamento causale fisso)
base_ends = ends[(ends % step == 0)]
entries: list[dict] = []
lib_M = None
lib_idx = None
next_rebuild = 0
for i in range(min_lib, n - 1):
if i % decide_step != 0:
continue
if lib_M is None or i >= next_rebuild:
elig = base_ends[(base_ends <= i - 1 - H) & (base_ends >= W - 1)]
elig = elig[~np.isnan(fr[elig])]
if len(elig) < max(K * 3, 200):
next_rebuild = i + rebuild
continue
lib_M = M[[end_pos[int(e)] for e in elig]]
lib_idx = elig
next_rebuild = i + rebuild
if lib_M is None:
continue
q = M[end_pos[i]]
if not np.isfinite(q).all():
continue
if trend_max is not None and a[i] > 0 and abs(close[i] - el[i]) / a[i] > trend_max:
continue
if dist == "dtw":
# prefiltro corr (cheap, vettoriale) -> DTW-rerank solo sui top dtw_prefilter
pre = _corr_dist(q, lib_M)
npre = min(dtw_prefilter, len(lib_idx))
cand = np.argpartition(pre, npre - 1)[:npre]
dd_cand = _dtw_dist(q, lib_M[cand], dtw_band)
kk = min(K, len(cand))
sub = np.argpartition(dd_cand, kk - 1)[:kk]
nn = cand[sub]
outs = fr[lib_idx[nn]]
outs = outs[~np.isnan(outs)]
if len(outs) < 5:
continue
mean_out = float(outs.mean())
d = 1 if mean_out > 0 else -1
frac = float(np.mean(np.sign(outs) == d))
if frac < agree:
continue
e = {"i": i, "d": d, "max_bars": H}
if tp_atr is not None and a[i] > 0:
e["tp"] = close[i] + d * tp_atr * a[i]
if sl_atr is not None and a[i] > 0:
e["sl"] = close[i] - d * sl_atr * a[i]
entries.append(e)
continue
dd = DIST_FUNCS[dist](q, lib_M)
kk = min(K, len(lib_idx))
nn = np.argpartition(dd, kk - 1)[:kk]
outs = fr[lib_idx[nn]]
outs = outs[~np.isnan(outs)]
if len(outs) < 5:
continue
mean_out = float(outs.mean())
d = 1 if mean_out > 0 else -1
frac = float(np.mean(np.sign(outs) == d))
if frac < agree:
continue
e = {"i": i, "d": d, "max_bars": H}
if tp_atr is not None and a[i] > 0:
e["tp"] = close[i] + d * tp_atr * a[i]
if sl_atr is not None and a[i] > 0:
e["sl"] = close[i] - d * sl_atr * a[i]
entries.append(e)
return entries
# =========================================================================================
# FILONE 2 — TEMPLATE di forma canonici
# =========================================================================================
def make_templates(W: int) -> dict[str, np.ndarray]:
"""Template parametrici z-normalizzati di lunghezza W (forma pura, no scala/livello).
Sono solo descrittori di FORMA recente (gli ultimi W close). La direzione attesa NON
e' decisa a priori: viene stimata causalmente sul passato (vedi template_entries).
"""
t = np.linspace(0, 1, W)
s = 0.012 # ampiezza gaussiana scalata sulla finestra (W-indipendente in t in [0,1])
g = lambda c: np.exp(-((t - c) ** 2) / s)
raw = {
# estremi di reversione a DOPPIO picco (due massimi / minimi simmetrici)
"double_top": g(0.25) + g(0.75), # M: due cime
"double_bottom": -(g(0.25) + g(0.75)), # W: due fondi
# testa-spalle: spalla-testa-spalla (centro piu' alto)
"head_shoulders": g(0.2) + 1.7 * g(0.5) + g(0.8),
"inv_head_shoulders": -(g(0.2) + 1.7 * g(0.5) + g(0.8)),
# singola reversione
"v_bottom": np.abs(t - 0.5),
"inv_v_top": -np.abs(t - 0.5),
"u_bottom": (t - 0.5) ** 2,
"arch_top": -((t - 0.5) ** 2),
# trend lineari
"ramp_up": t,
"ramp_down": -t,
}
out = {}
for k, v in raw.items():
v = np.asarray(v, dtype=float)
sd = v.std()
out[k] = (v - v.mean()) / (sd if sd > 0 else 1.0)
return out
def template_entries(df, W=24, H=12, corr_min=0.85, dir_min=0.10, min_lib=2000,
rebuild=300, tp_atr=None, sl_atr=None, trend_max=None, ema_long=200,
templates=None) -> list[dict]:
"""Entries da match con template canonici, DIREZIONE stimata SOLO sul passato.
A ogni i, per ogni template calcolo la correlazione di Pearson fra la forma recente
z-norm (close[i-W+1..i]) e il template. Prendo il template a correlazione massima; se
>= corr_min lo considero "attivo". La DIREZIONE in cui entrare e' il segno del rendimento
forward MEDIO storico delle occorrenze gia' concluse (end+H<=i-1) di quel template
(stesso criterio di match), purche' |media| in barre-equivalenti superi dir_min*media_atr-ish
-> qui dir_min e' una soglia sulla |media forward| relativa (frazione). NIENTE direzione a
priori: se il passato non e' coerente (occorrenze<min o segno debole) si salta.
"""
close = df["close"].values
n = len(close)
a = atr(df, 14)
M, ends = znorm_windows(close, W)
end_pos = {int(e): k for k, e in enumerate(ends)}
fr = fwd_return(close, H)
el = ema(close, ema_long) if trend_max is not None else None
tps = templates if templates is not None else make_templates(W)
names = list(tps.keys())
T = np.stack([tps[k] for k in names]) # (NT, W), gia' z-norm
# match-history: per ogni end di libreria, quale template e con che corr
# (precalcolo causale: per ogni end, corr con ogni template)
# corr Pearson fra forme z-norm = dot/W
lib_ends = ends[ends >= W - 1]
lib_M = M[[end_pos[int(e)] for e in lib_ends]] # (L, W)
corr_mat = (lib_M @ T.T) / W # (L, NT)
best_tpl = np.argmax(corr_mat, axis=1)
best_corr = corr_mat[np.arange(len(lib_ends)), best_tpl]
lib_fr = fr[lib_ends]
lib_end_arr = lib_ends
entries: list[dict] = []
# cache direzione per template, ricostruita ogni rebuild barre
dir_cache: dict[int, int] = {}
next_rebuild = 0
for i in range(min_lib, n - 1):
q = M[end_pos[i]]
if not np.isfinite(q).all():
continue
cq = (T @ q) / W # corr con ogni template
bt = int(np.argmax(cq))
if cq[bt] < corr_min:
continue
if trend_max is not None and a[i] > 0 and abs(close[i] - el[i]) / a[i] > trend_max:
continue
# direzione attesa: media forward causale delle occorrenze concluse dello stesso template
if i >= next_rebuild:
dir_cache = {}
next_rebuild = i + rebuild
if bt not in dir_cache:
mask = (lib_end_arr <= i - 1 - H) & (best_tpl == bt) & (best_corr >= corr_min) & (~np.isnan(lib_fr))
outs = lib_fr[mask]
if len(outs) < 30:
dir_cache[bt] = 0
else:
m = float(outs.mean())
# soglia: |media| forward deve superare dir_min volte la std forward (edge vs rumore)
sd = float(outs.std()) + 1e-12
dir_cache[bt] = (1 if m > 0 else -1) if abs(m) / sd >= dir_min else 0
d = dir_cache[bt]
if d == 0:
continue
e = {"i": i, "d": d, "max_bars": H}
if tp_atr is not None and a[i] > 0:
e["tp"] = close[i] + d * tp_atr * a[i]
if sl_atr is not None and a[i] > 0:
e["sl"] = close[i] - d * sl_atr * a[i]
entries.append(e)
return entries
# =========================================================================================
# CHECK CAUSALITA' espliciti
# =========================================================================================
def check_causality_analog(df, **kw) -> bool:
"""Le entries non devono cambiare se perturbo il FUTURO oltre l'ultima barra usata.
Tronco il df a una certa lunghezza L e verifico che le entries con i<L-H-1 siano
identiche a quelle calcolate sul df completo (la coda futura non le tocca)."""
L = int(len(df) * 0.55)
H = kw.get("H", 12)
full = analog_dist_entries(df, **kw)
trunc = analog_dist_entries(df.iloc[:L].reset_index(drop=True), **kw)
horizon = L - H - 2
f = {e["i"]: e["d"] for e in full if e["i"] < horizon}
t = {e["i"]: e["d"] for e in trunc if e["i"] < horizon}
ok = (f == t)
print(f" causalita' analog ({kw.get('dist','euclid')}): {'OK' if ok else 'VIOLATO'} "
f"({len(f)} entries confrontate <{horizon})")
return ok
def check_causality_template(df, **kw) -> bool:
L = int(len(df) * 0.55)
H = kw.get("H", 12)
full = template_entries(df, **kw)
trunc = template_entries(df.iloc[:L].reset_index(drop=True), **kw)
horizon = L - H - 2
f = {e["i"]: e["d"] for e in full if e["i"] < horizon}
t = {e["i"]: e["d"] for e in trunc if e["i"] < horizon}
ok = (f == t)
print(f" causalita' template: {'OK' if ok else 'VIOLATO'} "
f"({len(f)} entries confrontate <{horizon})")
return ok
# =========================================================================================
# RUN
# =========================================================================================
def run_head_to_head(assets=SUBC_ASSETS, W=16, H=12, K=40, step=6, agree=0.62,
decide_step=4, dtw_prefilter=120):
"""Confronto HEAD-TO-HEAD delle metriche di forma a PARITA' di selettivita'.
Tutte le metriche valutano le STESSE barre-decisione (decide_step) con lo STESSO
W/H/K/agree: l'unica variabile e' la distanza. decide_step>1 serve a rendere DTW
trattabile (pura Python ~9ms/query); applicato a tutte per equita'.
"""
print("=" * 100)
print(f" FILONE 1 — ANALOG head-to-head metriche (W{W} H{H} K{K} step{step} "
f"agree{agree} decide_step{decide_step}) | netto fee, OOS")
print("=" * 100)
results = {}
for asset in assets:
df = get_df(asset, "1h")
print(f"\n --- {asset} 1h (n={len(df)}) ---", flush=True)
for dist in ["euclid", "corr", "cosine", "dtw"]:
t0 = time.time()
ents = analog_dist_entries(df, dist=dist, W=W, H=H, K=K, step=step, agree=agree,
dtw_band=max(2, W // 5), dtw_prefilter=dtw_prefilter,
decide_step=decide_step)
dt = time.time() - t0
res = evaluate(f"{dist:<7s}", ents, df)
results[(asset, dist)] = res
print(f" ^ time={dt:>5.1f}s robust={'YES' if robust(res) else 'no '}", flush=True)
return results
def run_templates(assets=SUBC_ASSETS, W=20, H=12, corr_min=0.85, dir_min=0.10):
print("=" * 100)
print(f" FILONE 2 — TEMPLATE canonici (W{W} H{H} corr>={corr_min} dir>={dir_min}) | netto fee, OOS")
print("=" * 100)
results = {}
for asset in assets:
df = get_df(asset, "1h")
print(f"\n --- {asset} 1h (n={len(df)}) ---")
for cm in [0.80, 0.85, 0.90]:
ents = template_entries(df, W=W, H=H, corr_min=cm, dir_min=dir_min)
res = evaluate(f"corr_min={cm}", ents, df)
results[(asset, cm)] = res
print(f" ^ robust={'YES' if robust(res) else 'no '}")
return results
def run_sweep():
"""Sweep largo (lento per via di DTW). Usa run_in_background."""
print("=" * 100)
print(" SWEEP LARGO — analog griglia W/H/K/step x metriche + template griglia")
print("=" * 100)
for W in [16, 20, 28]:
for H in [8, 12, 24]:
print(f"\n##### W={W} H={H} #####")
run_head_to_head(W=W, H=H, K=40, step=6, agree=0.62)
for W in [16, 20, 28]:
for H in [8, 12, 24]:
print(f"\n##### TEMPLATE W={W} H={H} #####")
run_templates(W=W, H=H, corr_min=0.85, dir_min=0.10)
def run():
np.random.seed(RNG_SEED)
print("#" * 100)
print(" SHAPE_TEMPLATE_RESEARCH — distanze di forma alternative + template canonici")
print("#" * 100)
# 1) check causalita' espliciti
print("\n[CAUSALITA']")
dfb = get_df("BTC", "1h")
check_causality_analog(dfb, dist="euclid", W=20, H=12, K=40, step=6, min_lib=2000)
check_causality_analog(dfb, dist="dtw", W=16, H=12, K=40, step=8, min_lib=2000,
dtw_band=3, decide_step=20)
check_causality_template(dfb, W=20, H=12, corr_min=0.85)
# 2) head-to-head metriche
print()
run_head_to_head()
# 3) template
print()
run_templates()
if __name__ == "__main__":
if "--sweep" in sys.argv:
run_sweep()
elif "--templates" in sys.argv:
run_templates()
elif "--h2h" in sys.argv:
run_head_to_head()
else:
run()
+173
View File
@@ -0,0 +1,173 @@
"""Analisi di ACCORPAMENTO degli sleeve: le strategie possono essere raggruppate
meglio o diversamente rispetto all'attuale "per famiglia"?
Costruisce le 17 sleeve daily (FADE 6 + HONEST 3 + PAIRS 5 + TSM01 + SHAPE 2),
e risponde con evidenza a:
1. CORRELAZIONE: matrice completa -> quali sleeve sono ridondanti (corr alta)?
2. CLUSTER: clustering gerarchico sulla distanza 1-corr -> i gruppi NATURALI
coincidono con le famiglie o no?
3. RISCHIO: contributo di ogni sleeve alla volatilita' del portafoglio equal-weight
-> chi domina il rischio (e va cappato)?
4. PESI: confronto equal-weight vs inverse-vol vs risk-parity (per cluster) su
ritorno/DD/Sharpe FULL e OOS.
Tutto netto fee, leva 3x, finestra comune 2021-2026, OOS = ultimo 30%.
Run: uv run python scripts/analysis/sleeve_clustering.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scipy.cluster.hierarchy import linkage, fcluster
from scipy.spatial.distance import squareform
from scripts.analysis.report_families import build_everything
from scripts.analysis.combine_portfolio import port_returns, metrics, SPLIT
def daily_matrix(sleeves: dict) -> pd.DataFrame:
return pd.DataFrame({k: v.pct_change().fillna(0.0) for k, v in sleeves.items()})
def risk_contributions(dr: pd.DataFrame, w: np.ndarray) -> np.ndarray:
"""Contributo % di ogni sleeve alla varianza del portafoglio (w'Σ)."""
cov = dr.cov().values
port_var = float(w @ cov @ w)
mrc = cov @ w # marginal risk contribution
rc = w * mrc # risk contribution (somma = port_var)
return rc / port_var * 100 if port_var > 0 else rc
def inv_vol(dr: pd.DataFrame) -> np.ndarray:
v = dr.std().values
inv = np.where(v > 0, 1.0 / v, 0.0)
return inv / inv.sum()
def cluster_risk_parity(dr: pd.DataFrame, labels: np.ndarray) -> dict:
"""Peso: equal fra i CLUSTER, poi inverse-vol DENTRO ogni cluster.
Diversifica per gruppo-naturale invece che per sleeve -> non sovrappesa cluster affollati."""
cols = list(dr.columns)
w = np.zeros(len(cols))
clusters = sorted(set(labels))
per_cluster = 1.0 / len(clusters)
for cl in clusters:
idx = [i for i, lb in enumerate(labels) if lb == cl]
sub = dr.iloc[:, idx]
iv = inv_vol(sub)
for j, i in enumerate(idx):
w[i] = per_cluster * iv[j]
return {cols[i]: w[i] for i in range(len(cols))}
def main():
print("Costruzione 17 sleeve (~2-3 min)...\n")
S, pairs, tsm, shape = build_everything()
all_sl = {**S, **pairs, **tsm, **shape}
dr = daily_matrix(all_sl)
cols = list(dr.columns)
n = len(cols)
fam_of = {}
for k in cols:
if k.startswith("MR"):
fam_of[k] = "FADE"
elif k.startswith("PR_"):
fam_of[k] = "PAIRS"
elif k.startswith("SH_"):
fam_of[k] = "SHAPE"
elif k == "TSM01":
fam_of[k] = "TSM"
else:
fam_of[k] = "HONEST"
# ---------- 1. correlazione ----------
print("=" * 100)
print(" (1) MATRICE DI CORRELAZIONE daily fra sleeve")
print("=" * 100)
corr = dr.corr()
short = [c.replace("_", "")[:8] for c in cols]
print(" " + "".join(f"{s[:6]:>7s}" for s in short))
for i, c in enumerate(cols):
print(f" {short[i]:<6s}" + "".join(f"{corr.iloc[i, j]:>7.2f}" for j in range(n)))
# coppie piu' correlate (candidati all'accorpamento)
print("\n Coppie piu' correlate (>0.5 -> ridondanza potenziale):")
pairs_corr = []
for i in range(n):
for j in range(i + 1, n):
pairs_corr.append((corr.iloc[i, j], cols[i], cols[j]))
pairs_corr.sort(reverse=True)
for cc, a, b in pairs_corr[:12]:
flag = " <-- stessa famiglia" if fam_of[a] == fam_of[b] else " <-- CROSS-famiglia"
print(f" {a:<11s} {b:<11s} {cc:+.2f}{flag if cc > 0.5 else ''}")
# ---------- 2. cluster ----------
print("\n" + "=" * 100)
print(" (2) CLUSTERING GERARCHICO (distanza = 1-corr) — i gruppi naturali")
print("=" * 100)
dist = 1.0 - corr.values
np.fill_diagonal(dist, 0.0)
dist = (dist + dist.T) / 2
Z = linkage(squareform(dist, checks=False), method="average")
for thr in (0.85, 0.95):
labels = fcluster(Z, t=thr, criterion="distance")
groups: dict[int, list] = {}
for c, lb in zip(cols, labels):
groups.setdefault(lb, []).append(c)
print(f"\n taglio a distanza {thr} (corr>{1-thr:.2f}) -> {len(groups)} cluster:")
for lb, members in sorted(groups.items()):
fams = {fam_of[m] for m in members}
print(f" C{lb}: {', '.join(members)} [{'/'.join(sorted(fams))}]")
# ---------- 3. rischio ----------
print("\n" + "=" * 100)
print(" (3) CONTRIBUTO AL RISCHIO (equal-weight) — chi domina la volatilita'")
print("=" * 100)
w_eq = np.ones(n) / n
rc = risk_contributions(dr, w_eq)
order = np.argsort(rc)[::-1]
print(f" {'sleeve':<12s}{'peso%':>7s}{'risk%':>7s} famiglia")
for i in order:
print(f" {cols[i]:<12s}{w_eq[i]*100:>7.1f}{rc[i]:>7.1f} {fam_of[cols[i]]}")
# rischio per famiglia
print("\n contributo al rischio per FAMIGLIA (equal-weight sleeve):")
fam_rc: dict[str, float] = {}
for i, c in enumerate(cols):
fam_rc[fam_of[c]] = fam_rc.get(fam_of[c], 0.0) + rc[i]
for f, v in sorted(fam_rc.items(), key=lambda x: -x[1]):
print(f" {f:<8s} {v:>5.1f}%")
# ---------- 4. schemi di peso ----------
print("\n" + "=" * 100)
print(" (4) SCHEMI DI PESO a confronto | FULL ret/DD/Sharpe | OOS ret/DD/Sharpe")
print("=" * 100)
labels95 = fcluster(Z, t=0.95, criterion="distance")
schemes = {
"equal-weight": {c: 1.0 / n for c in cols},
"inverse-vol": {cols[i]: inv_vol(dr)[i] for i in range(n)},
"cluster-risk-parity": cluster_risk_parity(dr, labels95),
}
print(f" {'schema':<22s}{'Ret%':>9s}{'DD%':>7s}{'Shrp':>7s} | {'oRet%':>9s}{'oDD%':>7s}{'oShrp':>7s}")
print(" " + "-" * 78)
for nm, w in schemes.items():
dserved = port_returns(all_sl, w)
f, o = metrics(dserved), metrics(dserved, lo=SPLIT)
print(f" {nm:<22s}{f['ret']:>+9.0f}{f['dd']:>7.1f}{f['sharpe']:>7.2f} | "
f"{o['ret']:>+9.0f}{o['dd']:>7.1f}{o['sharpe']:>7.2f}")
print("\n Lettura: se i cluster naturali != famiglie, conviene pesare per CLUSTER (rischio)")
print(" invece che per famiglia. Se inverse-vol/risk-parity battono equal-weight in OOS,")
print(" l'accorpamento attuale (equal-weight per sleeve) e' migliorabile.")
if __name__ == "__main__":
main()
+39
View File
@@ -0,0 +1,39 @@
"""Smoke reale: un giro di fetch v2 + build worker + un tick del portafoglio attivo.
NON apre ordini reali (paper). Verifica data layer v2 + sizing + ledger."""
import sys, shutil, tempfile
from pathlib import Path
from datetime import datetime, timezone, timedelta
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from src.portfolio.base import load_active_portfolio
from src.portfolio.ledger import PortfolioLedger
from src.portfolio.runner import build_worker_for, _worker_equity
from src.live.cerbero_client import CerberoClient
from src.live.multi_runner import INSTRUMENT_MAP
def main():
tmp = Path(tempfile.mkdtemp())
p = load_active_portfolio(PROJECT_ROOT / "portfolios.yml")
ledger = PortfolioLedger(p.code, total_capital=p.total_capital, data_dir=tmp)
alloc = ledger.allocate({s.sid: 1.0 / len(p.sleeves) for s in p.sleeves})
client = CerberoClient()
print(f"Portafoglio attivo: {p.code} ({p.label}) — {len(p.sleeves)} sleeve, leva {p.leverage}x")
end = datetime.now(timezone.utc); start = end - timedelta(days=60)
ok = 0
for s in p.sleeves[:3]:
asset = s.asset or s.a
inst = INSTRUMENT_MAP.get(asset, f"{asset}-PERPETUAL")
candles = client.get_historical_v2(inst, start.strftime("%Y-%m-%d"),
end.strftime("%Y-%m-%d"), s.tf)
print(f" {s.sid:<12s} {inst:<18s} candele={len(candles)}")
ok += len(candles) > 0
print(f"OK: {ok}/3 sleeve con feed v2 fresco. Ledger equity iniziale={ledger.equity}")
shutil.rmtree(tmp, ignore_errors=True)
if __name__ == "__main__":
main()
+28
View File
@@ -0,0 +1,28 @@
"""PORT01 — Honest (default). Report backtest del portafoglio."""
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.portfolios._defs import PORTFOLIOS # noqa: E402
CODE = "PORT01"
def run():
p = PORTFOLIOS[CODE]
r = p.backtest()
print("=" * 80)
print(f" {p.code}{p.label} | pesi={p.weighting} caps={p.caps} leva={p.leverage}x")
print("=" * 80)
print(f" FULL ret {r.full['ret']:+.0f}% CAGR {r.full['cagr']:.0f}% "
f"DD {r.full['dd']:.1f}% Sharpe {r.full['sharpe']:.2f}")
print(f" OOS ret {r.oos['ret']:+.0f}% DD {r.oos['dd']:.1f}% Sharpe {r.oos['sharpe']:.2f}")
print(" per anno:", {y: round(v) for y, v in sorted(r.yearly.items())})
print(" rischio % per sleeve:", {k: round(v, 1) for k, v in
sorted(r.risk.items(), key=lambda x: -x[1])})
if __name__ == "__main__":
run()
+28
View File
@@ -0,0 +1,28 @@
"""PORT02 — Fade master (default). Report backtest del portafoglio."""
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.portfolios._defs import PORTFOLIOS # noqa: E402
CODE = "PORT02"
def run():
p = PORTFOLIOS[CODE]
r = p.backtest()
print("=" * 80)
print(f" {p.code}{p.label} | pesi={p.weighting} caps={p.caps} leva={p.leverage}x")
print("=" * 80)
print(f" FULL ret {r.full['ret']:+.0f}% CAGR {r.full['cagr']:.0f}% "
f"DD {r.full['dd']:.1f}% Sharpe {r.full['sharpe']:.2f}")
print(f" OOS ret {r.oos['ret']:+.0f}% DD {r.oos['dd']:.1f}% Sharpe {r.oos['sharpe']:.2f}")
print(" per anno:", {y: round(v) for y, v in sorted(r.yearly.items())})
print(" rischio % per sleeve:", {k: round(v, 1) for k, v in
sorted(r.risk.items(), key=lambda x: -x[1])})
if __name__ == "__main__":
run()
+28
View File
@@ -0,0 +1,28 @@
"""PORT03 — Master (default). Report backtest del portafoglio."""
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.portfolios._defs import PORTFOLIOS # noqa: E402
CODE = "PORT03"
def run():
p = PORTFOLIOS[CODE]
r = p.backtest()
print("=" * 80)
print(f" {p.code}{p.label} | pesi={p.weighting} caps={p.caps} leva={p.leverage}x")
print("=" * 80)
print(f" FULL ret {r.full['ret']:+.0f}% CAGR {r.full['cagr']:.0f}% "
f"DD {r.full['dd']:.1f}% Sharpe {r.full['sharpe']:.2f}")
print(f" OOS ret {r.oos['ret']:+.0f}% DD {r.oos['dd']:.1f}% Sharpe {r.oos['sharpe']:.2f}")
print(" per anno:", {y: round(v) for y, v in sorted(r.yearly.items())})
print(" rischio % per sleeve:", {k: round(v, 1) for k, v in
sorted(r.risk.items(), key=lambda x: -x[1])})
if __name__ == "__main__":
run()
+28
View File
@@ -0,0 +1,28 @@
"""PORT04 — Master + pairs (default). Report backtest del portafoglio."""
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.portfolios._defs import PORTFOLIOS # noqa: E402
CODE = "PORT04"
def run():
p = PORTFOLIOS[CODE]
r = p.backtest()
print("=" * 80)
print(f" {p.code}{p.label} | pesi={p.weighting} caps={p.caps} leva={p.leverage}x")
print("=" * 80)
print(f" FULL ret {r.full['ret']:+.0f}% CAGR {r.full['cagr']:.0f}% "
f"DD {r.full['dd']:.1f}% Sharpe {r.full['sharpe']:.2f}")
print(f" OOS ret {r.oos['ret']:+.0f}% DD {r.oos['dd']:.1f}% Sharpe {r.oos['sharpe']:.2f}")
print(" per anno:", {y: round(v) for y, v in sorted(r.yearly.items())})
print(" rischio % per sleeve:", {k: round(v, 1) for k, v in
sorted(r.risk.items(), key=lambda x: -x[1])})
if __name__ == "__main__":
run()
@@ -0,0 +1,28 @@
"""PORT05 — Master esteso (default). Report backtest del portafoglio."""
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.portfolios._defs import PORTFOLIOS # noqa: E402
CODE = "PORT05"
def run():
p = PORTFOLIOS[CODE]
r = p.backtest()
print("=" * 80)
print(f" {p.code}{p.label} | pesi={p.weighting} caps={p.caps} leva={p.leverage}x")
print("=" * 80)
print(f" FULL ret {r.full['ret']:+.0f}% CAGR {r.full['cagr']:.0f}% "
f"DD {r.full['dd']:.1f}% Sharpe {r.full['sharpe']:.2f}")
print(f" OOS ret {r.oos['ret']:+.0f}% DD {r.oos['dd']:.1f}% Sharpe {r.oos['sharpe']:.2f}")
print(" per anno:", {y: round(v) for y, v in sorted(r.yearly.items())})
print(" rischio % per sleeve:", {k: round(v, 1) for k, v in
sorted(r.risk.items(), key=lambda x: -x[1])})
if __name__ == "__main__":
run()
+28
View File
@@ -0,0 +1,28 @@
"""PORT06 — Master + shape (default). Report backtest del portafoglio."""
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.portfolios._defs import PORTFOLIOS # noqa: E402
CODE = "PORT06"
def run():
p = PORTFOLIOS[CODE]
r = p.backtest()
print("=" * 80)
print(f" {p.code}{p.label} | pesi={p.weighting} caps={p.caps} leva={p.leverage}x")
print("=" * 80)
print(f" FULL ret {r.full['ret']:+.0f}% CAGR {r.full['cagr']:.0f}% "
f"DD {r.full['dd']:.1f}% Sharpe {r.full['sharpe']:.2f}")
print(f" OOS ret {r.oos['ret']:+.0f}% DD {r.oos['dd']:.1f}% Sharpe {r.oos['sharpe']:.2f}")
print(" per anno:", {y: round(v) for y, v in sorted(r.yearly.items())})
print(" rischio % per sleeve:", {k: round(v, 1) for k, v in
sorted(r.risk.items(), key=lambda x: -x[1])})
if __name__ == "__main__":
run()
View File
+40
View File
@@ -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),
}
+47
View File
@@ -0,0 +1,47 @@
"""Confronto di tutti i portafogli PORT01-06 (backtest in un solo processo).
all_sleeve_equities() è cache-ata: la build (fade+honest+pairs+tsm+shape) avviene una
volta sola, poi i 6 backtest la riusano. Stampa una tabella FULL/OOS e i pesi/rischio.
Run: uv run python scripts/portfolios/compare_all.py
"""
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.portfolios._defs import PORTFOLIOS # noqa: E402
def run():
print("=" * 104)
print(" CONFRONTO PORTAFOGLI PORT01-06 | netto fee, finestra comune 2021-2026, OOS = ultimo 30%")
print("=" * 104)
print(f" {'code':<7s}{'label':<16s}{'n':>3s}{'pesi':>9s}"
f"{'FULLret':>9s}{'CAGR':>6s}{'DD':>6s}{'Shrp':>6s} |"
f"{'OOSret':>8s}{'oDD':>6s}{'oShrp':>7s}")
print(" " + "-" * 100)
rows = []
for code in ("PORT01", "PORT02", "PORT03", "PORT04", "PORT05", "PORT06"):
p = PORTFOLIOS[code]
r = p.backtest()
cap = f"{p.weighting}" + (f"{int(p.caps['PAIRS']*100)}" if p.caps else "")
print(f" {p.code:<7s}{p.label:<16s}{len(p.sleeves):>3d}{cap:>9s}"
f"{r.full['ret']:>+9.0f}{r.full['cagr']:>6.0f}{r.full['dd']:>6.1f}{r.full['sharpe']:>6.2f} |"
f"{r.oos['ret']:>+8.0f}{r.oos['dd']:>6.1f}{r.oos['sharpe']:>7.2f}")
rows.append((code, r))
# miglior per Sharpe OOS e per DD OOS
best_sharpe = max(rows, key=lambda x: x[1].oos["sharpe"])
best_dd = min(rows, key=lambda x: x[1].oos["dd"])
print(" " + "-" * 100)
print(f" miglior Sharpe OOS: {best_sharpe[0]} ({best_sharpe[1].oos['sharpe']:.2f}) "
f"miglior DD OOS: {best_dd[0]} ({best_dd[1].oos['dd']:.1f}%)")
print("\n PORT06 (default) — top contributi al rischio:")
r6 = dict(rows)["PORT06"]
top = sorted(r6.risk.items(), key=lambda x: -x[1])[:6]
print(" " + " ".join(f"{k}={v:.0f}%" for k, v in top))
if __name__ == "__main__":
run()
+102
View File
@@ -0,0 +1,102 @@
"""SH01 — Shape-ML: direzione predetta dalla FORMA del segnale (ML walk-forward).
FAMIGLIA NUOVA, distinta da tutto l'esistente. Non e' una regola fissa su bande/canali
(fade) ne' momentum/rotazione (honest) ne' spread (pairs): una LogisticRegression legge
la MORFOLOGIA della finestra recente (body/shadow delle candele, rendimenti, pendenza,
curvatura, posizione di max/min, RSI, estensione) e predice il segno del rendimento a H
barre. Entra a close[i] solo se la probabilita' supera una soglia (selettivita').
E' l'UNICO edge sopravvissuto alla ricerca sui pattern-di-forma (2026-05-29): le altre
4 famiglie testate con agenti paralleli su harness onesto sono RUMORE (analog kNN forma
grezza, encoding candele UP/DOWN/DOJI, DTW+template geometrici, PIP/pivot) confermano
la dominanza mean-reversion. Vedi scripts/analysis/shape_*_research.py e docs/diary.
Logica (engine onesto verificato in scripts/analysis/shape_ml_research.py):
feature di forma X[i] da o/h/l/c[i-W+1..i] (causali: solo dati fino a close[i])
walk-forward a blocchi: scaler+modello fittati SOLO sul passato con esito noto
(finestre e con e+H <= inizio_blocco-1), poi predicono il blocco corrente
proba(classe) >= thresh -> entra a close[i] nella direzione predetta, exit a H barre
fee 0.10% RT (single-leg). NESSUN look-ahead (check espliciti: perturbare il futuro
non cambia ne' le feature a i ne' le predizioni fino a i).
VALIDAZIONE DURA (netto fee, leva 3x, pos 0.15, OOS = ultimo 30%, config W24 H12 th0.58,
scripts/analysis/shape_ml_validate.py):
- MULTI-ASSET expanding: robusti BTC, ETH, ADA; scartati LTC/SOL/XRP.
BTC : FULL +219% / OOS +42% / Sharpe 2.72 / DD 23% / 8-9 anni+ / accOOS 56% (regge fee 0.2%: +60/+26)
ETH : FULL +80% / OOS +144% / Sharpe 1.21 / DD 61% / 6/9 anni+ / accOOS 55% (piu' volatile -> secondario)
ADA : FULL +707% / OOS +57% / Sharpe 3.22 / DD 39% / 7/8 anni+ (robusto solo expanding)
- WALK-FORWARD ROLLING (train fisso 2 anni): regge solo BTC (FULL +166% / OOS +96% / Sharpe 2.05).
-> l'edge si appoggia in parte alla memoria lunga: BTC e' il piu' solido.
- STRESS leva 2x + slippage doppio (fee 0.20% RT): BTC OK (FULL +40% / OOS +17% / Sharpe 1.24),
ETH marginale (FULL +7% / OOS +73% / Sharpe 0.37).
- GRIGLIA (W,H,thresh) su BTC: 5/27 celle robuste a fee 0.2%, su una CRESTA stretta
(W24, H8-12), non altopiano largo -> rischio overfit moderato. Per prudenza si sceglie
la config robusta sul PIU' ALTO numero di test (W24 H12 th0.58), non il PnL massimo
(W24 H8 rende di piu' ma accOOS ~49% = piu' drift che segnale).
USO CONSIGLIATO: NON motore standalone (per-asset e' troppo stretto/fragile fuori da BTC),
ma DIVERSIFICATORE di portafoglio. Corr daily col MASTER +0.08 (quasi scorrelato).
Aggiungere lo sleeve shape (BTC+ETH) al MASTER migliora l'OOS: Sharpe 4.33->5.10,
DD 4.7%->4.2% (scripts/analysis/shape_ml_validate.py sez. 5).
LIVE: richiede un worker capace di RIALLENARE periodicamente il modello (come il legacy
signal_engine ML, non lo StrategyWorker a regola fissa). Da wirare prima del paper live.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from src.strategies.base import Strategy, Signal # noqa: E402
from scripts.analysis.explore_lab import get_df, evaluate, robust # noqa: E402
from scripts.analysis.shape_ml_research import ml_wf_entries # noqa: E402
# Config robusta scelta (cresta W24 H8-12; H12 th0.58 = la piu' robusta sui test).
CONFIG = dict(W=24, H=12, model="logit", thresh=0.58)
# Asset con edge robusto. BTC primario (regge ogni stress); ETH secondario (diversificatore
# piu' volatile). ADA robusto solo expanding -> tenuto fuori dal set live conservativo.
ASSETS = ["BTC", "ETH"]
class ShapeMLStrategy(Strategy):
name = "SH01_shape_ml"
description = "Direzione predetta dalla forma del segnale (LogisticRegression walk-forward), exit a H barre"
default_assets = ASSETS
default_timeframes = ["1h"]
fee_rt = 0.001
leverage = 3.0
position_size = 0.15
def generate_signals(self, df: pd.DataFrame, ts: pd.DatetimeIndex, **params) -> list[Signal]:
cfg = {**CONFIG, **{k: params[k] for k in ("W", "H", "model", "thresh") if k in params}}
ents = ml_wf_entries(df, W=cfg["W"], H=cfg["H"], model=cfg["model"], thresh=cfg["thresh"])
c = df["close"].values
out: list[Signal] = []
for e in ents:
out.append(Signal(idx=e["i"], direction=e["d"], entry_price=float(c[e["i"]]),
metadata={"max_bars": e["max_bars"]}))
return out
def run():
print("=" * 96)
print(" SH01 — Shape-ML | direzione dalla FORMA (LogisticRegression walk-forward) | netto fee 0.10% RT, leva 3x")
print("=" * 96)
print(f" config: {CONFIG} (W=finestra forma, H=orizzonte/exit, thresh=soglia proba)")
for a in ASSETS:
df = get_df(a, "1h")
ents = ml_wf_entries(df, **CONFIG)
res = evaluate(f"{a}", ents, df)
print(f" ^ {'ROBUSTO (FULL+OOS+, regge fee 0.2%, ~tutti anni+)' if robust(res) else 'edge presente ma con anni negativi (diversificatore)'}")
print("\n Uso: diversificatore di portafoglio (corr ~0.08 col MASTER), non motore standalone.")
print(" Live: serve worker con retraining periodico del modello (vedi docstring).")
if __name__ == "__main__":
run()
+22
View File
@@ -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:
+4
View File
@@ -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"),
}
View File
+94
View File
@@ -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
+73
View File
@@ -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")
+158
View File
@@ -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()
+26
View File
@@ -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})
+96
View File
@@ -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}")
View File
+24
View File
@@ -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)
+18
View File
@@ -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
+10
View File
@@ -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
+17
View File
@@ -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
+26
View File
@@ -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()
+20
View File
@@ -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
+25
View File
@@ -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
+21
View File
@@ -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
+53
View File
@@ -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